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] 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 |