From e7591de2ec957d5a9a70c3db56a04498b8d94803 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:36:22 -0400 Subject: [PATCH 01/55] Objects: one selection for charts, pictures, shapes and slicers, with eight resize handles The sheet tracked the selected object as a bare id in `selected_chart`, so only charts and text boxes drew a selection frame, only the bottom-right corner resized, and the kind of the selected object had to be guessed from the id. The selection is now `SheetApp::selected_object: Option`, with `selected_chart()` kept for the Chart Design tab and the Format Chart pane. - Model: `ObjKind` (chart, image, shape, slicer) and `ObjRef { kind, id }`; `Sheet` helpers find, move and remove an object by `ObjRef` and walk every object's anchor in a fixed order. `next_object_id` saturates instead of overflowing on an id at `u32::MAX` read from a file. - Commands: `object.delete`, `object.move` and `object.setAnchorMode` accept the four kinds through one parameter check, refuse an unknown kind or id instead of succeeding silently, and clamp hostile offsets. `object.delete` replies with the deleted object. Arrange and the Selection Pane include slicers, which stack above the other objects. - Sheet: the selected object of any kind draws a frame with eight handles over every object. Each handle resizes only its own edges, which never cross; the body moves the object, and the first drag frame already applies the pointer's movement. The pointer shows the handle's resize cursor or a move cursor, with the diagonals swapped on a right-to-left sheet, where the frame moves with its object. - Keys: Escape drops the object selection; Delete and Backspace keep deleting the selected object (#213). A selection whose object disappears (undo, redo, delete, sheet switch) is cleared. - Double-click: `open_object` routes by kind (a text box edits in place, a chart opens Format Chart). - Control protocol: `ui.inspect` reports `selectedObject {kind, id}` and keeps `selectedChart`; `ui.object.select` selects any object and replies with its rect. The snapshot example takes `--object `. Tests: engine tests for every kind by kind and id, the Selection Pane and hostile parameters; UI tests for selecting each kind (a slicer over a chart takes the click), all eight handles on four kinds, cursors, Delete, Backspace and Escape, undo and redo, double-click routing, and resizing on a right-to-left sheet. Existing tests use the new selection API. --- ROADMAP.md | 4 +- crates/engine/src/cmd/extra.rs | 6 +- crates/engine/src/cmd/extra/objects.rs | 11 + crates/engine/src/cmd/insert.rs | 82 +++-- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/objects.rs | 105 ++++++ crates/model/src/features.rs | 43 +++ crates/model/src/sheet.rs | 79 ++++- crates/model/src/workbook.rs | 17 +- crates/ui-egui/examples/snapshot.rs | 7 +- crates/ui-egui/src/app.rs | 2 +- crates/ui-egui/src/app/commands.rs | 3 +- crates/ui-egui/src/chartview.rs | 64 ++-- crates/ui-egui/src/control.rs | 31 +- crates/ui-egui/src/dialogs/charts.rs | 2 +- crates/ui-egui/src/grid.rs | 6 +- crates/ui-egui/src/grid/cursor.rs | 29 +- crates/ui-egui/src/grid/interact.rs | 109 +++---- crates/ui-egui/src/grid/keyboard.rs | 7 +- crates/ui-egui/src/grid/menus.rs | 2 +- crates/ui-egui/src/grid/show.rs | 2 +- crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/objects.rs | 217 +++++++++++++ crates/ui-egui/src/panes.rs | 9 +- crates/ui-egui/src/ribbon.rs | 2 +- crates/ui-egui/src/ribbon/contextual.rs | 4 +- crates/ui-egui/src/text_box.rs | 2 +- crates/ui-egui/src/window_ui.rs | 6 +- crates/ui-egui/tests/it/chart_resize.rs | 6 +- .../ui-egui/tests/it/chart_source_layering.rs | 4 +- .../ui-egui/tests/it/committed_text_fields.rs | 2 +- crates/ui-egui/tests/it/drag_cursor.rs | 4 +- crates/ui-egui/tests/it/format_chart_type.rs | 2 +- crates/ui-egui/tests/it/interaction.rs | 10 +- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/objects.rs | 302 ++++++++++++++++++ crates/ui-egui/tests/it/show_objects.rs | 6 +- crates/ui-egui/tests/it/text_box_editing.rs | 2 +- docs/control-protocol.md | 10 +- docs/ui-parity.md | 5 +- 40 files changed, 1010 insertions(+), 201 deletions(-) create mode 100644 crates/engine/src/tests/objects.rs create mode 100644 crates/ui-egui/src/objects.rs create mode 100644 crates/ui-egui/tests/it/objects.rs diff --git a/ROADMAP.md b/ROADMAP.md index 40652c341..43af2299b 100644 --- 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; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (floating objects: one selection, eight resize handles, keyboard delete; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **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-31 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) | | 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 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | | 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`) | @@ -244,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Progress log: one selection and eight resize handles for every floating object | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | | 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index c9a1582a2..237b32860 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -97,7 +97,7 @@ pub fn specs() -> Vec { "Bring Forward", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape, id, toFront?: bool}", + "{kind: chart|image|shape|slicer, id, toFront?: bool}", has_doc, |s, p| arrange(s, p, true) ), @@ -106,7 +106,7 @@ pub fn specs() -> Vec { "Send Backward", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape, id, toBack?: bool}", + "{kind: chart|image|shape|slicer, id, toBack?: bool}", has_doc, |s, p| arrange(s, p, false) ), @@ -116,7 +116,7 @@ pub fn specs() -> Vec { "Align", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape, ids: [..], align: left|center|right|top|middle|bottom}", + "{kind: chart|image|shape|slicer, ids: [..], align: left|center|right|top|middle|bottom}", has_doc, align ), diff --git a/crates/engine/src/cmd/extra/objects.rs b/crates/engine/src/cmd/extra/objects.rs index 30725f81e..03e8d07b2 100644 --- a/crates/engine/src/cmd/extra/objects.rs +++ b/crates/engine/src/cmd/extra/objects.rs @@ -62,6 +62,11 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result reorder(&mut sh.images, i, forward, extreme); } } + "slicer" => { + if let Some(i) = sh.slicers.iter().position(|c| c.id == id) { + reorder(&mut sh.slicers, i, forward, extreme); + } + } _ => { if let Some(i) = sh.shapes.iter().position(|c| c.id == id) { reorder(&mut sh.shapes, i, forward, extreme); @@ -85,6 +90,11 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result { for c in &sh.charts { v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); } + // Slicers paint above the other objects. + for c in &sh.slicers { + let name = if c.caption.is_empty() { &c.column } else { &c.caption }; + v.push(json!({"kind": "slicer", "id": c.id, "name": name, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); + } Ok(Json::Array(v)) } @@ -92,6 +102,7 @@ fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> { match kind { "chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), "image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + "slicer" => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), _ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), } } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index d55af2b6f..669dc6a62 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -5,8 +5,8 @@ use gridcraft_core::{CellRef, MAX_ROWS, RangeRef}; use gridcraft_formula::adjust::{Axis, Edit}; use gridcraft_model::{ Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill, - Formula, Hyperlink, Image, LegendPos, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn, TotalsFn, Underline, - Workbook, + Formula, Hyperlink, Image, LegendPos, ObjKind, ObjRef, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn, + TotalsFn, Underline, Workbook, }; use serde_json::{Value as Json, json}; @@ -146,14 +146,22 @@ pub fn specs() -> Vec { &with(p, "kind", json!("textBox")) )), cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text), - cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape, id}", has_doc, delete_object), - cmd!("object.move", "Move Object", [], None, "{kind: chart|image|shape, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object), + cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer, id}", has_doc, delete_object), + cmd!( + "object.move", + "Move Object", + [], + None, + "{kind: chart|image|shape|slicer, id, at?: \"C3\", dx?, dy?, width?, height?}", + has_doc, + move_object + ), cmd!( "object.setAnchorMode", "Object Properties", [], None, - "{kind: chart|image|shape, id, mode: moveAndSize|moveOnly|absolute}", + "{kind: chart|image|shape|slicer, id, mode: moveAndSize|moveOnly|absolute}", has_doc, set_anchor_mode ), @@ -1168,45 +1176,44 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { }) } +/// The floating object a `{kind, id}` command names on the active sheet. Unknown kinds and +/// objects are errors, so a stale id never succeeds silently. +fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> { + let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape or slicer"))?; + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let o = ObjRef::new(kind, id); + if !d.wb.sheet(sheet).is_some_and(|sh| sh.has_object(o)) { + return Err(bad(cmd, "no such object")); + } + Ok((sheet, o)) +} + fn delete_object(s: &mut Session, p: &Json) -> Result { - let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?; - let kind = str_param(p, "kind").unwrap_or("").to_string(); - let sheet = s.doc()?.wb.active_sheet(); + let (sheet, o) = object_param(s, p, "object.delete")?; edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - match kind.as_str() { - "chart" => sh.charts.retain(|c| c.id != id), - "image" => sh.images.retain(|c| c.id != id), - "slicer" => sh.slicers.retain(|c| c.id != id), - _ => sh.shapes.retain(|c| c.id != id), - } - Ok(Json::Null) + cx.sheet_mut(sheet)?.remove_object(o); + Ok(json!({"deleted": o})) }) } +/// Largest offset (points) `object.move` accepts from the anchor cell. +const MAX_OBJECT_OFFSET: f64 = 100_000.0; + fn move_object(s: &mut Session, p: &Json) -> Result { - let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?; - let kind = str_param(p, "kind").unwrap_or("").to_string(); - let sheet = s.doc()?.wb.active_sheet(); + let (sheet, o) = object_param(s, p, "object.move")?; let at = cell_param(s, p, "at")?; edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - let anchor = match kind.as_str() { - "chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - "image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - "slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - "shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - _ => None, - }; - let Some(a) = anchor else { return Err(bad("object.move", "no such object")) }; + let Some(a) = cx.sheet_mut(sheet)?.object_anchor_mut(o) else { return Err(bad("object.move", "no such object")) }; if let Some(at) = at { a.cell = at; } if let Some(v) = f64_param(p, "dx") { - a.dx = v as f32; + a.dx = v.clamp(-MAX_OBJECT_OFFSET, MAX_OBJECT_OFFSET) as f32; } if let Some(v) = f64_param(p, "dy") { - a.dy = v as f32; + a.dy = v.clamp(-MAX_OBJECT_OFFSET, MAX_OBJECT_OFFSET) as f32; } if let Some(v) = f64_param(p, "width") { a.width = v.clamp(1.0, 10000.0) as f32; @@ -1222,21 +1229,10 @@ fn move_object(s: &mut Session, p: &Json) -> Result { /// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns /// when the sheet is sorted or edited structurally. fn set_anchor_mode(s: &mut Session, p: &Json) -> Result { - let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?; - let kind = str_param(p, "kind").unwrap_or("").to_string(); - if !matches!(kind.as_str(), "chart" | "image" | "shape") { - return Err(bad("object.setAnchorMode", "kind must be chart, image or shape")); - } + let (sheet, o) = object_param(s, p, "object.setAnchorMode")?; let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?; - let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - let anchor = match kind.as_str() { - "chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - "image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - _ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - }; - let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) }; + let Some(a) = cx.sheet_mut(sheet)?.object_anchor_mut(o) else { return Err(bad("object.setAnchorMode", "no such object")) }; a.mode = mode; Ok(json!({"mode": anchor_mode_name(mode)})) }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f99f246df..49c09dee5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5203,6 +5203,7 @@ fn a_script_cannot_switch_windows() { } mod macros; +mod objects; mod seams; #[test] diff --git a/crates/engine/src/tests/objects.rs b/crates/engine/src/tests/objects.rs new file mode 100644 index 000000000..ea4dfb0e8 --- /dev/null +++ b/crates/engine/src/tests/objects.rs @@ -0,0 +1,105 @@ +//! Floating objects of every kind: found, moved, listed, stacked and deleted by `{kind, id}`. + +use gridcraft_core::CellRef; +use gridcraft_model::{AnchorMode, ObjKind, ObjRef, Sheet}; +use serde_json::json; + +use super::s; +use crate::Session; + +fn at(a: &str) -> CellRef { + CellRef::parse(a).unwrap() +} + +fn sheet(s: &Session, i: usize) -> &Sheet { + s.doc().unwrap().wb.sheet(i).unwrap() +} + +fn id_of(v: &serde_json::Value, key: &str) -> u32 { + v[key].as_u64().unwrap() as u32 +} + +/// A table with one object of each kind on the first sheet: a chart, a picture, a shape and a +/// slicer, in `ObjKind::ALL` order. +fn every_kind(s: &mut Session) -> [ObjRef; 4] { + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap(); + let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart"); + let image = super::add_image(s, "D18", AnchorMode::MoveAndSize); + let shape = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "H18"})).unwrap(), "shape"); + s.execute("selection.set", json!({"cell": "A2"})).unwrap(); + let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer"); + [ObjRef::new(ObjKind::Chart, chart), ObjRef::new(ObjKind::Image, image), ObjRef::new(ObjKind::Shape, shape), ObjRef::new(ObjKind::Slicer, slicer)] +} + +#[test] +fn every_kind_is_found_moved_and_deleted_by_kind_and_id() { + let mut s = s(); + let objs = every_kind(&mut s); + for o in objs { + assert_eq!(sheet(&s, 0).object_ref(o.id), Some(o), "the id finds its kind"); + assert_eq!(ObjKind::parse(o.kind.name()), Some(o.kind)); + s.execute("object.move", json!({"kind": o.kind.name(), "id": o.id, "at": "M20", "width": 50, "height": 30})).unwrap(); + let a = sheet(&s, 0).object_anchor(o).unwrap(); + assert_eq!((a.cell, a.width, a.height), (at("M20"), 50.0, 30.0)); + s.execute("object.setAnchorMode", json!({"kind": o.kind.name(), "id": o.id, "mode": "absolute"})).unwrap(); + assert_eq!(sheet(&s, 0).object_anchor(o).unwrap().mode, AnchorMode::Absolute); + } + // A kind that does not match the id, an unknown id or kind, and a missing kind are refused. + assert!(s.execute("object.delete", json!({"kind": "slicer", "id": objs[0].id})).is_err()); + assert!(s.execute("object.delete", json!({"kind": "chart", "id": 9999})).is_err()); + assert!(s.execute("object.delete", json!({"kind": "widget", "id": objs[0].id})).is_err()); + assert!(s.execute("object.delete", json!({"id": objs[0].id})).is_err(), "kind is required"); + for (n, o) in objs.into_iter().enumerate() { + let out = s.execute("object.delete", json!({"kind": o.kind.name(), "id": o.id})).unwrap(); + assert_eq!(out["deleted"], json!({"kind": o.kind.name(), "id": o.id})); + assert!(!sheet(&s, 0).has_object(o)); + for later in &objs[n + 1..] { + assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}"); + } + } + // One undo step per delete brings the slicer back. + s.execute("edit.undo", json!({})).unwrap(); + assert!(sheet(&s, 0).has_object(objs[3])); +} + +#[test] +fn selection_pane_lists_and_stacks_slicers() { + let mut s = s(); + let objs = every_kind(&mut s); + let second = id_of(&s.execute("insert.slicer", json!({"column": "Qty", "at": "P2"})).unwrap(), "slicer"); + let list = s.execute("arrange.selectionPane", json!({})).unwrap(); + let items = list.as_array().unwrap(); + let kinds: Vec<&str> = items.iter().map(|o| o["kind"].as_str().unwrap()).collect(); + for k in ObjKind::ALL { + assert!(kinds.contains(&k.name()), "{k:?} listed in {kinds:?}"); + } + assert_eq!(kinds.last(), Some(&"slicer"), "slicers stack on top"); + let names: Vec<&str> = items.iter().map(|o| o["name"].as_str().unwrap()).collect(); + assert!(names.contains(&"Region") && names.contains(&"Qty"), "{names:?}"); + assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(objs[3].id)); + s.execute("arrange.sendBackward", json!({"kind": "slicer", "id": second, "toBack": true})).unwrap(); + assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(second)); +} + +#[test] +fn hostile_object_params_are_refused() { + let mut s = s(); + let objs = every_kind(&mut s); + for p in [ + json!({}), + json!({"kind": "chart"}), + json!({"kind": "chart", "id": -1}), + json!({"kind": "chart", "id": u64::MAX}), + json!({"kind": 7, "id": objs[0].id}), + json!({"kind": null, "id": objs[0].id}), + ] { + for cmd in ["object.delete", "object.move", "object.setAnchorMode"] { + assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p}"); + } + } + let o = objs[3]; + s.execute("object.move", json!({"kind": o.kind.name(), "id": o.id, "dx": 1e300, "dy": -1e300, "width": 1e300, "height": -5})).unwrap(); + let a = sheet(&s, 0).object_anchor(o).unwrap(); + assert!(a.dx.is_finite() && a.dy.is_finite() && a.width <= 10000.0 && a.height >= 0.0, "{a:?}"); +} diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 2394319c3..0d6cc53ff 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -604,6 +604,49 @@ pub struct Shape { pub format: ShapeFormat, } +/// The kinds of floating object a sheet holds. Commands name an object by kind and +/// workbook-unique id (`{kind: "chart", id}`); [`ObjKind::name`] is that parameter's spelling. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ObjKind { + Chart, + Image, + Shape, + Slicer, +} + +impl ObjKind { + pub const ALL: [ObjKind; 4] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer]; + + /// The name used in command parameters. + pub fn name(self) -> &'static str { + match self { + ObjKind::Chart => "chart", + ObjKind::Image => "image", + ObjKind::Shape => "shape", + ObjKind::Slicer => "slicer", + } + } + + /// The kind named `s` (any letter case). + pub fn parse(s: &str) -> Option { + ObjKind::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) + } +} + +/// One floating object on a sheet: its kind and its workbook-unique id. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ObjRef { + pub kind: ObjKind, + pub id: u32, +} + +impl ObjRef { + pub fn new(kind: ObjKind, id: u32) -> ObjRef { + ObjRef { kind, id } + } +} + /// Horizontal alignment of a shape's text (`a:pPr algn`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ShapeTextAlign { diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index abc4731dc..d442a5340 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use crate::cell::{Cell, CellPicture}; use crate::features::{ - AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, PivotTable, PrintSettings, Shape, SheetProtection, Slicer, Sparkline, Table, - Validation, + Anchor, AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, SheetProtection, + Slicer, Sparkline, Table, Validation, }; use crate::store::CellStore; use crate::style::{Color, StyleId}; @@ -202,6 +202,81 @@ impl Sheet { self.spill_ranges.iter().find(|(anchor, range)| **anchor != c && range.contains(c)).map(|(anchor, _)| *anchor) } + /// The floating object with workbook-unique id `id` on this sheet. + pub fn object_ref(&self, id: u32) -> Option { + let kind = if self.charts.iter().any(|o| o.id == id) { + ObjKind::Chart + } else if self.images.iter().any(|o| o.id == id) { + ObjKind::Image + } else if self.shapes.iter().any(|o| o.id == id) { + ObjKind::Shape + } else if self.slicers.iter().any(|o| o.id == id) { + ObjKind::Slicer + } else { + return None; + }; + Some(ObjRef::new(kind, id)) + } + + /// Whether the floating object `o` exists on this sheet. + pub fn has_object(&self, o: ObjRef) -> bool { + self.object_anchor(o).is_some() + } + + /// The anchor of the floating object `o`. + pub fn object_anchor(&self, o: ObjRef) -> Option { + match o.kind { + ObjKind::Chart => self.charts.iter().find(|c| c.id == o.id).map(|c| c.anchor), + ObjKind::Image => self.images.iter().find(|c| c.id == o.id).map(|c| c.anchor), + ObjKind::Shape => self.shapes.iter().find(|c| c.id == o.id).map(|c| c.anchor), + ObjKind::Slicer => self.slicers.iter().find(|c| c.id == o.id).map(|c| c.anchor), + } + } + + pub fn object_anchor_mut(&mut self, o: ObjRef) -> Option<&mut Anchor> { + match o.kind { + ObjKind::Chart => self.charts.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), + ObjKind::Image => self.images.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), + ObjKind::Shape => self.shapes.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), + ObjKind::Slicer => self.slicers.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), + } + } + + /// Removes the floating object `o`; false when there is no such object. + pub fn remove_object(&mut self, o: ObjRef) -> bool { + fn take(v: &mut Vec, f: impl Fn(&T) -> bool) -> bool { + let n = v.len(); + v.retain(|x| !f(x)); + v.len() != n + } + match o.kind { + ObjKind::Chart => take(&mut self.charts, |c| c.id == o.id), + ObjKind::Image => take(&mut self.images, |c| c.id == o.id), + ObjKind::Shape => take(&mut self.shapes, |c| c.id == o.id), + ObjKind::Slicer => take(&mut self.slicers, |c| c.id == o.id), + } + } + + /// Every floating object's anchor in a fixed order (charts, images, shapes, slicers) that + /// [`Sheet::object_anchors_mut`] repeats, so a pass can compute new anchors from the old ones + /// and write them back in step. + pub fn object_anchors(&self) -> impl Iterator { + self.charts + .iter() + .map(|o| &o.anchor) + .chain(self.images.iter().map(|o| &o.anchor)) + .chain(self.shapes.iter().map(|o| &o.anchor)) + .chain(self.slicers.iter().map(|o| &o.anchor)) + } + + pub fn object_anchors_mut(&mut self) -> impl Iterator { + self.charts + .iter_mut() + .map(|o| &mut o.anchor) + .chain(self.images.iter_mut().map(|o| &mut o.anchor)) + .chain(self.shapes.iter_mut().map(|o| &mut o.anchor)) + .chain(self.slicers.iter_mut().map(|o| &mut o.anchor)) + } /// Displayed value of a cell: its own value, or a spilled value. pub fn value(&self, c: CellRef) -> Value { match self.cells.get(c) { diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 0d82ca6ff..12feee628 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -433,7 +433,7 @@ impl Workbook { m = m.max(c.id); } } - m + 1 + m.saturating_add(1) } } @@ -483,4 +483,19 @@ mod tests { assert!(wb.check_sheet_name(&"x".repeat(32), None).is_err()); assert!(wb.check_sheet_name("Budget 2026", None).is_ok()); } + + #[test] + fn object_ids_saturate_instead_of_overflowing() { + let mut wb = Workbook::new(); + let image = |id| crate::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() }; + assert_eq!(wb.next_object_id(), 1); + if let Some(sh) = wb.sheet_mut(0) { + sh.images.push(image(7)); + } + assert_eq!(wb.next_object_id(), 8); + if let Some(sh) = wb.sheet_mut(0) { + sh.images.push(image(u32::MAX)); + } + assert_eq!(wb.next_object_id(), u32::MAX, "an id read from a file at the maximum saturates"); + } } diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 21477d30b..6c5dcaaa7 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -2,7 +2,7 @@ //! //! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx] //! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|es|zh|ja|ko|ru|pt|de|cs|fr|tr] -//! [--dialog 'name={json}'] [--hf-edit header|footer] out.png` +//! [--pane name] [--object id] [--dialog 'name={json}'] [--hf-edit header|footer] out.png` use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; @@ -59,7 +59,8 @@ fn main() { i += 1; pane = args.get(i).cloned(); } - "--chart" => { + // Selects a floating object of any kind by id (`--chart` is the older spelling). + "--object" | "--chart" => { i += 1; chart = args.get(i).and_then(|s| s.parse().ok()); } @@ -115,7 +116,7 @@ fn main() { app.ui.ribbon_tab = t; } app.grid.pane = pane; - app.selected_chart = chart; + app.select_object_id(chart); if let Some(d) = &dialog { let (name, p) = d.split_once('=').map(|(a, b)| (a, serde_json::from_str(b).unwrap_or(json!({})))).unwrap_or((d.as_str(), json!({}))); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6fa5def4f..2225f0e4f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -68,7 +68,7 @@ impl SheetApp { capture_epoch: 0, capture_geometry: None, capture_geometry_from_raw: false, - selected_chart: None, + selected_object: None, clipboard_text_at_copy: None, cut_text: None, tab_run: None, diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index c7dcde0ff..8df70e714 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -77,7 +77,7 @@ impl SheetApp { && let Ok(r) = &r && let Some(chart) = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()) { - self.selected_chart = Some(chart); + self.selected_object = Some(gridcraft_engine::model::ObjRef::new(gridcraft_engine::model::ObjKind::Chart, chart)); } if id == "file.exportPdf" && let Ok(r) = &r @@ -259,6 +259,7 @@ impl SheetApp { pub fn after_engine(&mut self) { // The requests belong to the window the command left active. self.follow_active_window(); + self.prune_selected_object(); for req in self.session.take_ui_requests() { match req { UiRequest::Dialog(name, params) => self.open_dialog(&name, params), diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 8adab4918..36604c5b7 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -1,10 +1,10 @@ //! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that -//! we paint with egui), pictures and shapes. +//! we paint with egui), pictures, shapes and slicers. use egui::epaint::{PathShape, PathStroke}; use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2}; use gridcraft_chart::{HAlign, Prim, VAlign}; -use gridcraft_engine::model::{Anchor, ShapeKind, Sheet, Workbook}; +use gridcraft_engine::model::{Anchor, ObjKind, ObjRef, ShapeKind, Sheet, Workbook}; use crate::SheetApp; use crate::grid::Geo; @@ -265,7 +265,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo return painted; } // Source outlines stay in cell space beneath every floating object. - if let Some(ch) = sh.charts.iter().find(|ch| app.selected_chart == Some(ch.id)) + if let Some(ch) = sh.charts.iter().find(|ch| app.selected_chart() == Some(ch.id)) && anchor_rect(geo, sh, &ch.anchor).intersects(p.clip_rect()) { for s in &ch.series { @@ -345,9 +345,6 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo if label && !sp.text.is_empty() && !app.editing_text_box(sp.id) { paint_shape_text(p, wb, sp, r, geo.z); } - if label && sp.kind == ShapeKind::TextBox && app.selected_chart == Some(sp.id) { - selection_frame(p, r); - } done(start, r); } for im in &sh.images { @@ -361,9 +358,6 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo } else { p.rect_filled(r, 0.0, Tokens::get(p.ctx()).sheet_track); } - if app.selected_chart == Some(im.id) { - selection_frame(p, r); - } done(start, r); } for ch in &sh.charts { @@ -379,12 +373,18 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo let m = EguiMeasure { painter: p, z: geo.z }; let prims = gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m); paint_prims(&p.with_clip_rect(r.intersect(p.clip_rect())), r.min, &prims, geo.z); - if app.selected_chart == Some(ch.id) { - selection_frame(p, r); - } done(start, r); } - paint_slicers(app, p, geo, wb, si, sh); + paint_slicers(p, geo, wb, si, sh); + // The selected object's frame and handles go over every object, and move with it on a + // right-to-left sheet. + if let Some(r) = app.selected_object.and_then(|o| object_rect(geo, sh, o)) + && r.intersects(p.clip_rect()) + { + let start = p.add(egui::Shape::Noop); + selection_frame(p, r); + done(start, r); + } painted } @@ -421,7 +421,7 @@ pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32) -> SlicerLayout SlicerLayout { header, clear, tiles } } -fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) { +fn paint_slicers(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) { let tk = theme::Tokens::get(p.ctx()); for sl in &sh.slicers { let r = anchor_rect(geo, sh, &sl.anchor); @@ -455,9 +455,6 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz let cp = p.with_clip_rect(tr.intersect(p.clip_rect())); cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg); } - if app.selected_chart == Some(sl.id) { - selection_frame(p, r); - } } } @@ -496,40 +493,47 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> fn selection_frame(p: &Painter, r: Rect) { let t = Tokens::get(p.ctx()); p.rect_stroke(r, 0.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Outside); - for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] { + for h in crate::objects::Handle::ALL { + let c = h.pos(r); p.circle_filled(c, 4.0, t.sheet_control_bg); p.circle_stroke(c, 4.0, Stroke::new(1.0, t.sheet_control_border)); } - let _ = Align2::CENTER_CENTER; } -/// The topmost object under `p`: (kind, id, rect). -pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(&'static str, u32, Rect)> { - if app.session.active().is_some_and(|d| objects_hidden(&d.wb)) { +/// Where object `o` sits on the sheet (logical coordinates), when it exists on `sh`. +pub fn object_rect(geo: &Geo, sh: &Sheet, o: ObjRef) -> Option { + sh.object_anchor(o).map(|a| anchor_rect(geo, sh, &a)) +} + +/// The topmost object under `p` and its rect: slicers paint on top, so they are hit first, then +/// charts, pictures and shapes, each from the top of its stack. Nothing is hit outside the cell +/// area or while objects are hidden. +pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect)> { + if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) { return None; } for sl in sh.slicers.iter().rev() { let r = anchor_rect(geo, sh, &sl.anchor); - if r.contains(p) && geo.cells.contains(p) { - return Some(("slicer", sl.id, r)); + if r.contains(p) { + return Some((ObjRef::new(ObjKind::Slicer, sl.id), r)); } } for ch in sh.charts.iter().rev() { let r = anchor_rect(geo, sh, &ch.anchor); - if r.expand(4.0).contains(p) && geo.cells.contains(p) { - return Some(("chart", ch.id, r)); + if r.expand(4.0).contains(p) { + return Some((ObjRef::new(ObjKind::Chart, ch.id), r)); } } for im in sh.images.iter().rev() { let r = anchor_rect(geo, sh, &im.anchor); - if r.contains(p) && geo.cells.contains(p) { - return Some(("image", im.id, r)); + if r.contains(p) { + return Some((ObjRef::new(ObjKind::Image, im.id), r)); } } for s in sh.shapes.iter().rev() { let r = anchor_rect(geo, sh, &s.anchor); - if r.expand(3.0).contains(p) && geo.cells.contains(p) { - return Some(("shape", s.id, r)); + if r.expand(3.0).contains(p) { + return Some((ObjRef::new(ObjKind::Shape, s.id), r)); } } None diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index af5eda156..c2bd46263 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -4,7 +4,8 @@ //! - `engine.execute {command, params}` — run any engine or UI command //! - `engine.commands {search?}` — every command with enablement and params docs //! - `document.inspect`, `sheet.read {range?}`, `cell.get {cell?}` — read state -//! - `ui.inspect` — UI state (ribbon tab, editor, dialog, grid geometry, perf) +//! - `ui.inspect` — UI state (ribbon tab, editor, dialog, selected object, grid geometry, perf) +//! - `ui.object.select {id?, kind?}` — select a floating object; replies with its rect //! - `ui.click {x, y, button?, count?, shift?, cmd?, alt?}`, `ui.drag {x, y, toX, toY}`, //! `ui.move {x, y}` — real pointer input in window points //! - `ui.cell.click {cell}`, `ui.cell.drag {from, to}` — pointer input on a cell's centre @@ -149,7 +150,9 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { "dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})), "backstage": app.backstage.as_ref().map(|b| b.page.id()), "message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})), - "selectedChart": app.selected_chart, + "selectedObject": app.selected_object, + // The id when the selection is a chart, as before `selectedObject`. + "selectedChart": app.selected_chart(), "mode": format!("{:?}", app.session.mode), "documents": app.session.documents().iter().map(|d| json!({"title": d.display_title(), "dirty": d.is_dirty()})).collect::>(), "activeDocument": app.session.active_index(), @@ -159,6 +162,29 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { }) } +/// `ui.object.select {id?, kind?}`: selects the object with that id on the active sheet (its kind +/// is found from the id when left out, and must match when given); no `id` clears the selection. +/// Replies with the object and its rect in window points, for `ui.click` and `ui.drag`. +fn select_object(app: &mut SheetApp, p: &Json) -> Outcome { + use gridcraft_engine::model::{ObjKind, ObjRef}; + let Some(id) = p.get("id").and_then(Json::as_u64) else { + app.selected_object = None; + return ok(Json::Null); + }; + let Some(sh) = app.session.active().and_then(|d| d.wb.active()) else { return err("no document") }; + let found = u32::try_from(id).ok().and_then(|id| match p.get("kind").and_then(Json::as_str) { + Some(k) => ObjKind::parse(k).map(|k| ObjRef::new(k, id)).filter(|o| sh.has_object(*o)), + None => sh.object_ref(id), + }); + let Some(o) = found else { return err("no such object on the active sheet") }; + let rect = app.grid.rect.and_then(|r| { + let geo = crate::grid::Geo::new(sh, r, app.view().scroll); + crate::chartview::object_rect(&geo, sh, o).map(|r| geo.mirror_rect(r)) + }); + app.selected_object = Some(o); + ok(json!({"selected": o, "rect": rect.map(|r| [r.left(), r.top(), r.width(), r.height()])})) +} + /// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all. fn filter_commands(commands: Vec, search: Option<&str>) -> Vec { let Some(q) = search.map(str::to_ascii_lowercase) else { return commands }; @@ -292,6 +318,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - app.dialog = None; ok(Json::Null) } + "ui.object.select" => select_object(app, p), "ui.message.dismiss" => { app.message = None; ok(Json::Null) diff --git a/crates/ui-egui/src/dialogs/charts.rs b/crates/ui-egui/src/dialogs/charts.rs index 612ba381f..039b8b0f3 100644 --- a/crates/ui-egui/src/dialogs/charts.rs +++ b/crates/ui-egui/src/dialogs/charts.rs @@ -114,7 +114,7 @@ pub(super) fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut match app.run("insert.chart", params) { Ok(r) => { // The new chart is selected, as after inserting any object. - app.selected_chart = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()); + app.select_object_id(r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok())); *open = false; } Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index c5e1dd473..0e20d7e0b 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -131,12 +131,12 @@ pub enum Drag { grab: CellRef, to: Option, }, + /// Moving a floating object, or resizing it from one of its eight handles. Object { - kind: &'static str, - id: u32, + obj: gridcraft_engine::model::ObjRef, start: Pos2, orig: (CellRef, f32, f32, f32, f32), - resize: bool, + handle: Option, }, } diff --git a/crates/ui-egui/src/grid/cursor.rs b/crates/ui-egui/src/grid/cursor.rs index ae381c7ff..e15c4800a 100644 --- a/crates/ui-egui/src/grid/cursor.rs +++ b/crates/ui-egui/src/grid/cursor.rs @@ -1,5 +1,5 @@ use super::geo::Geo; -use super::interact::{OBJECT_RESIZE_RADIUS, col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge}; +use super::interact::{col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge}; use super::{CellRef, Color32, CursorIcon, Drag, Pos2, RangeRef, Rect, Sheet, SheetApp, Stroke, pos2, vec2}; /// Decide after interaction so drag start and release use the new state in the same frame. @@ -30,7 +30,7 @@ pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Resp // has Drag::None and uses hover. A click can briefly leave a drag state after release. if resp.dragged() && pointer.primary_down() - && let Some(cursor) = drag_cursor(&app.grid.drag) + && let Some(cursor) = drag_cursor(&app.grid.drag, geo.rtl) { ctx.set_cursor_icon(cursor); return; @@ -42,18 +42,19 @@ pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Resp hover_cursor(app, ctx, resp, &pointer, geo, sh, p); } -/// The cursor a held drag shows, or `None` when the drag leaves it to hover. -fn drag_cursor(drag: &Drag) -> Option { +/// The cursor a held drag shows, or `None` when the drag leaves it to hover. `rtl` swaps the +/// diagonal resize cursors of an object's corner handles on a mirrored sheet. +fn drag_cursor(drag: &Drag, rtl: bool) -> Option { match drag { Drag::None => None, Drag::Select => Some(CursorIcon::Cell), Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), Drag::Fill { .. } => Some(CursorIcon::Crosshair), - Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move), + Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { handle: None, .. } => Some(CursorIcon::Move), Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }), - Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe), + Drag::Object { handle: Some(h), .. } => Some(h.cursor(rtl)), } } @@ -75,10 +76,18 @@ fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, poin let border_panes = geo.range_panes(sel.current()); let on_handle = handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); let on_cur_border = on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); - let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); - let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); - if app.grid.drag == Drag::None && near_corner { - ctx.set_cursor_icon(CursorIcon::ResizeNwSe); + // The selected object's handles resize it; any object's body moves it. + let idle = app.grid.drag == Drag::None && app.editor.is_none(); + let object_handle = if idle && in_cells { + app.selected_object.and_then(|o| crate::chartview::object_rect(geo, sh, o)).and_then(|r| crate::objects::handle_at(r, p)) + } else { + None + }; + let on_object = idle && crate::chartview::hit(app, geo, sh, p).is_some(); + if let Some(h) = object_handle { + ctx.set_cursor_icon(h.cursor(geo.rtl)); + } else if on_object { + ctx.set_cursor_icon(CursorIcon::Move); } else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) { header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge); } else if on_handle && app.editor.is_none() { diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 4ffeaeb83..5b8eade31 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -1,3 +1,5 @@ +use gridcraft_engine::model::{ObjKind, ObjRef}; + use super::editor::validation_arrow; use super::geo::Geo; use super::keyboard::keyboard; @@ -11,9 +13,6 @@ use super::{ // ------------------------------------------------------------------ interaction -/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. -pub(super) const OBJECT_RESIZE_RADIUS: f32 = 10.0; - struct InteractionInput<'a> { ui: &'a mut egui::Ui, response: &'a egui::Response, @@ -107,7 +106,7 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo && let Some(p) = pos && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p) { - app.selected_chart = Some(id); + app.selected_object = Some(ObjRef::new(ObjKind::Slicer, id)); match act { crate::chartview::SlicerAction::Toggle(value) => { let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl})); @@ -123,41 +122,40 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo let primary_drag = resp.drag_started_by(egui::PointerButton::Primary); if primary_drag || primary_clicked { // A drag is recognised only after the pointer has moved, so grab the object where the press began. - let at = if primary_drag { pointer.press_origin().or(pos) } else { pos }; + let at = if primary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos }; + // The selected object's handles reach a little outside it, so they win over hit-testing. + let on_handle = app.selected_object.and_then(|o| { + let r = crate::chartview::object_rect(geo, sh, o)?; + at.and_then(|p| crate::objects::handle_at(r, p)).map(|_| (o, r)) + }); if let Some(p) = at - && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) + && let Some((obj, rect)) = on_handle.or_else(|| crate::chartview::hit(app, geo, sh, p)) { - app.selected_chart = Some(id); - if kind == "shape" - && resp.double_clicked() - && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox) - { - app.begin_text_box_edit(id); + app.selected_object = Some(obj); + if resp.double_clicked() && app.open_object(obj) != crate::objects::OpenAction::Select { return; } - if kind == "chart" && resp.double_clicked() { - app.grid.pane = Some("formatChart".into()); - } - if primary_drag { - let a = object_anchor(sh, kind, id); - let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; - if let Some(a) = a { - app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize }; + if primary_drag && let Some(a) = sh.object_anchor(obj) { + let handle = crate::objects::handle_at(rect, p); + app.grid.drag = Drag::Object { obj, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), handle }; + // The first recognised drag frame already holds movement away from the press. + if let Some(now) = pos { + drag_object(app, geo, sh, now); } } return; } - app.selected_chart = None; + app.selected_object = None; } // A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's // under it; a right press that wobbles into a drag counts too (#2067). let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary); if (resp.secondary_clicked() || secondary_drag) && let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos } - && let Some((kind, id, _)) = crate::chartview::hit(app, geo, sh, p) + && let Some((obj, _)) = crate::chartview::hit(app, geo, sh, p) { - app.selected_chart = Some(id); - app.grid.object_menu = Some((geo.mirror_pos(p), kind, id)); + app.selected_object = Some(obj); + app.grid.object_menu = Some((geo.mirror_pos(p), obj.kind.name(), obj.id)); return; } @@ -241,7 +239,7 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool { } if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) { if let Some(p) = pos - && let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p) + && let Some((ObjRef { kind: ObjKind::Shape, id }, _)) = crate::chartview::hit(app, geo, sh, p) && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink) { let _ = app.run("object.delete", json!({"kind": "shape", "id": id})); @@ -669,21 +667,7 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) { app.grid.drag = Drag::Move { grab, to: Some(src.start.offset_clamped(dr, dc)) }; scroll_by(app, auto); } - Drag::Object { kind, id, start, orig, resize } => { - let d = (p - start) / geo.z; - let (cell, dx, dy, w, h) = orig; - let params = if resize { - json!({"kind": kind, "id": id, "width": (w + d.x).max(20.0), "height": (h + d.y).max(20.0)}) - } else { - // Re-anchor at the cell under the new top-left. - let x0 = sh.col_left(cell.col) as f32 + dx + d.x; - let y0 = sh.row_top(cell.row) as f32 + dy + d.y; - let nc = CellRef::new(sh.row_at(y0.max(0.0) as f64), sh.col_at(x0.max(0.0) as f64)); - json!({"kind": kind, "id": id, "at": nc.a1(), "dx": x0.max(0.0) - sh.col_left(nc.col) as f32, "dy": y0.max(0.0) - sh.row_top(nc.row) as f32}) - }; - let _ = app.session.run("object.move", params); - coalesce_last_undo(app); - } + Drag::Object { .. } => drag_object(app, geo, sh, p), Drag::None => {} } } @@ -974,29 +958,30 @@ pub(super) fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option { row_edge(geo, sh, y).map(|(r, _)| r).filter(|&r| sh.row_height(r) <= 0.0) } -fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option { - match kind { - "chart" => sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor), - "image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor), - "slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor), - _ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor), - } -} - -pub(super) fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool { - let Some(id) = app.selected_chart else { return false }; - let kind = if sh.charts.iter().any(|object| object.id == id) { - "chart" - } else if sh.images.iter().any(|object| object.id == id) { - "image" - } else if sh.shapes.iter().any(|object| object.id == id) { - "shape" - } else { - return false; +/// Moves the dragged object, or resizes it from the grabbed handle, to follow the pointer at `p` +/// (logical coordinates). The whole drag coalesces into one undo step. +fn drag_object(app: &mut SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) { + let Drag::Object { obj, start, orig, handle } = app.grid.drag.clone() else { return }; + let d = (p - start) / geo.z; + let (cell, dx, dy, w, h) = orig; + let x0 = sh.col_left(cell.col) as f32 + dx; + let y0 = sh.row_top(cell.row) as f32 + dy; + let [x0, y0, x1, y1] = match handle { + Some(hd) => crate::objects::resized([x0, y0, x0 + w, y0 + h], hd, d, crate::objects::min_size(sh, obj)), + None => { + let (x0, y0) = ((x0 + d.x).max(0.0), (y0 + d.y).max(0.0)); + [x0, y0, x0 + w, y0 + h] + } }; - app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); - app.selected_chart = None; - true + // Re-anchor at the cell under the new top-left. + let nc = CellRef::new(sh.row_at(y0 as f64), sh.col_at(x0 as f64)); + let params = json!({ + "kind": obj.kind.name(), "id": obj.id, "at": nc.a1(), + "dx": x0 - sh.col_left(nc.col) as f32, "dy": y0 - sh.row_top(nc.row) as f32, + "width": x1 - x0, "height": y1 - y0, + }); + let _ = app.session.run("object.move", params); + coalesce_last_undo(app); } /// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index c55e20df1..b8823d570 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -1,5 +1,4 @@ use super::geo::Geo; -use super::interact::delete_selected_object; use super::menus::is_checkbox; use super::{CellRef, Drag, Sheet, SheetApp, json}; @@ -124,9 +123,10 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res } Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }), Key::F2 => app.begin_edit(None, false), - Key::Delete if delete_selected_object(app, sh) => {} + // With an object selected, Delete and Backspace delete it, not the cells. + Key::Delete if app.delete_selected_object() => {} Key::Delete => app.run_or_alert("edit.clearContents", json!({})), - Key::Backspace if delete_selected_object(app, sh) => {} + Key::Backspace if app.delete_selected_object() => {} Key::Backspace => { // Excel for Mac: Delete clears the cell and starts editing it. app.run_or_alert("edit.clearContents", json!({})); @@ -134,6 +134,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res app.begin_edit(Some(String::new()), false); } } + Key::Escape if app.selected_object.is_some() => app.selected_object = None, Key::Escape => { app.session.clipboard = None; app.session.format_painter = None; diff --git a/crates/ui-egui/src/grid/menus.rs b/crates/ui-egui/src/grid/menus.rs index dbe34af8d..b05a04bc7 100644 --- a/crates/ui-egui/src/grid/menus.rs +++ b/crates/ui-egui/src/grid/menus.rs @@ -393,7 +393,7 @@ pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { "ui:formatChart" => app.grid.pane = Some("formatChart".into()), _ => { app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); - app.selected_chart = None; + app.selected_object = None; } } } diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs index 2e79ee165..e4ab8ee0d 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -172,7 +172,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // Objects above cells. A hidden object can't stay selected: Delete or the Format pane would act // on something the user can't see. if crate::chartview::objects_hidden(&wb) { - app.selected_chart = None; + app.selected_object = None; } let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); paint_overlays(OverlayInput { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index db9ab316f..76d66c4ca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -39,6 +39,7 @@ pub mod keymap; pub mod keytips; pub mod macro_editor; pub mod macro_ui; +pub mod objects; pub mod outline; pub mod panes; pub mod paper_view; @@ -265,8 +266,8 @@ pub struct SheetApp { /// false (a bare test/embedder that never runs the hook) capture code falls back to egui's /// current input instead of reusing a stale recorded value. pub(crate) capture_geometry_from_raw: bool, - /// Chart selected on the sheet (id). - pub selected_chart: Option, + /// The floating object selected on the sheet (chart, picture, shape or slicer). + pub selected_object: Option, /// The system clipboard's text when GridCraft last copied without publishing to it (a /// control-channel or MCP `edit.copy`/`edit.cut`). While the system clipboard still holds this /// text, GridCraft's copy is the newer one and Paste uses it. diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs new file mode 100644 index 000000000..004f07d09 --- /dev/null +++ b/crates/ui-egui/src/objects.rs @@ -0,0 +1,217 @@ +//! Selecting, resizing, deleting and opening floating objects (charts, pictures, shapes and +//! slicers). Painting and hit-testing live in `chartview`. + +use egui::{CursorIcon, Pos2, Rect, Vec2}; +use gridcraft_engine::model::{ObjKind, ObjRef, ShapeKind, Sheet}; +use serde_json::json; + +use crate::SheetApp; + +/// One of the eight resize handles on a selected object's frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Handle { + TopLeft, + Top, + TopRight, + Left, + Right, + BottomLeft, + Bottom, + BottomRight, +} + +impl Handle { + pub const ALL: [Handle; 8] = + [Handle::TopLeft, Handle::Top, Handle::TopRight, Handle::Left, Handle::Right, Handle::BottomLeft, Handle::Bottom, Handle::BottomRight]; + + /// Which edges the handle moves: -1 the left or top edge, 1 the right or bottom edge, 0 neither. + pub fn sides(self) -> (i8, i8) { + match self { + Handle::TopLeft => (-1, -1), + Handle::Top => (0, -1), + Handle::TopRight => (1, -1), + Handle::Left => (-1, 0), + Handle::Right => (1, 0), + Handle::BottomLeft => (-1, 1), + Handle::Bottom => (0, 1), + Handle::BottomRight => (1, 1), + } + } + + /// Where the handle sits on `r`. + pub fn pos(self, r: Rect) -> Pos2 { + let (sx, sy) = self.sides(); + let x = match sx { + -1 => r.left(), + 1 => r.right(), + _ => r.center().x, + }; + let y = match sy { + -1 => r.top(), + 1 => r.bottom(), + _ => r.center().y, + }; + Pos2::new(x, y) + } + + /// The pointer shape over the handle (on a right-to-left sheet the diagonals swap, as the + /// frame is mirrored on screen). + pub fn cursor(self, rtl: bool) -> CursorIcon { + match self { + Handle::TopLeft | Handle::BottomRight if !rtl => CursorIcon::ResizeNwSe, + Handle::TopRight | Handle::BottomLeft if rtl => CursorIcon::ResizeNwSe, + Handle::TopLeft | Handle::BottomRight | Handle::TopRight | Handle::BottomLeft => CursorIcon::ResizeNeSw, + Handle::Left | Handle::Right => CursorIcon::ResizeHorizontal, + Handle::Top | Handle::Bottom => CursorIcon::ResizeVertical, + } + } +} + +/// The handle of `r` under `p`: the nearest one within reach. Corners reach further than edge +/// midpoints, and the reach shrinks on small objects so that their middle still moves them. +pub fn handle_at(r: Rect, p: Pos2) -> Option { + let small = r.width().min(r.height()); + Handle::ALL + .into_iter() + .map(|h| { + let (sx, sy) = h.sides(); + let reach = if sx != 0 && sy != 0 { 10.0f32 } else { 7.0 }.min((small / 3.0).max(4.0)); + (h, (h.pos(r) - p).length(), reach) + }) + .filter(|(_, d, reach)| d <= reach) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(h, ..)| h) +} + +/// The smallest size (points) a resize leaves: lines and arrows may lie flat, every other object +/// keeps some body. +pub fn min_size(sh: &Sheet, o: ObjRef) -> Vec2 { + let flat = o.kind == ObjKind::Shape && sh.shapes.iter().any(|s| s.id == o.id && matches!(s.kind, ShapeKind::Line | ShapeKind::Arrow)); + if flat { Vec2::new(1.0, 0.0) } else { Vec2::new(8.0, 8.0) } +} + +/// The sheet-point rectangle `[x0, y0, x1, y1]` after dragging `handle` by `d` points. Only the +/// grabbed edges move; they never cross the opposite edge (each keeps `min` from it) or leave +/// the sheet's top-left corner. +pub fn resized(orig: [f32; 4], handle: Handle, d: Vec2, min: Vec2) -> [f32; 4] { + let [mut x0, mut y0, mut x1, mut y1] = orig; + let (sx, sy) = handle.sides(); + match sx { + -1 => x0 = (x0 + d.x).min(x1 - min.x).max(0.0), + 1 => x1 = (x1 + d.x).max(x0 + min.x), + _ => {} + } + match sy { + -1 => y0 = (y0 + d.y).min(y1 - min.y).max(0.0), + 1 => y1 = (y1 + d.y).max(y0 + min.y), + _ => {} + } + [x0, y0, x1, y1] +} + +/// What double-clicking an object opens. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OpenAction { + /// Edit a text box's text in place (`text_box.rs`). + EditText(u32), + /// The Format Chart pane. + FormatChart(u32), + /// Nothing beyond selecting it. + Select, +} + +pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction { + match o.kind { + ObjKind::Chart => OpenAction::FormatChart(o.id), + ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id), + ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select, + } +} + +impl SheetApp { + /// Double-click on an object: selects it and opens what edits it. + pub fn open_object(&mut self, o: ObjRef) -> OpenAction { + self.selected_object = Some(o); + let action = self.session.active().and_then(|d| d.wb.active()).map_or(OpenAction::Select, |sh| open_action(sh, o)); + match action { + OpenAction::EditText(id) => { + self.begin_text_box_edit(id); + } + OpenAction::FormatChart(_) => self.grid.pane = Some("formatChart".into()), + OpenAction::Select => {} + } + action + } + + /// The selected object's id when it is a chart (the Chart Design tab and Format Chart pane). + pub fn selected_chart(&self) -> Option { + self.selected_object.filter(|o| o.kind == ObjKind::Chart).map(|o| o.id) + } + + /// Selects the object with id `id` on the active sheet, whatever its kind; `None` (or an id + /// that is not there) clears the selection. + pub fn select_object_id(&mut self, id: Option) { + self.selected_object = id.and_then(|id| self.session.active().and_then(|d| d.wb.active()).and_then(|sh| sh.object_ref(id))); + } + + /// Deletes the selected object (Delete and Backspace). False when nothing on the active + /// sheet is selected, so the key clears cells instead. + pub fn delete_selected_object(&mut self) -> bool { + let Some(o) = self.selected_object.take() else { return false }; + if !self.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.has_object(o)) { + return false; + } + self.run_or_alert("object.delete", json!({"kind": o.kind.name(), "id": o.id})); + true + } + + /// Drops the selection when its object is gone from the active sheet (deleted, undone, or + /// another sheet or workbook became active). + pub fn prune_selected_object(&mut self) { + if let Some(o) = self.selected_object + && !self.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.has_object(o)) + { + self.selected_object = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{pos2, vec2}; + + #[test] + fn handles_sit_on_the_frame_and_corners_win() { + let r = Rect::from_min_size(pos2(100.0, 100.0), vec2(200.0, 100.0)); + for h in Handle::ALL { + assert_eq!(handle_at(r, h.pos(r)), Some(h)); + } + assert_eq!(handle_at(r, r.center()), None); + assert_eq!(handle_at(r, pos2(106.0, 106.0)), Some(Handle::TopLeft)); + // A tiny object keeps its middle for moving. + let tiny = Rect::from_min_size(pos2(0.0, 0.0), vec2(12.0, 12.0)); + assert_eq!(handle_at(tiny, tiny.center()), None); + } + + #[test] + fn resizing_moves_only_the_grabbed_edges_and_never_inverts() { + let o = [10.0, 20.0, 110.0, 70.0]; + let m = vec2(8.0, 8.0); + assert_eq!(resized(o, Handle::BottomRight, vec2(5.0, 6.0), m), [10.0, 20.0, 115.0, 76.0]); + assert_eq!(resized(o, Handle::TopLeft, vec2(5.0, 6.0), m), [15.0, 26.0, 110.0, 70.0]); + assert_eq!(resized(o, Handle::Top, vec2(50.0, 6.0), m), [10.0, 26.0, 110.0, 70.0]); + assert_eq!(resized(o, Handle::Right, vec2(-500.0, 6.0), m), [10.0, 20.0, 18.0, 70.0]); + assert_eq!(resized(o, Handle::Left, vec2(-500.0, 0.0), m), [0.0, 20.0, 110.0, 70.0]); + assert_eq!(resized(o, Handle::BottomLeft, vec2(500.0, 500.0), m), [102.0, 20.0, 110.0, 570.0]); + } + + #[test] + fn diagonal_cursors_swap_on_a_mirrored_sheet() { + assert_eq!(Handle::BottomRight.cursor(false), CursorIcon::ResizeNwSe); + assert_eq!(Handle::BottomRight.cursor(true), CursorIcon::ResizeNeSw); + assert_eq!(Handle::TopRight.cursor(false), CursorIcon::ResizeNeSw); + assert_eq!(Handle::TopRight.cursor(true), CursorIcon::ResizeNwSe); + assert_eq!(Handle::Left.cursor(true), CursorIcon::ResizeHorizontal); + } +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index ec6146018..6adc1f698 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -3,6 +3,8 @@ use egui::{Color32, Ui, vec2}; use serde_json::{Value as Json, json}; +use gridcraft_engine::model::{ObjKind, ObjRef}; + use crate::SheetApp; use crate::theme::{self, Tokens}; @@ -146,10 +148,11 @@ fn selection(app: &mut SheetApp, ui: &mut Ui) { for it in items.iter().rev() { let kind = it["kind"].as_str().unwrap_or("").to_string(); let id = it["id"].as_u64().unwrap_or(0) as u32; + let obj = ObjKind::parse(&kind).map(|k| ObjRef::new(k, id)); ui.horizontal(|ui| { - let sel = app.selected_chart == Some(id); + let sel = obj.is_some() && app.selected_object == obj; if ui.selectable_label(sel, it["name"].as_str().unwrap_or("")).clicked() { - app.selected_chart = Some(id); + app.selected_object = obj; } if ui.small_button("▲").on_hover_text("Bring Forward").clicked() { app.run_or_alert("arrange.bringForward", json!({"kind": kind, "id": id})); @@ -166,7 +169,7 @@ fn selection(app: &mut SheetApp, ui: &mut Ui) { /// Draws the chart format pane: title, type, legend, elements/axis titles, series colors, palettes/layouts and size. fn format_chart(app: &mut SheetApp, ui: &mut Ui) { - let Some(id) = app.selected_chart else { + let Some(id) = app.selected_chart() else { ui.label(egui::RichText::new("Select a chart to format it.").italics()); return; }; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 49aba2b0e..d2adc491e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -282,7 +282,7 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { if sh.table_at(d.selection.active).is_some() { v.push("Table Design"); } - if app.selected_chart.is_some_and(|id| sh.charts.iter().any(|c| c.id == id)) { + if app.selected_chart().is_some_and(|id| sh.charts.iter().any(|c| c.id == id)) { v.push("Chart Design"); } if app.grid.hf_edit.is_some() { diff --git a/crates/ui-egui/src/ribbon/contextual.rs b/crates/ui-egui/src/ribbon/contextual.rs index 469e76cf0..5f11b75e2 100644 --- a/crates/ui-egui/src/ribbon/contextual.rs +++ b/crates/ui-egui/src/ribbon/contextual.rs @@ -109,7 +109,7 @@ fn table_style_strip(app: &mut SheetApp, ui: &mut Ui, tname: &str, current: &str } pub(super) fn chart_design(app: &mut SheetApp, ui: &mut Ui) { - let Some(id) = app.selected_chart else { return }; + let Some(id) = app.selected_chart() else { return }; let ae = big_button(ui, Icon::Chart, "Add Chart\nElement", "Add Chart Element", true); for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["chart.set"]) { keytip(app, ui, sequence, ae.rect.center_bottom()); @@ -178,6 +178,6 @@ pub(super) fn chart_design(app: &mut SheetApp, ui: &mut Ui) { control_keytip(app, ui, &__k, "chart.delete"); if __k.clicked() { act(app, "chart.delete", json!({"chart": id})); - app.selected_chart = None; + app.selected_object = None; } } diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs index 01496babe..b14a6e95a 100644 --- a/crates/ui-egui/src/text_box.rs +++ b/crates/ui-egui/src/text_box.rs @@ -50,7 +50,7 @@ impl SheetApp { request_focus: true, rect: None, }); - self.selected_chart = Some(id); + self.selected_object = Some(gridcraft_engine::model::ObjRef::new(gridcraft_engine::model::ObjKind::Shape, id)); self.grid.drag = crate::grid::Drag::None; self.grid.drag_select = false; self.session.mode = Mode::Edit; diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index bc6f31a3d..906ad07f4 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -23,7 +23,7 @@ pub struct WindowUi { grid: grid::GridState, name_box: Option, keytips: keytips::KeyTips, - selected_chart: Option, + selected_object: Option, tab_run: Option<(u64, usize, u32, CellRef)>, open_switch_windows: bool, /// The status bar's Ready/Enter/Edit, which follows the window's cell draft. @@ -61,7 +61,7 @@ impl SheetApp { grid, name_box, keytips, - selected_chart, + selected_object, tab_run, open_switch_windows, mode, @@ -76,7 +76,7 @@ impl SheetApp { std::mem::swap(&mut self.grid, grid); std::mem::swap(&mut self.name_box, name_box); std::mem::swap(&mut self.keytips, keytips); - std::mem::swap(&mut self.selected_chart, selected_chart); + std::mem::swap(&mut self.selected_object, selected_object); std::mem::swap(&mut self.tab_run, tab_run); std::mem::swap(&mut self.open_switch_windows, open_switch_windows); std::mem::swap(&mut self.session.mode, mode); diff --git a/crates/ui-egui/tests/it/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs index e2fc537ac..e6b0cdf73 100644 --- a/crates/ui-egui/tests/it/chart_resize.rs +++ b/crates/ui-egui/tests/it/chart_resize.rs @@ -59,7 +59,7 @@ fn dragging_the_corner_handle_resizes_the_chart() { h.run_steps(2); button(&mut h, inside, true); button(&mut h, inside, false); - assert_eq!(h.state().selected_chart, Some(id)); + assert_eq!(h.state().selected_chart(), Some(id)); h.input_mut().events.push(Event::PointerMoved(start)); h.run_steps(2); @@ -76,7 +76,7 @@ fn dragging_the_corner_handle_resizes_the_chart() { /// Opens Format Chart Area for the chart and expands its Size section; returns the Width field's rect. fn open_size_fields(h: &mut Harness, id: u32) -> egui::Rect { - h.state_mut().selected_chart = Some(id); + h.state_mut().select_object_id(Some(id)); h.state_mut().grid.pane = Some("formatChart".into()); h.run_steps(2); h.get_by_role_and_label(Role::DisclosureTriangle, "Size").click(); @@ -141,7 +141,7 @@ fn right_clicking_a_chart_opens_its_menu_not_the_cells() { let (w0, h0) = size(&h, id); let inside = corner(&h, id) - vec2(100.0, 80.0); right_click(&mut h, inside, wobble); - assert_eq!(h.state().selected_chart, Some(id), "the right-clicked chart is selected ({wobble:?})"); + assert_eq!(h.state().selected_chart(), Some(id), "the right-clicked chart is selected ({wobble:?})"); assert!(matches!(h.state().grid.object_menu, Some((_, "chart", i)) if i == id), "the chart's menu opens ({wobble:?})"); assert!(h.state().grid.context_menu.is_none(), "the cell menu stays closed ({wobble:?})"); assert_eq!(size(&h, id), (w0, h0)); diff --git a/crates/ui-egui/tests/it/chart_source_layering.rs b/crates/ui-egui/tests/it/chart_source_layering.rs index 5c4b52f75..7dcfc3fa3 100644 --- a/crates/ui-egui/tests/it/chart_source_layering.rs +++ b/crates/ui-egui/tests/it/chart_source_layering.rs @@ -28,7 +28,7 @@ fn fixture(rtl: bool, frozen: bool) -> (Harness, u32) { session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); } let mut h = crate::support::harness(session, 4); - h.state_mut().selected_chart = Some(id); + h.state_mut().select_object_id(Some(id)); if frozen { h.state_mut().view_mut().unwrap().scroll.y = 20.0; } @@ -143,7 +143,7 @@ fn unselected_hidden_and_offscreen_charts_do_not_highlight_sources() { let (mut h, id) = fixture(false, false); assert!(!source_strokes(&h).is_empty(), "selected visible chart highlights its source"); match state { - "unselected" => h.state_mut().selected_chart = None, + "unselected" => h.state_mut().selected_object = None, "hidden" => { h.state_mut().run("view.showObjects", json!({"on": false})).unwrap(); } diff --git a/crates/ui-egui/tests/it/committed_text_fields.rs b/crates/ui-egui/tests/it/committed_text_fields.rs index e1e8c6d8a..84d824dd7 100644 --- a/crates/ui-egui/tests/it/committed_text_fields.rs +++ b/crates/ui-egui/tests/it/committed_text_fields.rs @@ -44,7 +44,7 @@ fn typing_a_chart_title_in_format_chart_area_sets_it() { let chart = session.execute("insert.chart", json!({"type": "column", "at": "D3"})).unwrap(); let id = chart["chart"].as_u64().unwrap() as u32; let mut h = harness(session); - h.state_mut().selected_chart = Some(id); + h.state_mut().select_object_id(Some(id)); h.state_mut().grid.pane = Some("formatChart".into()); h.run_steps(2); h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Title").click(); diff --git a/crates/ui-egui/tests/it/drag_cursor.rs b/crates/ui-egui/tests/it/drag_cursor.rs index 321e25355..1b2134564 100644 --- a/crates/ui-egui/tests/it/drag_cursor.rs +++ b/crates/ui-egui/tests/it/drag_cursor.rs @@ -259,7 +259,7 @@ fn object_move_and_resize_keep_their_cursors_and_update_the_object() { let mut h = harness(); let result = h.state_mut().session.execute("insert.chart", json!({"type": "column", "at": "D3", "width": 300, "height": 200})).unwrap(); let id = result["chart"].as_u64().unwrap() as u32; - h.state_mut().selected_chart = Some(id); + h.state_mut().select_object_id(Some(id)); h.run_steps(2); let g = geo(&h); let min = cell(&h, "D3").min; @@ -268,7 +268,7 @@ fn object_move_and_resize_keep_their_cursors_and_update_the_object() { let target = start + vec2(60.0, 30.0); let cursor = if resize { CursorIcon::ResizeNwSe } else { CursorIcon::Move }; move_with_cursor(&mut h, target, cursor); - assert!(matches!(h.state().grid.drag, Drag::Object { resize: r, .. } if r == resize)); + assert!(matches!(h.state().grid.drag, Drag::Object { handle, .. } if handle.is_some() == resize)); move_with_cursor(&mut h, target + vec2(1.0, 1.0), cursor); button(&mut h, target, false); assert_eq!(h.state().grid.drag, Drag::None); diff --git a/crates/ui-egui/tests/it/format_chart_type.rs b/crates/ui-egui/tests/it/format_chart_type.rs index ae19e1091..8fe695b06 100644 --- a/crates/ui-egui/tests/it/format_chart_type.rs +++ b/crates/ui-egui/tests/it/format_chart_type.rs @@ -18,7 +18,7 @@ fn open_chart_type(t: &str, sub: &str) -> (Harness, u32) { let chart = session.execute("insert.chart", json!({"type": t, "subtype": sub, "at": "E3"})).unwrap(); let id = chart["chart"].as_u64().unwrap() as u32; let mut h = crate::support::harness(session, 4); - h.state_mut().selected_chart = Some(id); + h.state_mut().select_object_id(Some(id)); h.state_mut().grid.pane = Some("formatChart".into()); h.run_steps(2); h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Type").click(); diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index e876d736f..39baf5f66 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -392,12 +392,12 @@ fn delete_key_removes_the_selected_chart() { click_cell(&mut h, "C10"); click_chart(&mut h, id); - assert_eq!(h.state().selected_chart, Some(id)); + assert_eq!(h.state().selected_chart(), Some(id)); key(&mut h, Key::Delete, Modifiers::NONE); assert!(h.state().session.active().unwrap().wb.active().unwrap().charts.is_empty()); - assert_eq!(h.state().selected_chart, None); + assert_eq!(h.state().selected_chart(), None); assert_eq!(value(&h, "A1"), Value::Number(1.0)); } @@ -412,7 +412,7 @@ fn inserted_chart_is_selected_so_delete_removes_it() { let id = h.state_mut().run("insert.chart", json!({"type": "column"})).unwrap()["chart"].as_u64().unwrap() as u32; h.run_steps(2); - assert_eq!(h.state().selected_chart, Some(id), "the new chart is selected"); + assert_eq!(h.state().selected_chart(), Some(id), "the new chart is selected"); key(&mut h, Key::Delete, Modifiers::NONE); @@ -621,7 +621,7 @@ fn recommended_charts_dialog_inserts_the_chosen_chart() { assert!(h.state().dialog.is_none()); let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect(); assert_eq!(kinds, [gridcraft_engine::model::ChartKind::Pie]); - assert!(h.state().selected_chart.is_some(), "the new chart is selected"); + assert!(h.state().selected_chart().is_some(), "the new chart is selected"); // Without numbers there is nothing to recommend: a message, no dialog. h.state_mut().session.run("selection.set", json!({"range": "A1:A4"})).unwrap(); @@ -680,7 +680,7 @@ fn select_data_dialog_sets_the_category_axis_labels() { s.execute("range.setValues", json!({"range": "A1", "values": [["Year", "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap(); let id = s.execute("insert.chart", json!({"range": "A1:B4", "type": "line"})).unwrap()["chart"].as_u64().unwrap(); let mut h = harness(s); - h.state_mut().selected_chart = Some(id as u32); + h.state_mut().select_object_id(Some(id as u32)); h.state_mut().open_dialog("selectData", json!({"chart": id})); h.run_steps(2); let d = h.state_mut().dialog.as_mut().unwrap(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 302bc9c49..ca72568da 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -68,6 +68,7 @@ mod more_colors; mod native_print; mod nowrap_line_breaks; mod number_stored_as_text; +mod objects; mod open_encrypted; mod outline; mod page_layout_view; diff --git a/crates/ui-egui/tests/it/objects.rs b/crates/ui-egui/tests/it/objects.rs new file mode 100644 index 000000000..733a1427a --- /dev/null +++ b/crates/ui-egui/tests/it/objects.rs @@ -0,0 +1,302 @@ +//! Floating objects through real pointer and keyboard input: selecting every kind, resizing from +//! each of the eight handles, deleting with Delete and Backspace, and double-click routing. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{ObjKind, ObjRef}; +use gridcraft_ui_egui::objects::{Handle, OpenAction, open_action}; +use gridcraft_ui_egui::{SheetApp, chartview, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +struct Objects { + chart: ObjRef, + image: ObjRef, + shape: ObjRef, + text_box: ObjRef, + /// A slicer lying over the chart's right edge: slicers paint on top and take the click. + slicer: ObjRef, +} + +fn at(a: &str) -> CellRef { + CellRef::parse(a).unwrap() +} + +fn id(v: &serde_json::Value, key: &str) -> u32 { + v[key].as_u64().unwrap() as u32 +} + +fn fixture() -> (Harness, Objects) { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap(); + s.execute("selection.set", json!({"cell": "A2"})).unwrap(); + let slicer = id(&s.execute("insert.slicer", json!({"column": "Region", "at": "G9", "width": 140, "height": 120})).unwrap(), "slicer"); + s.execute("cell.set", json!({"cell": "R30", "input": "keep me"})).unwrap(); + s.execute("selection.set", json!({"cell": "R30"})).unwrap(); + let chart = + id(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D8", "width": 220, "height": 140})).unwrap(), "chart"); + let shape = id(&s.execute("insert.shape", json!({"kind": "rectangle", "at": "C20", "width": 120, "height": 80})).unwrap(), "shape"); + let text_box = id(&s.execute("insert.textBox", json!({"at": "H20", "width": 140, "height": 60, "text": "Note"})).unwrap(), "shape"); + let image = id(&s.execute("insert.picture", json!({"base64": tiny_png(), "at": "M8", "width": 60, "height": 60})).unwrap(), "image"); + let objs = Objects { + chart: ObjRef::new(ObjKind::Chart, chart), + image: ObjRef::new(ObjKind::Image, image), + shape: ObjRef::new(ObjKind::Shape, shape), + text_box: ObjRef::new(ObjKind::Shape, text_box), + slicer: ObjRef::new(ObjKind::Slicer, slicer), + }; + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(1400.0, 900.0)) + .with_step_dt(1.0 / 60.0) + .with_os(egui::os::OperatingSystem::Mac) + .build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + (h, objs) +} + +fn tiny_png() -> String { + let mut bytes = Vec::new(); + image::RgbaImage::from_pixel(2, 2, image::Rgba([40, 120, 200, 255])) + .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png) + .unwrap(); + gridcraft_engine::io::base64_encode(&bytes) +} + +fn rect(h: &Harness, o: ObjRef) -> Rect { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + chartview::object_rect(&Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll), sh, o).unwrap() +} + +/// A point that selects `o` without doing anything else: the chart's lower left (clear of the +/// slicer), the slicer's header (clear of its value tiles), the middle of anything else. +fn grab_point(h: &Harness, objs: &Objects, o: ObjRef) -> Pos2 { + let r = rect(h, o); + if o == objs.chart { + r.left_bottom() + vec2(12.0, -12.0) + } else if o == objs.slicer { + r.left_top() + vec2(14.0, 10.0) + } else { + r.center() + } +} + +fn exists(h: &Harness, o: ObjRef) -> bool { + h.state().session.doc().unwrap().wb.active().unwrap().has_object(o) +} + +fn pointer(h: &mut Harness, p: Pos2, pressed: bool) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); +} + +fn click(h: &mut Harness, p: Pos2) { + pointer(h, p, true); + pointer(h, p, false); + h.run_steps(2); +} + +fn double_click(h: &mut Harness, p: Pos2) { + // Separate the gesture from earlier clicks (egui's triple-click window). + h.run_steps(45); + click(h, p); + click(h, p); +} + +fn drag(h: &mut Harness, from: Pos2, to: Pos2) { + h.run_steps(45); + pointer(h, from, true); + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * 0.25)); + h.step(); + h.input_mut().events.push(Event::PointerMoved(to)); + h.step(); + pointer(h, to, false); + h.run_steps(2); +} + +fn key(h: &mut Harness, key: Key) { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn kept(h: &Harness) -> Value { + h.state().session.doc().unwrap().wb.active().unwrap().value(at("R30")) +} + +#[test] +fn a_click_selects_every_kind_and_slicers_win_over_charts() { + let (mut h, o) = fixture(); + for obj in [o.chart, o.image, o.shape, o.text_box, o.slicer] { + let p = grab_point(&h, &o, obj); + click(&mut h, p); + assert_eq!(h.state().selected_object, Some(obj), "{obj:?}"); + } + let (chart, slicer) = (rect(&h, o.chart), rect(&h, o.slicer)); + assert!(chart.contains(grab_point(&h, &o, o.slicer)) && slicer.intersects(chart), "the slicer's header lies over the chart"); + assert_eq!(h.state().selected_chart(), None, "a slicer is not a chart"); + click(&mut h, chart.right_bottom() + vec2(120.0, 160.0)); + assert_eq!(h.state().selected_object, None, "a click on the cells clears the object selection"); + assert_eq!(kept(&h), Value::text("keep me")); +} + +#[test] +fn every_handle_resizes_only_its_own_edges() { + let (mut h, o) = fixture(); + for obj in [o.shape, o.image, o.slicer, o.chart] { + let p = grab_point(&h, &o, obj); + click(&mut h, p); + for handle in Handle::ALL { + let before = rect(&h, obj); + let (sx, sy) = handle.sides(); + let d = vec2(f32::from(sx) * 20.0, f32::from(sy) * 16.0); + let grab = handle.pos(before); + drag(&mut h, grab, grab + d); + let after = rect(&h, obj); + let moved = |edge: f32, was: f32, by: f32| (edge - (was + by)).abs() < 1.0; + assert!(moved(after.left(), before.left(), if sx < 0 { d.x } else { 0.0 }), "{obj:?} {handle:?} left"); + assert!(moved(after.right(), before.right(), if sx > 0 { d.x } else { 0.0 }), "{obj:?} {handle:?} right"); + assert!(moved(after.top(), before.top(), if sy < 0 { d.y } else { 0.0 }), "{obj:?} {handle:?} top {before:?} {after:?}"); + assert!(moved(after.bottom(), before.bottom(), if sy > 0 { d.y } else { 0.0 }), "{obj:?} {handle:?} bottom"); + assert_eq!(h.state().selected_object, Some(obj), "resizing keeps the selection"); + } + } + // Shrinking past the opposite edge stops short of it. + let before = rect(&h, o.shape); + click(&mut h, before.center()); + drag(&mut h, Handle::Right.pos(before), Handle::Right.pos(before) - vec2(before.width() + 50.0, 0.0)); + let after = rect(&h, o.shape); + assert!(after.width() > 1.0 && (after.left() - before.left()).abs() < 1.0); + // Dragging the body moves the object without resizing it. + let before = rect(&h, o.image); + click(&mut h, before.center()); + drag(&mut h, before.center(), before.center() + vec2(30.0, 25.0)); + let after = rect(&h, o.image); + assert!((after.min - before.min - vec2(30.0, 25.0)).length() < 1.0 && (after.size() - before.size()).length() < 0.5); + assert_eq!(kept(&h), Value::text("keep me")); +} + +#[test] +fn handles_show_resize_cursors_and_the_body_a_move_cursor() { + let (mut h, o) = fixture(); + let r = rect(&h, o.shape); + click(&mut h, r.center()); + for (handle, icon) in [ + (Handle::TopLeft, egui::CursorIcon::ResizeNwSe), + (Handle::Top, egui::CursorIcon::ResizeVertical), + (Handle::BottomLeft, egui::CursorIcon::ResizeNeSw), + ] { + h.input_mut().events.push(Event::PointerMoved(handle.pos(r))); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, icon, "{handle:?}"); + } + h.input_mut().events.push(Event::PointerMoved(r.center())); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move); +} + +#[test] +fn delete_and_backspace_delete_the_selected_object_not_the_cells() { + let (mut h, o) = fixture(); + let p = rect(&h, o.shape).center(); + click(&mut h, p); + key(&mut h, Key::Delete); + assert!(!exists(&h, o.shape)); + assert_eq!(h.state().selected_object, None); + assert_eq!(kept(&h), Value::text("keep me")); + + let p = rect(&h, o.image).center(); + click(&mut h, p); + key(&mut h, Key::Backspace); + assert!(!exists(&h, o.image)); + assert!(h.state().editor.is_none(), "Backspace on an object must not start a cell editor"); + assert_eq!(kept(&h), Value::text("keep me")); + + let p = grab_point(&h, &o, o.slicer); + click(&mut h, p); + assert_eq!(h.state().selected_object, Some(o.slicer)); + key(&mut h, Key::Escape); + assert_eq!(h.state().selected_object, None, "Escape drops the object selection"); + key(&mut h, Key::Delete); + assert!(exists(&h, o.slicer), "with nothing selected, Delete is for the cells"); + assert_eq!(kept(&h), Value::Empty); + + // Undo brings the object back; a redo that removes the selected object clears the selection. + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(exists(&h, o.image)); + h.state_mut().selected_object = Some(o.image); + h.state_mut().run("edit.redo", json!({})).unwrap(); + h.run_steps(2); + assert!(!exists(&h, o.image)); + assert_eq!(h.state().selected_object, None); +} + +#[test] +fn double_clicks_route_by_kind() { + let (mut h, o) = fixture(); + { + let sh = h.state().session.doc().unwrap().wb.active().unwrap(); + assert_eq!(open_action(sh, o.text_box), OpenAction::EditText(o.text_box.id)); + assert_eq!(open_action(sh, o.chart), OpenAction::FormatChart(o.chart.id)); + for obj in [o.shape, o.image, o.slicer] { + assert_eq!(open_action(sh, obj), OpenAction::Select, "{obj:?}"); + } + } + let p = rect(&h, o.text_box).center(); + double_click(&mut h, p); + assert!(h.state().text_box_editor.as_ref().is_some_and(|e| e.id == o.text_box.id), "text boxes keep in-place editing"); + let outside = rect(&h, o.text_box).right_bottom() + vec2(200.0, 150.0); + click(&mut h, outside); + assert!(h.state().text_box_editor.is_none()); + + let p = grab_point(&h, &o, o.chart); + double_click(&mut h, p); + assert_eq!(h.state().grid.pane.as_deref(), Some("formatChart")); + assert_eq!(h.state().selected_object, Some(o.chart)); + + for obj in [o.shape, o.image, o.slicer] { + let p = grab_point(&h, &o, obj); + double_click(&mut h, p); + assert_eq!(h.state().selected_object, Some(obj)); + assert!(h.state().text_box_editor.is_none() && h.state().editor.is_none(), "{obj:?} opens no text editor"); + } + assert_eq!(kept(&h), Value::text("keep me")); +} + +#[test] +fn on_a_right_to_left_sheet_the_mirrored_handles_resize() { + let (mut h, o) = fixture(); + h.state_mut().run("view.rightToLeft", json!({"on": true})).unwrap(); + h.run_steps(3); + let screen = |h: &Harness| { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + geo.mirror_rect(chartview::object_rect(&geo, sh, o.image).unwrap()) + }; + let before = screen(&h); + click(&mut h, before.center()); + assert_eq!(h.state().selected_object, Some(o.image), "the picture is hit where it is drawn"); + // The logical bottom-right handle is drawn at the bottom-left; dragging it leftwards widens the picture. + let grab = before.left_bottom(); + h.input_mut().events.push(Event::PointerMoved(grab)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNeSw); + drag(&mut h, grab, grab + vec2(-20.0, 16.0)); + let after = screen(&h); + assert!((after.left() - (before.left() - 20.0)).abs() < 1.0 && (after.right() - before.right()).abs() < 1.0, "{before:?} {after:?}"); + assert!((after.bottom() - (before.bottom() + 16.0)).abs() < 1.0 && (after.top() - before.top()).abs() < 1.0, "{before:?} {after:?}"); +} diff --git a/crates/ui-egui/tests/it/show_objects.rs b/crates/ui-egui/tests/it/show_objects.rs index f5eec206d..d3eaf3e0a 100644 --- a/crates/ui-egui/tests/it/show_objects.rs +++ b/crates/ui-egui/tests/it/show_objects.rs @@ -86,11 +86,11 @@ fn hidden_objects_do_not_take_clicks() { let (mut h, id) = fixture(); let at = inside(&h); click(&mut h, at); - assert_eq!(h.state().selected_chart, Some(id), "the click lands on the rectangle while it shows"); + assert_eq!(h.state().selected_object.map(|o| o.id), Some(id), "the click lands on the rectangle while it shows"); assert_eq!(active_cell(&h), CellRef::parse("A1").unwrap()); ctrl_6(&mut h); - assert_eq!(h.state().selected_chart, None, "hiding objects drops the selection on one"); + assert_eq!(h.state().selected_object, None, "hiding objects drops the selection on one"); click(&mut h, at); - assert_eq!(h.state().selected_chart, None); + assert_eq!(h.state().selected_object, None); assert_eq!(active_cell(&h), CellRef::parse("E4").unwrap(), "the click selects the cell under the hidden rectangle"); } diff --git a/crates/ui-egui/tests/it/text_box_editing.rs b/crates/ui-egui/tests/it/text_box_editing.rs index b89a59e2e..03b9efaa8 100644 --- a/crates/ui-egui/tests/it/text_box_editing.rs +++ b/crates/ui-egui/tests/it/text_box_editing.rs @@ -102,7 +102,7 @@ fn double_click_edits_multiline_text_and_commits_on_outside_click() { let (mut h, id) = fixture(); edit_box(&mut h, id); h.check_accessibility(); - assert_eq!(h.state().selected_chart, Some(id)); + assert_eq!(h.state().selected_object.map(|o| o.id), Some(id)); let anchor = shape(&h, id).anchor; let start = shape_rect(&h, id).min + vec2(5.0, 10.0); drag(&mut h, start, start + vec2(60.0, 0.0)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3dee729d3..bbe00c51f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -58,7 +58,8 @@ connection; nothing sent after it runs. Blank lines are skipped. | `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels | | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | -| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | +| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, selected object, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | +| `ui.object.select` | `{id?, kind?}` | Selects a floating object on the active sheet (no `id` clears the selection); replies `{selected, rect}`, the rect in window points | | `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 synthetic events or 8 MiB of synthetic input queued (pointer, key, text and move events share this budget) | | `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same shared queue limits | | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB | @@ -90,6 +91,13 @@ include `window` or `doc` to run it in that window, as with `engine.execute`; th (`window.*`, `view.newWindow`, `view.hideWindow`, `view.unhideWindow`, `view.switchWindows`) read them as their own parameters. +`ui.inspect` reports the selected floating object as `selectedObject: {kind, id}` (or `null`), +where `kind` is `chart`, `image`, `shape` or `slicer`; the same names go in the `kind` parameter +of `object.delete`, `object.move`, `object.setAnchorMode` and the `arrange.*` commands. +`selectedChart` remains for existing clients: the id when the selection is a chart, otherwise +`null`. With an object selected, `ui.key {key: "Delete"}` (or Backspace) deletes the object +instead of clearing cells, and Escape clears the selection. + ### Windows Every open workbook has one or more windows; View › New Window (`view.newWindow`) adds another diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0e1a98f47..a7c1345d5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -67,7 +67,7 @@ this logic, with one undo step. | A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) | | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | -| Move and resize charts, pictures and shapes | charts can't be resized (#190) | +| Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | | Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) | @@ -194,6 +194,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection | | 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text | | 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | | 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit | From 2fce1a9b420d44ff95fea1dba7f35748ce039807 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:38:58 -0400 Subject: [PATCH 02/55] Objects: every kind follows edits; chart series follow cut-moves and sheet deletes Structural edits and sorts kept three hand-indexed lists (charts, images, shapes) in step in two places, and slicers were left out of both, so a slicer stayed put when rows were inserted above it. Chart series were rewritten for inserted and deleted lines and for sheet renames, but not for cut-moves or sheet deletes, which left them pointing at the old cells. - Inserting and deleting rows and columns and sorting walk `Sheet::object_anchors` and `object_anchors_mut`, one fixed order over every kind, so slicers now follow their cells by their anchor mode like the other objects, and a new kind cannot be forgotten in one of the passes. - `rewrite_cell_links` is the one place that rewrites the references floating objects hold (chart series values, categories and names, and the source block a chart was made from). `rewrite_all_formulas` calls it, which covers inserting and deleting lines and cells and cut-moves on one sheet or across sheets; renaming and deleting a sheet call it too, so a series into a deleted sheet becomes #REF!, as a formula does. - Copying a sheet gives its charts, pictures, shapes and slicers fresh ids, keeping ids unique across the workbook, which object selection and the `{kind, id}` commands rely on. Tests: every kind through row and column inserts and deletes, an absolute shape, a sort and undo; a slicer through inserted rows; chart series through a cut-move, a sheet rename, a sheet delete and undo; ids after a sheet copy. --- ROADMAP.md | 2 +- crates/engine/src/cmd/data/sort.rs | 38 +++------ crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/edit/paste_special.rs | 28 +++++++ crates/engine/src/cmd/sheet.rs | 44 +++-------- crates/engine/src/cmd/sheet/tabs.rs | 25 +++--- crates/engine/src/tests/objects.rs | 87 +++++++++++++++++++++ 7 files changed, 155 insertions(+), 71 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 43af2299b..8afe566c6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,7 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| -| 2026-10-31 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) | +| 2026-10-31 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) | | 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 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | | 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`) | diff --git a/crates/engine/src/cmd/data/sort.rs b/crates/engine/src/cmd/data/sort.rs index a36527650..d335a7695 100644 --- a/crates/engine/src/cmd/data/sort.rs +++ b/crates/engine/src/cmd/data/sort.rs @@ -195,8 +195,7 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec = - sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect(); + let objs: Vec = sh.object_anchors().copied().collect(); let descs: Vec<(u32, u32, f32, f32)> = objs .iter() .map(|a| { @@ -242,33 +241,22 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec o1 { - continue; - } - let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of); - if na != a { - if i < ni { - if let Some(o) = shm.images.get_mut(i) { - o.anchor = na; - } - } else if i < ni + nch { - if let Some(o) = shm.charts.get_mut(i - ni) { - o.anchor = na; - } - } else if let Some(o) = shm.shapes.get_mut(i - ni - nch) { - o.anchor = na; - } - } + let new_anchors: Vec = objs + .into_iter() + .zip(descs) + .map(|(a, desc)| { + let other = if by_cols { a.cell.row } else { a.cell.col }; + if other < o0 || other > o1 { a } else { sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of) } + }) + .collect(); + for (a, n) in shm.object_anchors_mut().zip(new_anchors) { + *a = n; } for (c, _) in moved { cx.changed.push((sheet, c)); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 27e217474..4b5c98a8b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -35,7 +35,7 @@ use go_to::go_to_special; #[cfg(test)] use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts}; use paste_special::paste_special; -pub(crate) use paste_special::rewrite_all_formulas; +pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links}; use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index 0a6d14247..6c3f6baa4 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -355,4 +355,32 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: & n.formula = gridcraft_formula::print(&new); } } + rewrite_cell_links(wb, |host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e)); +} + +/// Runs `f` over every reference a floating object holds (a chart's series values, categories +/// and name, and the source block it was made from). Every edit that moves references calls it: +/// inserting and deleting lines and cells and cut-moves (through [`rewrite_all_formulas`]), and +/// renaming and deleting sheets. `f` maps a reference held by an object on the sheet named `host` +/// to its new form; text that does not parse is kept. A source block that no longer exists is +/// dropped. +pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) { + for si in 0..wb.sheets.len() { + let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue }; + let Some(sh) = wb.sheet_mut(si) else { continue }; + let fix = |text: &str| -> String { + match gridcraft_formula::parse(text) { + Ok(expr) => gridcraft_formula::print(&f(&host, expr)), + Err(_) => text.to_string(), + } + }; + for ch in sh.charts.iter_mut() { + ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!")); + for se in ch.series.iter_mut() { + se.values = fix(&se.values); + se.categories = se.categories.as_deref().map(fix); + se.name = se.name.as_deref().map(fix); + } + } + } } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 32122abd6..fa9c915c7 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -141,14 +141,7 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: }; // Floating objects: snapshot their bottom-right corners against the pre-shift geometry, so a // re-fit after the shift measures against the grid the object actually lands on. - let obj_corners: Vec<(u32, u32, f32, f32)> = sh - .charts - .iter() - .map(|o| &o.anchor) - .chain(sh.images.iter().map(|o| &o.anchor)) - .chain(sh.shapes.iter().map(|o| &o.anchor)) - .map(|a| anchor_corner(sh, a)) - .collect(); + let obj_corners: Vec<(u32, u32, f32, f32)> = sh.object_anchors().map(|a| anchor_corner(sh, a)).collect(); // Line info. let lines = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols }; let old = std::mem::take(lines); @@ -193,22 +186,13 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect(); sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); - // Floating objects follow their anchor lines per their [`AnchorMode`]. Corners were snapshotted - // as charts ++ images ++ shapes, matching the three slices below. New anchors are computed - // against the post-shift grid first (the re-fit needs its sizes), then written back. - let anchored = |ui: usize, a: Anchor| -> Anchor { obj_corners.get(ui).map_or(a, |&corner| shift_anchor(sh, a, corner, axis, at, &map_line)) }; - let new_charts: Vec = sh.charts.iter().enumerate().map(|(i, o)| anchored(i, o.anchor)).collect(); - let new_images: Vec = sh.images.iter().enumerate().map(|(i, o)| anchored(new_charts.len() + i, o.anchor)).collect(); - let base = new_charts.len() + new_images.len(); - let new_shapes: Vec = sh.shapes.iter().enumerate().map(|(i, o)| anchored(base + i, o.anchor)).collect(); - for (o, a) in sh.charts.iter_mut().zip(new_charts) { - o.anchor = a; - } - for (o, a) in sh.images.iter_mut().zip(new_images) { - o.anchor = a; - } - for (o, a) in sh.shapes.iter_mut().zip(new_shapes) { - o.anchor = a; + // Floating objects follow their anchor lines per their [`AnchorMode`]. New anchors are + // computed against the post-shift grid first (the re-fit needs its sizes), then written back + // in the same `object_anchors` order the corners were snapshotted in. + let new_anchors: Vec = + sh.object_anchors().zip(&obj_corners).map(|(a, &corner)| shift_anchor(sh, *a, corner, axis, at, &map_line)).collect(); + for (a, n) in sh.object_anchors_mut().zip(new_anchors) { + *a = n; } sh.sparklines.retain_mut(|sp| match map_cell(sp.cell) { Some(c) => { @@ -231,7 +215,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: } } -/// Rewrites every formula reference (and chart series) for a structural edit on `target`. +/// Rewrites every formula reference (chart series included), sparkline source and PivotTable +/// source for a structural edit on `target`. pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) { cx.edits.push((target.to_string(), e.clone())); let wb = &mut cx.wb; @@ -245,15 +230,6 @@ pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) { Err(_) => text.to_string(), } }; - for ch in sh.charts.iter_mut() { - // The source block moves with its cells; one that was deleted is dropped. - ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!")); - for se in ch.series.iter_mut() { - se.values = fix(&se.values); - se.categories = se.categories.as_deref().map(fix); - se.name = se.name.as_deref().map(fix); - } - } for sp in sh.sparklines.iter_mut() { sp.source = fix(&sp.source); } diff --git a/crates/engine/src/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index 13956e35c..2f87294bb 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -63,6 +63,8 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result { } } } + // Chart series into the deleted sheet become #REF!, like formulas. + crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order)); cx.wb.names.retain(|n| n.scope != Some(i)); for n in cx.wb.names.iter_mut() { if let Some(sc) = n.scope.as_mut() @@ -116,17 +118,7 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result { } } } - let fix = |t: &str| { - gridcraft_formula::parse(t) - .map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name))) - .unwrap_or_else(|_| t.to_string()) - }; for ch in sh.charts.iter_mut() { - for se in ch.series.iter_mut() { - se.values = fix(&se.values); - se.categories = se.categories.as_deref().map(fix); - se.name = se.name.as_deref().map(fix); - } // A PivotChart follows its PivotTable's sheet. if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link) && sheet.eq_ignore_ascii_case(&old) @@ -135,6 +127,7 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result { } } } + crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::rename_sheet(e, &old, &name)); for n in cx.wb.names.iter_mut() { if let Ok(e) = gridcraft_formula::parse_name(&n.formula) { n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name)); @@ -170,6 +163,18 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result { sh.name = name; sh.tables.clear(); // table names must be unique sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original + // Object ids are unique across the workbook (selection and the `{kind, id}` commands + // rely on it), so the copied objects get fresh ones. + let mut next = cx.wb.next_object_id(); + let mut fresh = || { + let id = next; + next = next.saturating_add(1); + id + }; + sh.charts.iter_mut().for_each(|o| o.id = fresh()); + sh.images.iter_mut().for_each(|o| o.id = fresh()); + sh.shapes.iter_mut().for_each(|o| o.id = fresh()); + sh.slicers.iter_mut().for_each(|o| o.id = fresh()); let at = to.min(n); // The copy gets its own copies of the sheet's names, referring to the copy. let copies: Vec = cx diff --git a/crates/engine/src/tests/objects.rs b/crates/engine/src/tests/objects.rs index ea4dfb0e8..d0c2eba44 100644 --- a/crates/engine/src/tests/objects.rs +++ b/crates/engine/src/tests/objects.rs @@ -103,3 +103,90 @@ fn hostile_object_params_are_refused() { let a = sheet(&s, 0).object_anchor(o).unwrap(); assert!(a.dx.is_finite() && a.dy.is_finite() && a.width <= 10000.0 && a.height >= 0.0, "{a:?}"); } + +fn cell_of(s: &Session, o: ObjRef) -> String { + sheet(s, 0).object_anchor(o).unwrap().cell.a1() +} + +#[test] +fn every_kind_follows_row_and_column_edits_and_sorts() { + let mut s = s(); + let objs = every_kind(&mut s); + // Anchors: chart D2, picture D18, shape H18, slicer L2. + s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap(); + let cells = |s: &Session| objs.map(|o| cell_of(s, o)); + assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]); + s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap(); + assert_eq!(cells(&s), ["D2", "D20", "H18", "L2"], "rows inserted above move the objects below them; an absolute shape stays"); + s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap(); + assert_eq!(cells(&s), ["E2", "E20", "H18", "M2"]); + s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap(); + s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap(); + assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]); + // Sorting moves a picture anchored inside the sorted block with its row. + s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap(); + let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize)); + s.execute("selection.set", json!({"range": "T1:T4"})).unwrap(); + s.execute("data.sortAscending", json!({"header": true, "column": "T"})).unwrap(); + assert_eq!(cell_of(&s, pic), "T4", "the row holding 3 sorts last"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(cell_of(&s, pic), "T2"); +} + +#[test] +fn slicers_follow_inserted_rows_like_other_objects() { + let mut s = s(); + let objs = every_kind(&mut s); + s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap(); + assert_eq!(cell_of(&s, objs[3]), "L4"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(cell_of(&s, objs[3]), "L2"); +} + +#[test] +fn chart_series_follow_cut_moves_renames_and_sheet_deletes() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[3], [5], [2], [8]]})).unwrap(); + let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:A4", "type": "column"})).unwrap(), "chart"); + let values = |s: &Session| sheet(s, 0).charts.iter().find(|c| c.id == chart).map(|c| c.series[0].values.clone()).unwrap_or_default(); + s.execute("edit.cut", json!({"range": "A1:A4"})).unwrap(); + s.execute("edit.paste", json!({"at": "F2"})).unwrap(); + assert!(values(&s).contains("$F$2:$F$5"), "a cut-move takes the series with the cells: {}", values(&s)); + s.execute("home.insertSheet", json!({"name": "Other"})).unwrap(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().charts[0].series[0].values = "Other!$A$1:$A$3".into(); + s.execute("sheet.rename", json!({"sheet": 1, "name": "Inputs"})).unwrap(); + assert_eq!(values(&s), "Inputs!$A$1:$A$3"); + s.execute("home.deleteSheet", json!({"sheet": 1})).unwrap(); + assert!(values(&s).contains("#REF!"), "a series into a deleted sheet becomes #REF!: {}", values(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(values(&s), "Inputs!$A$1:$A$3"); +} + +#[test] +fn a_copied_sheet_gives_its_objects_fresh_ids() { + let mut s = s(); + let objs = every_kind(&mut s); + s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap(); + let wb = &s.doc().unwrap().wb; + let ids_of = |sh: &Sheet| -> Vec { + sh.charts + .iter() + .map(|c| c.id) + .chain(sh.images.iter().map(|c| c.id)) + .chain(sh.shapes.iter().map(|c| c.id)) + .chain(sh.slicers.iter().map(|c| c.id)) + .collect() + }; + let mut ids: Vec = wb.sheets.iter().flat_map(|sh| ids_of(sh)).collect(); + assert_eq!(ids.len(), 2 * objs.len()); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), 2 * objs.len(), "every object id stays unique across the workbook"); + let copy = sheet(&s, 1); + for (o, id) in objs.iter().zip(ids_of(copy)) { + assert_eq!(copy.object_ref(id).map(|c| c.kind), Some(o.kind)); + } + assert!(s.doc().unwrap().wb.next_object_id() > *ids.last().unwrap_or(&0)); +} From de8f1854f136f5bf285e2eb3facbe1b855dcfdab Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:52:42 -0400 Subject: [PATCH 03/55] Objects: bubble sizes follow edits like the other series references `rewrite_cell_links` rewrote a chart series' values, categories and name but not its bubble sizes, so inserting or deleting lines, a cut-move or a sheet rename left a bubble chart's sizes pointing at the old cells. They are now rewritten with the rest. Test: bubble sizes through an inserted row, a cut-move and a sheet rename. --- crates/engine/src/cmd/edit/paste_special.rs | 13 +++++++------ crates/engine/src/tests/objects.rs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index 6c3f6baa4..c9e296ba5 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -358,12 +358,12 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: & rewrite_cell_links(wb, |host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e)); } -/// Runs `f` over every reference a floating object holds (a chart's series values, categories -/// and name, and the source block it was made from). Every edit that moves references calls it: -/// inserting and deleting lines and cells and cut-moves (through [`rewrite_all_formulas`]), and -/// renaming and deleting sheets. `f` maps a reference held by an object on the sheet named `host` -/// to its new form; text that does not parse is kept. A source block that no longer exists is -/// dropped. +/// Runs `f` over every reference a floating object holds (a chart's series values, categories, +/// name and bubble sizes, and the source block it was made from). Every edit that moves +/// references calls it: inserting and deleting lines and cells and cut-moves (through +/// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an +/// object on the sheet named `host` to its new form; text that does not parse is kept. A source +/// block that no longer exists is dropped. pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) { for si in 0..wb.sheets.len() { let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue }; @@ -380,6 +380,7 @@ pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn( se.values = fix(&se.values); se.categories = se.categories.as_deref().map(fix); se.name = se.name.as_deref().map(fix); + se.bubble_sizes = se.bubble_sizes.as_deref().map(fix); } } } diff --git a/crates/engine/src/tests/objects.rs b/crates/engine/src/tests/objects.rs index d0c2eba44..7082b9054 100644 --- a/crates/engine/src/tests/objects.rs +++ b/crates/engine/src/tests/objects.rs @@ -190,3 +190,20 @@ fn a_copied_sheet_gives_its_objects_fresh_ids() { } assert!(s.doc().unwrap().wb.next_object_id() > *ids.last().unwrap_or(&0)); } + +#[test] +fn bubble_sizes_follow_edits_like_the_other_series_references() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 3, 10], [2, 5, 20], [3, 2, 30]]})).unwrap(); + let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:B3", "type": "bubble"})).unwrap(), "chart"); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().charts.iter_mut().find(|c| c.id == chart).unwrap().series[0] + .bubble_sizes = Some("Sheet1!$C$1:$C$3".into()); + let sizes = |s: &Session| sheet(s, 0).charts.iter().find(|c| c.id == chart).and_then(|c| c.series[0].bubble_sizes.clone()).unwrap_or_default(); + s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); + assert_eq!(sizes(&s), "Sheet1!$C$2:$C$4", "an inserted row moves the bubble sizes with their cells"); + s.execute("edit.cut", json!({"range": "C2:C4"})).unwrap(); + s.execute("edit.paste", json!({"at": "F2"})).unwrap(); + assert_eq!(sizes(&s), "Sheet1!$F$2:$F$4", "a cut-move takes the bubble sizes with the cells"); + s.execute("sheet.rename", json!({"sheet": 0, "name": "Data"})).unwrap(); + assert_eq!(sizes(&s), "Data!$F$2:$F$4", "a renamed sheet is renamed in the bubble sizes"); +} From 028ce2b4179d987dedf9f4ec2016485280d6b1f8 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:02:30 -0400 Subject: [PATCH 04/55] Objects: a resize handle under the headers leaves the press to them A selected object's handles reach a few points outside it, and a press was matched against them before any check of where it landed, so a press on the column or row header beside an object at the sheet's edge resized the object instead of selecting the column or row. The cursor already ignored handles outside the cells. `chartview::selected_handle` finds the selected object's handle under a point only over the cell area, as `hit` does, and not while objects are hidden; the press and the cursor both use it. Test: presses within a handle's reach over the column header and the row header neither move nor resize the object. --- crates/ui-egui/src/chartview.rs | 13 +++++++++++++ crates/ui-egui/src/grid/cursor.rs | 6 +----- crates/ui-egui/src/grid/interact.rs | 5 +---- crates/ui-egui/tests/it/objects.rs | 24 ++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 36604c5b7..efa19d3d7 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -505,6 +505,19 @@ pub fn object_rect(geo: &Geo, sh: &Sheet, o: ObjRef) -> Option { sh.object_anchor(o).map(|a| anchor_rect(geo, sh, &a)) } +/// The resize handle of the selected object under `p`, with the object and its rect. Handles +/// reach a little outside their object, so they win over [`hit`], but like it only over the cell +/// area: a handle that lies under the headers, the formula bar or a scroll bar leaves the press +/// to them. Nothing while objects are hidden. +pub fn selected_handle(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect, crate::objects::Handle)> { + if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) { + return None; + } + let o = app.selected_object?; + let r = object_rect(geo, sh, o)?; + crate::objects::handle_at(r, p).map(|h| (o, r, h)) +} + /// The topmost object under `p` and its rect: slicers paint on top, so they are hit first, then /// charts, pictures and shapes, each from the top of its stack. Nothing is hit outside the cell /// area or while objects are hidden. diff --git a/crates/ui-egui/src/grid/cursor.rs b/crates/ui-egui/src/grid/cursor.rs index e15c4800a..f93e59cb6 100644 --- a/crates/ui-egui/src/grid/cursor.rs +++ b/crates/ui-egui/src/grid/cursor.rs @@ -78,11 +78,7 @@ fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, poin let on_cur_border = on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); // The selected object's handles resize it; any object's body moves it. let idle = app.grid.drag == Drag::None && app.editor.is_none(); - let object_handle = if idle && in_cells { - app.selected_object.and_then(|o| crate::chartview::object_rect(geo, sh, o)).and_then(|r| crate::objects::handle_at(r, p)) - } else { - None - }; + let object_handle = if idle { crate::chartview::selected_handle(app, geo, sh, p).map(|(.., h)| h) } else { None }; let on_object = idle && crate::chartview::hit(app, geo, sh, p).is_some(); if let Some(h) = object_handle { ctx.set_cursor_icon(h.cursor(geo.rtl)); diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 5b8eade31..84d8dbc33 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -124,10 +124,7 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo // A drag is recognised only after the pointer has moved, so grab the object where the press began. let at = if primary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos }; // The selected object's handles reach a little outside it, so they win over hit-testing. - let on_handle = app.selected_object.and_then(|o| { - let r = crate::chartview::object_rect(geo, sh, o)?; - at.and_then(|p| crate::objects::handle_at(r, p)).map(|_| (o, r)) - }); + let on_handle = at.and_then(|p| crate::chartview::selected_handle(app, geo, sh, p)).map(|(o, r, _)| (o, r)); if let Some(p) = at && let Some((obj, rect)) = on_handle.or_else(|| crate::chartview::hit(app, geo, sh, p)) { diff --git a/crates/ui-egui/tests/it/objects.rs b/crates/ui-egui/tests/it/objects.rs index 733a1427a..b9912559c 100644 --- a/crates/ui-egui/tests/it/objects.rs +++ b/crates/ui-egui/tests/it/objects.rs @@ -300,3 +300,27 @@ fn on_a_right_to_left_sheet_the_mirrored_handles_resize() { assert!((after.left() - (before.left() - 20.0)).abs() < 1.0 && (after.right() - before.right()).abs() < 1.0, "{before:?} {after:?}"); assert!((after.bottom() - (before.bottom() + 16.0)).abs() < 1.0 && (after.top() - before.top()).abs() < 1.0, "{before:?} {after:?}"); } + +#[test] +fn a_handle_under_the_headers_does_not_take_the_press() { + let (mut h, o) = fixture(); + h.state_mut().run("object.move", json!({"kind": "shape", "id": o.shape.id, "at": "A1", "dx": 0, "dy": 0})).unwrap(); + h.run_steps(2); + let before = rect(&h, o.shape); + click(&mut h, before.center()); + assert_eq!(h.state().selected_object, Some(o.shape)); + let cells = h.state().grid.cells_rect.unwrap(); + assert_eq!(before.left_top(), cells.left_top(), "the shape's top-left handle sits on the header corner"); + // Within the top-left handle's reach, but over the column header: the press is the header's. + let grab = Handle::TopLeft.pos(before) + vec2(4.0, -4.0); + assert!(grab.y < cells.top()); + drag(&mut h, grab, grab + vec2(30.0, 40.0)); + assert_eq!(rect(&h, o.shape), before, "the shape is neither moved nor resized"); + assert_eq!(h.state().selected_object, None, "the press went to the column header"); + // Within the left handle's reach, but over the row header. + let grab = Handle::Left.pos(before) + vec2(-4.0, 0.0); + assert!(grab.x < cells.left()); + h.state_mut().selected_object = Some(o.shape); + drag(&mut h, grab, grab + vec2(40.0, 0.0)); + assert_eq!(rect(&h, o.shape), before, "the shape is neither moved nor resized"); +} From 4b805d64bc0975413d8289d7f2a9111795541c0d Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:19:13 -0400 Subject: [PATCH 05/55] Objects: each drag of an object is its own undo step A drag merges its frames into one undo step by dropping a new step whose label matches the one before it. Its first frame did the same, so a second drag of an object (a move after a move, or a resize after a move) merged into the first drag's "Move Object" step, and one undo took back both. `Drag::Object` records whether the drag has made its step; only the later frames of the same drag merge into it. Test: a move and then a resize of a picture take two undos to reverse, the first restoring the moved picture. --- crates/ui-egui/src/grid.rs | 3 +++ crates/ui-egui/src/grid/interact.rs | 16 ++++++++++++---- crates/ui-egui/tests/it/objects.rs | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 0e20d7e0b..85ff53b07 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -137,6 +137,9 @@ pub enum Drag { start: Pos2, orig: (CellRef, f32, f32, f32, f32), handle: Option, + /// This drag has made its undo step; later frames merge into it, and never into the + /// step of an earlier drag. + stepped: bool, }, } diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 84d8dbc33..a7bbede7e 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -134,7 +134,7 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo } if primary_drag && let Some(a) = sh.object_anchor(obj) { let handle = crate::objects::handle_at(rect, p); - app.grid.drag = Drag::Object { obj, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), handle }; + app.grid.drag = Drag::Object { obj, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), handle, stepped: false }; // The first recognised drag frame already holds movement away from the press. if let Some(now) = pos { drag_object(app, geo, sh, now); @@ -956,9 +956,9 @@ pub(super) fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option { } /// Moves the dragged object, or resizes it from the grabbed handle, to follow the pointer at `p` -/// (logical coordinates). The whole drag coalesces into one undo step. +/// (logical coordinates). The whole drag coalesces into one undo step of its own. fn drag_object(app: &mut SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) { - let Drag::Object { obj, start, orig, handle } = app.grid.drag.clone() else { return }; + let Drag::Object { obj, start, orig, handle, stepped } = app.grid.drag.clone() else { return }; let d = (p - start) / geo.z; let (cell, dx, dy, w, h) = orig; let x0 = sh.col_left(cell.col) as f32 + dx; @@ -977,8 +977,16 @@ fn drag_object(app: &mut SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) { "dx": x0 - sh.col_left(nc.col) as f32, "dy": y0 - sh.row_top(nc.row) as f32, "width": x1 - x0, "height": y1 - y0, }); + let revision = app.session.active().map(|d| d.revision); let _ = app.session.run("object.move", params); - coalesce_last_undo(app); + if stepped { + coalesce_last_undo(app); + } else if app.session.active().map(|d| d.revision) != revision + && let Drag::Object { stepped, .. } = &mut app.grid.drag + { + // The first step of this drag stays apart from an earlier drag's step of the same name. + *stepped = true; + } } /// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's diff --git a/crates/ui-egui/tests/it/objects.rs b/crates/ui-egui/tests/it/objects.rs index b9912559c..521a2f44c 100644 --- a/crates/ui-egui/tests/it/objects.rs +++ b/crates/ui-egui/tests/it/objects.rs @@ -324,3 +324,23 @@ fn a_handle_under_the_headers_does_not_take_the_press() { drag(&mut h, grab, grab + vec2(40.0, 0.0)); assert_eq!(rect(&h, o.shape), before, "the shape is neither moved nor resized"); } + +#[test] +fn each_drag_of_an_object_is_its_own_undo_step() { + let (mut h, o) = fixture(); + let start = rect(&h, o.image); + click(&mut h, start.center()); + drag(&mut h, start.center(), start.center() + vec2(30.0, 20.0)); + let moved = rect(&h, o.image); + assert!((moved.min - start.min - vec2(30.0, 20.0)).length() < 1.0); + let grab = Handle::BottomRight.pos(moved); + drag(&mut h, grab, grab + vec2(24.0, 18.0)); + let resized = rect(&h, o.image); + assert!((resized.size() - moved.size() - vec2(24.0, 18.0)).length() < 1.0); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(rect(&h, o.image), moved, "one undo takes back the resize only"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(rect(&h, o.image), start, "a second undo takes back the move"); +} From 977c895378b7fad28ad5fd376d6f342aa52ed675 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:21:55 -0400 Subject: [PATCH 06/55] Objects: the parity and gap docs no longer list chart resizing as missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gaps.md (§4), target-app-parity.md (the Essentials tier's work, the Mainstream chart row, the interaction discount and the Essentials chart task) and chart-parity.md (the element table and the work list) still described charts as impossible to resize (#190). Charts move and resize from eight handles like every floating object, so those statements now say so; the 2026-10-10 revision rows and the 10-10 re-check stay as history. Scores and the 40-60 h chart estimate are unchanged (resizing was 1-2 h, within the rounding). --- docs/chart-parity.md | 18 +++++++++--------- docs/gaps.md | 7 ++++--- docs/target-app-parity.md | 11 ++++++----- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 89358dd30..9bc230e00 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-29 · **Change:** minor (selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-31 · **Change:** minor (charts resize from eight handles; earlier: selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's chart types and chart elements against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated). @@ -37,7 +37,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | Element | Excel | GridCraft | |---|---|---| -| Move and resize the chart object | yes | yes (corner handle, Format Chart Area Size fields) | +| Move and resize the chart object | yes | yes (eight handles, each moving only its own edges; Format Chart Area Size fields) (#190) | | Selected chart source-data outline | visible on uncovered cells, beneath floating objects | painted above cells and below charts, shapes, pictures and slicers; retains pane clipping and right-to-left cell geometry (#2861) | | Chart title, axis titles | text, linked to cells, full formatting | text only | | Legend | position, overlay, formatting, per-entry delete | position only | @@ -79,18 +79,18 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690). ## Work (40–60 h) -1. Chart resizing (#190): 1–2 h. -2. Preserve unmodelled chart XML on round-trip (keep the original part, patch only edited fields): 6–10 h. -3. Axis options, label options, gridlines per axis, legend formatting: 10–15 h. -4. Error bars; Format Trendline pane (trendlines landed): 4–6 h. -5. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h. -6. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h. -7. Chart sheets, templates and PivotChart field buttons: 5–8 h. Filled map: owner decision on boundary data. +1. Preserve unmodelled chart XML on round-trip (keep the original part, patch only edited fields): 6–10 h. +2. Axis options, label options, gridlines per axis, legend formatting: 10–15 h. +3. Error bars; Format Trendline pane (trendlines landed): 4–6 h. +4. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h. +5. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h. +6. Chart sheets, templates and PivotChart field buttons: 5–8 h. Filled map: owner decision on boundary data. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Charts move and resize from eight handles, each moving only its own edges (#190); resizing leaves the work list (1–2 h, within the 40–60 h rounding) | | 2026-10-29 | minor | Selected charts highlight their source cells beneath floating objects, so the blue outline no longer crosses the chart; uncovered source cells remain highlighted (#2861) | | 2026-10-24 | minor | 100% Stacked Area: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` | | 2026-10-18 | trivial | Stale notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip (PRs #158, #161) | diff --git a/docs/gaps.md b/docs/gaps.md index 97299acda..ae3759269 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (§8: PivotTable Enable show details; earlier: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (§4: chart resizing is no longer a gap; earlier: §8: PivotTable Enable show details; earlier: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -45,9 +45,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 4. Charts lose options on round trip; shallow chart model (F, FF) - **Missing:** axis scaling, label options, error bars, per-point formatting. - **Evidence:** `Chart` holds only title, legend position, two flags and axis titles - (`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn and written back. + (`crates/model/src/features.rs`); PRs #158, #161 open; charts resize from eight handles (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn and written back. (`crates/model/src/features.rs`); all 2-D kinds survive XLSX round trip (#158, #161); charts - can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, + resize from eight handles (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. - **Impact:** charts are in most business workbooks. - **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md) @@ -183,6 +183,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | §4: charts, like every floating object, move and resize from eight handles (#190), so resizing is no longer listed as missing | | 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` | | 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) | | 2026-10-22 | minor | §20: a native window per workbook window, New Window on the same workbook and Arrange All on the desktop; left: Arrange All across monitors, the web's one window, pen pressure (estimate 10–14 h → 5–8 h) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7cee60f60..9c76d7a4a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (chart resizing no longer listed as missing; earlier: automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -29,7 +29,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). |---|---|---|---| | Full target (ready for real work) | ~50% | ~650–1,050 | Get & Transform and the data model, localization (twelve languages), charts depth, file fidelity (`.xls`, corpus, pass-through), PivotTables, collaboration, mobile | | Mainstream practitioner | ~50% | ~200–310 | File exchange with Excel users (corpus and Excel verification, pass-through, large files, `.xls`, encryption: 63–97 h), common-chart depth (30–45 h), PivotTables (20–30 h), keyboard and interaction (18–28 h), calc correctness and functions (20–33 h) | -| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` import and saving with a password 24–36 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) | +| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` import and saving with a password 24–36 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart polish (5–8 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) | Each tier is a subset of the one above, so its hours are too. Calibrated as in [Effort and calibration](#effort-and-calibration) (~6–10 h per readiness point for the remaining, verification-heavy @@ -134,7 +134,7 @@ functions, co-authoring and admin, specialist hardware, and languages beyond the | Cell formatting, styles, conditional formatting | 13 | 80 | Feature-area table | | Sort, filter, tables, validation | 10 | 70 | Feature-area table | | PivotTables (core: fields, layouts, grouping, refresh) | 8 | 60 | Slicers and calculated fields excluded as less frequent | -| Charts (column, bar, line, pie, scatter, combo) | 9 | 45 | Common kinds work and survive save; no axis options or trendlines; resize broken (#190) | +| Charts (column, bar, line, pie, scatter, combo) | 9 | 45 | Common kinds work and survive save; no axis options or trendlines; moved and resized from eight handles (#190) | | Page layout, print, PDF | 5 | 60 | Latin text exports | | Comments, notes, protection | 3 | 55 | Feature-area table | | Pictures and shapes | 2 | 45 | Feature-area table | @@ -144,7 +144,7 @@ Discounts for what still stops real work: | Discount | Factor | Evidence | |---|---|---| -| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); charts can't be resized (#190); missing horizontal scrollbar report (#176) | +| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); missing horizontal scrollbar report (#176) | | Stability on real machines | × 0.92 | Never-crash lints, fuzzed commands and the `Session::execute` guard; but field reports of a startup crash on Intel UHD (#120), an error on close (#117), a freeze opening from Finder (#166) and screen flashing (#76) | | File exchange with Excel users | × 0.88 | Excel opening our output is unverified across features; unmodelled parts dropped on save; advanced chart kinds lose their type; 100 MB files fail (#175); `.xls` and encrypted files are refused with a clear message (#17) rather than opened. Harsher than a drawing app's discount because spreadsheets are exchanged constantly | @@ -167,7 +167,7 @@ edge cases are left out, along with everything excluded from the mainstream numb | Rows and columns: insert, delete, widths, AutoFit | 10 | 82 | AutoFit fixed (#42, #88); rows auto-fit oversized fonts on open and row formatting (#833) | | Sort and filter | 10 | 82 | | | Save, Save As, reopen XLSX | 10 | 85 | | -| A basic chart | 8 | 55 | Created easily; can't be resized (#190) | +| A basic chart | 8 | 55 | Created easily; moved and resized from eight handles (#190) | | Print and PDF | 7 | 65 | Desktop works; web PDF gives no file or feedback (#141) | | Find and replace | 5 | 85 | | | **Average depth** | 100 | **80.2** | | @@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Charts move and resize from eight handles like every floating object (#190): the Essentials chart work is polish only (5–8 h), and the chart rows and the interaction discount no longer list resizing as missing (no score change) | | 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) (no score change) | | 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) | | 2026-10-22 | minor | Several windows on the desktop: a native window per workbook window, New Window on the same workbook, Switch Windows and Arrange All; "one window" removed from UI/UX fidelity, Hardware and the interaction-fidelity discount; no score change | From f3fbaa5568fb6169e3ab1d1ed3b2ad68fa367b43 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:22:01 -0400 Subject: [PATCH 07/55] Objects: one chart evidence paragraph in gaps.md; trendlines are not missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gaps.md §4 carried two evidence paragraphs, an older one saying PRs #158 and #161 were open and a newer one saying all 2-D kinds survive XLSX round trip, both repeating the resize, smooth-line, Switch Row/Column and trendline statements. It is one paragraph with the current status again. The Mainstream chart row in target-app-parity.md listed trendlines as missing; they are read, written and drawn (`Series::trendlines`, the XLSX chart reader and writer, `chart.addElement`), as chart-parity.md records. The row now says so and keeps "no axis options". No score change. --- docs/gaps.md | 8 ++++---- docs/target-app-parity.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index ae3759269..76e0f8298 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -45,10 +45,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 4. Charts lose options on round trip; shallow chart model (F, FF) - **Missing:** axis scaling, label options, error bars, per-point formatting. - **Evidence:** `Chart` holds only title, legend position, two flags and axis titles - (`crates/model/src/features.rs`); PRs #158, #161 open; charts resize from eight handles (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn and written back. (`crates/model/src/features.rs`); all 2-D kinds survive XLSX round trip (#158, #161); charts - resize from eight handles (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, - written, drawn) landed. + resize from eight handles (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines + (read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn + and written back. - **Impact:** charts are in most business workbooks. - **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md) @@ -183,7 +183,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | §4: charts, like every floating object, move and resize from eight handles (#190), so resizing is no longer listed as missing | +| 2026-10-31 | minor | §4: charts, like every floating object, move and resize from eight handles (#190), so resizing is no longer listed as missing; the evidence is one paragraph again, without the stale "PRs #158, #161 open" | | 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` | | 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) | | 2026-10-22 | minor | §20: a native window per workbook window, New Window on the same workbook and Arrange All on the desktop; left: Arrange All across monitors, the web's one window, pen pressure (estimate 10–14 h → 5–8 h) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c76d7a4a..b5a63ea36 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -134,7 +134,7 @@ functions, co-authoring and admin, specialist hardware, and languages beyond the | Cell formatting, styles, conditional formatting | 13 | 80 | Feature-area table | | Sort, filter, tables, validation | 10 | 70 | Feature-area table | | PivotTables (core: fields, layouts, grouping, refresh) | 8 | 60 | Slicers and calculated fields excluded as less frequent | -| Charts (column, bar, line, pie, scatter, combo) | 9 | 45 | Common kinds work and survive save; no axis options or trendlines; moved and resized from eight handles (#190) | +| Charts (column, bar, line, pie, scatter, combo) | 9 | 45 | Common kinds work and survive save; trendlines read, written and drawn; no axis options; moved and resized from eight handles (#190) | | Page layout, print, PDF | 5 | 60 | Latin text exports | | Comments, notes, protection | 3 | 55 | Feature-area table | | Pictures and shapes | 2 | 45 | Feature-area table | @@ -334,7 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | Charts move and resize from eight handles like every floating object (#190): the Essentials chart work is polish only (5–8 h), and the chart rows and the interaction discount no longer list resizing as missing (no score change) | +| 2026-10-31 | minor | Charts move and resize from eight handles like every floating object (#190): the Essentials chart work is polish only (5–8 h), and the chart rows and the interaction discount no longer list resizing as missing; the Mainstream chart row no longer lists trendlines as missing (no score change) | | 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) (no score change) | | 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) | | 2026-10-22 | minor | Several windows on the desktop: a native window per workbook window, New Window on the same workbook, Switch Windows and Arrange All; "one window" removed from UI/UX fidelity, Hardware and the interaction-fidelity discount; no score change | From 548e5345eec232a0473428b86d02835104a7b1a2 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:53:46 -0400 Subject: [PATCH 08/55] Objects: only the selected object's handles resize; a press elsewhere on an object moves it A primary drag looked for a handle under the press on whatever object it grabbed, so pressing near the edge or corner of an object that was not selected started a resize, while the pointer over that spot showed the Move cursor (handles are only shown, and only offered by the cursor, on the selected object). The press now takes its handle from `chartview::selected_handle`, the same lookup the cursor uses; a press on any other object selects and moves it. Test: dragging an unselected shape by its bottom-right corner shows the Move cursor, then selects and moves the shape without resizing it. --- crates/ui-egui/src/grid/interact.rs | 8 +++++--- crates/ui-egui/tests/it/objects.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index a7bbede7e..4b7adf587 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -124,16 +124,18 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo // A drag is recognised only after the pointer has moved, so grab the object where the press began. let at = if primary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos }; // The selected object's handles reach a little outside it, so they win over hit-testing. - let on_handle = at.and_then(|p| crate::chartview::selected_handle(app, geo, sh, p)).map(|(o, r, _)| (o, r)); + // Only they resize: a press anywhere on another object selects and moves it, as the + // hover cursor shows. + let on_handle = at.and_then(|p| crate::chartview::selected_handle(app, geo, sh, p)); + let handle = on_handle.map(|(.., h)| h); if let Some(p) = at - && let Some((obj, rect)) = on_handle.or_else(|| crate::chartview::hit(app, geo, sh, p)) + && let Some((obj, _)) = on_handle.map(|(o, r, _)| (o, r)).or_else(|| crate::chartview::hit(app, geo, sh, p)) { app.selected_object = Some(obj); if resp.double_clicked() && app.open_object(obj) != crate::objects::OpenAction::Select { return; } if primary_drag && let Some(a) = sh.object_anchor(obj) { - let handle = crate::objects::handle_at(rect, p); app.grid.drag = Drag::Object { obj, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), handle, stepped: false }; // The first recognised drag frame already holds movement away from the press. if let Some(now) = pos { diff --git a/crates/ui-egui/tests/it/objects.rs b/crates/ui-egui/tests/it/objects.rs index 521a2f44c..e3382352d 100644 --- a/crates/ui-egui/tests/it/objects.rs +++ b/crates/ui-egui/tests/it/objects.rs @@ -344,3 +344,21 @@ fn each_drag_of_an_object_is_its_own_undo_step() { h.run_steps(2); assert_eq!(rect(&h, o.image), start, "a second undo takes back the move"); } + +#[test] +fn dragging_an_unselected_object_by_its_edge_moves_it() { + let (mut h, o) = fixture(); + assert_eq!(h.state().selected_object, None); + let before = rect(&h, o.shape); + // On the bottom-right corner, where the handle would be if the shape were selected; the + // pointer shows Move there, and the press selects and moves the shape. + let grab = Handle::BottomRight.pos(before) - vec2(2.0, 2.0); + h.input_mut().events.push(Event::PointerMoved(grab)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move); + drag(&mut h, grab, grab + vec2(30.0, 20.0)); + let after = rect(&h, o.shape); + assert_eq!(h.state().selected_object, Some(o.shape)); + assert!((after.size() - before.size()).length() < 0.5, "not resized: {before:?} {after:?}"); + assert!((after.min - before.min - vec2(30.0, 20.0)).length() < 1.0, "moved with the pointer: {before:?} {after:?}"); +} From a0d991e903e5953b17e52c3ec5abe02ff1908b25 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:33:25 -0400 Subject: [PATCH 09/55] Form controls: model and commands for buttons, sliders and spin buttons A sheet holds form controls as floating objects of a new kind (ObjKind::Control): buttons that run a macro or one command, and sliders and spin buttons that hold a whole number from 0 to 30000 and write it to a linked cell. They take part in everything other objects do: ids from the workbook sequence, object.move, object.delete and object.setAnchorMode, the Selection Pane and Bring Forward / Send Backward, anchors that follow row and column edits, fresh ids on a copied sheet, and links that follow inserts, deletes, cut-moves, sheet renames and sheet deletes like chart series. Commands (cmd/controls.rs): insert.button, insert.slider, insert.spinButton, control.format, control.assign, control.setValue, control.press and the control.list query. Each numeric rule (0 <= min < max <= 30000, 1 <= step <= max - min, page >= step for sliders) is refused with its own message, and values are clamped and snapped to the step. control.setValue writes the linked cell through cell.set inside one undo group, so dependents recalculate and one Undo restores both; every commit moves linked controls to what their cells hold. control.press runs a button's macro through macro.run, or its command through the macro policy with the document's trust, so a button in a workbook opened from a file asks before it runs anything. Like a macro step, the command runs inside the scripts' file sandbox unless it is an approved file command. Macros never press controls, and macro.rename keeps buttons pointing at the renamed macro. macro.run, macro.check and the debugger take `output`, which replaces the macro's output anchor for that run (a button that sends the output elsewhere). The policy and the approval go by the stored macro; the footprint, the conflict rule and the per-step check work on the cells `output` names. A value that isn't reference text of at most 1,024 characters, or names no cell, is refused before anything runs. --- crates/engine/src/cmd.rs | 3 + crates/engine/src/cmd/controls.rs | 532 ++++++++++++++++++++ crates/engine/src/cmd/edit/paste_special.rs | 8 +- crates/engine/src/cmd/extra.rs | 6 +- crates/engine/src/cmd/extra/objects.rs | 12 + crates/engine/src/cmd/insert.rs | 8 +- crates/engine/src/cmd/macros.rs | 22 +- crates/engine/src/cmd/sheet/tabs.rs | 1 + crates/engine/src/macros/policy.rs | 2 + crates/engine/src/macros/run.rs | 34 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/controls.rs | 317 ++++++++++++ crates/engine/src/tests/macros.rs | 55 ++ crates/engine/src/tests/objects.rs | 34 +- crates/model/src/features.rs | 169 ++++++- crates/model/src/sheet.rs | 22 +- crates/model/src/workbook.rs | 7 + crates/ui-egui/src/objects.rs | 2 +- docs/macros.md | 26 +- 19 files changed, 1220 insertions(+), 41 deletions(-) create mode 100644 crates/engine/src/cmd/controls.rs create mode 100644 crates/engine/src/tests/controls.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index be1a28f4d..5d2690ace 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -2,6 +2,7 @@ //! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`). pub mod analysis; +pub mod controls; pub mod data; pub mod draw; pub mod edit; @@ -138,6 +139,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(format::specs()); v.extend(sheet::specs()); v.extend(insert::specs()); + v.extend(controls::specs()); v.extend(data::specs()); v.extend(formulas::specs()); v.extend(review::specs()); @@ -401,6 +403,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) } else if !changed.is_empty() { d.calc.cells_changed(&mut wb, &changed); } + controls::sync_linked_values(&mut wb); if let Some(t0) = t0 { eprintln!("commit: + recalc {:?}", since(t0)); } diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs new file mode 100644 index 000000000..77193e091 --- /dev/null +++ b/crates/engine/src/cmd/controls.rs @@ -0,0 +1,532 @@ +//! Form controls (Insert › Controls): buttons that run a macro or a command, and sliders and spin +//! buttons that hold a whole number from 0 to 30000 and write it to a linked cell. +//! +//! A slider or spin button's value lives on the control and in its linked cell. Setting it goes +//! through `cell.set`, so the cell's dependents recalculate and Undo takes both back as one step; +//! editing the linked cell moves the control (see [`sync_linked_values`]). Pressing a button runs +//! its action through the macro policy with the document's trust, so a button from a file someone +//! else made asks before it runs anything. + +use gridcraft_core::CellRef; +use gridcraft_model::{Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MAX_MACRO_NAME, MacroInput, Sheet, Workbook}; +use serde_json::{Value as Json, json}; + +use super::{ + CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, find_command, + has_doc, split_sheet, str_param, u32_param, +}; +use crate::macros::policy::{Policy, macro_policy}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "insert.button", + "Button", + ["Insert", "Controls"], + None, + "{at?: \"B2\" (or cell), dx?, dy?, width?, height?, text?, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\", command?: id, params?: {..}} places a button; pressing it runs the macro or the command → {control: id}", + has_doc, + insert_button + ), + cmd!( + "insert.slider", + "Slider", + ["Insert", "Controls"], + None, + "{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\" | \"Sheet2!$B$1\", min?: 0, max?: 100, step?: 1, page?: 10, value?, horizontal?: true} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min, page ≥ step → {control: id}", + has_doc, + |s, p| insert_number_control(s, p, ControlKind::Slider) + ), + cmd!( + "insert.spinButton", + "Spin Button", + ["Insert", "Controls"], + None, + "{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\", min?: 0, max?: 100, step?: 1, value?} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min → {control: id}", + has_doc, + |s, p| insert_number_control(s, p, ControlKind::Spin) + ), + cmd!( + "control.format", + "Format Control", + [], + None, + "{id, text?, name?, link?: \"$B$1\" | \"\" (no link), min?, max?, step?, page?, value?, horizontal?, print?, locked?} → the control (as control.list)", + has_doc, + format + ), + cmd!( + "control.assign", + "Assign Action", + [], + None, + "{id, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\" | command?: id, params?: {..} | clear?: true} sets what a button runs → the control", + has_doc, + assign + ), + cmd!( + "control.setValue", + "Change Control Value", + [], + None, + "{id, value} clamps the value to min..max, snaps it to the step, and writes it to the linked cell → {value}", + has_doc, + set_value + ), + cmd!( + "control.press", + "Press Button", + [], + None, + "{id, approve?: bool, onConflict?, overwrite?} runs a button's macro or command (through the macro policy, with the workbook's trust; a workbook from a file asks first) → what the macro or command returned", + has_doc, + press + ), + cmd!(query "control.list", "Form Controls", [], None, "{} → the active sheet's controls: [{id, kind, name, text, at, link?, value, min, max, step, page, horizontal, action?}]", has_doc, list), + ] +} + +/// The longest caption or name a control takes. +const MAX_TEXT: usize = 1_024; + +fn control_json(c: &FormControl) -> Json { + let mut o = json!({ + "id": c.id, + "kind": match c.kind { ControlKind::Button => "button", ControlKind::Slider => "slider", ControlKind::Spin => "spinButton" }, + "name": c.display_name(), + "at": c.anchor.cell.a1(), + "width": c.anchor.width, + "height": c.anchor.height, + "print": c.print, + "locked": c.locked, + }); + if c.kind == ControlKind::Button { + o["text"] = json!(c.text); + if let Some(a) = &c.action { + o["action"] = serde_json::to_value(a).unwrap_or(Json::Null); + } + } else { + o["value"] = json!(c.value); + o["min"] = json!(c.min); + o["max"] = json!(c.max); + o["step"] = json!(c.step); + if c.kind == ControlKind::Slider { + o["page"] = json!(c.page); + o["horizontal"] = json!(c.horizontal); + } + } + if let Some(l) = &c.link { + o["link"] = json!(l); + } + o +} + +fn list(s: &mut Session, _: &Json) -> Result { + let sh = s.doc()?.wb.active().ok_or(EngineError::NoDocument)?; + Ok(Json::Array(sh.controls.iter().map(control_json).collect())) +} + +/// A whole number from 0 to 30000 named `key`, if given. Numbers in text (`"12"`) count; anything +/// else is refused with a message that names the parameter. +fn whole(cmd: &str, p: &Json, key: &str) -> Result> { + let v = match p.get(key) { + None | Some(Json::Null) => return Ok(None), + Some(Json::Number(n)) => n.as_f64(), + Some(Json::String(t)) => t.trim().parse::().ok(), + Some(_) => None, + }; + match v { + Some(x) if x.is_finite() && x.fract() == 0.0 && (0.0..=CONTROL_MAX as f64).contains(&x) => Ok(Some(x as u32)), + _ => Err(bad(cmd, format!("`{key}` must be a whole number from 0 to {CONTROL_MAX}"))), + } +} + +fn text_param(cmd: &str, p: &Json, key: &str) -> Result> { + match p.get(key) { + None | Some(Json::Null) => Ok(None), + Some(Json::String(t)) if t.chars().count() <= MAX_TEXT => Ok(Some(t.clone())), + Some(Json::String(_)) => Err(bad(cmd, format!("`{key}` is longer than {MAX_TEXT} characters"))), + Some(_) => Err(bad(cmd, format!("`{key}` must be text"))), + } +} + +/// The cell a link names, from the control's sheet `host`: `(sheet index, cell)`. `None` when it +/// doesn't name one cell of an existing sheet (a link into a deleted sheet reads `#REF!`). +pub fn link_target(wb: &Workbook, host: usize, link: &str) -> Option<(usize, CellRef)> { + let text = link.trim().trim_start_matches('='); + let (sheet, body) = split_sheet(text); + let si = match sheet { + Some(n) => wb.sheet_index(&n)?, + None => host, + }; + wb.sheet(si)?; + Some((si, CellRef::parse(body.trim())?)) +} + +/// A link parameter in its stored form: `$B$2` on the host sheet, `'My Sheet'!$B$2` elsewhere. +/// An empty text removes the link. +fn link_param(cmd: &str, wb: &Workbook, host: usize, p: &Json) -> Result>> { + let text = match p.get("link") { + None => return Ok(None), + Some(Json::Null) => return Ok(Some(None)), + Some(Json::String(t)) => t.trim(), + Some(_) => return Err(bad(cmd, "`link` must be a cell reference such as $B$1")), + }; + if text.is_empty() { + return Ok(Some(None)); + } + let (si, c) = link_target(wb, host, text) + .ok_or_else(|| bad(cmd, format!("`{text}` isn't a cell of this workbook; the link must be one cell, such as $B$1")))?; + let abs = format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1); + Ok(Some(Some(if si == host { + abs + } else { + let name = wb.sheet(si).map(|sh| sh.name.clone()).unwrap_or_default(); + format!("{}!{abs}", gridcraft_formula::quote_sheet(&name)) + }))) +} + +/// The anchor of a new control: `at` or `cell` (default the active cell), with its size. +fn new_anchor(s: &Session, p: &Json, size: (f32, f32)) -> Result { + let cell = match cell_param(s, p, "at")? { + Some(c) => c, + None => cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active), + }; + let dim = |k: &str, v: f32| f64_param(p, k).filter(|x| x.is_finite()).map_or(v, |x| x.clamp(4.0, 10_000.0) as f32); + let off = |k: &str| f64_param(p, k).filter(|x| x.is_finite()).map_or(0.0, |x| x.clamp(-10_000.0, 10_000.0) as f32); + Ok(Anchor { + cell, + dx: off("dx"), + dy: off("dy"), + width: dim("width", size.0), + height: dim("height", size.1), + mode: anchor_mode_param(p).unwrap_or(AnchorMode::MoveOnly), + }) +} + +fn insert_button(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "insert.button"; + let anchor = new_anchor(s, p, (96.0, 28.0))?; + let action = action_param(s, CMD, p)?; + let d = s.doc()?; + let id = d.wb.next_object_id(); + let text = text_param(CMD, p, "text")?.unwrap_or_else(|| format!("Button {id}")); + let sheet = d.wb.active_sheet(); + let control = FormControl { id, kind: ControlKind::Button, anchor, text, action, ..FormControl::default() }; + edit(s, |cx| { + cx.sheet_mut(sheet)?.controls.push(control); + Ok(json!({"control": id})) + }) +} + +fn insert_number_control(s: &mut Session, p: &Json, kind: ControlKind) -> Result { + let cmd = if kind == ControlKind::Slider { "insert.slider" } else { "insert.spinButton" }; + let horizontal = bool_param(p, "horizontal").unwrap_or(true); + let size = match kind { + ControlKind::Slider if horizontal => (144.0, 18.0), + ControlKind::Slider => (18.0, 144.0), + _ => (18.0, 36.0), + }; + let anchor = new_anchor(s, p, size)?; + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let id = d.wb.next_object_id(); + let mut c = FormControl { id, kind, anchor, horizontal, ..FormControl::default() }; + numbers(cmd, p, &mut c)?; + if let Some(link) = link_param(cmd, &d.wb, sheet, p)? { + c.link = link; + } + let given = whole(cmd, p, "value")?; + // Without a value, a control starts at the number its linked cell holds (or its minimum). + let start = given.map(f64::from).or_else(|| linked_number(&d.wb, sheet, &c)).unwrap_or(c.min as f64); + c.value = c.snap(start); + let value = c.value; + let link = c.link.clone(); + s.grouped(&format!("Insert {}", kind.label()), |s| { + edit(s, |cx| { + cx.sheet_mut(sheet)?.controls.push(c); + Ok(()) + })?; + if given.is_some() { + write_link(s, sheet, link.as_deref(), value)?; + } + Ok(json!({"control": id})) + }) +} + +/// Applies `min`, `max`, `step`, `page` and `value` from `p` to `c` and checks the result. +fn numbers(cmd: &str, p: &Json, c: &mut FormControl) -> Result<()> { + for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] { + if let Some(v) = whole(cmd, p, key)? { + *slot = v; + } + } + // A new slider's page follows a larger step unless it was given. + if p.get("page").is_none() && c.page < c.step { + c.page = c.step; + } + c.check_numbers().map_err(|e| bad(cmd, e)) +} + +/// The number the control's linked cell holds, if it holds one. +fn linked_number(wb: &Workbook, host: usize, c: &FormControl) -> Option { + let (si, cell) = link_target(wb, host, c.link.as_deref()?)?; + wb.sheet(si)?.cell(cell)?.value.as_f64() +} + +/// Writes `value` to the linked cell through `cell.set` (nothing without a link). +fn write_link(s: &mut Session, host: usize, link: Option<&str>, value: u32) -> Result<()> { + let Some(link) = link else { return Ok(()) }; + let Some((si, cell)) = link_target(&s.doc()?.wb, host, link) else { return Ok(()) }; + let holds = s.doc()?.wb.sheet(si).and_then(|sh| sh.cell(cell)).and_then(|c| if c.formula.is_none() { c.value.as_f64() } else { None }); + if holds == Some(value as f64) { + return Ok(()); + } + s.execute("cell.set", json!({"sheet": si, "cell": cell.a1(), "input": value.to_string()}))?; + Ok(()) +} + +/// The control `id` names on the active sheet: (sheet, its index in `controls`, a copy). +fn find(s: &Session, cmd: &str, p: &Json) -> Result<(usize, usize, FormControl)> { + let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (a control id from control.list)"))?; + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let i = sh.controls.iter().position(|c| c.id == id).ok_or_else(|| bad(cmd, format!("there is no control {id} on this sheet")))?; + let c = sh.controls.get(i).cloned().ok_or_else(|| bad(cmd, "no such control"))?; + Ok((sheet, i, c)) +} + +/// Drawing objects on a protected sheet can't be changed while they are locked. +fn check_unlocked(s: &Session, sheet: usize, c: &FormControl) -> Result<()> { + if c.locked && s.doc()?.wb.sheet(sheet).is_some_and(Sheet::is_protected) { + return Err(EngineError::Other(PROTECTED.into())); + } + Ok(()) +} + +fn format(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "control.format"; + let (sheet, i, mut c) = find(s, CMD, p)?; + check_unlocked(s, sheet, &c)?; + if let Some(t) = text_param(CMD, p, "text")? { + c.text = t; + } + if let Some(n) = text_param(CMD, p, "name")? { + c.name = n.trim().to_string(); + } + for (key, slot) in [("print", &mut c.print), ("locked", &mut c.locked), ("horizontal", &mut c.horizontal)] { + if let Some(v) = bool_param(p, key) { + *slot = v; + } + } + let mut given = None; + if c.kind != ControlKind::Button { + for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] { + if let Some(v) = whole(CMD, p, key)? { + *slot = v; + } + } + c.check_numbers().map_err(|e| bad(CMD, e))?; + given = whole(CMD, p, "value")?; + } + if let Some(link) = link_param(CMD, &s.doc()?.wb, sheet, p)? { + c.link = link; + } + // New bounds pull the value in; a given value is snapped to them. + c.value = c.snap(given.map_or(c.value as f64, f64::from)); + let number = c.kind != ControlKind::Button; + let write = number && (given.is_some() || p.get("min").is_some() || p.get("max").is_some() || p.get("step").is_some()); + let (value, link) = (c.value, c.link.clone()); + let out = control_json(&c); + s.grouped("Format Control", |s| { + edit(s, |cx| { + if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) { + *slot = c; + } + Ok(()) + })?; + if write { + write_link(s, sheet, link.as_deref(), value)?; + } + Ok(out) + }) +} + +/// A button action from `macro` (with `input` and `output`) or `command` (with `params`). +fn action_param(s: &Session, cmd: &str, p: &Json) -> Result> { + if let Some(name) = str_param(p, "macro").map(str::trim).filter(|n| !n.is_empty()) { + if name.chars().count() > MAX_MACRO_NAME { + return Err(bad(cmd, "`macro` is not a macro name")); + } + let input = match str_param(p, "input").map(str::trim) { + None | Some("") => MacroInput::Selection, + Some(t) if t.eq_ignore_ascii_case("selection") => MacroInput::Selection, + Some(t) => { + let (_, body) = split_sheet(t); + if gridcraft_core::RangeRef::parse(body).is_none() { + return Err(bad(cmd, format!("`input` must be \"selection\" or a range such as B2:D9, not `{t}`"))); + } + MacroInput::Fixed(t.to_string()) + } + }; + let output = str_param(p, "output").map(str::trim).filter(|o| !o.is_empty()).map(str::to_string); + if let Some(o) = &output + && gridcraft_core::RangeRef::parse(split_sheet(o).1).is_none() + { + return Err(bad(cmd, format!("`output` must be a cell such as F2, not `{o}`"))); + } + return Ok(Some(ControlAction::Macro { name: name.to_string(), input, output })); + } + if let Some(id) = str_param(p, "command").map(str::trim).filter(|n| !n.is_empty()) { + if find_command(id).is_none() { + return Err(bad(cmd, format!("there is no command `{id}`"))); + } + let params = p.get("params").cloned().unwrap_or_else(|| json!({})); + if !params.is_object() { + return Err(bad(cmd, "`params` must be an object")); + } + let trust = s.doc()?.trust; + if let Policy::Deny(why) = macro_policy(id, ¶ms, trust) { + return Err(EngineError::Other(format!("A button can't run {id}: {why}."))); + } + return Ok(Some(ControlAction::Command { id: id.to_string(), params })); + } + Ok(None) +} + +fn assign(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "control.assign"; + let (sheet, i, mut c) = find(s, CMD, p)?; + check_unlocked(s, sheet, &c)?; + if c.kind != ControlKind::Button { + return Err(bad(CMD, "only buttons run actions; a slider or spin button writes its value to its linked cell")); + } + let action = action_param(s, CMD, p)?; + if action.is_none() && bool_param(p, "clear") != Some(true) { + return Err(bad(CMD, "pass `macro` or `command` (or `clear: true` to remove the action)")); + } + c.action = action; + let out = control_json(&c); + edit(s, |cx| { + if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) { + *slot = c; + } + Ok(out) + }) +} + +fn set_value(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "control.setValue"; + let (sheet, i, c) = find(s, CMD, p)?; + if c.kind == ControlKind::Button { + return Err(bad(CMD, "a button has no value; use control.press")); + } + let v = match p.get("value") { + Some(Json::Number(n)) => n.as_f64(), + Some(Json::String(t)) => t.trim().parse::().ok(), + _ => None, + } + .filter(|v| v.is_finite()) + .ok_or_else(|| bad(CMD, "`value` must be a number"))?; + let value = c.snap(v); + let label = format!("Change {}", c.kind.label()); + s.grouped(&label, |s| { + if c.value != value { + edit(s, |cx| { + if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) { + slot.value = value; + } + Ok(()) + })?; + } + write_link(s, sheet, c.link.as_deref(), value)?; + Ok(json!({"value": value})) + }) +} + +fn press(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "control.press"; + let (_, _, c) = find(s, CMD, p)?; + if c.kind != ControlKind::Button { + return Err(bad(CMD, "only buttons can be pressed; use control.setValue for a slider or spin button")); + } + let name = c.display_name(); + match c.action { + None => Err(EngineError::Other(format!("'{name}' has no action. Right-click it and choose Assign Action."))), + Some(ControlAction::Macro { name: mac, input, output }) => { + let mut params = json!({"name": mac}); + if let MacroInput::Fixed(r) = input { + params["inputs"] = json!(r); + } + if let Some(o) = output { + params["output"] = json!(o); + } + for key in ["approve", "onConflict", "overwrite", "timeLimit"] { + if let Some(v) = p.get(key) { + params[key] = v.clone(); + } + } + // The macro runner gates every step with the document's trust. + crate::macros::run::run(s, &mac, ¶ms) + } + Some(ControlAction::Command { id, params }) => { + let d = s.doc()?; + let (doc, trust) = (d.uid, d.trust); + let policy = macro_policy(&id, ¶ms, trust); + let file_command = matches!(policy, Policy::NeedsPermission(_)); + let why = match policy { + Policy::Allow => None, + Policy::Ask(why) | Policy::NeedsPermission(why) => Some(why), + Policy::Deny(why) => return Err(EngineError::Other(format!("'{name}' can't run {id}: {why}."))), + }; + if let Some(why) = why { + let key = (doc, approval_key(&id, ¶ms)); + if !s.macro_approvals.contains(&key) { + if bool_param(p, "approve") != Some(true) { + return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}."))); + } + s.macro_approvals.insert(key); + } + } + // As a macro step: only an approved file command reaches the file system, so a + // command that reads or writes a file under another parameter name can't. + let _sandbox = (!file_command).then(crate::io::ScriptSandbox::enter); + s.grouped(&name, |s| s.execute(&id, params.clone())) + } + } +} + +/// The approval key of a button's command: its id and parameters, with a prefix that keeps it +/// apart from a macro's fingerprint (canonical JSON, which starts with `{`). +fn approval_key(id: &str, params: &Json) -> String { + format!("control-command\u{0}{id}\u{0}{params}") +} + +/// After an edit: each slider and spin button whose linked cell holds a number takes that number +/// (clamped and snapped), so editing the cell moves the control. Sheets without such a control +/// are left untouched. +pub(crate) fn sync_linked_values(wb: &mut Workbook) { + for si in 0..wb.sheets.len() { + let updates: Vec<(usize, u32)> = match wb.sheet(si) { + Some(sh) if sh.controls.iter().any(|c| c.link.is_some()) => sh + .controls + .iter() + .enumerate() + .filter(|(_, c)| c.kind != ControlKind::Button) + .filter_map(|(i, c)| linked_number(wb, si, c).map(|n| (i, c.snap(n))).filter(|(_, v)| *v != c.value)) + .collect(), + _ => continue, + }; + if updates.is_empty() { + continue; + } + if let Some(sh) = wb.sheet_mut(si) { + for (i, v) in updates { + if let Some(c) = sh.controls.get_mut(i) { + c.value = v; + } + } + } + } +} diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index c9e296ba5..80c37618e 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -359,14 +359,15 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: & } /// Runs `f` over every reference a floating object holds (a chart's series values, categories, -/// name and bubble sizes, and the source block it was made from). Every edit that moves +/// name and bubble sizes, and the source block it was made from, and a form control's linked +/// cell). Every edit that moves /// references calls it: inserting and deleting lines and cells and cut-moves (through /// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an /// object on the sheet named `host` to its new form; text that does not parse is kept. A source /// block that no longer exists is dropped. pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) { for si in 0..wb.sheets.len() { - let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue }; + let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty() || !s.controls.is_empty()).map(|s| s.name.clone()) else { continue }; let Some(sh) = wb.sheet_mut(si) else { continue }; let fix = |text: &str| -> String { match gridcraft_formula::parse(text) { @@ -383,5 +384,8 @@ pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn( se.bubble_sizes = se.bubble_sizes.as_deref().map(fix); } } + for c in sh.controls.iter_mut() { + c.link = c.link.as_deref().map(fix); + } } } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 237b32860..8dfda7601 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -97,7 +97,7 @@ pub fn specs() -> Vec { "Bring Forward", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer, id, toFront?: bool}", + "{kind: chart|image|shape|slicer|control, id, toFront?: bool}", has_doc, |s, p| arrange(s, p, true) ), @@ -106,7 +106,7 @@ pub fn specs() -> Vec { "Send Backward", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer, id, toBack?: bool}", + "{kind: chart|image|shape|slicer|control, id, toBack?: bool}", has_doc, |s, p| arrange(s, p, false) ), @@ -116,7 +116,7 @@ pub fn specs() -> Vec { "Align", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer, ids: [..], align: left|center|right|top|middle|bottom}", + "{kind: chart|image|shape|slicer|control, ids: [..], align: left|center|right|top|middle|bottom}", has_doc, align ), diff --git a/crates/engine/src/cmd/extra/objects.rs b/crates/engine/src/cmd/extra/objects.rs index 03e8d07b2..67286f268 100644 --- a/crates/engine/src/cmd/extra/objects.rs +++ b/crates/engine/src/cmd/extra/objects.rs @@ -67,6 +67,11 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result reorder(&mut sh.slicers, i, forward, extreme); } } + "control" => { + if let Some(i) = sh.controls.iter().position(|c| c.id == id) { + reorder(&mut sh.controls, i, forward, extreme); + } + } _ => { if let Some(i) = sh.shapes.iter().position(|c| c.id == id) { reorder(&mut sh.shapes, i, forward, extreme); @@ -95,6 +100,12 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result { let name = if c.caption.is_empty() { &c.column } else { &c.caption }; v.push(json!({"kind": "slicer", "id": c.id, "name": name, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); } + // Form controls paint above everything else. + for c in &sh.controls { + v.push( + json!({"kind": "control", "id": c.id, "name": c.display_name(), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}), + ); + } Ok(Json::Array(v)) } @@ -103,6 +114,7 @@ fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> { "chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), "image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), "slicer" => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + "control" => sh.controls.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), _ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), } } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 669dc6a62..0806f2183 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -146,13 +146,13 @@ pub fn specs() -> Vec { &with(p, "kind", json!("textBox")) )), cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text), - cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer, id}", has_doc, delete_object), + cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|control, id}", has_doc, delete_object), cmd!( "object.move", "Move Object", [], None, - "{kind: chart|image|shape|slicer, id, at?: \"C3\", dx?, dy?, width?, height?}", + "{kind: chart|image|shape|slicer|control, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object ), @@ -161,7 +161,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{kind: chart|image|shape|slicer, id, mode: moveAndSize|moveOnly|absolute}", + "{kind: chart|image|shape|slicer|control, id, mode: moveAndSize|moveOnly|absolute}", has_doc, set_anchor_mode ), @@ -1180,7 +1180,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { /// objects are errors, so a stale id never succeeds silently. fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> { let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; - let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape or slicer"))?; + let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or control"))?; let d = s.doc()?; let sheet = d.wb.active_sheet(); let o = ObjRef::new(kind, id); diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 396e0e28f..37f400f05 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { "Run Macro", [], None, - "{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}", + "{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", output?: \"F2\" (instead of the macro's output anchor), onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}", has_doc, run ), @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Check Macro", [], None, - "{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}", + "{name, inputs?, output?: \"F2\" (as macro.run), onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}", has_doc, check ), @@ -55,7 +55,7 @@ pub fn specs() -> Vec { "Step Into", [], None, - "{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}", + "{action: start|step|continue|runTo|stop|state, name, inputs?, output?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}", has_doc, |s, p| crate::macros::debug::debug(s, p) ), @@ -170,7 +170,21 @@ fn rename(s: &mut Session, p: &Json) -> Result { renamed.name = to.clone(); // The whole check, size included: a longer name can take a macro past what a file holds. crate::macros::check(&renamed).map_err(EngineError::Other)?; - *m = renamed; + let from = std::mem::replace(m, renamed).name; + // Buttons that run the macro follow the new name. + let runs = |c: &gridcraft_model::FormControl| matches!(&c.action, Some(gridcraft_model::ControlAction::Macro { name, .. }) if name.eq_ignore_ascii_case(&from)); + for si in 0..cx.wb.sheets.len() { + if !cx.wb.sheet(si).is_some_and(|sh| sh.controls.iter().any(runs)) { + continue; + } + for c in cx.sheet_mut(si)?.controls.iter_mut() { + if let Some(gridcraft_model::ControlAction::Macro { name, .. }) = &mut c.action + && name.eq_ignore_ascii_case(&from) + { + *name = to.clone(); + } + } + } Ok(()) })?; Ok(json!({"name": to})) diff --git a/crates/engine/src/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index 2f87294bb..8d8340aab 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -175,6 +175,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result { sh.images.iter_mut().for_each(|o| o.id = fresh()); sh.shapes.iter_mut().for_each(|o| o.id = fresh()); sh.slicers.iter_mut().for_each(|o| o.id = fresh()); + sh.controls.iter_mut().for_each(|o| o.id = fresh()); let at = to.min(n); // The copy gets its own copies of the sheet's names, referring to the copy. let copies: Vec = cx diff --git a/crates/engine/src/macros/policy.rs b/crates/engine/src/macros/policy.rs index a84097986..e04ea65d4 100644 --- a/crates/engine/src/macros/policy.rs +++ b/crates/engine/src/macros/policy.rs @@ -38,6 +38,7 @@ const DENIED: &[(&str, &str)] = &[ ("view.newWindow", "macros don't open, hide or show windows"), ("view.hideWindow", "macros don't open, hide or show windows"), ("view.unhideWindow", "macros don't open, hide or show windows"), + ("control.press", "macros don't press form controls"), ]; /// Commands under a denied prefix that only change the workbook. @@ -112,6 +113,7 @@ mod tests { "view.newWindow", "view.hideWindow", "view.unhideWindow", + "control.press", ] { assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}"); assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}"); diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index fcb6768f3..89e0cddca 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -23,7 +23,7 @@ use std::sync::Arc; use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_formula::adjust::Edit; -use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook}; +use gridcraft_model::{Cell, CompareOp, InputKind, MAX_MACRO_REF, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook}; use serde::Serialize; use serde_json::{Value as Json, json}; @@ -1272,7 +1272,12 @@ fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) { let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count(); (added, changed, removed) } - let parts = [count(&b.charts, &a.charts, |o| o.id), count(&b.images, &a.images, |o| o.id), count(&b.shapes, &a.shapes, |o| o.id)]; + let parts = [ + count(&b.charts, &a.charts, |o| o.id), + count(&b.images, &a.images, |o| o.id), + count(&b.shapes, &a.shapes, |o| o.id), + count(&b.controls, &a.controls, |o| o.id), + ]; parts.iter().fold((0, 0, 0), |t, p| (t.0 + p.0, t.1 + p.1, t.2 + p.2)) } @@ -1380,6 +1385,7 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { check_params(p, &m)?; let asks = gate(&m, trust)?; approve(s, &m, doc, &asks, p.get("approve").and_then(Json::as_bool).unwrap_or(false))?; + let m = with_output(m, p); let prepared = prepare(s, &m, p.get("inputs"))?; let rule = conflict_rule(p, &m)?; let time_limit_ms = time_limit(p); @@ -1480,9 +1486,21 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result { }) } +/// `m` with the run's `output` (a form button that sends the output elsewhere) in place of its own +/// output anchor, after the policy and the approval, which go by the macro as it is stored. The +/// anchor is resolved and checked by [`prepare`] like the macro's own, and the footprint, the +/// conflict rule and the per-step guard all work on the cells it names. +fn with_output(mut m: Macro, p: &Json) -> Macro { + if let Some(o) = p.get("output").and_then(Json::as_str).map(str::trim).filter(|o| !o.is_empty()) { + m.output.anchor = Some(o.to_string()); + } + m +} + /// Refuses run parameters that would otherwise be ignored: an input the macro doesn't have, an -/// input given as something other than reference text, and `onConflict`, `overwrite`, `approve` -/// or `timeLimit` of the wrong kind (`null` counts as not given). +/// input given as something other than reference text, `onConflict`, `overwrite`, `approve` or +/// `timeLimit` of the wrong kind, and an `output` that isn't reference text of at most +/// `MAX_MACRO_REF` characters (`null` counts as not given). pub fn check_params(p: &Json, m: &Macro) -> Result<()> { let refuse = |msg: String| Err(EngineError::Other(format!("Macro '{}': {msg}", m.name))); let names = || m.inputs.iter().map(|i| i.name.clone()).collect::>().join(", "); @@ -1515,6 +1533,12 @@ pub fn check_params(p: &Json, m: &Macro) -> Result<()> { return refuse(format!("`{k}` must be true or false.")); } } + if let Some(o) = given("output") { + let ok = o.as_str().map(str::trim).is_some_and(|t| !t.is_empty() && t.chars().count() <= MAX_MACRO_REF && !t.chars().any(char::is_control)); + if !ok { + return refuse(format!("`output` must be reference text of at most {MAX_MACRO_REF} characters, such as \"F2\" or \"Sheet2!F2\".")); + } + } if given("timeLimit").is_some_and(|v| !v.as_f64().is_some_and(|t| t > 0.0)) { return refuse("`timeLimit` must be a number of seconds above 0.".into()); } @@ -1591,7 +1615,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { // Nothing from an unapproved macro runs, not even in a dry run. out["needsApproval"] = json!(asks); } - let prepared = match prepare(s, &m, p.get("inputs")) { + let prepared = match prepare(s, &with_output(m, p), p.get("inputs")) { Ok(pr) => pr, Err(e) => return refused(out, e.to_string()), }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 49c09dee5..c4c7d3d99 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5202,6 +5202,7 @@ fn a_script_cannot_switch_windows() { assert_eq!(v(&s, "A1"), Value::number(2.0)); } +mod controls; mod macros; mod objects; mod seams; diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs new file mode 100644 index 000000000..08f2e550a --- /dev/null +++ b/crates/engine/src/tests/controls.rs @@ -0,0 +1,317 @@ +//! Form controls: buttons, sliders and spin buttons (insert, format, assign, setValue, press). + +use gridcraft_core::Value; +use gridcraft_model::{ControlAction, ControlKind, FormControl, MacroInput}; +use serde_json::{Value as Json, json}; + +use super::{s, v}; +use crate::{EngineError, Session, Trust}; + +fn id(r: &Json) -> u64 { + r["control"].as_u64().unwrap() +} + +fn control(s: &Session, id: u64) -> FormControl { + s.doc().unwrap().wb.active().unwrap().controls.iter().find(|c| c.id as u64 == id).cloned().unwrap() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().undo.len() +} + +fn err(r: crate::Result) -> String { + r.unwrap_err().to_string() +} + +#[test] +fn numbers_are_whole_and_each_rule_has_its_own_error() { + let mut s = s(); + let ok = s.execute("insert.slider", json!({"at": "D2", "min": 10, "max": 50, "step": 5, "page": 10})).unwrap(); + let c = control(&s, id(&ok)); + assert_eq!((c.kind, c.min, c.max, c.step, c.page, c.value), (ControlKind::Slider, 10, 50, 5, 10, 10)); + for (p, needle) in [ + (json!({"min": 2.5}), "`min` must be a whole number from 0 to 30000"), + (json!({"min": -1}), "`min` must be a whole number"), + (json!({"max": 30001}), "`max` must be a whole number from 0 to 30000"), + (json!({"step": "two"}), "`step` must be a whole number"), + (json!({"max": 1e300}), "`max` must be a whole number"), + (json!({"min": 50, "max": 50}), "Maximum value (50) must be greater than Minimum value (50)"), + (json!({"min": 60, "max": 50}), "must be greater than Minimum"), + (json!({"step": 0}), "Incremental change must be at least 1"), + (json!({"min": 0, "max": 10, "step": 11}), "Incremental change (11) can be at most Maximum minus Minimum (10)"), + (json!({"step": 5, "page": 2}), "Page change (2) must be at least the incremental change (5)"), + (json!({"link": "not a cell"}), "isn't a cell of this workbook"), + (json!({"link": "Nowhere!A1"}), "isn't a cell of this workbook"), + (json!({"link": "A1:B2"}), "isn't a cell"), + ] { + let e = err(s.execute("insert.slider", p.clone())); + assert!(e.contains(needle), "{p}: {e}"); + } + // A spin button has no page change, so a page below the step is fine. + s.execute("insert.spinButton", json!({"step": 5, "page": 2})).unwrap(); + // Numbers in text are numbers. + let n = id(&s.execute("insert.spinButton", json!({"min": "3", "max": "9"})).unwrap()); + let c = control(&s, n); + assert_eq!((c.min, c.max, c.value), (3, 9, 3)); + // A larger step pulls a slider's default page up with it. + let n = id(&s.execute("insert.slider", json!({"max": 1000, "step": 50})).unwrap()); + let c = control(&s, n); + assert_eq!(c.page, 50); + // The model checks the same rules. + let bad = FormControl { kind: ControlKind::Spin, min: 0, max: 30_001, ..Default::default() }; + assert!(bad.check_numbers().is_err()); + assert!(FormControl { kind: ControlKind::Button, max: 0, ..Default::default() }.check_numbers().is_ok()); +} + +#[test] +fn set_value_clamps_snaps_and_writes_the_link_as_one_undo_step() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "C1", "input": "=B1*2"})).unwrap(); + let c = id(&s.execute("insert.slider", json!({"link": "B1", "min": 10, "max": 100, "step": 5})).unwrap()); + assert_eq!(control(&s, c).link.as_deref(), Some("$B$1")); + let before = undo_len(&s); + for (given, want) in [(37.0, 35.0), (38.0, 40.0), (-5.0, 10.0), (1e9, 100.0), (99.0, 100.0), (12.4, 10.0)] { + let r = s.execute("control.setValue", json!({"id": c, "value": given})).unwrap(); + assert_eq!(r["value"], json!(want as u32), "{given}"); + assert_eq!(v(&s, "B1"), Value::Number(want), "{given}"); + assert_eq!(v(&s, "C1"), Value::Number(want * 2.0), "dependents recalculate"); + assert_eq!(control(&s, c).value, want as u32); + } + assert_eq!(undo_len(&s), before + 5, "one undo step per change (99 snaps to the 100 it holds)"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Number(100.0)); + assert_eq!(control(&s, c).value, 100); + // Setting the value it already has adds no step. + let n = undo_len(&s); + s.execute("control.setValue", json!({"id": c, "value": 100})).unwrap(); + assert_eq!(undo_len(&s), n); + for p in [json!({"id": c}), json!({"id": c, "value": "x"}), json!({"id": 999, "value": 1}), json!({"value": 1})] { + assert!(s.execute("control.setValue", p).is_err()); + } + // A button has no value. + let b = id(&s.execute("insert.button", json!({})).unwrap()); + assert!(err(s.execute("control.setValue", json!({"id": b, "value": 1}))).contains("control.press")); +} + +#[test] +fn editing_the_linked_cell_moves_the_control() { + let mut s = s(); + let c = id(&s.execute("insert.spinButton", json!({"link": "$B$2", "max": 20, "step": 2})).unwrap()); + s.execute("cell.set", json!({"cell": "B2", "input": "7"})).unwrap(); + assert_eq!(control(&s, c).value, 8, "snapped to the step"); + s.execute("cell.set", json!({"cell": "B2", "input": "500"})).unwrap(); + assert_eq!(control(&s, c).value, 20, "clamped"); + s.execute("cell.set", json!({"cell": "B2", "input": "text"})).unwrap(); + assert_eq!(control(&s, c).value, 20, "text leaves it where it was"); + // A new control starts at what its cell holds. + s.execute("cell.set", json!({"cell": "D4", "input": "12"})).unwrap(); + let d = id(&s.execute("insert.slider", json!({"link": "D4", "max": 20})).unwrap()); + assert_eq!(control(&s, d).value, 12); + // A linked cell on another sheet. + s.execute("home.insertSheet", json!({"name": "Data Sheet"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + let e = id(&s.execute("insert.slider", json!({"link": "'Data Sheet'!a1", "value": 30})).unwrap()); + assert_eq!(control(&s, e).link.as_deref(), Some("'Data Sheet'!$A$1")); + let wb = &s.doc().unwrap().wb; + let other = wb.sheet(wb.sheet_index("Data Sheet").unwrap()).unwrap(); + assert_eq!(other.cell(gridcraft_core::CellRef::new(0, 0)).unwrap().value, Value::Number(30.0)); +} + +#[test] +fn links_follow_inserted_rows() { + let mut s = s(); + let c = id(&s.execute("insert.slider", json!({"at": "E2", "link": "B3", "value": 40})).unwrap()); + s.execute("selection.set", json!({"range": "1:1"})).unwrap(); + s.execute("home.insertRows", json!({})).unwrap(); + let ctl = control(&s, c); + assert_eq!(ctl.link.as_deref(), Some("$B$4")); + assert_eq!(ctl.anchor.cell.a1(), "E3"); + assert_eq!(v(&s, "B4"), Value::Number(40.0)); + s.execute("control.setValue", json!({"id": c, "value": 41})).unwrap(); + assert_eq!(v(&s, "B4"), Value::Number(41.0)); +} + +#[test] +fn format_and_assign() { + let mut s = s(); + let c = id(&s.execute("insert.slider", json!({"link": "B1", "max": 100, "value": 80})).unwrap()); + let r = s.execute("control.format", json!({"id": c, "max": 50, "name": "Rate", "horizontal": false})).unwrap(); + assert_eq!( + (r["max"].clone(), r["value"].clone(), r["name"].clone(), r["horizontal"].clone()), + (json!(50), json!(50), json!("Rate"), json!(false)) + ); + assert_eq!(v(&s, "B1"), Value::Number(50.0), "the new maximum pulls the linked value in"); + assert!(err(s.execute("control.format", json!({"id": c, "min": 50}))).contains("greater than Minimum")); + assert_eq!(control(&s, c).min, 0, "a refused change leaves the control as it was"); + s.execute("control.format", json!({"id": c, "link": ""})).unwrap(); + assert_eq!(control(&s, c).link, None); + s.execute("control.format", json!({"id": c, "value": 25})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Number(50.0), "without a link nothing is written"); + // Buttons: text and action. + s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})).unwrap(); + let b = id(&s.execute("insert.button", json!({"text": "Go", "macro": "Fill", "input": "C2:D4", "output": "F2"})).unwrap()); + let action = control(&s, b).action; + assert_eq!(action, Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Fixed("C2:D4".into()), output: Some("F2".into()) })); + s.execute("control.assign", json!({"id": b, "command": "home.bold", "params": {"range": "A1"}})).unwrap(); + assert!(matches!(control(&s, b).action, Some(ControlAction::Command { ref id, .. }) if id == "home.bold")); + assert!(err(s.execute("control.assign", json!({"id": b, "command": "file.saveAs"}))).contains("can't run file.saveAs")); + assert!(err(s.execute("control.assign", json!({"id": b, "command": "control.press", "params": {"id": b}}))).contains("can't run control.press")); + assert!(err(s.execute("control.assign", json!({"id": b, "command": "no.such"}))).contains("no command")); + assert!(err(s.execute("control.assign", json!({"id": b, "macro": "Fill", "input": "nonsense!!"}))).contains("`input`")); + assert!(err(s.execute("control.assign", json!({"id": b}))).contains("clear")); + assert!(err(s.execute("control.assign", json!({"id": c, "macro": "Fill"}))).contains("only buttons")); + s.execute("control.assign", json!({"id": b, "clear": true})).unwrap(); + assert_eq!(control(&s, b).action, None); + // Renaming the macro keeps the button pointing at it. + s.execute("control.assign", json!({"id": b, "macro": "fill"})).unwrap(); + s.execute("macro.rename", json!({"name": "Fill", "to": "Fill Block"})).unwrap(); + assert!(matches!(control(&s, b).action, Some(ControlAction::Macro { ref name, .. }) if name == "Fill Block")); + let list = s.execute("control.list", json!({})).unwrap(); + assert_eq!(list.as_array().map(Vec::len), Some(2)); + assert_eq!(list[1]["text"], json!("Go")); +} + +#[test] +fn pressing_a_button_runs_its_macro_as_one_undo_step() { + let mut s = s(); + s.execute( + "macro.save", + json!({"name": "Mark", "inputs": [{"name": "Input", "kind": "range"}], "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "done"}}], "output": {"onConflict": "overwrite"}}), + ) + .unwrap(); + let b = id(&s.execute("insert.button", json!({"macro": "Mark"})).unwrap()); + s.execute("selection.set", json!({"range": "C3:D4"})).unwrap(); + let before = undo_len(&s); + let r = s.execute("control.press", json!({"id": b})).unwrap(); + assert_eq!(r["macro"], json!("Mark")); + assert_eq!(v(&s, "C3"), Value::text("done")); + assert_eq!(undo_len(&s), before + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "C3"), Value::Empty); + // A fixed input ignores the selection. + s.execute("control.assign", json!({"id": b, "macro": "Mark", "input": "F6:G7"})).unwrap(); + s.execute("control.press", json!({"id": b})).unwrap(); + assert_eq!((v(&s, "F6"), v(&s, "C3")), (Value::text("done"), Value::Empty)); + // No action, a missing macro, not a button. + let empty = id(&s.execute("insert.button", json!({"text": "Nothing"})).unwrap()); + assert!(err(s.execute("control.press", json!({"id": empty}))).contains("has no action")); + s.execute("control.assign", json!({"id": empty, "macro": "Gone"})).unwrap(); + assert!(err(s.execute("control.press", json!({"id": empty}))).contains("no macro called")); + let spin = id(&s.execute("insert.spinButton", json!({})).unwrap()); + assert!(err(s.execute("control.press", json!({"id": spin}))).contains("only buttons")); +} + +#[test] +fn a_command_button_goes_through_the_policy_with_the_documents_trust() { + let mut s = s(); + let b = id(&s.execute("insert.button", json!({"command": "cell.set", "params": {"cell": "A1", "input": "7"}})).unwrap()); + // A workbook made here runs it straight away, as one undo step. + let before = undo_len(&s); + s.execute("control.press", json!({"id": b})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(7.0)); + assert_eq!(undo_len(&s), before + 1); + s.execute("edit.undo", json!({})).unwrap(); + // From a file: it asks first, once. + s.doc_mut().unwrap().trust = Trust::FromFile; + match s.execute("control.press", json!({"id": b})) { + Err(EngineError::Approve(q)) => assert!(q.contains("came with a workbook you opened"), "{q}"), + other => panic!("expected a question, got {other:?}"), + } + assert_eq!(v(&s, "A1"), Value::Empty, "nothing ran"); + s.execute("control.press", json!({"id": b, "approve": true})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(7.0)); + s.execute("cell.set", json!({"cell": "A1", "input": ""})).unwrap(); + s.execute("control.press", json!({"id": b})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(7.0), "approved for the session"); + // A command that reads a file always asks. + s.doc_mut().unwrap().trust = Trust::Local; + let f = id(&s.execute("insert.button", json!({"command": "data.fromTextCsv", "params": {"path": "/nonexistent/x.csv"}})).unwrap()); + assert!(matches!(s.execute("control.press", json!({"id": f})), Err(EngineError::Approve(q)) if q.contains("reads a file"))); + // A denied command that a file brought in never runs. + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + if let Some(c) = sh.controls.iter_mut().find(|c| c.id as u64 == f) { + c.action = Some(ControlAction::Command { id: "file.saveAs".into(), params: json!({"path": "/tmp/x.xlsx"}) }); + } + assert!(err(s.execute("control.press", json!({"id": f, "approve": true}))).contains("can't run file.saveAs")); + // A macro button in a file's workbook asks too (the runner's gate). + s.execute( + "macro.save", + json!({"name": "M", "steps": [{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}], "output": {"onConflict": "overwrite"}}), + ) + .unwrap(); + let m = id(&s.execute("insert.button", json!({"macro": "M"})).unwrap()); + s.doc_mut().unwrap().trust = Trust::FromFile; + assert!(matches!(s.execute("control.press", json!({"id": m})), Err(EngineError::Approve(_)))); + s.execute("control.press", json!({"id": m, "approve": true})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Number(1.0)); +} + +#[test] +fn hostile_params_are_refused() { + let mut s = s(); + let c = id(&s.execute("insert.slider", json!({"width": 1e30, "height": -5, "at": "XFD1048576"})).unwrap()); + let a = control(&s, c).anchor; + assert_eq!((a.width, a.height), (10_000.0, 4.0)); + let long = "x".repeat(2_000); + assert!(s.execute("insert.button", json!({"text": long})).is_err()); + assert!(s.execute("insert.button", json!({"text": 5})).is_err()); + assert!(s.execute("insert.button", json!({"command": "cell.set", "params": [1]})).is_err()); + assert!(s.execute("control.format", json!({"id": c, "link": 5})).is_err()); + assert!(s.execute("control.format", json!({"id": c, "value": f64::MAX})).is_err()); + assert!(s.execute("control.setValue", json!({"id": c, "value": f64::MIN})).is_ok()); + assert_eq!(control(&s, c).value, 0); + // Snapping keeps the maximum reachable when the step doesn't divide the range. + let odd = FormControl { kind: ControlKind::Spin, min: 0, max: 10, step: 3, ..Default::default() }; + assert_eq!((odd.snap(9.4), odd.snap(9.6), odd.snap(f64::NAN)), (9, 10, 0)); +} + +fn link(s: &Session, sheet_i: usize, id: u64) -> String { + let sh = s.doc().unwrap().wb.sheet(sheet_i).unwrap(); + sh.controls.iter().find(|c| c.id as u64 == id).and_then(|c| c.link.clone()).unwrap_or_default() +} + +#[test] +fn links_follow_inserts_deletes_renames_and_cut_moves() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Data"})).unwrap(); + // A control on Data pointing back at Sheet1. + let back = id(&s.execute("insert.spinButton", json!({"at": "C3", "link": "Sheet1!$B$2"})).unwrap()); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + let local = id(&s.execute("insert.slider", json!({"at": "E2", "link": "$B$2"})).unwrap()); + let remote = id(&s.execute("insert.spinButton", json!({"at": "E6", "link": "Data!$A$1"})).unwrap()); + + s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); + s.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap(); + assert_eq!(link(&s, 0, local), "$C$3"); + assert_eq!(link(&s, 1, back), "Sheet1!$C$3"); + assert_eq!(link(&s, 0, remote), "Data!$A$1", "edits on Sheet1 leave links into Data alone"); + + s.execute("cell.set", json!({"cell": "C3", "input": "7"})).unwrap(); + s.execute("edit.cut", json!({"range": "C3"})).unwrap(); + s.execute("edit.paste", json!({"at": "G9"})).unwrap(); + assert_eq!(link(&s, 0, local), "$G$9", "a cut-move takes the link with the cell"); + assert_eq!(link(&s, 1, back), "Sheet1!$G$9"); + + s.execute("sheet.rename", json!({"sheet": 1, "name": "Inputs"})).unwrap(); + assert_eq!(link(&s, 0, remote), "Inputs!$A$1"); + s.execute("home.deleteRows", json!({"rows": "9:9"})).unwrap(); + assert!(link(&s, 0, local).contains("#REF!"), "{}", link(&s, 0, local)); + s.execute("home.deleteSheet", json!({"sheet": 1})).unwrap(); + assert!(link(&s, 0, remote).contains("#REF!"), "{}", link(&s, 0, remote)); + // A link to a deleted cell writes nothing. + s.execute("control.setValue", json!({"id": local, "value": 5})).unwrap(); + for _ in 0..3 { + s.execute("edit.undo", json!({})).unwrap(); + } + assert_eq!((link(&s, 0, local), link(&s, 0, remote)), ("$G$9".to_string(), "Inputs!$A$1".to_string())); +} + +#[test] +fn a_locked_control_on_a_protected_sheet_cannot_be_changed() { + let mut s = s(); + let c = id(&s.execute("insert.slider", json!({"link": "B1"})).unwrap()); + let b = id(&s.execute("insert.button", json!({})).unwrap()); + s.execute("review.protectSheet", json!({})).unwrap(); + assert!(err(s.execute("control.format", json!({"id": c, "max": 50}))).contains("protected")); + assert!(err(s.execute("control.assign", json!({"id": b, "command": "home.bold"}))).contains("protected")); +} diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index aa17080fa..fe983f97e 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -2434,3 +2434,58 @@ fn a_shortcut_taken_while_recording_is_reported_when_it_is_left_off() { assert!(r.get("shortcutDropped").is_none(), "{r}"); assert_eq!(s.execute("macro.get", json!({"name": "Keep"})).unwrap()["shortcut"], "Ctrl+Shift+Q"); } + +/// `output` (a form button's output cell) moves where the macro writes: the footprint, the +/// conflict rule and `macro.check` protect the data at the cells it names, as at the macro's own +/// anchor, and a value that names no cell is refused before anything runs. +#[test] +fn an_output_override_is_checked_and_protected_like_the_macros_own_anchor() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Other"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + put( + &mut s, + json!({"name": "Placed", "output": {"anchor": "D1"}, "steps": [ + {"command": "cell.set", "params": {"cell": "Output", "input": "out"}} + ]}), + ); + set(&mut s, "F1", "keep me"); + let start = s.doc().unwrap().wb.clone(); + // Writing over F1 asks (programmatic runs fail) and changes nothing, as it would at D1. + let r = s.execute("macro.run", json!({"name": "Placed", "output": "F1"})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert!(q.contains("(F1 on Sheet1)"), "{q}"); + let err = s.execute("macro.run", json!({"name": "Placed", "output": "F1", "onConflict": "fail"})).unwrap_err().to_string(); + assert!(err.contains("Nothing was changed"), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); + // macro.check reports the override and its conflict. + let c = s.execute("macro.check", json!({"name": "Placed", "output": "F1"})).unwrap(); + assert_eq!((&c["ok"], &c["output"], &c["conflicts"]), (&json!(false), &json!("F1"), &json!(1)), "{c}"); + // On another sheet too. + s.execute("cell.set", json!({"cell": "Other!B2", "input": "theirs"})).unwrap(); + let r = s.execute("macro.run", json!({"name": "Placed", "output": "Other!B2"})); + assert!(matches!(&r, Err(EngineError::Confirm(q)) if q.contains("(B2 on Other)")), "{r:?}"); + // The stored anchor holding data doesn't matter when the output goes elsewhere. + set(&mut s, "D1", "stored anchor's data"); + s.execute("macro.run", json!({"name": "Placed", "output": "H1"})).unwrap(); + assert_eq!((v(&s, "H1").display(), v(&s, "D1").display()), ("out".to_string(), "stored anchor's data".to_string())); + // Hostile values are refused before anything runs. + let before = s.doc().unwrap().wb.clone(); + for (bad, needle) in [ + (json!(5), "`output` must be reference text"), + (json!({"cell": "F1"}), "`output` must be reference text"), + (json!(" "), "`output` must be reference text"), + (json!("x".repeat(1_025)), "`output` must be reference text"), + (json!("F1\nF2"), "`output` must be reference text"), + (json!("NoSuchSheet!A1"), "output anchor"), + (json!("XFE1"), "output anchor"), + (json!("A1048577"), "output anchor"), + (json!("not a cell!!"), "output anchor"), + ] { + let err = s.execute("macro.run", json!({"name": "Placed", "output": bad, "onConflict": "overwrite"})).unwrap_err().to_string(); + assert!(err.contains(needle), "{bad}: {err}"); + let c = s.execute("macro.check", json!({"name": "Placed", "output": bad})).unwrap(); + assert!(c["errors"].to_string().contains(needle), "{bad}: {c}"); + } + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &before)); +} diff --git a/crates/engine/src/tests/objects.rs b/crates/engine/src/tests/objects.rs index 7082b9054..3d420be16 100644 --- a/crates/engine/src/tests/objects.rs +++ b/crates/engine/src/tests/objects.rs @@ -19,9 +19,9 @@ fn id_of(v: &serde_json::Value, key: &str) -> u32 { v[key].as_u64().unwrap() as u32 } -/// A table with one object of each kind on the first sheet: a chart, a picture, a shape and a -/// slicer, in `ObjKind::ALL` order. -fn every_kind(s: &mut Session) -> [ObjRef; 4] { +/// A table with one object of each kind on the first sheet: a chart, a picture, a shape, a +/// slicer and a form control, in `ObjKind::ALL` order. +fn every_kind(s: &mut Session) -> [ObjRef; 5] { s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap(); s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap(); let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart"); @@ -29,7 +29,14 @@ fn every_kind(s: &mut Session) -> [ObjRef; 4] { let shape = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "H18"})).unwrap(), "shape"); s.execute("selection.set", json!({"cell": "A2"})).unwrap(); let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer"); - [ObjRef::new(ObjKind::Chart, chart), ObjRef::new(ObjKind::Image, image), ObjRef::new(ObjKind::Shape, shape), ObjRef::new(ObjKind::Slicer, slicer)] + let control = id_of(&s.execute("insert.slider", json!({"at": "P18", "link": "$B$7"})).unwrap(), "control"); + [ + ObjRef::new(ObjKind::Chart, chart), + ObjRef::new(ObjKind::Image, image), + ObjRef::new(ObjKind::Shape, shape), + ObjRef::new(ObjKind::Slicer, slicer), + ObjRef::new(ObjKind::Control, control), + ] } #[test] @@ -58,9 +65,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() { assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}"); } } - // One undo step per delete brings the slicer back. + // One undo step per delete brings the control back. s.execute("edit.undo", json!({})).unwrap(); - assert!(sheet(&s, 0).has_object(objs[3])); + assert!(sheet(&s, 0).has_object(objs[4])); } #[test] @@ -74,9 +81,11 @@ fn selection_pane_lists_and_stacks_slicers() { for k in ObjKind::ALL { assert!(kinds.contains(&k.name()), "{k:?} listed in {kinds:?}"); } - assert_eq!(kinds.last(), Some(&"slicer"), "slicers stack on top"); + assert_eq!(kinds.last(), Some(&"control"), "form controls stack on top"); + assert_eq!(kinds.get(kinds.len() - 2), Some(&"slicer"), "slicers stack above charts, pictures and shapes"); let names: Vec<&str> = items.iter().map(|o| o["name"].as_str().unwrap()).collect(); assert!(names.contains(&"Region") && names.contains(&"Qty"), "{names:?}"); + assert!(names.contains(&format!("Slider {}", objs[4].id).as_str()), "{names:?}"); assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(objs[3].id)); s.execute("arrange.sendBackward", json!({"kind": "slicer", "id": second, "toBack": true})).unwrap(); assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(second)); @@ -112,17 +121,17 @@ fn cell_of(s: &Session, o: ObjRef) -> String { fn every_kind_follows_row_and_column_edits_and_sorts() { let mut s = s(); let objs = every_kind(&mut s); - // Anchors: chart D2, picture D18, shape H18, slicer L2. + // Anchors: chart D2, picture D18, shape H18, slicer L2, slider P18. s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap(); let cells = |s: &Session| objs.map(|o| cell_of(s, o)); - assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]); + assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "P18"]); s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap(); - assert_eq!(cells(&s), ["D2", "D20", "H18", "L2"], "rows inserted above move the objects below them; an absolute shape stays"); + assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "P20"], "rows inserted above move the objects below them; an absolute shape stays"); s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap(); - assert_eq!(cells(&s), ["E2", "E20", "H18", "M2"]); + assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "Q20"]); s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap(); s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap(); - assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]); + assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "P18"]); // Sorting moves a picture anchored inside the sorted block with its row. s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap(); let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize)); @@ -177,6 +186,7 @@ fn a_copied_sheet_gives_its_objects_fresh_ids() { .chain(sh.images.iter().map(|c| c.id)) .chain(sh.shapes.iter().map(|c| c.id)) .chain(sh.slicers.iter().map(|c| c.id)) + .chain(sh.controls.iter().map(|c| c.id)) .collect() }; let mut ids: Vec = wb.sheets.iter().flat_map(|sh| ids_of(sh)).collect(); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 0d6cc53ff..0c7f880d7 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -604,6 +604,171 @@ pub struct Shape { pub format: ShapeFormat, } +/// What a form control on the sheet is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ControlKind { + #[default] + Button, + Slider, + Spin, +} + +impl ControlKind { + /// The kind's name in the window: Button, Slider, Spin Button. + pub fn label(self) -> &'static str { + match self { + ControlKind::Button => "Button", + ControlKind::Slider => "Slider", + ControlKind::Spin => "Spin Button", + } + } +} + +/// The largest value a slider or spin button takes (Excel's scroll bar limit). Values are whole +/// numbers from 0 to this. +pub const CONTROL_MAX: u32 = 30_000; + +/// What a button runs when it is pressed. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ControlAction { + /// Runs a macro of the workbook on `input`, optionally sending its output to `output`. + Macro { + name: String, + #[serde(default)] + input: MacroInput, + #[serde(default, skip_serializing_if = "Option::is_none")] + output: Option, + }, + /// Runs one GridCraft command with fixed parameters. + Command { + id: String, + #[serde(default)] + params: serde_json::Value, + }, +} + +/// The range a button's macro runs on. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MacroInput { + /// The selection when the button is pressed. + #[default] + Selection, + /// Always this range (`B2:D9` or `Data!B2:D9`). + Fixed(String), +} + +/// A form control (button, slider, spin button) floating over the sheet. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct FormControl { + pub id: u32, + pub kind: ControlKind, + pub anchor: Anchor, + /// The name shown in the Selection Pane (`Button 3`); empty means the kind and id. + pub name: String, + /// The caption of a button. + pub text: String, + /// The linked cell as reference text: `$B$2` on the control's own sheet, or `Sheet2!$B$2`. + /// Structural edits rewrite it like a formula reference. + pub link: Option, + /// The current value of a slider or spin button (the linked cell holds it too). + pub value: u32, + pub min: u32, + pub max: u32, + /// Incremental change: one arrow click or arrow key. + pub step: u32, + /// Page change (sliders): a click on the track, or Page Up / Page Down. + pub page: u32, + /// A slider lies horizontally (false: vertically). + pub horizontal: bool, + /// What a button runs. + pub action: Option, + /// Printed with the sheet. + pub print: bool, + /// Locked when the sheet is protected. + pub locked: bool, +} + +impl Default for FormControl { + fn default() -> Self { + FormControl { + id: 0, + kind: ControlKind::default(), + anchor: Anchor::default(), + name: String::new(), + text: String::new(), + link: None, + value: 0, + min: 0, + max: 100, + step: 1, + page: 10, + horizontal: true, + action: None, + print: true, + locked: true, + } + } +} + +impl FormControl { + /// The name shown for the control: its own, else `Button 3`, `Slider 4`, `Spin Button 5`. + pub fn display_name(&self) -> String { + if self.name.trim().is_empty() { format!("{} {}", self.kind.label(), self.id) } else { self.name.clone() } + } + + /// Checks the numbers of a slider or spin button: 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min, + /// and (sliders) page ≥ step. Each rule has its own message. + pub fn check_numbers(&self) -> Result<(), String> { + if self.kind == ControlKind::Button { + return Ok(()); + } + if self.max > CONTROL_MAX { + return Err(format!("Maximum value can be at most {CONTROL_MAX}.")); + } + if self.min > CONTROL_MAX { + return Err(format!("Minimum value must be a whole number from 0 to {CONTROL_MAX}.")); + } + if self.min >= self.max { + return Err(format!("Maximum value ({}) must be greater than Minimum value ({}).", self.max, self.min)); + } + if self.step < 1 { + return Err("Incremental change must be at least 1.".into()); + } + let span = self.max - self.min; + if self.step > span { + return Err(format!("Incremental change ({}) can be at most Maximum minus Minimum ({span}).", self.step)); + } + if self.kind == ControlKind::Slider { + if self.page < self.step { + return Err(format!("Page change ({}) must be at least the incremental change ({}).", self.page, self.step)); + } + if self.page > CONTROL_MAX { + return Err(format!("Page change can be at most {CONTROL_MAX}.")); + } + } + Ok(()) + } + + /// `v` clamped to min..max and snapped to the nearest step from min (max stays reachable). + pub fn snap(&self, v: f64) -> u32 { + let (min, max) = (self.min.min(CONTROL_MAX), self.max.min(CONTROL_MAX).max(self.min.min(CONTROL_MAX))); + if !v.is_finite() || v <= min as f64 { + return min; + } + if v >= max as f64 { + return max; + } + let step = self.step.max(1) as f64; + let snapped = (min as f64 + ((v - min as f64) / step).round() * step).clamp(min as f64, max as f64); + // When the step doesn't divide the range, the maximum is a stop of its own. + if (max as f64 - v).abs() < (snapped - v).abs() { max } else { snapped as u32 } + } +} + /// The kinds of floating object a sheet holds. Commands name an object by kind and /// workbook-unique id (`{kind: "chart", id}`); [`ObjKind::name`] is that parameter's spelling. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -613,10 +778,11 @@ pub enum ObjKind { Image, Shape, Slicer, + Control, } impl ObjKind { - pub const ALL: [ObjKind; 4] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer]; + pub const ALL: [ObjKind; 5] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Control]; /// The name used in command parameters. pub fn name(self) -> &'static str { @@ -625,6 +791,7 @@ impl ObjKind { ObjKind::Image => "image", ObjKind::Shape => "shape", ObjKind::Slicer => "slicer", + ObjKind::Control => "control", } } diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index d442a5340..70b504174 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use crate::cell::{Cell, CellPicture}; use crate::features::{ - Anchor, AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, SheetProtection, - Slicer, Sparkline, Table, Validation, + Anchor, AutoFilter, Chart, Comment, CondFormat, FormControl, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, + SheetProtection, Slicer, Sparkline, Table, Validation, }; use crate::store::CellStore; use crate::style::{Color, StyleId}; @@ -111,6 +111,10 @@ pub struct Sheet { /// On-sheet slicer widgets (filter a table column by clicking value tiles). #[serde(default)] pub slicers: Vec, + /// Form controls (buttons, sliders, spin buttons). They paint above every other object and + /// take clicks first. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub controls: Vec, /// PivotTables whose output lives on this sheet. #[serde(default)] pub pivots: Vec, @@ -182,6 +186,7 @@ impl Sheet { shapes: vec![], sparklines: vec![], slicers: vec![], + controls: vec![], pivots: vec![], protection: None, edit_ranges: vec![], @@ -212,6 +217,8 @@ impl Sheet { ObjKind::Shape } else if self.slicers.iter().any(|o| o.id == id) { ObjKind::Slicer + } else if self.controls.iter().any(|o| o.id == id) { + ObjKind::Control } else { return None; }; @@ -230,6 +237,7 @@ impl Sheet { ObjKind::Image => self.images.iter().find(|c| c.id == o.id).map(|c| c.anchor), ObjKind::Shape => self.shapes.iter().find(|c| c.id == o.id).map(|c| c.anchor), ObjKind::Slicer => self.slicers.iter().find(|c| c.id == o.id).map(|c| c.anchor), + ObjKind::Control => self.controls.iter().find(|c| c.id == o.id).map(|c| c.anchor), } } @@ -239,6 +247,7 @@ impl Sheet { ObjKind::Image => self.images.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), ObjKind::Shape => self.shapes.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), ObjKind::Slicer => self.slicers.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), + ObjKind::Control => self.controls.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), } } @@ -254,12 +263,13 @@ impl Sheet { ObjKind::Image => take(&mut self.images, |c| c.id == o.id), ObjKind::Shape => take(&mut self.shapes, |c| c.id == o.id), ObjKind::Slicer => take(&mut self.slicers, |c| c.id == o.id), + ObjKind::Control => take(&mut self.controls, |c| c.id == o.id), } } - /// Every floating object's anchor in a fixed order (charts, images, shapes, slicers) that - /// [`Sheet::object_anchors_mut`] repeats, so a pass can compute new anchors from the old ones - /// and write them back in step. + /// Every floating object's anchor in a fixed order (charts, images, shapes, slicers, controls) + /// that [`Sheet::object_anchors_mut`] repeats, so a pass can compute new anchors from the old + /// ones and write them back in step. pub fn object_anchors(&self) -> impl Iterator { self.charts .iter() @@ -267,6 +277,7 @@ impl Sheet { .chain(self.images.iter().map(|o| &o.anchor)) .chain(self.shapes.iter().map(|o| &o.anchor)) .chain(self.slicers.iter().map(|o| &o.anchor)) + .chain(self.controls.iter().map(|o| &o.anchor)) } pub fn object_anchors_mut(&mut self) -> impl Iterator { @@ -276,6 +287,7 @@ impl Sheet { .chain(self.images.iter_mut().map(|o| &mut o.anchor)) .chain(self.shapes.iter_mut().map(|o| &mut o.anchor)) .chain(self.slicers.iter_mut().map(|o| &mut o.anchor)) + .chain(self.controls.iter_mut().map(|o| &mut o.anchor)) } /// Displayed value of a cell: its own value, or a spilled value. pub fn value(&self, c: CellRef) -> Value { diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 12feee628..dca068155 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -432,6 +432,9 @@ impl Workbook { for c in &s.slicers { m = m.max(c.id); } + for c in &s.controls { + m = m.max(c.id); + } } m.saturating_add(1) } @@ -493,6 +496,10 @@ mod tests { sh.images.push(image(7)); } assert_eq!(wb.next_object_id(), 8); + if let Some(sh) = wb.sheet_mut(0) { + sh.controls.push(crate::FormControl { id: 12, ..Default::default() }); + } + assert_eq!(wb.next_object_id(), 13, "form controls take ids from the same sequence"); if let Some(sh) = wb.sheet_mut(0) { sh.images.push(image(u32::MAX)); } diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 004f07d09..e6afa30bd 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -124,7 +124,7 @@ pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction { match o.kind { ObjKind::Chart => OpenAction::FormatChart(o.id), ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id), - ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select, + ObjKind::Shape | ObjKind::Image | ObjKind::Slicer | ObjKind::Control => OpenAction::Select, } } diff --git a/docs/macros.md b/docs/macros.md index 3335b5ecc..9a4b32859 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) +> **Last updated:** 2026-10-31 · **Change:** minor (form buttons run macros; `macro.run` takes `output`) GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro @@ -98,12 +98,18 @@ refused rather than read as the header. ## Running -`macro.run {name, inputs?, onConflict?, overwrite?, approve?, timeLimit?}`: +`macro.run {name, inputs?, output?, onConflict?, overwrite?, approve?, timeLimit?}` (`output` +replaces the macro's output anchor for this run, as a form button that sends the output elsewhere +does; `macro.check` and the debugger take it too). The policy and the approval go by the macro as +it is stored; the footprint, the conflict rule and the check of each step work on the cells +`output` names, so data there is protected as at the macro's own anchor. An `output` that isn't +reference text of at most 1,024 characters, or that names no cell (a bad reference, a sheet that +doesn't exist, a cell outside the grid), is refused before anything runs: 1. **Policy.** File commands other than Properties, exports and printing (such as Open, Save and Close), app, window (including New Window, Hide and Unhide), automate and macro commands, - and Undo and Redo, never run in a macro. File › Properties runs, and so do exports and - printing (`file.exportPdf`, `exportCsv`, `exportHtml`, `print`, `printPreview`, `saveBytes`) + Undo and Redo, and pressing a form control (`control.press`), never run in a macro. File › + Properties runs, and so do exports and printing (`file.exportPdf`, `exportCsv`, `exportHtml`, `print`, `printPreview`, `saveBytes`) that only produce bytes. Reading or writing a file (`data.getData`, `data.fromTextCsv`, `insert.picture` or `pageLayout.background` with a `path`, or an export or print with a `path`) needs the command in the macro's `permissions`. Any `path` other than `null` counts, @@ -246,6 +252,18 @@ kept but does not run the macro. {name, to}`. Saving under a name a macro has is refused unless `replace: true` is passed, and renaming to one is refused; names compare ignoring the case of the letters A to Z. A renamed macro is checked against every limit again, its size included. Saving, renaming and deleting are undoable. +Renaming a macro also renames it in the form buttons that run it. + +## Form buttons + +A form button (Insert › Controls › Button, `insert.button`) runs a macro or one command when it is +pressed (`control.press {id, approve?, onConflict?}`). `control.assign {id, macro, input?, +output?}` makes it run a macro, on the selection when it is pressed (`input: "selection"`, the +default) or always on one range (`input: "B2:D9"`), with an optional output cell; `control.assign +{id, command, params}` makes it run a command with fixed parameters. A macro runs through +`macro.run` with its checks; a command goes through the same policy as a macro step and runs, as +a step does, inside the scripts' file sandbox unless it is an approved file command, so a button +in a workbook opened from a file asks before it runs anything. Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden defined names (`_gc_macro__`, the macro's JSON split into string literals within Excel's From 295c57535c3f2d42e70df2a1cf03ca9c309d49d1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:38:07 -0400 Subject: [PATCH 10/55] XLSX: form controls (control properties, and VML ClientData), alongside kept controls Buttons, scroll bars (sliders) and spin buttons are written the three ways the published formats describe: a control properties part (xl/ctrlProps/ctrlPropN.xml, formControlPr with objectType, fmlaLink, min, max, inc, page, val and horiz), a in the sheet's inside mc:AlternateContent with its anchor, print and locked flags and a button's macro="[0]!Name", and a VML shape with x:ClientData. Notes and controls share one VML drawing per sheet; shape ids come from 1024-id blocks listed in the drawing's idmap (after the blocks kept header and footer drawings use), notes first, so each control's shapeId names its VML shape. Reading prefers the properties part, takes the caption from the VML text box and falls back to the VML alone for files that only have it. Values are clamped to whole numbers 0..30000 and repaired to pass the model's rules. Form controls of other kinds (check boxes, list boxes, drop-downs) stay with pass-through: the reader returns the shape numbers it modelled, and pass-through keeps the rest of the element and VML drawing without them. On save, modelled controls join the kept element and take a fresh shape block in the kept drawing, so a sheet with both round-trips without duplicates. Properties part names are reserved so kept parts of other sheets don't collide with them. Button actions xlsx has no place for (commands, fixed inputs, output cells) travel in a chunked hidden name keyed by sheet and position, and are taken back out on open. The streaming sheet parser now splits only shallow elements, so an anchor's inside stays in the tree. --- crates/engine/src/cmd/controls.rs | 58 ++- crates/engine/src/io.rs | 10 +- crates/engine/src/tests/controls.rs | 60 +++ crates/xlsx/src/controls.rs | 506 +++++++++++++++++++++ crates/xlsx/src/drawing.rs | 2 +- crates/xlsx/src/lib.rs | 1 + crates/xlsx/src/package.rs | 8 + crates/xlsx/src/passthrough.rs | 4 +- crates/xlsx/src/passthrough/read.rs | 131 +++++- crates/xlsx/src/passthrough/rebase.rs | 40 +- crates/xlsx/src/passthrough/write.rs | 6 + crates/xlsx/src/read.rs | 6 +- crates/xlsx/src/sheet_read.rs | 17 +- crates/xlsx/src/sheet_write.rs | 54 ++- crates/xlsx/src/sheet_write/parts.rs | 10 +- crates/xlsx/src/tests.rs | 1 + crates/xlsx/src/tests/controls_tests.rs | 205 +++++++++ crates/xlsx/src/tests/passthrough_tests.rs | 61 +++ crates/xlsx/src/write.rs | 7 + crates/xlsx/src/xml.rs | 9 +- docs/file-format-parity.md | 4 +- 21 files changed, 1131 insertions(+), 69 deletions(-) create mode 100644 crates/xlsx/src/controls.rs create mode 100644 crates/xlsx/src/tests/controls_tests.rs diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 77193e091..94a4ef7ec 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -13,7 +13,7 @@ use serde_json::{Value as Json, json}; use super::{ CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, find_command, - has_doc, split_sheet, str_param, u32_param, + has_doc, split_sheet, stash, str_param, u32_param, }; use crate::macros::policy::{Policy, macro_policy}; @@ -530,3 +530,59 @@ pub(crate) fn sync_linked_values(wb: &mut Workbook) { } } } + +// ---------------------------------------------------------------- xlsx + +/// The hidden-data key of the button actions xlsx has no place for. +const ACTIONS_KEY: &str = "control_actions"; + +/// A button's action that xlsx carries itself: a macro on the selection (`macro="[0]!Name"`). +fn plain(a: &ControlAction) -> bool { + matches!(a, ControlAction::Macro { input: MacroInput::Selection, output: None, .. }) +} + +/// A copy of `wb` for xlsx with the button actions xlsx can't express (commands, fixed inputs, +/// output cells) in a hidden name, keyed by sheet name and the control's place on the sheet. +/// `None` when there is nothing to store. +pub(crate) fn with_action_names(wb: &Workbook) -> Result> { + let mut items = Vec::new(); + for sh in &wb.sheets { + for (i, c) in sh.controls.iter().enumerate() { + if let Some(a) = c.action.as_ref().filter(|a| !plain(a)) { + items.push(json!({"sheet": sh.name, "index": i, "action": a})); + } + } + } + let stale = stash::get(wb, ACTIONS_KEY).is_some(); + if items.is_empty() && !stale { + return Ok(None); + } + let mut out = wb.clone(); + stash::remove(&mut out, ACTIONS_KEY); + if !items.is_empty() { + stash::set_json(&mut out, ACTIONS_KEY, &Json::Array(items))?; + } + Ok(Some(out)) +} + +/// After a file is opened: gives buttons back the actions kept in the hidden name, and removes +/// it. Entries that don't match a button, or don't parse, are dropped. +pub(crate) fn load_action_names(wb: &mut Workbook) { + let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return }; + stash::remove(wb, ACTIONS_KEY); + for item in items.as_array().into_iter().flatten().take(100_000) { + let (Some(sheet), Some(index)) = (item.get("sheet").and_then(Json::as_str), item.get("index").and_then(Json::as_u64)) else { continue }; + let Ok(action) = serde_json::from_value::(item.get("action").cloned().unwrap_or(Json::Null)) else { continue }; + let valid = match &action { + ControlAction::Macro { name, .. } => !name.trim().is_empty() && name.chars().count() <= MAX_MACRO_NAME, + ControlAction::Command { id, params } => !id.is_empty() && id.len() <= 200 && params.is_object(), + }; + let Some(si) = wb.sheet_index(sheet) else { continue }; + if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index as usize).is_some_and(|c| c.kind == ControlKind::Button)) { + continue; + } + if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index as usize)) { + c.action = Some(action); + } + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 71060a4c9..8351bb3f4 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -118,10 +118,12 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String { } } -/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names. +/// Parses bytes that aren't encrypted, then takes the workbook's macros, and the button actions +/// xlsx has no place for, out of their hidden names. fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec)> { let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?; warnings.extend(crate::macros::load(&mut wb)); + crate::cmd::controls::load_action_names(&mut wb); Ok((wb, warnings)) } @@ -247,9 +249,13 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result", + if move_cells { " moveWithCells=\"1\"" } else { "" }, + if size_cells { " sizeWithCells=\"1\"" } else { "" }, + marker("from", from, fdx, fdy), + marker("to", to, tdx, tdy), + ); + vml.push_str(&vml_shape(sheet, c, id, i)); + } + Written { entries: xml, vml } +} + +/// The control properties part. +fn ctrl_prop(c: &FormControl) -> String { + let mut s = format!("{} { + s.push_str(" lockText=\"1\""); + } + ControlKind::Slider | ControlKind::Spin => { + s.push_str(" dx=\"22\""); + if let Some(l) = &c.link { + let _ = write!(s, " fmlaLink=\"{}\"", esc_attr(l)); + } + if c.kind == ControlKind::Slider && c.horizontal { + s.push_str(" horiz=\"1\""); + } + let _ = write!(s, " inc=\"{}\" max=\"{}\" min=\"{}\"", c.step, c.max, c.min); + if c.kind == ControlKind::Slider { + let _ = write!(s, " page=\"{}\"", c.page); + } + let _ = write!(s, " val=\"{}\"", c.value); + } + } + s.push_str("/>"); + s +} + +/// A control's legacy VML shape. +fn vml_shape(sheet: &Sheet, c: &FormControl, id: u64, z: usize) -> String { + let ((from, fdx, fdy), (to, tdx, tdy)) = corners(sheet, &c.anchor); + let pt = |px: f64| px * 0.75; + let x = sheet.col_left(from.col) + fdx; + let y = sheet.row_top(from.row) + fdy; + let mut data = format!( + "{}{}{}, {}, {}, {}, {}, {}, {}, {}", + object_type(c.kind), + // These two elements mean the object does *not* move (size) with its cells. + if c.anchor.mode == AnchorMode::Absolute { "" } else { "" }, + if c.anchor.mode == AnchorMode::MoveAndSize { "" } else { "" }, + from.col, + fdx.round() as i64, + from.row, + fdy.round() as i64, + to.col, + tdx.round() as i64, + to.row, + tdy.round() as i64 + ); + if !c.print { + data.push_str("False"); + } + if !c.locked { + data.push_str("False"); + } + data.push_str("False"); + match c.kind { + ControlKind::Button => { + if let Some(m) = macro_ref(c) { + let _ = write!(data, "{}", esc(&m)); + } + data.push_str("CenterCenter"); + } + ControlKind::Slider | ControlKind::Spin => { + if let Some(l) = &c.link { + let _ = write!(data, "{}", esc(l)); + } + let _ = write!(data, "{}{}{}{}", c.value, c.min, c.max, c.step); + if c.kind == ControlKind::Slider { + let _ = write!(data, "{}", c.page); + if c.horizontal { + data.push_str(""); + } + } + data.push_str("22"); + } + } + data.push_str(""); + let text = if c.kind == ControlKind::Button { + format!("
{}
", esc(&c.text)) + } else { + String::new() + }; + format!( + "{text}{data}", + pt(x), + pt(y), + pt(c.anchor.width.max(0.0) as f64), + pt(c.anchor.height.max(0.0) as f64), + z + 1, + if c.kind == ControlKind::Button { " o:button=\"t\"" } else { " stroked=\"f\" filled=\"f\"" }, + ) +} + +/// The VML shape type form controls use. +pub const SHAPETYPE: &str = ""; + +// ---------------------------------------------------------------- reading + +/// What a control's properties say, from `formControlPr` or VML `x:ClientData`. +#[derive(Default)] +struct Props { + kind: Option, + link: Option, + min: Option, + max: Option, + inc: Option, + page: Option, + val: Option, + horiz: Option, + macro_: Option, + text: String, + print: Option, + locked: Option, +} + +fn props_from_ctrl_prop(e: &El) -> Props { + let int = |k: &str| e.attr_f64(k).map(|v| v.round() as i64); + Props { + kind: e.attr("objectType").and_then(kind_of), + link: e.attr("fmlaLink").map(str::to_string), + min: int("min"), + max: int("max"), + inc: int("inc"), + page: int("page"), + val: int("val"), + horiz: e.attr_bool("horiz"), + macro_: None, + text: String::new(), + print: None, + locked: None, + } +} + +fn props_from_vml(shape: &El) -> Option { + let data = shape.child("ClientData")?; + let kind = data.attr("ObjectType").and_then(kind_of)?; + let text_of = |n: &str| data.child(n).map(|c| c.text.trim().to_string()); + let int = |n: &str| text_of(n).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()).map(|v| v.round() as i64); + let truth = |n: &str| text_of(n).map(|t| !t.eq_ignore_ascii_case("false")); + let text = shape.child("textbox").map(|t| all_text(t, 0)).unwrap_or_default().trim().to_string(); + Some(Props { + kind: Some(kind), + link: text_of("FmlaLink").filter(|t| !t.is_empty()), + min: int("Min"), + max: int("Max"), + inc: int("Inc"), + page: int("Page"), + val: int("Val"), + horiz: data.child("Horiz").map(|_| true), + macro_: text_of("FmlaMacro"), + text, + print: truth("PrintObject"), + locked: truth("Locked"), + }) +} + +/// The text inside `e` and its descendants (a VML text box holds `
` and ``), up to a +/// caption's length; `
` is a line break. +fn all_text(e: &El, depth: usize) -> String { + let mut s = e.text.clone(); + if depth < 16 { + for k in &e.children { + if k.name == "br" { + s.push('\n'); + } + s.push_str(&all_text(k, depth + 1)); + if s.len() > 4_096 { + break; + } + } + } + s +} + +/// A control from what the file says, its numbers clamped to whole numbers 0..30000 that pass +/// the model's rules (a hostile or odd file can't give a control it couldn't have). +fn build(p: Props, kind: ControlKind, anchor: Anchor, name: Option<&str>, id: u32) -> FormControl { + let clamp = |v: Option, d: u32| v.map_or(d, |v| v.clamp(0, CONTROL_MAX as i64) as u32); + let mut c = FormControl { id, kind, anchor, ..FormControl::default() }; + c.min = clamp(p.min, 0); + c.max = clamp(p.max, 100); + if c.min >= c.max { + if c.min >= CONTROL_MAX { + c.min = CONTROL_MAX - 1; + } + c.max = c.min + 1; + } + c.step = clamp(p.inc, 1).clamp(1, c.max - c.min); + c.page = clamp(p.page, 10).max(c.step); + c.value = c.snap(clamp(p.val, c.min) as f64); + // Only sliders have an orientation; `horiz` is left out for a vertical one. + c.horizontal = kind != ControlKind::Slider || p.horiz.unwrap_or(false); + c.link = p.link.map(|l| l.trim().trim_start_matches('=').to_string()).filter(|l| !l.is_empty() && l.chars().count() <= 300); + c.text = p.text.chars().take(1_024).collect(); + if let Some(n) = name { + c.name = n.chars().take(255).collect(); + } + c.print = p.print.unwrap_or(true); + c.locked = p.locked.unwrap_or(true); + if kind == ControlKind::Button + && let Some(m) = p.macro_.as_deref().and_then(macro_name) + { + c.action = Some(ControlAction::Macro { name: m, input: MacroInput::Selection, output: None }); + } + c +} + +/// The `` elements under a `` element, looking through `mc:AlternateContent` +/// (the Choice, else the Fallback). +fn controls_in(e: &El) -> Vec<&El> { + let mut out = Vec::new(); + for k in &e.children { + match k.name.as_str() { + "control" => out.push(k), + "AlternateContent" => { + if let Some(inner) = k.child("Choice").filter(|c| c.child("control").is_some()).or_else(|| k.child("Fallback")) { + out.extend(inner.kids("control")); + } + } + _ => {} + } + } + out +} + +/// The `` element of a worksheet child (`controls`, or one inside AlternateContent). +pub fn controls_of(e: &El) -> Option<&El> { + match e.name.as_str() { + "controls" => Some(e), + "AlternateContent" => e.child("Choice").and_then(|c| c.child("controls")).or_else(|| e.child("Fallback").and_then(|c| c.child("controls"))), + _ => None, + } +} + +/// The shape number of a VML shape id (`_x0000_s1025` → 1025) or a `shapeId` attribute. +fn shape_number(id: &str) -> Option { + id.trim().trim_start_matches("_x0000_s").parse().ok() +} + +/// Reads a sheet's buttons, scroll bars and spin buttons: each `` with its properties +/// part, else its VML shape, and VML shapes the sheet doesn't list (files that only have VML). +/// Returns the shape numbers of the controls it read, so pass-through keeps only the others. +pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut Sheet) -> HashSet { + let mut read = HashSet::new(); + let vml = match rels.iter().find(|r| r.kind == "vmlDrawing") { + Some(r) => cx.pkg.read_xml(&r.target).ok().flatten(), + None => None, + }; + let shapes: Vec<&El> = vml.as_ref().map(|v| v.kids("shape").collect()).unwrap_or_default(); + let shape_of = |n: u64| shapes.iter().copied().find(|s| s.attr("id").and_then(shape_number) == Some(n)); + // Every shape the sheet lists, read here or not. + let mut listed: HashSet = HashSet::new(); + for ctl in controls.map(controls_in).unwrap_or_default() { + let Some(n) = ctl.attr("shapeId").and_then(shape_number) else { continue }; + listed.insert(n); + if sheet.controls.len() >= MAX_CONTROLS { + continue; + } + let shape = shape_of(n); + let vml_props = shape.and_then(props_from_vml); + let prop_part = ctl.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()); + let file_props = match prop_part { + Some(t) => cx.pkg.read_xml(&t).ok().flatten().filter(|e| e.name == "formControlPr").map(|e| props_from_ctrl_prop(&e)), + None => None, + }; + let pr = ctl.child("controlPr"); + let mut p = match (file_props, vml_props) { + (Some(mut f), v) => { + // The properties part has no caption; the VML text box does. + if let Some(v) = v { + f.text = v.text; + f.print = f.print.or(v.print); + f.locked = f.locked.or(v.locked); + f.macro_ = v.macro_; + } + f + } + (None, Some(v)) => v, + (None, None) => continue, + }; + if let Some(pr) = pr { + if let Some(m) = pr.attr("macro").filter(|m| !m.trim().is_empty()) { + p.macro_ = Some(m.to_string()); + } + p.print = pr.attr_bool("print").or(p.print); + p.locked = pr.attr_bool("locked").or(p.locked); + } + // Other kinds (a check box, a list box) stay with pass-through. + let Some(kind) = p.kind else { continue }; + let anchor = + pr.and_then(|pr| pr.child("anchor")).and_then(|a| anchor_from_markers(a, sheet)).or_else(|| shape.and_then(|s| vml_anchor(s, sheet))); + let Some(anchor) = anchor else { continue }; + let id = cx.next_object_id(); + sheet.controls.push(build(p, kind, anchor, ctl.attr("name"), id)); + read.insert(n); + } + // Controls known only from the VML. + for s in &shapes { + let Some(n) = s.attr("id").and_then(shape_number) else { continue }; + if listed.contains(&n) || sheet.controls.len() >= MAX_CONTROLS { + continue; + } + let Some(p) = props_from_vml(s) else { continue }; + let (Some(kind), Some(anchor)) = (p.kind, vml_anchor(s, sheet)) else { continue }; + let id = cx.next_object_id(); + sheet.controls.push(build(p, kind, anchor, None, id)); + read.insert(n); + } + read +} + +fn anchor_from_markers(a: &El, sheet: &Sheet) -> Option { + let (cell, dx, dy) = crate::drawing::marker(a.child("from")?); + let (to, tdx, tdy) = a.child("to").map(crate::drawing::marker).unwrap_or((cell, dx + 64.0, dy + 20.0)); + let x1 = sheet.col_left(cell.col) + dx as f64; + let y1 = sheet.row_top(cell.row) + dy as f64; + let x2 = sheet.col_left(to.col) + tdx as f64; + let y2 = sheet.row_top(to.row) + tdy as f64; + let mode = match (a.flag("moveWithCells", false), a.flag("sizeWithCells", false)) { + (_, true) => AnchorMode::MoveAndSize, + (true, false) => AnchorMode::MoveOnly, + _ => AnchorMode::Absolute, + }; + Some(Anchor { cell, dx, dy, width: (x2 - x1).clamp(1.0, 10_000.0) as f32, height: (y2 - y1).clamp(1.0, 10_000.0) as f32, mode }) +} + +/// A VML `x:Anchor` (`LeftColumn, LeftOffset, TopRow, TopOffset, RightColumn, RightOffset, +/// BottomRow, BottomOffset`, offsets in pixels). +fn vml_anchor(shape: &El, sheet: &Sheet) -> Option { + let data = shape.child("ClientData")?; + let nums: Vec = data.child("Anchor")?.text.split(',').filter_map(|t| t.trim().parse::().ok()).collect(); + let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = nums.as_slice() else { return None }; + let col = |c: i64| c.clamp(0, gridcraft_core::MAX_COLS as i64 - 1) as u32; + let row = |r: i64| r.clamp(0, gridcraft_core::MAX_ROWS as i64 - 1) as u32; + let off = |v: i64| v.clamp(0, 10_000) as f64; + let cell = CellRef::new(row(*r1), col(*c1)); + let x1 = sheet.col_left(cell.col) + off(*dx1); + let y1 = sheet.row_top(cell.row) + off(*dy1); + let x2 = sheet.col_left(col(*c2)) + off(*dx2); + let y2 = sheet.row_top(row(*r2)) + off(*dy2); + // The elements are there when the object does *not* move (size) with its cells. + let mode = match (data.child("MoveWithCells").is_none(), data.child("SizeWithCells").is_none()) { + (_, true) => AnchorMode::MoveAndSize, + (true, false) => AnchorMode::MoveOnly, + _ => AnchorMode::Absolute, + }; + Some(Anchor { + cell, + dx: off(*dx1) as f32, + dy: off(*dy1) as f32, + width: (x2 - x1).clamp(1.0, 10_000.0) as f32, + height: (y2 - y1).clamp(1.0, 10_000.0) as f32, + mode, + }) +} diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 0668ca9d8..2eff98418 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -19,7 +19,7 @@ fn emu_px(v: Option) -> f32 { v.map(|e| (e as f64 / EMU_PER_PX).clamp(-1e7, 1e7) as f32).unwrap_or(0.0) } -fn marker(e: &El) -> (CellRef, f32, f32) { +pub(crate) fn marker(e: &El) -> (CellRef, f32, f32) { let num = |n: &str| e.child(n).and_then(|c| c.text.trim().parse::().ok()); let col = num("col").unwrap_or(0).clamp(0, MAX_COLS as i64 - 1) as u32; let row = num("row").unwrap_or(0).clamp(0, MAX_ROWS as i64 - 1) as u32; diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 650b22820..c9cfe7acf 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -15,6 +15,7 @@ mod array_formula; mod cfb; mod chart; mod chartex; +mod controls; mod crypto; mod csv; mod drawing; diff --git a/crates/xlsx/src/package.rs b/crates/xlsx/src/package.rs index a6950a07e..e117e24e4 100644 --- a/crates/xlsx/src/package.rs +++ b/crates/xlsx/src/package.rs @@ -52,6 +52,14 @@ impl<'a> Package<'a> { Ok(Package { zip, names, total: 0 }) } + /// Every part's (normalized) name, sorted. + #[cfg(test)] + pub fn names(&self) -> Vec { + let mut v: Vec = self.names.keys().cloned().collect(); + v.sort(); + v + } + pub fn has(&self, part: &str) -> bool { self.names.contains_key(&normalize(part)) } diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index 3e2fea105..9d85e03b2 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -4,8 +4,8 @@ //! relationship graph, per owner: the package root (custom document properties, ribbon //! customisation, add-in task panes), the workbook (custom XML, links to external workbooks with //! their ``, data connections, XML maps) and each sheet (query tables, cells -//! mapped to XML elements, form controls, ActiveX controls, OLE objects, the sheet background and -//! printer settings, with the sheet's ``, `` and `` XML, the `r:id` +//! mapped to XML elements, form controls other than the buttons, scroll bars and spin buttons +//! `controls.rs` models, ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's ``, `` and `` XML, the `r:id` //! of ``, the legacy VML shapes those controls need and the pictures of the headers and //! footers, `&G`, with their ``). The workbook also keeps its //! `` and the calcFeatures extension, which Excel needs to trust the saved values. diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs index 6be20d76d..dad81a9c5 100644 --- a/crates/xlsx/src/passthrough/read.rs +++ b/crates/xlsx/src/passthrough/read.rs @@ -283,8 +283,9 @@ impl KeptReader { (!p.is_empty()).then(|| Arc::new(p)) } - /// Sheet-level content of the sheet part `part`. - pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option> { + /// Sheet-level content of the sheet part `part`. `read_controls` are the VML shape numbers of + /// the form controls the sheet reader modelled; they are left out of the kept content. + pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, read_controls: &HashSet) -> Option> { if !self.on { return None; } @@ -306,12 +307,22 @@ impl KeptReader { let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count(); let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count(); if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes { - self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen); + self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen, read_controls); } (!p.is_empty()).then(|| Arc::new(p)) } - fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet) { + #[allow(clippy::too_many_arguments)] + fn sheet_elements( + &mut self, + cx: &mut Ctx<'_>, + part: &str, + name: &str, + rels: &[PreservedRel], + p: &mut Preserved, + seen: &mut HashSet, + read_controls: &HashSet, + ) { let scan = match cx.pkg.read(part) { Ok(Some(bytes)) => scan_sheet(&bytes), _ => None, @@ -344,10 +355,15 @@ impl KeptReader { Err(why) => cx.warn(format!("sheet \"{name}\": header and footer pictures were dropped: {why}")), } } - let mut frags = scan.fragments; + // Form controls the sheet reader modelled are written from the model; the others stay. + let mut frags: Vec<(String, String)> = scan + .fragments + .into_iter() + .filter_map(|(slot, x)| if slot == "controls" { without_controls(&x, read_controls).map(|x| (slot, x)) } else { Some((slot, x)) }) + .collect(); // Controls and OLE objects are drawn from shapes in the legacy VML drawing. if frags.iter().any(|(slot, _)| slot != "picture") { - match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) { + match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen, read_controls) { Ok((vml, parts)) => { p.parts.extend(parts); p.vml = Some(vml); @@ -383,14 +399,15 @@ impl KeptReader { } } - /// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its - /// shapes refer to (preview images). + /// The sheet's VML drawing without its notes and the form controls read into the model + /// (GridCraft writes those), and the parts its shapes refer to (preview images). fn vml( &mut self, cx: &mut Ctx<'_>, rels: &[PreservedRel], rid: Option<&str>, seen: &mut HashSet, + read_controls: &HashSet, ) -> Result<(PreservedPart, Vec), String> { let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?; // Charged to the kept total like any other part: many sheets can point at one highly @@ -407,7 +424,7 @@ impl KeptReader { }; self.budget = self.budget.saturating_sub(data.len()); let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?; - let text = strip_notes(&text); + let text = strip_shapes(&text, read_controls); let vrels = raw_rels(cx, &rel.target)?; let mut q = Preserved::default(); let roots: Vec = vrels.iter().filter(|r| !r.external).cloned().collect(); @@ -557,13 +574,14 @@ fn shape_spans(vml: &str) -> Vec<(usize, usize)> { spans } -/// VML without its note shapes (`ObjectType="Note"`). -fn strip_notes(vml: &str) -> String { +/// VML without its note shapes (`ObjectType="Note"`) and the shapes numbered in `drop`. +fn strip_shapes(vml: &str, drop: &HashSet) -> String { let mut out = String::with_capacity(vml.len()); let mut last = 0; for (a, b) in shape_spans(vml) { let shape = vml.get(a..b).unwrap_or(""); - if shape.contains("ObjectType=\"Note\"") { + let numbered = shape_numbers(shape).first().is_some_and(|n| drop.contains(n)); + if shape.contains("ObjectType=\"Note\"") || numbered { out.push_str(vml.get(last..a).unwrap_or("")); last = b; } @@ -595,19 +613,32 @@ pub(super) fn vml_block_max(vml: &str) -> u64 { used.max(claimed) } -/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh -/// shape block so they can't collide with the kept shapes the sheet XML refers to. +/// The first shape block free in kept VML: GridCraft's own shapes (notes, then form controls) are +/// numbered from `block * 1024 + 1` when they join it ([`merge_vml`]). +pub(super) fn first_free_block(kept: &str) -> u64 { + shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1 +} + +/// Kept VML (controls, OLE objects) plus GridCraft's note and form control shapes: ours get ids +/// in fresh shape blocks so they can't collide with the kept shapes the sheet XML refers to. pub(super) fn merge_vml(kept: &str, ours: &str) -> String { let Some(close) = kept.rfind("") else { return ours.to_string() }; - let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1; + let block = first_free_block(kept); let mut add = String::new(); - if !kept.contains("id=\"_x0000_t202\"") - && let Some(i) = ours.find("")) - { - add.push_str(ours.get(i..i + j + "".len()).unwrap_or("")); + // Our shape types (notes, form controls) that the kept drawing doesn't define. + let mut from = 0; + while let Some(i) = ours.get(from..).and_then(|s| s.find("")).map(|j| i + j + "".len()) else { break }; + let ty = ours.get(i..j).unwrap_or(""); + let id = ty.split("id=\"").nth(1).and_then(|r| r.split('"').next()).unwrap_or(""); + if !id.is_empty() && !kept.contains(&format!("id=\"{id}\"")) { + add.push_str(ty); + } + from = j; } - for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() { + let spans = shape_spans(ours); + let blocks = (spans.len() as u64 + 1).div_ceil(1024).max(1); + for (k, (a, b)) in spans.into_iter().enumerate() { let shape = ours.get(a..b).unwrap_or(""); let id = block.saturating_mul(1024).saturating_add(1 + k as u64); match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) { @@ -618,16 +649,70 @@ pub(super) fn merge_vml(kept: &str, ours: &str) -> String { } } let mut head = kept.get(..close).unwrap_or("").to_string(); - // Claim the block in the drawing's id map. + // Claim the blocks in the drawing's id map. if let Some(i) = head.find("` fragment without the controls whose `shapeId` is in `drop` (they are +/// written from the model). `None` when no control is left. A fragment that doesn't parse is +/// returned unchanged. +fn without_controls(frag: &str, drop: &HashSet) -> Option { + if drop.is_empty() { + return Some(frag.to_string()); + } + let numbered = |x: &str| { + x.split("shapeId=\"").skip(1).any(|r| r.split('"').next().and_then(|n| n.trim().parse::().ok()).is_some_and(|n| drop.contains(&n))) + }; + let mut reader = Reader::from_str(frag); + let mut out = String::with_capacity(frag.len()); + let (mut last, mut left, mut inside) = (0usize, 0usize, false); + loop { + let Ok(start) = usize::try_from(reader.buffer_position()) else { return Some(frag.to_string()) }; + let ev = match reader.read_event() { + Ok(ev) => ev, + Err(_) => return Some(frag.to_string()), + }; + let child = match ev { + Event::Eof => break, + Event::Start(e) if e.local_name().as_ref() == b"controls" => { + inside = true; + continue; + } + Event::End(e) if e.local_name().as_ref() == b"controls" => { + inside = false; + continue; + } + Event::Start(e) if inside => { + if reader.read_to_end(e.name()).is_err() { + return Some(frag.to_string()); + } + true + } + Event::Empty(_) if inside => true, + _ => false, + }; + if !child { + continue; + } + let Ok(end) = usize::try_from(reader.buffer_position()) else { return Some(frag.to_string()) }; + if numbered(frag.get(start..end).unwrap_or("")) { + out.push_str(frag.get(last..start).unwrap_or("")); + last = end; + } else { + left += 1; + } + } + out.push_str(frag.get(last..).unwrap_or("")); + (left > 0).then_some(out) +} + /// Rewrites relationship-id attributes (`r:id`, `r:embed`…: any attribute in the relationships /// namespace) of an XML fragment through `map`. `None` when the fragment is damaged or `map` has /// no id for one of them. diff --git a/crates/xlsx/src/passthrough/rebase.rs b/crates/xlsx/src/passthrough/rebase.rs index d758f86d6..1ca5d82e1 100644 --- a/crates/xlsx/src/passthrough/rebase.rs +++ b/crates/xlsx/src/passthrough/rebase.rs @@ -1,4 +1,4 @@ -use super::read::merge_vml; +use super::read::{first_free_block, merge_vml}; use super::write::{NameMap, attach, part_rels_xml, remap, write_parts}; use super::*; @@ -28,7 +28,15 @@ impl<'a> SheetKept<'a> { } } - /// GridCraft's notes VML for part `name`, merged with the kept control and object shapes. + /// The number GridCraft's own VML shapes start after when they join the kept drawing + /// ([`SheetKept::notes_vml`] numbers them `base + 1`, `base + 2`…); `None` without one. + pub fn shape_base(&self) -> Option { + let vml = self.vml()?; + Some(first_free_block(&String::from_utf8_lossy(&vml.data)).saturating_mul(1024)) + } + + /// GridCraft's notes and form controls VML for part `name`, merged with the kept control and + /// object shapes. pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String { let Some(vml) = self.vml() else { return ours }; self.write_vml_rels(vml, name, out); @@ -50,17 +58,31 @@ impl<'a> SheetKept<'a> { self.ids.get(old).map(String::as_str) } - /// The kept sheet elements, in schema order. - pub fn elements(&self) -> String { - let Some(p) = self.p else { return String::new() }; + /// The kept sheet elements, in schema order, with GridCraft's own form control entries + /// (`controls`, see `controls::write`) in the sheet's one `` element. + pub fn elements(&self, controls: &str) -> String { let mut s = String::new(); - for slot in SHEET_SLOTS { - for e in p.elements.iter().filter(|e| e.slot == *slot) { - if let Some(x) = remap(&e.xml, &self.ids) { - s.push_str(&x); + let mut placed = controls.is_empty(); + if let Some(p) = self.p { + for slot in SHEET_SLOTS { + for e in p.elements.iter().filter(|e| e.slot == *slot) { + if let Some(mut x) = remap(&e.xml, &self.ids) { + if *slot == "controls" + && !placed + && let Some(i) = x.rfind("") + { + x.insert_str(i, controls); + placed = true; + } + s.push_str(&x); + } } } } + // `` comes last of the kept elements. + if !placed { + s.push_str(&crate::controls::controls_element(controls)); + } s } } diff --git a/crates/xlsx/src/passthrough/write.rs b/crates/xlsx/src/passthrough/write.rs index 8b8d24591..cf11d0216 100644 --- a/crates/xlsx/src/passthrough/write.rs +++ b/crates/xlsx/src/passthrough/write.rs @@ -15,6 +15,12 @@ pub struct KeptOut { } impl KeptOut { + /// Reserves a part name GridCraft writes itself, so kept parts don't take it. False when it is + /// taken. + pub fn reserve(&mut self, name: &str) -> bool { + self.used.insert(name.to_ascii_lowercase()) + } + /// Reserves the VML shape blocks the kept header and footer drawings of `wb` use, so that the /// notes drawings GridCraft writes don't reuse their shape ids. pub fn reserve_vml_blocks(&mut self, wb: &Workbook) { diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 8b118ecdb..020969c77 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -293,12 +293,12 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { continue; } let name = unique_name(&wb, &name, i); - let mut sheet = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) { + let (mut sheet, read_controls) = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) { Ok(s) => s, Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)), Err(e) => { cx.warn(format!("sheet \"{name}\" could not be read: {e}")); - Sheet::new(name.clone()) + (Sheet::new(name.clone()), Default::default()) } }; match crate::pivot::read_pivots(&mut cx, &mut pivot_caches, &rel.target, &name) { @@ -306,7 +306,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)), Err(e) => cx.warn(format!("PivotTables on sheet \"{name}\" could not be read: {e}")), } - sheet.preserved = kept.sheet(&mut cx, &rel.target, &name); + sheet.preserved = kept.sheet(&mut cx, &rel.target, &name, &read_controls); sheet.file_id = se.attr_u32("sheetId").unwrap_or(0); sheet.visibility = match se.attr("state") { Some("hidden") => Visibility::Hidden, diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 7a37e41d8..f2f7de668 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -1,6 +1,6 @@ //! Reading worksheet parts. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use gridcraft_core::date::{serial_from_ymd, time_fraction}; @@ -115,7 +115,9 @@ fn read_sheet_format(e: &El, sheet: &mut Sheet) { } } -pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result { +/// Reads the sheet part `part`, with the VML shape numbers of the form controls read into the +/// model (pass-through keeps the other controls). +pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, HashSet), IoError> { let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?; let rels = cx.pkg.rels(part)?; let mut sheet = Sheet::new(name); @@ -139,6 +141,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result = None; for e in &root.children { match e.name.as_str() { "sheetPr" => { @@ -260,10 +263,12 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result read_ext(cx, e, &mut sheet), + "controls" | "AlternateContent" => controls = crate::controls::controls_of(e).or(controls), "protectedRanges" => read_protected_ranges(e, &mut sheet), _ => {} } } + let read_controls = read_form_controls(cx, controls, &rels, &mut sheet); // Comments are related to the sheet without an element in it. for rel in rels.iter().filter(|r| r.kind == "comments") { if let Some(x) = cx.optional_xml(&rel.target)? { @@ -285,7 +290,13 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result` list with its properties parts, else +/// the VML shapes. Returns the VML shape numbers read, so pass-through keeps only the others. +fn read_form_controls(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut Sheet) -> HashSet { + if controls.is_some() || rels.iter().any(|r| r.kind == "vmlDrawing") { crate::controls::read(cx, controls, rels, sheet) } else { HashSet::new() } } /// A protection password hash: the iterated attributes (`algorithmName`, `hashValue`, diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 10bb3d21d..f41ccb908 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -279,29 +279,51 @@ fn write_sheet_extensions(s: &mut String, sheet: &Sheet, prio: &[u32]) { } fn write_sheet_notes(s: &mut String, sheet: &Sheet, si: usize, kept: &crate::passthrough::SheetKept<'_>, rels: &mut Rels, out: &mut Out) { - // Notes: comments part + VML so Excel shows them. Threaded comments also get a legacy note - // (its text with the replies as lines, for readers without threaded comments) linked by id. - if !sheet.comments.is_empty() { - out.comments += 1; - let n = out.comments; - let ids = thread_ids(sheet, si); - out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes()); - if !ids.is_empty() { - let x = threaded_xml(sheet, &ids, &mut out.persons); - out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes()); - rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml")); + // Notes and form controls share one legacy VML drawing: notes need it for Excel to show them, + // and controls carry their `x:ClientData` there. Shape ids come from 1024-id blocks listed in + // the drawing's idmap (after the blocks kept header and footer drawings use), notes first, so + // a control's `shapeId` names its VML shape. Threaded comments also get a legacy note (its + // text with the replies as lines, for readers without threaded comments) linked by id. + let mut controls = String::new(); + if !sheet.comments.is_empty() || !sheet.controls.is_empty() { + let shapes = (sheet.comments.len() as u64).saturating_add(sheet.controls.len() as u64); + let blocks = u32::try_from(shapes.saturating_add(1).div_ceil(1024)).unwrap_or(u32::MAX).max(1); + let first = out.kept.notes_block(out.vml_block.saturating_add(1)); + out.vml_block = out.vml_block.saturating_add(blocks); + let idmap = (first..first.saturating_add(blocks)).map(|b| b.to_string()).collect::>().join(","); + // With a kept drawing, our shapes join it in a block of their own. + let base = kept.shape_base().unwrap_or(u64::from(first) * 1024); + let notes = sheet.comments.len() as u64; + let mut control_shapes = String::new(); + if !sheet.controls.is_empty() { + let w = crate::controls::write(out, rels, sheet, |i| base + 1 + notes + i as u64); + controls = w.entries; + control_shapes = format!("{}{}", crate::controls::SHAPETYPE, w.vml); } + if !sheet.comments.is_empty() { + out.comments += 1; + let n = out.comments; + let ids = thread_ids(sheet, si); + out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes()); + if !ids.is_empty() { + let x = threaded_xml(sheet, &ids, &mut out.persons); + out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes()); + rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml")); + } + rels.add("comments", &format!("../comments{n}.xml")); + } + out.vmls += 1; + let v = out.vmls; out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing"); - let vml_name = format!("xl/drawings/vmlDrawing{n}.vml"); - let vml = kept.notes_vml(vml_xml(sheet, out.kept.notes_block(n)), &vml_name, out); + let vml_name = format!("xl/drawings/vmlDrawing{v}.vml"); + let vml = kept.notes_vml(vml_xml(sheet, &idmap, base, &control_shapes), &vml_name, out); out.part(&vml_name, None, vml.into_bytes()); - rels.add("comments", &format!("../comments{n}.xml")); - let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml")); + let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{v}.vml")); let _ = write!(s, ""); } else if let Some(rid) = kept.lone_vml(rels, out) { let _ = write!(s, ""); } - s.push_str(&kept.elements()); + s.push_str(&kept.elements(&controls)); } fn write_sheet_drawing(s: &mut String, wb: &Workbook, si: usize, sheet: &Sheet, rels: &mut Rels, out: &mut Out) { diff --git a/crates/xlsx/src/sheet_write/parts.rs b/crates/xlsx/src/sheet_write/parts.rs index 2e40d0b26..9bd2c1a9e 100644 --- a/crates/xlsx/src/sheet_write/parts.rs +++ b/crates/xlsx/src/sheet_write/parts.rs @@ -348,13 +348,14 @@ fn note_anchor(cell: CellRef, b: Option<&NoteBox>) -> (String, (f64, f64)) { (format!("{}, {}", corner(&b.from), corner(&b.to)), b.size_pt.unwrap_or(DEFAULT_NOTE_SIZE_PT)) } -/// Legacy VML shapes Excel uses to display notes. -pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String { +/// Legacy VML shapes Excel uses to display notes (numbered from `base + 1`), followed by the +/// form control shapes `controls`. `idmap` lists the shape blocks the drawing uses. +pub(super) fn vml_xml(sheet: &Sheet, idmap: &str, base: u64, controls: &str) -> String { let mut s = format!( - "" + "" ); for (i, (cell, c)) in sheet.comments.iter().enumerate() { - let id = n as u64 * 1024 + 1 + i as u64; + let id = base + 1 + i as u64; let vis = if c.visible { "visible" } else { "hidden" }; let (anchor, (w, h)) = note_anchor(*cell, c.note_box.as_ref()); let _ = write!( @@ -368,6 +369,7 @@ pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String { if c.visible { "" } else { "" } ); } + s.push_str(controls); s.push_str(""); s } diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs index 73be1a064..da1ddf666 100644 --- a/crates/xlsx/src/tests.rs +++ b/crates/xlsx/src/tests.rs @@ -6,6 +6,7 @@ mod chart_book_names; mod chart_bool_default; mod chart_combo_kinds; mod chart_ref_offsets; +mod controls_tests; mod csv_tests; mod encrypted_tests; mod fixtures; diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs new file mode 100644 index 000000000..0e52ed3de --- /dev/null +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -0,0 +1,205 @@ +//! Form controls: the three parts written for each control agree, they read back, and files with +//! only VML or with odd values give valid controls. + +use gridcraft_core::CellRef; +use gridcraft_model::{Anchor, AnchorMode, Comment, ControlAction, ControlKind, FormControl, MacroInput, Sheet, Workbook}; + +use super::minimal; +use crate::package::Package; +use crate::{read_xlsx, write_xlsx}; + +fn at(a: &str) -> CellRef { + CellRef::parse(a).unwrap() +} + +fn anchor(cell: &str, width: f32, height: f32) -> Anchor { + Anchor { cell: at(cell), dx: 4.0, dy: 3.0, width, height, mode: AnchorMode::MoveOnly } +} + +fn note(author: &str, text: &str) -> Comment { + Comment { + author: author.into(), + text: text.into(), + replies: vec![], + threaded: false, + resolved: false, + visible: false, + dates: vec![], + note_box: None, + } +} + +fn book() -> Workbook { + let mut wb = Workbook::default(); + wb.sheets.clear(); + let mut sh = Sheet::new("Data"); + sh.comments.insert(at("A1"), note("Me", "a note")); + sh.controls.push(FormControl { + id: 1, + kind: ControlKind::Slider, + anchor: anchor("C3", 150.0, 20.0), + link: Some("$B$1".into()), + min: 5, + max: 500, + step: 5, + page: 50, + value: 35, + name: "Rate".into(), + ..Default::default() + }); + sh.controls.push(FormControl { + id: 2, + kind: ControlKind::Spin, + anchor: Anchor { mode: AnchorMode::Absolute, ..anchor("F3", 20.0, 40.0) }, + link: Some("'Other Sheet'!$A$2".into()), + max: 30_000, + step: 100, + value: 30_000, + print: false, + ..Default::default() + }); + sh.controls.push(FormControl { + id: 3, + kind: ControlKind::Button, + anchor: Anchor { mode: AnchorMode::MoveAndSize, ..anchor("C8", 100.0, 28.0) }, + text: "Run & go".into(), + action: Some(ControlAction::Macro { name: "Fill Down".into(), input: MacroInput::Selection, output: None }), + locked: false, + ..Default::default() + }); + sh.controls.push(FormControl { id: 4, kind: ControlKind::Slider, anchor: anchor("H3", 20.0, 120.0), horizontal: false, ..Default::default() }); + wb.sheets.push(sh.into()); + wb.sheets.push(Sheet::new("Other Sheet").into()); + wb +} + +fn text(pkg: &mut Package<'_>, part: &str) -> String { + String::from_utf8(pkg.read(part).unwrap().unwrap_or_else(|| panic!("missing {part}"))).unwrap() +} + +#[test] +fn the_three_parts_agree_and_read_back() { + let wb = book(); + let bytes = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&bytes).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + let vml = text(&mut pkg, "xl/drawings/vmlDrawing1.vml"); + let types = text(&mut pkg, "[Content_Types].xml"); + assert!(sheet.contains(""), "{sheet}"); + // Each control's shape id is a VML shape of the same kind; the note keeps its own id. + let ids: Vec = sheet.split("").next().unwrap().contains(&format!("ObjectType=\"{kind}\"")), "{id}"); + } + assert!(vml.contains("id=\"_x0000_s1025\"") && vml.contains("ObjectType=\"Note\""), "the note is shape 1025"); + assert!(!ids.contains(&"1025".to_string())); + for n in 1..=4 { + assert!(types.contains(&format!("/xl/ctrlProps/ctrlProp{n}.xml")), "content type {n}"); + } + let prop = text(&mut pkg, "xl/ctrlProps/ctrlProp1.xml"); + assert!( + prop.contains("objectType=\"Scroll\"") && prop.contains("fmlaLink=\"$B$1\"") && prop.contains("page=\"50\"") && prop.contains("horiz=\"1\""), + "{prop}" + ); + assert!(sheet.contains("macro=\"[0]!Fill Down\"")); + + let (back, report) = read_xlsx(&bytes).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + let got = &back.sheet(0).unwrap().controls; + assert_eq!(got.len(), 4); + for (a, b) in wb.sheet(0).unwrap().controls.iter().zip(got) { + let same = |x: &FormControl| { + (x.kind, x.link.clone(), x.min, x.max, x.step, x.value, x.horizontal, x.print, x.locked, x.action.clone(), x.anchor.cell, x.anchor.mode) + }; + assert_eq!(same(a), same(b)); + assert!( + (a.anchor.width - b.anchor.width).abs() < 1.0 + && (a.anchor.height - b.anchor.height).abs() < 1.0 + && (a.anchor.dx - b.anchor.dx).abs() < 1.0 + ); + if a.kind == ControlKind::Slider { + assert_eq!(a.page, b.page); + } + } + assert_eq!(got[0].name, "Rate"); + assert_eq!(got[2].text, "Run & go"); + assert_eq!(back.sheet(0).unwrap().comments.len(), 1, "notes still read"); +} + +#[test] +fn many_shapes_take_several_id_blocks() { + let mut wb = Workbook::default(); + wb.sheets.clear(); + let mut sh = Sheet::new("Data"); + for i in 0..1100u32 { + sh.controls.push(FormControl { id: i + 1, kind: ControlKind::Spin, anchor: anchor("C3", 20.0, 30.0), ..Default::default() }); + } + wb.sheets.push(sh.into()); + wb.sheets.push(Sheet::new("Next").into()); + if let Some(s) = wb.sheet_mut(1) { + s.comments.insert(at("B2"), note("x", "y")); + } + let bytes = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&bytes).unwrap(); + assert!(text(&mut pkg, "xl/drawings/vmlDrawing1.vml").contains("data=\"1,2\"")); + let second = text(&mut pkg, "xl/drawings/vmlDrawing2.vml"); + assert!(second.contains("data=\"3\"") && second.contains("_x0000_s3073"), "the next sheet's ids don't collide"); + let (back, _) = read_xlsx(&bytes).unwrap(); + assert_eq!(back.sheet(0).unwrap().controls.len(), 1100); +} + +const VML_ONLY: &str = r##" +2, 0, 3, 0, 5, 0, 4, 0$B$150000-5999990-3 +1, 0, 1, 0, 1, 20, 3, 0799 +
Go
1, 0, 6, 0, 3, 0, 7, 0[0]!Tidy
+1, 0, 1, 0, 2, 0, 2, 0 +junk +
"##; + +#[test] +fn vml_only_controls_are_read_and_clamped() { + let rels = r#""#; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", VML_ONLY)], + "", + "", + ); + let (wb, report) = read_xlsx(&bytes).unwrap(); + assert!(report.warnings.iter().all(|w| !w.contains("form controls are not supported")), "{:?}", report.warnings); + let c = &wb.sheet(0).unwrap().controls; + assert_eq!(c.len(), 3, "the drop-down and the shape without an anchor are skipped"); + assert_eq!((c[0].kind, c[0].min, c[0].max, c[0].step, c[0].value, c[0].horizontal), (ControlKind::Slider, 0, 30_000, 1, 30_000, true)); + assert!(c[0].page >= c[0].step && c[0].check_numbers().is_ok()); + assert_eq!((c[1].min, c[1].max), (9, 10), "a range with no width gets one"); + assert!(c[1].check_numbers().is_ok()); + assert_eq!(c[2].text, "Go"); + assert_eq!(c[2].action, Some(ControlAction::Macro { name: "Tidy".into(), input: MacroInput::Selection, output: None })); +} + +#[test] +fn the_properties_part_wins_over_the_vml() { + let mut wb = book(); + if let Some(s) = wb.sheet_mut(0) { + s.comments.clear(); + s.controls.truncate(1); + } + let bytes = write_xlsx(&wb).unwrap(); + // Rewrite the VML's numbers: the reader still takes the properties part's. + let mut pkg = Package::open(&bytes).unwrap(); + let mut parts = Vec::new(); + for name in pkg.names() { + let mut data = pkg.read(&name).unwrap().unwrap(); + if name.ends_with(".vml") { + data = String::from_utf8(data).unwrap().replace("500", "9").into_bytes(); + } + parts.push((name, data)); + } + let zipped = super::make_zip(&parts.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect::>()); + let (back, _) = read_xlsx(&zipped).unwrap(); + assert_eq!(back.sheet(0).unwrap().controls[0].max, 500); +} diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 28113be0d..80e4c1edc 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -938,3 +938,64 @@ fn notes_take_shape_blocks_the_header_pictures_dont_use() { } assert!(notes_ids.iter().all(|b| notes_map.contains(b)), "note ids outside their drawing's blocks"); } + +#[test] +fn modelled_form_controls_join_kept_ones_in_one_controls_element() { + use gridcraft_model::{Anchor, ControlKind, FormControl}; + let (mut wb, _) = read_xlsx(&rich_package()).unwrap(); + assert!(wb.sheet(0).unwrap().controls.is_empty(), "a check box stays with pass-through"); + let anchor = Anchor { cell: gridcraft_core::CellRef::new(8, 1), width: 120.0, height: 20.0, ..Default::default() }; + let slider = FormControl { id: 900, kind: ControlKind::Slider, anchor, link: Some("$B$9".into()), value: 40, ..Default::default() }; + if let Some(sh) = wb.sheet_mut(0) { + sh.controls.push(slider); + } + let out = write_xlsx(&wb).unwrap(); + let u = Unpacked::new(&out); + u.assert_valid(); + let sheet = sheet_part(&u, "Data"); + let sx = u.text(&sheet); + assert_eq!(sx.matches(" = sx.split(", pub table_id: u32, @@ -231,6 +235,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) drawings: 0, tables: 0, comments: 0, + vmls: 0, + vml_block: 0, + ctrl_props: 0, persons: vec![], table_id: 0, dynamic_arrays: false, diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index 940a66261..b9097eea2 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -213,8 +213,10 @@ pub fn parse(xml: &[u8]) -> Result { parse_streaming(xml, &[], &mut |_| Ok(())) } -/// Parses a part; complete elements named in `split` (below the root) are handed to `cb` and not -/// kept in the returned tree. +/// Parses a part; complete elements named in `split` that are children or grandchildren of the +/// root (`sst/si`, `worksheet/sheetData/row`) are handed to `cb` and not kept in the returned +/// tree. Deeper elements of the same name (the `xdr:row` of a form control's anchor) stay in the +/// tree. pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Result<(), IoError>) -> Result { let data = to_utf8(xml); let mut reader = Reader::from_reader(data.as_ref()); @@ -241,9 +243,10 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res cb: &mut dyn FnMut(El) -> Result<(), IoError>, kept: &mut usize, ) -> Result<(), IoError> { + let shallow = stack.len() <= 2; match stack.last_mut() { Some(parent) => { - if split.contains(&el.name.as_str()) { + if shallow && split.contains(&el.name.as_str()) { cb(el)?; } else { *kept = kept.saturating_add(1); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1523e9d94..d3607f612 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (form controls: buttons, scroll bars and spin buttons read and written) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -41,7 +41,7 @@ values) is still dropped. | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), form controls (buttons, scroll bars and spin buttons: control properties parts, `` and VML `x:ClientData`; a VML-only control is read too), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), other form controls (check boxes, list boxes, drop-downs; written in the same `` element and VML drawing as the modelled ones), ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), pictures in headers and footers (`&G`, #1257: their VML drawing and images are written back unchanged; they are not shown, and can't be inserted), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | From 704230647863532f77996c4d54dc368b4dcbdb8f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:54:41 -0400 Subject: [PATCH 11/55] =?UTF-8?q?UI:=20Insert=20=E2=80=BA=20Controls,=20fo?= =?UTF-8?q?rm=20controls=20on=20the=20sheet,=20Assign=20Action=20and=20For?= =?UTF-8?q?mat=20Control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Insert tab gains Button, Slider and Spin Button beside Cell Controls › Checkbox, which is unchanged. Each arms placement: a drag on the grid draws the control at that size (a tall drag makes a vertical slider), a click places it at its default size, and Escape disarms. Key tips (N B U, N S L, N S B) place one at the active cell. A new button opens Assign Action, as Excel opens Assign Macro. form_controls.rs draws the controls in the sheet tokens, so they look the same in both themes: buttons with hover and pressed states, sliders with a track, a filled part and a thumb (and the value beside the thumb while it is dragged), spin buttons with two arrows, stacked or side by side. A plain click works a control: dragging the thumb, clicking the track (a page), clicking or holding an arrow (it repeats after 0.4 s), pressing a button (it runs on release over it). A drag or a hold is one undo step. The control last worked takes the arrows, Page Up and Page Down, Home and End, and Space or Enter for a button. Ctrl or Cmd+click or a right-click selects a control to move or resize it; a right-click opens the object menu, which for a control adds Edit Text and Assign Action (buttons) and Format Control before Delete. Double-clicking a selected control opens its editor. On a right-to-left sheet a control moves as a whole and works where it is drawn. Assign Action runs a macro (from the list or typed, on the selection or a fixed range, with an optional output cell) or a command (searched among those a macro may run, with JSON parameters). Format Control edits the value, limits, incremental and page change, cell link, orientation, name and the locked and print flags, checking the same rules as the engine. A button whose action needs approval, or whose macro would write over data, asks in a prompt whose buttons press it again with the answer. New theme tokens (thumb, hover, pressed, fill, focus ring) are in the contrast test; Button, Slider and Spin icons are drawn in code; the new labels are translated in every interface language. The catalog gains Developer › Controls (280 / 296). --- ROADMAP.md | 6 +- crates/engine/src/catalog.rs | 5 + crates/ui-egui/src/chartview.rs | 26 +- crates/ui-egui/src/control_ui.rs | 517 ++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/dialogs/show.rs | 4 + crates/ui-egui/src/form_controls.rs | 443 +++++++++++++++ crates/ui-egui/src/grid.rs | 8 + crates/ui-egui/src/grid/cursor.rs | 15 +- crates/ui-egui/src/grid/interact.rs | 17 +- crates/ui-egui/src/grid/keyboard.rs | 19 +- crates/ui-egui/src/grid/menus.rs | 15 +- crates/ui-egui/src/i18n/cs.rs | 9 + crates/ui-egui/src/i18n/de.rs | 13 + crates/ui-egui/src/i18n/el.rs | 11 + crates/ui-egui/src/i18n/es.rs | 13 + crates/ui-egui/src/i18n/fr.rs | 11 + crates/ui-egui/src/i18n/it.rs | 13 + crates/ui-egui/src/i18n/ja.rs | 9 + crates/ui-egui/src/i18n/ko.rs | 9 + crates/ui-egui/src/i18n/nl.rs | 11 + crates/ui-egui/src/i18n/pl.rs | 11 + crates/ui-egui/src/i18n/pt.rs | 13 + crates/ui-egui/src/i18n/ro.rs | 11 + crates/ui-egui/src/i18n/ru.rs | 11 + crates/ui-egui/src/i18n/sr.rs | 11 + crates/ui-egui/src/i18n/tr.rs | 11 + crates/ui-egui/src/i18n/uk.rs | 11 + crates/ui-egui/src/i18n/vi.rs | 9 + crates/ui-egui/src/i18n/zh.rs | 9 + crates/ui-egui/src/icons.rs | 23 + .../src/keytips/keytip_table/generated.rs | 3 + crates/ui-egui/src/keytips/keytips.json | 21 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/objects.rs | 6 +- crates/ui-egui/src/ribbon.rs | 4 +- crates/ui-egui/src/ribbon/insert.rs | 24 +- crates/ui-egui/src/theme.rs | 31 +- crates/ui-egui/tests/it/form_controls.rs | 421 ++++++++++++++ crates/ui-egui/tests/it/keytips_generated.rs | 23 +- crates/ui-egui/tests/it/main.rs | 1 + docs/control-protocol.md | 6 +- docs/parity-checklist.md | 12 +- docs/target-app-parity.md | 15 +- docs/ui-parity.md | 5 +- 45 files changed, 1839 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/control_ui.rs create mode 100644 crates/ui-egui/src/form_controls.rs create mode 100644 crates/ui-egui/tests/it/form_controls.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8afe566c6..1a42a5fd6 100644 --- 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-31 · **Change:** minor (floating objects: one selection, eight resize handles, keyboard delete; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (form controls: buttons, sliders and spin buttons; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **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. @@ -22,7 +22,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| | 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) | -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 276 / 292 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 280 / 296 (94.6%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | @@ -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-31 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `` and VML), alongside the form controls GridCraft keeps unchanged | | 2026-10-31 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) | | 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 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | @@ -245,6 +246,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Form controls; catalog 276 / 292 → 280 / 296 (Developer › Controls); progress log row | | 2026-10-31 | minor | Progress log: one selection and eight resize handles for every floating object | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 45455e00b..234e92e58 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -275,6 +275,11 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[ ("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"), ("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"), ("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"), + // Developer (Insert › Controls in GridCraft) + ("insert.button", "Developer", "Controls", "Button (Form Control)"), + ("insert.spinButton", "Developer", "Controls", "Spin Button (Form Control)"), + ("insert.slider", "Developer", "Controls", "Scroll Bar (Form Control)"), + ("control.format", "Developer", "Controls", "Properties"), // Table Design ("table.rename", "Table Design", "Properties", "Table Name"), ("table.resize", "Table Design", "Properties", "Resize Table"), diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index efa19d3d7..3881b4cc3 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -1,5 +1,5 @@ //! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that -//! we paint with egui), pictures, shapes and slicers. +//! we paint with egui), pictures, shapes, slicers and form controls (`form_controls.rs`). use egui::epaint::{PathShape, PathStroke}; use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2}; @@ -376,6 +376,18 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo done(start, r); } paint_slicers(p, geo, wb, si, sh); + // Form controls sit above every other object. + let hover = p.ctx().input(|i| i.pointer.hover_pos()).map(|h| geo.mirror_pos(h)).filter(|h| geo.in_cells(*h)); + for c in &sh.controls { + let r = anchor_rect(geo, sh, &c.anchor); + // A slider's value shows beside it while it is dragged. + if !r.expand(24.0).intersects(p.clip_rect()) { + continue; + } + let start = p.add(egui::Shape::Noop); + crate::form_controls::paint(p, r, c, geo.z, crate::form_controls::look(app, geo, c, r, hover)); + done(start, r); + } // The selected object's frame and handles go over every object, and move with it on a // right-to-left sheet. if let Some(r) = app.selected_object.and_then(|o| object_rect(geo, sh, o)) @@ -518,13 +530,19 @@ pub fn selected_handle(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option crate::objects::handle_at(r, p).map(|h| (o, r, h)) } -/// The topmost object under `p` and its rect: slicers paint on top, so they are hit first, then -/// charts, pictures and shapes, each from the top of its stack. Nothing is hit outside the cell -/// area or while objects are hidden. +/// The topmost object under `p` and its rect: form controls paint on top, so they are hit first, +/// then slicers, charts, pictures and shapes, each from the top of its stack. Nothing is hit +/// outside the cell area or while objects are hidden. pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect)> { if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) { return None; } + for c in sh.controls.iter().rev() { + let r = anchor_rect(geo, sh, &c.anchor); + if r.contains(p) { + return Some((ObjRef::new(ObjKind::Control, c.id), r)); + } + } for sl in sh.slicers.iter().rev() { let r = anchor_rect(geo, sh, &sl.anchor); if r.contains(p) { diff --git a/crates/ui-egui/src/control_ui.rs b/crates/ui-egui/src/control_ui.rs new file mode 100644 index 000000000..4100ba2a3 --- /dev/null +++ b/crates/ui-egui/src/control_ui.rs @@ -0,0 +1,517 @@ +//! Form controls in the window, apart from drawing them: placing a new control (draw it on the +//! grid, or click for the default size), pressing a button and answering its questions, the +//! control's context menu (Edit Text, Assign Action, Format Control, Delete), and the Assign +//! Action and Format Control dialogs. Everything goes through the `insert.*` and `control.*` +//! commands. + +use egui::{Pos2, Rect, Stroke, StrokeKind, Ui, vec2}; +use gridcraft_engine::core::CellRef; +use gridcraft_engine::macros::policy::{Policy, macro_policy}; +use gridcraft_engine::model::{CONTROL_MAX, ControlAction, ControlKind, FormControl, MacroInput, ObjKind, ObjRef, Sheet}; +use serde_json::{Value as Json, json}; + +use crate::SheetApp; +use crate::dialogs::Dialog; +use crate::grid::Geo; +use crate::theme::Tokens; + +/// The control `id` on the active sheet. +pub fn control(app: &SheetApp, id: u32) -> Option { + app.session.active()?.wb.active()?.controls.iter().find(|c| c.id == id).cloned() +} + +/// Whether the control `id` on the active sheet is a button (its menu adds Edit Text and Assign +/// Action). +pub fn is_button(app: &SheetApp, id: u32) -> bool { + app.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.controls.iter().any(|c| c.id == id && c.kind == ControlKind::Button)) +} + +fn insert_id(kind: ControlKind) -> &'static str { + match kind { + ControlKind::Button => "insert.button", + ControlKind::Slider => "insert.slider", + ControlKind::Spin => "insert.spinButton", + } +} + +/// The cell under a logical grid position and the offset into it, in sheet points. +fn cell_point(geo: &Geo, sh: &Sheet, p: Pos2) -> (CellRef, f32, f32) { + let (col, row) = (geo.col_at(sh, p.x), geo.row_at(sh, p.y)); + let dx = ((p.x - geo.x(sh, col)) / geo.z).max(0.0); + let dy = ((p.y - geo.y(sh, row)) / geo.z).max(0.0); + (CellRef::new(row, col), dx, dy) +} + +/// Insert › Controls placement: while a kind is armed, a drag on the grid draws the control and a +/// click places it at its default size. Escape disarms. True when it used the pointer. +pub fn place(app: &mut SheetApp, ui: &Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) -> bool { + let Some(kind) = app.grid.placing else { + app.grid.place_start = None; + return false; + }; + let ctx = ui.ctx().clone(); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.grid.placing = None; + app.grid.place_start = None; + return true; + } + let pointer = ctx.input(|i| i.pointer.clone()); + // Logical positions (see `Geo::rtl`). + let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); + if pos.is_some_and(|p| geo.in_cells(p)) { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + } + if pointer.primary_pressed() + && resp.contains_pointer() + && let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos).filter(|p| geo.in_cells(*p)) + { + app.grid.place_start = Some(p); + } + let Some(start) = app.grid.place_start else { return resp.contains_pointer() && pointer.any_down() }; + let rect = Rect::from_two_pos(start, pos.unwrap_or(start)); + if pointer.primary_down() { + let t = Tokens::get(&ctx); + ui.painter().rect_stroke(geo.mirror_rect(rect), 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside); + return true; + } + app.grid.place_start = None; + app.grid.placing = None; + let (cell, dx, dy) = cell_point(geo, sh, rect.min); + let mut params = json!({"at": cell.a1(), "dx": dx, "dy": dy}); + // A drag sets the size (and a slider's orientation); a click keeps the default size. + if rect.width() >= 6.0 || rect.height() >= 6.0 { + params["width"] = json!((rect.width() / geo.z).max(8.0)); + params["height"] = json!((rect.height() / geo.z).max(8.0)); + if kind == ControlKind::Slider { + params["horizontal"] = json!(rect.width() >= rect.height()); + } + } + match app.run(insert_id(kind), params) { + Ok(v) => { + if let Some(id) = v["control"].as_u64().and_then(|id| u32::try_from(id).ok()) { + app.selected_object = Some(ObjRef::new(ObjKind::Control, id)); + // A new button asks what it should run, as Excel's Assign Macro does. + if kind == ControlKind::Button { + app.open_dialog("assignAction", json!({"id": id})); + } + } + } + Err(e) => app.message = Some(("Insert Control".into(), crate::clean_error(&e))), + } + true +} + +/// Opens a control's editor (double-click): a button's text, else Format Control. +pub fn edit(app: &mut SheetApp, id: u32) { + match control(app, id).map(|c| c.kind) { + Some(ControlKind::Button) => app.open_dialog("formatControl", json!({"id": id, "textOnly": true})), + Some(_) => app.open_dialog("formatControl", json!({"id": id})), + None => {} + } +} + +// ---------------------------------------------------------------- pressing a button + +/// The question in an `approve: …` error, if it is one. +fn approve_text(e: &str) -> Option { + let rest = e.split_once("approve: ")?.1; + Some(rest.split_once(" (run again with").map_or(rest, |(q, _)| q).to_string()) +} + +/// The question in a `confirm: …` error, if it is one. +fn confirm_text(e: &str) -> Option { + let rest = e.split_once("confirm: ")?.1; + Some(rest.split_once(" (run again with").map_or(rest, |(q, _)| q).to_string()) +} + +/// Presses a button (`control.press` with `params`). A question (approve the action, or make +/// room for a macro's output) opens a prompt whose buttons press it again with the answer; any +/// other error shows in a message box. +pub fn press(app: &mut SheetApp, params: Json) { + let Err(e) = app.run("control.press", params.clone()) else { return }; + let (text, choices) = if let Some(q) = approve_text(&e) { + (q, json!([["Run", {"approve": true}]])) + } else if let Some(q) = confirm_text(&e) { + let what = q.split_once(" Overwrite them").map_or(q.as_str(), |(a, _)| a).trim_end_matches(['.', ' ']).to_string(); + ( + format!("{what}. Overwrite them, or move the data out of the way?"), + json!([["Overwrite", {"onConflict": "overwrite"}], ["Shift Down", {"onConflict": "shiftDown"}], ["Shift Right", {"onConflict": "shiftRight"}]]), + ) + } else { + app.message = Some(("Button".into(), crate::clean_error(&e))); + return; + }; + app.open_dialog("controlQuestion", json!({"text": text, "params": params, "choices": choices})); +} + +pub fn open_question(params: &Json) -> Option { + let text = params.get("text").and_then(Json::as_str)?; + let v = json!({"text": text, "params": params.get("params").cloned().unwrap_or_else(|| json!({})), "choices": params.get("choices").cloned().unwrap_or_else(|| json!([]))}); + Some(Dialog::custom("controlQuestion", "Button", v)) +} + +/// The prompt [`press`] opens: the question, a button per answer, and Cancel. +pub fn question_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + ui.label(val(d, "text")); + ui.add_space(8.0); + let choices: Vec<(String, Json)> = d + .values + .get("choices") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| Some((c.get(0)?.as_str()?.to_string(), c.get(1)?.clone()))).collect()) + .unwrap_or_default(); + let mut answer = None; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(" Cancel ").clicked() { + *open = false; + } + for (label, extra) in choices.iter().rev() { + if ui.button(format!(" {label} ")).clicked() { + answer = Some(extra.clone()); + } + } + }); + if let Some(extra) = answer { + *open = false; + let mut params = d.values.get("params").cloned().unwrap_or_else(|| json!({})); + if let (Some(p), Some(x)) = (params.as_object_mut(), extra.as_object()) { + for (k, v) in x { + p.insert(k.clone(), v.clone()); + } + } + press(app, params); + } +} + +// ---------------------------------------------------------------- Assign Action + +fn val(d: &Dialog, key: &str) -> String { + d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string() +} + +/// The workbook's macros: (name, description). +fn macros(app: &SheetApp) -> Vec<(String, String)> { + app.session.active().map(|d| d.wb.macros.iter().map(|m| (m.name.clone(), m.description.clone())).collect()).unwrap_or_default() +} + +/// Assign Action: a macro (on the selection or a fixed range, with an optional output cell) or a +/// command with its parameters. +pub fn open_assign(app: &SheetApp, params: &Json) -> Option { + let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?; + let c = control(app, id)?; + let mut v = + json!({"id": id, "mode": "macro", "macro": "", "input": "selection", "range": "", "output": "", "command": "", "params": "{}", "search": ""}); + match &c.action { + Some(ControlAction::Macro { name, input, output }) => { + v["macro"] = json!(name); + if let MacroInput::Fixed(r) = input { + v["input"] = json!("fixed"); + v["range"] = json!(r); + } + v["output"] = json!(output.clone().unwrap_or_default()); + } + Some(ControlAction::Command { id, params }) => { + v["mode"] = json!("command"); + v["command"] = json!(id); + v["params"] = json!(serde_json::to_string_pretty(params).unwrap_or_else(|_| "{}".into())); + } + None => { + // A new button suggests the workbook's first macro. + v["macro"] = json!(macros(app).first().map(|m| m.0.clone()).unwrap_or_default()); + } + } + Some(Dialog::custom("assignAction", &format!("Assign Action: {}", c.display_name()), v)) +} + +/// The `control.assign` params the dialog describes, or why it can't yet. +pub fn assign_params(d: &Dialog) -> Result { + let id = d.values.get("id").cloned().unwrap_or(Json::Null); + if val(d, "mode") == "command" { + let command = val(d, "command"); + if command.trim().is_empty() { + return Err("Choose a command.".into()); + } + let text = val(d, "params"); + let params: Json = if text.trim().is_empty() { + json!({}) + } else { + serde_json::from_str(&text).map_err(|e| format!("The parameters aren't valid JSON: {e}"))? + }; + if !params.is_object() { + return Err("The parameters must be a JSON object, such as {\"range\": \"A1:B2\"}.".into()); + } + return Ok(json!({"id": id, "command": command.trim(), "params": params})); + } + let name = val(d, "macro"); + if name.trim().is_empty() { + return Err("Type or choose a macro name.".into()); + } + let input = if val(d, "input") == "fixed" { val(d, "range") } else { "selection".into() }; + if input.trim().is_empty() { + return Err("Type the range the macro always runs on.".into()); + } + Ok(json!({"id": id, "macro": name.trim(), "input": input.trim(), "output": val(d, "output").trim()})) +} + +/// Commands a button may run whose id or label contains `search` (every one when it is empty): +/// (id, label). File commands are left out: the ones a button may run only produce bytes. +fn command_choices(search: &str) -> Vec<(&'static str, &'static str)> { + let q = search.trim().to_lowercase(); + gridcraft_engine::command_specs() + .iter() + .filter(|c| c.journal && !c.id.starts_with("file.")) + .filter(|c| !matches!(macro_policy(c.id, &json!({}), gridcraft_engine::Trust::Local), Policy::Deny(_))) + .filter(|c| q.is_empty() || c.id.to_lowercase().contains(&q) || c.label.to_lowercase().contains(&q)) + .map(|c| (c.id, c.label)) + .take(200) + .collect() +} + +fn text_field(ui: &mut Ui, d: &mut Dialog, key: &str, width: f32) { + let mut s = val(d, key); + ui.add(egui::TextEdit::singleline(&mut s).desired_width(width).id_salt(("control_dialog", key.to_string()))); + d.values.insert(key.into(), json!(s)); +} + +pub fn assign_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + let t = Tokens::get(ui.ctx()); + let mut mode = val(d, "mode"); + ui.horizontal(|ui| { + ui.radio_value(&mut mode, "macro".to_string(), "Run a macro"); + ui.radio_value(&mut mode, "command".to_string(), "Run a command"); + }); + d.values.insert("mode".into(), json!(mode)); + ui.add_space(4.0); + if mode == "macro" { + let macros = macros(app); + let mut name = val(d, "macro"); + ui.label("Macro name:"); + ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("assign_macro")); + egui::Frame::NONE.fill(t.input_bg).stroke(Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| { + ui.set_min_size(vec2(ui.available_width(), 110.0)); + egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { + if macros.is_empty() { + ui.label(egui::RichText::new("This workbook has no macros yet.").italics().color(t.text_dim)); + } + for (n, desc) in ¯os { + let r = + ui.add(egui::Button::selectable(n.eq_ignore_ascii_case(name.trim()), n.as_str()).min_size(vec2(ui.available_width(), 20.0))); + let r = if desc.is_empty() { r } else { r.on_hover_text(desc) }; + if r.clicked() { + name = n.clone(); + } + } + }); + }); + d.values.insert("macro".into(), json!(name)); + ui.add_space(4.0); + let mut input = val(d, "input"); + ui.label("Run it on:"); + ui.radio_value(&mut input, "selection".to_string(), "The selection when the button is pressed"); + ui.horizontal(|ui| { + ui.radio_value(&mut input, "fixed".to_string(), "Always this range:"); + if input == "fixed" { + text_field(ui, d, "range", 160.0); + } + }); + d.values.insert("input".into(), json!(input)); + ui.horizontal(|ui| { + ui.label("Output to (optional):"); + text_field(ui, d, "output", 120.0); + }); + } else { + let mut search = val(d, "search"); + let command = val(d, "command"); + ui.label("Command:"); + ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search commands").desired_width(f32::INFINITY).id_salt("assign_search")); + egui::Frame::NONE.fill(t.input_bg).stroke(Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| { + ui.set_min_size(vec2(ui.available_width(), 130.0)); + egui::ScrollArea::vertical().max_height(130.0).show(ui, |ui| { + for (cid, label) in command_choices(&search) { + let text = format!("{label} ({cid})"); + if ui.add(egui::Button::selectable(cid == command, text).min_size(vec2(ui.available_width(), 20.0))).clicked() { + d.values.insert("command".into(), json!(cid)); + } + } + }); + }); + d.values.insert("search".into(), json!(search)); + let doc = gridcraft_engine::find_command(val(d, "command").trim()).map(|c| c.params).unwrap_or(""); + if !doc.is_empty() { + ui.label(egui::RichText::new(doc).small().color(t.text_dim)); + } + ui.label("Parameters (JSON):"); + let mut params = val(d, "params"); + ui.add(egui::TextEdit::multiline(&mut params).code_editor().desired_rows(3).desired_width(f32::INFINITY).id_salt("assign_params")); + d.values.insert("params".into(), json!(params)); + } + let problem = assign_params(d).err(); + if let Some(p) = &problem { + ui.label(egui::RichText::new(p).color(t.danger)); + } + let (ok, cancel) = ok_cancel(ui, problem.is_none()); + if cancel || (ok && assign(app, d)) { + *open = false; + } +} + +/// OK (offered while `enabled`) and Cancel at the bottom right, as the other dialogs place them: +/// (OK clicked, Cancel clicked). +fn ok_cancel(ui: &mut Ui, enabled: bool) -> (bool, bool) { + let (mut ok, mut cancel) = (false, false); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let t = Tokens::get(ui.ctx()); + ok = ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(" OK ").color(t.on_accent)).fill(t.accent)).clicked(); + cancel = ui.button(" Cancel ").clicked(); + }); + (ok, cancel) +} + +/// Runs `control.assign` from the dialog. False (with the error shown) when it failed. +fn assign(app: &mut SheetApp, d: &Dialog) -> bool { + let result = assign_params(d).and_then(|params| app.run("control.assign", params).map_err(|e| crate::clean_error(&e))); + match result { + Ok(_) => true, + Err(e) => { + app.message = Some(("Assign Action".into(), e)); + false + } + } +} + +// ---------------------------------------------------------------- Format Control + +/// Format Control: a slider's or spin button's cell link, value and limits (orientation and page +/// change for sliders); a button's text. `textOnly` shows just the text (Edit Text). +pub fn open_format(app: &SheetApp, params: &Json) -> Option { + let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?; + let c = control(app, id)?; + let text_only = params.get("textOnly").and_then(Json::as_bool) == Some(true) && c.kind == ControlKind::Button; + let v = json!({ + "id": id, + "textOnly": text_only, + "text": c.text, + "name": c.name, + "value": c.value.to_string(), + "min": c.min.to_string(), + "max": c.max.to_string(), + "step": c.step.to_string(), + "page": c.page.to_string(), + "link": c.link.clone().unwrap_or_default(), + "horizontal": c.horizontal, + "print": c.print, + "locked": c.locked, + }); + let title = if text_only { "Edit Text".to_string() } else { format!("Format Control: {}", c.display_name()) }; + Some(Dialog::custom("formatControl", &title, v)) +} + +fn whole(text: &str) -> Option { + text.trim().parse::().ok().filter(|v| *v <= CONTROL_MAX) +} + +/// The `control.format` params the dialog describes, or the first problem with them (the same +/// rules the engine checks, so OK is only offered when it will work). +pub fn format_params(app: &SheetApp, d: &Dialog) -> Result { + let id = d.values.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok()).ok_or("The control is gone.")?; + let c = control(app, id).ok_or("The control is gone.")?; + let flag = |k: &str| d.values.get(k).and_then(Json::as_bool).unwrap_or(false); + if d.values.get("textOnly").and_then(Json::as_bool) == Some(true) { + return Ok(json!({"id": id, "text": val(d, "text")})); + } + let mut out = json!({"id": id, "name": val(d, "name"), "print": flag("print"), "locked": flag("locked")}); + if c.kind == ControlKind::Button { + out["text"] = json!(val(d, "text")); + return Ok(out); + } + let mut draft = c.clone(); + for (key, label, slot) in [ + ("min", "Minimum value", &mut draft.min), + ("max", "Maximum value", &mut draft.max), + ("step", "Incremental change", &mut draft.step), + ("page", "Page change", &mut draft.page), + ] { + if key == "page" && c.kind != ControlKind::Slider { + continue; + } + *slot = whole(&val(d, key)).ok_or_else(|| format!("{label} must be a whole number from 0 to {CONTROL_MAX}."))?; + out[key] = json!(*slot); + } + draft.check_numbers()?; + let value = whole(&val(d, "value")).ok_or_else(|| format!("Current value must be a whole number from 0 to {CONTROL_MAX}."))?; + if value < draft.min || value > draft.max { + return Err(format!("Current value must be between {} and {}.", draft.min, draft.max)); + } + out["value"] = json!(value); + out["link"] = json!(val(d, "link").trim()); + if c.kind == ControlKind::Slider { + out["horizontal"] = json!(flag("horizontal")); + } + Ok(out) +} + +pub fn format_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) { + let t = Tokens::get(ui.ctx()); + let Some(c) = d.values.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok()).and_then(|id| control(app, id)) else { + *open = false; + return; + }; + let text_only = d.values.get("textOnly").and_then(Json::as_bool) == Some(true); + if c.kind == ControlKind::Button { + let mut text = val(d, "text"); + ui.label("Text:"); + ui.add(egui::TextEdit::multiline(&mut text).desired_rows(2).desired_width(f32::INFINITY).id_salt("control_text")); + d.values.insert("text".into(), json!(text)); + } else { + egui::Grid::new("format_control_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { + let mut fields = vec![("value", "Current value:"), ("min", "Minimum value:"), ("max", "Maximum value:"), ("step", "Incremental change:")]; + if c.kind == ControlKind::Slider { + fields.push(("page", "Page change:")); + } + fields.push(("link", "Cell link:")); + for (key, label) in fields { + ui.label(label); + text_field(ui, d, key, if key == "link" { 160.0 } else { 90.0 }); + ui.end_row(); + } + }); + if c.kind == ControlKind::Slider { + let mut h = d.values.get("horizontal").and_then(Json::as_bool).unwrap_or(true); + ui.horizontal(|ui| { + ui.label("Orientation:"); + ui.radio_value(&mut h, true, "Horizontal"); + ui.radio_value(&mut h, false, "Vertical"); + }); + d.values.insert("horizontal".into(), json!(h)); + } + } + if !text_only { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("Name:"); + let mut name = val(d, "name"); + ui.add(egui::TextEdit::singleline(&mut name).hint_text(c.display_name()).desired_width(200.0).id_salt("control_name")); + d.values.insert("name".into(), json!(name)); + }); + for (key, label) in [("locked", "Locked (when the sheet is protected)"), ("print", "Print object")] { + let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(true); + ui.checkbox(&mut on, label); + d.values.insert(key.into(), json!(on)); + } + } + let params = format_params(app, d); + if let Err(p) = ¶ms { + ui.label(egui::RichText::new(p).color(t.danger)); + } + let (ok, cancel) = ok_cancel(ui, params.is_ok()); + if cancel { + *open = false; + } + if ok && let Ok(p) = params { + match app.run("control.format", p) { + Ok(_) => *open = false, + Err(e) => app.message = Some(("Format Control".into(), crate::clean_error(&e))), + } + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 92187d5c0..411992c27 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1017,6 +1017,9 @@ impl Dialog { dialog } "pickList" => Dialog::custom("pickList", "Pick From List", json!({})), + "assignAction" => return crate::control_ui::open_assign(app, ¶ms), + "formatControl" => return crate::control_ui::open_format(app, ¶ms), + "controlQuestion" => return crate::control_ui::open_question(¶ms), "saveCopy" => Dialog::custom("saveCopy", "Save a Copy", json!({"format": p("format")})), "saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title"), "quit": p("quit")})), "start" => Dialog::custom("start", "GridCraft", json!({})), diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index 071a94e5b..8a2acc010 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -143,6 +143,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "recommendedCharts" => 600.0, "analysisToolPak" => 420.0, "solver" => 460.0, + "assignAction" => 440.0, "zoom" => 220.0, "pasteSpecial" => 480.0, _ => 380.0, @@ -184,6 +185,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "solver" => solver(ui, &mut d, &mut confirm, &mut open), "solverResults" => solver_results(app, ui, &mut d, &mut open), "spelling" => spelling(app, ui, &mut d, &mut open), + "assignAction" => crate::control_ui::assign_dialog(app, ui, &mut d, &mut open), + "formatControl" => crate::control_ui::format_dialog(app, ui, &mut d, &mut open), + "controlQuestion" => crate::control_ui::question_dialog(app, ui, &mut d, &mut open), "agents" => { ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:"); ui.add_space(4.0); diff --git a/crates/ui-egui/src/form_controls.rs b/crates/ui-egui/src/form_controls.rs new file mode 100644 index 000000000..c4fff919a --- /dev/null +++ b/crates/ui-egui/src/form_controls.rs @@ -0,0 +1,443 @@ +//! Form controls on the sheet: drawing buttons, sliders and spin buttons, and working them with +//! the pointer and the keyboard. A click operates a control; Ctrl or Cmd+click, or a right-click, +//! selects it instead (to move, resize or format it). Values change through `control.setValue`, +//! and a whole drag or a held arrow is one undo step. +//! +//! Positions are logical (see `Geo::rtl`): on a right-to-left sheet a control moves as a whole +//! but is not mirrored, so a point inside it is reflected back with [`inner`] first. + +use egui::epaint::PathShape; +use egui::{Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2}; +use gridcraft_engine::model::{ControlKind, FormControl, ObjKind, ObjRef, Sheet}; +use serde_json::json; + +use crate::SheetApp; +use crate::grid::Geo; +use crate::theme::{self, Tokens}; + +/// How long an arrow or the track waits before it repeats, and then between repeats (seconds). +const REPEAT_DELAY: f64 = 0.4; +const REPEAT_EVERY: f64 = 0.06; + +/// The part of a control under the pointer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Part { + Button, + /// A spin button's arrow that adds a step. + Up, + /// A spin button's arrow that takes a step away. + Down, + Thumb, + /// The track on the minimum's side of the thumb (a page down). + TrackBefore, + /// The track on the maximum's side of the thumb (a page up). + TrackAfter, +} + +/// A control being worked with the pointer. +#[derive(Clone, Debug, PartialEq)] +pub struct Gesture { + pub id: u32, + pub part: Part, + /// Thumb drags: where along the track the pointer grabbed the thumb, from its centre. + pub grab: f32, + /// The undo stack's length when the gesture began: everything it changes is one step. + pub undo_start: usize, + /// When a held arrow or track repeats next (egui time, seconds). + pub next_repeat: f64, +} + +/// What a control looks like this frame. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Look { + pub hover: Option, + pub pressed: Option, + pub focused: bool, + /// Show the value next to the thumb (while dragging). + pub show_value: bool, +} + +fn fraction(c: &FormControl) -> f32 { + let span = c.max.saturating_sub(c.min).max(1) as f32; + (c.value.saturating_sub(c.min) as f32 / span).clamp(0.0, 1.0) +} + +/// A slider's track and thumb in `r`. A horizontal slider has its minimum on the left; a vertical +/// one has it at the top (as Excel's scroll bars do). +pub fn slider_parts(r: Rect, c: &FormControl, z: f32) -> (Rect, Rect) { + let f = fraction(c); + if c.horizontal { + let knob = (r.height() - 4.0 * z).clamp(6.0 * z, 16.0 * z).min(r.width() / 2.0).max(1.0); + let track = Rect::from_center_size(r.center(), vec2((r.width() - knob).max(1.0), (4.0 * z).min(r.height()))); + let x = track.left() + f * track.width(); + (track, Rect::from_center_size(pos2(x, r.center().y), vec2(knob, (r.height() - 4.0 * z).max(4.0)))) + } else { + let knob = (r.width() - 4.0 * z).clamp(6.0 * z, 16.0 * z).min(r.height() / 2.0).max(1.0); + let track = Rect::from_center_size(r.center(), vec2((4.0 * z).min(r.width()), (r.height() - knob).max(1.0))); + let y = track.top() + f * track.height(); + (track, Rect::from_center_size(pos2(r.center().x, y), vec2((r.width() - 4.0 * z).max(4.0), knob))) + } +} + +/// A spin button's halves: (the arrow that adds, the arrow that subtracts). Tall spin buttons +/// stack them (up on top); wide ones put them side by side (up on the right). +pub fn spin_parts(r: Rect) -> (Rect, Rect) { + if r.height() >= r.width() { + let (top, bottom) = r.split_top_bottom_at_fraction(0.5); + (top, bottom) + } else { + let (left, right) = r.split_left_right_at_fraction(0.5); + (right, left) + } +} + +/// A logical point `p` inside control rect `r`, as the control is drawn: on a right-to-left +/// sheet the control moves as a whole without mirroring, so `p` is reflected within `r`. +pub fn inner(geo: &Geo, r: Rect, p: Pos2) -> Pos2 { + if geo.rtl { pos2(r.left() + r.right() - p.x, p.y) } else { p } +} + +/// The part of control `c` (drawn in `r`) under `p`. +pub fn part_at(r: Rect, c: &FormControl, z: f32, p: Pos2) -> Option { + if !r.contains(p) { + return None; + } + Some(match c.kind { + ControlKind::Button => Part::Button, + ControlKind::Spin => { + let (up, _) = spin_parts(r); + if up.contains(p) { Part::Up } else { Part::Down } + } + ControlKind::Slider => { + let (_, thumb) = slider_parts(r, c, z); + if thumb.expand(2.0).contains(p) { + Part::Thumb + } else { + let before = if c.horizontal { p.x < thumb.center().x } else { p.y < thumb.center().y }; + if before { Part::TrackBefore } else { Part::TrackAfter } + } + } + }) +} + +/// The value a thumb dragged to `p` stands for (`grab`: the offset from the thumb's centre where +/// it was grabbed). +pub fn value_at(r: Rect, c: &FormControl, z: f32, p: Pos2, grab: f32) -> f64 { + let (track, _) = slider_parts(r, c, z); + let f = if c.horizontal { (p.x - grab - track.left()) / track.width().max(1.0) } else { (p.y - grab - track.top()) / track.height().max(1.0) }; + c.min as f64 + f.clamp(0.0, 1.0) as f64 * c.max.saturating_sub(c.min) as f64 +} + +/// Paints control `c` in `r`, in the sheet's control colours. +pub fn paint(p: &Painter, r: Rect, c: &FormControl, z: f32, look: Look) { + let t = Tokens::get(p.ctx()); + let border = Stroke::new(1.0, t.sheet_control_border); + let bg = |part: Part| { + if look.pressed == Some(part) { + t.sheet_control_pressed + } else if look.hover == Some(part) { + t.sheet_control_hover + } else { + t.sheet_control_bg + } + }; + let clip = p.with_clip_rect(r.expand(2.0 * z.max(1.0) + 3.0).intersect(p.clip_rect())); + match c.kind { + ControlKind::Button => { + let fill = bg(Part::Button); + clip.rect_filled(r, 3.0 * z, fill); + clip.rect_stroke(r, 3.0 * z, border, StrokeKind::Inside); + let shift = if look.pressed == Some(Part::Button) { vec2(0.0, 1.0) } else { vec2(0.0, 0.0) }; + let g = clip.layout(c.text.clone(), theme::ui_font(12.0 * z), t.sheet_control_text, (r.width() - 8.0 * z).max(1.0)); + clip.with_clip_rect(r.shrink(1.0).intersect(clip.clip_rect())).galley(r.center() - g.size() / 2.0 + shift, g, t.sheet_control_text); + } + ControlKind::Slider => { + clip.rect_filled(r, 2.0 * z, t.sheet_control_bg); + clip.rect_stroke(r, 2.0 * z, border, StrokeKind::Inside); + let (track, thumb) = slider_parts(r, c, z); + clip.rect_filled(track, 2.0 * z, t.sheet_track); + let filled = if c.horizontal { + Rect::from_min_max(track.min, pos2(thumb.center().x, track.max.y)) + } else { + Rect::from_min_max(track.min, pos2(track.max.x, thumb.center().y)) + }; + clip.rect_filled(filled, 2.0 * z, t.sheet_fill); + let knob = if look.pressed == Some(Part::Thumb) || look.hover == Some(Part::Thumb) { t.sheet_control_text } else { t.sheet_thumb }; + clip.rect_filled(thumb, 3.0 * z, knob); + clip.rect_stroke(thumb, 3.0 * z, Stroke::new(1.0, t.sheet_control_bg), StrokeKind::Inside); + if look.show_value { + let g = clip.layout_no_wrap(c.value.to_string(), theme::ui_font(11.0 * z), t.sheet_control_text); + let at = if c.horizontal { + pos2(thumb.center().x - g.size().x / 2.0, r.top() - g.size().y - 4.0 * z) + } else { + pos2(r.right() + 4.0 * z, thumb.center().y - g.size().y / 2.0) + }; + let card = Rect::from_min_size(at, g.size()).expand(2.0 * z); + p.rect_filled(card, 2.0 * z, t.sheet_control_bg); + p.rect_stroke(card, 2.0 * z, border, StrokeKind::Inside); + p.galley(at, g, t.sheet_control_text); + } + } + ControlKind::Spin => { + let (up, down) = spin_parts(r); + let tall = r.height() >= r.width(); + for (half, part) in [(up, Part::Up), (down, Part::Down)] { + clip.rect_filled(half, 2.0 * z, bg(part)); + clip.rect_stroke(half, 2.0 * z, border, StrokeKind::Inside); + let s = (half.width().min(half.height()) * 0.28).max(1.5); + let m = half.center(); + let tri = match (tall, part) { + (true, Part::Up) => vec![pos2(m.x, m.y - s * 0.6), pos2(m.x - s, m.y + s * 0.5), pos2(m.x + s, m.y + s * 0.5)], + (true, _) => vec![pos2(m.x, m.y + s * 0.6), pos2(m.x + s, m.y - s * 0.5), pos2(m.x - s, m.y - s * 0.5)], + (false, Part::Up) => vec![pos2(m.x + s * 0.6, m.y), pos2(m.x - s * 0.5, m.y + s), pos2(m.x - s * 0.5, m.y - s)], + (false, _) => vec![pos2(m.x - s * 0.6, m.y), pos2(m.x + s * 0.5, m.y - s), pos2(m.x + s * 0.5, m.y + s)], + }; + clip.add(PathShape::convex_polygon(tri, t.sheet_control_text, Stroke::NONE)); + } + } + } + if look.focused { + p.rect_stroke(r.expand(2.0), 3.0 * z, Stroke::new(2.0, t.sheet_focus), StrokeKind::Outside); + } +} + +/// The control with `id` on `sh`. +pub fn find(sh: &Sheet, id: u32) -> Option<&FormControl> { + sh.controls.iter().find(|c| c.id == id) +} + +/// How control `c` looks this frame, from the gesture, the focus and the pointer (`hover`, a +/// logical position). +pub fn look(app: &SheetApp, geo: &Geo, c: &FormControl, r: Rect, hover: Option) -> Look { + let gesture = app.grid.control.as_ref().filter(|g| g.id == c.id); + let selected = app.selected_object == Some(ObjRef::new(ObjKind::Control, c.id)); + let hover = if selected { None } else { hover.and_then(|p| part_at(r, c, geo.z, inner(geo, r, p))) }; + let pressed = gesture.map(|g| g.part).filter(|part| *part == Part::Thumb || hover == Some(*part)); + Look { hover, pressed, focused: app.grid.focused_control == Some(c.id) && !selected, show_value: gesture.is_some_and(|g| g.part == Part::Thumb) } +} + +impl SheetApp { + /// Sets a slider or spin button's value; inside a gesture, keeps it one undo step. + fn control_set(&mut self, id: u32, value: f64) -> bool { + match self.session.run("control.setValue", json!({"id": id, "value": value})) { + Ok(_) => { + if let Some(start) = self.grid.control.as_ref().map(|g| g.undo_start) + && let Some(d) = self.session.active_mut() + && d.undo.len() > start + 1 + { + d.undo.truncate(start + 1); + } + true + } + Err(e) => { + self.grid.control = None; + self.message = Some(("Form Control".into(), crate::clean_error(&e))); + false + } + } + } + + /// Moves a slider or spin button by `steps` steps (negative: toward the minimum), or by pages + /// when `page` is set. + pub fn control_nudge(&mut self, id: u32, steps: i64, page: bool) -> bool { + let Some(c) = self.session.active().and_then(|d| d.wb.active()).and_then(|sh| find(sh, id)).cloned() else { return false }; + if c.kind == ControlKind::Button { + return false; + } + let by = if page && c.kind == ControlKind::Slider { c.page } else { c.step } as f64; + self.control_set(id, c.value as f64 + steps as f64 * by) + } + + /// Runs a button's action. Questions (approve it, make room for a macro's output) become a + /// prompt. + pub fn press_button(&mut self, id: u32) { + crate::control_ui::press(self, json!({"id": id})); + } +} + +/// Pointer handling for controls on the grid. Returns true when it used the pointer this frame +/// (so the grid neither selects cells nor objects). +pub fn interact(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) -> bool { + let (pointer, mods, time) = ctx.input(|i| (i.pointer.clone(), i.modifiers, i.time)); + let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); + // A control that no longer exists loses its gesture and focus. + if let Some(id) = app.grid.focused_control + && find(sh, id).is_none() + { + app.grid.focused_control = None; + } + if let Some(g) = app.grid.control.clone() { + let Some(c) = find(sh, g.id).cloned() else { + app.grid.control = None; + return true; + }; + let r = crate::chartview::anchor_rect(geo, sh, &c.anchor); + if pointer.primary_down() { + match g.part { + Part::Thumb => { + if let Some(p) = pos { + let v = c.snap(value_at(r, &c, geo.z, inner(geo, r, p), g.grab)); + if v != c.value { + app.control_set(c.id, v as f64); + } + } + } + Part::Button => {} + part => { + // Held arrows and track repeat while the pointer stays on them. + let over = pos.and_then(|p| part_at(r, &c, geo.z, inner(geo, r, p))) == Some(part); + if over && time >= g.next_repeat { + step_part(app, &c, part); + if let Some(gg) = app.grid.control.as_mut() { + gg.next_repeat = time + REPEAT_EVERY; + } + } + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + } + } + return true; + } + // Released: a button runs when the pointer is still on it. + app.grid.control = None; + if g.part == Part::Button && pos.is_some_and(|p| r.contains(p)) { + app.press_button(c.id); + } + resp.request_focus(); + return true; + } + if !pointer.primary_pressed() || !resp.contains_pointer() { + // Any other press on the grid moves the focus off the control. + if pointer.any_pressed() && app.grid.focused_control.is_some() && !resp.contains_pointer() { + app.grid.focused_control = None; + } + return false; + } + let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return false }; + // The selected object's handles reach past its frame and win over a control beneath them. + if let Some(r) = app.selected_object.and_then(|o| crate::chartview::object_rect(geo, sh, o)) + && crate::objects::handle_at(r, p).is_some() + { + return false; + } + let hit = crate::chartview::hit(app, geo, sh, p).filter(|(o, _)| o.kind == ObjKind::Control); + // Ctrl or Cmd+click selects; a selected control moves and resizes instead of working. + let Some((o, r)) = hit.filter(|(o, _)| !mods.command && !mods.ctrl && app.selected_object != Some(*o)) else { + app.grid.focused_control = None; + return false; + }; + let Some(c) = find(sh, o.id).cloned() else { return false }; + let p = inner(geo, r, p); + let Some(part) = part_at(r, &c, geo.z, p) else { return false }; + app.selected_object = None; + app.grid.focused_control = Some(c.id); + let undo_start = app.session.active().map_or(0, |d| d.undo.len()); + let grab = if part == Part::Thumb { + let (_, thumb) = slider_parts(r, &c, geo.z); + if c.horizontal { p.x - thumb.center().x } else { p.y - thumb.center().y } + } else { + 0.0 + }; + app.grid.control = Some(Gesture { id: c.id, part, grab, undo_start, next_repeat: time + REPEAT_DELAY }); + if !matches!(part, Part::Button | Part::Thumb) { + step_part(app, &c, part); + } + resp.request_focus(); + true +} + +/// One click on an arrow or the track. +fn step_part(app: &mut SheetApp, c: &FormControl, part: Part) { + match part { + Part::Up => app.control_nudge(c.id, 1, false), + Part::Down => app.control_nudge(c.id, -1, false), + Part::TrackBefore => app.control_nudge(c.id, -1, true), + Part::TrackAfter => app.control_nudge(c.id, 1, true), + Part::Button | Part::Thumb => false, + }; +} + +/// Keys for the focused control: arrows move by a step, Page Up and Page Down by a page, Home and +/// End to the ends; Space or Enter presses a button. True when the key was used. +pub fn key(app: &mut SheetApp, sh: &Sheet, key: egui::Key) -> bool { + use egui::Key; + let Some(c) = app.grid.focused_control.and_then(|id| find(sh, id)).cloned() else { return false }; + if app.selected_object.is_some() { + return false; + } + match (c.kind, key) { + (_, Key::Escape) => { + app.grid.focused_control = None; + true + } + (ControlKind::Button, Key::Space | Key::Enter) => { + app.press_button(c.id); + true + } + (ControlKind::Button, _) => false, + // A spin button's up arrow adds; a slider's minimum is at its left or top. + (ControlKind::Spin, Key::ArrowUp | Key::ArrowRight) | (ControlKind::Slider, Key::ArrowDown | Key::ArrowRight) => { + app.control_nudge(c.id, 1, false) + } + (ControlKind::Spin, Key::ArrowDown | Key::ArrowLeft) | (ControlKind::Slider, Key::ArrowUp | Key::ArrowLeft) => { + app.control_nudge(c.id, -1, false) + } + (ControlKind::Spin, Key::PageUp) | (ControlKind::Slider, Key::PageDown) => app.control_nudge(c.id, 1, true), + (ControlKind::Spin, Key::PageDown) | (ControlKind::Slider, Key::PageUp) => app.control_nudge(c.id, -1, true), + (_, Key::Home) => app.control_set(c.id, c.min as f64), + (_, Key::End) => app.control_set(c.id, c.max as f64), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn slider(value: u32, horizontal: bool) -> FormControl { + FormControl { kind: ControlKind::Slider, min: 0, max: 100, value, horizontal, ..Default::default() } + } + + #[test] + fn the_thumb_sits_at_the_value_and_dragging_reads_it_back() { + let r = Rect::from_min_size(pos2(10.0, 10.0), vec2(200.0, 20.0)); + for v in [0, 25, 50, 100] { + let c = slider(v, true); + let (_, thumb) = slider_parts(r, &c, 1.0); + assert!((value_at(r, &c, 1.0, thumb.center(), 0.0) - v as f64).abs() < 0.5, "{v}"); + assert_eq!(part_at(r, &c, 1.0, thumb.center()), Some(Part::Thumb)); + } + let c = slider(50, true); + assert_eq!(part_at(r, &c, 1.0, pos2(15.0, 20.0)), Some(Part::TrackBefore)); + assert_eq!(part_at(r, &c, 1.0, pos2(205.0, 20.0)), Some(Part::TrackAfter)); + assert_eq!(part_at(r, &c, 1.0, pos2(5.0, 20.0)), None); + // Past the ends clamps. + assert_eq!(value_at(r, &c, 1.0, pos2(-500.0, 20.0), 0.0), 0.0); + assert_eq!(value_at(r, &c, 1.0, pos2(900.0, 20.0), 0.0), 100.0); + // Vertical: the minimum is at the top. + let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 200.0)); + let c = slider(0, false); + let (_, thumb) = slider_parts(r, &c, 1.0); + assert!(thumb.center().y < 20.0); + assert_eq!(part_at(r, &slider(50, false), 1.0, pos2(10.0, 190.0)), Some(Part::TrackAfter)); + } + + #[test] + fn spin_halves() { + let tall = Rect::from_min_size(pos2(0.0, 0.0), vec2(18.0, 36.0)); + let c = FormControl { kind: ControlKind::Spin, ..Default::default() }; + assert_eq!(part_at(tall, &c, 1.0, pos2(9.0, 5.0)), Some(Part::Up)); + assert_eq!(part_at(tall, &c, 1.0, pos2(9.0, 30.0)), Some(Part::Down)); + let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(40.0, 18.0)); + assert_eq!(part_at(wide, &c, 1.0, pos2(35.0, 9.0)), Some(Part::Up)); + assert_eq!(part_at(wide, &c, 1.0, pos2(5.0, 9.0)), Some(Part::Down)); + } + + #[test] + fn degenerate_ranges_do_not_divide_by_zero() { + let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(1.0, 1.0)); + let c = FormControl { kind: ControlKind::Slider, min: 5, max: 5, value: 5, ..Default::default() }; + let (track, thumb) = slider_parts(r, &c, 1.0); + assert!(track.width().is_finite() && thumb.center().x.is_finite()); + assert!(value_at(r, &c, 1.0, pos2(0.5, 0.5), 0.0).is_finite()); + } +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 85ff53b07..e86f7aac6 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -240,6 +240,14 @@ pub struct GridState { pub hf_start: bool, /// The grid geometry of the last frame (hit-testing for agents and tests). pub geo: Option, + /// A form control being worked with the pointer. + pub control: Option, + /// The form control that takes the arrow keys (the one last worked). + pub focused_control: Option, + /// Insert › Controls: the kind a click or drag on the grid places, and where the drag began + /// (logical position). + pub placing: Option, + pub place_start: Option, /// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests). pub hscroll: Option, /// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests). diff --git a/crates/ui-egui/src/grid/cursor.rs b/crates/ui-egui/src/grid/cursor.rs index f93e59cb6..2b560b3d9 100644 --- a/crates/ui-egui/src/grid/cursor.rs +++ b/crates/ui-egui/src/grid/cursor.rs @@ -1,3 +1,5 @@ +use gridcraft_engine::model::ObjKind; + use super::geo::Geo; use super::interact::{col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge}; use super::{CellRef, Color32, CursorIcon, Drag, Pos2, RangeRef, Rect, Sheet, SheetApp, Stroke, pos2, vec2}; @@ -21,8 +23,9 @@ pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Resp { app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); } - // Draw tools own the cursor over cells, including while recording a stroke. - if !app.session.draw_tool.is_empty() && in_cells { + // Draw tools and Insert › Controls placement own the cursor over cells, including while + // recording a stroke or drawing the control. + if (!app.session.draw_tool.is_empty() || app.grid.placing.is_some()) && in_cells { ctx.set_cursor_icon(CursorIcon::Crosshair); return; } @@ -79,9 +82,15 @@ fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, poin // The selected object's handles resize it; any object's body moves it. let idle = app.grid.drag == Drag::None && app.editor.is_none(); let object_handle = if idle { crate::chartview::selected_handle(app, geo, sh, p).map(|(.., h)| h) } else { None }; - let on_object = idle && crate::chartview::hit(app, geo, sh, p).is_some(); + let hit = if idle { crate::chartview::hit(app, geo, sh, p) } else { None }; + // A form control works on click unless it is selected (or Ctrl or Cmd is held). + let mods = ctx.input(|i| i.modifiers); + let works = hit.is_some_and(|(o, _)| o.kind == ObjKind::Control && app.selected_object != Some(o) && !mods.command && !mods.ctrl); + let on_object = hit.is_some(); if let Some(h) = object_handle { ctx.set_cursor_icon(h.cursor(geo.rtl)); + } else if works { + ctx.set_cursor_icon(CursorIcon::PointingHand); } else if on_object { ctx.set_cursor_icon(CursorIcon::Move); } else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) { diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 4b7adf587..289f76206 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -64,6 +64,15 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left()); let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top()); let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top()); + // Insert › Controls places a control; a click works a form control (Ctrl or Cmd+click, or a + // right-click, selects it instead). + if app.editor.is_none() && crate::control_ui::place(app, ui, resp, geo, sh) { + return; + } + if app.editor.is_none() && app.session.draw_tool.is_empty() && crate::form_controls::interact(app, &ctx, resp, geo, sh) { + keyboard(app, &ctx, resp, geo, sh); + return; + } let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); let cur_rect = geo.range_rect(sh, sel.current()); let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); @@ -146,14 +155,18 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo } app.selected_object = None; } - // A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's - // under it; a right press that wobbles into a drag counts too (#2067). + // A right-click on a chart, picture, shape, slicer or form control opens the object's menu, not + // the cell's under it; a right press that wobbles into a drag counts too (#2067). On a form + // control it selects the control without running it. let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary); if (resp.secondary_clicked() || secondary_drag) && let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos } && let Some((obj, _)) = crate::chartview::hit(app, geo, sh, p) { app.selected_object = Some(obj); + if obj.kind == ObjKind::Control { + app.grid.focused_control = None; + } app.grid.object_menu = Some((geo.mirror_pos(p), obj.kind.name(), obj.id)); return; } diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index b8823d570..f2d571bb3 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -32,7 +32,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res ctx.memory_mut(|m| { m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) }); - let events = ctx.input(|i| i.events.clone()); + let events = grid_events(app, ctx, sh); // A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit. let shortcut_pressed = events.iter().any(|e| { matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. } @@ -157,3 +157,20 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res } } } + +/// This frame's input events, without those the focused form control took. +fn grid_events(app: &mut SheetApp, ctx: &egui::Context, sh: &Sheet) -> Vec { + let events = ctx.input(|i| i.events.clone()); + events.into_iter().filter(|ev| !form_control_event(app, sh, ev)).collect() +} + +/// Whether the focused form control takes `ev`: arrows, Page Up and Down, Home, End, Space and +/// Enter without Ctrl, Cmd or Alt, and the space a Space press types (it presses a focused button +/// and doesn't start typing in a cell). +fn form_control_event(app: &mut SheetApp, sh: &Sheet, ev: &egui::Event) -> bool { + match ev { + egui::Event::Text(text) => text == " " && app.grid.focused_control.is_some() && app.selected_object.is_none(), + egui::Event::Key { key, pressed: true, modifiers: m, .. } => !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key), + _ => false, + } +} diff --git a/crates/ui-egui/src/grid/menus.rs b/crates/ui-egui/src/grid/menus.rs index b05a04bc7..0cd9c9d74 100644 --- a/crates/ui-egui/src/grid/menus.rs +++ b/crates/ui-egui/src/grid/menus.rs @@ -369,14 +369,22 @@ pub(super) fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { } } -/// The right-click menu of a chart, picture, shape or slicer. +/// The right-click menu of a chart, picture, shape, slicer or form control (Edit Text and Assign +/// Action for a button, and Format Control, before Delete). pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { let Some((at, kind, id)) = app.grid.object_menu else { return }; let mut close = false; let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(200.0); - let mut items = vec![("Delete", "object.delete")]; + let mut items = vec![]; + if kind == "control" { + if crate::control_ui::is_button(app, id) { + items.extend([("Edit Text", "ui:editText"), ("Assign Action…", "ui:assignAction")]); + } + items.extend([("Format Control…", "ui:formatControl"), ("-", "")]); + } + items.push(("Delete", "object.delete")); if kind == "chart" { items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]); } @@ -391,6 +399,9 @@ pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { match action { "ui:selectData" => app.open_dialog("selectData", json!({"chart": id})), "ui:formatChart" => app.grid.pane = Some("formatChart".into()), + "ui:editText" => app.open_dialog("formatControl", json!({"id": id, "textOnly": true})), + "ui:assignAction" => app.open_dialog("assignAction", json!({"id": id})), + "ui:formatControl" => app.open_dialog("formatControl", json!({"id": id})), _ => { app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); app.selected_object = None; diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 9a6d54030..88bed777b 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -242,6 +242,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Convert to Comments" => "Převést na komentáře", "Checkbox" => "Zaškrtávací políčko", "Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk", + "Button" => "Tlačítko", + "Slider" => "Posuvník", + "Spin Button" => "Číselník", + "Draw a button that runs a macro or a command" => "Nakreslit tlačítko, které spustí makro nebo příkaz", + "Draw a slider that sets a whole number in a linked cell" => "Nakreslit posuvník, který nastaví celé číslo v propojené buňce", + "Draw a spin button that steps a whole number in a linked cell" => "Nakreslit číselník, který po krocích mění celé číslo v propojené buňce", + "Edit Text" => "Upravit text", + "Assign Action…" => "Přiřadit akci…", + "Format Control…" => "Formát ovládacího prvku…", "Sheet\nRight-to-Left" => "List zprava\ndoleva", "Sheet Right-to-Left" => "List zprava doleva", "Data\nAnalysis" => "Analýza\ndat", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 6427b279d..15775cf5d 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -246,6 +246,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Convert to Comments" => "In Kommentare konvertieren", "Checkbox" => "Kontrollkästchen", "Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen", + "Button" => "Schaltfläche", + "Slider" => "Schieberegler", + "Spin Button" => "Drehfeld", + "Draw a button that runs a macro or a command" => "Eine Schaltfläche zeichnen, die ein Makro oder einen Befehl ausführt", + "Draw a slider that sets a whole number in a linked cell" => { + "Einen Schieberegler zeichnen, der eine ganze Zahl in einer verknüpften Zelle festlegt" + } + "Draw a spin button that steps a whole number in a linked cell" => { + "Ein Drehfeld zeichnen, das eine ganze Zahl in einer verknüpften Zelle schrittweise ändert" + } + "Edit Text" => "Text bearbeiten", + "Assign Action…" => "Aktion zuweisen…", + "Format Control…" => "Steuerelement formatieren…", "Sheet\nRight-to-Left" => "Blatt von rechts\nnach links", "Sheet Right-to-Left" => "Blatt von rechts nach links", "Data\nAnalysis" => "Daten-\nanalyse", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index 9231f8244..1383f876f 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -235,6 +235,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Convert to Comments" => "Μετατροπή σε σχόλια", "Checkbox" => "Πλαίσιο ελέγχου", "Insert checkboxes in the selected cells" => "Εισαγωγή πλαισίων ελέγχου στα επιλεγμένα κελιά", + "Button" => "Κουμπί", + "Slider" => "Ρυθμιστικό", + "Spin Button" => "Κουμπί περιστροφής", + "Draw a button that runs a macro or a command" => "Σχεδίαση κουμπιού που εκτελεί μια μακροεντολή ή μια εντολή", + "Draw a slider that sets a whole number in a linked cell" => "Σχεδίαση ρυθμιστικού που ορίζει έναν ακέραιο αριθμό σε ένα συνδεδεμένο κελί", + "Draw a spin button that steps a whole number in a linked cell" => { + "Σχεδίαση κουμπιού περιστροφής που αλλάζει κατά βήματα έναν ακέραιο αριθμό σε ένα συνδεδεμένο κελί" + } + "Edit Text" => "Επεξεργασία κειμένου", + "Assign Action…" => "Αντιστοίχιση ενέργειας…", + "Format Control…" => "Μορφοποίηση στοιχείου ελέγχου…", "Sheet\nRight-to-Left" => "Φύλλο από\nδεξιά προς αριστερά", "Sheet Right-to-Left" => "Φύλλο από δεξιά προς αριστερά", "Data\nAnalysis" => "Ανάλυση\nδεδομένων", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index a4b4e884a..9eb7f968a 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -245,6 +245,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Convert to Comments" => "Convertir en comentarios", "Checkbox" => "Casilla", "Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas", + "Button" => "Botón", + "Slider" => "Control deslizante", + "Spin Button" => "Control de número", + "Draw a button that runs a macro or a command" => "Dibujar un botón que ejecuta una macro o un comando", + "Draw a slider that sets a whole number in a linked cell" => { + "Dibujar un control deslizante que establece un número entero en una celda vinculada" + } + "Draw a spin button that steps a whole number in a linked cell" => { + "Dibujar un control de número que cambia un número entero en una celda vinculada" + } + "Edit Text" => "Modificar texto", + "Assign Action…" => "Asignar acción…", + "Format Control…" => "Formato de control…", "Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda", "Sheet Right-to-Left" => "Hoja de derecha a izquierda", "Data\nAnalysis" => "Análisis\nde datos", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index fdb20f7bf..e6a1e4edb 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -118,6 +118,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "Courbes de tendance", "Checkbox" => "Case à cocher", "Insert checkboxes in the selected cells" => "Insérer des cases à cocher dans les cellules sélectionnées", + "Button" => "Bouton", + "Slider" => "Curseur", + "Spin Button" => "Toupie", + "Draw a button that runs a macro or a command" => "Dessiner un bouton qui exécute une macro ou une commande", + "Draw a slider that sets a whole number in a linked cell" => "Dessiner un curseur qui définit un nombre entier dans une cellule liée", + "Draw a spin button that steps a whole number in a linked cell" => { + "Dessiner une toupie qui modifie par pas un nombre entier dans une cellule liée" + } + "Edit Text" => "Modifier le texte", + "Assign Action…" => "Affecter une action…", + "Format Control…" => "Format de contrôle…", "Header & Footer" => "En-tête et pied de page", "Draw with ink" => "Dessiner à l'encre", "Eraser" => "Gomme", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 31bc6721c..87726f66b 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -111,6 +111,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "Sparkline", "Checkbox" => "Casella di controllo", "Insert checkboxes in the selected cells" => "Inserisci caselle di controllo nelle celle selezionate", + "Button" => "Pulsante", + "Slider" => "Dispositivo di scorrimento", + "Spin Button" => "Pulsante di selezione", + "Draw a button that runs a macro or a command" => "Disegna un pulsante che esegue una macro o un comando", + "Draw a slider that sets a whole number in a linked cell" => { + "Disegna un dispositivo di scorrimento che imposta un numero intero in una cella collegata" + } + "Draw a spin button that steps a whole number in a linked cell" => { + "Disegna un pulsante di selezione che modifica a passi un numero intero in una cella collegata" + } + "Edit Text" => "Modifica testo", + "Assign Action…" => "Assegna azione…", + "Format Control…" => "Formato controllo…", "Header & Footer" => "Intestazione e piè di pagina", "Draw with ink" => "Disegna a mano libera", "Eraser" => "Gomma", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 1f06755dd..202872cb9 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -115,6 +115,15 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Sparklines", "スパークライン"), ("Checkbox", "チェックボックス"), ("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"), + ("Button", "ボタン"), + ("Slider", "スライダー"), + ("Spin Button", "スピン ボタン"), + ("Draw a button that runs a macro or a command", "マクロまたはコマンドを実行するボタンを描画"), + ("Draw a slider that sets a whole number in a linked cell", "リンクされたセルに整数を設定するスライダーを描画"), + ("Draw a spin button that steps a whole number in a linked cell", "リンクされたセルの整数を増減するスピン ボタンを描画"), + ("Edit Text", "テキストの編集"), + ("Assign Action…", "動作の登録…"), + ("Format Control…", "コントロールの書式設定…"), ("Header & Footer", "ヘッダーとフッター"), ("Draw with ink", "インクで描画"), ("Eraser", "消しゴム"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c54ed7eee..bc189e74a 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -113,6 +113,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "스파크라인", "Checkbox" => "확인란", "Insert checkboxes in the selected cells" => "선택한 셀에 확인란 삽입", + "Button" => "단추", + "Slider" => "슬라이더", + "Spin Button" => "스핀 단추", + "Draw a button that runs a macro or a command" => "매크로나 명령을 실행하는 단추 그리기", + "Draw a slider that sets a whole number in a linked cell" => "연결된 셀에 정수를 설정하는 슬라이더 그리기", + "Draw a spin button that steps a whole number in a linked cell" => "연결된 셀의 정수를 증감하는 스핀 단추 그리기", + "Edit Text" => "텍스트 편집", + "Assign Action…" => "동작 지정…", + "Format Control…" => "컨트롤 서식…", "Header & Footer" => "머리글 및 바닥글", "Draw with ink" => "잉크로 그리기", "Eraser" => "지우개", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index a28459f83..a89f7b322 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -112,6 +112,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "Sparklines", "Checkbox" => "Checkbox", "Insert checkboxes in the selected cells" => "Checkboxes invoegen in de geselecteerde cellen", + "Button" => "Knop", + "Slider" => "Schuifregelaar", + "Spin Button" => "Kringveld", + "Draw a button that runs a macro or a command" => "Een knop tekenen die een macro of een opdracht uitvoert", + "Draw a slider that sets a whole number in a linked cell" => "Een schuifregelaar tekenen die een geheel getal in een gekoppelde cel instelt", + "Draw a spin button that steps a whole number in a linked cell" => { + "Een kringveld tekenen dat een geheel getal in een gekoppelde cel stapsgewijs wijzigt" + } + "Edit Text" => "Tekst bewerken", + "Assign Action…" => "Actie toewijzen…", + "Format Control…" => "Besturingselement opmaken…", "Header & Footer" => "Koptekst & voettekst", "Draw with ink" => "Tekenen met inkt", "Eraser" => "Gum", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index d7741a9c6..5954d5806 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -107,6 +107,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "Wykresy przebiegu w czasie", "Checkbox" => "Pole wyboru", "Insert checkboxes in the selected cells" => "Wstaw pola wyboru w zaznaczonych komórkach", + "Button" => "Przycisk", + "Slider" => "Suwak", + "Spin Button" => "Pokrętło", + "Draw a button that runs a macro or a command" => "Narysuj przycisk, który uruchamia makro lub polecenie", + "Draw a slider that sets a whole number in a linked cell" => "Narysuj suwak, który ustawia liczbę całkowitą w połączonej komórce", + "Draw a spin button that steps a whole number in a linked cell" => { + "Narysuj pokrętło, które zmienia o krok liczbę całkowitą w połączonej komórce" + } + "Edit Text" => "Edytuj tekst", + "Assign Action…" => "Przypisz akcję…", + "Format Control…" => "Formatuj formant…", "Header & Footer" => "Nagłówek i stopka", "Draw with ink" => "Rysuj piórem", "Eraser" => "Gumka", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index c79be64e1..5b4233669 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -110,6 +110,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "Minigráficos", "Checkbox" => "Caixa de Seleção", "Insert checkboxes in the selected cells" => "Inserir caixas de seleção nas células selecionadas", + "Button" => "Botão", + "Slider" => "Controle Deslizante", + "Spin Button" => "Botão de Rotação", + "Draw a button that runs a macro or a command" => "Desenhar um botão que executa uma macro ou um comando", + "Draw a slider that sets a whole number in a linked cell" => { + "Desenhar um controle deslizante que define um número inteiro em uma célula vinculada" + } + "Draw a spin button that steps a whole number in a linked cell" => { + "Desenhar um botão de rotação que altera um número inteiro em uma célula vinculada" + } + "Edit Text" => "Editar Texto", + "Assign Action…" => "Atribuir Ação…", + "Format Control…" => "Formatar Controle…", "Header & Footer" => "Cabeçalho e Rodapé", "Draw with ink" => "Desenhar com Tinta", "Eraser" => "Borracha", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 058911d87..8f8f5940f 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -114,6 +114,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "Diagrame sparkline", "Checkbox" => "Casetă de selectare", "Insert checkboxes in the selected cells" => "Inserați casete de selectare în celulele selectate", + "Button" => "Buton", + "Slider" => "Glisor", + "Spin Button" => "Buton rotativ", + "Draw a button that runs a macro or a command" => "Desenați un buton care rulează o macrocomandă sau o comandă", + "Draw a slider that sets a whole number in a linked cell" => "Desenați un glisor care setează un număr întreg într-o celulă legată", + "Draw a spin button that steps a whole number in a linked cell" => { + "Desenați un buton rotativ care modifică pas cu pas un număr întreg într-o celulă legată" + } + "Edit Text" => "Editare text", + "Assign Action…" => "Atribuire acțiune…", + "Format Control…" => "Formatare control…", "Header & Footer" => "Antet și subsol", "Draw with ink" => "Desenare cu cerneală", "Eraser" => "Radieră", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index b0e894095..42eef64b6 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -113,6 +113,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "Спарклайны", "Checkbox" => "Флажок", "Insert checkboxes in the selected cells" => "Вставить флажки в выбранные ячейки", + "Button" => "Кнопка", + "Slider" => "Ползунок", + "Spin Button" => "Счётчик", + "Draw a button that runs a macro or a command" => "Нарисовать кнопку, которая запускает макрос или команду", + "Draw a slider that sets a whole number in a linked cell" => "Нарисовать ползунок, который задаёт целое число в связанной ячейке", + "Draw a spin button that steps a whole number in a linked cell" => { + "Нарисовать счётчик, который изменяет целое число в связанной ячейке с шагом" + } + "Edit Text" => "Изменить текст", + "Assign Action…" => "Назначить действие…", + "Format Control…" => "Формат элемента управления…", "Header & Footer" => "Верхний и нижний колонтитулы", "Draw with ink" => "Рисование от руки", "Eraser" => "Ластик", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index c9ce49e9e..ff4aec29b 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -114,6 +114,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "Mini grafikoni", "Checkbox" => "Polje za potvrdu", "Insert checkboxes in the selected cells" => "Umetni polja za potvrdu u izabrane ćelije", + "Button" => "Dugme", + "Slider" => "Klizač", + "Spin Button" => "Dugme za okretanje", + "Draw a button that runs a macro or a command" => "Nacrtaj dugme koje pokreće makro ili komandu", + "Draw a slider that sets a whole number in a linked cell" => "Nacrtaj klizač koji postavlja ceo broj u povezanoj ćeliji", + "Draw a spin button that steps a whole number in a linked cell" => { + "Nacrtaj dugme za okretanje koje menja ceo broj u povezanoj ćeliji po koracima" + } + "Edit Text" => "Uredi tekst", + "Assign Action…" => "Dodeli radnju…", + "Format Control…" => "Oblikuj kontrolu…", "Header & Footer" => "Zaglavlje i podnožje", "Draw with ink" => "Crtaj mastilom", "Eraser" => "Gumica", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index 68cd198bb..4d3bd2df7 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -241,6 +241,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Convert to Comments" => "Açıklamalara Dönüştür", "Checkbox" => "Onay Kutusu", "Insert checkboxes in the selected cells" => "Seçili hücrelere onay kutuları ekle", + "Button" => "Düğme", + "Slider" => "Kaydırıcı", + "Spin Button" => "Döndürme Düğmesi", + "Draw a button that runs a macro or a command" => "Makro veya komut çalıştıran bir düğme çiz", + "Draw a slider that sets a whole number in a linked cell" => "Bağlı bir hücrede tamsayı ayarlayan bir kaydırıcı çiz", + "Draw a spin button that steps a whole number in a linked cell" => { + "Bağlı bir hücredeki tamsayıyı adım adım değiştiren bir döndürme düğmesi çiz" + } + "Edit Text" => "Metni Düzenle", + "Assign Action…" => "Eylem Ata…", + "Format Control…" => "Denetimi Biçimlendir…", "Sheet\nRight-to-Left" => "Sayfa\nSağdan Sola", "Sheet Right-to-Left" => "Sayfa Sağdan Sola", "Data\nAnalysis" => "Veri\nAnalizi", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 72c1bf474..1920d106d 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -109,6 +109,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "Спарклайни", "Checkbox" => "Прапорець", "Insert checkboxes in the selected cells" => "Вставити прапорці у виділені комірки", + "Button" => "Кнопка", + "Slider" => "Повзунок", + "Spin Button" => "Лічильник", + "Draw a button that runs a macro or a command" => "Намалювати кнопку, яка запускає макрос або команду", + "Draw a slider that sets a whole number in a linked cell" => "Намалювати повзунок, який задає ціле число у зв’язаній комірці", + "Draw a spin button that steps a whole number in a linked cell" => { + "Намалювати лічильник, який змінює ціле число у зв’язаній комірці з кроком" + } + "Edit Text" => "Редагувати текст", + "Assign Action…" => "Призначити дію…", + "Format Control…" => "Формат елемента керування…", "Header & Footer" => "Верхній і нижній колонтитули", "Draw with ink" => "Малювання від руки", "Eraser" => "Гумка", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index df76b4a93..0a8db64f5 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -115,6 +115,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "Biểu đồ thu nhỏ", "Checkbox" => "Hộp kiểm", "Insert checkboxes in the selected cells" => "Chèn hộp kiểm vào các ô đã chọn", + "Button" => "Nút", + "Slider" => "Thanh trượt", + "Spin Button" => "Nút xoay", + "Draw a button that runs a macro or a command" => "Vẽ một nút chạy macro hoặc lệnh", + "Draw a slider that sets a whole number in a linked cell" => "Vẽ một thanh trượt đặt số nguyên trong ô được liên kết", + "Draw a spin button that steps a whole number in a linked cell" => "Vẽ một nút xoay thay đổi từng bước số nguyên trong ô được liên kết", + "Edit Text" => "Sửa văn bản", + "Assign Action…" => "Gán hành động…", + "Format Control…" => "Định dạng điều khiển…", "Header & Footer" => "Đầu trang & Chân trang", "Draw with ink" => "Vẽ bằng mực", "Eraser" => "Tẩy", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index d6dd0eabd..831ac8401 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -113,6 +113,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Sparklines" => "迷你图", "Checkbox" => "复选框", "Insert checkboxes in the selected cells" => "在所选单元格中插入复选框", + "Button" => "按钮", + "Slider" => "滑块", + "Spin Button" => "数值调节钮", + "Draw a button that runs a macro or a command" => "绘制运行宏或命令的按钮", + "Draw a slider that sets a whole number in a linked cell" => "绘制在链接单元格中设置整数的滑块", + "Draw a spin button that steps a whole number in a linked cell" => "绘制按步长调整链接单元格中整数的数值调节钮", + "Edit Text" => "编辑文字", + "Assign Action…" => "指定操作…", + "Format Control…" => "设置控件格式…", "Header & Footer" => "页眉和页脚", "Draw with ink" => "墨迹绘制", "Eraser" => "橡皮擦", diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c1f894ea9..e7e3c948f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -85,6 +85,12 @@ pub enum Icon { Symbol, Sparkline, Checkbox, + /// A form button with a pointer. + Button, + /// A form slider (scroll bar). + Slider, + /// A form spin button. + Spin, Function, Name, Trace, @@ -485,6 +491,23 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c)); g.line(&[(4.5, 8.0), (7.0, 10.5), (11.5, 5.0)], c, 1.4); } + Icon::Button => { + let frame = Rect::from_min_max(g.pt(1.5, 3.0), g.pt(14.5, 11.0)); + g.p.rect_filled(frame, 2.0 * g.s, paper); + g.p.rect_stroke(frame, 2.0 * g.s, Stroke::new(1.0, c), egui::StrokeKind::Inside); + g.poly(&[(9.0, 7.5), (9.0, 15.0), (13.5, 12.5)], c); + } + Icon::Slider => { + g.line(&[(2.0, 8.0), (14.0, 8.0)], light, 1.6); + g.line(&[(2.0, 8.0), (7.0, 8.0)], blue, 1.6); + g.circle(7.0, 8.0, 2.4, Some(paper), Some(c)); + } + Icon::Spin => { + g.rect(4.0, 1.5, 8.0, 13.0, Some(paper), Some(c)); + g.stroke(&[(4.0, 8.0), (12.0, 8.0)], c); + g.poly(&[(8.0, 3.2), (10.5, 6.3), (5.5, 6.3)], c); + g.poly(&[(5.5, 9.7), (10.5, 9.7), (8.0, 12.8)], c); + } Icon::Fx => g.text(8.0, 8.0, "fx", 10.5, c, (false, true)), Icon::Plus => { g.line(&[(8.0, 3.0), (8.0, 13.0)], c, 1.3); diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 2a2509f08..253c8321e 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -152,6 +152,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("HW", Entry::Continue), // Excel: Wrap Text Menu; group level: containment(Wrap Text) ("HWW", Entry::Finish(Action::Command("home.wrapText"))), // Excel: Wrap Text ("HX", Entry::Finish(Action::Command("edit.cut"))), // Excel: Cut + ("NBU", Entry::Finish(Action::Command("insert.button"))), // Excel: Button (Form Control) ("NC1", Entry::Finish(Action::Control("insert.chart.column"))), // Excel: Column Chart; Column chart (kind TBD) ("NC2", Entry::Finish(Action::Command("review.newComment"))), // Excel: New Comment ("NCM", Entry::Finish(Action::Dialog("comment"))), // Excel: Excel: New Comment (modern) @@ -179,10 +180,12 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("NQ", Entry::Finish(Action::Control("insert.chart.pie"))), // Excel: Pie Chart; Pie (kind TBD) ("NR", Entry::Finish(Action::Command("insert.recommendedCharts"))), // Excel: Recommended Charts ("NSA", Entry::Finish(Action::Control("insert.chart.statistic"))), // Excel: Statistical Chart; Statistical chart (kind TBD) + ("NSB", Entry::Finish(Action::Command("insert.spinButton"))), // Excel: Spin Button (Form Control) ("NSC", Entry::Finish(Action::Pending("insert.screenshot"))), // pending; Excel: Screenshot Menu; Screenshot; reserved until the command lands ("NSD", Entry::Finish(Action::Command("insert.chart"))), // Excel: Combo Chart; Combo (kind TBD) ("NSF", Entry::Finish(Action::Command("insert.slicer"))), // Excel: Slicer; insert.slicer missing (gap #8); reserved until the command lands ("NSH", Entry::Finish(Action::Command("insert.shape"))), // Excel: Shapes + ("NSL", Entry::Finish(Action::Command("insert.slider"))), // Excel: Scroll Bar (Form Control) ("NSP", Entry::Finish(Action::Command("insert.recommendedPivotTables"))), // Excel: Recommended PivotTables ("NST", Entry::Finish(Action::Pending("insert.timeline"))), // pending; Excel: Timeline; insert.timeline missing (gap #8); reserved until the command lands ("NSZ", Entry::Finish(Action::Command("insert.pivotChart"))), // Excel: PivotChart; PivotChart; reserved until the command lands diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5dfe7b2f5..fac814b70 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -1035,6 +1035,13 @@ "excel": "Cut", "note": "" }, + { + "seq": "NBU", + "kind": "command", + "target": "insert.button", + "excel": "Button (Form Control)", + "note": "" + }, { "seq": "NC1", "kind": "control", @@ -1222,6 +1229,13 @@ "excel": "Statistical Chart", "note": "Statistical chart (kind TBD)" }, + { + "seq": "NSB", + "kind": "command", + "target": "insert.spinButton", + "excel": "Spin Button (Form Control)", + "note": "" + }, { "seq": "NSC", "kind": "command", @@ -1252,6 +1266,13 @@ "excel": "Shapes", "note": "" }, + { + "seq": "NSL", + "kind": "command", + "target": "insert.slider", + "excel": "Scroll Bar (Form Control)", + "note": "" + }, { "seq": "NSP", "kind": "command", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 76d66c4ca..502c7f8c0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ mod cell_pictures; pub mod chartview; pub mod code_editor; pub mod control; +pub mod control_ui; mod craft_fonts_policy; pub mod credits; pub mod dialogs; @@ -30,6 +31,7 @@ pub mod doc_windows; pub mod editor; mod floating_pictures; pub mod fonts; +pub mod form_controls; pub mod formula_bar; mod formula_locale; pub mod grid; diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index e6afa30bd..93b6d6372 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -116,6 +116,8 @@ pub enum OpenAction { EditText(u32), /// The Format Chart pane. FormatChart(u32), + /// A form control's editor: a button's text, else Format Control. + EditControl(u32), /// Nothing beyond selecting it. Select, } @@ -124,7 +126,8 @@ pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction { match o.kind { ObjKind::Chart => OpenAction::FormatChart(o.id), ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id), - ObjKind::Shape | ObjKind::Image | ObjKind::Slicer | ObjKind::Control => OpenAction::Select, + ObjKind::Control => OpenAction::EditControl(o.id), + ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select, } } @@ -138,6 +141,7 @@ impl SheetApp { self.begin_text_box_edit(id); } OpenAction::FormatChart(_) => self.grid.pane = Some("formatChart".into()), + OpenAction::EditControl(id) => crate::control_ui::edit(self, id), OpenAction::Select => {} } action diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d2adc491e..2d5f42c5a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -8,7 +8,9 @@ use crate::SheetApp; use crate::icons::{self, Icon}; use crate::keytips::keytip_table; use crate::theme::{self, Cat, Tokens}; -use crate::widgets::{big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button}; +use crate::widgets::{ + big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, small_toggle_button, split_button, toggle_button, +}; mod automate; mod contextual; diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index 63f61f755..38a3cfeef 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -1,7 +1,7 @@ use super::controls::{act, anchor_keytip, control_keytip, invoke, popup_command, sep}; -use super::{Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, icons, json, small_button, vec2}; +use super::{Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, icons, json, small_button, small_toggle_button, vec2}; -/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, checkbox, links, comments, text box, header/footer and symbols. +/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, form controls (button, slider, spin button), checkbox, links, comments, text box, header/footer and symbols. pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { let __k = big_button(ui, Icon::PivotTable, "PivotTable", "PivotTable", false); control_keytip(app, ui, &__k, "insert.pivotTable"); @@ -165,6 +165,26 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { } }); sep(ui); + // Form controls: draw a button, slider or spin button on the sheet (a click places it at its + // default size). Their key tips run the command at the active cell. + use gridcraft_engine::model::ControlKind; + let placing = app.grid.placing; + ui.vertical(|ui| { + for (icon, kind, label, tip, id) in [ + (Icon::Button, ControlKind::Button, "Button", "Draw a button that runs a macro or a command", "insert.button"), + (Icon::Slider, ControlKind::Slider, "Slider", "Draw a slider that sets a whole number in a linked cell", "insert.slider"), + (Icon::Spin, ControlKind::Spin, "Spin Button", "Draw a spin button that steps a whole number in a linked cell", "insert.spinButton"), + ] { + let on = placing == Some(kind); + let r = small_toggle_button(ui, icon, label, tip, on); + control_keytip(app, ui, &r, id); + if r.clicked() { + app.grid.placing = if placing == Some(kind) { None } else { Some(kind) }; + app.selected_object = None; + } + } + }); + sep(ui); // Cell Controls. if big_button(ui, Icon::Checkbox, "Checkbox", "Insert checkboxes in the selected cells", false).clicked() { act(app, "insert.checkbox", json!({})); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f37eb0c35..a0b267c13 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -102,8 +102,17 @@ pub struct Tokens { pub sheet_control_bg: Color32, pub sheet_control_border: Color32, pub sheet_control_text: Color32, - /// A placeholder fill on the sheet (a picture that has not loaded). + /// A placeholder fill on the sheet (a picture that has not loaded), and a form slider's track. pub sheet_track: Color32, + /// A form slider's thumb. + pub sheet_thumb: Color32, + /// A form button or spin arrow under the pointer, and pressed. + pub sheet_control_hover: Color32, + pub sheet_control_pressed: Color32, + /// The filled part of a form slider's track (from the minimum to the thumb). + pub sheet_fill: Color32, + /// The ring around the form control that takes the arrow keys. + pub sheet_focus: Color32, /// The macro recording dot (Record Macro, the status bar). pub recording: Color32, /// A breakpoint dot in the macro step editor's gutter. @@ -165,6 +174,11 @@ const SHEET_CONTROL_BG: Color32 = rgb(0xF3F3F3); const SHEET_CONTROL_BORDER: Color32 = rgb(0x8A8A8A); const SHEET_CONTROL_TEXT: Color32 = rgb(0x1F1F1F); const SHEET_TRACK: Color32 = rgb(0xD6D6D6); +const SHEET_THUMB: Color32 = rgb(0x6B6B6B); +const SHEET_CONTROL_HOVER: Color32 = rgb(0xE4E4E4); +const SHEET_CONTROL_PRESSED: Color32 = rgb(0xCFCFCF); +const SHEET_FILL: Color32 = rgb(0x107C41); +const SHEET_FOCUS: Color32 = rgb(0x107C41); impl Tokens { pub fn light() -> Tokens { @@ -238,6 +252,11 @@ impl Tokens { sheet_control_border: SHEET_CONTROL_BORDER, sheet_control_text: SHEET_CONTROL_TEXT, sheet_track: SHEET_TRACK, + sheet_thumb: SHEET_THUMB, + sheet_control_hover: SHEET_CONTROL_HOVER, + sheet_control_pressed: SHEET_CONTROL_PRESSED, + sheet_fill: SHEET_FILL, + sheet_focus: SHEET_FOCUS, recording: pick(dark, rgb(0xC42B1C), rgb(0xF1705F)), breakpoint: pick(dark, rgb(0xD13438), rgb(0xF1707A)), sheet_macro_input: SHEET_MACRO_INPUT, @@ -385,6 +404,12 @@ mod contrast_tests { add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH); add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT); add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH); + add("slider thumb on track", t.sheet_thumb, t.sheet_track, GLYPH); + add("sheet control text on hover", t.sheet_control_text, t.sheet_control_hover, TEXT); + add("sheet control text when pressed", t.sheet_control_text, t.sheet_control_pressed, TEXT); + add("slider fill on sheet", t.sheet_fill, t.grid_bg, GLYPH); + add("slider fill on track", t.sheet_fill, t.sheet_track, GLYPH); + add("control focus ring on sheet", t.sheet_focus, t.grid_bg, GLYPH); add("recording dot on ribbon", t.recording, t.ribbon, GLYPH); add("recording dot on status bar", t.recording, t.status_bar, GLYPH); for (name, c) in [ @@ -435,6 +460,10 @@ mod contrast_tests { (l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track), (d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track) ); + assert_eq!( + (l.sheet_thumb, l.sheet_control_hover, l.sheet_control_pressed, l.sheet_fill, l.sheet_focus), + (d.sheet_thumb, d.sheet_control_hover, d.sheet_control_pressed, d.sheet_fill, d.sheet_focus) + ); assert_eq!( (l.sheet_macro_input, l.sheet_macro_footprint, l.sheet_macro_conflict, l.sheet_macro_target), (d.sheet_macro_input, d.sheet_macro_footprint, d.sheet_macro_conflict, d.sheet_macro_target) diff --git a/crates/ui-egui/tests/it/form_controls.rs b/crates/ui-egui/tests/it/form_controls.rs new file mode 100644 index 000000000..79823f62c --- /dev/null +++ b/crates/ui-egui/tests/it/form_controls.rs @@ -0,0 +1,421 @@ +//! Form controls through real pointer and keyboard input: dragging a slider (one undo step), +//! paging on the track, clicking and holding spin arrows, keyboard steps, a button running its +//! macro, Cmd+click or right-click selecting a control without running it, right-to-left +//! sheets, and the Insert › Controls buttons, context menu and dialogs. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_engine::model::{FormControl, ObjKind, ObjRef}; +use gridcraft_ui_egui::form_controls::{Part, part_at, slider_parts}; +use gridcraft_ui_egui::{SheetApp, chartview, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +struct Ids { + slider: u32, + spin: u32, + button: u32, +} + +fn fixture() -> (Harness, Ids) { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "B1", "input": "20"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=B1*10"})).unwrap(); + let id = |v: serde_json::Value| v["control"].as_u64().unwrap() as u32; + let slider = + id(s.execute("insert.slider", json!({"at": "E3", "width": 200, "height": 20, "link": "B1", "max": 100, "step": 1, "page": 10})).unwrap()); + let spin = + id(s.execute("insert.spinButton", json!({"at": "E8", "width": 20, "height": 40, "link": "B2", "max": 50, "step": 2, "value": 10})).unwrap()); + s.execute( + "macro.save", + json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "H1", "input": "pressed"}}], "output": {"onConflict": "overwrite"}}), + ) + .unwrap(); + let button = id(s.execute("insert.button", json!({"at": "E12", "text": "Mark it", "macro": "Mark"})).unwrap()); + s.execute("selection.set", json!({"cell": "A20"})).unwrap(); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(1400.0, 900.0)) + .with_step_dt(1.0 / 60.0) + .with_os(egui::os::OperatingSystem::Mac) + .build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + (h, Ids { slider, spin, button }) +} + +fn control(h: &Harness, id: u32) -> FormControl { + h.state().session.doc().unwrap().wb.active().unwrap().controls.iter().find(|c| c.id == id).cloned().unwrap() +} + +fn rect(h: &Harness, id: u32) -> Rect { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + chartview::object_rect(&Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll), sh, ObjRef::new(ObjKind::Control, id)).unwrap() +} + +fn cell(h: &Harness, a: &str) -> Value { + h.state().session.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) +} + +fn undo_len(h: &Harness) -> usize { + h.state().session.doc().unwrap().undo.len() +} + +fn button(h: &mut Harness, p: Pos2, pressed: bool, which: PointerButton, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: which, pressed, modifiers }); + h.step(); +} + +fn click_with(h: &mut Harness, p: Pos2, modifiers: Modifiers) { + h.run_steps(30); + button(h, p, true, PointerButton::Primary, modifiers); + button(h, p, false, PointerButton::Primary, modifiers); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +fn click(h: &mut Harness, p: Pos2) { + click_with(h, p, Modifiers::NONE); +} + +fn key(h: &mut Harness, key: Key) { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn thumb(h: &Harness, id: u32) -> Rect { + let c = control(h, id); + slider_parts(rect(h, id), &c, 1.0).1 +} + +#[test] +fn dragging_the_slider_is_one_undo_step() { + let (mut h, ids) = fixture(); + let start = thumb(&h, ids.slider).center(); + assert_eq!(control(&h, ids.slider).value, 20); + let before = undo_len(&h); + let r = rect(&h, ids.slider); + let (track, _) = slider_parts(r, &control(&h, ids.slider), 1.0); + h.run_steps(30); + button(&mut h, start, true, PointerButton::Primary, Modifiers::NONE); + for k in 1..=6 { + let x = start.x + (track.left() + track.width() * 0.7 - start.x) * k as f32 / 6.0; + h.input_mut().events.push(Event::PointerMoved(Pos2::new(x, start.y + 3.0))); + h.step(); + } + let mid = control(&h, ids.slider).value; + assert!((68..=72).contains(&mid), "the thumb follows the pointer: {mid}"); + assert_eq!(cell(&h, "B1"), Value::Number(mid as f64), "the linked cell follows while dragging"); + assert_eq!(cell(&h, "C1"), Value::Number(mid as f64 * 10.0), "and its dependents"); + button(&mut h, Pos2::new(track.left() + track.width() * 0.7, start.y), false, PointerButton::Primary, Modifiers::NONE); + h.run_steps(2); + assert_eq!(undo_len(&h), before + 1, "the whole drag is one undo step"); + assert_eq!(h.state().selected_object, None, "working a control doesn't select it"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(cell(&h, "B1"), Value::Number(20.0)); + assert_eq!(control(&h, ids.slider).value, 20); +} + +#[test] +fn the_track_pages_and_editing_the_cell_moves_the_thumb() { + let (mut h, ids) = fixture(); + let r = rect(&h, ids.slider); + let after = Pos2::new(r.right() - 4.0, r.center().y); + assert_eq!(part_at(r, &control(&h, ids.slider), 1.0, after), Some(Part::TrackAfter)); + click(&mut h, after); + assert_eq!(cell(&h, "B1"), Value::Number(30.0), "a click on the track moves a page"); + click(&mut h, Pos2::new(r.left() + 3.0, r.center().y)); + assert_eq!(cell(&h, "B1"), Value::Number(20.0)); + let was = thumb(&h, ids.slider).center().x; + h.state_mut().run("cell.set", json!({"cell": "B1", "input": "90"})).unwrap(); + h.run_steps(2); + assert_eq!(control(&h, ids.slider).value, 90); + assert!(thumb(&h, ids.slider).center().x > was + 100.0, "editing the linked cell moves the thumb"); +} + +#[test] +fn spin_arrows_step_and_repeat_while_held() { + let (mut h, ids) = fixture(); + let r = rect(&h, ids.spin); + let up = Pos2::new(r.center().x, r.top() + 5.0); + let down = Pos2::new(r.center().x, r.bottom() - 5.0); + click(&mut h, up); + assert_eq!(cell(&h, "B2"), Value::Number(12.0)); + click(&mut h, down); + click(&mut h, down); + assert_eq!(cell(&h, "B2"), Value::Number(8.0)); + // Held: after a pause it repeats, and the whole hold is one undo step. + let before = undo_len(&h); + h.run_steps(30); + button(&mut h, up, true, PointerButton::Primary, Modifiers::NONE); + h.run_steps(60); + button(&mut h, up, false, PointerButton::Primary, Modifiers::NONE); + let v = control(&h, ids.spin).value; + assert!(v >= 14, "holding the arrow repeats: {v}"); + assert_eq!(undo_len(&h), before + 1); + // It stops at the maximum. + h.run_steps(30); + button(&mut h, up, true, PointerButton::Primary, Modifiers::NONE); + h.run_steps(400); + button(&mut h, up, false, PointerButton::Primary, Modifiers::NONE); + assert_eq!(control(&h, ids.spin).value, 50); +} + +#[test] +fn the_keyboard_steps_the_control_last_worked() { + let (mut h, ids) = fixture(); + let r = rect(&h, ids.spin); + click(&mut h, Pos2::new(r.center().x, r.top() + 5.0)); + assert_eq!(h.state().grid.focused_control, Some(ids.spin)); + key(&mut h, Key::ArrowUp); + key(&mut h, Key::ArrowUp); + assert_eq!(control(&h, ids.spin).value, 16); + key(&mut h, Key::ArrowDown); + assert_eq!(control(&h, ids.spin).value, 14); + key(&mut h, Key::End); + assert_eq!(control(&h, ids.spin).value, 50); + let tr = rect(&h, ids.slider); + click(&mut h, Pos2::new(tr.right() - 4.0, tr.center().y)); + assert_eq!(h.state().grid.focused_control, Some(ids.slider)); + key(&mut h, Key::ArrowRight); + assert_eq!(control(&h, ids.slider).value, 31); + key(&mut h, Key::PageDown); + assert_eq!(control(&h, ids.slider).value, 41); + key(&mut h, Key::PageUp); + key(&mut h, Key::ArrowLeft); + assert_eq!(control(&h, ids.slider).value, 30); + key(&mut h, Key::Home); + assert_eq!(cell(&h, "B1"), Value::Number(0.0)); + // Escape lets go; the arrows move the cell cursor again. + key(&mut h, Key::Escape); + assert_eq!(h.state().grid.focused_control, None); + key(&mut h, Key::ArrowDown); + assert_eq!(control(&h, ids.slider).value, 0); + assert_eq!(h.state().session.doc().unwrap().selection.active.a1(), "A21"); +} + +#[test] +fn a_button_runs_its_macro_and_cmd_or_right_click_only_selects() { + let (mut h, ids) = fixture(); + let r = rect(&h, ids.button); + click_with(&mut h, r.center(), Modifiers::COMMAND); + assert_eq!(h.state().selected_object, Some(ObjRef::new(ObjKind::Control, ids.button))); + assert_eq!(cell(&h, "H1"), Value::Empty, "Cmd+click selects without running"); + // A click on a selected control moves it rather than running it. + click(&mut h, r.center()); + assert_eq!(cell(&h, "H1"), Value::Empty); + // Click elsewhere, then right-click: selects, doesn't run. + click(&mut h, Pos2::new(r.right() + 300.0, r.bottom() + 100.0)); + assert_eq!(h.state().selected_object, None); + h.run_steps(30); + button(&mut h, r.center(), true, PointerButton::Secondary, Modifiers::NONE); + button(&mut h, r.center(), false, PointerButton::Secondary, Modifiers::NONE); + h.run_steps(2); + assert_eq!(h.state().selected_object, Some(ObjRef::new(ObjKind::Control, ids.button))); + assert_eq!(cell(&h, "H1"), Value::Empty); + h.state_mut().selected_object = None; + // A plain click runs the macro, as one undo step. + let before = undo_len(&h); + click(&mut h, r.center()); + assert_eq!(cell(&h, "H1"), Value::text("pressed")); + assert_eq!(undo_len(&h), before + 1); + assert!(h.state().message.is_none(), "{:?}", h.state().message); + // Pressing and sliding off before letting go doesn't run it. + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(30); + button(&mut h, r.center(), true, PointerButton::Primary, Modifiers::NONE); + button(&mut h, r.right_bottom() + vec2(80.0, 80.0), false, PointerButton::Primary, Modifiers::NONE); + h.run_steps(2); + assert_eq!(cell(&h, "H1"), Value::Empty); +} + +#[test] +fn a_button_from_a_file_asks_before_it_runs() { + use egui_kittest::kittest::Queryable as _; + let (mut h, ids) = fixture(); + h.state_mut().session.doc_mut().unwrap().trust = gridcraft_engine::Trust::FromFile; + let r = rect(&h, ids.button); + click(&mut h, r.center()); + assert_eq!(cell(&h, "H1"), Value::Empty, "nothing runs before the person says so"); + let d = h.state().dialog.clone().expect("a prompt"); + assert_eq!(d.name(), "controlQuestion"); + let text = d.values["text"].as_str().unwrap_or("").to_string(); + assert!(text.contains("came with a workbook you opened"), "{text}"); + h.get_by_label(" Run ").click(); + h.run_steps(2); + assert_eq!(cell(&h, "H1"), Value::text("pressed"), "Run presses it again with the approval"); + assert!(h.state().dialog.is_none()); +} + +#[test] +fn on_a_right_to_left_sheet_a_control_works_where_it_is_drawn() { + let (mut h, ids) = fixture(); + h.state_mut().run("view.rightToLeft", json!({"on": true})).unwrap(); + h.run_steps(3); + let screen = |h: &Harness, id: u32| { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + geo.mirror_rect(chartview::object_rect(&geo, sh, ObjRef::new(ObjKind::Control, id)).unwrap()) + }; + // The slider moves as a whole and keeps its minimum on the left. + let r = screen(&h, ids.slider); + click(&mut h, Pos2::new(r.right() - 4.0, r.center().y)); + assert_eq!(cell(&h, "B1"), Value::Number(30.0), "the right end of the track pages up"); + let r = screen(&h, ids.spin); + click(&mut h, Pos2::new(r.center().x, r.top() + 5.0)); + assert_eq!(cell(&h, "B2"), Value::Number(12.0), "the upper arrow adds a step"); +} + +// ---------------------------------------------------------------- ribbon, menus and dialogs + +fn insert_tab() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + let mut app = SheetApp::new(s, Default::default()); + app.ui.ribbon_tab = "Insert".into(); + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(1400.0, 900.0)) + .with_step_dt(1.0 / 60.0) + .with_os(egui::os::OperatingSystem::Mac) + .build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn controls(h: &Harness) -> Vec { + h.state().session.doc().unwrap().wb.active().unwrap().controls.clone() +} + +fn cells_rect(h: &Harness) -> Rect { + h.state().grid.cells_rect.unwrap() +} + +#[test] +fn the_controls_group_places_by_click_or_drag() { + use egui_kittest::kittest::Queryable as _; + let mut h = insert_tab(); + // A click places a slider at its default size. + h.get_by_label("Slider").click(); + h.run_steps(2); + assert_eq!(h.state().grid.placing, Some(gridcraft_engine::model::ControlKind::Slider)); + let at = cells_rect(&h).min + vec2(300.0, 120.0); + click(&mut h, at); + let list = controls(&h); + assert_eq!(list.len(), 1); + assert_eq!((list[0].anchor.width, list[0].anchor.height, list[0].horizontal), (144.0, 18.0, true)); + assert_eq!(h.state().grid.placing, None, "placing is one-shot"); + assert_eq!(h.state().selected_object, Some(ObjRef::new(ObjKind::Control, list[0].id)), "the new control is selected"); + // A tall drag draws a vertical slider of that size. + h.get_by_label("Slider").click(); + h.run_steps(2); + let from = cells_rect(&h).min + vec2(600.0, 100.0); + h.run_steps(30); + button(&mut h, from, true, PointerButton::Primary, Modifiers::NONE); + h.input_mut().events.push(Event::PointerMoved(from + vec2(10.0, 60.0))); + h.step(); + button(&mut h, from + vec2(20.0, 150.0), false, PointerButton::Primary, Modifiers::NONE); + h.run_steps(2); + let s = controls(&h).pop().unwrap(); + // The drawn size is in sheet points (screen pixels over the zoom). + let drawn = rect(&h, s.id); + assert!(!s.horizontal && (drawn.width() - 20.0).abs() < 1.5 && (drawn.height() - 150.0).abs() < 1.5, "{drawn:?}"); + // Escape disarms. + h.get_by_label("Spin Button").click(); + h.run_steps(2); + key(&mut h, Key::Escape); + assert_eq!(h.state().grid.placing, None); + // A button asks what to run. + h.get_by_label("Button").click(); + h.run_steps(2); + let spot = cells_rect(&h).min + vec2(100.0, 300.0); + click(&mut h, spot); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("assignAction")); +} + +#[test] +fn assign_action_and_format_control_dialogs() { + use egui_kittest::kittest::Queryable as _; + let (mut h, ids) = fixture(); + // Right-click opens the control's menu; Assign Action picks a command. + let r = rect(&h, ids.button); + h.run_steps(30); + button(&mut h, r.center(), true, PointerButton::Secondary, Modifiers::NONE); + button(&mut h, r.center(), false, PointerButton::Secondary, Modifiers::NONE); + h.run_steps(2); + assert_eq!(h.state().grid.object_menu.map(|(_, kind, id)| (kind, id)), Some(("control", ids.button))); + // The ribbon has a Delete button of its own. + for item in ["Edit Text", "Assign Action…", "Format Control…", "Delete"] { + assert!(h.query_all_by_label(item).next().is_some(), "{item}"); + } + h.get_by_label("Assign Action…").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("assignAction")); + // The dialog settles its size (and so its place) over the first frames. + h.run_steps(30); + let radio = h.get_by_label("Run a command").rect(); + click(&mut h, radio.center()); + assert_eq!(h.state().dialog.as_ref().map(|d| d.values["mode"].clone()), Some(json!("command"))); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + d.values.insert("command".into(), json!("cell.set")); + d.values.insert("params".into(), json!("{\"cell\": \"H2\", \"input\": \"cmd\"}")); + } + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "{:?}", h.state().message); + assert!(matches!(control(&h, ids.button).action, Some(gridcraft_engine::model::ControlAction::Command { ref id, .. }) if id == "cell.set")); + h.state_mut().selected_object = None; + click(&mut h, r.center()); + assert_eq!(cell(&h, "H2"), Value::text("cmd"), "{:?}", h.state().message); + + // Format Control: refuses broken limits, then applies good ones and writes the link. + h.state_mut().open_dialog("formatControl", json!({"id": ids.spin})); + h.run_steps(2); + assert!(h.query_by_label("Incremental change:").is_some()); + assert!(h.query_by_label("Page change:").is_none(), "a spin button has no page change"); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + d.values.insert("max".into(), json!("0")); + } + h.run_steps(2); + assert!(h.query_by_label_contains("must be greater than Minimum").is_some()); + { + let d = h.state_mut().dialog.as_mut().unwrap(); + d.values.insert("max".into(), json!("40")); + d.values.insert("value".into(), json!("30")); + d.values.insert("link".into(), json!("$D$1")); + } + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(2); + let c = control(&h, ids.spin); + assert_eq!((c.max, c.value, c.link.as_deref()), (40, 30, Some("$D$1"))); + assert_eq!(cell(&h, "D1"), Value::Number(30.0)); + + // Double-clicking a selected button edits its text. + h.state_mut().selected_object = Some(ObjRef::new(ObjKind::Control, ids.button)); + h.state_mut().open_object(ObjRef::new(ObjKind::Control, ids.button)); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.title.as_str()), Some("Edit Text")); +} diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 810338a48..09a04d55c 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -1095,6 +1095,13 @@ fn keytip_hx_() { assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.cut"), "HX must journal edit.cut"); } +#[test] +fn keytip_nbu_() { + let mut h = harness(blank()); + sequence(&mut h, "NBU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.button"), "NBU must journal insert.button"); +} + #[test] fn keytip_nc1_() { let mut h = harness(blank()); @@ -1302,6 +1309,13 @@ fn keytip_nsa_() { assert_eq!(h.state().keytips.prefix(), None); } +#[test] +fn keytip_nsb_() { + let mut h = harness(blank()); + sequence(&mut h, "NSB", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.spinButton"), "NSB must journal insert.spinButton"); +} + #[test] fn keytip_nsc_() { let mut h = harness(blank()); @@ -1333,6 +1347,13 @@ fn keytip_nsh_() { assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.shape"), "NSH must journal insert.shape"); } +#[test] +fn keytip_nsl_() { + let mut h = harness(blank()); + sequence(&mut h, "NSL", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.slider"), "NSL must journal insert.slider"); +} + #[test] fn keytip_nsp_() { let mut h = harness(blank()); @@ -2754,7 +2775,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 365; + const EXPECTED: usize = 368; assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index ca72568da..a96b02a20 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -39,6 +39,7 @@ mod find_dialog_focus; mod floating_pictures; mod font_box_filter; mod font_size_box; +mod form_controls; mod format_chart_type; mod format_painter; mod formula_bar; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bbe00c51f..1ca0a3e9d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -92,8 +92,10 @@ include `window` or `doc` to run it in that window, as with `engine.execute`; th them as their own parameters. `ui.inspect` reports the selected floating object as `selectedObject: {kind, id}` (or `null`), -where `kind` is `chart`, `image`, `shape` or `slicer`; the same names go in the `kind` parameter -of `object.delete`, `object.move`, `object.setAnchorMode` and the `arrange.*` commands. +where `kind` is `chart`, `image`, `shape`, `slicer` or `control` (a form button, slider or spin +button); the same names go in the `kind` parameter of `object.delete`, `object.move`, +`object.setAnchorMode` and the `arrange.*` commands. Form controls are worked with +`control.setValue` and `control.press`, and listed by `control.list`. `selectedChart` remains for existing clients: the id when the selection is a chart, otherwise `null`. With an object selected, `ui.key {key: "Delete"}` (or Backspace) deletes the object instead of clearing cells, and Escape clears the selection. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 71d75f659..f02cb8490 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 276 / 292 commands implemented (94.5%).** +**Overall: 280 / 296 commands implemented (94.6%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -18,6 +18,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Review | 15 | 18 | 83% | | View | 21 | 21 | 100% | | Automate | 4 | 4 | 100% | +| Developer | 4 | 4 | 100% | | Table Design | 13 | 13 | 100% | | Chart Design | 8 | 8 | 100% | | Format | 3 | 3 | 100% | @@ -334,6 +335,15 @@ Implemented: - `automate.recordActions` Record Actions (Scripting Tools) - `automate.trustScripts` Trust Scripts (Scripting Tools) +## Developer (4 / 4, 100%) + +Implemented: + +- `insert.button` Button (Form Control) (Controls) +- `insert.spinButton` Spin Button (Form Control) (Controls) +- `insert.slider` Scroll Bar (Form Control) (Controls) +- `control.format` Properties (Controls) + ## Table Design (13 / 13, 100%) Implemented: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b5a63ea36..7b8791f17 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (chart resizing no longer listed as missing; earlier: automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (form controls; catalog 280 / 296; earlier: chart resizing no longer listed as missing; earlier: automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| | Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements | -| Ribbon and menu catalog | **276 / 292 (94.5%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | +| Ribbon and menu catalog | **280 / 296 (94.6%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) | | Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) | | Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) | | **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) | @@ -54,11 +54,12 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with COPILOT and PY: **523 functions**. Ours: every `f!(…)` spec in `crates/functions/src/*.rs` plus `SPECIAL_FUNCTIONS` in `crates/calc/src/eval.rs` (evaluator special forms), minus the test-only `ADD2`: **505**, all of them Excel names. -- **Ribbon catalog:** `crates/engine/src/catalog.rs` (292 unique ids across 15 tabs) against the - 313 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and +- **Ribbon catalog:** `crates/engine/src/catalog.rs` (296 unique ids across 16 tabs) against the + 321 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and identical to the committed `parity-checklist.md`. The catalog is our own transcription of Excel's main ribbon tabs; it leaves out most contextual tabs (Picture Format, Shape Format, - Header & Footer, Sparkline, Slicer, Timeline), the Developer and Help tabs and sub-menu items, so + Header & Footer, Sparkline, Slicer, Timeline), most of the Developer tab (its form controls are + in), the Help tab and sub-menu items, so 89% of it is roughly **60–65% of Excel's full command surface** (estimated). - **Depth and readiness:** source reading (the model types in `crates/model/src/features.rs`, readers and writers in `crates/xlsx/src`, the evaluator), 478 `#[test]` functions, and the @@ -91,9 +92,9 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; no Sensitivity report or options dialog). Missing: Forecast Sheet, Analyze Data | | Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | -| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | +| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | -| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility | +| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog, a Ctrl+Shift shortcut or a form button (which can also run one command). VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility | | **Weighted** | 100 | **~75%** | **~59%** | **300–485** | | ## Dimensions diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a7c1345d5..9b19ddbb5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,10 +1,10 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (form controls: buttons, sliders and spin buttons on the sheet; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (276 / 292). +separately in [parity-checklist.md](parity-checklist.md) (280 / 296). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes @@ -68,6 +68,7 @@ this logic, with one undo step. | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing | +| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file; the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter; Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | | Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) | From c7a11e9860d861f7d829f6dff717ed7c5c3b67fd Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:58:01 -0400 Subject: [PATCH 12/55] Form controls: a button's file command names its file in the approval question Pressing a button whose command reads or writes a file asked "Run file.exportCsv from 'Button 1'? It writes a file." without saying which file, so the person could not tell what they were approving. The question now ends with the command's `path` in parentheses; control characters show as spaces and a path longer than 200 characters is shortened in the middle. --- crates/engine/src/cmd/controls.rs | 23 ++++++++++++++++++++++- crates/engine/src/tests/controls.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 94a4ef7ec..4a9ade919 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -484,7 +484,7 @@ fn press(s: &mut Session, p: &Json) -> Result { let key = (doc, approval_key(&id, ¶ms)); if !s.macro_approvals.contains(&key) { if bool_param(p, "approve") != Some(true) { - return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}."))); + return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}{}.", path_note(¶ms)))); } s.macro_approvals.insert(key); } @@ -497,6 +497,27 @@ fn press(s: &mut Session, p: &Json) -> Result { } } +/// The file a button's command names in its `path`, as ` (path)` for its approval question, so +/// the person sees what it reads or writes before saying yes. Empty without a `path`; a long one +/// is shortened in the middle. +fn path_note(params: &Json) -> String { + let text = match params.get("path") { + None | Some(Json::Null) => return String::new(), + Some(Json::String(t)) => t.clone(), + Some(other) => other.to_string(), + }; + let text: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); + let n = text.chars().count(); + let shown = if n > 200 { + let head: String = text.chars().take(100).collect(); + let tail: String = text.chars().skip(n - 99).collect(); + format!("{head}…{tail}") + } else { + text + }; + format!(" ({shown})") +} + /// The approval key of a button's command: its id and parameters, with a prefix that keeps it /// apart from a macro's fingerprint (canonical JSON, which starts with `{`). fn approval_key(id: &str, params: &Json) -> String { diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs index 4ea2461a0..61de5caa2 100644 --- a/crates/engine/src/tests/controls.rs +++ b/crates/engine/src/tests/controls.rs @@ -246,6 +246,35 @@ fn a_command_button_goes_through_the_policy_with_the_documents_trust() { assert_eq!(v(&s, "B1"), Value::Number(1.0)); } +#[test] +fn a_file_commands_approval_question_names_the_file() { + let mut s = s(); + let b = id(&s.execute("insert.button", json!({"command": "file.exportCsv", "params": {"path": "/tmp/reports/q3 totals.csv"}})).unwrap()); + match s.execute("control.press", json!({"id": b})) { + Err(EngineError::Approve(q)) => assert!(q.contains("writes a file (/tmp/reports/q3 totals.csv)"), "{q}"), + other => panic!("expected a question, got {other:?}"), + } + // A long path is shortened in the middle, and line breaks can't hide part of it. + let long = format!("/tmp/{}\n{}.csv", "a".repeat(300), "b".repeat(300)); + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + if let Some(c) = sh.controls.iter_mut().find(|c| c.id as u64 == b) { + c.action = Some(ControlAction::Command { id: "file.exportCsv".into(), params: json!({"path": long}) }); + } + match s.execute("control.press", json!({"id": b})) { + Err(EngineError::Approve(q)) => { + assert!(q.contains("(/tmp/aaa") && q.contains("…") && q.contains("bbb.csv)") && !q.contains('\n') && q.len() < 600, "{q}") + } + other => panic!("expected a question, got {other:?}"), + } + // Without a path the question is unchanged. + let plain = id(&s.execute("insert.button", json!({"command": "cell.set", "params": {"cell": "A1", "input": "1"}})).unwrap()); + s.doc_mut().unwrap().trust = Trust::FromFile; + match s.execute("control.press", json!({"id": plain})) { + Err(EngineError::Approve(q)) => assert!(q.ends_with("came with a workbook you opened."), "{q}"), + other => panic!("expected a question, got {other:?}"), + } +} + #[test] fn hostile_params_are_refused() { let mut s = s(); From 09a37a8f10a4c33a9407448dbdeb3661f61a5b59 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:59:59 -0400 Subject: [PATCH 13/55] Form controls: a kept button action comes back only on the same button The button actions xlsx has no place for (commands, fixed inputs, output cells) are kept in a hidden name keyed by sheet and the control's place on the sheet. When another application added, removed or reordered controls before saving, opening the file gave the action to whichever button now had that place, and could replace a macro the file itself gave that button. Each entry now also holds the button's name and anchor cell, and is restored only on a button with that name and cell whose own action from the file does not contradict it: a stored macro action only on a button that still runs that macro, a stored command only on a button the file gives no macro. --- crates/engine/src/cmd/controls.rs | 29 ++++++++++++++---- crates/engine/src/tests/controls.rs | 46 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 4a9ade919..d2cf17f01 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -563,14 +563,15 @@ fn plain(a: &ControlAction) -> bool { } /// A copy of `wb` for xlsx with the button actions xlsx can't express (commands, fixed inputs, -/// output cells) in a hidden name, keyed by sheet name and the control's place on the sheet. -/// `None` when there is nothing to store. +/// output cells) in a hidden name, keyed by sheet name and the control's place on the sheet, with +/// the button's name and anchor cell to check it is still the same button when the file comes +/// back. `None` when there is nothing to store. pub(crate) fn with_action_names(wb: &Workbook) -> Result> { let mut items = Vec::new(); for sh in &wb.sheets { for (i, c) in sh.controls.iter().enumerate() { if let Some(a) = c.action.as_ref().filter(|a| !plain(a)) { - items.push(json!({"sheet": sh.name, "index": i, "action": a})); + items.push(json!({"sheet": sh.name, "index": i, "name": c.display_name(), "at": c.anchor.cell.a1(), "action": a})); } } } @@ -587,7 +588,11 @@ pub(crate) fn with_action_names(wb: &Workbook) -> Result> { } /// After a file is opened: gives buttons back the actions kept in the hidden name, and removes -/// it. Entries that don't match a button, or don't parse, are dropped. +/// it. Entries that don't parse, or don't match a button, are dropped: the button at the stored +/// place must have the stored name and anchor cell (another application may have added, removed +/// or reordered controls), and must not have been given another macro since (a stored macro +/// action comes back only on a button that still runs that macro, a stored command only on one +/// the file gives no macro). pub(crate) fn load_action_names(wb: &mut Workbook) { let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return }; stash::remove(wb, ACTIONS_KEY); @@ -599,10 +604,22 @@ pub(crate) fn load_action_names(wb: &mut Workbook) { ControlAction::Command { id, params } => !id.is_empty() && id.len() <= 200 && params.is_object(), }; let Some(si) = wb.sheet_index(sheet) else { continue }; - if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index as usize).is_some_and(|c| c.kind == ControlKind::Button)) { + let Ok(index) = usize::try_from(index) else { continue }; + let text = |k: &str| item.get(k).and_then(Json::as_str); + let same = |c: &FormControl| { + c.kind == ControlKind::Button + && text("name").is_none_or(|n| n == c.display_name()) + && text("at").is_none_or(|a| a == c.anchor.cell.a1()) + && match (&c.action, &action) { + (None, _) => true, + (Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept), + _ => false, + } + }; + if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index).is_some_and(same)) { continue; } - if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index as usize)) { + if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index)) { c.action = Some(action); } } diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs index 61de5caa2..98b2404ae 100644 --- a/crates/engine/src/tests/controls.rs +++ b/crates/engine/src/tests/controls.rs @@ -351,6 +351,52 @@ fn reopen(s: &mut Session) { s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap(); } +#[test] +fn a_kept_button_action_comes_back_only_on_the_same_button() { + use crate::cmd::controls::{load_action_names, with_action_names}; + let mut s = s(); + s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap(); + s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap(); + s.execute("insert.button", json!({"at": "D6", "text": "Other"})).unwrap(); + s.execute("insert.button", json!({"at": "D9", "text": "Fixed", "macro": "Mark", "input": "F1:G2"})).unwrap(); + let wb = (*s.doc().unwrap().wb).clone(); + let stored = with_action_names(&wb).unwrap().unwrap(); + let actions = |wb: &gridcraft_model::Workbook| wb.sheet(0).unwrap().controls.iter().map(|c| c.action.clone()).collect::>(); + let kept = |wb: &gridcraft_model::Workbook| { + let mut wb = wb.clone(); + for c in &mut wb.sheet_mut(0).unwrap().controls { + // As xlsx carries them: a macro on the selection, or nothing for a command. + c.action = match c.action.take() { + Some(ControlAction::Macro { name, .. }) => Some(ControlAction::Macro { name, input: MacroInput::Selection, output: None }), + _ => None, + }; + } + wb + }; + // The same buttons in the same places get their actions back. + let mut same = kept(&stored); + load_action_names(&mut same); + assert_eq!(actions(&same), actions(&wb)); + // Another application removed the first button: the command must not move to "Other". + let mut shifted = kept(&stored); + shifted.sheet_mut(0).unwrap().controls.remove(0); + load_action_names(&mut shifted); + assert_eq!(shifted.sheets[0].controls[0].action, None, "the command stays with the removed button"); + // A button given another macro there keeps it; a command doesn't replace a macro either. + let mut reassigned = kept(&stored); + for i in [0, 2] { + reassigned.sheet_mut(0).unwrap().controls[i].action = + Some(ControlAction::Macro { name: "Other".into(), input: MacroInput::Selection, output: None }); + } + load_action_names(&mut reassigned); + for i in [0, 2] { + assert!( + matches!(&reassigned.sheets[0].controls[i].action, Some(ControlAction::Macro { name, input: MacroInput::Selection, .. }) if name == "Other"), + "{i}" + ); + } +} + #[test] fn every_kind_and_action_survives_xlsx() { let mut s = s(); From b0531aeb7318884c567ded0fd67e413da68f841c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:01:04 -0400 Subject: [PATCH 14/55] XLSX: form control VML shapes are also found by their o:spid Some producers give a VML shape a named id (`id="Button 1"`) and keep its number in `o:spid`. The form control reader looked for the number only in `id`, so a control listed in lost its caption and macro (they live in the VML), and a control known only from the VML was skipped. Shapes are now matched by `id`, else by `o:spid`, as pass-through already does when it leaves the modelled shapes out. --- crates/xlsx/src/controls.rs | 10 +++++++-- crates/xlsx/src/tests/controls_tests.rs | 29 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index dabcadc45..58b240745 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -388,6 +388,12 @@ fn shape_number(id: &str) -> Option { id.trim().trim_start_matches("_x0000_s").parse().ok() } +/// A VML shape's number: from its `id` (`_x0000_s1025`), else from `o:spid`, where producers that +/// give the shape a named id (`id="Button 1"`) keep it. +fn number_of(shape: &El) -> Option { + shape.attr("id").and_then(shape_number).or_else(|| shape.attr("spid").and_then(shape_number)) +} + /// Reads a sheet's buttons, scroll bars and spin buttons: each `` with its properties /// part, else its VML shape, and VML shapes the sheet doesn't list (files that only have VML). /// Returns the shape numbers of the controls it read, so pass-through keeps only the others. @@ -398,7 +404,7 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut S None => None, }; let shapes: Vec<&El> = vml.as_ref().map(|v| v.kids("shape").collect()).unwrap_or_default(); - let shape_of = |n: u64| shapes.iter().copied().find(|s| s.attr("id").and_then(shape_number) == Some(n)); + let shape_of = |n: u64| shapes.iter().copied().find(|s| number_of(s) == Some(n)); // Every shape the sheet lists, read here or not. let mut listed: HashSet = HashSet::new(); for ctl in controls.map(controls_in).unwrap_or_default() { @@ -447,7 +453,7 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut S } // Controls known only from the VML. for s in &shapes { - let Some(n) = s.attr("id").and_then(shape_number) else { continue }; + let Some(n) = number_of(s) else { continue }; if listed.contains(&n) || sheet.controls.len() >= MAX_CONTROLS { continue; } diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index 0e52ed3de..a4f97439a 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -181,6 +181,35 @@ fn vml_only_controls_are_read_and_clamped() { assert_eq!(c[2].action, Some(ControlAction::Macro { name: "Tidy".into(), input: MacroInput::Selection, output: None })); } +/// Shapes with a named `id` and their number in `o:spid`, as some producers write them. +const VML_SPID: &str = r##" +
Go
1, 0, 6, 0, 3, 0, 7, 0[0]!Tidy
+1, 0, 1, 0, 1, 20, 3, 079 +
"##; + +#[test] +fn vml_shapes_are_also_found_by_their_spid() { + let rels = r#""#; + // The button is listed in without a properties part; the spin button is only in + // the VML. + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", VML_SPID)], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let c = &wb.sheet(0).unwrap().controls; + assert_eq!(c.len(), 2, "{c:?}"); + assert_eq!((c[0].kind, c[0].name.as_str(), c[0].text.as_str()), (ControlKind::Button, "Go Button", "Go")); + assert_eq!(c[0].action, Some(ControlAction::Macro { name: "Tidy".into(), input: MacroInput::Selection, output: None })); + assert_eq!((c[1].kind, c[1].value, c[1].max), (ControlKind::Spin, 7, 9)); + // Saved again, each is written once and nothing of them is kept twice. + let out = write_xlsx(&wb).unwrap(); + let (back, _) = read_xlsx(&out).unwrap(); + assert_eq!(back.sheet(0).unwrap().controls.len(), 2); +} + #[test] fn the_properties_part_wins_over_the_vml() { let mut wb = book(); From 0e631e20dabaf022eb3e83b0a60025898cdd8bb9 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:02:48 -0400 Subject: [PATCH 15/55] XLSX: a hostile-file test for form controls A sheet with more controls than a sheet keeps (10,000), properties parts that are not XML, are missing or hold numbers no control can take (1e400, NaN, -inf, 2^64), shape ids that are not numbers, anchors far outside the grid, and oversized names and links. Reading keeps exactly the cap, every control passes the model's rules, and a save and reopen keeps them all. --- crates/xlsx/src/tests/controls_tests.rs | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index a4f97439a..f8aa60d08 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -232,3 +232,53 @@ fn the_properties_part_wins_over_the_vml() { let (back, _) = read_xlsx(&zipped).unwrap(); assert_eq!(back.sheet(0).unwrap().controls[0].max, 500); } + +/// A hostile sheet: more controls than a sheet keeps, properties parts that are not XML or hold +/// numbers no control can take, shape ids that are not numbers, and oversized names, captions and +/// links. Reading must not panic, must keep at most the cap, and every control it keeps must pass +/// the model's rules. +#[test] +fn hostile_form_controls_are_capped_and_repaired() { + let n = 13_000; + let mut controls = String::from(""); + let anchor = r#"1-99999999999999999999990-4020"#; + for i in 0..n { + let rid = match i % 10 { + 0 => "rIdB", + 1 => "rIdC", + _ => "rIdA", + }; + let _ = std::fmt::Write::write_fmt( + &mut controls, + format_args!(r#"{anchor}"#, 2000 + i, "n".repeat(400)), + ); + } + controls + .push_str(r#""#); + let rels = r#""#; + let link = format!("$B${}", "9".repeat(500)); + let prop_a = format!( + r#""# + ); + let body = format!(r#"{controls}"#) + .replace("", r#""#); + let bytes = minimal( + &body, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/a.xml", &prop_a), ("xl/ctrlProps/b.xml", "\u{0}\u{1}not xml <<<")], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let c = &wb.sheet(0).unwrap().controls; + assert_eq!(c.len(), 10_000, "the cap of one sheet"); + for x in c { + assert_eq!(x.kind, ControlKind::Slider); + assert!(x.check_numbers().is_ok(), "{x:?}"); + assert!(x.value >= x.min && x.value <= x.max, "{x:?}"); + assert!(x.name.chars().count() <= 255 && x.link.is_none(), "{x:?}"); + assert!(x.anchor.width >= 1.0 && x.anchor.height >= 1.0 && x.anchor.cell.row < gridcraft_core::MAX_ROWS, "{x:?}"); + } + // Written back and read again, the kept controls stay valid. + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + assert_eq!(back.sheet(0).unwrap().controls.len(), c.len()); +} From cc68d71ac203963c53360096f35e0948ad13dd22 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:04:05 -0400 Subject: [PATCH 16/55] XLSX: form controls find their VML shapes through an index Each control listed in looked for its VML shape by scanning every shape, so a sheet of 10,000 controls took over 8 s to open. The shapes are now indexed by number once, and the same sheet opens in about 0.4 s. --- crates/xlsx/src/controls.rs | 11 +++++++++-- crates/xlsx/src/tests/controls_tests.rs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index 58b240745..37765dd19 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -12,7 +12,7 @@ //! list boxes, drop-downs, ActiveX controls) are left to pass-through (`passthrough.rs`), which //! keeps them unchanged; a sheet can hold both, in one `` element and one VML drawing. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fmt::Write as _; use gridcraft_core::CellRef; @@ -404,7 +404,14 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut S None => None, }; let shapes: Vec<&El> = vml.as_ref().map(|v| v.kids("shape").collect()).unwrap_or_default(); - let shape_of = |n: u64| shapes.iter().copied().find(|s| number_of(s) == Some(n)); + // Looked up once per listed control: an index keeps a sheet of many controls linear. + let mut by_number: HashMap = HashMap::with_capacity(shapes.len()); + for s in &shapes { + if let Some(n) = number_of(s) { + by_number.entry(n).or_insert(s); + } + } + let shape_of = |n: u64| by_number.get(&n).copied(); // Every shape the sheet lists, read here or not. let mut listed: HashSet = HashSet::new(); for ctl in controls.map(controls_in).unwrap_or_default() { diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index f8aa60d08..8825e040a 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -282,3 +282,24 @@ fn hostile_form_controls_are_capped_and_repaired() { let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); assert_eq!(back.sheet(0).unwrap().controls.len(), c.len()); } + +/// Each listed control finds its VML shape through an index, so a sheet of many controls reads in +/// linear time (it used to scan every shape for every control). +#[test] +#[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm +fn many_listed_controls_read_in_linear_time() { + let mut wb = Workbook::default(); + if let Some(sh) = wb.sheet_mut(0) { + for i in 0..10_000u32 { + let a = anchor(&format!("B{}", i + 1), 60.0, 18.0); + sh.controls.push(FormControl { id: i + 1, kind: ControlKind::Spin, anchor: a, ..Default::default() }); + } + } + let bytes = write_xlsx(&wb).unwrap(); + let t0 = std::time::Instant::now(); + let (back, _) = read_xlsx(&bytes).unwrap(); + let took = t0.elapsed(); + assert_eq!(back.sheet(0).unwrap().controls.len(), 10_000); + // About 0.4 s here; scanning every shape for every control took over 8 s. + assert!(took < std::time::Duration::from_secs(3), "{took:?}"); +} From 0fb3ea7a68ec97992821d3f680fdf581d8964bd8 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:12:07 -0400 Subject: [PATCH 17/55] Docs: revision history for form controls file-format-parity.md, target-app-parity.md and ui-parity.md gave form controls a new timestamp line without a matching revision-history row; each now has one. The file-format-parity.md timestamp line keeps the earlier change it replaced, as the other docs do. --- docs/file-format-parity.md | 3 ++- docs/target-app-parity.md | 1 + docs/ui-parity.md | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3607f612..6f01e5c42 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (form controls: buttons, scroll bars and spin buttons read and written) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (form controls: buttons, scroll bars and spin buttons read and written; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -110,6 +110,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through | | 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `` with ``) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `` without runs is kept on its own, and when duplicate `` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) | | 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (``, also Hebr, Jpan and the rest) are read and written back in file order after ``; they were dropped before. A hostile file is held to 64 entries of 256 characters | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7b8791f17..be1dec8b6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -335,6 +335,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Form controls (buttons, scroll bars, spin buttons) in Insert objects and Automation; catalog 276 / 292 → 280 / 296 (Developer › Controls) | | 2026-10-31 | minor | Charts move and resize from eight handles like every floating object (#190): the Essentials chart work is polish only (5–8 h), and the chart rows and the interaction discount no longer list resizing as missing; the Mainstream chart row no longer lists trendlines as missing (no score change) | | 2026-10-25 | minor | Automation: macro interface (Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) (no score change) | | 2026-10-25 | trivial | Catalog count 276 / 292 in the summary table and the method, as `cargo xtask parity` measures (no score change) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9b19ddbb5..b395e70df 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -195,6 +195,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Form controls: buttons, sliders (scroll bars) and spin buttons placed, worked, selected and edited on the sheet (Insert › Controls, Assign Action, Format Control); catalog 280 / 296 | | 2026-10-31 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection | | 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text | | 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | From 14dee9155dd7e26ba99004bbcd2046b9227ef800 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:35:36 -0400 Subject: [PATCH 18/55] Form controls: the object commands leave a locked control on a protected sheet alone control.format and control.assign refused to change a locked control on a protected sheet, but object.move, object.delete, object.setAnchorMode, arrange.bringForward, arrange.sendBackward and arrange.align changed it all the same. They now refuse it with the same message; an unlocked control still moves, and other kinds of objects are unchanged. --- crates/engine/src/cmd/controls.rs | 18 +++++++++++++++++- crates/engine/src/cmd/extra/objects.rs | 6 ++++++ crates/engine/src/cmd/insert.rs | 2 ++ crates/engine/src/tests/controls.rs | 25 +++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index d2cf17f01..f68effc9e 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -8,7 +8,9 @@ //! else made asks before it runs anything. use gridcraft_core::CellRef; -use gridcraft_model::{Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MAX_MACRO_NAME, MacroInput, Sheet, Workbook}; +use gridcraft_model::{ + Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MAX_MACRO_NAME, MacroInput, ObjKind, Sheet, Workbook, +}; use serde_json::{Value as Json, json}; use super::{ @@ -298,6 +300,20 @@ fn find(s: &Session, cmd: &str, p: &Json) -> Result<(usize, usize, FormControl)> } /// Drawing objects on a protected sheet can't be changed while they are locked. +/// For the object commands (`object.move`, `object.delete`, `object.setAnchorMode`, `arrange.*`, +/// `arrange.align`): refuses to change form controls `ids` on `sheet` when one is locked and the +/// sheet is protected, as `control.format` and `control.assign` do. Other kinds pass. +pub(crate) fn check_objects_unlocked(s: &Session, sheet: usize, kind: ObjKind, ids: &[u32]) -> Result<()> { + if kind != ObjKind::Control { + return Ok(()); + } + let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Ok(()) }; + if sh.is_protected() && sh.controls.iter().any(|c| c.locked && ids.contains(&c.id)) { + return Err(EngineError::Other(PROTECTED.into())); + } + Ok(()) +} + fn check_unlocked(s: &Session, sheet: usize, c: &FormControl) -> Result<()> { if c.locked && s.doc()?.wb.sheet(sheet).is_some_and(Sheet::is_protected) { return Err(EngineError::Other(PROTECTED.into())); diff --git a/crates/engine/src/cmd/extra/objects.rs b/crates/engine/src/cmd/extra/objects.rs index 67286f268..91952c219 100644 --- a/crates/engine/src/cmd/extra/objects.rs +++ b/crates/engine/src/cmd/extra/objects.rs @@ -36,6 +36,9 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result let kind = str_param(p, "kind").unwrap_or("shape").to_string(); let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false); let si = s.doc()?.wb.active_sheet(); + if let Some(k) = gridcraft_model::ObjKind::parse(&kind) { + crate::cmd::controls::check_objects_unlocked(s, si, k, &[id])?; + } fn reorder(v: &mut Vec, i: usize, forward: bool, extreme: bool) { if i >= v.len() { return; @@ -125,6 +128,9 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result { p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default(); let how = str_param(p, "align").unwrap_or("left").to_string(); let si = s.doc()?.wb.active_sheet(); + if let Some(k) = gridcraft_model::ObjKind::parse(&kind) { + crate::cmd::controls::check_objects_unlocked(s, si, k, &ids)?; + } edit(s, |cx| { let sheet = cx.sheet_mut(si)?; let geo: Vec<(u32, f64, f64, f32, f32)> = { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 0806f2183..3c95e3388 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1187,6 +1187,8 @@ fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> { if !d.wb.sheet(sheet).is_some_and(|sh| sh.has_object(o)) { return Err(bad(cmd, "no such object")); } + // A locked form control on a protected sheet stays as it is. + super::controls::check_objects_unlocked(s, sheet, kind, &[id])?; Ok((sheet, o)) } diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs index 98b2404ae..8f0741d49 100644 --- a/crates/engine/src/tests/controls.rs +++ b/crates/engine/src/tests/controls.rs @@ -335,6 +335,31 @@ fn links_follow_inserts_deletes_renames_and_cut_moves() { assert_eq!((link(&s, 0, local), link(&s, 0, remote)), ("$G$9".to_string(), "Inputs!$A$1".to_string())); } +#[test] +fn the_object_commands_leave_a_locked_control_on_a_protected_sheet_alone() { + let mut s = s(); + let locked = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1"})).unwrap()); + let other = id(&s.execute("insert.spinButton", json!({"at": "D8"})).unwrap()); + let free = id(&s.execute("insert.button", json!({"at": "D12"})).unwrap()); + s.execute("control.format", json!({"id": free, "locked": false})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + for (cmd, p) in [ + ("object.move", json!({"kind": "control", "id": locked, "at": "H9"})), + ("object.delete", json!({"kind": "control", "id": locked})), + ("object.setAnchorMode", json!({"kind": "control", "id": locked, "mode": "absolute"})), + ("arrange.bringForward", json!({"kind": "control", "id": locked, "toFront": true})), + ("arrange.sendBackward", json!({"kind": "control", "id": locked})), + ("arrange.align", json!({"kind": "control", "ids": [locked, other], "align": "left"})), + ] { + assert!(err(s.execute(cmd, p)).contains("protected"), "{cmd}"); + } + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before), "nothing changed"); + // An unlocked control still moves, as in Excel. + s.execute("object.move", json!({"kind": "control", "id": free, "at": "H12"})).unwrap(); + assert_eq!(control(&s, free).anchor.cell.a1(), "H12"); +} + #[test] fn a_locked_control_on_a_protected_sheet_cannot_be_changed() { let mut s = s(); From b8f04d7af9493190d7628e35d03a46e3c1fd6186 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:36:53 -0400 Subject: [PATCH 19/55] Macros: the check of each step counts the form controls a step deletes The footprint counts a deleted form control as a conflict, but the check made as each step runs counted only charts, pictures and shapes. After a footprint that stopped early (at Solver, or an approved file step), a later step could delete a control with no question. It now counts controls too, so such a step stops the run under fail and asks under ask. --- crates/engine/src/macros/run.rs | 3 ++- crates/engine/src/tests/macros.rs | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 89e0cddca..2885d6673 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -539,7 +539,8 @@ impl Guard { } objects += deleted(&ss.charts, &ps.charts, &sa.charts, |o| o.id) + deleted(&ss.images, &ps.images, &sa.images, |o| o.id) - + deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id); + + deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id) + + deleted(&ss.controls, &ps.controls, &sa.controls, |o| o.id); } if objects > 0 { parts.push(format!("{objects} object{}", if objects == 1 { "" } else { "s" })); diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index fe983f97e..7030951ff 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -2489,3 +2489,28 @@ fn an_output_override_is_checked_and_protected_like_the_macros_own_anchor() { } assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &before)); } + +/// After a partial footprint (Solver can't be previewed), a later step that deletes a form +/// control is caught by the check of each step, as one that deletes a chart or a picture is. +#[test] +fn a_step_that_deletes_a_form_control_after_a_partial_footprint_is_caught() { + let mut s = s(); + set(&mut s, "A1", "1"); + set(&mut s, "C1", "=A1"); + let control = s.execute("insert.spinButton", json!({"at": "E2"})).unwrap()["control"].as_u64().unwrap(); + let solver = json!({"command": "data.solver", "params": {"objective": "C1", "variables": "A1", "constraints": ["A1 <= 5"], "method": "simplex", "saveModel": false}}); + let delete = json!({"command": "object.delete", "params": {"kind": "control", "id": control}}); + put(&mut s, json!({"name": "Drop", "inputs": [{"name": "Input"}], "steps": [solver, delete]})); + let start = s.doc().unwrap().wb.clone(); + for rule in ["fail", "ask"] { + let r = s.execute("macro.run", json!({"name": "Drop", "inputs": "A1", "onConflict": rule})); + let e = match (rule, r) { + ("fail", Err(EngineError::Other(e))) | ("ask", Err(EngineError::Confirm(e))) => e, + (_, r) => panic!("{rule}: {r:?}"), + }; + assert!(e.contains("Step 2 (object.delete): It would write over data (1 object)"), "{e}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "{rule}: rolled back"); + } + s.execute("macro.run", json!({"name": "Drop", "inputs": "A1", "overwrite": true})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().controls.is_empty()); +} From e1fbee8ebc91022b7e1a9848906c7127b142579f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:37:49 -0400 Subject: [PATCH 20/55] Form controls: holding Enter or Space presses a focused button once The focused control took every pressed key event, the system's key repeats included, so holding Enter or Space on a focused button ran its macro or command again for each repeat. A repeat now still belongs to the button (it doesn't reach the grid) but doesn't press it; arrows, Page Up and Page Down held on a slider or spin button keep stepping. --- crates/ui-egui/src/form_controls.rs | 9 ++++++--- crates/ui-egui/src/grid/keyboard.rs | 4 +++- crates/ui-egui/tests/it/form_controls.rs | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/form_controls.rs b/crates/ui-egui/src/form_controls.rs index c4fff919a..fb6d41c03 100644 --- a/crates/ui-egui/src/form_controls.rs +++ b/crates/ui-egui/src/form_controls.rs @@ -357,8 +357,9 @@ fn step_part(app: &mut SheetApp, c: &FormControl, part: Part) { } /// Keys for the focused control: arrows move by a step, Page Up and Page Down by a page, Home and -/// End to the ends; Space or Enter presses a button. True when the key was used. -pub fn key(app: &mut SheetApp, sh: &Sheet, key: egui::Key) -> bool { +/// End to the ends; Space or Enter presses a button. True when the key was used. `repeat` is a key +/// held down: it steps a slider or spin button again, but a button runs once per press. +pub fn key(app: &mut SheetApp, sh: &Sheet, key: egui::Key, repeat: bool) -> bool { use egui::Key; let Some(c) = app.grid.focused_control.and_then(|id| find(sh, id)).cloned() else { return false }; if app.selected_object.is_some() { @@ -370,7 +371,9 @@ pub fn key(app: &mut SheetApp, sh: &Sheet, key: egui::Key) -> bool { true } (ControlKind::Button, Key::Space | Key::Enter) => { - app.press_button(c.id); + if !repeat { + app.press_button(c.id); + } true } (ControlKind::Button, _) => false, diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index f2d571bb3..81f4b432f 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -170,7 +170,9 @@ fn grid_events(app: &mut SheetApp, ctx: &egui::Context, sh: &Sheet) -> Vec bool { match ev { egui::Event::Text(text) => text == " " && app.grid.focused_control.is_some() && app.selected_object.is_none(), - egui::Event::Key { key, pressed: true, modifiers: m, .. } => !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key), + egui::Event::Key { key, pressed: true, repeat, modifiers: m, .. } => { + !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key, *repeat) + } _ => false, } } diff --git a/crates/ui-egui/tests/it/form_controls.rs b/crates/ui-egui/tests/it/form_controls.rs index 79823f62c..09b57c4a8 100644 --- a/crates/ui-egui/tests/it/form_controls.rs +++ b/crates/ui-egui/tests/it/form_controls.rs @@ -241,6 +241,29 @@ fn a_button_runs_its_macro_and_cmd_or_right_click_only_selects() { assert_eq!(cell(&h, "H1"), Value::Empty); } +#[test] +fn holding_enter_or_space_presses_a_focused_button_once() { + let (mut h, ids) = fixture(); + let r = rect(&h, ids.button); + click(&mut h, r.center()); + assert_eq!(h.state().grid.focused_control, Some(ids.button)); + for k in [Key::Enter, Key::Space] { + let before = undo_len(&h); + // A held key: one press, then the system's repeats, then the release. + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + for _ in 0..5 { + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(undo_len(&h), before + 1, "{k:?} ran the macro once"); + assert_eq!(cell(&h, "H1"), Value::text("pressed")); + assert_eq!(cell(&h, "A20"), Value::Empty, "{k:?} typed nothing into the cell"); + } +} + #[test] fn a_button_from_a_file_asks_before_it_runs() { use egui_kittest::kittest::Queryable as _; From 85c22dd27bb46dbd3a7e3dae9c12c44fe0875fd2 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:38:18 -0400 Subject: [PATCH 21/55] XLSX: control properties parts use the ctrlProp relationship type Excel uses The sheet related each control properties part with http://schemas.microsoft.com/office/2006/relationships/ctrlProp. MS-XLSX defines the relationship in the officeDocument namespace, http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp, which Excel writes and looks the part up by. GridCraft's own reader matched either (it goes by the last segment and the r:id), so its round trips passed; Excel would not have found the parts. --- crates/xlsx/src/controls.rs | 3 ++- crates/xlsx/src/tests/controls_tests.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index 37765dd19..0a3d57bf3 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -25,7 +25,8 @@ use crate::write::{Out, Rels}; use crate::xml::{El, esc, esc_attr}; pub const CT_CTRL_PROP: &str = "application/vnd.ms-excel.controlproperties+xml"; -pub const REL_CTRL_PROP: &str = "http://schemas.microsoft.com/office/2006/relationships/ctrlProp"; +/// The worksheet relationship to a control properties part (MS-XLSX), as Excel writes it. +pub const REL_CTRL_PROP: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp"; const NS_X14: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; const NS_MC: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006"; const NS_XDR: &str = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"; diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index 8825e040a..f94be0e3a 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -303,3 +303,15 @@ fn many_listed_controls_read_in_linear_time() { // About 0.4 s here; scanning every shape for every control took over 8 s. assert!(took < std::time::Duration::from_secs(3), "{took:?}"); } + +/// The sheet relates each control properties part with the relationship type MS-XLSX defines and +/// Excel writes (in the officeDocument namespace); Excel finds the part by that type. +#[test] +fn the_properties_parts_use_the_ctrl_prop_relationship_type() { + let bytes = write_xlsx(&book()).unwrap(); + let mut pkg = Package::open(&bytes).unwrap(); + let rels = text(&mut pkg, "xl/worksheets/_rels/sheet1.xml.rels"); + let types: Vec<&str> = rels.split("Type=\"").skip(1).filter_map(|r| r.split('"').next()).filter(|t| t.ends_with("/ctrlProp")).collect(); + assert_eq!(types.len(), 4, "{rels}"); + assert!(types.iter().all(|t| *t == "http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp"), "{types:?}"); +} From dd97b29dec2bd1d0d8554de74322d7b8a56e0d4b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:39:08 -0400 Subject: [PATCH 22/55] XLSX: form controls are read from the drawing names The reader took the sheet's first VML drawing relationship. A sheet with pictures in its header or footer has a second VML drawing (), and when that relationship came first the control shapes were not found: VML-only controls were skipped, and listed controls lost their caption and macro. The drawing is now the one names, as pass-through already does. --- crates/xlsx/src/controls.rs | 5 +++-- crates/xlsx/src/sheet_read.rs | 16 +++++++++------- crates/xlsx/src/tests/controls_tests.rs | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index 0a3d57bf3..e45c98fe3 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -397,10 +397,11 @@ fn number_of(shape: &El) -> Option { /// Reads a sheet's buttons, scroll bars and spin buttons: each `` with its properties /// part, else its VML shape, and VML shapes the sheet doesn't list (files that only have VML). +/// The shapes come from the VML drawing with relationship id `vml_rid` (``). /// Returns the shape numbers of the controls it read, so pass-through keeps only the others. -pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut Sheet) -> HashSet { +pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels: &[Rel], sheet: &mut Sheet) -> HashSet { let mut read = HashSet::new(); - let vml = match rels.iter().find(|r| r.kind == "vmlDrawing") { + let vml = match vml_rid.and_then(|id| rels.iter().find(|r| r.id == id && r.kind == "vmlDrawing")) { Some(r) => cx.pkg.read_xml(&r.target).ok().flatten(), None => None, }; diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index f2f7de668..1cf875524 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -141,7 +141,6 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, Ha let mut fit_to_page = false; let mut column_assignments_left = MAX_COLUMN_ASSIGNMENTS; cx.cf_ext_ids.clear(); - let mut controls: Option<&El> = None; for e in &root.children { match e.name.as_str() { "sheetPr" => { @@ -263,12 +262,11 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, Ha } } "extLst" => read_ext(cx, e, &mut sheet), - "controls" | "AlternateContent" => controls = crate::controls::controls_of(e).or(controls), "protectedRanges" => read_protected_ranges(e, &mut sheet), _ => {} } } - let read_controls = read_form_controls(cx, controls, &rels, &mut sheet); + let read_controls = read_form_controls(cx, &root, &rels, &mut sheet); // Comments are related to the sheet without an element in it. for rel in rels.iter().filter(|r| r.kind == "comments") { if let Some(x) = cx.optional_xml(&rel.target)? { @@ -293,10 +291,14 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, Ha Ok((sheet, read_controls)) } -/// Buttons, scroll bars and spin buttons: the `` list with its properties parts, else -/// the VML shapes. Returns the VML shape numbers read, so pass-through keeps only the others. -fn read_form_controls(cx: &mut Ctx<'_>, controls: Option<&El>, rels: &[Rel], sheet: &mut Sheet) -> HashSet { - if controls.is_some() || rels.iter().any(|r| r.kind == "vmlDrawing") { crate::controls::read(cx, controls, rels, sheet) } else { HashSet::new() } +/// Buttons, scroll bars and spin buttons: the `` list (also inside +/// `mc:AlternateContent`) with its properties parts, else the shapes of the VML drawing that +/// `` names (not the header and footer drawing of ``). +/// Returns the VML shape numbers read, so pass-through keeps only the others. +fn read_form_controls(cx: &mut Ctx<'_>, root: &El, rels: &[Rel], sheet: &mut Sheet) -> HashSet { + let controls = root.children.iter().filter_map(crate::controls::controls_of).next_back(); + let vml = root.child("legacyDrawing").and_then(|e| e.attr("id")); + if controls.is_some() || vml.is_some() { crate::controls::read(cx, controls, vml, rels, sheet) } else { HashSet::new() } } /// A protection password hash: the iterated attributes (`algorithmName`, `hashValue`, diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index f94be0e3a..b58d1f200 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -315,3 +315,21 @@ fn the_properties_parts_use_the_ctrl_prop_relationship_type() { assert_eq!(types.len(), 4, "{rels}"); assert!(types.iter().all(|t| *t == "http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp"), "{types:?}"); } + +/// A sheet with a header and footer drawing and a drawing for its controls: the controls come +/// from the drawing `` names, whichever relationship is listed first. +#[test] +fn controls_come_from_the_legacy_drawing_not_the_header_and_footer_drawing() { + let hf = r##""##; + let rels = r#""#; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawingHF1.vml", hf), ("xl/drawings/vmlDrawing1.vml", VML_ONLY)], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let c = &wb.sheet(0).unwrap().controls; + assert_eq!(c.len(), 3, "{c:?}"); + assert_eq!(c[2].text, "Go"); +} From 10de76e6c73c6e32199182c7f7c82e574e1a41da Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:18:55 -0400 Subject: [PATCH 23/55] Docs: macro.check's signature in macros.md lists output The Running section says macro.check takes `output`, but the macro.check signature further down left it out. It now lists `output?`, and says the reported output anchor is `output` when given. --- docs/macros.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index 9a4b32859..7e428b4d1 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -144,9 +144,9 @@ doesn't exist, a cell outside the grid), is refused before anything runs: do; a step that deletes one of them, or changes the size of a range a Repeat is going over, stops the run. -`macro.check {name, inputs?, onConflict?, overwrite?, timeLimit?}` reports the bound inputs, the -output anchor, any approval needed and the footprint, and puts back everything the dry run -changed. `ok` says whether `macro.run` with the same parameters would go ahead without a question; +`macro.check {name, inputs?, output?, onConflict?, overwrite?, timeLimit?}` reports the bound +inputs, the output anchor (`output` when given), any approval needed and the footprint, and puts +back everything the dry run changed. `ok` says whether `macro.run` with the same parameters would go ahead without a question; when it wouldn't, `errors` says why: the run would fail at a step, a Stop step rolls it back, it would write over data under `ask` or `fail`, shifting can't make room (the check shifts and checks again, then puts everything back), or the run would go past From 2b787b191299415fc061b642a1158e6b7519354f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:23:56 -0400 Subject: [PATCH 24/55] Docs: ui.object.select says what it replies when it clears the selection --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bbe00c51f..b5822da64 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -59,7 +59,7 @@ connection; nothing sent after it runs. Blank lines are skipped. | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | | `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, selected object, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | -| `ui.object.select` | `{id?, kind?}` | Selects a floating object on the active sheet (no `id` clears the selection); replies `{selected, rect}`, the rect in window points | +| `ui.object.select` | `{id?, kind?}` | Selects a floating object on the active sheet and replies `{selected, rect}`, the rect in window points; with no `id` it clears the selection and replies `null` | | `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 synthetic events or 8 MiB of synthetic input queued (pointer, key, text and move events share this budget) | | `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same shared queue limits | | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB | From b874f58667be88a5dbaf46ad58319ba574c73f0c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:38:23 -0500 Subject: [PATCH 25/55] Objects: a test pins that both anchor accessors visit the same objects in the same order Sorting and row or column edits read every anchor through `object_anchors`, compute the new ones and write them back through `object_anchors_mut` in step. Nothing checked that the two accessors visit the same objects in the same order; a kind of floating object reaching only one of them would have paired objects with other objects' anchors silently. Test: two objects of every kind are inserted in a mixed order, each anchor is marked with its position through the mutable accessor, the marks are read back through the shared one in the same sequence, the count matches the per-kind collections, and the mark is found on the object `object_anchor` names by kind and id. The test's constructor matches on `ObjKind` exhaustively, so a new kind does not compile until the test covers it. --- crates/model/src/sheet.rs | 87 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index d442a5340..9956500d0 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -701,6 +701,93 @@ mod tests { assert_eq!(s.col_at(-5.0), 0); assert_eq!(s.col_left(2), 128.0); } + + use crate::features::{ChartKind, LegendPos, ShapeFormat, ShapeKind}; + + /// Adds a floating object of `kind` with id `id` at `anchor`. The match is exhaustive on + /// purpose: a new kind of floating object does not compile here until this test covers it, + /// and the counts in `object_anchor_accessors_visit_the_same_objects_in_the_same_order` + /// then catch an accessor that was not taught about it. + fn push_object(s: &mut Sheet, kind: ObjKind, id: u32, anchor: Anchor) { + match kind { + ObjKind::Chart => s.charts.push(Chart { + id, + kind: ChartKind::default(), + anchor, + title: None, + series: vec![], + legend: LegendPos::default(), + data_labels: false, + gridlines: false, + style: 0, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }), + ObjKind::Image => s.images.push(Image { id, anchor, data: Arc::new(vec![]), mime: String::new(), alt: String::new() }), + ObjKind::Shape => s.shapes.push(Shape { + id, + kind: ShapeKind::default(), + anchor, + fill: Color::default(), + line: Color::default(), + text: String::new(), + format: ShapeFormat::default(), + }), + ObjKind::Slicer => { + s.slicers.push(Slicer { id, table: String::new(), column: String::new(), caption: String::new(), anchor, selected: None, columns: 1 }) + } + } + } + + /// Sorting, and inserting or deleting rows and columns, read every anchor through + /// `object_anchors`, compute the new ones and write them back through `object_anchors_mut` + /// in step. The two must visit the same objects in the same order, or an object receives + /// another object's anchor; a kind that reaches only one of them would break that silently. + #[test] + fn object_anchor_accessors_visit_the_same_objects_in_the_same_order() { + let mut s = Sheet::new("S"); + // Two objects of every kind, inserted in a mixed order, with ids unrelated to the kind. + // Each anchor cell's row carries the id, so an anchor read back names its object. + let inserted = [ + (ObjKind::Slicer, 7), + (ObjKind::Chart, 3), + (ObjKind::Image, 11), + (ObjKind::Shape, 2), + (ObjKind::Chart, 9), + (ObjKind::Slicer, 1), + (ObjKind::Image, 4), + (ObjKind::Shape, 12), + ]; + for &(kind, id) in &inserted { + push_object(&mut s, kind, id, Anchor { cell: CellRef::new(id, 0), ..Default::default() }); + } + let per_kind = s.charts.len() + s.images.len() + s.shapes.len() + s.slicers.len(); + assert_eq!(per_kind, inserted.len()); + assert_eq!(s.object_anchors().count(), per_kind, "object_anchors visits every collection"); + assert_eq!(s.object_anchors_mut().count(), per_kind, "object_anchors_mut visits every collection"); + // Mark each anchor with its position through the mutable accessor; the shared accessor + // reads the marks back in the same sequence. + for (i, a) in s.object_anchors_mut().enumerate() { + a.dx = i as f32; + } + let marks: Vec = s.object_anchors().map(|a| a.dx).collect(); + assert_eq!(marks, (0..per_kind).map(|i| i as f32).collect::>()); + // The sequence is the documented one (charts, images, shapes, slicers, each in insertion + // order) and every object appears in it once. + let documented: Vec = + ObjKind::ALL.iter().flat_map(|&k| inserted.iter().filter(move |(kind, _)| *kind == k).map(|&(_, id)| id)).collect(); + let visited: Vec = s.object_anchors().map(|a| a.cell.row).collect(); + assert_eq!(visited, documented); + // The mark written through the bulk accessor landed on the object `object_anchor` finds + // by kind and id. + for &(kind, id) in &inserted { + let position = documented.iter().position(|&x| x == id).map(|p| p as f32); + assert_eq!(s.object_anchor(ObjRef::new(kind, id)).map(|a| a.dx), position, "{kind:?} {id}"); + } + } } // CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats. From df98e9cef9faaaf1ae7b6095e74e1b8ee514aaec Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:38:23 -0500 Subject: [PATCH 26/55] Objects: sorting keeps the size of a "move and size" picture whose lines swap places A picture anchored to move and size with cells and spanning two rows (or columns) was re-fitted between its top-left and bottom-right lines after a sort. When the sort put the bottom line above the top one, the fitted extent was negative and the picture collapsed to a zero height or width. It now keeps its old size on that axis and travels with its top-left line, like a "move only" picture. Tests: for a sort by rows and one by columns, "move and size" and "move only" pictures inside one line travel with it (the first re-fitted to the same line, the second at its own size), an absolute picture stays while the cells move under it, a two-line picture whose lines swap keeps its size, and undo restores every anchor. --- crates/engine/src/cmd/data/sort.rs | 9 +++- crates/engine/src/tests/objects.rs | 76 +++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/data/sort.rs b/crates/engine/src/cmd/data/sort.rs index d335a7695..491e3d51e 100644 --- a/crates/engine/src/cmd/data/sort.rs +++ b/crates/engine/src/cmd/data/sort.rs @@ -272,7 +272,9 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec Anchor { if a.mode == AnchorMode::Absolute { return a; @@ -290,7 +292,10 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b let y1 = sh.row_top(nr) + a.dy as f64; let x2 = sh.col_left(nbc) + tdx as f64; let y2 = sh.row_top(nbr) + tdy as f64; - Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode } + // An extent that is not positive means the sort put the bottom-right line before the + // top-left one on that axis: the object keeps its old size there. + let fit = |extent: f64, old: f32| if extent > 0.0 { extent as f32 } else { old.max(0.0) }; + Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: fit(x2 - x1, a.width), height: fit(y2 - y1, a.height), mode: a.mode } } else { Anchor { cell: CellRef::new(nr, nc), ..a } } diff --git a/crates/engine/src/tests/objects.rs b/crates/engine/src/tests/objects.rs index 7082b9054..c39b14e11 100644 --- a/crates/engine/src/tests/objects.rs +++ b/crates/engine/src/tests/objects.rs @@ -1,7 +1,7 @@ //! Floating objects of every kind: found, moved, listed, stacked and deleted by `{kind, id}`. use gridcraft_core::CellRef; -use gridcraft_model::{AnchorMode, ObjKind, ObjRef, Sheet}; +use gridcraft_model::{Anchor, AnchorMode, ObjKind, ObjRef, Sheet}; use serde_json::json; use super::s; @@ -133,6 +133,80 @@ fn every_kind_follows_row_and_column_edits_and_sorts() { assert_eq!(cell_of(&s, pic), "T2"); } +/// A picture at `cell` with anchor `mode`, offset `(dx, dy)` and size `(width, height)`, added +/// straight to the model like `add_image`, so a sort that follows is the only step to undo. +fn picture(s: &mut Session, cell: &str, mode: AnchorMode, dx: f32, dy: f32, width: f32, height: f32) -> ObjRef { + let id = super::add_image(s, cell, mode); + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + if let Some(img) = wb.sheet_mut(0).unwrap().images.iter_mut().find(|i| i.id == id) { + img.anchor = Anchor { dx, dy, width, height, ..img.anchor }; + } + ObjRef::new(ObjKind::Image, id) +} + +/// The anchor of `o` as (cell, dx, dy, width, height). +fn anchor_of(s: &Session, o: ObjRef) -> (String, f32, f32, f32, f32) { + let a = sheet(s, 0).object_anchor(o).unwrap(); + (a.cell.a1(), a.dx, a.dy, a.width, a.height) +} + +/// Sorting rows under a picture follows its anchor mode, as Excel's "Move and size with cells", +/// "Move but don't size with cells" and "Don't move or size with cells" do. +#[test] +fn sorting_rows_under_a_picture_follows_its_anchor_mode() { + let mut s = s(); + // Rows 2..5 hold 3, 1, 4, 2; sorted ascending they read 1, 2, 3, 4, so row 2 moves to row 4, + // row 3 to row 2, row 4 to row 5 and row 5 to row 3. Default rows are 20 pt tall. + s.execute("range.setValues", json!({"range": "A1", "values": [["k"], [3], [1], [4], [2]]})).unwrap(); + // Three pictures inside one row each (3 pt down, 12 pt tall), one per anchor mode. + let sized = picture(&mut s, "A2", AnchorMode::MoveAndSize, 5.0, 3.0, 30.0, 12.0); + let moved = picture(&mut s, "A3", AnchorMode::MoveOnly, 5.0, 3.0, 30.0, 12.0); + let pinned = picture(&mut s, "A4", AnchorMode::Absolute, 5.0, 3.0, 30.0, 12.0); + // A "move and size" picture spanning rows 2 and 3: its top is 10 pt into row 2 (y 30) and + // its bottom 15 pt into row 3 (y 55). The sort puts row 3 above row 2. + let tall = picture(&mut s, "A2", AnchorMode::MoveAndSize, 5.0, 10.0, 30.0, 25.0); + let before: Vec<_> = [sized, moved, pinned, tall].iter().map(|&o| anchor_of(&s, o)).collect(); + s.execute("selection.set", json!({"range": "A1:A5"})).unwrap(); + s.execute("data.sortAscending", json!({"header": true, "column": "A"})).unwrap(); + assert_eq!( + anchor_of(&s, sized), + ("A4".into(), 5.0, 3.0, 30.0, 12.0), + "move and size: travels with row 2 to row 4, re-fitted to the same 20 pt row" + ); + assert_eq!(anchor_of(&s, moved), ("A2".into(), 5.0, 3.0, 30.0, 12.0), "move only: travels with row 3 to row 2 at its own size"); + assert_eq!(anchor_of(&s, pinned), ("A4".into(), 5.0, 3.0, 30.0, 12.0), "absolute: stays while the cells move under it"); + // The tall picture's bottom row now sorts above its top row, so there is nothing to stretch + // it between: it travels with its top-left row and keeps its size instead of collapsing. + assert_eq!(anchor_of(&s, tall), ("A4".into(), 5.0, 10.0, 30.0, 25.0), "two rows swapped: moves with its top row at its old size"); + s.execute("edit.undo", json!({})).unwrap(); + let after: Vec<_> = [sized, moved, pinned, tall].iter().map(|&o| anchor_of(&s, o)).collect(); + assert_eq!(after, before, "undo restores every anchor"); +} + +/// A sort by columns follows the anchor mode the same way. +#[test] +fn sorting_columns_under_a_picture_follows_its_anchor_mode() { + let mut s = s(); + // Row 10 holds a header in A and 3, 1, 2 in B..D; sorted ascending, column B moves to D, + // C to B and D to C. Default columns are 64 pt wide. + s.execute("range.setValues", json!({"range": "A10", "values": [["k", 3, 1, 2]]})).unwrap(); + let sized = picture(&mut s, "B10", AnchorMode::MoveAndSize, 5.0, 3.0, 30.0, 12.0); + let moved = picture(&mut s, "C10", AnchorMode::MoveOnly, 5.0, 3.0, 30.0, 12.0); + let pinned = picture(&mut s, "D10", AnchorMode::Absolute, 5.0, 3.0, 30.0, 12.0); + // A "move and size" picture spanning columns B and C: from x 104 (40 pt into B) to x 154 + // (26 pt into C). The sort puts column C before column B. + let wide = picture(&mut s, "B10", AnchorMode::MoveAndSize, 40.0, 3.0, 50.0, 12.0); + let before: Vec<_> = [sized, moved, pinned, wide].iter().map(|&o| anchor_of(&s, o)).collect(); + s.execute("data.sort", json!({"range": "A10:D10", "header": true, "orientation": "columns", "keys": [{"row": 10}]})).unwrap(); + assert_eq!(anchor_of(&s, sized), ("D10".into(), 5.0, 3.0, 30.0, 12.0), "move and size: travels with column B to D at the same width"); + assert_eq!(anchor_of(&s, moved), ("B10".into(), 5.0, 3.0, 30.0, 12.0), "move only: travels with column C to B at its own size"); + assert_eq!(anchor_of(&s, pinned), ("D10".into(), 5.0, 3.0, 30.0, 12.0), "absolute: stays"); + assert_eq!(anchor_of(&s, wide), ("D10".into(), 40.0, 3.0, 50.0, 12.0), "two columns swapped: moves with its left column at its old size"); + s.execute("edit.undo", json!({})).unwrap(); + let after: Vec<_> = [sized, moved, pinned, wide].iter().map(|&o| anchor_of(&s, o)).collect(); + assert_eq!(after, before, "undo restores every anchor"); +} + #[test] fn slicers_follow_inserted_rows_like_other_objects() { let mut s = s(); From 11ffe094391e951c7765bf1cf4a9799689c1cb77 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:05:25 -0500 Subject: [PATCH 27/55] Form controls: tests for denied commands brought in by a file and for linked cells under protection A button's command is refused at Insert, at Assign Action and, when a file's hidden name carries it, at press time (file.save, file.saveAs with a path, file.open, app.commands, macro.run, automate.runScript, control.press); nothing runs and no file is written. A slider or spin button whose linked cell is locked on a protected sheet refuses the change through control.setValue, Format Control and Insert, and keeps its value; a link to another sheet obeys that sheet's protection; an unlocked linked cell takes the value. --- crates/engine/src/tests/controls.rs | 113 ++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs index 8f0741d49..ba770b598 100644 --- a/crates/engine/src/tests/controls.rs +++ b/crates/engine/src/tests/controls.rs @@ -475,3 +475,116 @@ fn every_kind_and_action_survives_xlsx() { s.execute("cell.set", json!({"cell": "B1", "input": "120"})).unwrap(); assert_eq!(s.execute("control.list", json!({})).unwrap()[0]["value"], json!(120)); } + +/// A button's command goes through the macro policy when it is assigned and again when it is +/// pressed, so a file whose hidden name carries a denied command (written by another application, +/// or edited by hand) can't use a button as a way around the policy. +#[test] +fn a_denied_command_is_refused_when_assigned_and_when_a_file_brings_it_in() { + let dir = std::env::temp_dir().join(format!("gridcraft-control-deny-{}", std::process::id())); + let leak = dir.join("leak.xlsx").to_string_lossy().to_string(); + let denied = [ + ("file.saveAs", json!({"path": leak})), + ("file.save", json!({})), + ("file.open", json!({"path": leak})), + ("app.commands", json!({})), + ("macro.run", json!({"name": "Mark"})), + ("automate.runScript", json!({"name": "Leak", "trusted": true})), + ("control.press", json!({"id": 1})), + ]; + let mut s = s(); + s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap(); + // Neither Insert › Button nor Assign Action takes one. + let b = id(&s.execute("insert.button", json!({"at": "B2", "text": "Run"})).unwrap()); + for (cmd, params) in &denied { + let want = format!("can't run {cmd}"); + assert!(err(s.execute("insert.button", json!({"at": "B9", "command": cmd, "params": params}))).contains(&want), "{cmd}"); + assert!(err(s.execute("control.assign", json!({"id": b, "command": cmd, "params": params}))).contains(&want), "{cmd}"); + assert_eq!(control(&s, b).action, None, "{cmd}"); + } + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1, "no button was placed"); + // A file can still carry them: one button per command, given its action as a file would. + for i in 1..denied.len() { + s.execute("insert.button", json!({"at": format!("B{}", 2 + 3 * i), "text": format!("Run {i}")})).unwrap(); + } + let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + for (c, (cmd, params)) in sh.controls.iter_mut().zip(&denied) { + c.action = Some(ControlAction::Command { id: (*cmd).to_string(), params: params.clone() }); + } + reopen(&mut s); + assert_eq!(s.doc().unwrap().trust, Trust::FromFile); + let list = s.execute("control.list", json!({})).unwrap(); + for (i, (cmd, params)) in denied.iter().enumerate() { + // The action came back through the hidden name, so the check at press time is what holds. + assert_eq!(list[i]["action"], json!({"type": "command", "id": cmd, "params": params}), "{cmd}"); + let pressed = list[i]["id"].as_u64().unwrap(); + let before = undo_len(&s); + assert!(err(s.execute("control.press", json!({"id": pressed, "approve": true}))).contains(&format!("can't run {cmd}")), "{cmd}"); + assert_eq!(undo_len(&s), before, "{cmd}: nothing ran"); + } + assert!(!std::path::Path::new(&leak).exists()); + assert_eq!(v(&s, "A1"), Value::Empty, "the macro never ran"); +} + +/// Using a slider or spin button writes its linked cell through `cell.set`, so the cell's +/// protection governs it: a locked cell on a protected sheet refuses the change, and the control +/// keeps its value (the control's change and the cell write are one group, rolled back together). +/// A control's own Locked flag keeps it from being moved or edited, not from being used, as in +/// Excel, so an unlocked linked cell takes the value on a protected sheet. +#[test] +fn a_locked_linked_cell_on_a_protected_sheet_refuses_the_change_and_the_control_keeps_its_value() { + let mut s = s(); + let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1", "value": 40})).unwrap()); + s.execute("control.format", json!({"id": c, "locked": false})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + let before = undo_len(&s); + assert!(err(s.execute("control.setValue", json!({"id": c, "value": 60}))).contains("protected")); + assert_eq!((control(&s, c).value, v(&s, "B1")), (40, Value::Number(40.0)), "nothing is half-applied"); + assert_eq!(undo_len(&s), before, "no undo step is left behind"); + // Format Control: a new maximum that would pull the value into the cell is refused whole. + assert!(err(s.execute("control.format", json!({"id": c, "max": 30}))).contains("protected")); + assert_eq!((control(&s, c).max, control(&s, c).value), (100, 40)); + // Inserting a control with a value for a locked cell is refused and leaves no control behind. + assert!(err(s.execute("insert.spinButton", json!({"at": "D8", "link": "B2", "value": 5}))).contains("protected")); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1); + // The value the cell already holds writes nothing, so nothing is refused. + s.execute("control.setValue", json!({"id": c, "value": 40})).unwrap(); + assert_eq!(undo_len(&s), before); + // An unlocked linked cell takes the value on the protected sheet. + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("home.lockCell", json!({"range": "B1", "on": false})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + s.execute("control.setValue", json!({"id": c, "value": 60})).unwrap(); + assert_eq!((control(&s, c).value, v(&s, "B1")), (60, Value::Number(60.0))); +} + +/// A link to a cell on another sheet obeys that sheet's protection, not the control's sheet's. +#[test] +fn a_link_to_another_sheet_obeys_that_sheets_protection() { + let mut s = s(); + s.execute("home.insertSheet", json!({"name": "Data"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "Data!B1", "value": 10})).unwrap()); + assert_eq!(control(&s, c).link.as_deref(), Some("Data!$B$1")); + let data = |s: &Session| { + let wb = &s.doc().unwrap().wb; + wb.sheet(wb.sheet_index("Data").unwrap()).unwrap().value(gridcraft_core::CellRef::new(0, 1)) + }; + assert_eq!(data(&s), Value::Number(10.0)); + s.execute("review.protectSheet", json!({"sheet": "Data"})).unwrap(); + let before = undo_len(&s); + assert!(err(s.execute("control.setValue", json!({"id": c, "value": 20}))).contains("protected")); + assert_eq!((control(&s, c).value, data(&s)), (10, Value::Number(10.0))); + assert_eq!(undo_len(&s), before); + assert!(err(s.execute("insert.spinButton", json!({"at": "D8", "link": "Data!B2", "value": 5}))).contains("protected")); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1); + // Protecting the control's own sheet instead leaves the write to the other sheet alone. + s.execute("review.unprotectSheet", json!({"sheet": "Data"})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + s.execute("control.setValue", json!({"id": c, "value": 20})).unwrap(); + assert_eq!((control(&s, c).value, data(&s)), (20, Value::Number(20.0))); + // A link to a sheet the workbook doesn't have is refused with a clear message. + s.execute("review.unprotectSheet", json!({})).unwrap(); + assert!(err(s.execute("control.format", json!({"id": c, "link": "Nowhere!B1"}))).contains("isn't a cell of this workbook")); + assert_eq!(control(&s, c).link.as_deref(), Some("Data!$B$1")); +} From d8703162be6a6f447da9bbb094deaa9cc43284cf Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:06:51 -0500 Subject: [PATCH 28/55] XLSX: form controls find their properties parts through a relationship index Each listed control looked its properties part up with a scan of the sheet's relationships, so a sheet at the 10,000-control cap compared ids a hundred million times. The relationships are indexed by id once, as the VML shapes already were; the first of a repeated id still wins. --- crates/xlsx/src/controls.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index e45c98fe3..cf38d0908 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -406,7 +406,7 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels None => None, }; let shapes: Vec<&El> = vml.as_ref().map(|v| v.kids("shape").collect()).unwrap_or_default(); - // Looked up once per listed control: an index keeps a sheet of many controls linear. + // Looked up once per listed control: indexes keep a sheet of many controls linear. let mut by_number: HashMap = HashMap::with_capacity(shapes.len()); for s in &shapes { if let Some(n) = number_of(s) { @@ -414,6 +414,12 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels } } let shape_of = |n: u64| by_number.get(&n).copied(); + // The relationships likewise: one per control, looked up by id (the first of a repeated id wins, + // as a scan would find it). + let mut rel_by_id: HashMap<&str, &Rel> = HashMap::with_capacity(rels.len()); + for r in rels { + rel_by_id.entry(r.id.as_str()).or_insert(r); + } // Every shape the sheet lists, read here or not. let mut listed: HashSet = HashSet::new(); for ctl in controls.map(controls_in).unwrap_or_default() { @@ -424,7 +430,7 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels } let shape = shape_of(n); let vml_props = shape.and_then(props_from_vml); - let prop_part = ctl.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()); + let prop_part = ctl.attr("id").and_then(|id| rel_by_id.get(id)).map(|r| r.target.clone()); let file_props = match prop_part { Some(t) => cx.pkg.read_xml(&t).ok().flatten().filter(|e| e.name == "formControlPr").map(|e| props_from_ctrl_prop(&e)), None => None, From a6afa05309f5574fcd02b0bd78b51d8d39170ba9 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:23:58 -0500 Subject: [PATCH 29/55] Objects: a copied sheet's objects get free ids even when a file used the largest id `Workbook::next_object_id` returned one more than the largest id in use, saturating at `u32::MAX`. A workbook read from a file can carry an object with that id; every later insert (a chart, picture, shape, table or slicer) then got `u32::MAX` again, and so did each object of a copied sheet, whose ids also came from a saturating counter. Lookups and selection take the first object with an id, and `object.delete` removes every one. `Workbook::free_object_ids(count)` now returns distinct unused ids: those above the largest in use while they last, then the lowest free ones from 1. `next_object_id` takes the first of them, and the sheet copy takes one per copied object (and refuses if the id space were ever exhausted). The model test that recorded the saturation now checks the free ids instead, and a new one covers a file that used the largest ids: the free ids come from below, 100,000 of them distinct and in order. Test: object_ids_stay_unique_when_a_file_used_the_largest_id gives a shape id u32::MAX, inserts two shapes and copies the sheet; every shape id is distinct across both sheets. It fails without the fix (both new shapes get 4294967295). --- crates/engine/src/cmd/sheet/tabs.rs | 16 +++-- crates/engine/src/tests/objects.rs | 20 ++++++ crates/model/src/workbook.rs | 102 ++++++++++++++++++++++------ 3 files changed, 111 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index 2f87294bb..176c1d542 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -164,13 +164,15 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result { sh.tables.clear(); // table names must be unique sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original // Object ids are unique across the workbook (selection and the `{kind, id}` commands - // rely on it), so the copied objects get fresh ones. - let mut next = cx.wb.next_object_id(); - let mut fresh = || { - let id = next; - next = next.saturating_add(1); - id - }; + // rely on it), so the copied objects get fresh ones (free ones, even when a file used + // the largest id). + let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len(); + let ids = cx.wb.free_object_ids(count); + if ids.len() < count { + return Err(bad("sheet.move", "no free object ids are left for the copied objects")); + } + let mut ids = ids.into_iter(); + let mut fresh = || ids.next().unwrap_or(u32::MAX); sh.charts.iter_mut().for_each(|o| o.id = fresh()); sh.images.iter_mut().for_each(|o| o.id = fresh()); sh.shapes.iter_mut().for_each(|o| o.id = fresh()); diff --git a/crates/engine/src/tests/objects.rs b/crates/engine/src/tests/objects.rs index c39b14e11..e590f4851 100644 --- a/crates/engine/src/tests/objects.rs +++ b/crates/engine/src/tests/objects.rs @@ -281,3 +281,23 @@ fn bubble_sizes_follow_edits_like_the_other_series_references() { s.execute("sheet.rename", json!({"sheet": 0, "name": "Data"})).unwrap(); assert_eq!(sizes(&s), "Data!$F$2:$F$4", "a renamed sheet is renamed in the bubble sizes"); } + +/// A file can carry an object with the largest id. New objects and a copied sheet's objects then +/// take free ids instead of the largest one again, so `{kind, id}` still names one object. +#[test] +fn object_ids_stay_unique_when_a_file_used_the_largest_id() { + let mut s = s(); + let first = id_of(&s.execute("insert.shape", json!({"kind": "rectangle", "at": "B2"})).unwrap(), "shape"); + // The file's shape carries the largest id (set on the model, as a loaded file would). + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().shapes.iter_mut().find(|o| o.id == first).unwrap().id = u32::MAX; + let second = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "D2"})).unwrap(), "shape"); + let third = id_of(&s.execute("insert.shape", json!({"kind": "triangle", "at": "F2"})).unwrap(), "shape"); + assert!(second != u32::MAX && third != u32::MAX && second != third, "new shapes take free ids, not the largest again: {second}, {third}"); + s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap(); + let wb = &s.doc().unwrap().wb; + let mut ids: Vec = wb.sheets.iter().flat_map(|sh| sh.shapes.iter().map(|o| o.id)).collect(); + assert_eq!(ids.len(), 6); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), 6, "every shape id is distinct across both sheets"); +} diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 486b7019d..c1a7f2913 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -463,26 +463,54 @@ impl Workbook { } None } + /// A free id for a new chart, picture, shape, table or slicer: one more than the largest in + /// use, or, when that is `u32::MAX` (a file can carry any id), the lowest free one. pub fn next_object_id(&self) -> u32 { - let mut m = 0; - for s in &self.sheets { - for c in &s.charts { - m = m.max(c.id); - } - for c in &s.images { - m = m.max(c.id); - } - for c in &s.shapes { - m = m.max(c.id); - } - for c in &s.tables { - m = m.max(c.id); - } - for c in &s.slicers { - m = m.max(c.id); - } + self.free_object_ids(1).first().copied().unwrap_or(u32::MAX) + } + + /// `count` distinct ids no object in the workbook uses, sorted: the ones above the largest id + /// in use while they last, then the lowest free ones. Fewer come back only when the id space + /// is exhausted. + pub fn free_object_ids(&self, count: usize) -> Vec { + let mut used: Vec = self + .sheets + .iter() + .flat_map(|s| { + s.charts + .iter() + .map(|o| o.id) + .chain(s.images.iter().map(|o| o.id)) + .chain(s.shapes.iter().map(|o| o.id)) + .chain(s.tables.iter().map(|o| o.id)) + .chain(s.slicers.iter().map(|o| o.id)) + }) + .collect(); + used.sort_unstable(); + used.dedup(); + let max = used.last().copied().unwrap_or(0); + let mut out = Vec::with_capacity(count.min(1 << 16)); + let mut next = max.checked_add(1); + while out.len() < count + && let Some(id) = next + { + out.push(id); + next = id.checked_add(1); } - m.saturating_add(1) + // Past the top: the gaps below the largest id, from 1 (0 is never an object's id). + let mut taken = used.iter().copied().peekable(); + let mut id = 1u32; + while out.len() < count && id < max { + while taken.next_if(|&t| t < id).is_some() {} + if taken.peek() == Some(&id) { + taken.next(); + } else { + out.push(id); + } + id += 1; + } + out.sort_unstable(); + out } } @@ -534,7 +562,7 @@ mod tests { } #[test] - fn object_ids_saturate_instead_of_overflowing() { + fn object_ids_never_overflow_or_repeat() { let mut wb = Workbook::new(); let image = |id| crate::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() }; assert_eq!(wb.next_object_id(), 1); @@ -545,6 +573,40 @@ mod tests { if let Some(sh) = wb.sheet_mut(0) { sh.images.push(image(u32::MAX)); } - assert_eq!(wb.next_object_id(), u32::MAX, "an id read from a file at the maximum saturates"); + // An id read from a file at the maximum: the lowest free id, not the maximum again. + assert_eq!(wb.next_object_id(), 1); + if let Some(sh) = wb.sheet_mut(0) { + sh.images.push(image(1)); + sh.images.push(image(3)); + } + assert_eq!(wb.next_object_id(), 2); + assert_eq!(wb.free_object_ids(4), vec![2, 4, 5, 6], "the gaps, skipping 1, 3 and 7"); + let mut wb = Workbook::new(); + if let Some(sh) = wb.sheet_mut(0) { + sh.images.push(image(u32::MAX - 1)); + } + assert_eq!(wb.free_object_ids(3), vec![1, 2, u32::MAX], "the top first, then from 1"); + } + + /// A file can use the largest id and the one below it: nothing is free above them, so the free + /// ids are the lowest unused ones from 1 (0 is never an object's id), and a large request is + /// served in time linear in the count and the ids in use. + #[test] + fn free_object_ids_come_from_below_when_nothing_is_free_above() { + let mut wb = Workbook::new(); + let image = |id| crate::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() }; + if let Some(sh) = wb.sheet_mut(0) { + sh.images.push(image(u32::MAX)); + sh.images.push(image(u32::MAX - 1)); + sh.images.push(image(0)); + } + assert_eq!(wb.free_object_ids(3), vec![1, 2, 3]); + assert_eq!(wb.next_object_id(), 1); + // The 100,000 lowest free ids: distinct, ascending, none of them in use. + let many = wb.free_object_ids(100_000); + assert_eq!(many.len(), 100_000); + assert!(many.windows(2).all(|w| w[0] < w[1]), "ascending without repeats"); + assert!(!many.contains(&0) && !many.contains(&(u32::MAX - 1)) && !many.contains(&u32::MAX)); + assert_eq!(wb.free_object_ids(0), Vec::::new()); } } From 4219720e1391a25d17019b3205cd5179125fce17 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:30:26 -0500 Subject: [PATCH 30/55] UI tests: the button test closes the control's menu before the press it checks The right-click that selects the button also opens its menu at the pointer, so the plain click that follows could have gone to the menu instead of the button. The test now closes the menu with Escape and asserts it is closed before clicking; the dialogs test asserts that choosing a menu item closes it. --- crates/ui-egui/tests/it/form_controls.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/ui-egui/tests/it/form_controls.rs b/crates/ui-egui/tests/it/form_controls.rs index 09b57c4a8..ad13818a1 100644 --- a/crates/ui-egui/tests/it/form_controls.rs +++ b/crates/ui-egui/tests/it/form_controls.rs @@ -225,6 +225,11 @@ fn a_button_runs_its_macro_and_cmd_or_right_click_only_selects() { h.run_steps(2); assert_eq!(h.state().selected_object, Some(ObjRef::new(ObjKind::Control, ids.button))); assert_eq!(cell(&h, "H1"), Value::Empty); + assert_eq!(h.state().grid.object_menu.map(|(_, kind, id)| (kind, id)), Some(("control", ids.button))); + // Escape closes the menu (which would otherwise take the next click at the pointer), then + // the selection is dropped, so the click below reaches the button itself. + key(&mut h, Key::Escape); + assert!(h.state().grid.object_menu.is_none(), "Escape closes the control's menu"); h.state_mut().selected_object = None; // A plain click runs the macro, as one undo step. let before = undo_len(&h); @@ -393,6 +398,7 @@ fn assign_action_and_format_control_dialogs() { h.get_by_label("Assign Action…").click(); h.run_steps(2); assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("assignAction")); + assert!(h.state().grid.object_menu.is_none(), "choosing an item closes the menu"); // The dialog settles its size (and so its place) over the first frames. h.run_steps(30); let radio = h.get_by_label("Run a command").rect(); From 18cb02cbd9a6a99e2fd169b94cfdeff7fe92ac2e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:30:26 -0500 Subject: [PATCH 31/55] XLSX: a control properties part is read once, however many controls name it Each listed control read and parsed its properties part again, and every read is charged to the package's 1 GB budget, so a file whose controls all named one part spent the budget on it: the controls after that were dropped and the parts read later (notes, threaded comments) failed with TooLarge. The parsed part is now cached by target; a part that fails to read or isn't a formControlPr is cached as nothing and not read again. Test: 13,000 controls naming one 200 KB part keep the sheet's 10,000 controls and the note read after them. --- crates/xlsx/src/controls.rs | 14 +++++++-- crates/xlsx/src/tests/controls_tests.rs | 41 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index cf38d0908..75c8032df 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -249,7 +249,7 @@ pub const SHAPETYPE: &str = ", link: Option, @@ -420,6 +420,11 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels for r in rels { rel_by_id.entry(r.id.as_str()).or_insert(r); } + // Each properties part is read and parsed once, however many controls name it: every read is + // charged to the package's budget, so a file whose controls all point at one part would + // otherwise spend it on that part alone. A part that fails to read, or isn't a + // `formControlPr`, is remembered as nothing and not read again. + let mut props_by_part: HashMap<&str, Option> = HashMap::new(); // Every shape the sheet lists, read here or not. let mut listed: HashSet = HashSet::new(); for ctl in controls.map(controls_in).unwrap_or_default() { @@ -430,9 +435,12 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels } let shape = shape_of(n); let vml_props = shape.and_then(props_from_vml); - let prop_part = ctl.attr("id").and_then(|id| rel_by_id.get(id)).map(|r| r.target.clone()); + let prop_part = ctl.attr("id").and_then(|id| rel_by_id.get(id)).map(|r| r.target.as_str()); let file_props = match prop_part { - Some(t) => cx.pkg.read_xml(&t).ok().flatten().filter(|e| e.name == "formControlPr").map(|e| props_from_ctrl_prop(&e)), + Some(t) => props_by_part + .entry(t) + .or_insert_with(|| cx.pkg.read_xml(t).ok().flatten().filter(|e| e.name == "formControlPr").map(|e| props_from_ctrl_prop(&e))) + .clone(), None => None, }; let pr = ctl.child("controlPr"); diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index b58d1f200..7aedc51d3 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -333,3 +333,44 @@ fn controls_come_from_the_legacy_drawing_not_the_header_and_footer_drawing() { assert_eq!(c.len(), 3, "{c:?}"); assert_eq!(c[2].text, "Go"); } + +/// Every listed control that names the same properties part shares one read of it. Each read is +/// charged to the package's 1 GB budget, so 10,000 reads of a 200 KB part would spend it, drop the +/// controls after that, and fail the parts read after the controls (here the sheet's notes). A +/// hostile file can list that many controls against one part. +#[test] +fn controls_naming_one_properties_part_read_it_once() { + let n = 13_000; + let mut controls = String::from(r#""#); + for i in 0..n { + let _ = std::fmt::Write::write_fmt( + &mut controls, + format_args!( + r#"10{i}020{}0"#, + 2000 + i, + i + 1 + ), + ); + } + controls.push_str(""); + let rels = r#""#; + // 200 KB of attribute whitespace: read once per control, 10,000 reads would be 2 GB. + let prop_a = format!( + r#""#, + " ".repeat(200 * 1024) + ); + let notes = r#"Annread after the controls"#; + let bytes = minimal( + &format!("{controls}"), + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/a.xml", &prop_a), ("xl/comments1.xml", notes)], + "", + "", + ); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 10_000, "the cap of one sheet, every control read from the one part"); + assert!(sh.controls.iter().all(|c| { + c.kind == ControlKind::Slider && c.link.as_deref() == Some("$B$1") && (c.min, c.max, c.step, c.page, c.value) == (0, 200, 5, 20, 45) + })); + assert_eq!(sh.comments.len(), 1, "a part read after the controls still reads: {:?}", report.warnings); +} From 834fa94f854bd62b90a0adc24d35685730682738 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:33:29 -0500 Subject: [PATCH 32/55] Objects: tests that a copied sheet's form controls take free ids and that a control's id stays in use A sheet copy renumbers its form controls from the same free ids as its charts, pictures, shapes and slicers, so when a file's slider carries the largest id the copies still get distinct ids; free_object_ids counts the controls' ids as used, so 3 and u32::MAX held by controls are skipped. --- crates/engine/src/tests/objects.rs | 20 ++++++++++++++++++++ crates/model/src/workbook.rs | 13 +++++++++++++ 2 files changed, 33 insertions(+) diff --git a/crates/engine/src/tests/objects.rs b/crates/engine/src/tests/objects.rs index 2a96841f5..77425e6c4 100644 --- a/crates/engine/src/tests/objects.rs +++ b/crates/engine/src/tests/objects.rs @@ -311,3 +311,23 @@ fn object_ids_stay_unique_when_a_file_used_the_largest_id() { ids.dedup(); assert_eq!(ids.len(), 6, "every shape id is distinct across both sheets"); } + +/// A copied sheet's form controls take free ids like its other objects: a file's control can +/// carry the largest id, and the ids the copy asks for count the controls. +#[test] +fn a_copied_sheets_form_controls_take_free_ids_too() { + let mut s = s(); + let slider = id_of(&s.execute("insert.slider", json!({"at": "B2", "link": "$A$1"})).unwrap(), "control"); + s.execute("insert.button", json!({"at": "B6"})).unwrap(); + s.execute("insert.shape", json!({"kind": "rectangle", "at": "D2"})).unwrap(); + // The file's slider carries the largest id (set on the model, as a loaded file would). + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().controls.iter_mut().find(|o| o.id == slider).unwrap().id = u32::MAX; + s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap(); + let wb = &s.doc().unwrap().wb; + let mut ids: Vec = wb.sheets.iter().flat_map(|sh| sh.controls.iter().map(|o| o.id).chain(sh.shapes.iter().map(|o| o.id))).collect(); + assert_eq!(ids.len(), 6); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), 6, "every control and shape id is distinct across both sheets: {ids:?}"); + assert_eq!(ids.iter().filter(|&&i| i == u32::MAX).count(), 1, "only the file's slider keeps the largest id"); +} diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 69f7a2992..5df18b48c 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -593,6 +593,19 @@ mod tests { assert_eq!(wb.free_object_ids(3), vec![1, 2, u32::MAX], "the top first, then from 1"); } + /// A form control's id is in use like any object's: the free ids skip it, also when it is the + /// largest id a file could carry. + #[test] + fn free_object_ids_skip_a_form_controls_id() { + let mut wb = Workbook::new(); + if let Some(sh) = wb.sheet_mut(0) { + sh.controls.push(crate::FormControl { id: 3, ..Default::default() }); + sh.controls.push(crate::FormControl { id: u32::MAX, ..Default::default() }); + } + assert_eq!(wb.free_object_ids(4), vec![1, 2, 4, 5], "3 and the largest id are taken"); + assert_eq!(wb.next_object_id(), 1); + } + /// A file can use the largest id and the one below it: nothing is free above them, so the free /// ids are the lowest unused ones from 1 (0 is never an object's id), and a large request is /// served in time linear in the count and the ids in use. From 099349d91deb84e3cc0532597db0e42d11263206 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:44:41 -0500 Subject: [PATCH 33/55] Form controls: a macro assignment removed in another application stays removed on reopen load_action_names gave every kept action back to a button the file assigned no macro. A button that ran a macro with a fixed input range or an output cell, whose macro another application removed while keeping the button's name and place, so came back with the assignment. Now a kept macro action comes back only onto a button the file still assigns that macro, and a kept command only onto a button the file assigns no macro, as the function's comment already said. Tests: a_macro_assignment_removed_in_another_application_stays_removed (the saved file with the controlPr macro attribute and the VML FmlaMacro removed reopens with no action and no hidden name; unedited, with the whole action), a_macro_assigned_in_another_application_replaces_a_stored_command (the file's macro wins over a kept command), and the cleared case in a_kept_button_action_comes_back_only_on_the_same_button. macros.md and file-format-parity.md describe the hidden name and the reopen rule. --- crates/engine/src/cmd/controls.rs | 2 +- crates/engine/src/tests/controls.rs | 103 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 4 +- docs/macros.md | 12 +++- 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index f68effc9e..773262480 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -627,7 +627,7 @@ pub(crate) fn load_action_names(wb: &mut Workbook) { && text("name").is_none_or(|n| n == c.display_name()) && text("at").is_none_or(|a| a == c.anchor.cell.a1()) && match (&c.action, &action) { - (None, _) => true, + (None, ControlAction::Command { .. }) => true, (Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept), _ => false, } diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs index ba770b598..8dce29fcd 100644 --- a/crates/engine/src/tests/controls.rs +++ b/crates/engine/src/tests/controls.rs @@ -420,6 +420,109 @@ fn a_kept_button_action_comes_back_only_on_the_same_button() { "{i}" ); } + // Another application cleared the macro of "Fixed": the stored action (with its fixed input) + // does not bring the assignment back. The command on a button given no macro still does. + let mut cleared = kept(&stored); + cleared.sheet_mut(0).unwrap().controls[2].action = None; + load_action_names(&mut cleared); + assert_eq!(cleared.sheets[0].controls[2].action, None, "the removed assignment stays removed"); + assert_eq!(cleared.sheets[0].controls[0].action, actions(&wb)[0], "the command comes back"); +} + +/// The saved xlsx bytes of `s`'s workbook. +fn saved_xlsx(s: &mut Session) -> Vec { + let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap() +} + +/// `bytes` with `edit` applied to the text of every part, as another application that rewrote +/// those parts would leave the file. +fn edit_parts(bytes: &[u8], mut edit: impl FnMut(&str, String) -> String) -> Vec { + use std::io::{Cursor, Read, Write}; + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..zip.len() { + let mut part = zip.by_index(i).unwrap(); + let name = part.name().to_string(); + let mut data = Vec::new(); + part.read_to_end(&mut data).unwrap(); + let data = match String::from_utf8(data) { + Ok(text) => edit(&name, text).into_bytes(), + Err(e) => e.into_bytes(), + }; + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&data).unwrap(); + } + out.finish().unwrap().into_inner() +} + +/// Opens `bytes` as `name`, checks the hidden name of the button actions is taken back out, and +/// lists the controls. +fn open_and_list(s: &mut Session, name: &str, bytes: &[u8]) -> Json { + s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(bytes)})).unwrap(); + assert!(s.doc().unwrap().wb.names.iter().all(|n| !n.name.contains("control_actions")), "{name}: the hidden name is taken back out"); + s.execute("control.list", json!({})).unwrap() +} + +/// The macro reference xlsx writes for a button that runs `Mark`. +const MARK: &str = "[0]!Mark"; + +/// Another application removed a button's macro assignment and kept the button with its name and +/// place: the hidden name's copy of the action (with the fixed input and the output cell) does not +/// bring the assignment back. The same file, unedited, reopens with the whole action. +#[test] +fn a_macro_assignment_removed_in_another_application_stays_removed() { + let mut s = s(); + s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}], "output": {"onConflict": "overwrite"}})).unwrap(); + s.execute("insert.button", json!({"at": "D2", "text": "Run", "macro": "Mark", "input": "F1:G2", "output": "H1"})).unwrap(); + let before = s.execute("control.list", json!({})).unwrap()[0].clone(); + let full = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Fixed("F1:G2".into()), output: Some("H1".into()) }; + assert_eq!(before["action"], serde_json::to_value(&full).unwrap()); + let bytes = saved_xlsx(&mut s); + // xlsx assigns the macro in the sheet's `` and in the VML shape's + // `` (the parts the reader takes it from); clearing the assignment drops both. + let (attr, fmla) = (format!(" macro=\"{MARK}\""), format!("{MARK}")); + let mut edited = Vec::new(); + let cleared = edit_parts(&bytes, |name, text| { + if text.contains(&attr) || text.contains(&fmla) { + edited.push(name.to_string()); + } + text.replace(&attr, "").replace(&fmla, "") + }); + edited.sort(); + assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]); + let list = open_and_list(&mut s, "cleared.xlsx", &cleared); + assert_eq!(list.as_array().unwrap().len(), 1); + for key in ["kind", "name", "at", "text"] { + assert_eq!(list[0][key], before[key], "{key}: the same button"); + } + assert_eq!(list[0].get("action"), None, "the removed assignment stays removed"); + // Unedited, the file still assigns the macro, and the action comes back whole. + let list = open_and_list(&mut s, "kept.xlsx", &bytes); + assert_eq!(list[0]["action"], before["action"]); +} + +/// Another application assigned a macro to a button that ran a command (kept in the hidden name): +/// the file's macro wins, as it does when a macro button is given another macro. +#[test] +fn a_macro_assigned_in_another_application_replaces_a_stored_command() { + let mut s = s(); + s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap(); + s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap(); + let bytes = saved_xlsx(&mut s); + let (pr, data) = (""); + let mut edited = Vec::new(); + let assigned = edit_parts(&bytes, |name, text| { + if text.contains(pr) || text.contains(data) { + edited.push(name.to_string()); + } + text.replace(pr, &format!("{MARK}")) + }); + edited.sort(); + assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]); + let list = open_and_list(&mut s, "assigned.xlsx", &assigned); + let plain = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Selection, output: None }; + assert_eq!(list[0]["action"], serde_json::to_value(&plain).unwrap(), "the file's macro, not the stored command"); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 697dfc696..1cc453a28 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -76,7 +76,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), form controls (buttons, scroll bars and spin buttons: control properties parts, `` and VML `x:ClientData`; a VML-only control is read too), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), other form controls (check boxes, list boxes, drop-downs; written in the same `` element and VML drawing as the modelled ones), ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), form controls (buttons, scroll bars and spin buttons: control properties parts, `` and VML `x:ClientData`; a VML-only control is read too; a button's command, fixed input range or output cell travels in a hidden defined name and comes back only onto the same button while the file still assigns its macro, or none for a command), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), other form controls (check boxes, list boxes, drop-downs; written in the same `` element and VML drawing as the modelled ones), ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), pictures in headers and footers (`&G`, #1257: their VML drawing and images are written back unchanged; they are not shown, and can't be inserted), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | @@ -145,7 +145,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| -| 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through | +| 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, ``, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) | | 2026-11-01 | minor | Chart data tables preserve showKeys, showHorzBorder, showVertBorder and showOutline through native c:dTable XLSX import/export; synthetic tests cover all 16 flag combinations and boolean defaults. Excel-side verification remains outstanding. | | 2026-11-01 | minor | XLSX reads and writes Page Setup's page order: `` for Over, then down, and no attribute for Down, then over (the default), as Excel writes them; PDF export and printing number and order the pages by it | | 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) | diff --git a/docs/macros.md b/docs/macros.md index 11e1030a8..8073b2fcd 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-11-02 · **Change:** minor (form buttons run macros; `macro.run` takes `output`; earlier: a close that waits on the step editor's question runs in the editor's window, and a further close asked meanwhile waits for the answer or is refused; earlier: closing a workbook or quitting asks about the step editor's unsaved draft first; recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) +> **Last updated:** 2026-11-02 · **Change:** minor (form buttons run macros, `macro.run` takes `output`, and a button action kept in a hidden name comes back on reopen only while the file still agrees with it; earlier: a close that waits on the step editor's question runs in the editor's window, and a further close asked meanwhile waits for the answer or is refused; earlier: closing a workbook or quitting asks about the step editor's unsaved draft first; recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro @@ -265,6 +265,16 @@ default) or always on one range (`input: "B2:D9"`), with an optional output cell a step does, inside the scripts' file sandbox unless it is an approved file command, so a button in a workbook opened from a file asks before it runs anything. +In xlsx a button carries its macro as Excel writes it (`macro="[0]!Name"`, a macro run on the +selection). A command, a fixed input range or an output cell has no place there and is kept in a +hidden defined name (`_gc_control_actions_`), keyed by the sheet, the button's place, name and +anchor cell. When the file is opened the hidden name is removed and each kept action goes back +onto its button only when the file still agrees with it: a kept macro action needs the file to +assign that macro still, so an assignment removed in another application stays removed and a +button given another macro keeps that one; a kept command needs the file to assign no macro. A +button whose name, anchor cell or place changed, or which another application removed, gets no +kept action. + Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden defined names (`_gc_macro__`, the macro's JSON split into string literals within Excel's limits), which other spreadsheet applications keep but do not run. A visible name of that shape From 21a27a76be53c7dfc227284210358c976d16e99e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:57:49 -0500 Subject: [PATCH 34/55] Objects: main's chart data table compiles with the object selection and the model's object test Two leftovers of the merge of origin/main (25881a8e), neither a textual conflict: `Chart` gained a `data_table` field, which the `push_object` helper of the model's object test did not set (it is `None`, as in `insert.chart`); and chart_data_table.rs set the `selected_chart` field this branch replaced by `selected_object`. The test selects its chart as a floating object, as the branch's other tests do. --- crates/model/src/sheet.rs | 1 + crates/ui-egui/tests/it/chart_data_table.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 9956500d0..b97372097 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -718,6 +718,7 @@ mod tests { series: vec![], legend: LegendPos::default(), data_labels: false, + data_table: None, gridlines: false, style: 0, x_title: None, diff --git a/crates/ui-egui/tests/it/chart_data_table.rs b/crates/ui-egui/tests/it/chart_data_table.rs index aedb1ebfc..a94e5a16b 100644 --- a/crates/ui-egui/tests/it/chart_data_table.rs +++ b/crates/ui-egui/tests/it/chart_data_table.rs @@ -1,6 +1,7 @@ use egui::accesskit::Role; use egui_kittest::kittest::Queryable; use gridcraft_engine::Session; +use gridcraft_engine::model::{ObjKind, ObjRef}; use serde_json::json; #[test] @@ -10,7 +11,7 @@ fn format_chart_pane_controls_the_engine_data_table() { session.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap(); let id = session.execute("insert.chart", json!({"range": "A1:C3", "type": "column", "at": "E3"})).unwrap()["chart"].as_u64().unwrap() as u32; let mut h = crate::support::harness(session, 4); - h.state_mut().selected_chart = Some(id); + h.state_mut().selected_object = Some(ObjRef::new(ObjKind::Chart, id)); h.state_mut().grid.pane = Some("formatChart".into()); h.run_steps(2); h.get_by_role_and_label(Role::DisclosureTriangle, "Data Table").click(); From 381a1213394bd6d5649277af0e59650293af85fe Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:52:03 -0500 Subject: [PATCH 35/55] Objects: object.move and chart.set keep a size a file made larger than 10,000 `object.move` clamped `width` and `height` to 10,000 points, the most it gives a new size, while the XLSX reader accepts anchors far past that. A resize of a picture a file made 12,000 wide passed 11,990 through the clamp and jumped to 10,000; `chart.set`, whose Format Chart pane sends the unchanged dimension back with the edited one, clamped the same way. Each dimension is now capped at 10,000 or the object's current size, whichever is larger (`size_cap`): such an object keeps its size when it is moved, shrinks by what a resize asks and never grows further, and a hostile size is still capped. The params doc of `object.move` and the control protocol notes say so. Tests: an_oversized_imported_object_keeps_its_size_when_moved_and_shrinks_only_by_a_resize makes a picture 12,000 by 300, writes and reads it back as a file, then moves it without a size (12,000 stays), asks 12,010 (stays 12,000), 11,990 (11,990, no jump) and 50,000 by 50,000 (11,990 by 10,000). chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit does the same through `chart.set`. Both fail without the fix (width 10,000). --- crates/engine/src/cmd/insert.rs | 22 +++++++++--- crates/engine/src/tests/objects.rs | 58 ++++++++++++++++++++++++++++++ docs/control-protocol.md | 3 ++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 479d15b9c..940d24873 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -152,7 +152,7 @@ pub fn specs() -> Vec { "Move Object", [], None, - "{kind: chart|image|shape|slicer, id, at?: \"C3\", dx?, dy?, width?, height?}", + "{kind: chart|image|shape|slicer, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}", has_doc, move_object ), @@ -933,10 +933,10 @@ fn chart_set(s: &mut Session, p: &Json) -> Result { c.anchor.cell = at; } if let Some(w) = f64_param(p, "width") { - c.anchor.width = w.clamp(20.0, 10000.0) as f32; + c.anchor.width = w.clamp(20.0, size_cap(c.anchor.width)) as f32; } if let Some(h) = f64_param(p, "height") { - c.anchor.height = h.clamp(20.0, 10000.0) as f32; + c.anchor.height = h.clamp(20.0, size_cap(c.anchor.height)) as f32; } if let Some(v) = f64_param(p, "dx") { c.anchor.dx = v as f32; @@ -1248,6 +1248,18 @@ fn delete_object(s: &mut Session, p: &Json) -> Result { /// Largest offset (points) `object.move` accepts from the anchor cell. const MAX_OBJECT_OFFSET: f64 = 100_000.0; +/// Largest width or height (points) `object.move` and `chart.set` give an object that is not +/// already larger: see [`size_cap`]. +const MAX_OBJECT_SIZE: f64 = 10_000.0; + +/// The most a command may make a dimension that is `current` now: [`MAX_OBJECT_SIZE`], or the +/// current size when a file made the object larger (the XLSX reader accepts anchors far past the +/// limit). Such an object keeps its size when it is moved and shrinks by what a resize asks, but +/// it never grows further, so hostile input is still capped. Never NaN: `f64::max` drops one. +fn size_cap(current: f32) -> f64 { + MAX_OBJECT_SIZE.max(f64::from(current)) +} + fn move_object(s: &mut Session, p: &Json) -> Result { let (sheet, o) = object_param(s, p, "object.move")?; let at = cell_param(s, p, "at")?; @@ -1263,10 +1275,10 @@ fn move_object(s: &mut Session, p: &Json) -> Result { a.dy = v.clamp(-MAX_OBJECT_OFFSET, MAX_OBJECT_OFFSET) as f32; } if let Some(v) = f64_param(p, "width") { - a.width = v.clamp(1.0, 10000.0) as f32; + a.width = v.clamp(1.0, size_cap(a.width)) as f32; } if let Some(v) = f64_param(p, "height") { - a.height = v.clamp(0.0, 10000.0) as f32; + a.height = v.clamp(0.0, size_cap(a.height)) as f32; } Ok(Json::Null) }) diff --git a/crates/engine/src/tests/objects.rs b/crates/engine/src/tests/objects.rs index e590f4851..c10844fb8 100644 --- a/crates/engine/src/tests/objects.rs +++ b/crates/engine/src/tests/objects.rs @@ -301,3 +301,61 @@ fn object_ids_stay_unique_when_a_file_used_the_largest_id() { ids.dedup(); assert_eq!(ids.len(), 6, "every shape id is distinct across both sheets"); } + +/// A file's anchors are not held to the 10,000 points `object.move` gives a new size. A picture +/// a file made 12,000 wide keeps that width when it is moved, shrinks by what a resize asks +/// (11,990 stays 11,990, no jump to 10,000) and never grows further; a dimension the file kept +/// within the limit is still capped there. +#[test] +fn an_oversized_imported_object_keeps_its_size_when_moved_and_shrinks_only_by_a_resize() { + let mut s = s(); + let image = super::add_image(&mut s, "B2", AnchorMode::MoveOnly); + // The file's picture is 12,000 by 300: set on the model, then written and read back as a file. + { + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + let a = wb.sheet_mut(0).unwrap().object_anchor_mut(ObjRef::new(ObjKind::Image, image)).unwrap(); + a.width = 12_000.0; + a.height = 300.0; + } + let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + s.execute("file.open", json!({"name": "wide.xlsx", "base64": saved["base64"]})).unwrap(); + let o = ObjRef::new(ObjKind::Image, sheet(&s, 0).images[0].id); + let size = |s: &Session| { + let a = sheet(s, 0).object_anchor(o).unwrap(); + (a.width.round(), a.height.round()) + }; + assert_eq!(size(&s), (12_000.0, 300.0), "the reader keeps the file's size"); + s.execute("object.move", json!({"kind": "image", "id": o.id, "at": "D5", "dx": 3, "dy": 4})).unwrap(); + let a = sheet(&s, 0).object_anchor(o).unwrap(); + assert_eq!((a.cell, a.dx, a.dy), (at("D5"), 3.0, 4.0)); + assert_eq!(size(&s), (12_000.0, 300.0), "a move without a size keeps the file's size"); + s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 12_010})).unwrap(); + assert_eq!(size(&s), (12_000.0, 300.0), "the object does not grow past what the file gave"); + s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 11_990})).unwrap(); + assert_eq!(size(&s), (11_990.0, 300.0), "a resize shrinks by what it asks, with no jump to 10,000"); + s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 50_000, "height": 50_000})).unwrap(); + assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped: at the current width, and at 10,000 for a height the file kept within it"); +} + +/// `chart.set` follows the same rule: the Format Chart pane's Size fields send the unchanged +/// dimension back with the edited one, so a chart a file made 12,000 wide keeps that width when +/// its height is edited, shrinks by what is asked and never grows further. +#[test] +fn chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap(); + let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:A3", "type": "column", "at": "C2"})).unwrap(), "chart"); + // The file's chart is 12,000 wide (set on the model, as a loaded file would). + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + wb.sheet_mut(0).unwrap().charts.iter_mut().find(|c| c.id == chart).unwrap().anchor.width = 12_000.0; + let size = |s: &Session| { + let c = sheet(s, 0).charts.iter().find(|c| c.id == chart).unwrap(); + (c.anchor.width, c.anchor.height) + }; + s.execute("chart.set", json!({"chart": chart, "width": 12_000, "height": 250})).unwrap(); + assert_eq!(size(&s), (12_000.0, 250.0), "the unchanged width sent back with a new height stays"); + s.execute("chart.set", json!({"chart": chart, "width": 11_990})).unwrap(); + assert_eq!(size(&s), (11_990.0, 250.0), "a resize shrinks by what it asks"); + s.execute("chart.set", json!({"chart": chart, "width": 50_000, "height": 50_000})).unwrap(); + assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped at the current width and at 10,000"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b5822da64..9c3d0bc13 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -94,6 +94,9 @@ them as their own parameters. `ui.inspect` reports the selected floating object as `selectedObject: {kind, id}` (or `null`), where `kind` is `chart`, `image`, `shape` or `slicer`; the same names go in the `kind` parameter of `object.delete`, `object.move`, `object.setAnchorMode` and the `arrange.*` commands. +`object.move` and `chart.set` cap `width` and `height` at 10,000 points, or at the object's +current size when a file made it larger: such an object keeps its size when it is moved and +shrinks by what a resize asks, but does not grow further. `selectedChart` remains for existing clients: the id when the selection is a chart, otherwise `null`. With an object selected, `ui.key {key: "Delete"}` (or Backspace) deletes the object instead of clearing cells, and Escape clears the selection. From af28f01bfdf7c913420050efa14d9e846f725a6e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:52:03 -0500 Subject: [PATCH 36/55] Objects: dragging an object's body sends its position only, not its size `drag_object` sent `width` and `height` with every step of a body drag, so moving a picture a file made 12,000 wide shrank it to the 10,000 that `object.move` gives a new size. Only a handle drag sends a size now; a body move leaves the engine's size alone. Tests (real input): dragging_the_body_of_an_oversized_imported_object_keeps_its_size reopens the workbook with a 12,000 by 60 picture, drags its body by 30 by 25 and checks the size is untouched, the picture moved with the pointer and the journaled `object.move` carries no width or height; it fails without the fix (the journaled command carries both, and without the engine's cap the picture is 10,000 wide). a_handle_drag_on_an_oversized_imported_object_resizes_by_the_drag_only moves the left handle 10 px right: the picture is 10 points narrower in the window and keeps a width near 12,000, with no jump to the limit. --- crates/ui-egui/src/grid/interact.rs | 12 +++++- crates/ui-egui/tests/it/objects.rs | 62 +++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 4b7adf587..5de25fd53 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -974,11 +974,19 @@ fn drag_object(app: &mut SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) { }; // Re-anchor at the cell under the new top-left. let nc = CellRef::new(sh.row_at(y0 as f64), sh.col_at(x0 as f64)); - let params = json!({ + let mut params = json!({ "kind": obj.kind.name(), "id": obj.id, "at": nc.a1(), "dx": x0 - sh.col_left(nc.col) as f32, "dy": y0 - sh.row_top(nc.row) as f32, - "width": x1 - x0, "height": y1 - y0, }); + // Only a handle changes the size. A body move sends none, so the engine keeps whatever the + // object has: a file can make it larger than `object.move` accepts for a new size, and + // moving it must not shrink it to that limit. + if handle.is_some() + && let Some(p) = params.as_object_mut() + { + p.insert("width".into(), json!(x1 - x0)); + p.insert("height".into(), json!(y1 - y0)); + } let revision = app.session.active().map(|d| d.revision); let _ = app.session.run("object.move", params); if stepped { diff --git a/crates/ui-egui/tests/it/objects.rs b/crates/ui-egui/tests/it/objects.rs index e3382352d..e0c272817 100644 --- a/crates/ui-egui/tests/it/objects.rs +++ b/crates/ui-egui/tests/it/objects.rs @@ -362,3 +362,65 @@ fn dragging_an_unselected_object_by_its_edge_moves_it() { assert!((after.size() - before.size()).length() < 0.5, "not resized: {before:?} {after:?}"); assert!((after.min - before.min - vec2(30.0, 20.0)).length() < 1.0, "moved with the pointer: {before:?} {after:?}"); } + +fn anchor(h: &Harness, o: ObjRef) -> gridcraft_engine::model::Anchor { + h.state().session.doc().unwrap().wb.active().unwrap().object_anchor(o).unwrap() +} + +/// Makes the fixture's picture 12,000 by 60 and reopens the workbook from its XLSX bytes, the way +/// a file can carry an object wider than the 10,000 points `object.move` gives a new size. +/// Returns the picture as reopened. +fn imported_oversized_picture(h: &mut Harness, o: &Objects) -> ObjRef { + { + let wb = std::sync::Arc::make_mut(&mut h.state_mut().session.doc_mut().unwrap().wb); + let a = wb.sheet_mut(0).unwrap().object_anchor_mut(o.image).unwrap(); + a.width = 12_000.0; + a.height = 60.0; + } + let saved = h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + h.state_mut().run("file.open", json!({"name": "wide.xlsx", "base64": saved["base64"]})).unwrap(); + h.run_steps(4); + let pic = ObjRef::new(ObjKind::Image, h.state().session.doc().unwrap().wb.active().unwrap().images[0].id); + let a = anchor(h, pic); + assert_eq!((a.width.round(), a.height.round()), (12_000.0, 60.0), "the reader keeps the file's size: {a:?}"); + pic +} + +/// Dragging the body of such a picture moves it and sends no size, so nothing shrinks it to the +/// limit on the way. +#[test] +fn dragging_the_body_of_an_oversized_imported_object_keeps_its_size() { + let (mut h, o) = fixture(); + let pic = imported_oversized_picture(&mut h, &o); + let before = rect(&h, pic); + // Well inside the body, on the part of the picture in view and clear of the handles' reach. + let grab = before.left_top() + vec2(30.0, 30.0); + click(&mut h, grab); + assert_eq!(h.state().selected_object, Some(pic)); + drag(&mut h, grab, grab + vec2(30.0, 25.0)); + let a = anchor(&h, pic); + assert_eq!((a.width.round(), a.height.round()), (12_000.0, 60.0), "the size is untouched: {a:?}"); + let after = rect(&h, pic); + assert!((after.min - before.min - vec2(30.0, 25.0)).length() < 1.0, "moved with the pointer: {before:?} {after:?}"); + // The drag sent the position only: the engine saw no size to cap. + let (_, p) = h.state().session.journal.iter().rev().find(|(id, _)| id == "object.move").unwrap(); + assert!(p.get("width").is_none() && p.get("height").is_none(), "a body move sends no size: {p}"); +} + +/// A handle drag on such a picture resizes it by the drag only: the left handle moved 10 px to +/// the right leaves it 11,990 wide, with no jump to 10,000. +#[test] +fn a_handle_drag_on_an_oversized_imported_object_resizes_by_the_drag_only() { + let (mut h, o) = fixture(); + let pic = imported_oversized_picture(&mut h, &o); + let before = rect(&h, pic); + click(&mut h, before.left_top() + vec2(30.0, 30.0)); + let grab = Handle::Left.pos(before); + drag(&mut h, grab, grab + vec2(10.0, 0.0)); + // In window points (the grid draws at its zoom): 10 narrower, the left edge 10 further right. + let after = rect(&h, pic); + assert!((before.width() - after.width() - 10.0).abs() < 1.0, "narrower by the drag only: {before:?} {after:?}"); + assert!((after.left() - before.left() - 10.0).abs() < 1.0 && (after.top() - before.top()).abs() < 0.5, "{before:?} {after:?}"); + let a = anchor(&h, pic); + assert!(a.width > 11_900.0 && a.width < 12_000.0 && a.height.round() == 60.0, "no jump to the 10,000 limit: {a:?}"); +} From 05851de46c8fb83264c55aefa444dddede84c1dd Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:03:47 -0500 Subject: [PATCH 37/55] Objects: the Format Chart Size fields show a chart's real size and send only the edited dimension The Width and Height fields ranged 40 to 4,000 and sent both values on every edit. egui clamps an existing value into a field's range, so a chart a file made 12,000 wide showed "Width 4000", and typing a new height sent that 4,000 as the width: the chart shrank although only its height changed, and the cap `chart.set` keeps for an existing size cannot help once the pane itself sends 4,000. Each field's range now reaches the larger of 4,000 and the chart's current size, so the field shows the real size, and a commit sends only the dimension that differs from the chart's (`chart.set` reads `width` and `height` independently). Test (real input): the_size_fields_keep_an_oversized_imported_chart_width_when_the_height_changes reopens a workbook with a 12,000-wide chart, opens Format Chart Area's Size section, checks the Width field shows 12000 and types 201 as the height: the width stays 12,000, the height is 201 and the journaled chart.set carries no width. Without the fix the field shows "Width 4000". --- crates/ui-egui/src/panes.rs | 24 ++++++++++--- crates/ui-egui/tests/it/chart_resize.rs | 45 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 69dbbe99f..9675d2a95 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -308,13 +308,29 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { ui.collapsing("Size", |ui| { // Keep the value being typed or dragged between frames; reloading it from the chart every frame threw the edit away. let draft = egui::Id::unique(("gridcraft.chart_size_draft", id)); - let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or((chart.anchor.width, chart.anchor.height)); - let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(40.0..=4000.0)); - let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(40.0..=4000.0)); + let current = (chart.anchor.width, chart.anchor.height); + let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or(current); + // A file can make a chart larger than the 4,000 points a field otherwise reaches. The + // range keeps such a size, so the field shows it instead of clamping it to 4,000. + let range = |v: f32| 40.0..=v.max(4000.0); + let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(range(current.0))); + let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(range(current.1))); if a.drag_stopped() || a.lost_focus() || b.drag_stopped() || b.lost_focus() { ui.data_mut(|d| d.remove::<(f32, f32)>(draft)); if !ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.run_or_alert("chart.set", json!({"chart": id, "width": w, "height": h})); + // Only an edited dimension is sent: the other keeps what the chart has, however + // large a file made it. + let mut params = serde_json::Map::new(); + params.insert("chart".into(), json!(id)); + if w != current.0 { + params.insert("width".into(), json!(w)); + } + if h != current.1 { + params.insert("height".into(), json!(h)); + } + if params.len() > 1 { + app.run_or_alert("chart.set", Json::Object(params)); + } } } else if a.dragged() || a.has_focus() || b.dragged() || b.has_focus() { ui.data_mut(|d| d.insert_temp(draft, (w, h))); diff --git a/crates/ui-egui/tests/it/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs index 2ae8707df..36934fbcb 100644 --- a/crates/ui-egui/tests/it/chart_resize.rs +++ b/crates/ui-egui/tests/it/chart_resize.rs @@ -118,6 +118,51 @@ fn dragging_the_size_field_resizes_the_chart() { assert!(w1 > w0 + 20.0, "width went from {w0} to {w1}"); } +/// Makes the chart 12,000 wide and reopens the workbook from its XLSX bytes, the way a file can +/// carry a chart larger than the 4,000 points the Size fields otherwise reach; returns its id. +fn imported_oversized_chart(h: &mut Harness, id: u32) -> u32 { + { + let wb = std::sync::Arc::make_mut(&mut h.state_mut().session.doc_mut().unwrap().wb); + wb.sheet_mut(0).unwrap().charts.iter_mut().find(|c| c.id == id).unwrap().anchor.width = 12_000.0; + } + let saved = h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).unwrap(); + h.state_mut().run("file.open", json!({"name": "wide.xlsx", "base64": saved["base64"]})).unwrap(); + h.run_steps(4); + let id = h.state().session.doc().unwrap().wb.active().unwrap().charts[0].id; + let (w, hh) = size(h, id); + assert_eq!((w.round(), hh.round()), (12_000.0, 200.0), "the reader keeps the file's size"); + id +} + +/// A chart a file made 12,000 wide shows that width in the Size fields, and a new height sends +/// the height alone, so nothing shrinks the width to the fields' 4,000 reach on the way. +#[test] +fn the_size_fields_keep_an_oversized_imported_chart_width_when_the_height_changes() { + let (mut h, id) = fixture(); + let id = imported_oversized_chart(&mut h, id); + open_size_fields(&mut h, id); + let (width_value, height_field) = { + let mut fields = h.get_all_by_role(Role::SpinButton); + let width = fields.next().unwrap(); + let height = fields.next().unwrap(); + (width.value(), height.rect()) + }; + assert!(width_value.as_deref().is_some_and(|v| v.contains("12000")), "the field shows the file's width: {width_value:?}"); + h.input_mut().events.push(Event::PointerMoved(height_field.center())); + button(&mut h, height_field.center(), true); + button(&mut h, height_field.center(), false); + for digit in ["2", "0", "1"] { + h.input_mut().events.push(Event::Text(digit.into())); + h.run_steps(2); + } + h.key_press(Key::Enter); + h.run_steps(2); + let (w, hh) = size(&h, id); + assert_eq!((w.round(), hh), (12_000.0, 201.0), "the width stays and the height is the typed one"); + let (_, p) = h.state().session.journal.iter().rev().find(|(cmd, _)| cmd == "chart.set").unwrap(); + assert!(p.get("width").is_none(), "a height edit sends no width: {p}"); +} + fn right_click(h: &mut Harness, pos: egui::Pos2, wobble: egui::Vec2) { h.input_mut().events.push(Event::PointerMoved(pos)); h.run_steps(2); From 975d5407a1058a3af455be40b6774d3de54edfaa Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:13:19 -0500 Subject: [PATCH 38/55] Form controls: a kept button action must carry the button's name and anchor cell An entry of the hidden name without `name` or `at`, or with something other than text there, skipped those checks (is_none_or) and could pin its action onto whatever button sat at the stored index. The writer always stores both keys, so such an entry is now dropped like an unparsable one (is_some_and); the doc comment says so. Test: a_kept_action_without_the_buttons_name_or_anchor_is_dropped (an entry without `name`, without `at`, with a number for `name` and with null for `at` gives the button no action and the hidden name is removed; the writer's own entry still comes back). --- crates/engine/src/cmd/controls.rs | 16 ++++++------ crates/engine/src/tests/controls.rs | 38 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 773262480..0e03f8f9f 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -571,7 +571,7 @@ pub(crate) fn sync_linked_values(wb: &mut Workbook) { // ---------------------------------------------------------------- xlsx /// The hidden-data key of the button actions xlsx has no place for. -const ACTIONS_KEY: &str = "control_actions"; +pub(crate) const ACTIONS_KEY: &str = "control_actions"; /// A button's action that xlsx carries itself: a macro on the selection (`macro="[0]!Name"`). fn plain(a: &ControlAction) -> bool { @@ -604,11 +604,11 @@ pub(crate) fn with_action_names(wb: &Workbook) -> Result> { } /// After a file is opened: gives buttons back the actions kept in the hidden name, and removes -/// it. Entries that don't parse, or don't match a button, are dropped: the button at the stored -/// place must have the stored name and anchor cell (another application may have added, removed -/// or reordered controls), and must not have been given another macro since (a stored macro -/// action comes back only on a button that still runs that macro, a stored command only on one -/// the file gives no macro). +/// it. Entries that don't parse, or don't match a button, are dropped: an entry must carry the +/// button's name and anchor cell, the button at the stored place must have that name and anchor +/// cell (another application may have added, removed or reordered controls), and it must not +/// have been given another macro since (a stored macro action comes back only on a button that +/// still runs that macro, a stored command only on one the file gives no macro). pub(crate) fn load_action_names(wb: &mut Workbook) { let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return }; stash::remove(wb, ACTIONS_KEY); @@ -624,8 +624,8 @@ pub(crate) fn load_action_names(wb: &mut Workbook) { let text = |k: &str| item.get(k).and_then(Json::as_str); let same = |c: &FormControl| { c.kind == ControlKind::Button - && text("name").is_none_or(|n| n == c.display_name()) - && text("at").is_none_or(|a| a == c.anchor.cell.a1()) + && text("name").is_some_and(|n| n == c.display_name()) + && text("at").is_some_and(|a| a == c.anchor.cell.a1()) && match (&c.action, &action) { (None, ControlAction::Command { .. }) => true, (Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept), diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs index 8dce29fcd..e567fb913 100644 --- a/crates/engine/src/tests/controls.rs +++ b/crates/engine/src/tests/controls.rs @@ -429,6 +429,44 @@ fn a_kept_button_action_comes_back_only_on_the_same_button() { assert_eq!(cleared.sheets[0].controls[0].action, actions(&wb)[0], "the command comes back"); } +/// An entry of the hidden name must carry the button's name and anchor cell (the writer always +/// stores both): a hand-edited entry without either, or with something else there, is dropped +/// like an unparsable one, so it cannot pin its action onto whatever button sits at that index. +#[test] +fn a_kept_action_without_the_buttons_name_or_anchor_is_dropped() { + use crate::cmd::controls::{ACTIONS_KEY, load_action_names, with_action_names}; + use crate::cmd::stash; + let mut s = s(); + s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap(); + let stored = with_action_names(&s.doc().unwrap().wb).unwrap().unwrap(); + let items = stash::get_json(&stored, ACTIONS_KEY).unwrap(); + assert!(items[0]["name"].is_string() && items[0]["at"].is_string(), "the writer stores both keys: {items}"); + // As the file brings a command button back: with no action. + let kept = |items: &Json| { + let mut wb = stored.clone(); + wb.sheet_mut(0).unwrap().controls[0].action = None; + stash::remove(&mut wb, ACTIONS_KEY); + stash::set_json(&mut wb, ACTIONS_KEY, items).unwrap(); + load_action_names(&mut wb); + assert!(stash::get(&wb, ACTIONS_KEY).is_none(), "the hidden name is removed"); + wb.sheets[0].controls[0].action.clone() + }; + let edited = |key: &str, value: Option| { + let mut items = items.clone(); + let entry = items[0].as_object_mut().unwrap(); + match value { + Some(v) => entry.insert(key.to_string(), v), + None => entry.remove(key), + }; + items + }; + assert_eq!(kept(&edited("name", None)), None, "no `name`"); + assert_eq!(kept(&edited("at", None)), None, "no `at`"); + assert_eq!(kept(&edited("name", Some(json!(1)))), None, "`name` is not text"); + assert_eq!(kept(&edited("at", Some(Json::Null))), None, "`at` is null"); + assert!(matches!(kept(&items), Some(ControlAction::Command { .. })), "the writer's own entry still comes back"); +} + /// The saved xlsx bytes of `s`'s workbook. fn saved_xlsx(s: &mut Session) -> Vec { let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); From 36a44a35cb8c6478fea61fa2bef7a2c3b661a78b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:20:48 -0500 Subject: [PATCH 39/55] Form controls: a button never opens a web page or a dialog A button's command ran through the macro policy and the file sandbox, but not through the macro runner's request filter, so a file-borne button assigned `link.open {url}` opened the page once the generic trust question was answered. Now `link.open` is refused for buttons up front, when it is assigned and when a file brings it in, with the wording of the other denied commands; and after a press runs its command, the UI requests it queued are checked as the macro runner checks a step's: a dialog or a web page makes the press fail with the reason and rolls the group back, a cell edit is dropped, and messages for the person stay queued. Tests: link.open joins a_denied_command_is_refused_when_assigned_and_when_a_file_brings_it_in (assign, insert and a file-borne press refuse it; no OpenUrl request is left), and a_button_command_that_would_open_a_dialog_or_a_page_is_refused_after_it_runs (insert.link with no parameters is refused with the dialog wording and its request dropped; the filter refuses a page, drops a cell edit and keeps messages). docs/macros.md says what a button's command may not do. --- crates/engine/src/cmd/controls.rs | 49 +++++++++++++++++++++++++++-- crates/engine/src/tests/controls.rs | 30 ++++++++++++++++++ docs/macros.md | 5 ++- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 0e03f8f9f..ac1c9d90f 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -369,6 +369,38 @@ fn format(s: &mut Session, p: &Json) -> Result { }) } +/// Commands a button never runs, beyond the macro policy's lists: a button has no way to show +/// what its command would open, so a web page is refused when the action is assigned or comes in +/// from a file. A command that turns out to open a page or a dialog when it runs is refused +/// then, by [`press`]. +const BUTTON_DENIED: &[(&str, &str)] = &[("link.open", "buttons don't open web pages")]; + +/// The macro policy for a button's command, with the button's own refusals first. +fn button_policy(id: &str, params: &Json, trust: crate::Trust) -> Policy { + match BUTTON_DENIED.iter().find(|(d, _)| *d == id) { + Some((_, why)) => Policy::Deny((*why).into()), + None => macro_policy(id, params, trust), + } +} + +/// Drops the UI requests a button's command queued from `queued` on, keeping messages for the +/// person, and says why the press is refused when one would open a dialog or a web page, which +/// buttons don't do (`name` is the button's). A cell edit the command asked for is dropped too. +pub(crate) fn take_requests(s: &mut Session, queued: usize, name: &str) -> Option { + use crate::UiRequest; + let mut blocked = None; + let asked: Vec = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect(); + for q in asked { + match q { + UiRequest::Dialog(d, _) => blocked = Some(format!("'{name}' would open the {d} dialog; give its command the parameters it needs.")), + UiRequest::OpenUrl(_) => blocked = Some(format!("'{name}' would open a web page, which buttons don't do.")), + UiRequest::EditCell(_) | UiRequest::InsertName(_) => {} + m @ UiRequest::Message(_) => s.ui_requests.push(m), + } + } + blocked +} + /// A button action from `macro` (with `input` and `output`) or `command` (with `params`). fn action_param(s: &Session, cmd: &str, p: &Json) -> Result> { if let Some(name) = str_param(p, "macro").map(str::trim).filter(|n| !n.is_empty()) { @@ -403,7 +435,7 @@ fn action_param(s: &Session, cmd: &str, p: &Json) -> Result Result { Some(ControlAction::Command { id, params }) => { let d = s.doc()?; let (doc, trust) = (d.uid, d.trust); - let policy = macro_policy(&id, ¶ms, trust); + let policy = button_policy(&id, ¶ms, trust); let file_command = matches!(policy, Policy::NeedsPermission(_)); let why = match policy { Policy::Allow => None, @@ -508,7 +540,18 @@ fn press(s: &mut Session, p: &Json) -> Result { // As a macro step: only an approved file command reaches the file system, so a // command that reads or writes a file under another parameter name can't. let _sandbox = (!file_command).then(crate::io::ScriptSandbox::enter); - s.grouped(&name, |s| s.execute(&id, params.clone())) + s.grouped(&name, |s| { + let queued = s.ui_requests.len(); + let r = s.execute(&id, params.clone()); + // As a macro step: a command that would open a dialog or a web page is refused + // and its group rolled back; messages for the person stay queued. + let blocked = take_requests(s, queued, &name); + let r = r?; + match blocked { + Some(why) => Err(EngineError::Other(why)), + None => Ok(r), + } + }) } } } diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs index e567fb913..5442aabde 100644 --- a/crates/engine/src/tests/controls.rs +++ b/crates/engine/src/tests/controls.rs @@ -625,6 +625,7 @@ fn a_denied_command_is_refused_when_assigned_and_when_a_file_brings_it_in() { let dir = std::env::temp_dir().join(format!("gridcraft-control-deny-{}", std::process::id())); let leak = dir.join("leak.xlsx").to_string_lossy().to_string(); let denied = [ + ("link.open", json!({"url": "https://example.com/?d=secret"})), ("file.saveAs", json!({"path": leak})), ("file.save", json!({})), ("file.open", json!({"path": leak})), @@ -665,6 +666,35 @@ fn a_denied_command_is_refused_when_assigned_and_when_a_file_brings_it_in() { } assert!(!std::path::Path::new(&leak).exists()); assert_eq!(v(&s, "A1"), Value::Empty, "the macro never ran"); + assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "no page was opened: {:?}", s.ui_requests); +} + +/// A command the policy allows may still turn out to open a dialog or a web page when it runs +/// (a command given no parameters opens its dialog): the press is refused with the reason, as a +/// macro step is, and the request is dropped, so nothing opens. Messages for the person pass. +#[test] +fn a_button_command_that_would_open_a_dialog_or_a_page_is_refused_after_it_runs() { + use crate::UiRequest; + use crate::cmd::controls::take_requests; + let mut s = s(); + let b = id(&s.execute("insert.button", json!({"at": "B2", "text": "Link", "command": "insert.link", "params": {}})).unwrap()); + let before = undo_len(&s); + let name = control(&s, b).display_name(); + assert_eq!( + err(s.execute("control.press", json!({"id": b}))), + format!("'{name}' would open the insertLink dialog; give its command the parameters it needs.") + ); + assert!(!s.ui_requests.iter().any(|r| matches!(r, UiRequest::Dialog(..))), "{:?}", s.ui_requests); + assert_eq!(undo_len(&s), before, "nothing stays done"); + // The filter itself, with every kind of request a command can queue: the page is refused, + // the cell edit is dropped, the message stays, and requests queued before are left alone. + s.ui_requests.push(UiRequest::Message("earlier".into())); + let queued = s.ui_requests.len(); + s.ui_requests.extend([UiRequest::EditCell(None), UiRequest::Message("saved".into()), UiRequest::OpenUrl("https://example.com/".into())]); + assert_eq!(take_requests(&mut s, queued, "Go").as_deref(), Some("'Go' would open a web page, which buttons don't do.")); + assert_eq!(s.ui_requests, vec![UiRequest::Message("earlier".into()), UiRequest::Message("saved".into())]); + assert_eq!(take_requests(&mut s, 5, "Go"), None, "a mark past the queue's end drops nothing"); + assert_eq!(s.ui_requests.len(), 2); } /// Using a slider or spin button writes its linked cell through `cell.set`, so the cell's diff --git a/docs/macros.md b/docs/macros.md index 8073b2fcd..b4937fcbe 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -263,7 +263,10 @@ default) or always on one range (`input: "B2:D9"`), with an optional output cell {id, command, params}` makes it run a command with fixed parameters. A macro runs through `macro.run` with its checks; a command goes through the same policy as a macro step and runs, as a step does, inside the scripts' file sandbox unless it is an approved file command, so a button -in a workbook opened from a file asks before it runs anything. +in a workbook opened from a file asks before it runs anything. Like a step, a button's command +never opens a web page or a dialog: `link.open` is refused when it is assigned or comes in from +a file, and a command that would open a dialog or a page once it runs (a command given no +parameters opens its dialog) is refused then, with the request dropped and nothing done. In xlsx a button carries its macro as Excel writes it (`macro="[0]!Name"`, a macro run on the selection). A command, a fixed input range or an output cell has no place there and is kept in a From be0882a148d087139d8275005da5e80587ce943e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:22:18 -0500 Subject: [PATCH 40/55] Form controls: a button gives the keyboard back when the grid takes a key or a click A click on a button focused it, and the focus stayed while the grid moved the cell cursor with the arrows, Tab or Delete, or opened a cell's menu with a right-click, so a later Enter or Space pressed the button again without a new click, and Space could not start a cell edit. Now a key press the focused button doesn't take (any key but Space, Enter and Escape, or one with Ctrl, Cmd or Alt) gives the keyboard back to the grid, and any press that isn't a left click on the grid, a right-click on a cell included, moves the focus off a control. A slider or spin button keeps the keyboard as the control last worked. Tests (kittest): a_button_gives_the_keyboard_back_when_the_grid_takes_a_key (arrow, then Enter moves the cursor and does not press; arrow, then Space starts a cell edit; Cmd+arrow takes the keyboard too) and a_right_click_on_a_cell_takes_the_keyboard_from_a_button. docs/ui-parity.md says so. --- crates/ui-egui/src/form_controls.rs | 14 ++++- crates/ui-egui/src/grid/keyboard.rs | 9 ++- crates/ui-egui/tests/it/form_controls.rs | 77 +++++++++++++++++++++++- docs/ui-parity.md | 2 +- 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/form_controls.rs b/crates/ui-egui/src/form_controls.rs index fb6d41c03..10b6388b2 100644 --- a/crates/ui-egui/src/form_controls.rs +++ b/crates/ui-egui/src/form_controls.rs @@ -306,8 +306,9 @@ pub fn interact(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, return true; } if !pointer.primary_pressed() || !resp.contains_pointer() { - // Any other press on the grid moves the focus off the control. - if pointer.any_pressed() && app.grid.focused_control.is_some() && !resp.contains_pointer() { + // Any other press, on the grid (a right-click on a cell) or off it, moves the focus off + // the control, so a later Enter or Space doesn't work it. + if pointer.any_pressed() && app.grid.focused_control.is_some() { app.grid.focused_control = None; } return false; @@ -356,6 +357,15 @@ fn step_part(app: &mut SheetApp, c: &FormControl, part: Part) { }; } +/// The grid takes a key while a control is focused: a button gives the keyboard back, so a later +/// Enter or Space doesn't press it again and Space starts a cell edit; a slider or spin button +/// keeps it, as the control last worked, which the arrows still step. +pub fn key_to_grid(app: &mut SheetApp, sh: &Sheet) { + if app.grid.focused_control.and_then(|id| find(sh, id)).is_some_and(|c| c.kind == ControlKind::Button) { + app.grid.focused_control = None; + } +} + /// Keys for the focused control: arrows move by a step, Page Up and Page Down by a page, Home and /// End to the ends; Space or Enter presses a button. True when the key was used. `repeat` is a key /// held down: it steps a slider or spin button again, but a button runs once per press. diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index 81f4b432f..f72badef6 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -166,12 +166,17 @@ fn grid_events(app: &mut SheetApp, ctx: &egui::Context, sh: &Sheet) -> Vec bool { match ev { egui::Event::Text(text) => text == " " && app.grid.focused_control.is_some() && app.selected_object.is_none(), egui::Event::Key { key, pressed: true, repeat, modifiers: m, .. } => { - !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key, *repeat) + if !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key, *repeat) { + return true; + } + crate::form_controls::key_to_grid(app, sh); + false } _ => false, } diff --git a/crates/ui-egui/tests/it/form_controls.rs b/crates/ui-egui/tests/it/form_controls.rs index ad13818a1..325ad1a29 100644 --- a/crates/ui-egui/tests/it/form_controls.rs +++ b/crates/ui-egui/tests/it/form_controls.rs @@ -90,11 +90,29 @@ fn click(h: &mut Harness, p: Pos2) { } fn key(h: &mut Harness, key: Key) { - h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); - h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + key_with(h, key, Modifiers::NONE); +} + +fn key_with(h: &mut Harness, key: Key, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); h.run_steps(2); } +/// A Space press as the system sends it: the key and the space it types. +fn space(h: &mut Harness) { + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Text(" ".into())); + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn active(h: &Harness) -> String { + h.state().session.doc().unwrap().selection.active.a1() +} + fn thumb(h: &Harness, id: u32) -> Rect { let c = control(h, id); slider_parts(rect(h, id), &c, 1.0).1 @@ -269,6 +287,61 @@ fn holding_enter_or_space_presses_a_focused_button_once() { } } +/// A button holds the keyboard only until the grid takes a key: after an arrow (or Cmd+arrow) +/// moves the cursor, Enter moves it again instead of pressing the button a second time, and +/// Space starts a cell edit. +#[test] +fn a_button_gives_the_keyboard_back_when_the_grid_takes_a_key() { + let (mut h, ids) = fixture(); + let r = rect(&h, ids.button); + click(&mut h, r.center()); + assert_eq!(cell(&h, "H1"), Value::text("pressed")); + let runs = undo_len(&h); + assert_eq!(active(&h), "A20"); + key(&mut h, Key::ArrowDown); + assert_eq!(active(&h), "A21", "the arrow moved the cursor"); + assert_eq!(h.state().grid.focused_control, None, "and took the keyboard from the button"); + key(&mut h, Key::Enter); + assert_eq!(undo_len(&h), runs, "Enter did not press the button again"); + assert_eq!(active(&h), "A22", "it moved the cursor"); + // Space after an arrow starts a cell edit. + click(&mut h, r.center()); + assert_eq!(undo_len(&h), runs + 1, "the click pressed it"); + key(&mut h, Key::ArrowDown); + space(&mut h); + assert!(h.state().editor.is_some(), "Space starts editing the cell"); + assert_eq!(undo_len(&h), runs + 1, "Space did not press the button"); + key(&mut h, Key::Escape); + assert!(h.state().editor.is_none()); + // A key with a modifier the grid takes (Cmd+arrow jumps to the edge) takes the keyboard too. + click(&mut h, r.center()); + assert_eq!(h.state().grid.focused_control, Some(ids.button)); + key_with(&mut h, Key::ArrowUp, Modifiers::COMMAND); + assert_eq!(h.state().grid.focused_control, None); + assert_eq!(active(&h), "A1"); +} + +/// A right-click on a cell takes the keyboard from a button, like any other click on the grid: +/// the Enter that follows does not press it again. +#[test] +fn a_right_click_on_a_cell_takes_the_keyboard_from_a_button() { + let (mut h, ids) = fixture(); + let r = rect(&h, ids.button); + click(&mut h, r.center()); + let runs = undo_len(&h); + assert_eq!(h.state().grid.focused_control, Some(ids.button)); + let on_a_cell = Pos2::new(r.right() + 300.0, r.bottom() + 100.0); + h.run_steps(30); + button(&mut h, on_a_cell, true, PointerButton::Secondary, Modifiers::NONE); + button(&mut h, on_a_cell, false, PointerButton::Secondary, Modifiers::NONE); + h.run_steps(2); + assert_eq!(h.state().selected_object, None, "a cell, not an object"); + assert_eq!(h.state().grid.focused_control, None, "the right-click took the keyboard"); + key(&mut h, Key::Enter); + assert_eq!(undo_len(&h), runs, "Enter did not press the button again"); + assert_eq!(cell(&h, "H1"), Value::text("pressed")); +} + #[test] fn a_button_from_a_file_asks_before_it_runs() { use egui_kittest::kittest::Queryable as _; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b4a0b7d9a..b3c467e5e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -69,7 +69,7 @@ this logic, with one undo step. | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing | -| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file; the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter; Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | +| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file; the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter (a button gives the keyboard back as soon as the grid takes a key or a right-click); Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | | Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) | From 8495cc99e151f8fd61a0e767f7eedacd8a74a3ca Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:41:19 -0500 Subject: [PATCH 41/55] Form controls: Space on a focused slider or spin button starts a cell edit The space a Space press types was dropped while any control was focused, because Space presses a focused button. A slider or spin button doesn't take Space, so with one focused Space neither worked the control nor started a cell edit, while every other character did. Now the space is dropped only for a focused button; on a slider or spin button it reaches the grid like any other character and starts a cell edit. The control keeps the keyboard, as the control last worked: Space is a key it doesn't take, exactly like a letter, and letting go on Space alone would make Space differ from every other printable key. Test (kittest): space_starts_a_cell_edit_while_a_slider_or_spin_button_is_focused (a cell edit starts with a space, the control's value is unchanged, Escape cancels, the arrows still step it). docs/ui-parity.md says so. --- crates/ui-egui/src/form_controls.rs | 9 +++++++- crates/ui-egui/src/grid/keyboard.rs | 9 ++++---- crates/ui-egui/tests/it/form_controls.rs | 26 ++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/form_controls.rs b/crates/ui-egui/src/form_controls.rs index 10b6388b2..27385847f 100644 --- a/crates/ui-egui/src/form_controls.rs +++ b/crates/ui-egui/src/form_controls.rs @@ -357,11 +357,18 @@ fn step_part(app: &mut SheetApp, c: &FormControl, part: Part) { }; } +/// Whether the focused control is a button. Space presses one, so the space it types must not +/// start a cell edit; a slider or spin button doesn't take Space, and its space reaches the grid +/// like any other character. +pub fn focused_button(app: &SheetApp, sh: &Sheet) -> bool { + app.grid.focused_control.and_then(|id| find(sh, id)).is_some_and(|c| c.kind == ControlKind::Button) +} + /// The grid takes a key while a control is focused: a button gives the keyboard back, so a later /// Enter or Space doesn't press it again and Space starts a cell edit; a slider or spin button /// keeps it, as the control last worked, which the arrows still step. pub fn key_to_grid(app: &mut SheetApp, sh: &Sheet) { - if app.grid.focused_control.and_then(|id| find(sh, id)).is_some_and(|c| c.kind == ControlKind::Button) { + if focused_button(app, sh) { app.grid.focused_control = None; } } diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index f72badef6..8f3dfb141 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -165,12 +165,13 @@ fn grid_events(app: &mut SheetApp, ctx: &egui::Context, sh: &Sheet) -> Vec bool { match ev { - egui::Event::Text(text) => text == " " && app.grid.focused_control.is_some() && app.selected_object.is_none(), + egui::Event::Text(text) => text == " " && app.selected_object.is_none() && crate::form_controls::focused_button(app, sh), egui::Event::Key { key, pressed: true, repeat, modifiers: m, .. } => { if !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key, *repeat) { return true; diff --git a/crates/ui-egui/tests/it/form_controls.rs b/crates/ui-egui/tests/it/form_controls.rs index 325ad1a29..f202df1be 100644 --- a/crates/ui-egui/tests/it/form_controls.rs +++ b/crates/ui-egui/tests/it/form_controls.rs @@ -321,6 +321,32 @@ fn a_button_gives_the_keyboard_back_when_the_grid_takes_a_key() { assert_eq!(active(&h), "A1"); } +/// Space is a key a slider or spin button doesn't take, so it reaches the grid like any other +/// character: a cell edit starts with a space and the control's value is unchanged. The control +/// keeps the keyboard, as the control last worked: after the edit, the arrows still step it. +#[test] +fn space_starts_a_cell_edit_while_a_slider_or_spin_button_is_focused() { + let (mut h, ids) = fixture(); + let spin = rect(&h, ids.spin); + for (id, at, step) in + [(ids.slider, thumb(&h, ids.slider).center(), Key::ArrowRight), (ids.spin, Pos2::new(spin.center().x, spin.top() + 5.0), Key::ArrowUp)] + { + click(&mut h, at); + assert_eq!(h.state().grid.focused_control, Some(id)); + let value = control(&h, id).value; + space(&mut h); + assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some(" "), "Space starts editing the cell with a space"); + assert_eq!(control(&h, id).value, value, "the control is unchanged"); + assert_eq!(h.state().grid.focused_control, Some(id), "the control last worked keeps the keyboard"); + key(&mut h, Key::Escape); + assert!(h.state().editor.is_none(), "Escape cancels the edit"); + key(&mut h, step); + assert_eq!(control(&h, id).value, value + control(&h, id).step, "the arrows still step it"); + key(&mut h, Key::Escape); + assert_eq!(h.state().grid.focused_control, None); + } +} + /// A right-click on a cell takes the keyboard from a button, like any other click on the grid: /// the Enter that follows does not press it again. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b3c467e5e..f0ead2518 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -69,7 +69,7 @@ this logic, with one undo step. | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing | -| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file; the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter (a button gives the keyboard back as soon as the grid takes a key or a right-click); Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | +| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file; the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter (a button gives the keyboard back as soon as the grid takes a key or a right-click; Space on a slider or spin button starts a cell edit like any other character); Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | | Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) | From d1f69878f6701490a2ba2b05d89b789b57be0dba Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 03:06:40 -0500 Subject: [PATCH 42/55] Form controls: Space on a checkbox cell toggles it once while a slider or spin button is focused With a slider or spin button focused, the Space key reached the grid and toggled a checkbox cell, and the space it types then started a cell edit over the toggle (Escape cancelled the edit but not the toggle). Now the typed space is dropped while a control is focused and the active cell is a checkbox, as it is for a focused button: Space on a cell does what it does with no control focused and nothing more, a checkbox cell toggles once with no edit, any other cell starts an edit with a space. The path with no control focused is unchanged. Test (kittest): space_on_a_checkbox_cell_toggles_it_once_while_a_slider_is_focused (toggled once, no editor, the slider's value and focus unchanged; a second Space toggles back). docs/ui-parity.md says so. --- crates/ui-egui/src/grid/keyboard.rs | 17 ++++++++++++----- crates/ui-egui/tests/it/form_controls.rs | 23 +++++++++++++++++++++++ docs/ui-parity.md | 2 +- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index 8f3dfb141..4c0ca282d 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -165,13 +165,20 @@ fn grid_events(app: &mut SheetApp, ctx: &egui::Context, sh: &Sheet) -> Vec bool { match ev { - egui::Event::Text(text) => text == " " && app.selected_object.is_none() && crate::form_controls::focused_button(app, sh), + egui::Event::Text(text) => { + text == " " + && app.selected_object.is_none() + && app.grid.focused_control.is_some() + && (crate::form_controls::focused_button(app, sh) || is_checkbox(sh, app)) + } egui::Event::Key { key, pressed: true, repeat, modifiers: m, .. } => { if !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key, *repeat) { return true; diff --git a/crates/ui-egui/tests/it/form_controls.rs b/crates/ui-egui/tests/it/form_controls.rs index f202df1be..44d1f9f32 100644 --- a/crates/ui-egui/tests/it/form_controls.rs +++ b/crates/ui-egui/tests/it/form_controls.rs @@ -347,6 +347,29 @@ fn space_starts_a_cell_edit_while_a_slider_or_spin_button_is_focused() { } } +/// With a slider or spin button focused, Space on a cell does what it does with no control +/// focused and nothing more: a checkbox cell toggles once and no edit starts (the typed space is +/// dropped, as the toggle is what Space does there), and the control's value is unchanged. +#[test] +fn space_on_a_checkbox_cell_toggles_it_once_while_a_slider_is_focused() { + let (mut h, ids) = fixture(); + h.state_mut().run("insert.checkbox", json!({"range": "A20"})).unwrap(); + h.run_steps(2); + assert_eq!(cell(&h, "A20"), Value::Bool(false)); + let at = thumb(&h, ids.slider).center(); + click(&mut h, at); + assert_eq!(h.state().grid.focused_control, Some(ids.slider)); + let value = control(&h, ids.slider).value; + space(&mut h); + assert_eq!(cell(&h, "A20"), Value::Bool(true), "Space toggled the box once"); + assert!(h.state().editor.is_none(), "and started no edit"); + assert_eq!(control(&h, ids.slider).value, value, "the slider is unchanged"); + assert_eq!(h.state().grid.focused_control, Some(ids.slider), "and keeps the keyboard"); + space(&mut h); + assert_eq!(cell(&h, "A20"), Value::Bool(false), "a second Space toggles it back"); + assert!(h.state().editor.is_none()); +} + /// A right-click on a cell takes the keyboard from a button, like any other click on the grid: /// the Enter that follows does not press it again. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f0ead2518..7147bd400 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -69,7 +69,7 @@ this logic, with one undo step. | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing | -| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file; the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter (a button gives the keyboard back as soon as the grid takes a key or a right-click; Space on a slider or spin button starts a cell edit like any other character); Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | +| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file; the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter (a button gives the keyboard back as soon as the grid takes a key or a right-click; Space on a slider or spin button does what it does with no control focused: it toggles a checkbox cell or starts a cell edit like any other character); Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | | Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) | From d09694ff619a8836615ca7df658becf838670f1b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:45:33 -0500 Subject: [PATCH 43/55] Form controls: a slicer tile under a control is not toggled by a click meant for the control A click on a form control that form_controls::interact leaves to the grid (Ctrl or Cmd+click, which selects the control; a click on a selected control, which moves it) fell through to the slicer tile branch, which looks only at slicers, so a control placed over a slicer's value tile had the tile toggled and the slicer selected before the generic object handling could select or drag the control. The slicer tile branch now runs only when the press lands on a slicer by the same test the generic path uses (the selected object's handle first, then the topmost object), so the z-order decides once; a control over a tile, and a selected object's handle over one, keep the generic path. Charts, pictures and shapes lie beneath slicers in that order, so they were not affected. Test (kittest): a_control_over_a_slicer_tile_keeps_the_generic_path_on_a_selecting_click (a button over the first tile; Cmd+click selects the button and the slicer's filter is unchanged; a click on the selected button still toggles nothing; an uncovered tile still toggles). --- crates/ui-egui/src/grid/interact.rs | 21 ++++++++-- crates/ui-egui/tests/it/objects.rs | 60 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 65214813b..deb75d338 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -109,10 +109,14 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo return; } // A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear - // button resets it. Runs before the generic object select/drag so tiles stay clickable. + // button resets it. Runs before the generic object select/drag so tiles stay clickable, but + // only when the slicer is what the press lands on: a form control over a tile (Ctrl or + // Cmd+clicked, or clicked while selected, so `form_controls::interact` left the click) and a + // selected object's handle keep the generic path, which selects, moves or resizes them. if primary_clicked && !resp.drag_started() && let Some(p) = pos + && object_under(app, geo, sh, p).is_some_and(|(o, _, handle)| o.kind == ObjKind::Slicer && handle.is_none()) && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p) { app.selected_object = Some(ObjRef::new(ObjKind::Slicer, id)); @@ -135,10 +139,10 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo // The selected object's handles reach a little outside it, so they win over hit-testing. // Only they resize: a press anywhere on another object selects and moves it, as the // hover cursor shows. - let on_handle = at.and_then(|p| crate::chartview::selected_handle(app, geo, sh, p)); - let handle = on_handle.map(|(.., h)| h); + let under = at.and_then(|p| object_under(app, geo, sh, p)); + let handle = under.and_then(|(.., h)| h); if let Some(p) = at - && let Some((obj, _)) = on_handle.map(|(o, r, _)| (o, r)).or_else(|| crate::chartview::hit(app, geo, sh, p)) + && let Some((obj, _, _)) = under { app.selected_object = Some(obj); if resp.double_clicked() && app.open_object(obj) != crate::objects::OpenAction::Select { @@ -544,6 +548,15 @@ fn handle_cell_double_click(app: &mut SheetApp, input: &InteractionInput<'_>, c: false } +/// What a press at `p` lands on, for slicer tiles and the generic object handling alike, so the +/// z-order decides once: the selected object's handle (handles reach a little past the frame and +/// win over hit-testing), else the topmost object under `p` and its rect. +fn object_under(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect, Option)> { + crate::chartview::selected_handle(app, geo, sh, p) + .map(|(o, r, h)| (o, r, Some(h))) + .or_else(|| crate::chartview::hit(app, geo, sh, p).map(|(o, r)| (o, r, None))) +} + fn handle_context_menu_and_keyboard(app: &mut SheetApp, input: &InteractionInput<'_>) { let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position); let resp = input.response; diff --git a/crates/ui-egui/tests/it/objects.rs b/crates/ui-egui/tests/it/objects.rs index e0c272817..8870f0709 100644 --- a/crates/ui-egui/tests/it/objects.rs +++ b/crates/ui-egui/tests/it/objects.rs @@ -108,6 +108,18 @@ fn click(h: &mut Harness, p: Pos2) { h.run_steps(2); } +fn click_with(h: &mut Harness, p: Pos2, modifiers: Modifiers) { + h.run_steps(30); + for pressed in [true, false] { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + fn double_click(h: &mut Harness, p: Pos2) { // Separate the gesture from earlier clicks (egui's triple-click window). h.run_steps(45); @@ -136,6 +148,54 @@ fn kept(h: &Harness) -> Value { h.state().session.doc().unwrap().wb.active().unwrap().value(at("R30")) } +/// A form control over a slicer's value tile is the topmost object there, so a click that +/// `form_controls::interact` leaves to the grid (Cmd+click selects the control; a click on a +/// selected control moves it) takes the generic object path and never toggles the tile beneath. +#[test] +fn a_control_over_a_slicer_tile_keeps_the_generic_path_on_a_selecting_click() { + let (mut h, o) = fixture(); + let slicer = rect(&h, o.slicer); + // A button exactly over the slicer's first value tile: the tile in screen units at the + // grid's scale, then the button's anchor in sheet units from the slicer's own. + let (z, anchor, n, columns) = { + let app = h.state(); + let d = app.session.doc().unwrap(); + let sh = d.wb.active().unwrap(); + let sl = sh.slicers.iter().find(|s| s.id == o.slicer.id).unwrap(); + let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + (geo.z, sl.anchor, gridcraft_engine::cmd::slicer::slicer_items(&d.wb, d.wb.active_sheet(), sl).len(), sl.columns) + }; + let layout = chartview::slicer_layout(slicer, z, n, columns); + let tile = layout.tiles[0]; + let params = json!({ + "at": anchor.cell.a1(), + "dx": anchor.dx + (tile.left() - slicer.left()) / z, + "dy": anchor.dy + (tile.top() - slicer.top()) / z, + "width": tile.width() / z, + "height": tile.height() / z, + "text": "Over", + }); + let button = ObjRef::new(ObjKind::Control, id(&h.state_mut().run("insert.button", params).unwrap(), "control")); + h.run_steps(2); + assert!(rect(&h, button).contains(tile.center()), "the button covers the tile: {:?} vs {tile:?}", rect(&h, button)); + let filter = + |h: &Harness| h.state().session.doc().unwrap().wb.active().unwrap().slicers.iter().find(|s| s.id == o.slicer.id).unwrap().selected.clone(); + assert_eq!(filter(&h), None); + // Cmd+click selects the control without working it, and the tile beneath stays as it was. + click_with(&mut h, tile.center(), Modifiers::COMMAND); + assert_eq!(h.state().selected_object, Some(button), "the control is selected"); + assert_eq!(filter(&h), None, "the tile under the control was not toggled"); + // A click on the selected control is for moving it, not for the tile beneath either. + click(&mut h, tile.center()); + assert_eq!(h.state().selected_object, Some(button)); + assert_eq!(filter(&h), None, "still no toggle"); + // The tile itself, clear of the control, still toggles. + let free = layout.tiles[1].center(); + h.state_mut().selected_object = None; + click(&mut h, free); + assert!(filter(&h).is_some(), "a click on an uncovered tile toggles it"); +} + #[test] fn a_click_selects_every_kind_and_slicers_win_over_charts() { let (mut h, o) = fixture(); From 112a9c78b9a69716297980fc3f49f8a4e06b84ca Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:11:23 -0500 Subject: [PATCH 44/55] Form controls: a linked cell's formula survives a write of the value it already shows write_link skipped the write only when the linked cell held a constant equal to the value; a cell with a formula was always written, so Format Control's OK (which sends the value back every time) replaced the formula with a constant even when the formula showed that value, and on a protected sheet with the linked cell locked a rename was refused. The value the cell shows is now compared whether or not it holds a formula: a write that changes nothing is skipped, and a real change still replaces the formula with the constant, as in Excel. Test: a_formula_in_the_linked_cell_survives_a_write_of_the_value_it_shows (insert and Format Control with the value the formula shows keep the formula, also on a protected sheet with the cell locked; a changed value writes the constant). --- crates/engine/src/cmd/controls.rs | 7 ++++-- crates/engine/src/tests/controls.rs | 36 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index ac1c9d90f..a7c0afbf8 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -276,11 +276,14 @@ fn linked_number(wb: &Workbook, host: usize, c: &FormControl) -> Option { wb.sheet(si)?.cell(cell)?.value.as_f64() } -/// Writes `value` to the linked cell through `cell.set` (nothing without a link). +/// Writes `value` to the linked cell through `cell.set` (nothing without a link, or when the cell +/// already shows `value`, formula or not: Format Control's OK sends the value back every time, and +/// a write that changes nothing must not turn a formula into a constant or fail on a locked cell +/// of a protected sheet; a real change replaces a formula with the constant, as in Excel). fn write_link(s: &mut Session, host: usize, link: Option<&str>, value: u32) -> Result<()> { let Some(link) = link else { return Ok(()) }; let Some((si, cell)) = link_target(&s.doc()?.wb, host, link) else { return Ok(()) }; - let holds = s.doc()?.wb.sheet(si).and_then(|sh| sh.cell(cell)).and_then(|c| if c.formula.is_none() { c.value.as_f64() } else { None }); + let holds = s.doc()?.wb.sheet(si).and_then(|sh| sh.cell(cell)).and_then(|c| c.value.as_f64()); if holds == Some(value as f64) { return Ok(()); } diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs index 5442aabde..27c0a685a 100644 --- a/crates/engine/src/tests/controls.rs +++ b/crates/engine/src/tests/controls.rs @@ -697,6 +697,42 @@ fn a_button_command_that_would_open_a_dialog_or_a_page_is_refused_after_it_runs( assert_eq!(s.ui_requests.len(), 2); } +/// A linked cell that holds a formula: a write of the value the formula already shows is skipped, +/// so Format Control's OK (which sends the value back every time) keeps the formula, also on a +/// protected sheet with the cell locked; a changed value replaces the formula with the constant. +#[test] +fn a_formula_in_the_linked_cell_survives_a_write_of_the_value_it_shows() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap(); + let formula = + |s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().cell(gridcraft_core::CellRef::parse("B1").unwrap()).is_some_and(|c| c.formula.is_some()); + assert!(formula(&s)); + // Inserting with the value the formula shows (10 * 2 = 20) writes nothing. + let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1", "value": 20})).unwrap()); + assert!(formula(&s), "the formula stays"); + assert_eq!(v(&s, "B1"), Value::Number(20.0)); + // The dialog's OK: every number sent back, the value unchanged, a new name. + s.execute("control.format", json!({"id": c, "name": "Speed", "value": 20, "min": 0, "max": 100, "step": 1, "page": 10, "locked": false})) + .unwrap(); + assert!(formula(&s), "a rename keeps the formula"); + // Editing A1 moves the control through the formula (15 * 2 = 30); OK with that value keeps it. + s.execute("cell.set", json!({"cell": "A1", "input": "15"})).unwrap(); + assert_eq!(control(&s, c).value, 30); + s.execute("control.format", json!({"id": c, "value": 30, "min": 0, "max": 100, "step": 1})).unwrap(); + assert!(formula(&s)); + // On a protected sheet with B1 locked, the same OK is not refused. + s.execute("review.protectSheet", json!({})).unwrap(); + s.execute("control.format", json!({"id": c, "name": "Rate", "value": 30, "min": 0, "max": 100, "step": 1})).unwrap(); + assert_eq!(control(&s, c).name, "Rate"); + assert!(formula(&s)); + s.execute("review.unprotectSheet", json!({})).unwrap(); + // A changed value writes the constant over the formula. + s.execute("control.format", json!({"id": c, "value": 40, "min": 0, "max": 100, "step": 1})).unwrap(); + assert!(!formula(&s), "the formula is replaced"); + assert_eq!(v(&s, "B1"), Value::Number(40.0)); +} + /// Using a slider or spin button writes its linked cell through `cell.set`, so the cell's /// protection governs it: a locked cell on a protected sheet refuses the change, and the control /// keeps its value (the control's change and the cell write are one group, rolled back together). From 6b9310657555596f5dbb77c77a0d887af8026162 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:14:17 -0500 Subject: [PATCH 45/55] XLSX: a kept control is matched to the modelled ones as the reader reads it, in either quote style without_controls found the controls to drop from a kept fragment by splitting its text on `shapeId="`. The control reader takes the attribute through the XML parser, which also accepts `shapeId='1025'`, so a producer's single-quoted control was modelled, its VML shape taken out of the kept drawing, and the control itself still kept: a save wrote it twice, the kept copy pointing at a shape that no longer existed. The kept child is now parsed and its controls found with the reader's own helpers (controls_of_child, shape_number), so what the reader modelled is what is dropped. The shape type check of the VML merge (``) accepts either quote style too, so a kept drawing that defines our shape type with single quotes is not given a second definition. Test: a_single_quoted_shape_id_is_dropped_from_the_kept_controls_too (the control is read once, nothing of it is kept, the saved sheet holds one control and no single-quoted copy, the VML one shape, and the file reads back with one control). --- crates/xlsx/src/controls.rs | 31 +++++++++++---------- crates/xlsx/src/passthrough/read.rs | 31 ++++++++++++++++++--- crates/xlsx/src/tests/controls_tests.rs | 36 +++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index 75c8032df..da22eb127 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -357,22 +357,25 @@ fn build(p: Props, kind: ControlKind, anchor: Anchor, name: Option<&str>, id: u3 c } +/// The `` elements one child of a `` element holds: the child itself, or those +/// inside its `mc:AlternateContent` (the Choice, else the Fallback). +pub(crate) fn controls_of_child(k: &El) -> Vec<&El> { + match k.name.as_str() { + "control" => vec![k], + "AlternateContent" => k + .child("Choice") + .filter(|c| c.child("control").is_some()) + .or_else(|| k.child("Fallback")) + .map(|inner| inner.kids("control").collect()) + .unwrap_or_default(), + _ => Vec::new(), + } +} + /// The `` elements under a `` element, looking through `mc:AlternateContent` /// (the Choice, else the Fallback). fn controls_in(e: &El) -> Vec<&El> { - let mut out = Vec::new(); - for k in &e.children { - match k.name.as_str() { - "control" => out.push(k), - "AlternateContent" => { - if let Some(inner) = k.child("Choice").filter(|c| c.child("control").is_some()).or_else(|| k.child("Fallback")) { - out.extend(inner.kids("control")); - } - } - _ => {} - } - } - out + e.children.iter().flat_map(controls_of_child).collect() } /// The `` element of a worksheet child (`controls`, or one inside AlternateContent). @@ -385,7 +388,7 @@ pub fn controls_of(e: &El) -> Option<&El> { } /// The shape number of a VML shape id (`_x0000_s1025` → 1025) or a `shapeId` attribute. -fn shape_number(id: &str) -> Option { +pub(crate) fn shape_number(id: &str) -> Option { id.trim().trim_start_matches("_x0000_s").parse().ok() } diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs index dad81a9c5..4fc55b9c6 100644 --- a/crates/xlsx/src/passthrough/read.rs +++ b/crates/xlsx/src/passthrough/read.rs @@ -630,8 +630,8 @@ pub(super) fn merge_vml(kept: &str, ours: &str) -> String { while let Some(i) = ours.get(from..).and_then(|s| s.find("")).map(|j| i + j + "".len()) else { break }; let ty = ours.get(i..j).unwrap_or(""); - let id = ty.split("id=\"").nth(1).and_then(|r| r.split('"').next()).unwrap_or(""); - if !id.is_empty() && !kept.contains(&format!("id=\"{id}\"")) { + let id = attr_value(ty, "id").unwrap_or(""); + if !id.is_empty() && !kept.contains(&format!("id=\"{id}\"")) && !kept.contains(&format!("id='{id}'")) { add.push_str(ty); } from = j; @@ -660,6 +660,25 @@ pub(super) fn merge_vml(kept: &str, ours: &str) -> String { format!("{head}{add}{}", kept.get(close..).unwrap_or("")) } +/// The value of the first attribute `name` in XML text `x`, in either quote style (`name="v"` or +/// `name='v'`), when it has one. The name must start a token (`id` is not the end of `spid`). +fn attr_value<'a>(x: &'a str, name: &str) -> Option<&'a str> { + let mut from = 0; + while let Some(i) = x.get(from..)?.find(name).map(|i| i + from) { + from = i + name.len(); + let starts_token = x.get(..i).is_some_and(|before| before.ends_with(|c: char| c.is_whitespace() || c == '<')); + let Some(rest) = x.get(from..).map(str::trim_start).and_then(|r| r.strip_prefix('=')).map(str::trim_start) else { continue }; + if !starts_token { + continue; + } + if let Some(q) = rest.chars().next().filter(|q| *q == '"' || *q == '\'') { + let body = rest.get(q.len_utf8()..)?; + return body.find(q).and_then(|e| body.get(..e)); + } + } + None +} + /// A kept `` fragment without the controls whose `shapeId` is in `drop` (they are /// written from the model). `None` when no control is left. A fragment that doesn't parse is /// returned unchanged. @@ -667,8 +686,14 @@ fn without_controls(frag: &str, drop: &HashSet) -> Option { if drop.is_empty() { return Some(frag.to_string()); } + // A kept child names its shapes the way the control reader reads them (`shapeId` in either + // quote style, through mc:AlternateContent), so what the reader modelled is what is dropped. let numbered = |x: &str| { - x.split("shapeId=\"").skip(1).any(|r| r.split('"').next().and_then(|n| n.trim().parse::().ok()).is_some_and(|n| drop.contains(&n))) + crate::xml::parse(x.as_bytes()).is_ok_and(|e| { + crate::controls::controls_of_child(&e) + .iter() + .any(|c| c.attr("shapeId").and_then(crate::controls::shape_number).is_some_and(|n| drop.contains(&n))) + }) }; let mut reader = Reader::from_str(frag); let mut out = String::with_capacity(frag.len()); diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index 7aedc51d3..323ca14f3 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -181,6 +181,42 @@ fn vml_only_controls_are_read_and_clamped() { assert_eq!(c[2].action, Some(ControlAction::Macro { name: "Tidy".into(), input: MacroInput::Selection, output: None })); } +/// A producer that quotes `shapeId` with single quotes: the control is read, and the kept +/// `` fragment drops it as it drops a double-quoted one, so a save holds the control +/// once and no kept copy points at a VML shape that was taken out. +#[test] +fn a_single_quoted_shape_id_is_dropped_from_the_kept_controls_too() { + let rels = r#""#; + let vml = r##"2, 0, 3, 0, 5, 0, 4, 0$B$1400100110"##; + let bytes = minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", vml)], + "", + "", + ); + let (wb, report) = read_xlsx(&bytes).unwrap(); + // The minimal package has no styles part; nothing about the control is reported. + assert!(report.warnings.iter().all(|w| w == "no styles part"), "{:?}", report.warnings); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1); + assert_eq!((sh.controls[0].kind, sh.controls[0].value, sh.controls[0].name.as_str()), (ControlKind::Slider, 40, "Scroll 1")); + let kept = sh.preserved.as_ref().and_then(|k| k.elements.iter().find(|e| e.slot == "controls")).map(|e| e.xml.clone()); + assert_eq!(kept, None, "nothing of the modelled control is kept for pass-through"); + // The saved file holds the control once, with its one VML shape, and reads back once. + let out = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + // One element with one control (each control is written as a Choice with a + // Fallback inside the element's own AlternateContent), and no kept copy. + assert_eq!(sheet.matches("").count(), 1, "{sheet}"); + assert!(!sheet.contains("shapeId='1025'"), "the kept copy would point at a shape that is gone: {sheet}"); + assert_eq!(text(&mut pkg, "xl/drawings/vmlDrawing1.vml").matches("
Go
1, 0, 6, 0, 3, 0, 7, 0[0]!Tidy
From 5cbc168441baf817fdf9375fbc111c1f177ad79f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:39:45 -0500 Subject: [PATCH 46/55] XLSX: a kept control beside a modelled one in the same AlternateContent stays with pass-through without_controls dropped a whole child of a kept element as soon as one control in it was modelled, so an mc:AlternateContent holding a scroll bar (modelled) and a list box (left to pass-through) lost the list box's worksheet entry on save, while its VML shape and properties part stayed. The controls are now taken out one by one, in the Choice and the Fallback branches alike, by the shapeId the control reader reads (an attribute, in either quote style); the wrapper stays while a control remains and goes only when it is empty, and a child with none of ours is kept unchanged. strip_shapes already works per shape, so the kept control's shape stays, and its properties part follows its relationship as before. Test: an_unsupported_control_beside_a_modelled_one_in_one_alternate_content_is_kept (after open and save the scroll bar is written once, the list box is still in in both branches with its VML shape and its properties part, and the file reads back with one modelled control and the kept one). --- crates/xlsx/src/controls.rs | 2 +- crates/xlsx/src/passthrough/read.rs | 69 +++++++++++++++++++------ crates/xlsx/src/tests/controls_tests.rs | 62 ++++++++++++++++++++++ 3 files changed, 117 insertions(+), 16 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index da22eb127..6f56894cd 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -359,7 +359,7 @@ fn build(p: Props, kind: ControlKind, anchor: Anchor, name: Option<&str>, id: u3 /// The `` elements one child of a `` element holds: the child itself, or those /// inside its `mc:AlternateContent` (the Choice, else the Fallback). -pub(crate) fn controls_of_child(k: &El) -> Vec<&El> { +fn controls_of_child(k: &El) -> Vec<&El> { match k.name.as_str() { "control" => vec![k], "AlternateContent" => k diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs index 4fc55b9c6..4e2264e6a 100644 --- a/crates/xlsx/src/passthrough/read.rs +++ b/crates/xlsx/src/passthrough/read.rs @@ -680,21 +680,14 @@ fn attr_value<'a>(x: &'a str, name: &str) -> Option<&'a str> { } /// A kept `` fragment without the controls whose `shapeId` is in `drop` (they are -/// written from the model). `None` when no control is left. A fragment that doesn't parse is -/// returned unchanged. +/// written from the model): a bare `` child goes when it is one of them, and within an +/// `mc:AlternateContent` child only those controls go, in each of its branches, the wrapper +/// staying while a control remains. `None` when nothing is left. A fragment that doesn't parse +/// is returned unchanged. fn without_controls(frag: &str, drop: &HashSet) -> Option { if drop.is_empty() { return Some(frag.to_string()); } - // A kept child names its shapes the way the control reader reads them (`shapeId` in either - // quote style, through mc:AlternateContent), so what the reader modelled is what is dropped. - let numbered = |x: &str| { - crate::xml::parse(x.as_bytes()).is_ok_and(|e| { - crate::controls::controls_of_child(&e) - .iter() - .any(|c| c.attr("shapeId").and_then(crate::controls::shape_number).is_some_and(|n| drop.contains(&n))) - }) - }; let mut reader = Reader::from_str(frag); let mut out = String::with_capacity(frag.len()); let (mut last, mut left, mut inside) = (0usize, 0usize, false); @@ -727,17 +720,63 @@ fn without_controls(frag: &str, drop: &HashSet) -> Option { continue; } let Ok(end) = usize::try_from(reader.buffer_position()) else { return Some(frag.to_string()) }; - if numbered(frag.get(start..end).unwrap_or("")) { - out.push_str(frag.get(last..start).unwrap_or("")); - last = end; - } else { + let text = frag.get(start..end).unwrap_or(""); + let Some((kept, controls, dropped)) = controls_kept(text, drop) else { return Some(frag.to_string()) }; + if dropped == 0 { + // Nothing of ours in it: the child stays as it is. left += 1; + } else { + out.push_str(frag.get(last..start).unwrap_or("")); + if controls > 0 { + out.push_str(&kept); + left += 1; + } + last = end; } } out.push_str(frag.get(last..).unwrap_or("")); (left > 0).then_some(out) } +/// One child of a `` element with the `` elements numbered in `drop` taken +/// out, wherever they are in it (a bare control, or the Choice and Fallback branches of an +/// `mc:AlternateContent`), as (the text, the controls that remain, the controls taken out). The +/// `shapeId` is read as the control reader reads it (an attribute, in either quote style). `None` +/// when the child doesn't parse. +fn controls_kept(child: &str, drop: &HashSet) -> Option<(String, usize, usize)> { + let mut reader = Reader::from_str(child); + let mut out = String::with_capacity(child.len()); + let (mut last, mut kept, mut dropped) = (0usize, 0usize, 0usize); + loop { + let start = usize::try_from(reader.buffer_position()).ok()?; + let (e, has_end) = match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) => (e, true), + Event::Empty(e) => (e, false), + _ => continue, + }; + if e.local_name().as_ref() != b"control" { + // A wrapper or a branch: its controls are the events that follow. + continue; + } + let number = + e.try_get_attribute("shapeId").ok().flatten().and_then(|a| std::str::from_utf8(&a.value).ok().and_then(crate::controls::shape_number)); + if has_end { + reader.read_to_end(e.name()).ok()?; + } + let end = usize::try_from(reader.buffer_position()).ok()?; + if number.is_some_and(|n| drop.contains(&n)) { + out.push_str(child.get(last..start)?); + last = end; + dropped += 1; + } else { + kept += 1; + } + } + out.push_str(child.get(last..)?); + Some((out, kept, dropped)) +} + /// Rewrites relationship-id attributes (`r:id`, `r:embed`…: any attribute in the relationships /// namespace) of an XML fragment through `map`. `None` when the fragment is damaged or `map` has /// no id for one of them. diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index 323ca14f3..46b81428c 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -217,6 +217,68 @@ fn a_single_quoted_shape_id_is_dropped_from_the_kept_controls_too() { assert_eq!(back.sheet(0).unwrap().controls.len(), 1); } +/// One `mc:AlternateContent` holds a scroll bar, which is modelled, and a list box, which stays +/// with pass-through: only the scroll bar leaves the kept fragment, the list box keeps its +/// worksheet entry, its VML shape and its properties part through a save, and the file reads +/// back with the one modelled control and the kept one. +#[test] +fn an_unsupported_control_beside_a_modelled_one_in_one_alternate_content_is_kept() { + let rels = r#""#; + let prop_a = r#""#; + let prop_b = r#""#; + let vml = r##"2, 0, 3, 0, 5, 0, 4, 0$B$14001001102, 0, 6, 0, 5, 0, 9, 0$D$1:$D$32"##; + let anchor = |from: u32, to: u32| { + format!( + r#"20{from}050{to}0"# + ) + }; + let controls = format!( + r#"{}{}"#, + anchor(3, 4), + anchor(6, 9) + ); + let bytes = minimal( + &format!(r#"{controls}"#), + &[ + ("xl/worksheets/_rels/sheet1.xml.rels", rels), + ("xl/ctrlProps/a.xml", prop_a), + ("xl/ctrlProps/b.xml", prop_b), + ("xl/drawings/vmlDrawing1.vml", vml), + ], + "", + "", + ); + let (wb, _) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1, "the scroll bar is modelled, the list box is not"); + assert_eq!((sh.controls[0].kind, sh.controls[0].name.as_str()), (ControlKind::Slider, "Scroll 1")); + let kept = sh.preserved.as_ref().and_then(|k| k.elements.iter().find(|e| e.slot == "controls")).map(|e| e.xml.clone()).unwrap_or_default(); + assert!(kept.contains("List 1") && kept.contains(""), "the list box stays, in its wrapper: {kept}"); + assert!(!kept.contains("Scroll 1") && !kept.contains("shapeId=\"1025\""), "the scroll bar leaves every branch: {kept}"); + // Saved: the scroll bar once (one Fallback of ours), the list box in the kept wrapper with its + // shape and its properties part. + let out = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + assert_eq!(sheet.matches(" = pkg.names().into_iter().filter(|n| n.starts_with("xl/ctrlprops/")).collect(); + assert_eq!(props.len(), 2, "ours and the kept one: {props:?}"); + assert!(props.iter().any(|n| text(&mut pkg, n).contains("objectType=\"List\"")), "the list box's properties part survives: {props:?}"); + // Read back: the one modelled control and the kept one. + let (back, _) = read_xlsx(&out).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1); + let kept = sh.preserved.as_ref().and_then(|k| k.elements.iter().find(|e| e.slot == "controls")).map(|e| e.xml.clone()).unwrap_or_default(); + assert!(kept.contains("List 1") && !kept.contains("Scroll 1"), "{kept}"); +} + /// Shapes with a named `id` and their number in `o:spid`, as some producers write them. const VML_SPID: &str = r##"
Go
1, 0, 6, 0, 3, 0, 7, 0[0]!Tidy
From f18ec0407fa1715812281a3c696f44815492463c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:06:25 -0500 Subject: [PATCH 47/55] Form controls: a fixed input range is refused for a macro that takes no inputs A button could be given a fixed input range for any macro name; pressing it then passed the range to macro.run as `inputs`, which check_params refuses for a macro without inputs ("it takes no inputs"), so such a button failed on every press. Now insert.button and control.assign refuse a fixed input when the named macro exists and declares no inputs, naming the macro; a macro the workbook does not have yet is not checked, and the assignment is kept as given. On open, a stored fixed input for a macro without inputs becomes the selection: the file itself assigns the macro on the selection, and the stored range would fail every press. Tests: a_fixed_input_for_a_macro_without_inputs_is_refused_when_assigned (insert and assign are refused with the reason in any letter case; a macro with an input and a macro not yet defined take the range; a stored fixed input comes back as the selection). format_and_assign and a_kept_button_action_comes_back_only_on_the_same_button give their macro an input. --- crates/engine/src/cmd/controls.rs | 22 ++++++++++++++++- crates/engine/src/tests/controls.rs | 38 +++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index a7c0afbf8..5acfb75b6 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -404,6 +404,14 @@ pub(crate) fn take_requests(s: &mut Session, queued: usize, name: &str) -> Optio blocked } +/// The stored name of the workbook's macro `name` when it exists and declares no inputs: a fixed +/// input range would make every press fail (`macro.run` refuses inputs it doesn't take), so such +/// an assignment is refused when it is made and dropped to the selection when a file brings one +/// in. A macro the workbook doesn't have (yet) is not checked: the assignment is kept as given. +fn takes_no_inputs(wb: &Workbook, name: &str) -> Option { + crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).filter(|m| m.inputs.is_empty()).map(|m| m.name.clone()) +} + /// A button action from `macro` (with `input` and `output`) or `command` (with `params`). fn action_param(s: &Session, cmd: &str, p: &Json) -> Result> { if let Some(name) = str_param(p, "macro").map(str::trim).filter(|n| !n.is_empty()) { @@ -418,6 +426,9 @@ fn action_param(s: &Session, cmd: &str, p: &Json) -> Result Result> { /// button's name and anchor cell, the button at the stored place must have that name and anchor /// cell (another application may have added, removed or reordered controls), and it must not /// have been given another macro since (a stored macro action comes back only on a button that -/// still runs that macro, a stored command only on one the file gives no macro). +/// still runs that macro, a stored command only on one the file gives no macro). A stored fixed +/// input for a macro that takes no inputs becomes the selection (see [`takes_no_inputs`]). pub(crate) fn load_action_names(wb: &mut Workbook) { let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return }; stash::remove(wb, ACTIONS_KEY); @@ -681,6 +693,14 @@ pub(crate) fn load_action_names(wb: &mut Workbook) { if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index).is_some_and(same)) { continue; } + let action = match action { + // The macro takes no inputs now: the stored range would fail every press, so the + // button runs the macro on the selection, as the file itself assigns it. + ControlAction::Macro { name, input: MacroInput::Fixed(_), output } if takes_no_inputs(wb, &name).is_some() => { + ControlAction::Macro { name, input: MacroInput::Selection, output } + } + other => other, + }; if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index)) { c.action = Some(action); } diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs index 27c0a685a..2bcf5d671 100644 --- a/crates/engine/src/tests/controls.rs +++ b/crates/engine/src/tests/controls.rs @@ -148,7 +148,8 @@ fn format_and_assign() { s.execute("control.format", json!({"id": c, "value": 25})).unwrap(); assert_eq!(v(&s, "B1"), Value::Number(50.0), "without a link nothing is written"); // Buttons: text and action. - s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})).unwrap(); + // A fixed input needs a macro that takes one. + s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "x"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap(); let b = id(&s.execute("insert.button", json!({"text": "Go", "macro": "Fill", "input": "C2:D4", "output": "F2"})).unwrap()); let action = control(&s, b).action; assert_eq!(action, Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Fixed("C2:D4".into()), output: Some("F2".into()) })); @@ -380,7 +381,7 @@ fn reopen(s: &mut Session) { fn a_kept_button_action_comes_back_only_on_the_same_button() { use crate::cmd::controls::{load_action_names, with_action_names}; let mut s = s(); - s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap(); + s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap(); s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap(); s.execute("insert.button", json!({"at": "D6", "text": "Other"})).unwrap(); s.execute("insert.button", json!({"at": "D9", "text": "Fixed", "macro": "Mark", "input": "F1:G2"})).unwrap(); @@ -467,6 +468,39 @@ fn a_kept_action_without_the_buttons_name_or_anchor_is_dropped() { assert!(matches!(kept(&items), Some(ControlAction::Command { .. })), "the writer's own entry still comes back"); } +/// A fixed input range for a macro that declares no inputs would fail every press (`macro.run` +/// refuses inputs the macro doesn't take), so the assignment is refused when it is made, naming +/// the macro; a macro the workbook doesn't have yet is not checked, and a macro with an input +/// takes the range. A file that brings such a stored input in gets the macro on the selection. +#[test] +fn a_fixed_input_for_a_macro_without_inputs_is_refused_when_assigned() { + use crate::cmd::controls::{load_action_names, with_action_names}; + let mut s = s(); + s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})).unwrap(); + s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap(); + let want = "macro 'Fill' takes no inputs"; + assert!(err(s.execute("insert.button", json!({"at": "B2", "macro": "Fill", "input": "C2:D4"}))).contains(want)); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 0, "no button was placed"); + let b = id(&s.execute("insert.button", json!({"at": "B2", "macro": "Fill", "output": "F2"})).unwrap()); + let e = err(s.execute("control.assign", json!({"id": b, "macro": "fill", "input": "C2:D4"}))); + assert!(e.contains(want), "any letter case: {e}"); + assert_eq!(control(&s, b).action, Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Selection, output: Some("F2".into()) })); + s.execute("control.assign", json!({"id": b, "macro": "Mark", "input": "C2:D4"})).unwrap(); + s.execute("control.assign", json!({"id": b, "macro": "Later", "input": "C2:D4"})).unwrap(); + assert_eq!(control(&s, b).action, Some(ControlAction::Macro { name: "Later".into(), input: MacroInput::Fixed("C2:D4".into()), output: None })); + // A file's stored fixed input for Fill (another version assigned it) comes back as the selection. + s.execute("control.assign", json!({"id": b, "macro": "Fill", "output": "F2"})).unwrap(); + let mut wb = (*s.doc().unwrap().wb).clone(); + wb.sheet_mut(0).unwrap().controls[0].action = + Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Fixed("C2:D4".into()), output: Some("F2".into()) }); + let mut stored = with_action_names(&wb).unwrap().unwrap(); + load_action_names(&mut stored); + assert_eq!( + stored.sheets[0].controls[0].action, + Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Selection, output: Some("F2".into()) }) + ); +} + /// The saved xlsx bytes of `s`'s workbook. fn saved_xlsx(s: &mut Session) -> Vec { let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); From c1249e3ad102bedda6c2583e0e41323c2653fd37 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:06:25 -0500 Subject: [PATCH 48/55] XLSX: a modelled control joins a kept element whatever its namespace prefix The pass-through writer found the kept element's end by the literal ``, so a producer that prefixes the main namespace (``) got a second element for the modelled controls, which the worksheet schema does not allow. The close tag is now found by its local name with any prefix. No other literal tag search is left in rebase.rs; the ones in read.rs (VML ``, ``, `ObjectType="Note"`) rely on the fixed VML prefixes and are main's code. Test: a_modelled_control_joins_a_kept_controls_element_with_a_namespace_prefix (a kept `` with a list box and a modelled slider save as one controls element holding both, and the file reads back with the slider modelled and the list box kept). --- crates/xlsx/src/passthrough/rebase.rs | 12 +++++- crates/xlsx/src/tests/controls_tests.rs | 49 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/crates/xlsx/src/passthrough/rebase.rs b/crates/xlsx/src/passthrough/rebase.rs index 1ca5d82e1..87fe715c3 100644 --- a/crates/xlsx/src/passthrough/rebase.rs +++ b/crates/xlsx/src/passthrough/rebase.rs @@ -69,7 +69,7 @@ impl<'a> SheetKept<'a> { if let Some(mut x) = remap(&e.xml, &self.ids) { if *slot == "controls" && !placed - && let Some(i) = x.rfind("") + && let Some(i) = last_close_tag(&x, "controls") { x.insert_str(i, controls); placed = true; @@ -87,6 +87,16 @@ impl<'a> SheetKept<'a> { } } +/// The byte offset of the last close tag of `local` in `x`, whatever namespace prefix the file +/// gives it (`
`, ``). +fn last_close_tag(x: &str, local: &str) -> Option { + x.rmatch_indices("')?)?.trim_end(); + (name.rsplit(':').next().unwrap_or(name) == local).then_some(i) + }) +} + // ---------------------------------------------------------------- linked workbooks /// `wb` as it is saved into the folder `to` (absolute): the relative paths of its linked diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index 46b81428c..252942296 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -279,6 +279,55 @@ fn an_unsupported_control_beside_a_modelled_one_in_one_alternate_content_is_kept assert!(kept.contains("List 1") && !kept.contains("Scroll 1"), "{kept}"); } +/// A producer that prefixes the main namespace (``): the kept element is still the +/// sheet's one `` element, so a modelled control is written into it rather than into a +/// second one, which the worksheet schema does not allow. +#[test] +fn a_modelled_control_joins_a_kept_controls_element_with_a_namespace_prefix() { + use gridcraft_model::{Anchor, ControlKind, FormControl}; + let rels = r#""#; + let prop_b = r#""#; + let vml = r##"2, 0, 6, 0, 5, 0, 9, 0$D$1:$D$32"##; + let controls = r#"20605090"#; + let bytes = minimal( + &format!(r#"{controls}"#), + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/b.xml", prop_b), ("xl/drawings/vmlDrawing1.vml", vml)], + "", + "", + ); + let (mut wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.sheet(0).unwrap().controls.is_empty(), "the list box stays with pass-through"); + let kept = wb + .sheet(0) + .unwrap() + .preserved + .as_ref() + .and_then(|k| k.elements.iter().find(|e| e.slot == "controls")) + .map(|e| e.xml.clone()) + .unwrap_or_default(); + assert!(kept.contains(""), "kept with its prefix: {kept}"); + let anchor = Anchor { cell: gridcraft_core::CellRef::new(2, 1), width: 120.0, height: 20.0, ..Default::default() }; + wb.sheet_mut(0).unwrap().controls.push(FormControl { + id: 900, + kind: ControlKind::Slider, + anchor, + link: Some("$B$1".into()), + value: 40, + ..Default::default() + }); + let out = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + let opened = sheet.matches("").unwrap(); + let inner = &sheet[start..end]; + assert!(inner.contains("name=\"List 1\"") && inner.contains(""), "both controls inside the one element: {sheet}"); + let (back, _) = read_xlsx(&out).unwrap(); + assert_eq!(back.sheet(0).unwrap().controls.len(), 1, "the slider reads back; the list box stays kept"); +} + /// Shapes with a named `id` and their number in `o:spid`, as some producers write them. const VML_SPID: &str = r##"
Go
1, 0, 6, 0, 3, 0, 7, 0[0]!Tidy
From 7202d1cf7c90df03a920ba96b029a4c26ef9ea94 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:33:10 -0500 Subject: [PATCH 49/55] XLSX: a modelled control joins the Choice branch's element of a kept AlternateContent The pass-through writer put GridCraft's control entries before the last close tag of the kept fragment, so a kept mc:AlternateContent with separate elements in its Choice and Fallback got them in the Fallback, which the reader (and an application that knows x14) doesn't read: on reopen the control came back from its VML shape alone, without its name and its properties part. The entries now go into the element the reader reads: the Choice's when the Choice holds one, else the first one (a plain fragment, or a Fallback alone); a Fallback's element is left as the file had it. Tags are matched by local name, with any namespace prefix. Test: a_modelled_control_joins_the_choice_controls_element_of_a_kept_alternate_content (the saved Choice element holds the kept list box and the modelled slider, the Fallback only the list box; the file reads back with the slider's name, link and value from its worksheet entry and properties part, and the list box kept). --- crates/xlsx/src/passthrough/rebase.rs | 40 +++++++++++++++---- crates/xlsx/src/tests/controls_tests.rs | 53 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/passthrough/rebase.rs b/crates/xlsx/src/passthrough/rebase.rs index 87fe715c3..e9232658d 100644 --- a/crates/xlsx/src/passthrough/rebase.rs +++ b/crates/xlsx/src/passthrough/rebase.rs @@ -69,7 +69,7 @@ impl<'a> SheetKept<'a> { if let Some(mut x) = remap(&e.xml, &self.ids) { if *slot == "controls" && !placed - && let Some(i) = last_close_tag(&x, "controls") + && let Some(i) = controls_insert_at(&x) { x.insert_str(i, controls); placed = true; @@ -87,13 +87,39 @@ impl<'a> SheetKept<'a> { } } -/// The byte offset of the last close tag of `local` in `x`, whatever namespace prefix the file -/// gives it (``, ``). -fn last_close_tag(x: &str, local: &str) -> Option { - x.rmatch_indices("` fragment: just before the close +/// tag of the `` element the reader reads (`controls::controls_of`), whatever its +/// namespace prefix. In an `mc:AlternateContent` with separate `` elements in its +/// Choice and Fallback branches that is the Choice's, the one an application that knows the +/// x14 extensions reads; the Fallback's is left as the file had it. With no Choice, or a Choice +/// without a `` element, it is the first `` element (a plain fragment, or a +/// Fallback alone). +fn controls_insert_at(x: &str) -> Option { + let from = open_tag(x, "Choice").unwrap_or(0); + close_tag_from(x, from, "controls") +} + +/// The local name of the tag whose text starts at `name` (`x:controls` → `controls`). +fn local_name(name: &str) -> &str { + name.rsplit(':').next().unwrap_or(name) +} + +/// The byte offset of the first open tag of `local` in `x`, with any namespace prefix. +fn open_tag(x: &str, local: &str) -> Option { + x.match_indices('<').find_map(|(i, _)| { + let rest = x.get(i + 1..)?; + let end = rest.find(|c: char| c.is_whitespace() || c == '>' || c == '/')?; + (local_name(rest.get(..end)?) == local).then_some(i) + }) +} + +/// The byte offset of the first close tag of `local` at or after `from` in `x`, with any +/// namespace prefix (``, ``). +fn close_tag_from(x: &str, from: usize, local: &str) -> Option { + x.get(from..)?.match_indices("')?)?.trim_end(); - (name.rsplit(':').next().unwrap_or(name) == local).then_some(i) + (local_name(name) == local).then_some(from + i) }) } diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index 252942296..a3e2c214d 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -328,6 +328,59 @@ fn a_modelled_control_joins_a_kept_controls_element_with_a_namespace_prefix() { assert_eq!(back.sheet(0).unwrap().controls.len(), 1, "the slider reads back; the list box stays kept"); } +/// A kept `mc:AlternateContent` with separate `` elements in its Choice and Fallback: +/// a modelled control is written into the Choice's element, the one the reader (and an +/// application that knows x14) reads, so it comes back with its name and its properties part; +/// the Fallback's element is left as the file had it. +#[test] +fn a_modelled_control_joins_the_choice_controls_element_of_a_kept_alternate_content() { + use gridcraft_model::{Anchor, ControlKind, FormControl}; + let rels = r#""#; + let prop_b = r#""#; + let vml = r##"2, 0, 6, 0, 5, 0, 9, 0$D$1:$D$32"##; + let list = r#"20605090"#; + let controls = format!( + r#"{list}"# + ); + let bytes = minimal( + &format!(r#"{controls}"#), + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/b.xml", prop_b), ("xl/drawings/vmlDrawing1.vml", vml)], + "", + "", + ); + let (mut wb, _) = read_xlsx(&bytes).unwrap(); + assert!(wb.sheet(0).unwrap().controls.is_empty(), "the list box stays with pass-through"); + let anchor = Anchor { cell: gridcraft_core::CellRef::new(2, 1), width: 120.0, height: 20.0, ..Default::default() }; + wb.sheet_mut(0).unwrap().controls.push(FormControl { + id: 900, + kind: ControlKind::Slider, + anchor, + name: "Speed".into(), + link: Some("$B$1".into()), + value: 40, + ..Default::default() + }); + let out = write_xlsx(&wb).unwrap(); + let mut pkg = Package::open(&out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + // The Choice's comes first in the sheet, the Fallback's last. + let (first, last) = (sheet.find("
Go
1, 0, 6, 0, 3, 0, 7, 0[0]!Tidy
From 57ad523b8389ef4bcfd4a8e1509b299575590b1a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:39:50 -0500 Subject: [PATCH 50/55] Form controls: a button's question is answered only while the workbook, sheet, button, action, macro and selection it asked about are unchanged --- crates/ui-egui/src/control_ui.rs | 60 ++++++++- crates/ui-egui/tests/it/form_controls.rs | 156 ++++++++++++++++++++++- docs/macros.md | 4 + docs/ui-parity.md | 2 +- 4 files changed, 216 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/control_ui.rs b/crates/ui-egui/src/control_ui.rs index 4100ba2a3..70603ef1d 100644 --- a/crates/ui-egui/src/control_ui.rs +++ b/crates/ui-egui/src/control_ui.rs @@ -124,10 +124,43 @@ fn confirm_text(e: &str) -> Option { Some(rest.split_once(" (run again with").map_or(rest, |(q, _)| q).to_string()) } +/// What a press of the button `params` names would run, as text: the workbook, the sheet, the +/// button, its action, the content of the macro it runs, and the selection (a macro on the +/// selection, or a command without a range, works there). `None` when the active sheet has no +/// such button. A question is answered only while this is unchanged, so an answer never applies +/// to an action other than the one it asked about. +fn press_binding(app: &SheetApp, params: &Json) -> Option { + let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?; + let d = app.session.active()?; + let sheet = d.wb.active_sheet(); + let sh = d.wb.sheet(sheet)?; + let c = sh.controls.iter().find(|c| c.id == id && c.kind == ControlKind::Button)?; + let mac = match &c.action { + Some(ControlAction::Macro { name, .. }) => { + gridcraft_engine::macros::find(&d.wb, name).and_then(|i| d.wb.macros.get(i)).map(gridcraft_engine::macros::fingerprint) + } + _ => None, + }; + Some( + json!({ + "doc": d.uid, + "sheet": sheet, + "sheetName": sh.name, + "control": id, + "action": c.action, + "macro": mac, + "selection": d.selection, + }) + .to_string(), + ) +} + /// Presses a button (`control.press` with `params`). A question (approve the action, or make -/// room for a macro's output) opens a prompt whose buttons press it again with the answer; any -/// other error shows in a message box. +/// room for a macro's output) opens a prompt whose buttons press it again with the answer, bound +/// to what the question was about (see [`press_binding`]); any other error shows in a message +/// box. pub fn press(app: &mut SheetApp, params: Json) { + let bound = press_binding(app, ¶ms); let Err(e) = app.run("control.press", params.clone()) else { return }; let (text, choices) = if let Some(q) = approve_text(&e) { (q, json!([["Run", {"approve": true}]])) @@ -141,12 +174,17 @@ pub fn press(app: &mut SheetApp, params: Json) { app.message = Some(("Button".into(), crate::clean_error(&e))); return; }; - app.open_dialog("controlQuestion", json!({"text": text, "params": params, "choices": choices})); + app.open_dialog("controlQuestion", json!({"text": text, "params": params, "choices": choices, "bound": bound})); } pub fn open_question(params: &Json) -> Option { let text = params.get("text").and_then(Json::as_str)?; - let v = json!({"text": text, "params": params.get("params").cloned().unwrap_or_else(|| json!({})), "choices": params.get("choices").cloned().unwrap_or_else(|| json!([]))}); + let v = json!({ + "text": text, + "params": params.get("params").cloned().unwrap_or_else(|| json!({})), + "choices": params.get("choices").cloned().unwrap_or_else(|| json!([])), + "bound": params.get("bound").and_then(Json::as_str), + }); Some(Dialog::custom("controlQuestion", "Button", v)) } @@ -174,6 +212,20 @@ pub fn question_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &m if let Some(extra) = answer { *open = false; let mut params = d.values.get("params").cloned().unwrap_or_else(|| json!({})); + // The answer is for the action the question named: if the workbook, the sheet, the + // button, its action, its macro or the selection changed meanwhile (an agent reassigned + // it), nothing runs, and the next press asks about what it runs now. + let asked = d.values.get("bound").and_then(Json::as_str); + let now = press_binding(app, ¶ms); + if asked.is_none() || asked != now.as_deref() { + let why = if now.is_none() { + "The button is gone, so nothing ran." + } else { + "The button changed while the question was open, so nothing ran. Press it again to see what it runs now." + }; + app.message = Some(("Button".into(), why.into())); + return; + } if let (Some(p), Some(x)) = (params.as_object_mut(), extra.as_object()) { for (k, v) in x { p.insert(k.clone(), v.clone()); diff --git a/crates/ui-egui/tests/it/form_controls.rs b/crates/ui-egui/tests/it/form_controls.rs index 44d1f9f32..004563fe9 100644 --- a/crates/ui-egui/tests/it/form_controls.rs +++ b/crates/ui-egui/tests/it/form_controls.rs @@ -1,7 +1,8 @@ //! Form controls through real pointer and keyboard input: dragging a slider (one undo step), //! paging on the track, clicking and holding spin arrows, keyboard steps, a button running its //! macro, Cmd+click or right-click selecting a control without running it, right-to-left -//! sheets, and the Insert › Controls buttons, context menu and dialogs. +//! sheets, a button's questions (an answer runs only the action it asked about), and the Insert › +//! Controls buttons, context menu and dialogs. use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2}; use gridcraft_engine::Session; @@ -409,6 +410,159 @@ fn a_button_from_a_file_asks_before_it_runs() { assert!(h.state().dialog.is_none()); } +/// A file this test may write, removed first: `/gridcraft-press--`. +fn scratch(name: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("gridcraft-press-{}-{name}", std::process::id())); + let _ = std::fs::remove_file(&p); + p +} + +/// Points the fixture's button at `file.exportCsv` to `path` and presses it: the question about +/// the file opens. +fn ask_export(h: &mut Harness, button: u32, path: &std::path::Path) { + h.state_mut().run("control.assign", json!({"id": button, "command": "file.exportCsv", "params": {"path": path}})).unwrap(); + h.state_mut().press_button(button); + h.run_steps(4); + let d = h.state().dialog.clone().expect("a prompt"); + assert_eq!(d.name(), "controlQuestion"); + let text = d.values["text"].as_str().unwrap_or("").to_string(); + assert!(text.contains(&path.display().to_string()), "{text}"); +} + +/// Clicks `label` in the open prompt. +fn answer(h: &mut Harness, label: &str) { + use egui_kittest::kittest::Queryable as _; + h.get_by_label(label).click(); + h.run_steps(2); +} + +/// An agent reassigns the button while its question is open: the answer was about the old file, +/// so neither file is written, and the next press asks about the new one. +#[test] +fn an_answer_does_not_run_an_action_reassigned_while_the_question_was_open() { + let (mut h, ids) = fixture(); + let (a, b) = (scratch("a.csv"), scratch("b.csv")); + ask_export(&mut h, ids.button, &a); + h.state_mut().session.execute("control.assign", json!({"id": ids.button, "command": "file.exportCsv", "params": {"path": b}})).unwrap(); + answer(&mut h, " Run "); + assert!(!a.exists() && !b.exists(), "the stale answer ran nothing"); + assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "controlQuestion")); + let msg = h.state().message.clone().map(|m| m.1).unwrap_or_default(); + assert!(msg.contains("changed while the question was open"), "{msg}"); + // The approval was not cached for the new file either: pressing again asks about it. + h.state_mut().message = None; + h.state_mut().press_button(ids.button); + h.run_steps(4); + let text = h.state().dialog.clone().map(|d| d.values["text"].as_str().unwrap_or("").to_string()).unwrap_or_default(); + assert!(text.contains(&b.display().to_string()), "{text}"); + answer(&mut h, " Run "); + assert!(b.exists() && !a.exists(), "the new question's answer runs the new action"); + let _ = std::fs::remove_file(&b); + // A change to a different command, and to a macro, is refused the same way. + let c = scratch("c.csv"); + ask_export(&mut h, ids.button, &c); + h.state_mut().session.execute("control.assign", json!({"id": ids.button, "macro": "Mark"})).unwrap(); + answer(&mut h, " Run "); + assert!(!c.exists()); + assert_eq!(cell(&h, "H1"), Value::Empty, "the macro put in its place did not run"); +} + +/// Reassigning the same action (identical parameters) changes nothing the question asked about, +/// so the answer still runs it. +#[test] +fn an_answer_runs_when_the_action_is_reassigned_unchanged() { + let (mut h, ids) = fixture(); + let a = scratch("same.csv"); + ask_export(&mut h, ids.button, &a); + h.state_mut().session.execute("control.assign", json!({"id": ids.button, "command": "file.exportCsv", "params": {"path": a}})).unwrap(); + answer(&mut h, " Run "); + assert!(a.exists(), "{:?}", h.state().message); + assert!(h.state().message.is_none(), "{:?}", h.state().message); + let _ = std::fs::remove_file(&a); +} + +/// The button deleted, its workbook closed, or another workbook (with an identical button) +/// active while the question is open: nothing runs. +#[test] +fn an_answer_runs_nothing_when_the_button_or_its_workbook_is_gone() { + let (mut h, ids) = fixture(); + let a = scratch("gone.csv"); + ask_export(&mut h, ids.button, &a); + h.state_mut().session.execute("object.delete", json!({"kind": "control", "id": ids.button})).unwrap(); + answer(&mut h, " Run "); + assert!(!a.exists()); + let msg = h.state().message.clone().map(|m| m.1).unwrap_or_default(); + assert!(msg.contains("gone"), "{msg}"); + + // Another workbook with the same button (same id, same action) becomes active. + let (mut h, ids) = fixture(); + ask_export(&mut h, ids.button, &a); + let s = &mut h.state_mut().session; + s.new_workbook(); + for at in ["A1", "A5"] { + s.execute("insert.button", json!({"at": at})).unwrap(); + } + let id = s.execute("insert.button", json!({"at": "E12", "command": "file.exportCsv", "params": {"path": a}})).unwrap()["control"].as_u64(); + assert_eq!(id, Some(u64::from(ids.button)), "the other workbook's button has the same id"); + answer(&mut h, " Run "); + assert!(!a.exists(), "the answer belongs to the first workbook's button"); + + // The workbook closed. + let (mut h, ids) = fixture(); + ask_export(&mut h, ids.button, &a); + h.state_mut().session.execute("file.close", json!({"force": true, "document": true})).unwrap(); + assert!(h.state().session.doc().is_err(), "no workbook is open"); + answer(&mut h, " Run "); + assert!(!a.exists()); +} + +/// The overwrite question is about the cells the macro would write: when the macro (or the +/// selection it runs on) changes while it is open, Overwrite writes over nothing. +#[test] +fn an_overwrite_answer_applies_only_to_the_output_it_asked_about() { + let (mut h, ids) = fixture(); + let s = &mut h.state_mut().session; + s.execute("macro.save", json!({"name": "Note", "steps": [{"command": "cell.set", "params": {"cell": "H2", "input": "noted"}}]})).unwrap(); + s.execute("cell.set", json!({"cell": "H2", "input": "keep"})).unwrap(); + s.execute("cell.set", json!({"cell": "H3", "input": "keep too"})).unwrap(); + s.execute("control.assign", json!({"id": ids.button, "macro": "Note"})).unwrap(); + let ask = |h: &mut Harness| { + h.state_mut().press_button(ids.button); + h.run_steps(4); + let text = h.state().dialog.clone().map(|d| d.values["text"].as_str().unwrap_or("").to_string()).unwrap_or_default(); + assert!(text.contains("Overwrite them"), "{text}"); + }; + ask(&mut h); + // The macro now writes H3 instead. + h.state_mut() + .session + .execute( + "macro.save", + json!({"name": "Note", "replace": true, "steps": [{"command": "cell.set", "params": {"cell": "H3", "input": "noted"}}]}), + ) + .unwrap(); + answer(&mut h, " Overwrite "); + assert_eq!((cell(&h, "H2"), cell(&h, "H3")), (Value::text("keep"), Value::text("keep too"))); + // The output cell given to the button changes. + h.state_mut().message = None; + ask(&mut h); + h.state_mut().session.execute("control.assign", json!({"id": ids.button, "macro": "Note", "output": "H2"})).unwrap(); + answer(&mut h, " Overwrite "); + assert_eq!((cell(&h, "H2"), cell(&h, "H3")), (Value::text("keep"), Value::text("keep too"))); + // The selection the macro runs on changes. + h.state_mut().message = None; + h.state_mut().session.execute("control.assign", json!({"id": ids.button, "macro": "Note"})).unwrap(); + ask(&mut h); + h.state_mut().session.execute("selection.set", json!({"cell": "A21"})).unwrap(); + answer(&mut h, " Overwrite "); + assert_eq!(cell(&h, "H3"), Value::text("keep too")); + // Nothing changed: Overwrite overwrites. + h.state_mut().message = None; + ask(&mut h); + answer(&mut h, " Overwrite "); + assert_eq!(cell(&h, "H3"), Value::text("noted")); +} + #[test] fn on_a_right_to_left_sheet_a_control_works_where_it_is_drawn() { let (mut h, ids) = fixture(); diff --git a/docs/macros.md b/docs/macros.md index b4937fcbe..586800045 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -267,6 +267,10 @@ in a workbook opened from a file asks before it runs anything. Like a step, a bu never opens a web page or a dialog: `link.open` is refused when it is assigned or comes in from a file, and a command that would open a dialog or a page once it runs (a command given no parameters opens its dialog) is refused then, with the request dropped and nothing done. +In the window, a press that asks (approve the action, or overwrite data in the macro's way) opens +a prompt whose answer applies only to what it asked about: if the workbook, the sheet, the +button, its action, its macro or the selection changes while the prompt is open (an agent +reassigns the button), the answer runs nothing and the next press asks again. In xlsx a button carries its macro as Excel writes it (`macro="[0]!Name"`, a macro run on the selection). A command, a fixed input range or an output cell has no place there and is kept in a diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c4613d4cf..aa4154889 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -70,7 +70,7 @@ this logic, with one undo step. | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes, equations and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing | -| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file; the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter (a button gives the keyboard back as soon as the grid takes a key or a right-click; Space on a slider or spin button does what it does with no control focused: it toggles a checkbox cell or starts a cell edit like any other character); Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | +| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file (an answer runs only the action it asked about: if an agent changes the button, its macro or the selection while the prompt is open, nothing runs); the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter (a button gives the keyboard back as soon as the grid takes a key or a right-click; Space on a slider or spin button does what it does with no control focused: it toggles a checkbox cell or starts a cell edit like any other character); Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | | Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) | From 675d4b9a3c305445a6d88b8ffd9e47a67667d404 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:44:33 -0500 Subject: [PATCH 51/55] XLSX: form controls that only the VML drawing lists are kept on save when no element is left, as when the last listed control is modelled --- crates/xlsx/src/controls.rs | 50 +++++-- crates/xlsx/src/passthrough/read.rs | 21 +-- crates/xlsx/src/sheet_read.rs | 8 +- crates/xlsx/src/tests/controls_tests.rs | 151 +++++++++++++++++++++ crates/xlsx/src/tests/passthrough_tests.rs | 6 +- docs/file-format-parity.md | 2 +- 6 files changed, 213 insertions(+), 25 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index 6f56894cd..bf6d9858c 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -398,11 +398,33 @@ fn number_of(shape: &El) -> Option { shape.attr("id").and_then(shape_number).or_else(|| shape.attr("spid").and_then(shape_number)) } +/// What [`read`] took from a sheet's form controls, for pass-through. +#[derive(Debug, Default)] +pub struct ReadControls { + /// The VML shape numbers of the controls read into the model: GridCraft writes those, so they + /// are left out of the kept content. + pub modelled: HashSet, + /// The VML drawing also holds a control that the sheet doesn't list and the model doesn't + /// hold (a check box only the drawing has): the drawing is kept for it even when no + /// `` element is left to keep. + pub vml_only_unmodelled: bool, +} + +/// Whether a VML shape the sheet doesn't list is a control that only pass-through can keep: it +/// has client data that is neither a note (GridCraft writes notes) nor an embedded object's +/// picture (`Pict`, which means nothing without its sheet entry). +fn unlisted_kept(shape: &El) -> bool { + shape + .child("ClientData") + .and_then(|d| d.attr("ObjectType")) + .is_some_and(|t| !t.trim().eq_ignore_ascii_case("note") && !t.trim().eq_ignore_ascii_case("pict")) +} + /// Reads a sheet's buttons, scroll bars and spin buttons: each `` with its properties /// part, else its VML shape, and VML shapes the sheet doesn't list (files that only have VML). /// The shapes come from the VML drawing with relationship id `vml_rid` (``). /// Returns the shape numbers of the controls it read, so pass-through keeps only the others. -pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels: &[Rel], sheet: &mut Sheet) -> HashSet { +pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels: &[Rel], sheet: &mut Sheet) -> ReadControls { let mut read = HashSet::new(); let vml = match vml_rid.and_then(|id| rels.iter().find(|r| r.id == id && r.kind == "vmlDrawing")) { Some(r) => cx.pkg.read_xml(&r.target).ok().flatten(), @@ -477,19 +499,27 @@ pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels sheet.controls.push(build(p, kind, anchor, ctl.attr("name"), id)); read.insert(n); } - // Controls known only from the VML. + // Controls known only from the VML. The ones not read (a check box, or past the limit) stay + // in the kept drawing. + let mut vml_only_unmodelled = false; for s in &shapes { - let Some(n) = number_of(s) else { continue }; - if listed.contains(&n) || sheet.controls.len() >= MAX_CONTROLS { + let n = number_of(s); + if n.is_some_and(|n| listed.contains(&n)) { continue; } - let Some(p) = props_from_vml(s) else { continue }; - let (Some(kind), Some(anchor)) = (p.kind, vml_anchor(s, sheet)) else { continue }; - let id = cx.next_object_id(); - sheet.controls.push(build(p, kind, anchor, None, id)); - read.insert(n); + let modelled = n.filter(|_| sheet.controls.len() < MAX_CONTROLS).and_then(|n| { + let p = props_from_vml(s)?; + let (kind, anchor) = (p.kind?, vml_anchor(s, sheet)?); + let id = cx.next_object_id(); + sheet.controls.push(build(p, kind, anchor, None, id)); + read.insert(n); + Some(()) + }); + if modelled.is_none() && unlisted_kept(s) { + vml_only_unmodelled = true; + } } - read + ReadControls { modelled: read, vml_only_unmodelled } } fn anchor_from_markers(a: &El, sheet: &Sheet) -> Option { diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs index 4e2264e6a..73c176136 100644 --- a/crates/xlsx/src/passthrough/read.rs +++ b/crates/xlsx/src/passthrough/read.rs @@ -283,9 +283,10 @@ impl KeptReader { (!p.is_empty()).then(|| Arc::new(p)) } - /// Sheet-level content of the sheet part `part`. `read_controls` are the VML shape numbers of - /// the form controls the sheet reader modelled; they are left out of the kept content. - pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, read_controls: &HashSet) -> Option> { + /// Sheet-level content of the sheet part `part`. `controls` says which form controls the + /// sheet reader modelled (their VML shapes are left out of the kept content) and whether the + /// VML drawing holds others that only it lists. + pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, controls: &crate::controls::ReadControls) -> Option> { if !self.on { return None; } @@ -306,8 +307,8 @@ impl KeptReader { // Notes have one VML drawing per sheet; another one holds the header and footer pictures. let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count(); let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count(); - if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes { - self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen, read_controls); + if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes || controls.vml_only_unmodelled { + self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen, controls); } (!p.is_empty()).then(|| Arc::new(p)) } @@ -321,8 +322,9 @@ impl KeptReader { rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet, - read_controls: &HashSet, + controls: &crate::controls::ReadControls, ) { + let read_controls = &controls.modelled; let scan = match cx.pkg.read(part) { Ok(Some(bytes)) => scan_sheet(&bytes), _ => None, @@ -361,8 +363,9 @@ impl KeptReader { .into_iter() .filter_map(|(slot, x)| if slot == "controls" { without_controls(&x, read_controls).map(|x| (slot, x)) } else { Some((slot, x)) }) .collect(); - // Controls and OLE objects are drawn from shapes in the legacy VML drawing. - if frags.iter().any(|(slot, _)| slot != "picture") { + // Controls and OLE objects are drawn from shapes in the legacy VML drawing. Controls only + // the drawing lists need it too, whether or not a `` element is left. + if frags.iter().any(|(slot, _)| slot != "picture") || controls.vml_only_unmodelled { match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen, read_controls) { Ok((vml, parts)) => { p.parts.extend(parts); @@ -392,7 +395,7 @@ impl KeptReader { Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")), } } - if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") { + if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") || (controls.vml_only_unmodelled && p.vml.is_some()) { cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving")); } else { p.vml = None; diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index df4a4bf4e..f2047b6ee 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -1,6 +1,6 @@ //! Reading worksheet parts. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::date::{serial_from_ymd, time_fraction}; @@ -159,7 +159,7 @@ fn read_sheet_format(e: &El, sheet: &mut Sheet) { /// Reads the sheet part `part`, with the VML shape numbers of the form controls read into the /// model (pass-through keeps the other controls). -pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, HashSet), IoError> { +pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, crate::controls::ReadControls), IoError> { let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?; let rels = cx.pkg.rels(part)?; let mut sheet = Sheet::new(name); @@ -340,10 +340,10 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, Ha /// `mc:AlternateContent`) with its properties parts, else the shapes of the VML drawing that /// `` names (not the header and footer drawing of ``). /// Returns the VML shape numbers read, so pass-through keeps only the others. -fn read_form_controls(cx: &mut Ctx<'_>, root: &El, rels: &[Rel], sheet: &mut Sheet) -> HashSet { +fn read_form_controls(cx: &mut Ctx<'_>, root: &El, rels: &[Rel], sheet: &mut Sheet) -> crate::controls::ReadControls { let controls = root.children.iter().filter_map(crate::controls::controls_of).next_back(); let vml = root.child("legacyDrawing").and_then(|e| e.attr("id")); - if controls.is_some() || vml.is_some() { crate::controls::read(cx, controls, vml, rels, sheet) } else { HashSet::new() } + if controls.is_some() || vml.is_some() { crate::controls::read(cx, controls, vml, rels, sheet) } else { Default::default() } } /// A protection password hash: the iterated attributes (`algorithmName`, `hashValue`, diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index a3e2c214d..4fbb65218 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -574,3 +574,154 @@ fn controls_naming_one_properties_part_read_it_once() { })); assert_eq!(sh.comments.len(), 1, "a part read after the controls still reads: {:?}", report.warnings); } + +/// A legacy VML drawing with a button (`1025`, modelled), a check box (`1026`) and an option +/// button (`1027`, with a picture through the drawing's own relationship) that only the drawing +/// lists, plus `note` (a note shape for A1, with its comments part) when set. `listed`: the sheet's +/// `` lists the button. +fn vml_only_others(listed: bool, button: bool, note: bool) -> Vec { + const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + let mut rels = format!(r#""#); + if listed { + rels.push_str(&format!(r#""#)); + } + if note { + rels.push_str(&format!(r#""#)); + } + let rels = + format!(r#"{rels}"#); + let vml_rels = format!( + r#""# + ); + let prop_a = r#""#; + let mut shapes = String::new(); + if note { + shapes.push_str(r##"1, 15, 0, 2, 3, 15, 4, 400"##); + } + if button { + shapes.push_str(r##"
Go
2, 0, 3, 0, 4, 0, 5, 0[0]!Go
"##); + } + shapes.push_str(r##"
Paid
2, 0, 7, 0, 4, 0, 8, 0$B$71
"##); + shapes.push_str(r##"2, 0, 9, 0, 4, 0, 10, 0$B$9"##); + let vml = format!( + r#"{shapes}"# + ); + let controls = if listed { + r#"20304050"# + } else { + "" + }; + let notes = r#"Anncheck the totals"#; + let mut parts: Vec<(&str, &str)> = vec![ + ("xl/worksheets/_rels/sheet1.xml.rels", &rels), + ("xl/drawings/vmlDrawing1.vml", &vml), + ("xl/drawings/_rels/vmlDrawing1.vml.rels", &vml_rels), + ("xl/media/image1.png", "not really a png"), + ]; + if listed { + parts.push(("xl/ctrlProps/a.xml", prop_a)); + } + if note { + parts.push(("xl/comments1.xml", notes)); + } + // As `minimal` builds it, with content types for the picture and the drawing. + let ct = super::CT.replace( + "", + r#""#, + ); + let base = minimal(&format!(r#"{controls}"#), &parts, "", ""); + let mut zip = Package::open(&base).unwrap(); + let mut files: Vec<(String, Vec)> = vec![("[Content_Types].xml".into(), ct.into_bytes())]; + for n in zip.names() { + if n != "[Content_Types].xml" { + let data = zip.read(&n).unwrap().unwrap(); + files.push((n, data)); + } + } + super::make_zip(&files.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect::>()) +} + +/// Checks a saved package keeps the check box and option button shapes once each, in the sheet's +/// one VML drawing, with that drawing's one relationship to its one picture; returns the drawing. +fn kept_vml_only_others(out: &[u8]) -> String { + let mut pkg = Package::open(out).unwrap(); + let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml"); + assert_eq!(sheet.matches(" = names.iter().filter(|n| n.ends_with(".vml")).collect(); + assert_eq!(vmls.len(), 1, "{names:?}"); + let vml_name = vmls[0].clone(); + let vml = text(&mut pkg, &vml_name); + assert_eq!(vml.matches("ObjectType=\"Checkbox\"").count(), 1, "the check box is kept once: {vml}"); + assert_eq!(vml.matches("ObjectType=\"Radio\"").count(), 1, "the option button is kept once: {vml}"); + let (dir, file) = vml_name.rsplit_once('/').unwrap(); + let vml_rels = text(&mut pkg, &format!("{dir}/_rels/{file}.rels")); + assert_eq!(vml_rels.matches(" = names.iter().filter(|n| n.starts_with("xl/media/")).collect(); + assert_eq!(media.len(), 1, "the option button's picture once: {names:?}"); + vml +} + +/// The sheet's `` lists only a button, which is modelled; a check box and an option +/// button in the same VML drawing have no worksheet entry. With the button's entry gone from the +/// kept content no `` element is left, but the drawing still holds the two, so it is +/// kept: the save writes them beside the regenerated button, and they survive a second round trip. +#[test] +fn vml_only_controls_survive_when_the_last_listed_control_is_modelled() { + let bytes = vml_only_others(true, true, false); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1, "the button is modelled"); + assert_eq!(sh.controls[0].kind, ControlKind::Button); + assert!(sh.preserved.as_ref().is_some_and(|p| p.vml.is_some()), "the drawing is kept: {:?}", report.warnings); + assert!(report.warnings.iter().any(|w| w.contains("form controls are not shown")), "{:?}", report.warnings); + let out = write_xlsx(&wb).unwrap(); + let vml = kept_vml_only_others(&out); + assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "the button once, written from the model: {vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1); + assert_eq!(sh.controls[0].text, "Go"); + // Saved again: still once each. + let again = write_xlsx(&back).unwrap(); + let vml = kept_vml_only_others(&again); + assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "{vml}"); +} + +/// A sheet whose only controls are a check box and an option button that only the VML drawing +/// lists (no ``, no notes, nothing modelled): the drawing is kept on its own. +#[test] +fn a_sheet_whose_only_controls_are_vml_only_and_unmodelled_keeps_them() { + let bytes = vml_only_others(false, false, false); + let (wb, report) = read_xlsx(&bytes).unwrap(); + assert!(wb.sheet(0).unwrap().controls.is_empty()); + let out = write_xlsx(&wb).unwrap(); + let vml = kept_vml_only_others(&out); + assert!(!vml.contains("ObjectType=\"Button\""), "{vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + let again = write_xlsx(&back).unwrap(); + kept_vml_only_others(&again); + assert!(report.warnings.iter().any(|w| w.contains("form controls are not shown")), "{:?}", report.warnings); +} + +/// The same with a note in the drawing (one VML drawing for one comments part) and a VML-only +/// button that is modelled: the note and the button are written from the model, the check box and +/// option button stay, all in the one drawing. +#[test] +fn vml_only_controls_beside_a_note_are_kept() { + let bytes = vml_only_others(false, true, true); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!((sh.controls.len(), sh.comments.len()), (1, 1), "{:?}", report.warnings); + let out = write_xlsx(&wb).unwrap(); + let vml = kept_vml_only_others(&out); + assert_eq!(vml.matches("ObjectType=\"Note\"").count(), 1, "{vml}"); + assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "{vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!((sh.controls.len(), sh.comments.len()), (1, 1)); + let vml = kept_vml_only_others(&write_xlsx(&back).unwrap()); + assert_eq!(vml.matches("ObjectType=\"Note\"").count(), 1, "{vml}"); +} diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 80e4c1edc..a71b34422 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -849,6 +849,10 @@ const LOGO: &[u8] = b"\x89PNG\r\n\x1a\n letterhead logo"; const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n footer signature"; const HF_VML: &str = r##""##; +/// The VML drawing of one note and nothing else. [`VML`] also holds a check box that only the +/// drawing lists, which is kept with the drawing (and its source shape block). +const NOTE_VML: &str = r##"
old note
00
"##; + /// #1257: a letterhead in the header and a picture in the footer (`&G`), drawn from a VML part /// that `` names, next to a note whose VML drawing GridCraft rewrites. fn header_picture_package() -> Vec { @@ -875,7 +879,7 @@ fn header_picture_package() -> Vec { ("xl/worksheets/sheet1.xml", sheet.as_bytes()), ("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels.as_bytes()), ("xl/comments1.xml", COMMENTS.as_bytes()), - ("xl/drawings/vmlDrawing1.vml", VML.as_bytes()), + ("xl/drawings/vmlDrawing1.vml", NOTE_VML.as_bytes()), ("xl/drawings/vmlDrawing2.vml", HF_VML.as_bytes()), ("xl/drawings/_rels/vmlDrawing2.vml.rels", hf_rels.as_bytes()), ("xl/media/image1.png", LOGO), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 18b03f5cb..a3bc169bd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -76,7 +76,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests | |---|---|---|---|---|---| -| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), form controls (buttons, scroll bars and spin buttons: control properties parts, `` and VML `x:ClientData`; a VML-only control is read too; a button's command, fixed input range or output cell travels in a hidden defined name and comes back only onto the same button while the file still assigns its macro, or none for a command), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), other form controls (check boxes, list boxes, drop-downs; written in the same `` element and VML drawing as the modelled ones), ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | +| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), form controls (buttons, scroll bars and spin buttons: control properties parts, `` and VML `x:ClientData`; a VML-only control is read too; a button's command, fixed input range or output cell travels in a hidden defined name and comes back only onto the same button while the file still assigns its macro, or none for a command), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), other form controls (check boxes, list boxes, drop-downs; written in the same `` element and VML drawing as the modelled ones, also those only the VML drawing lists, with no `` entry), ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `` offsets and readings and `` are written back next to the same string; the table holds each text once, so duplicate `` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | | `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), pictures in headers and footers (`&G`, #1257: their VML drawing and images are written back unchanged; they are not shown, and can't be inserted), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` | From 00d79eb98aba2e55392c26a295094c25392375b1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:21:29 -0500 Subject: [PATCH 52/55] Form controls: control.format replies with the control as committed, so a new link's value shows --- crates/engine/src/cmd/controls.rs | 9 ++++++--- crates/engine/src/tests/controls.rs | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 5acfb75b6..f1064ce33 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -357,7 +357,6 @@ fn format(s: &mut Session, p: &Json) -> Result { let number = c.kind != ControlKind::Button; let write = number && (given.is_some() || p.get("min").is_some() || p.get("max").is_some() || p.get("step").is_some()); let (value, link) = (c.value, c.link.clone()); - let out = control_json(&c); s.grouped("Format Control", |s| { edit(s, |cx| { if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) { @@ -368,8 +367,12 @@ fn format(s: &mut Session, p: &Json) -> Result { if write { write_link(s, sheet, link.as_deref(), value)?; } - Ok(out) - }) + Ok(()) + })?; + // The reply is the control as committed: a new link to a cell holding another number has + // moved its value there. + let (_, _, c) = find(s, CMD, p)?; + Ok(control_json(&c)) } /// Commands a button never runs, beyond the macro policy's lists: a button has no way to show diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs index 2bcf5d671..cb86727fa 100644 --- a/crates/engine/src/tests/controls.rs +++ b/crates/engine/src/tests/controls.rs @@ -117,6 +117,22 @@ fn editing_the_linked_cell_moves_the_control() { assert_eq!(other.cell(gridcraft_core::CellRef::new(0, 0)).unwrap().value, Value::Number(30.0)); } +#[test] +fn format_replies_with_the_value_a_new_link_gives() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "C5", "input": "30"})).unwrap(); + let c = id(&s.execute("insert.slider", json!({"link": "B1", "max": 100, "value": 10})).unwrap()); + // Relinked to a cell that holds another number, the control takes that number, and the reply + // says so, as control.list does. + let r = s.execute("control.format", json!({"id": c, "link": "C5"})).unwrap(); + assert_eq!(control(&s, c).value, 30); + assert_eq!(r["value"], json!(30), "{r}"); + assert_eq!(r["link"], json!("$C$5"), "{r}"); + // A given value is written to the new link and replied as given. + let r = s.execute("control.format", json!({"id": c, "link": "D6", "value": 44})).unwrap(); + assert_eq!((r["value"].clone(), v(&s, "D6")), (json!(44), Value::Number(44.0))); +} + #[test] fn links_follow_inserted_rows() { let mut s = s(); From 5725f7b44e4752e7d1268413d8ba09ddc3be5f09 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:44:00 -0500 Subject: [PATCH 53/55] XLSX: a kept VML drawing drops a note whatever the case or quotes of its ObjectType, so a comment gets one note --- crates/xlsx/src/passthrough/read.rs | 16 ++++++++++-- crates/xlsx/src/tests/controls_tests.rs | 34 ++++++++++++++++++------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs index 73c176136..7ad0f8867 100644 --- a/crates/xlsx/src/passthrough/read.rs +++ b/crates/xlsx/src/passthrough/read.rs @@ -577,14 +577,26 @@ fn shape_spans(vml: &str) -> Vec<(usize, usize)> { spans } -/// VML without its note shapes (`ObjectType="Note"`) and the shapes numbered in `drop`. +/// Whether a VML shape is a note: its client data's `ObjectType` is `Note` in any case, in either +/// quote and with spaces around it, as the control reader reads it. +fn is_note_shape(shape: &str) -> bool { + shape.match_indices("ObjectType").any(|(i, m)| { + let Some(rest) = shape.get(i + m.len()..).map(str::trim_start).and_then(|r| r.strip_prefix('=')).map(str::trim_start) else { + return false; + }; + let Some(q) = rest.chars().next().filter(|c| matches!(c, '"' | '\'')) else { return false }; + rest.get(1..).and_then(|v| v.split(q).next()).is_some_and(|v| v.trim().eq_ignore_ascii_case("note")) + }) +} + +/// VML without its note shapes (see [`is_note_shape`]) and the shapes numbered in `drop`. fn strip_shapes(vml: &str, drop: &HashSet) -> String { let mut out = String::with_capacity(vml.len()); let mut last = 0; for (a, b) in shape_spans(vml) { let shape = vml.get(a..b).unwrap_or(""); let numbered = shape_numbers(shape).first().is_some_and(|n| drop.contains(n)); - if shape.contains("ObjectType=\"Note\"") || numbered { + if is_note_shape(shape) || numbered { out.push_str(vml.get(last..a).unwrap_or("")); last = b; } diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index 4fbb65218..8936e0153 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -577,15 +577,16 @@ fn controls_naming_one_properties_part_read_it_once() { /// A legacy VML drawing with a button (`1025`, modelled), a check box (`1026`) and an option /// button (`1027`, with a picture through the drawing's own relationship) that only the drawing -/// lists, plus `note` (a note shape for A1, with its comments part) when set. `listed`: the sheet's +/// lists, plus `note` (a note shape for A1 whose client data carries that `ObjectType` attribute, +/// with its comments part) when given. `listed`: the sheet's /// `` lists the button. -fn vml_only_others(listed: bool, button: bool, note: bool) -> Vec { +fn vml_only_others(listed: bool, button: bool, note: Option<&str>) -> Vec { const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; let mut rels = format!(r#""#); if listed { rels.push_str(&format!(r#""#)); } - if note { + if note.is_some() { rels.push_str(&format!(r#""#)); } let rels = @@ -595,8 +596,8 @@ fn vml_only_others(listed: bool, button: bool, note: bool) -> Vec { ); let prop_a = r#""#; let mut shapes = String::new(); - if note { - shapes.push_str(r##"1, 15, 0, 2, 3, 15, 4, 400"##); + if let Some(attr) = note { + shapes.push_str(&format!(r##"1, 15, 0, 2, 3, 15, 4, 400"##)); } if button { shapes.push_str(r##"
Go
2, 0, 3, 0, 4, 0, 5, 0[0]!Go
"##); @@ -621,7 +622,7 @@ fn vml_only_others(listed: bool, button: bool, note: bool) -> Vec { if listed { parts.push(("xl/ctrlProps/a.xml", prop_a)); } - if note { + if note.is_some() { parts.push(("xl/comments1.xml", notes)); } // As `minimal` builds it, with content types for the picture and the drawing. @@ -670,7 +671,7 @@ fn kept_vml_only_others(out: &[u8]) -> String { /// kept: the save writes them beside the regenerated button, and they survive a second round trip. #[test] fn vml_only_controls_survive_when_the_last_listed_control_is_modelled() { - let bytes = vml_only_others(true, true, false); + let bytes = vml_only_others(true, true, None); let (wb, report) = read_xlsx(&bytes).unwrap(); let sh = wb.sheet(0).unwrap(); assert_eq!(sh.controls.len(), 1, "the button is modelled"); @@ -694,7 +695,7 @@ fn vml_only_controls_survive_when_the_last_listed_control_is_modelled() { /// lists (no ``, no notes, nothing modelled): the drawing is kept on its own. #[test] fn a_sheet_whose_only_controls_are_vml_only_and_unmodelled_keeps_them() { - let bytes = vml_only_others(false, false, false); + let bytes = vml_only_others(false, false, None); let (wb, report) = read_xlsx(&bytes).unwrap(); assert!(wb.sheet(0).unwrap().controls.is_empty()); let out = write_xlsx(&wb).unwrap(); @@ -711,7 +712,7 @@ fn a_sheet_whose_only_controls_are_vml_only_and_unmodelled_keeps_them() { /// option button stay, all in the one drawing. #[test] fn vml_only_controls_beside_a_note_are_kept() { - let bytes = vml_only_others(false, true, true); + let bytes = vml_only_others(false, true, Some(r#"ObjectType="Note""#)); let (wb, report) = read_xlsx(&bytes).unwrap(); let sh = wb.sheet(0).unwrap(); assert_eq!((sh.controls.len(), sh.comments.len()), (1, 1), "{:?}", report.warnings); @@ -725,3 +726,18 @@ fn vml_only_controls_beside_a_note_are_kept() { let vml = kept_vml_only_others(&write_xlsx(&back).unwrap()); assert_eq!(vml.matches("ObjectType=\"Note\"").count(), 1, "{vml}"); } + +#[test] +fn a_note_spelled_in_another_case_or_quote_is_not_kept_beside_the_written_one() { + // The note's client data reads ObjectType='note': still a note, so the kept drawing drops it + // and the note GridCraft writes for the comment is the only one. + for attr in ["ObjectType='note'", "ObjectType = \"NOTE\"", "ObjectType=\" Note \""] { + let (wb, report) = read_xlsx(&vml_only_others(false, true, Some(attr))).unwrap(); + assert_eq!(wb.sheet(0).unwrap().comments.len(), 1, "{attr}: {:?}", report.warnings); + let out = write_xlsx(&wb).unwrap(); + let vml = kept_vml_only_others(&out).to_ascii_lowercase().replace(['\'', ' '], "\""); + assert_eq!(vml.matches("objecttype=\"note\"").count() + vml.matches("objecttype=\"\"note\"\"").count(), 1, "{attr}: {vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + assert_eq!(back.sheet(0).unwrap().comments.len(), 1, "{attr}"); + } +} From 02df071b4c719d46c939968428d48a25ea30b287 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:18:54 -0500 Subject: [PATCH 54/55] XLSX: a kept VML drawing drops a shape as a note only when its client data's ObjectType is Note, so a control whose caption or other attribute reads like one is kept --- crates/xlsx/src/controls.rs | 16 +++++-- crates/xlsx/src/passthrough/read.rs | 20 +++----- crates/xlsx/src/tests/controls_tests.rs | 61 ++++++++++++++++++++++++- crates/xlsx/src/xml.rs | 23 ++++++++++ 4 files changed, 100 insertions(+), 20 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index bf6d9858c..0a9167f85 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -414,10 +414,18 @@ pub struct ReadControls { /// has client data that is neither a note (GridCraft writes notes) nor an embedded object's /// picture (`Pict`, which means nothing without its sheet entry). fn unlisted_kept(shape: &El) -> bool { - shape - .child("ClientData") - .and_then(|d| d.attr("ObjectType")) - .is_some_and(|t| !t.trim().eq_ignore_ascii_case("note") && !t.trim().eq_ignore_ascii_case("pict")) + object_type_of(shape).is_some_and(|t| !t.eq_ignore_ascii_case("note") && !t.eq_ignore_ascii_case("pict")) +} + +/// A VML shape's client data `ObjectType`, trimmed (`Note`, `Button`, `Checkbox`). +fn object_type_of(shape: &El) -> Option<&str> { + shape.child("ClientData").and_then(|d| d.attr("ObjectType")).map(str::trim) +} + +/// Whether a VML shape's client data is a note's: its `ObjectType` is `Note` in any case and with +/// spaces around it. Only that attribute counts, not text elsewhere in the shape (a caption). +pub(crate) fn is_note_data(data: &El) -> bool { + data.attr("ObjectType").is_some_and(|t| t.trim().eq_ignore_ascii_case("note")) } /// Reads a sheet's buttons, scroll bars and spin buttons: each `` with its properties diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs index 7ad0f8867..669812597 100644 --- a/crates/xlsx/src/passthrough/read.rs +++ b/crates/xlsx/src/passthrough/read.rs @@ -577,26 +577,18 @@ fn shape_spans(vml: &str) -> Vec<(usize, usize)> { spans } -/// Whether a VML shape is a note: its client data's `ObjectType` is `Note` in any case, in either -/// quote and with spaces around it, as the control reader reads it. -fn is_note_shape(shape: &str) -> bool { - shape.match_indices("ObjectType").any(|(i, m)| { - let Some(rest) = shape.get(i + m.len()..).map(str::trim_start).and_then(|r| r.strip_prefix('=')).map(str::trim_start) else { - return false; - }; - let Some(q) = rest.chars().next().filter(|c| matches!(c, '"' | '\'')) else { return false }; - rest.get(1..).and_then(|v| v.split(q).next()).is_some_and(|v| v.trim().eq_ignore_ascii_case("note")) - }) -} - -/// VML without its note shapes (see [`is_note_shape`]) and the shapes numbered in `drop`. +/// VML without its note shapes and the shapes numbered in `drop`. A note is told as the control +/// reader tells it, from the shape's client data tag and never from text elsewhere in the shape: +/// its `ObjectType` ([`crate::controls::is_note_data`]). The tag is read on its own +/// ([`xml::first_start`]), so a caption's unclosed `
` doesn't hide it. fn strip_shapes(vml: &str, drop: &HashSet) -> String { let mut out = String::with_capacity(vml.len()); let mut last = 0; for (a, b) in shape_spans(vml) { let shape = vml.get(a..b).unwrap_or(""); + let note = xml::first_start(shape.as_bytes(), "ClientData").is_some_and(|d| crate::controls::is_note_data(&d)); let numbered = shape_numbers(shape).first().is_some_and(|n| drop.contains(n)); - if is_note_shape(shape) || numbered { + if note || numbered { out.push_str(vml.get(last..a).unwrap_or("")); last = b; } diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index 8936e0153..ec4b66c09 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -735,9 +735,66 @@ fn a_note_spelled_in_another_case_or_quote_is_not_kept_beside_the_written_one() let (wb, report) = read_xlsx(&vml_only_others(false, true, Some(attr))).unwrap(); assert_eq!(wb.sheet(0).unwrap().comments.len(), 1, "{attr}: {:?}", report.warnings); let out = write_xlsx(&wb).unwrap(); - let vml = kept_vml_only_others(&out).to_ascii_lowercase().replace(['\'', ' '], "\""); - assert_eq!(vml.matches("objecttype=\"note\"").count() + vml.matches("objecttype=\"\"note\"\"").count(), 1, "{attr}: {vml}"); + let vml = kept_vml_only_others(&out); + assert_eq!(note_shapes(&vml), 1, "{attr}: {vml}"); let (back, _) = read_xlsx(&out).unwrap(); assert_eq!(back.sheet(0).unwrap().comments.len(), 1, "{attr}"); } } + +/// The note shapes in VML text, counted as the reader tells a note: by each shape's client data +/// `ObjectType`, whatever its spelling. +fn note_shapes(vml: &str) -> usize { + crate::xml::parse(vml.as_bytes()).unwrap().kids("shape").filter(|s| s.child("ClientData").is_some_and(crate::controls::is_note_data)).count() +} + +/// The saved package's one VML drawing. +fn only_vml(out: &[u8]) -> String { + let mut pkg = Package::open(out).unwrap(); + let names: Vec = pkg.names().into_iter().filter(|n| n.ends_with(".vml")).collect(); + assert_eq!(names.len(), 1, "{names:?}"); + text(&mut pkg, &names[0]) +} + +/// A sheet's VML drawing, as the only part beside the sheet's relationships. +fn with_vml(shapes: &str) -> Vec { + let rels = r#""#; + let vml = format!( + r#"{shapes}"# + ); + minimal( + r#""#, + &[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", &vml)], + "", + "", + ) +} + +/// Text that reads like a note's `ObjectType` in a check box's caption or in another attribute of +/// an option button doesn't make either a note: both are kept, and a modelled button with that +/// caption keeps it. +#[test] +fn a_control_whose_text_reads_like_a_note_object_type_is_kept() { + let bytes = with_vml(concat!( + r##"
ObjectType="Note"
2, 0, 3, 0, 4, 0, 5, 0
"##, + r##"
ObjectType="Note"
2, 0, 7, 0, 4, 0, 8, 0$B$7
"##, + r##"2, 0, 9, 0, 4, 0, 10, 0"##, + )); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1, "{:?}", report.warnings); + assert_eq!(sh.controls[0].text, "ObjectType=\"Note\""); + let mut out = write_xlsx(&wb).unwrap(); + for _ in 0..2 { + let vml = only_vml(&out); + assert_eq!(vml.matches("ObjectType=\"Checkbox\"").count(), 1, "the check box is kept: {vml}"); + assert_eq!(vml.matches("ObjectType=\"Radio\"").count(), 1, "the option button is kept: {vml}"); + assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "the button once: {vml}"); + assert_eq!(note_shapes(&vml), 0, "{vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(sh.controls.len(), 1); + assert_eq!(sh.controls[0].text, "ObjectType=\"Note\""); + out = write_xlsx(&back).unwrap(); + } +} diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index b9097eea2..902d420c4 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -213,6 +213,29 @@ pub fn parse(xml: &[u8]) -> Result { parse_streaming(xml, &[], &mut |_| Ok(())) } +/// The first element named `local_name` (namespace prefix dropped) in `xml`, read from its start tag +/// alone: no children, and nothing after the tag is read, so unbalanced markup around it (a VML +/// caption's unclosed `
`) neither hides nor moves it. `None` when there is none, or when the +/// markup before it is malformed. +pub fn first_start(xml: &[u8], local_name: &str) -> Option { + let mut reader = Reader::from_reader(xml); + { + let cfg = reader.config_mut(); + cfg.check_end_names = false; + cfg.allow_unmatched_ends = true; + cfg.allow_dangling_amp = true; + } + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf).ok()? { + Event::Start(e) | Event::Empty(e) if local(e.name().as_ref()) == local_name => return Some(start_el(&e)), + Event::Eof => return None, + _ => {} + } + buf.clear(); + } +} + /// Parses a part; complete elements named in `split` that are children or grandchildren of the /// root (`sst/si`, `worksheet/sheetData/row`) are handed to `cb` and not kept in the returned /// tree. Deeper elements of the same name (the `xdr:row` of a form control's anchor) stay in the From 99b5063489f1550e051a48b21a7092f6d0991199 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:19:13 -0500 Subject: [PATCH 55/55] XLSX: a kept VML drawing leaves out a modelled control by the number the reader gave it, its id before its o:spid, so the control is written once --- crates/xlsx/src/controls.rs | 2 +- crates/xlsx/src/passthrough/read.rs | 16 +++++++------ crates/xlsx/src/tests/controls_tests.rs | 32 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/xlsx/src/controls.rs b/crates/xlsx/src/controls.rs index 0a9167f85..7136633ed 100644 --- a/crates/xlsx/src/controls.rs +++ b/crates/xlsx/src/controls.rs @@ -394,7 +394,7 @@ pub(crate) fn shape_number(id: &str) -> Option { /// A VML shape's number: from its `id` (`_x0000_s1025`), else from `o:spid`, where producers that /// give the shape a named id (`id="Button 1"`) keep it. -fn number_of(shape: &El) -> Option { +pub(crate) fn number_of(shape: &El) -> Option { shape.attr("id").and_then(shape_number).or_else(|| shape.attr("spid").and_then(shape_number)) } diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs index 669812597..dde7aac5f 100644 --- a/crates/xlsx/src/passthrough/read.rs +++ b/crates/xlsx/src/passthrough/read.rs @@ -577,17 +577,19 @@ fn shape_spans(vml: &str) -> Vec<(usize, usize)> { spans } -/// VML without its note shapes and the shapes numbered in `drop`. A note is told as the control -/// reader tells it, from the shape's client data tag and never from text elsewhere in the shape: -/// its `ObjectType` ([`crate::controls::is_note_data`]). The tag is read on its own -/// ([`xml::first_start`]), so a caption's unclosed `
` doesn't hide it. +/// VML without its note shapes and the shapes numbered in `drop`, each judged as the control +/// reader judges it, from the shape's own tags and never from text elsewhere in it: a note by its +/// client data's `ObjectType` ([`crate::controls::is_note_data`]), its number from its `id`, else +/// its `o:spid` ([`crate::controls::number_of`]). Each tag is read on its own ([`xml::first_start`]), +/// so a caption's unclosed `
` doesn't hide the client data after it. A shape with no number +/// that isn't a note is kept. fn strip_shapes(vml: &str, drop: &HashSet) -> String { let mut out = String::with_capacity(vml.len()); let mut last = 0; for (a, b) in shape_spans(vml) { - let shape = vml.get(a..b).unwrap_or(""); - let note = xml::first_start(shape.as_bytes(), "ClientData").is_some_and(|d| crate::controls::is_note_data(&d)); - let numbered = shape_numbers(shape).first().is_some_and(|n| drop.contains(n)); + let shape = vml.get(a..b).unwrap_or("").as_bytes(); + let note = xml::first_start(shape, "ClientData").is_some_and(|d| crate::controls::is_note_data(&d)); + let numbered = xml::first_start(shape, "shape").and_then(|s| crate::controls::number_of(&s)).is_some_and(|n| drop.contains(&n)); if note || numbered { out.push_str(vml.get(last..a).unwrap_or("")); last = b; diff --git a/crates/xlsx/src/tests/controls_tests.rs b/crates/xlsx/src/tests/controls_tests.rs index ec4b66c09..ee56c9421 100644 --- a/crates/xlsx/src/tests/controls_tests.rs +++ b/crates/xlsx/src/tests/controls_tests.rs @@ -798,3 +798,35 @@ fn a_control_whose_text_reads_like_a_note_object_type_is_kept() { out = write_xlsx(&back).unwrap(); } } + +/// Kept VML leaves out the modelled controls by the number the reader gave them: the `id`, else +/// the `o:spid`, wherever either comes in the shape's tag. A button whose `o:spid` comes before a +/// different `id`, and one with a plain numeric `id`, are written once, from the model; a check +/// box whose `o:spid` is a modelled button's number but whose `id` is its own is kept; a button +/// with no number isn't modelled and is kept; notes are dropped whatever their numbers. +#[test] +fn kept_vml_leaves_out_modelled_controls_by_the_readers_shape_number() { + let bytes = with_vml(concat!( + r##"
A
2, 0, 1, 0, 4, 0, 2, 0
"##, + r##"
B
2, 0, 3, 0, 4, 0, 4, 0
"##, + r##"
Paid
2, 0, 5, 0, 4, 0, 6, 0
"##, + r##"
C
2, 0, 7, 0, 4, 0, 8, 0
"##, + r##"00"##, + r##"10"##, + )); + let (wb, report) = read_xlsx(&bytes).unwrap(); + let texts: Vec<&str> = wb.sheet(0).unwrap().controls.iter().map(|c| c.text.as_str()).collect(); + assert_eq!(texts, ["A", "B"], "{:?}", report.warnings); + let mut out = write_xlsx(&wb).unwrap(); + for _ in 0..2 { + let vml = only_vml(&out); + for caption in [">A
", ">B", ">C", ">Paid"] { + assert_eq!(vml.matches(caption).count(), 1, "each control once, kept or written: {caption} {vml}"); + } + assert_eq!(vml.matches("ObjectType=\"Checkbox\"").count(), 1, "the check box is kept: {vml}"); + assert_eq!(note_shapes(&vml), 0, "{vml}"); + let (back, _) = read_xlsx(&out).unwrap(); + assert_eq!(back.sheet(0).unwrap().controls.len(), 2); + out = write_xlsx(&back).unwrap(); + } +}