mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 20:09:57 -06:00
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:
+3
-1
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}))
|
||||
})
|
||||
|
||||
@@ -5203,6 +5203,7 @@ fn a_script_cannot_switch_windows() {
|
||||
}
|
||||
|
||||
mod macros;
|
||||
mod objects;
|
||||
mod seams;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!({})));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user