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<ObjRef>`,
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 <id>`.

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.
This commit is contained in:
prxnvt
2026-10-31 05:51:02 -04:00
parent 981fad14e0
commit e7591de2ec
40 changed files with 1010 additions and 201 deletions
+3 -1
View File
@@ -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 |
+3 -3
View File
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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
),
+11
View File
@@ -62,6 +62,11 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
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<Json> {
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(),
}
}
+39 -43
View File
@@ -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<CommandSpec> {
&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<Json> {
})
}
/// 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<Json> {
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<Json> {
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<Json> {
/// 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<Json> {
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)}))
})
+1
View File
@@ -5203,6 +5203,7 @@ fn a_script_cannot_switch_windows() {
}
mod macros;
mod objects;
mod seams;
#[test]
+105
View File
@@ -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:?}");
}
+43
View File
@@ -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> {
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 {
+77 -2
View File
@@ -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<ObjRef> {
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<Anchor> {
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<T>(v: &mut Vec<T>, 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<Item = &Anchor> {
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<Item = &mut Anchor> {
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) {
+16 -1
View File
@@ -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");
}
}
+4 -3
View File
@@ -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!({})));
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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),
+34 -30
View File
@@ -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<Rect> {
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
+29 -2
View File
@@ -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::<Vec<_>>(),
"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<Json>, search: Option<&str>) -> Vec<Json> {
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)
+1 -1
View File
@@ -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))),
+3 -3
View File
@@ -131,12 +131,12 @@ pub enum Drag {
grab: CellRef,
to: Option<CellRef>,
},
/// 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<crate::objects::Handle>,
},
}
+19 -10
View File
@@ -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<CursorIcon> {
/// 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<CursorIcon> {
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() {
+47 -62
View File
@@ -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<u32> {
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<gridcraft_engine::model::Anchor> {
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
+4 -3
View File
@@ -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;
+1 -1
View File
@@ -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;
}
}
}
+1 -1
View File
@@ -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 {
+3 -2
View File
@@ -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<u32>,
/// The floating object selected on the sheet (chart, picture, shape or slicer).
pub selected_object: Option<gridcraft_engine::model::ObjRef>,
/// 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.
+217
View File
@@ -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<Handle> {
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<u32> {
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<u32>) {
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);
}
}
+6 -3
View File
@@ -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;
};
+1 -1
View File
@@ -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() {
+2 -2
View File
@@ -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;
}
}
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -23,7 +23,7 @@ pub struct WindowUi {
grid: grid::GridState,
name_box: Option<String>,
keytips: keytips::KeyTips,
selected_chart: Option<u32>,
selected_object: Option<gridcraft_engine::model::ObjRef>,
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);
+3 -3
View File
@@ -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));
@@ -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();
}
@@ -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();
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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();
+5 -5
View File
@@ -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();
+1
View File
@@ -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;
+302
View File
@@ -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:?}");
}
+3 -3
View File
@@ -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");
}
+1 -1
View File
@@ -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));
+9 -1
View File
@@ -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
+3 -2
View File
@@ -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 |