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
@@ -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! |@@ -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> {@@ -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);@@ -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;new file mode 100644@@ -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);+}