collective

Browse / github.com/storytold/gridcraft / patch

Objects keep their placement when rows and columns change (#2343)

Objects keep their placement when rows and columns change (#2343) Pictures, shapes and charts now keep the place their placement gives them when rows and columns are resized, hidden, filtered, inserted or deleted (#2343). **What was wrong:** the three placements (editAs twoCell, oneCell, absolute) were already stored, read and written, and an insert or delete already honoured them. A row height, column width, hide or filter change did not: an object set to "Don't move or size with cells" slid with the cell it was anchored to, and a "Move and size" object did not stretch. **What changed:** after any undoable command that changes row heights, column widths o

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #2687, open)
Base
f6b0cec67e328f2f6352768b5cb2feb7e562c5db
Size
+359 −3 in 5 files
Review
pending
Shared
2026-10-28
Id
32b0d885d03cb3c88542c14a

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd/sheet.rs crates/engine/src/lib.rs crates/engine/tests/it/main.rs crates/engine/tests/it/object_placement.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-26 · **Change:** minor (pictures, shapes and charts keep their place, size or follow their cells as their placement says when rows and columns are resized, hidden, inserted or deleted) · **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-26 | Pictures, shapes and charts follow their cells as their placement says (#2343): "Don't move or size with cells" stays put, "Move but don't size" keeps its size, "Move and size" stretches, when rows or columns are resized, hidden, filtered, inserted or deleted; a deleted anchor line drops the offset into it. Numbers checked against Excel through COM | | 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`) |@@ -242,6 +243,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Progress log: object placement under row and column changes (#2343) | | 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/sheet.rs b/crates/engine/src/cmd/sheet.rs--- a/crates/engine/src/cmd/sheet.rs+++ b/crates/engine/src/cmd/sheet.rs@@ -1,10 +1,11 @@ //! Worksheets and structure: insert/delete rows, columns and cells; sheet tabs. +use std::collections::HashMap; use std::sync::Arc;  use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; use gridcraft_formula::adjust::{Axis, Edit};-use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};+use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility, Workbook}; use serde_json::{Value as Json, json};  use super::{@@ -95,8 +96,14 @@ fn shift_anchor(sh: &Sheet, a: Anchor, corner: (u32, u32, f32, f32), axis: Axis,         return a;     }     let main = if axis == Axis::Rows { a.cell.row } else { a.cell.col };-    // A deleted anchor line collapses onto the line that takes its place.+    // A deleted anchor line collapses onto the line that takes its place, and Excel drops the+    // offset into it: the object's edge then sits on that line.     let nmain = map_line(main).unwrap_or(at);+    let a = match (map_line(main), axis) {+        (None, Axis::Rows) => Anchor { dy: 0.0, ..a },+        (None, Axis::Cols) => Anchor { dx: 0.0, ..a },+        _ => a,+    };     let (nr, nc) = if axis == Axis::Rows { (nmain, a.cell.col) } else { (a.cell.row, nmain) };     if a.mode == AnchorMode::MoveOnly {         return Anchor { cell: CellRef::new(nr, nc), ..a };@@ -114,6 +121,94 @@ fn shift_anchor(sh: &Sheet, a: Anchor, corner: (u32, u32, f32, f32), axis: Axis,     Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (xn - x1).max(0.0) as f32, height: (yn - y1).max(0.0) as f32, mode: a.mode } } +/// Where an object's anchor lands after a command changed row heights, column widths or hidden+/// lines (`old` and `new` are the sheet before and after), as Excel places it: "move and size"+/// keeps its top-left corner on its cell and follows its bottom-right corner (hiding a line shrinks+/// it); "move but don't size" keeps its cell and size; "don't move or size" keeps its place on the+/// sheet, so its anchor cell is found again in the new grid. An anchor the command itself changed+/// (a move, a resize, an insert or delete that already re-fitted it) is left alone.+fn refit_anchor(old: &Sheet, new: &Sheet, was: &Anchor, now: &Anchor) -> Anchor {+    if was != now {+        return *now;+    }+    match now.mode {+        AnchorMode::MoveOnly => *now,+        AnchorMode::Absolute => {+            let x = old.col_left(was.cell.col) + was.dx as f64;+            let y = old.row_top(was.cell.row) + was.dy as f64;+            let (col, row) = (new.col_at(x), new.row_at(y));+            let (dx, dy) = ((x - new.col_left(col)).max(0.0) as f32, (y - new.row_top(row)).max(0.0) as f32);+            Anchor { cell: CellRef::new(row, col), dx, dy, ..*now }+        }+        AnchorMode::MoveAndSize => {+            // A hidden first line has no room for the offset into it: Excel measures from its edge,+            // in the old grid (where the corner is found) as in the new one.+            let at = Anchor {+                dx: (was.dx as f64).min(old.col_width(was.cell.col) as f64) as f32,+                dy: (was.dy as f64).min(old.row_height(was.cell.row) as f64) as f32,+                ..*was+            };+            let (tc, tr, tdx, tdy) = anchor_corner(old, &at);+            let x1 = new.col_left(now.cell.col) + (now.dx as f64).min(new.col_width(now.cell.col) as f64);+            let y1 = new.row_top(now.cell.row) + (now.dy as f64).min(new.row_height(now.cell.row) as f64);+            let width = (new.col_left(tc) + tdx as f64 - x1).max(0.0) as f32;+            let height = (new.row_top(tr) + tdy as f64 - y1).max(0.0) as f32;+            Anchor { width, height, ..*now }+        }+    }+}++/// Re-fits every floating object of `after` to the grid its command left, honouring each object's+/// [`AnchorMode`] (see [`refit_anchor`]). `before` is the workbook as the command found it; sheets+/// are matched by position and name, and objects by id. Does nothing, and copies nothing, when no+/// object's anchor changes.+pub(crate) fn refit_objects(before: &Workbook, after: &mut Arc<Workbook>) {+    let mut fixes: Vec<(usize, Vec<Anchor>, Vec<Anchor>, Vec<Anchor>)> = Vec::new();+    for (i, (old, new)) in before.sheets.iter().zip(after.sheets.iter()).enumerate() {+        // Most commands leave most sheets alone, and most sheets have no objects.+        if Arc::ptr_eq(old, new) || (old.charts.is_empty() && old.images.is_empty() && old.shapes.is_empty()) {+            continue;+        }+        let same_grid = old.rows == new.rows+            && old.cols == new.cols+            && old.default_row_height == new.default_row_height+            && old.default_col_width == new.default_col_width;+        if same_grid || old.name != new.name {+            continue;+        }+        // Prior anchors by object id, so a sheet of many objects is matched in one pass.+        let old_charts: HashMap<u32, &Anchor> = old.charts.iter().map(|o| (o.id, &o.anchor)).collect();+        let old_images: HashMap<u32, &Anchor> = old.images.iter().map(|o| (o.id, &o.anchor)).collect();+        let old_shapes: HashMap<u32, &Anchor> = old.shapes.iter().map(|o| (o.id, &o.anchor)).collect();+        let fit = |olds: &HashMap<u32, &Anchor>, id: u32, now: &Anchor| olds.get(&id).map_or(*now, |was| refit_anchor(old, new, was, now));+        let charts: Vec<Anchor> = new.charts.iter().map(|n| fit(&old_charts, n.id, &n.anchor)).collect();+        let images: Vec<Anchor> = new.images.iter().map(|n| fit(&old_images, n.id, &n.anchor)).collect();+        let shapes: Vec<Anchor> = new.shapes.iter().map(|n| fit(&old_shapes, n.id, &n.anchor)).collect();+        let unchanged = new.charts.iter().map(|o| &o.anchor).eq(charts.iter())+            && new.images.iter().map(|o| &o.anchor).eq(images.iter())+            && new.shapes.iter().map(|o| &o.anchor).eq(shapes.iter());+        if !unchanged {+            fixes.push((i, charts, images, shapes));+        }+    }+    if fixes.is_empty() {+        return;+    }+    let wb = Arc::make_mut(after);+    for (i, charts, images, shapes) in fixes {+        let Some(sh) = wb.sheet_mut(i) else { continue };+        for (o, a) in sh.charts.iter_mut().zip(charts) {+            o.anchor = a;+        }+        for (o, a) in sh.images.iter_mut().zip(images) {+            o.anchor = a;+        }+        for (o, a) in sh.shapes.iter_mut().zip(shapes) {+            o.anchor = a;+        }+    }+}+ /// Shifts everything that has a position on the sheet for an insert/delete. fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: bool) {     let map_line = |v: u32| -> Option<u32> {diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -699,6 +699,17 @@ impl Session {         // it leaves the group its step and makes one of its own for what it changed after the end.         // A command that opened and closed its own groups makes one step of its own, which         // replaces the steps those groups made.+        // Floating objects keep the place their "Move and size with cells" setting gives them+        // when the command resized, hid or moved the rows and columns around them.+        if result.is_ok()+            && spec.undoable+            && self.depth == 0+            && let Some((uid, wb, _)) = &before+            && let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid)+            && !Arc::ptr_eq(&d.wb, wb)+        {+            cmd::sheet::refit_objects(wb, &mut d.wb);+        }         let epoch_changed = self.group_epoch != group_epoch;         let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid));         let was_open = start.is_some_and(|(was_open, _)| was_open);diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs--- a/crates/engine/tests/it/main.rs+++ b/crates/engine/tests/it/main.rs@@ -17,6 +17,7 @@ mod hidden_sheet_navigation; mod ink_roundtrip; mod line_count_overflow; mod long_operator_chains;+mod object_placement; mod offset_overflow; mod percent_entry; mod pivot_unknown_func;diff --git a/crates/engine/tests/it/object_placement.rs b/crates/engine/tests/it/object_placement.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/it/object_placement.rs@@ -0,0 +1,247 @@+//! Pictures, shapes and charts follow their cells as their placement says ("Move and size with+//! cells", "Move but don't size with cells", "Don't move or size with cells") when rows and columns+//! are resized, hidden, inserted or deleted under them. The expected numbers (points) were measured+//! in Excel for Microsoft 365 through COM: one object of each kind per placement, from C5 (offset 3+//! pt right, 4 pt down) over 144 x 57.6 pt, on a sheet with 14.4 pt rows and 48 pt columns.++use gridcraft_engine::Session;+use gridcraft_model::AnchorMode;+use serde_json::json;++/// A 1 x 1 PNG.+const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";+/// The model sizes lines and objects in pixels at 96 dpi; Excel reports points.+const PT: f64 = 0.75;++#[derive(Clone, Copy)]+enum Kind {+    Picture,+    Shape,+    Chart,+}++/// One object of each kind in each placement, stacked at the same spot.+struct Fixture {+    s: Session,+    objects: Vec<(Kind, u32, AnchorMode)>,+}++const MODES: [AnchorMode; 3] = [AnchorMode::MoveAndSize, AnchorMode::MoveOnly, AnchorMode::Absolute];++/// The `mode` parameter value for a placement.+fn mode_name(m: AnchorMode) -> &'static str {+    match m {+        AnchorMode::MoveAndSize => "moveAndSize",+        AnchorMode::MoveOnly => "moveOnly",+        AnchorMode::Absolute => "absolute",+    }+}++/// A sheet with Excel's measured row and column sizes and the nine objects.+fn fixture() -> Fixture {+    let mut s = Session::new();+    s.new_workbook();+    {+        let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);+        let sh = wb.sheet_mut(0).unwrap();+        sh.default_row_height = (14.4 / PT) as f32;+        sh.default_col_width = (48.0 / PT) as f32;+    }+    s.execute("range.setValues", json!({"range": "A1", "values": [["h", 1], ["x", 2], ["y", 3]]})).unwrap();+    let (w, h) = (144.0 / PT, 57.6 / PT);+    let mut objects = Vec::new();+    for mode in MODES {+        let m = mode_name(mode);+        let r = s.execute("insert.picture", json!({"base64": PNG, "at": "C5", "width": w, "height": h, "mode": m})).unwrap();+        objects.push((Kind::Picture, r["image"].as_u64().unwrap() as u32, mode));+        let r = s.execute("insert.shape", json!({"kind": "rectangle", "at": "C5", "width": w, "height": h, "mode": m})).unwrap();+        objects.push((Kind::Shape, r["shape"].as_u64().unwrap() as u32, mode));+        let r = s.execute("insert.chart", json!({"range": "A1:B3", "at": "C5", "width": w, "height": h, "mode": m})).unwrap();+        objects.push((Kind::Chart, r["chart"].as_u64().unwrap() as u32, mode));+    }+    for &(kind, id, _) in &objects {+        let kind = match kind {+            Kind::Picture => "image",+            Kind::Shape => "shape",+            Kind::Chart => "chart",+        };+        s.execute("object.move", json!({"kind": kind, "id": id, "dx": 3.0 / PT, "dy": 4.0 / PT})).unwrap();+    }+    Fixture { s, objects }+}++impl Fixture {+    /// Left, top, width, height in points, as Excel reports them.+    fn rect(&self, kind: Kind, id: u32) -> [f64; 4] {+        let sh = self.s.doc().unwrap().wb.sheet(0).unwrap();+        let a = match kind {+            Kind::Picture => sh.images.iter().find(|o| o.id == id).unwrap().anchor,+            Kind::Shape => sh.shapes.iter().find(|o| o.id == id).unwrap().anchor,+            Kind::Chart => sh.charts.iter().find(|o| o.id == id).unwrap().anchor,+        };+        [(sh.col_left(a.cell.col) + a.dx as f64) * PT, (sh.row_top(a.cell.row) + a.dy as f64) * PT, a.width as f64 * PT, a.height as f64 * PT]+    }++    /// Runs `action`, then checks every object against the rectangle Excel gave its placement.+    fn expect(&mut self, what: &str, action: impl FnOnce(&mut Session), moves_and_sizes: [f64; 4], moves: [f64; 4], free: [f64; 4]) {+        action(&mut self.s);+        for &(kind, id, mode) in &self.objects {+            let want = match mode {+                AnchorMode::MoveAndSize => moves_and_sizes,+                AnchorMode::MoveOnly => moves,+                AnchorMode::Absolute => free,+            };+            let got = self.rect(kind, id);+            for (g, w) in got.iter().zip(want) {+                assert!((g - w).abs() < 0.01, "{what}: {} {:?} is {got:?}, Excel gives {want:?}", mode_name(mode), id);+            }+        }+    }+}++const HOME: [f64; 4] = [99.0, 61.6, 144.0, 57.6];++/// Runs a row command on `rows`.+fn rows(s: &mut Session, id: &str, rows: &str) {+    s.execute(id, json!({"rows": rows})).unwrap();+}++/// Runs a column command on `cols`.+fn cols(s: &mut Session, id: &str, cols: &str) {+    s.execute(id, json!({"cols": cols})).unwrap();+}++#[test]+/// Row resize above inside and below.+fn row_resize_above_inside_and_below() {+    let height = |s: &mut Session, r: &str, pt: f64| s.execute("home.rowHeight", json!({"rows": r, "points": pt})).unwrap();+    // Row 2 to 60 pt: everything but "don't move or size" slides down by 45.6 pt.+    let down = [99.0, 107.2, 144.0, 57.6];+    fixture().expect("row 2 to 60 pt", |s| drop(height(s, "2", 60.0)), down, down, HOME);+    // Rows 5 (the object's first), 6 and 8 inside it: only "move and size" stretches.+    let tall = [99.0, 61.6, 144.0, 103.2];+    for r in ["5", "6", "8"] {+        fixture().expect(&format!("row {r} to 60 pt"), |s| drop(height(s, r, 60.0)), tall, HOME, HOME);+    }+    // Row 12 is below it.+    fixture().expect("row 12 to 60 pt", |s| drop(height(s, "12", 60.0)), HOME, HOME, HOME);+}++#[test]+/// Column resize left inside and right.+fn column_resize_left_inside_and_right() {+    // 221.6 px is 166.2 pt, Excel's width for 30 characters on the machine it was measured on.+    let width = |s: &mut Session, c: &str| s.execute("home.columnWidth", json!({"cols": c, "width": 166.2 / PT})).unwrap();+    let right = [217.2, 61.6, 144.0, 57.6];+    fixture().expect("column A", |s| drop(width(s, "A")), right, right, HOME);+    let wide = [99.0, 61.6, 262.2, 57.6];+    fixture().expect("column D", |s| drop(width(s, "D")), wide, HOME, HOME);+    fixture().expect("column H", |s| drop(width(s, "H")), HOME, HOME, HOME);+}++#[test]+/// Row insert and delete.+fn row_insert_and_delete() {+    let down = [99.0, 76.0, 144.0, 57.6];+    fixture().expect("insert row 2", |s| rows(s, "home.insertRows", "2"), down, down, HOME);+    fixture().expect("insert row 5", |s| rows(s, "home.insertRows", "5"), down, down, HOME);+    let tall = [99.0, 61.6, 144.0, 72.0];+    fixture().expect("insert row 6", |s| rows(s, "home.insertRows", "6"), tall, HOME, HOME);+    // Row 9 holds the object's bottom edge.+    fixture().expect("insert row 9", |s| rows(s, "home.insertRows", "9"), tall, HOME, HOME);+    fixture().expect("insert row 12", |s| rows(s, "home.insertRows", "12"), HOME, HOME, HOME);+    let up = [99.0, 47.2, 144.0, 57.6];+    fixture().expect("delete row 2", |s| rows(s, "home.deleteRows", "2"), up, up, HOME);+    fixture().expect("delete row 12", |s| rows(s, "home.deleteRows", "12"), HOME, HOME, HOME);+    fixture().expect("delete row 6", |s| rows(s, "home.deleteRows", "6"), [99.0, 61.6, 144.0, 43.2], HOME, HOME);+    // The rows under the object's top edge go: the edge sits on the line that takes their place.+    fixture().expect("delete rows 5:8", |s| rows(s, "home.deleteRows", "5:8"), [99.0, 57.6, 144.0, 4.0], [99.0, 57.6, 144.0, 57.6], HOME);+    fixture().expect("delete rows 4:6", |s| rows(s, "home.deleteRows", "4:6"), [99.0, 43.2, 144.0, 32.8], [99.0, 43.2, 144.0, 57.6], HOME);+}++#[test]+/// Column insert and delete.+fn column_insert_and_delete() {+    let right = [147.0, 61.6, 144.0, 57.6];+    fixture().expect("insert column B", |s| cols(s, "home.insertColumns", "B"), right, right, HOME);+    fixture().expect("insert column D", |s| cols(s, "home.insertColumns", "D"), [99.0, 61.6, 192.0, 57.6], HOME, HOME);+    let left = [51.0, 61.6, 144.0, 57.6];+    fixture().expect("delete column B", |s| cols(s, "home.deleteColumns", "B"), left, left, HOME);+    fixture().expect("delete column D", |s| cols(s, "home.deleteColumns", "D"), [99.0, 61.6, 96.0, 57.6], HOME, HOME);+    fixture().expect("delete columns C:E", |s| cols(s, "home.deleteColumns", "C:E"), [96.0, 61.6, 3.0, 57.6], [96.0, 61.6, 144.0, 57.6], HOME);+}++#[test]+/// Hiding rows and columns.+fn hiding_rows_and_columns() {+    let up = [99.0, 47.2, 144.0, 57.6];+    fixture().expect("hide row 2", |s| rows(s, "home.hideRows", "2"), up, up, HOME);+    fixture().expect("hide row 6", |s| rows(s, "home.hideRows", "6"), [99.0, 61.6, 144.0, 43.2], HOME, HOME);+    let left = [51.0, 61.6, 144.0, 57.6];+    fixture().expect("hide column B", |s| cols(s, "home.hideColumns", "B"), left, left, HOME);+    fixture().expect("hide column D", |s| cols(s, "home.hideColumns", "D"), [99.0, 61.6, 96.0, 57.6], HOME, HOME);+    // Unhiding gives everything back, whichever placement.+    for (hide, show, r) in [("home.hideRows", "home.unhideRows", "2:2"), ("home.hideRows", "home.unhideRows", "5:8")] {+        fixture().expect(+            &format!("hide and unhide rows {r}"),+            |s| {+                rows(s, hide, r);+                rows(s, show, r);+            },+            HOME,+            HOME,+            HOME,+        );+    }+}++#[test]+/// Hiding the rows an object spans.+fn hiding_the_rows_an_object_spans() {+    // Excel measures a hidden top row from its edge, so the object keeps what is below it: 4 pt.+    // (It also draws the object's top at the row's edge, 57.6 pt; GridCraft keeps the 3 pt/4 pt+    // offset in the file and draws the top 4 pt lower.)+    let mut f = fixture();+    rows(&mut f.s, "home.hideRows", "5:8");+    for &(kind, id, mode) in &f.objects {+        let [_, _, _, h] = f.rect(kind, id);+        let want = if mode == AnchorMode::MoveAndSize { 4.0 } else { 57.6 };+        assert!((h - want).abs() < 0.01, "{} height {h}", mode_name(mode));+    }+    let mut f = fixture();+    rows(&mut f.s, "home.hideRows", "5");+    for &(kind, id, mode) in &f.objects {+        let [_, _, _, h] = f.rect(kind, id);+        let want = if mode == AnchorMode::MoveAndSize { 47.2 } else { 57.6 };+        assert!((h - want).abs() < 0.01, "{} height {h}", mode_name(mode));+    }+}++#[test]+/// An applied filter hides rows like hiding them.+fn an_applied_filter_hides_rows_like_hiding_them() {+    // Rows 2 and 3 hold "x" and "y": keeping "y" hides row 2, above the object.+    let up = [99.0, 47.2, 144.0, 57.6];+    fixture().expect(+        "filter hides row 2",+        |s| {+            s.execute("data.filter", json!({"range": "A1:A3"})).unwrap();+            s.execute("data.filterBy", json!({"column": "A", "values": ["y"]})).unwrap();+            assert!(s.doc().unwrap().wb.sheet(0).unwrap().is_row_hidden(1));+        },+        up,+        up,+        HOME,+    );+}++#[test]+/// Undo puts objects back.+fn undo_puts_objects_back() {+    let mut f = fixture();+    let before: Vec<[f64; 4]> = f.objects.iter().map(|&(k, id, _)| f.rect(k, id)).collect();+    f.s.execute("home.rowHeight", json!({"rows": "2", "points": 60.0})).unwrap();+    f.s.execute("edit.undo", json!({})).unwrap();+    let after: Vec<[f64; 4]> = f.objects.iter().map(|&(k, id, _)| f.rect(k, id)).collect();+    assert_eq!(before, after);+}