collective

Browse / github.com/storytold/gridcraft / patch

Clear Formats removes conditional formatting (#2536)

Clear Formats removes conditional formatting (#2536) Fixes #2536. **Problem:** Clear Formats (and Clear All) reset cell styles but never touched the sheet's conditional formats, so the rule's colours stayed on the cleared cells. **Change** (`crates/engine/src/cmd/edit.rs`, `crates/engine/src/cmd/data.rs`): - Clear Formats and Clear All cut the cleared cells out of every conditional format's ranges, as Excel does. The rest of each rule keeps it, and a rule left with no cells is removed. Clear Contents is unchanged. - The cut uses the whole selection, not the used-range part `clear()` iterates, so rules on empty cells (the usual colour-scale cas

Status
open
Score
0.00
Adopters
0 other operators
Author
kulveersingh (imported from pull request #2585, open)
Base
1a36d9db075604fe8e48a07c0275444efd276d75
Size
+150 −1 in 4 files
Review
pending
Shared
2026-10-28
Id
8287458193f5c0b1fca3eaf5

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd/data.rs crates/engine/src/cmd/edit.rs crates/engine/src/tests.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,7 +2,7 @@  **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (Clear Formats removes conditional formatting; AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)  GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in  | Date | What landed | |---|---|+| 2026-10-25 | Clear Formats and Clear All remove conditional formatting from the cleared cells, as in Excel, keeping the rest of each rule's range; rules on empty cells past the used range go too. When a cut removes a rule's first cell, its relative formulas follow the new anchor, so the remaining cells still test themselves (#2536) | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |@@ -237,6 +238,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Progress log: Clear Formats removes conditional formatting (#2536) | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs--- a/crates/engine/src/cmd/data.rs+++ b/crates/engine/src/cmd/data.rs@@ -1066,6 +1066,56 @@ pub(super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &     validations.retain(|d| !d.ranges.is_empty()); } +/// The most ranges a clear grows a conditional format to. Each cut can add three, so a selection of+/// many small parts would otherwise grow a rule (and every lookup of it) without bound; Go To+/// Special's largest selection (2,000 areas) stays well under it.+const MAX_CF_RANGES: usize = 16_384;++/// Cuts the cells of `cuts` (largest first) out of a rule's `ranges`. Rule formulas are relative to+/// the top-left of the first range: the piece that keeps that cell stays first, and when the cell+/// itself is cut this returns the anchor's move for the caller to rebase them. A cut that would grow+/// the rule past [`MAX_CF_RANGES`] (and past its own size) is refused.+fn cut_rule_ranges(cmd: &str, ranges: &mut Vec<RangeRef>, cuts: &[RangeRef]) -> Result<Option<(CellRef, CellRef)>> {+    let before = ranges.first().map(|r| r.start);+    let limit = MAX_CF_RANGES.max(ranges.len());+    for cut in cuts {+        if !ranges.iter().any(|r| r.intersects(cut)) {+            continue;+        }+        *ranges = ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();+        if ranges.len() > limit {+            return Err(bad(cmd, format!("the selection would split a conditional format into more than {limit} ranges")));+        }+    }+    if let Some(i) = before.and_then(|a| ranges.iter().position(|r| r.start == a))+        && let Some(head) = ranges.get_mut(..=i)+    {+        head.rotate_right(1);+    }+    Ok(before.zip(ranges.first().map(|r| r.start)).filter(|(a, b)| a != b))+}++/// Removes the cells of `cuts` from every conditional format, keeping the rest of each rule's ranges.+pub(super) fn remove_cond_format_cells(cmd: &str, cond_formats: &mut Vec<CondFormat>, cuts: &[RangeRef]) -> Result<()> {+    // Largest first, so a later small cut finds less to split.+    let mut cuts = cuts.to_vec();+    cuts.sort_by_key(|r| std::cmp::Reverse(r.count()));+    for cf in cond_formats.iter_mut() {+        if let Some((from, to)) = cut_rule_ranges(cmd, &mut cf.ranges, &cuts)? {+            match &mut cf.rule {+                CfRule::Expression { formula, .. } => *formula = move_rule_formula(formula, from, to),+                CfRule::CellIs { a, b, .. } => {+                    *a = move_rule_formula(a, from, to);+                    *b = b.as_deref().map(|f| move_rule_formula(f, from, to));+                }+                _ => {}+            }+        }+    }+    cond_formats.retain(|c| !c.ranges.is_empty());+    Ok(())+}+ fn validation(s: &mut Session, p: &Json) -> Result<Json> {     let ranges = target_ranges(s, p)?;     let sheet = target_sheet(s, p)?;diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs--- a/crates/engine/src/cmd/edit.rs+++ b/crates/engine/src/cmd/edit.rs@@ -1539,6 +1539,12 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {                 }             }         }+        // Conditional formats are formats too; they can cover empty cells past the used range, so+        // they are cut by the whole selection, keeping the rest of each rule's ranges (#2536).+        if matches!(what, "all" | "formats") {+            let cmd = if what == "all" { "edit.clearAll" } else { "edit.clearFormats" };+            super::data::remove_cond_format_cells(cmd, &mut cx.sheet_mut(sheet)?.cond_formats, &ranges)?;+        }         Ok(Json::Null)     }) }diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -4062,6 +4062,97 @@ fn vertical_alignment_commands_and_roundtrip() {     assert_eq!(reopen_style("A3").align.v, gridcraft_model::VAlign::Bottom); } +/// Clear Formats and Clear All remove conditional formatting from the cleared cells only: the rest+/// of each rule's ranges stays, a rule on empty cells past the used range goes, and Undo brings+/// the rules back (#2536).+#[test]+fn clear_formats_removes_conditional_formatting_from_the_cleared_cells() {+    let mut s = s();+    let ranges = |s: &Session| -> Vec<Vec<String>> {+        s.doc().unwrap().wb.active().unwrap().cond_formats.iter().map(|c| c.ranges.iter().map(|r| r.a1()).collect()).collect()+    };+    s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap();+    s.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": {"type": "iconSet", "set": "3Arrows"}})).unwrap();+    s.execute("home.conditionalFormat", json!({"range": "D10:D12", "rule": {"type": "iconSet", "set": "3Arrows"}})).unwrap();++    s.execute("edit.clearFormats", json!({"range": "A2:A3"})).unwrap();+    // The newer rule comes first (it has priority).+    assert_eq!(ranges(&s), [vec!["D10:D12"], vec!["A1", "A4:A5"]], "the cleared cells leave the rule; the rest keeps it");+    s.execute("edit.clearFormats", json!({"range": "D10:D12"})).unwrap();+    assert_eq!(ranges(&s), [vec!["A1", "A4:A5"]], "a rule on empty cells past the used range goes too");+    s.execute("edit.undo", json!({})).unwrap();+    assert_eq!(ranges(&s), [vec!["D10:D12"], vec!["A1", "A4:A5"]]);++    s.execute("edit.clearAll", json!({"range": "A1:A5"})).unwrap();+    assert_eq!(ranges(&s), [vec!["D10:D12"]], "Clear All clears formats, conditional ones included");+    s.execute("edit.clearContents", json!({"range": "D10:D12"})).unwrap();+    assert_eq!(ranges(&s), [vec!["D10:D12"]], "Clear Contents keeps them");+}++/// Rule formulas are relative to the top-left cell of the rule's first range: when a clear cuts that+/// cell out, the formulas follow the new anchor, so every remaining cell still tests itself (#2536).+#[test]+fn clearing_a_rules_first_cell_keeps_its_relative_formulas_on_the_same_cells() {+    let mut s = s();+    s.execute("range.setValues", json!({"range": "A1", "values": [[5, 5, 5], [1, 5, 1], [5, 1, 5], [1, 1, 1], [5, 5, 5]]})).unwrap();+    let red = json!({"fill": "#FF0000"});+    s.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": {"type": "expression", "formula": "=A1>2", "style": red}})).unwrap();+    s.execute("home.conditionalFormat", json!({"range": "B1:C3", "rule": {"type": "cellIs", "operator": "greater", "value": "=B1-2", "style": red}}))+        .unwrap();+    let lit = |s: &Session, a: &str| {+        let wb = &s.doc().unwrap().wb;+        crate::cf::CfCache::new().look(wb, 0, CellRef::parse(a).unwrap()).is_some_and(|l| l.style.is_some())+    };+    s.execute("edit.clearFormats", json!({"range": "A1"})).unwrap();+    assert_eq!([lit(&s, "A2"), lit(&s, "A3"), lit(&s, "A4"), lit(&s, "A5")], [false, true, false, true], "A2:A5 still test themselves");+    // A 2-D cut through the top-left corner: B1 goes, C1 and B2:C3 keep comparing with themselves.+    s.execute("edit.clearFormats", json!({"range": "B1"})).unwrap();+    assert!(!lit(&s, "B1"));+    assert_eq!([lit(&s, "C1"), lit(&s, "B2"), lit(&s, "C2"), lit(&s, "B3"), lit(&s, "C3")], [true; 5], "each cell compares x > x - 2 with itself");++    // Undo, redo and an XLSX round trip keep the split ranges and the rebased formulas.+    s.execute("edit.undo", json!({})).unwrap();+    s.execute("edit.redo", json!({})).unwrap();+    let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string();+    s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();+    assert_eq!([lit(&s, "A1"), lit(&s, "A3"), lit(&s, "A4"), lit(&s, "B1"), lit(&s, "C2")], [false, true, false, false, true]);+}++/// A clear over many small areas (as Go To Special selects) cuts them all; one that would split a+/// rule into more than the cap is refused without changing anything, and a large area in the same+/// selection goes first, so the rule just goes. A cut that keeps the anchor cell leaves formulas be.+#[test]+fn clear_formats_over_many_areas_is_bounded() {+    let mut s = s();+    let ranges = |s: &Session| -> Vec<Vec<String>> {+        s.doc().unwrap().wb.active().unwrap().cond_formats.iter().map(|c| c.ranges.iter().map(|r| r.a1()).collect()).collect()+    };+    s.execute(+        "home.conditionalFormat",+        json!({"range": "A1:J1000", "rule": {"type": "expression", "formula": "=A1>2", "style": {"fill": "#FF0000"}}}),+    )+    .unwrap();+    let blanks: Vec<String> = (1..=500).map(|k| format!("B{}", 2 * k)).collect();+    s.execute("edit.clearFormats", json!({"range": blanks.join(",")})).unwrap();+    let wb = &s.doc().unwrap().wb;+    let cf = &wb.active().unwrap().cond_formats[0];+    let formula = match &cf.rule {+        gridcraft_model::CfRule::Expression { formula, .. } => formula.clone(),+        _ => String::new(),+    };+    assert_eq!((cf.ranges[0].a1(), formula.as_str()), ("A1:J1".to_string(), "A1>2"), "the anchor cell survived, so the formula is unchanged");+    s.execute("edit.clearFormats", json!({"range": "A1:J1000"})).unwrap();+    assert!(ranges(&s).is_empty());++    s.execute("home.conditionalFormat", json!({"range": "A1:C12000", "rule": {"type": "iconSet", "set": "3Arrows"}})).unwrap();+    let many: Vec<String> = (1..=5600).map(|k| format!("B{}", 2 * k)).collect();+    let before = ranges(&s);+    assert!(s.execute("edit.clearFormats", json!({"range": many.join(",")})).is_err());+    assert_eq!(ranges(&s), before, "a refused clear changes nothing");+    s.execute("edit.clearFormats", json!({"range": format!("{},A1:C12000", many.join(","))})).unwrap();+    assert!(ranges(&s).is_empty(), "the large area goes first and takes the rule");+}+ /// Icon-set thresholds as read from a file (one per icon, the first the lower bound) decide the /// icon; a rule without thresholds uses Excel's defaults (percent 33 and 67) (#235). #[test]