mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 00:41:56 -06:00
Objects: one selection for every floating object, eight resize handles, and objects that follow edits (#470)
* 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.
* Objects: every kind follows edits; chart series follow cut-moves and sheet deletes
Structural edits and sorts kept three hand-indexed lists (charts, images,
shapes) in step in two places, and slicers were left out of both, so a
slicer stayed put when rows were inserted above it. Chart series were
rewritten for inserted and deleted lines and for sheet renames, but not for
cut-moves or sheet deletes, which left them pointing at the old cells.
- Inserting and deleting rows and columns and sorting walk
`Sheet::object_anchors` and `object_anchors_mut`, one fixed order over
every kind, so slicers now follow their cells by their anchor mode like
the other objects, and a new kind cannot be forgotten in one of the
passes.
- `rewrite_cell_links` is the one place that rewrites the references
floating objects hold (chart series values, categories and names,
and the source block a chart was made from).
`rewrite_all_formulas` calls it, which covers inserting and deleting
lines and cells and cut-moves on one sheet or across sheets; renaming and
deleting a sheet call it too, so a series into a deleted sheet becomes
#REF!, as a formula does.
- Copying a sheet gives its charts, pictures, shapes and slicers fresh ids,
keeping ids unique across the workbook, which object selection and the
`{kind, id}` commands rely on.
Tests: every kind through row and column inserts and deletes, an absolute
shape, a sort and undo; a slicer through inserted rows; chart series
through a cut-move, a sheet rename, a sheet delete and undo; ids after a
sheet copy.
* Objects: bubble sizes follow edits like the other series references
`rewrite_cell_links` rewrote a chart series' values, categories and name
but not its bubble sizes, so inserting or deleting lines, a cut-move or a
sheet rename left a bubble chart's sizes pointing at the old cells. They
are now rewritten with the rest.
Test: bubble sizes through an inserted row, a cut-move and a sheet rename.
* Objects: a resize handle under the headers leaves the press to them
A selected object's handles reach a few points outside it, and a press
was matched against them before any check of where it landed, so a press
on the column or row header beside an object at the sheet's edge resized
the object instead of selecting the column or row. The cursor already
ignored handles outside the cells.
`chartview::selected_handle` finds the selected object's handle under a
point only over the cell area, as `hit` does, and not while objects are
hidden; the press and the cursor both use it.
Test: presses within a handle's reach over the column header and the row
header neither move nor resize the object.
* Objects: each drag of an object is its own undo step
A drag merges its frames into one undo step by dropping a new step whose
label matches the one before it. Its first frame did the same, so a second
drag of an object (a move after a move, or a resize after a move) merged
into the first drag's "Move Object" step, and one undo took back both.
`Drag::Object` records whether the drag has made its step; only the later
frames of the same drag merge into it.
Test: a move and then a resize of a picture take two undos to reverse,
the first restoring the moved picture.
* Objects: the parity and gap docs no longer list chart resizing as missing
gaps.md (§4), target-app-parity.md (the Essentials tier's work, the
Mainstream chart row, the interaction discount and the Essentials chart
task) and chart-parity.md (the element table and the work list) still
described charts as impossible to resize (#190). Charts move and resize
from eight handles like every floating object, so those statements now
say so; the 2026-10-10 revision rows and the 10-10 re-check stay as
history. Scores and the 40-60 h chart estimate are unchanged (resizing
was 1-2 h, within the rounding).
* Objects: one chart evidence paragraph in gaps.md; trendlines are not missing
gaps.md §4 carried two evidence paragraphs, an older one saying PRs #158
and #161 were open and a newer one saying all 2-D kinds survive XLSX round
trip, both repeating the resize, smooth-line, Switch Row/Column and
trendline statements. It is one paragraph with the current status again.
The Mainstream chart row in target-app-parity.md listed trendlines as
missing; they are read, written and drawn (`Series::trendlines`, the XLSX
chart reader and writer, `chart.addElement`), as chart-parity.md records.
The row now says so and keeps "no axis options". No score change.
* Objects: only the selected object's handles resize; a press elsewhere on an object moves it
A primary drag looked for a handle under the press on whatever object it
grabbed, so pressing near the edge or corner of an object that was not
selected started a resize, while the pointer over that spot showed the
Move cursor (handles are only shown, and only offered by the cursor, on
the selected object). The press now takes its handle from
`chartview::selected_handle`, the same lookup the cursor uses; a press on
any other object selects and moves it.
Test: dragging an unselected shape by its bottom-right corner shows the
Move cursor, then selects and moves the shape without resizing it.
* Docs: ui.object.select says what it replies when it clears the selection
* Objects: a test pins that both anchor accessors visit the same objects in the same order
Sorting and row or column edits read every anchor through
`object_anchors`, compute the new ones and write them back through
`object_anchors_mut` in step. Nothing checked that the two accessors
visit the same objects in the same order; a kind of floating object
reaching only one of them would have paired objects with other objects'
anchors silently.
Test: two objects of every kind are inserted in a mixed order, each
anchor is marked with its position through the mutable accessor, the
marks are read back through the shared one in the same sequence, the
count matches the per-kind collections, and the mark is found on the
object `object_anchor` names by kind and id. The test's constructor
matches on `ObjKind` exhaustively, so a new kind does not compile until
the test covers it.
* Objects: sorting keeps the size of a "move and size" picture whose lines swap places
A picture anchored to move and size with cells and spanning two rows
(or columns) was re-fitted between its top-left and bottom-right lines
after a sort. When the sort put the bottom line above the top one, the
fitted extent was negative and the picture collapsed to a zero height
or width. It now keeps its old size on that axis and travels with its
top-left line, like a "move only" picture.
Tests: for a sort by rows and one by columns, "move and size" and
"move only" pictures inside one line travel with it (the first
re-fitted to the same line, the second at its own size), an absolute
picture stays while the cells move under it, a two-line picture whose
lines swap keeps its size, and undo restores every anchor.
* Objects: a copied sheet's objects get free ids even when a file used the largest id
`Workbook::next_object_id` returned one more than the largest id in use,
saturating at `u32::MAX`. A workbook read from a file can carry an object
with that id; every later insert (a chart, picture, shape, table or
slicer) then got `u32::MAX` again, and so did each object of a copied
sheet, whose ids also came from a saturating counter. Lookups and
selection take the first object with an id, and `object.delete` removes
every one.
`Workbook::free_object_ids(count)` now returns distinct unused ids: those
above the largest in use while they last, then the lowest free ones from
1. `next_object_id` takes the first of them, and the sheet copy takes one
per copied object (and refuses if the id space were ever exhausted). The
model test that recorded the saturation now checks the free ids instead,
and a new one covers a file that used the largest ids: the free ids come
from below, 100,000 of them distinct and in order.
Test: object_ids_stay_unique_when_a_file_used_the_largest_id gives a
shape id u32::MAX, inserts two shapes and copies the sheet; every shape id
is distinct across both sheets. It fails without the fix (both new shapes
get 4294967295).
* Objects: main's chart data table compiles with the object selection and the model's object test
Two leftovers of the merge of origin/main (25881a8e), neither a textual
conflict: `Chart` gained a `data_table` field, which the `push_object`
helper of the model's object test did not set (it is `None`, as in
`insert.chart`); and chart_data_table.rs set the `selected_chart` field
this branch replaced by `selected_object`. The test selects its chart as
a floating object, as the branch's other tests do.
* Objects: object.move and chart.set keep a size a file made larger than 10,000
`object.move` clamped `width` and `height` to 10,000 points, the most it
gives a new size, while the XLSX reader accepts anchors far past that.
A resize of a picture a file made 12,000 wide passed 11,990 through the
clamp and jumped to 10,000; `chart.set`, whose Format Chart pane sends
the unchanged dimension back with the edited one, clamped the same way.
Each dimension is now capped at 10,000 or the object's current size,
whichever is larger (`size_cap`): such an object keeps its size when it
is moved, shrinks by what a resize asks and never grows further, and a
hostile size is still capped. The params doc of `object.move` and the
control protocol notes say so.
Tests: an_oversized_imported_object_keeps_its_size_when_moved_and_shrinks_only_by_a_resize
makes a picture 12,000 by 300, writes and reads it back as a file, then
moves it without a size (12,000 stays), asks 12,010 (stays 12,000),
11,990 (11,990, no jump) and 50,000 by 50,000 (11,990 by 10,000).
chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit does the
same through `chart.set`. Both fail without the fix (width 10,000).
* Objects: dragging an object's body sends its position only, not its size
`drag_object` sent `width` and `height` with every step of a body drag,
so moving a picture a file made 12,000 wide shrank it to the 10,000 that
`object.move` gives a new size. Only a handle drag sends a size now; a
body move leaves the engine's size alone.
Tests (real input): dragging_the_body_of_an_oversized_imported_object_keeps_its_size
reopens the workbook with a 12,000 by 60 picture, drags its body by
30 by 25 and checks the size is untouched, the picture moved with the
pointer and the journaled `object.move` carries no width or height; it
fails without the fix (the journaled command carries both, and without
the engine's cap the picture is 10,000 wide).
a_handle_drag_on_an_oversized_imported_object_resizes_by_the_drag_only
moves the left handle 10 px right: the picture is 10 points narrower in
the window and keeps a width near 12,000, with no jump to the limit.
* Objects: the Format Chart Size fields show a chart's real size and send only the edited dimension
The Width and Height fields ranged 40 to 4,000 and sent both values on
every edit. egui clamps an existing value into a field's range, so a
chart a file made 12,000 wide showed "Width 4000", and typing a new
height sent that 4,000 as the width: the chart shrank although only its
height changed, and the cap `chart.set` keeps for an existing size
cannot help once the pane itself sends 4,000. Each field's range now
reaches the larger of 4,000 and the chart's current size, so the field
shows the real size, and a commit sends only the dimension that differs
from the chart's (`chart.set` reads `width` and `height` independently).
Test (real input): the_size_fields_keep_an_oversized_imported_chart_width_when_the_height_changes
reopens a workbook with a 12,000-wide chart, opens Format Chart Area's
Size section, checks the Width field shows 12000 and types 201 as the
height: the width stays 12,000, the height is 201 and the journaled
chart.set carries no width. Without the fix the field shows
"Width 4000".
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-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **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-11-02 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) |
|
||||
| 2026-11-01 | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
|
||||
| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). |
|
||||
| 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) |
|
||||
@@ -251,6 +252,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Progress log: one selection and eight resize handles for every floating object |
|
||||
| 2026-11-01 | minor | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
|
||||
| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) |
|
||||
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
|
||||
|
||||
@@ -195,8 +195,7 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
|
||||
// The object's own geometry (its top-left anchor cell, offset and size) is read from the
|
||||
// pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it
|
||||
// can be re-fitted to the lines it spans after the sort.
|
||||
let objs: Vec<Anchor> =
|
||||
sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect();
|
||||
let objs: Vec<Anchor> = sh.object_anchors().copied().collect();
|
||||
let descs: Vec<(u32, u32, f32, f32)> = objs
|
||||
.iter()
|
||||
.map(|a| {
|
||||
@@ -242,33 +241,22 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-anchor floating objects now that the row heights are final. `objs` was collected as
|
||||
// images ++ charts ++ shapes, so the same index maps straight onto each vec.
|
||||
// Re-anchor floating objects now that the row heights are final. `objs` was collected in
|
||||
// `object_anchors` order, which `object_anchors_mut` repeats.
|
||||
// Only objects anchored inside the sorted block move: within its lines on the sort axis
|
||||
// and within its span on the other axis (a picture beside the block stays put).
|
||||
let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) };
|
||||
let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) };
|
||||
let ni = shm.images.len();
|
||||
let nch = shm.charts.len();
|
||||
for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() {
|
||||
let other = if by_cols { a.cell.row } else { a.cell.col };
|
||||
if other < o0 || other > o1 {
|
||||
continue;
|
||||
}
|
||||
let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of);
|
||||
if na != a {
|
||||
if i < ni {
|
||||
if let Some(o) = shm.images.get_mut(i) {
|
||||
o.anchor = na;
|
||||
}
|
||||
} else if i < ni + nch {
|
||||
if let Some(o) = shm.charts.get_mut(i - ni) {
|
||||
o.anchor = na;
|
||||
}
|
||||
} else if let Some(o) = shm.shapes.get_mut(i - ni - nch) {
|
||||
o.anchor = na;
|
||||
}
|
||||
}
|
||||
let new_anchors: Vec<Anchor> = objs
|
||||
.into_iter()
|
||||
.zip(descs)
|
||||
.map(|(a, desc)| {
|
||||
let other = if by_cols { a.cell.row } else { a.cell.col };
|
||||
if other < o0 || other > o1 { a } else { sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of) }
|
||||
})
|
||||
.collect();
|
||||
for (a, n) in shm.object_anchors_mut().zip(new_anchors) {
|
||||
*a = n;
|
||||
}
|
||||
for (c, _) in moved {
|
||||
cx.changed.push((sheet, c));
|
||||
@@ -284,7 +272,9 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
|
||||
/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort
|
||||
/// grid (row heights already reordered), so re-fitting uses the heights the object lands on.
|
||||
/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size"
|
||||
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body.
|
||||
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body, and
|
||||
/// keeps its size when the sort turns its lines around (its bottom line now sorts before its top
|
||||
/// line), since the lines then enclose nothing to stretch it between.
|
||||
fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor {
|
||||
if a.mode == AnchorMode::Absolute {
|
||||
return a;
|
||||
@@ -302,7 +292,10 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b
|
||||
let y1 = sh.row_top(nr) + a.dy as f64;
|
||||
let x2 = sh.col_left(nbc) + tdx as f64;
|
||||
let y2 = sh.row_top(nbr) + tdy as f64;
|
||||
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode }
|
||||
// An extent that is not positive means the sort put the bottom-right line before the
|
||||
// top-left one on that axis: the object keeps its old size there.
|
||||
let fit = |extent: f64, old: f32| if extent > 0.0 { extent as f32 } else { old.max(0.0) };
|
||||
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: fit(x2 - x1, a.width), height: fit(y2 - y1, a.height), mode: a.mode }
|
||||
} else {
|
||||
Anchor { cell: CellRef::new(nr, nc), ..a }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ use go_to::go_to_special;
|
||||
#[cfg(test)]
|
||||
use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
|
||||
use paste_special::paste_special;
|
||||
pub(crate) use paste_special::rewrite_all_formulas;
|
||||
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links};
|
||||
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -355,4 +355,33 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
n.formula = gridcraft_formula::print(&new);
|
||||
}
|
||||
}
|
||||
rewrite_cell_links(wb, |host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e));
|
||||
}
|
||||
|
||||
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
|
||||
/// name and bubble sizes, and the source block it was made from). Every edit that moves
|
||||
/// references calls it: inserting and deleting lines and cells and cut-moves (through
|
||||
/// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an
|
||||
/// object on the sheet named `host` to its new form; text that does not parse is kept. A source
|
||||
/// block that no longer exists is dropped.
|
||||
pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) {
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue };
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
let fix = |text: &str| -> String {
|
||||
match gridcraft_formula::parse(text) {
|
||||
Ok(expr) => gridcraft_formula::print(&f(&host, expr)),
|
||||
Err(_) => text.to_string(),
|
||||
}
|
||||
};
|
||||
for ch in sh.charts.iter_mut() {
|
||||
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
|
||||
for se in ch.series.iter_mut() {
|
||||
se.values = fix(&se.values);
|
||||
se.categories = se.categories.as_deref().map(fix);
|
||||
se.name = se.name.as_deref().map(fix);
|
||||
se.bubble_sizes = se.bubble_sizes.as_deref().map(fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_COLS, 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? (each at most 10,000 points, or the object's current size when a file made it larger)}",
|
||||
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
|
||||
),
|
||||
@@ -925,10 +933,10 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
c.anchor.cell = at;
|
||||
}
|
||||
if let Some(w) = f64_param(p, "width") {
|
||||
c.anchor.width = w.clamp(20.0, 10000.0) as f32;
|
||||
c.anchor.width = w.clamp(20.0, size_cap(c.anchor.width)) as f32;
|
||||
}
|
||||
if let Some(h) = f64_param(p, "height") {
|
||||
c.anchor.height = h.clamp(20.0, 10000.0) as f32;
|
||||
c.anchor.height = h.clamp(20.0, size_cap(c.anchor.height)) as f32;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "dx") {
|
||||
c.anchor.dx = v as f32;
|
||||
@@ -1215,51 +1223,62 @@ 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;
|
||||
|
||||
/// Largest width or height (points) `object.move` and `chart.set` give an object that is not
|
||||
/// already larger: see [`size_cap`].
|
||||
const MAX_OBJECT_SIZE: f64 = 10_000.0;
|
||||
|
||||
/// The most a command may make a dimension that is `current` now: [`MAX_OBJECT_SIZE`], or the
|
||||
/// current size when a file made the object larger (the XLSX reader accepts anchors far past the
|
||||
/// limit). Such an object keeps its size when it is moved and shrinks by what a resize asks, but
|
||||
/// it never grows further, so hostile input is still capped. Never NaN: `f64::max` drops one.
|
||||
fn size_cap(current: f32) -> f64 {
|
||||
MAX_OBJECT_SIZE.max(f64::from(current))
|
||||
}
|
||||
|
||||
fn move_object(s: &mut Session, p: &Json) -> Result<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;
|
||||
a.width = v.clamp(1.0, size_cap(a.width)) as f32;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "height") {
|
||||
a.height = v.clamp(0.0, 10000.0) as f32;
|
||||
a.height = v.clamp(0.0, size_cap(a.height)) as f32;
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
@@ -1269,21 +1288,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)}))
|
||||
})
|
||||
|
||||
@@ -141,14 +141,7 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
};
|
||||
// Floating objects: snapshot their bottom-right corners against the pre-shift geometry, so a
|
||||
// re-fit after the shift measures against the grid the object actually lands on.
|
||||
let obj_corners: Vec<(u32, u32, f32, f32)> = sh
|
||||
.charts
|
||||
.iter()
|
||||
.map(|o| &o.anchor)
|
||||
.chain(sh.images.iter().map(|o| &o.anchor))
|
||||
.chain(sh.shapes.iter().map(|o| &o.anchor))
|
||||
.map(|a| anchor_corner(sh, a))
|
||||
.collect();
|
||||
let obj_corners: Vec<(u32, u32, f32, f32)> = sh.object_anchors().map(|a| anchor_corner(sh, a)).collect();
|
||||
// Line info.
|
||||
let lines = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let old = std::mem::take(lines);
|
||||
@@ -193,22 +186,13 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
|
||||
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
// Floating objects follow their anchor lines per their [`AnchorMode`]. Corners were snapshotted
|
||||
// as charts ++ images ++ shapes, matching the three slices below. New anchors are computed
|
||||
// against the post-shift grid first (the re-fit needs its sizes), then written back.
|
||||
let anchored = |ui: usize, a: Anchor| -> Anchor { obj_corners.get(ui).map_or(a, |&corner| shift_anchor(sh, a, corner, axis, at, &map_line)) };
|
||||
let new_charts: Vec<Anchor> = sh.charts.iter().enumerate().map(|(i, o)| anchored(i, o.anchor)).collect();
|
||||
let new_images: Vec<Anchor> = sh.images.iter().enumerate().map(|(i, o)| anchored(new_charts.len() + i, o.anchor)).collect();
|
||||
let base = new_charts.len() + new_images.len();
|
||||
let new_shapes: Vec<Anchor> = sh.shapes.iter().enumerate().map(|(i, o)| anchored(base + i, o.anchor)).collect();
|
||||
for (o, a) in sh.charts.iter_mut().zip(new_charts) {
|
||||
o.anchor = a;
|
||||
}
|
||||
for (o, a) in sh.images.iter_mut().zip(new_images) {
|
||||
o.anchor = a;
|
||||
}
|
||||
for (o, a) in sh.shapes.iter_mut().zip(new_shapes) {
|
||||
o.anchor = a;
|
||||
// Floating objects follow their anchor lines per their [`AnchorMode`]. New anchors are
|
||||
// computed against the post-shift grid first (the re-fit needs its sizes), then written back
|
||||
// in the same `object_anchors` order the corners were snapshotted in.
|
||||
let new_anchors: Vec<Anchor> =
|
||||
sh.object_anchors().zip(&obj_corners).map(|(a, &corner)| shift_anchor(sh, *a, corner, axis, at, &map_line)).collect();
|
||||
for (a, n) in sh.object_anchors_mut().zip(new_anchors) {
|
||||
*a = n;
|
||||
}
|
||||
sh.sparklines.retain_mut(|sp| match map_cell(sp.cell) {
|
||||
Some(c) => {
|
||||
@@ -231,7 +215,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites every formula reference (and chart series) for a structural edit on `target`.
|
||||
/// Rewrites every formula reference (chart series included), sparkline source and PivotTable
|
||||
/// source for a structural edit on `target`.
|
||||
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
|
||||
cx.edits.push((target.to_string(), e.clone()));
|
||||
let wb = &mut cx.wb;
|
||||
@@ -245,15 +230,6 @@ pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
|
||||
Err(_) => text.to_string(),
|
||||
}
|
||||
};
|
||||
for ch in sh.charts.iter_mut() {
|
||||
// The source block moves with its cells; one that was deleted is dropped.
|
||||
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
|
||||
for se in ch.series.iter_mut() {
|
||||
se.values = fix(&se.values);
|
||||
se.categories = se.categories.as_deref().map(fix);
|
||||
se.name = se.name.as_deref().map(fix);
|
||||
}
|
||||
}
|
||||
for sp in sh.sparklines.iter_mut() {
|
||||
sp.source = fix(&sp.source);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Chart series into the deleted sheet become #REF!, like formulas.
|
||||
crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order));
|
||||
cx.wb.names.retain(|n| n.scope != Some(i));
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = n.scope.as_mut()
|
||||
@@ -116,17 +118,7 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let fix = |t: &str| {
|
||||
gridcraft_formula::parse(t)
|
||||
.map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name)))
|
||||
.unwrap_or_else(|_| t.to_string())
|
||||
};
|
||||
for ch in sh.charts.iter_mut() {
|
||||
for se in ch.series.iter_mut() {
|
||||
se.values = fix(&se.values);
|
||||
se.categories = se.categories.as_deref().map(fix);
|
||||
se.name = se.name.as_deref().map(fix);
|
||||
}
|
||||
// A PivotChart follows its PivotTable's sheet.
|
||||
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
|
||||
&& sheet.eq_ignore_ascii_case(&old)
|
||||
@@ -135,6 +127,7 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::rename_sheet(e, &old, &name));
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
|
||||
n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name));
|
||||
@@ -170,6 +163,20 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.name = name;
|
||||
sh.tables.clear(); // table names must be unique
|
||||
sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original
|
||||
// Object ids are unique across the workbook (selection and the `{kind, id}` commands
|
||||
// rely on it), so the copied objects get fresh ones (free ones, even when a file used
|
||||
// the largest id).
|
||||
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len();
|
||||
let ids = cx.wb.free_object_ids(count);
|
||||
if ids.len() < count {
|
||||
return Err(bad("sheet.move", "no free object ids are left for the copied objects"));
|
||||
}
|
||||
let mut ids = ids.into_iter();
|
||||
let mut fresh = || ids.next().unwrap_or(u32::MAX);
|
||||
sh.charts.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.images.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.shapes.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.slicers.iter_mut().for_each(|o| o.id = fresh());
|
||||
let at = to.min(n);
|
||||
// The copy gets its own copies of the sheet's names, referring to the copy.
|
||||
let copies: Vec<gridcraft_model::DefinedName> = cx
|
||||
|
||||
@@ -5244,6 +5244,7 @@ fn a_script_cannot_switch_windows() {
|
||||
}
|
||||
|
||||
mod macros;
|
||||
mod objects;
|
||||
mod seams;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Floating objects of every kind: found, moved, listed, stacked and deleted by `{kind, id}`.
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::{Anchor, 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:?}");
|
||||
}
|
||||
|
||||
fn cell_of(s: &Session, o: ObjRef) -> String {
|
||||
sheet(s, 0).object_anchor(o).unwrap().cell.a1()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_follows_row_and_column_edits_and_sorts() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
// Anchors: chart D2, picture D18, shape H18, slicer L2.
|
||||
s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap();
|
||||
let cells = |s: &Session| objs.map(|o| cell_of(s, o));
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]);
|
||||
s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap();
|
||||
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2"], "rows inserted above move the objects below them; an absolute shape stays");
|
||||
s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap();
|
||||
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2"]);
|
||||
s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap();
|
||||
s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap();
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]);
|
||||
// Sorting moves a picture anchored inside the sorted block with its row.
|
||||
s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap();
|
||||
let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize));
|
||||
s.execute("selection.set", json!({"range": "T1:T4"})).unwrap();
|
||||
s.execute("data.sortAscending", json!({"header": true, "column": "T"})).unwrap();
|
||||
assert_eq!(cell_of(&s, pic), "T4", "the row holding 3 sorts last");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(cell_of(&s, pic), "T2");
|
||||
}
|
||||
|
||||
/// A picture at `cell` with anchor `mode`, offset `(dx, dy)` and size `(width, height)`, added
|
||||
/// straight to the model like `add_image`, so a sort that follows is the only step to undo.
|
||||
fn picture(s: &mut Session, cell: &str, mode: AnchorMode, dx: f32, dy: f32, width: f32, height: f32) -> ObjRef {
|
||||
let id = super::add_image(s, cell, mode);
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
if let Some(img) = wb.sheet_mut(0).unwrap().images.iter_mut().find(|i| i.id == id) {
|
||||
img.anchor = Anchor { dx, dy, width, height, ..img.anchor };
|
||||
}
|
||||
ObjRef::new(ObjKind::Image, id)
|
||||
}
|
||||
|
||||
/// The anchor of `o` as (cell, dx, dy, width, height).
|
||||
fn anchor_of(s: &Session, o: ObjRef) -> (String, f32, f32, f32, f32) {
|
||||
let a = sheet(s, 0).object_anchor(o).unwrap();
|
||||
(a.cell.a1(), a.dx, a.dy, a.width, a.height)
|
||||
}
|
||||
|
||||
/// Sorting rows under a picture follows its anchor mode, as Excel's "Move and size with cells",
|
||||
/// "Move but don't size with cells" and "Don't move or size with cells" do.
|
||||
#[test]
|
||||
fn sorting_rows_under_a_picture_follows_its_anchor_mode() {
|
||||
let mut s = s();
|
||||
// Rows 2..5 hold 3, 1, 4, 2; sorted ascending they read 1, 2, 3, 4, so row 2 moves to row 4,
|
||||
// row 3 to row 2, row 4 to row 5 and row 5 to row 3. Default rows are 20 pt tall.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["k"], [3], [1], [4], [2]]})).unwrap();
|
||||
// Three pictures inside one row each (3 pt down, 12 pt tall), one per anchor mode.
|
||||
let sized = picture(&mut s, "A2", AnchorMode::MoveAndSize, 5.0, 3.0, 30.0, 12.0);
|
||||
let moved = picture(&mut s, "A3", AnchorMode::MoveOnly, 5.0, 3.0, 30.0, 12.0);
|
||||
let pinned = picture(&mut s, "A4", AnchorMode::Absolute, 5.0, 3.0, 30.0, 12.0);
|
||||
// A "move and size" picture spanning rows 2 and 3: its top is 10 pt into row 2 (y 30) and
|
||||
// its bottom 15 pt into row 3 (y 55). The sort puts row 3 above row 2.
|
||||
let tall = picture(&mut s, "A2", AnchorMode::MoveAndSize, 5.0, 10.0, 30.0, 25.0);
|
||||
let before: Vec<_> = [sized, moved, pinned, tall].iter().map(|&o| anchor_of(&s, o)).collect();
|
||||
s.execute("selection.set", json!({"range": "A1:A5"})).unwrap();
|
||||
s.execute("data.sortAscending", json!({"header": true, "column": "A"})).unwrap();
|
||||
assert_eq!(
|
||||
anchor_of(&s, sized),
|
||||
("A4".into(), 5.0, 3.0, 30.0, 12.0),
|
||||
"move and size: travels with row 2 to row 4, re-fitted to the same 20 pt row"
|
||||
);
|
||||
assert_eq!(anchor_of(&s, moved), ("A2".into(), 5.0, 3.0, 30.0, 12.0), "move only: travels with row 3 to row 2 at its own size");
|
||||
assert_eq!(anchor_of(&s, pinned), ("A4".into(), 5.0, 3.0, 30.0, 12.0), "absolute: stays while the cells move under it");
|
||||
// The tall picture's bottom row now sorts above its top row, so there is nothing to stretch
|
||||
// it between: it travels with its top-left row and keeps its size instead of collapsing.
|
||||
assert_eq!(anchor_of(&s, tall), ("A4".into(), 5.0, 10.0, 30.0, 25.0), "two rows swapped: moves with its top row at its old size");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let after: Vec<_> = [sized, moved, pinned, tall].iter().map(|&o| anchor_of(&s, o)).collect();
|
||||
assert_eq!(after, before, "undo restores every anchor");
|
||||
}
|
||||
|
||||
/// A sort by columns follows the anchor mode the same way.
|
||||
#[test]
|
||||
fn sorting_columns_under_a_picture_follows_its_anchor_mode() {
|
||||
let mut s = s();
|
||||
// Row 10 holds a header in A and 3, 1, 2 in B..D; sorted ascending, column B moves to D,
|
||||
// C to B and D to C. Default columns are 64 pt wide.
|
||||
s.execute("range.setValues", json!({"range": "A10", "values": [["k", 3, 1, 2]]})).unwrap();
|
||||
let sized = picture(&mut s, "B10", AnchorMode::MoveAndSize, 5.0, 3.0, 30.0, 12.0);
|
||||
let moved = picture(&mut s, "C10", AnchorMode::MoveOnly, 5.0, 3.0, 30.0, 12.0);
|
||||
let pinned = picture(&mut s, "D10", AnchorMode::Absolute, 5.0, 3.0, 30.0, 12.0);
|
||||
// A "move and size" picture spanning columns B and C: from x 104 (40 pt into B) to x 154
|
||||
// (26 pt into C). The sort puts column C before column B.
|
||||
let wide = picture(&mut s, "B10", AnchorMode::MoveAndSize, 40.0, 3.0, 50.0, 12.0);
|
||||
let before: Vec<_> = [sized, moved, pinned, wide].iter().map(|&o| anchor_of(&s, o)).collect();
|
||||
s.execute("data.sort", json!({"range": "A10:D10", "header": true, "orientation": "columns", "keys": [{"row": 10}]})).unwrap();
|
||||
assert_eq!(anchor_of(&s, sized), ("D10".into(), 5.0, 3.0, 30.0, 12.0), "move and size: travels with column B to D at the same width");
|
||||
assert_eq!(anchor_of(&s, moved), ("B10".into(), 5.0, 3.0, 30.0, 12.0), "move only: travels with column C to B at its own size");
|
||||
assert_eq!(anchor_of(&s, pinned), ("D10".into(), 5.0, 3.0, 30.0, 12.0), "absolute: stays");
|
||||
assert_eq!(anchor_of(&s, wide), ("D10".into(), 40.0, 3.0, 50.0, 12.0), "two columns swapped: moves with its left column at its old size");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let after: Vec<_> = [sized, moved, pinned, wide].iter().map(|&o| anchor_of(&s, o)).collect();
|
||||
assert_eq!(after, before, "undo restores every anchor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slicers_follow_inserted_rows_like_other_objects() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap();
|
||||
assert_eq!(cell_of(&s, objs[3]), "L4");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(cell_of(&s, objs[3]), "L2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_series_follow_cut_moves_renames_and_sheet_deletes() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[3], [5], [2], [8]]})).unwrap();
|
||||
let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:A4", "type": "column"})).unwrap(), "chart");
|
||||
let values = |s: &Session| sheet(s, 0).charts.iter().find(|c| c.id == chart).map(|c| c.series[0].values.clone()).unwrap_or_default();
|
||||
s.execute("edit.cut", json!({"range": "A1:A4"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "F2"})).unwrap();
|
||||
assert!(values(&s).contains("$F$2:$F$5"), "a cut-move takes the series with the cells: {}", values(&s));
|
||||
s.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().charts[0].series[0].values = "Other!$A$1:$A$3".into();
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Inputs"})).unwrap();
|
||||
assert_eq!(values(&s), "Inputs!$A$1:$A$3");
|
||||
s.execute("home.deleteSheet", json!({"sheet": 1})).unwrap();
|
||||
assert!(values(&s).contains("#REF!"), "a series into a deleted sheet becomes #REF!: {}", values(&s));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(values(&s), "Inputs!$A$1:$A$3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_sheet_gives_its_objects_fresh_ids() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let ids_of = |sh: &Sheet| -> Vec<u32> {
|
||||
sh.charts
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.chain(sh.images.iter().map(|c| c.id))
|
||||
.chain(sh.shapes.iter().map(|c| c.id))
|
||||
.chain(sh.slicers.iter().map(|c| c.id))
|
||||
.collect()
|
||||
};
|
||||
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| ids_of(sh)).collect();
|
||||
assert_eq!(ids.len(), 2 * objs.len());
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 2 * objs.len(), "every object id stays unique across the workbook");
|
||||
let copy = sheet(&s, 1);
|
||||
for (o, id) in objs.iter().zip(ids_of(copy)) {
|
||||
assert_eq!(copy.object_ref(id).map(|c| c.kind), Some(o.kind));
|
||||
}
|
||||
assert!(s.doc().unwrap().wb.next_object_id() > *ids.last().unwrap_or(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bubble_sizes_follow_edits_like_the_other_series_references() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 3, 10], [2, 5, 20], [3, 2, 30]]})).unwrap();
|
||||
let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:B3", "type": "bubble"})).unwrap(), "chart");
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().charts.iter_mut().find(|c| c.id == chart).unwrap().series[0]
|
||||
.bubble_sizes = Some("Sheet1!$C$1:$C$3".into());
|
||||
let sizes = |s: &Session| sheet(s, 0).charts.iter().find(|c| c.id == chart).and_then(|c| c.series[0].bubble_sizes.clone()).unwrap_or_default();
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
assert_eq!(sizes(&s), "Sheet1!$C$2:$C$4", "an inserted row moves the bubble sizes with their cells");
|
||||
s.execute("edit.cut", json!({"range": "C2:C4"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "F2"})).unwrap();
|
||||
assert_eq!(sizes(&s), "Sheet1!$F$2:$F$4", "a cut-move takes the bubble sizes with the cells");
|
||||
s.execute("sheet.rename", json!({"sheet": 0, "name": "Data"})).unwrap();
|
||||
assert_eq!(sizes(&s), "Data!$F$2:$F$4", "a renamed sheet is renamed in the bubble sizes");
|
||||
}
|
||||
|
||||
/// A file can carry an object with the largest id. New objects and a copied sheet's objects then
|
||||
/// take free ids instead of the largest one again, so `{kind, id}` still names one object.
|
||||
#[test]
|
||||
fn object_ids_stay_unique_when_a_file_used_the_largest_id() {
|
||||
let mut s = s();
|
||||
let first = id_of(&s.execute("insert.shape", json!({"kind": "rectangle", "at": "B2"})).unwrap(), "shape");
|
||||
// The file's shape carries the largest id (set on the model, as a loaded file would).
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().shapes.iter_mut().find(|o| o.id == first).unwrap().id = u32::MAX;
|
||||
let second = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "D2"})).unwrap(), "shape");
|
||||
let third = id_of(&s.execute("insert.shape", json!({"kind": "triangle", "at": "F2"})).unwrap(), "shape");
|
||||
assert!(second != u32::MAX && third != u32::MAX && second != third, "new shapes take free ids, not the largest again: {second}, {third}");
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| sh.shapes.iter().map(|o| o.id)).collect();
|
||||
assert_eq!(ids.len(), 6);
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 6, "every shape id is distinct across both sheets");
|
||||
}
|
||||
|
||||
/// A file's anchors are not held to the 10,000 points `object.move` gives a new size. A picture
|
||||
/// a file made 12,000 wide keeps that width when it is moved, shrinks by what a resize asks
|
||||
/// (11,990 stays 11,990, no jump to 10,000) and never grows further; a dimension the file kept
|
||||
/// within the limit is still capped there.
|
||||
#[test]
|
||||
fn an_oversized_imported_object_keeps_its_size_when_moved_and_shrinks_only_by_a_resize() {
|
||||
let mut s = s();
|
||||
let image = super::add_image(&mut s, "B2", AnchorMode::MoveOnly);
|
||||
// The file's picture is 12,000 by 300: set on the model, then written and read back as a file.
|
||||
{
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
let a = wb.sheet_mut(0).unwrap().object_anchor_mut(ObjRef::new(ObjKind::Image, image)).unwrap();
|
||||
a.width = 12_000.0;
|
||||
a.height = 300.0;
|
||||
}
|
||||
let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
s.execute("file.open", json!({"name": "wide.xlsx", "base64": saved["base64"]})).unwrap();
|
||||
let o = ObjRef::new(ObjKind::Image, sheet(&s, 0).images[0].id);
|
||||
let size = |s: &Session| {
|
||||
let a = sheet(s, 0).object_anchor(o).unwrap();
|
||||
(a.width.round(), a.height.round())
|
||||
};
|
||||
assert_eq!(size(&s), (12_000.0, 300.0), "the reader keeps the file's size");
|
||||
s.execute("object.move", json!({"kind": "image", "id": o.id, "at": "D5", "dx": 3, "dy": 4})).unwrap();
|
||||
let a = sheet(&s, 0).object_anchor(o).unwrap();
|
||||
assert_eq!((a.cell, a.dx, a.dy), (at("D5"), 3.0, 4.0));
|
||||
assert_eq!(size(&s), (12_000.0, 300.0), "a move without a size keeps the file's size");
|
||||
s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 12_010})).unwrap();
|
||||
assert_eq!(size(&s), (12_000.0, 300.0), "the object does not grow past what the file gave");
|
||||
s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 11_990})).unwrap();
|
||||
assert_eq!(size(&s), (11_990.0, 300.0), "a resize shrinks by what it asks, with no jump to 10,000");
|
||||
s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 50_000, "height": 50_000})).unwrap();
|
||||
assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped: at the current width, and at 10,000 for a height the file kept within it");
|
||||
}
|
||||
|
||||
/// `chart.set` follows the same rule: the Format Chart pane's Size fields send the unchanged
|
||||
/// dimension back with the edited one, so a chart a file made 12,000 wide keeps that width when
|
||||
/// its height is edited, shrinks by what is asked and never grows further.
|
||||
#[test]
|
||||
fn chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
|
||||
let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:A3", "type": "column", "at": "C2"})).unwrap(), "chart");
|
||||
// The file's chart is 12,000 wide (set on the model, as a loaded file would).
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
wb.sheet_mut(0).unwrap().charts.iter_mut().find(|c| c.id == chart).unwrap().anchor.width = 12_000.0;
|
||||
let size = |s: &Session| {
|
||||
let c = sheet(s, 0).charts.iter().find(|c| c.id == chart).unwrap();
|
||||
(c.anchor.width, c.anchor.height)
|
||||
};
|
||||
s.execute("chart.set", json!({"chart": chart, "width": 12_000, "height": 250})).unwrap();
|
||||
assert_eq!(size(&s), (12_000.0, 250.0), "the unchanged width sent back with a new height stays");
|
||||
s.execute("chart.set", json!({"chart": chart, "width": 11_990})).unwrap();
|
||||
assert_eq!(size(&s), (11_990.0, 250.0), "a resize shrinks by what it asks");
|
||||
s.execute("chart.set", json!({"chart": chart, "width": 50_000, "height": 50_000})).unwrap();
|
||||
assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped at the current width and at 10,000");
|
||||
}
|
||||
@@ -646,6 +646,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 {
|
||||
|
||||
+165
-2
@@ -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) {
|
||||
@@ -626,6 +701,94 @@ mod tests {
|
||||
assert_eq!(s.col_at(-5.0), 0);
|
||||
assert_eq!(s.col_left(2), 128.0);
|
||||
}
|
||||
|
||||
use crate::features::{ChartKind, LegendPos, ShapeFormat, ShapeKind};
|
||||
|
||||
/// Adds a floating object of `kind` with id `id` at `anchor`. The match is exhaustive on
|
||||
/// purpose: a new kind of floating object does not compile here until this test covers it,
|
||||
/// and the counts in `object_anchor_accessors_visit_the_same_objects_in_the_same_order`
|
||||
/// then catch an accessor that was not taught about it.
|
||||
fn push_object(s: &mut Sheet, kind: ObjKind, id: u32, anchor: Anchor) {
|
||||
match kind {
|
||||
ObjKind::Chart => s.charts.push(Chart {
|
||||
id,
|
||||
kind: ChartKind::default(),
|
||||
anchor,
|
||||
title: None,
|
||||
series: vec![],
|
||||
legend: LegendPos::default(),
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: false,
|
||||
style: 0,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
}),
|
||||
ObjKind::Image => s.images.push(Image { id, anchor, data: Arc::new(vec![]), mime: String::new(), alt: String::new() }),
|
||||
ObjKind::Shape => s.shapes.push(Shape {
|
||||
id,
|
||||
kind: ShapeKind::default(),
|
||||
anchor,
|
||||
fill: Color::default(),
|
||||
line: Color::default(),
|
||||
text: String::new(),
|
||||
format: ShapeFormat::default(),
|
||||
}),
|
||||
ObjKind::Slicer => {
|
||||
s.slicers.push(Slicer { id, table: String::new(), column: String::new(), caption: String::new(), anchor, selected: None, columns: 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorting, and inserting or deleting rows and columns, read every anchor through
|
||||
/// `object_anchors`, compute the new ones and write them back through `object_anchors_mut`
|
||||
/// in step. The two must visit the same objects in the same order, or an object receives
|
||||
/// another object's anchor; a kind that reaches only one of them would break that silently.
|
||||
#[test]
|
||||
fn object_anchor_accessors_visit_the_same_objects_in_the_same_order() {
|
||||
let mut s = Sheet::new("S");
|
||||
// Two objects of every kind, inserted in a mixed order, with ids unrelated to the kind.
|
||||
// Each anchor cell's row carries the id, so an anchor read back names its object.
|
||||
let inserted = [
|
||||
(ObjKind::Slicer, 7),
|
||||
(ObjKind::Chart, 3),
|
||||
(ObjKind::Image, 11),
|
||||
(ObjKind::Shape, 2),
|
||||
(ObjKind::Chart, 9),
|
||||
(ObjKind::Slicer, 1),
|
||||
(ObjKind::Image, 4),
|
||||
(ObjKind::Shape, 12),
|
||||
];
|
||||
for &(kind, id) in &inserted {
|
||||
push_object(&mut s, kind, id, Anchor { cell: CellRef::new(id, 0), ..Default::default() });
|
||||
}
|
||||
let per_kind = s.charts.len() + s.images.len() + s.shapes.len() + s.slicers.len();
|
||||
assert_eq!(per_kind, inserted.len());
|
||||
assert_eq!(s.object_anchors().count(), per_kind, "object_anchors visits every collection");
|
||||
assert_eq!(s.object_anchors_mut().count(), per_kind, "object_anchors_mut visits every collection");
|
||||
// Mark each anchor with its position through the mutable accessor; the shared accessor
|
||||
// reads the marks back in the same sequence.
|
||||
for (i, a) in s.object_anchors_mut().enumerate() {
|
||||
a.dx = i as f32;
|
||||
}
|
||||
let marks: Vec<f32> = s.object_anchors().map(|a| a.dx).collect();
|
||||
assert_eq!(marks, (0..per_kind).map(|i| i as f32).collect::<Vec<f32>>());
|
||||
// The sequence is the documented one (charts, images, shapes, slicers, each in insertion
|
||||
// order) and every object appears in it once.
|
||||
let documented: Vec<u32> =
|
||||
ObjKind::ALL.iter().flat_map(|&k| inserted.iter().filter(move |(kind, _)| *kind == k).map(|&(_, id)| id)).collect();
|
||||
let visited: Vec<u32> = s.object_anchors().map(|a| a.cell.row).collect();
|
||||
assert_eq!(visited, documented);
|
||||
// The mark written through the bulk accessor landed on the object `object_anchor` finds
|
||||
// by kind and id.
|
||||
for &(kind, id) in &inserted {
|
||||
let position = documented.iter().position(|&x| x == id).map(|p| p as f32);
|
||||
assert_eq!(s.object_anchor(ObjRef::new(kind, id)).map(|a| a.dx), position, "{kind:?} {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats.
|
||||
|
||||
@@ -463,26 +463,54 @@ impl Workbook {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// A free id for a new chart, picture, shape, table or slicer: one more than the largest in
|
||||
/// use, or, when that is `u32::MAX` (a file can carry any id), the lowest free one.
|
||||
pub fn next_object_id(&self) -> u32 {
|
||||
let mut m = 0;
|
||||
for s in &self.sheets {
|
||||
for c in &s.charts {
|
||||
m = m.max(c.id);
|
||||
}
|
||||
for c in &s.images {
|
||||
m = m.max(c.id);
|
||||
}
|
||||
for c in &s.shapes {
|
||||
m = m.max(c.id);
|
||||
}
|
||||
for c in &s.tables {
|
||||
m = m.max(c.id);
|
||||
}
|
||||
for c in &s.slicers {
|
||||
m = m.max(c.id);
|
||||
}
|
||||
self.free_object_ids(1).first().copied().unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// `count` distinct ids no object in the workbook uses, sorted: the ones above the largest id
|
||||
/// in use while they last, then the lowest free ones. Fewer come back only when the id space
|
||||
/// is exhausted.
|
||||
pub fn free_object_ids(&self, count: usize) -> Vec<u32> {
|
||||
let mut used: Vec<u32> = self
|
||||
.sheets
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
s.charts
|
||||
.iter()
|
||||
.map(|o| o.id)
|
||||
.chain(s.images.iter().map(|o| o.id))
|
||||
.chain(s.shapes.iter().map(|o| o.id))
|
||||
.chain(s.tables.iter().map(|o| o.id))
|
||||
.chain(s.slicers.iter().map(|o| o.id))
|
||||
})
|
||||
.collect();
|
||||
used.sort_unstable();
|
||||
used.dedup();
|
||||
let max = used.last().copied().unwrap_or(0);
|
||||
let mut out = Vec::with_capacity(count.min(1 << 16));
|
||||
let mut next = max.checked_add(1);
|
||||
while out.len() < count
|
||||
&& let Some(id) = next
|
||||
{
|
||||
out.push(id);
|
||||
next = id.checked_add(1);
|
||||
}
|
||||
m + 1
|
||||
// Past the top: the gaps below the largest id, from 1 (0 is never an object's id).
|
||||
let mut taken = used.iter().copied().peekable();
|
||||
let mut id = 1u32;
|
||||
while out.len() < count && id < max {
|
||||
while taken.next_if(|&t| t < id).is_some() {}
|
||||
if taken.peek() == Some(&id) {
|
||||
taken.next();
|
||||
} else {
|
||||
out.push(id);
|
||||
}
|
||||
id += 1;
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,4 +560,53 @@ 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_never_overflow_or_repeat() {
|
||||
let mut wb = Workbook::new();
|
||||
let image = |id| crate::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() };
|
||||
assert_eq!(wb.next_object_id(), 1);
|
||||
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));
|
||||
}
|
||||
// An id read from a file at the maximum: the lowest free id, not the maximum again.
|
||||
assert_eq!(wb.next_object_id(), 1);
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.images.push(image(1));
|
||||
sh.images.push(image(3));
|
||||
}
|
||||
assert_eq!(wb.next_object_id(), 2);
|
||||
assert_eq!(wb.free_object_ids(4), vec![2, 4, 5, 6], "the gaps, skipping 1, 3 and 7");
|
||||
let mut wb = Workbook::new();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.images.push(image(u32::MAX - 1));
|
||||
}
|
||||
assert_eq!(wb.free_object_ids(3), vec![1, 2, u32::MAX], "the top first, then from 1");
|
||||
}
|
||||
|
||||
/// A file can use the largest id and the one below it: nothing is free above them, so the free
|
||||
/// ids are the lowest unused ones from 1 (0 is never an object's id), and a large request is
|
||||
/// served in time linear in the count and the ids in use.
|
||||
#[test]
|
||||
fn free_object_ids_come_from_below_when_nothing_is_free_above() {
|
||||
let mut wb = Workbook::new();
|
||||
let image = |id| crate::Image { id, anchor: Default::default(), data: Default::default(), mime: String::new(), alt: String::new() };
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.images.push(image(u32::MAX));
|
||||
sh.images.push(image(u32::MAX - 1));
|
||||
sh.images.push(image(0));
|
||||
}
|
||||
assert_eq!(wb.free_object_ids(3), vec![1, 2, 3]);
|
||||
assert_eq!(wb.next_object_id(), 1);
|
||||
// The 100,000 lowest free ids: distinct, ascending, none of them in use.
|
||||
let many = wb.free_object_ids(100_000);
|
||||
assert_eq!(many.len(), 100_000);
|
||||
assert!(many.windows(2).all(|w| w[0] < w[1]), "ascending without repeats");
|
||||
assert!(!many.contains(&0) && !many.contains(&(u32::MAX - 1)) && !many.contains(&u32::MAX));
|
||||
assert_eq!(wb.free_object_ids(0), Vec::<u32>::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -89,7 +89,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
|
||||
@@ -271,6 +271,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,60 @@ 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 resize handle of the selected object under `p`, with the object and its rect. Handles
|
||||
/// reach a little outside their object, so they win over [`hit`], but like it only over the cell
|
||||
/// area: a handle that lies under the headers, the formula bar or a scroll bar leaves the press
|
||||
/// to them. Nothing while objects are hidden.
|
||||
pub fn selected_handle(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect, crate::objects::Handle)> {
|
||||
if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) {
|
||||
return None;
|
||||
}
|
||||
let o = app.selected_object?;
|
||||
let r = object_rect(geo, sh, o)?;
|
||||
crate::objects::handle_at(r, p).map(|h| (o, r, h))
|
||||
}
|
||||
|
||||
/// The topmost object under `p` and its rect: slicers paint on top, so they are hit first, then
|
||||
/// charts, pictures and shapes, each from the top of its stack. Nothing is hit outside the cell
|
||||
/// area or while objects are hidden.
|
||||
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,15 @@ 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>,
|
||||
/// This drag has made its undo step; later frames merge into it, and never into the
|
||||
/// step of an earlier drag.
|
||||
stepped: bool,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,14 @@ 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 { crate::chartview::selected_handle(app, geo, sh, p).map(|(.., h)| h) } else { None };
|
||||
let on_object = idle && crate::chartview::hit(app, geo, sh, p).is_some();
|
||||
if let Some(h) = object_handle {
|
||||
ctx.set_cursor_icon(h.cursor(geo.rtl));
|
||||
} 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,39 @@ 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.
|
||||
// Only they resize: a press anywhere on another object selects and moves it, as the
|
||||
// hover cursor shows.
|
||||
let on_handle = at.and_then(|p| crate::chartview::selected_handle(app, geo, sh, p));
|
||||
let handle = on_handle.map(|(.., h)| h);
|
||||
if let Some(p) = at
|
||||
&& let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p)
|
||||
&& let Some((obj, _)) = on_handle.map(|(o, r, _)| (o, r)).or_else(|| crate::chartview::hit(app, geo, sh, p))
|
||||
{
|
||||
app.selected_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) {
|
||||
app.grid.drag = Drag::Object { obj, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), handle, stepped: false };
|
||||
// The first recognised drag frame already holds movement away from the press.
|
||||
if let Some(now) = pos {
|
||||
drag_object(app, geo, sh, now);
|
||||
}
|
||||
}
|
||||
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 +238,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 +666,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 +957,46 @@ 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 of its own.
|
||||
fn drag_object(app: &mut SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) {
|
||||
let Drag::Object { obj, start, orig, handle, stepped } = app.grid.drag.clone() else { return };
|
||||
let d = (p - start) / geo.z;
|
||||
let (cell, dx, dy, w, h) = orig;
|
||||
let x0 = sh.col_left(cell.col) as f32 + dx;
|
||||
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 mut params = json!({
|
||||
"kind": obj.kind.name(), "id": obj.id, "at": nc.a1(),
|
||||
"dx": x0 - sh.col_left(nc.col) as f32, "dy": y0 - sh.row_top(nc.row) as f32,
|
||||
});
|
||||
// Only a handle changes the size. A body move sends none, so the engine keeps whatever the
|
||||
// object has: a file can make it larger than `object.move` accepts for a new size, and
|
||||
// moving it must not shrink it to that limit.
|
||||
if handle.is_some()
|
||||
&& let Some(p) = params.as_object_mut()
|
||||
{
|
||||
p.insert("width".into(), json!(x1 - x0));
|
||||
p.insert("height".into(), json!(y1 - y0));
|
||||
}
|
||||
let revision = app.session.active().map(|d| d.revision);
|
||||
let _ = app.session.run("object.move", params);
|
||||
if stepped {
|
||||
coalesce_last_undo(app);
|
||||
} else if app.session.active().map(|d| d.revision) != revision
|
||||
&& let Drag::Object { stepped, .. } = &mut app.grid.drag
|
||||
{
|
||||
// The first step of this drag stays apart from an earlier drag's step of the same name.
|
||||
*stepped = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,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 {
|
||||
|
||||
@@ -40,6 +40,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;
|
||||
@@ -266,8 +267,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;
|
||||
};
|
||||
@@ -305,13 +308,29 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
|
||||
ui.collapsing("Size", |ui| {
|
||||
// Keep the value being typed or dragged between frames; reloading it from the chart every frame threw the edit away.
|
||||
let draft = egui::Id::unique(("gridcraft.chart_size_draft", id));
|
||||
let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or((chart.anchor.width, chart.anchor.height));
|
||||
let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(40.0..=4000.0));
|
||||
let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(40.0..=4000.0));
|
||||
let current = (chart.anchor.width, chart.anchor.height);
|
||||
let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or(current);
|
||||
// A file can make a chart larger than the 4,000 points a field otherwise reaches. The
|
||||
// range keeps such a size, so the field shows it instead of clamping it to 4,000.
|
||||
let range = |v: f32| 40.0..=v.max(4000.0);
|
||||
let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(range(current.0)));
|
||||
let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(range(current.1)));
|
||||
if a.drag_stopped() || a.lost_focus() || b.drag_stopped() || b.lost_focus() {
|
||||
ui.data_mut(|d| d.remove::<(f32, f32)>(draft));
|
||||
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.run_or_alert("chart.set", json!({"chart": id, "width": w, "height": h}));
|
||||
// Only an edited dimension is sent: the other keeps what the chart has, however
|
||||
// large a file made it.
|
||||
let mut params = serde_json::Map::new();
|
||||
params.insert("chart".into(), json!(id));
|
||||
if w != current.0 {
|
||||
params.insert("width".into(), json!(w));
|
||||
}
|
||||
if h != current.1 {
|
||||
params.insert("height".into(), json!(h));
|
||||
}
|
||||
if params.len() > 1 {
|
||||
app.run_or_alert("chart.set", Json::Object(params));
|
||||
}
|
||||
}
|
||||
} else if a.dragged() || a.has_focus() || b.dragged() || b.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(draft, (w, h)));
|
||||
|
||||
@@ -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());
|
||||
@@ -181,6 +181,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 ribbon tab this window shows. A contextual tab depends on this window's selection, so
|
||||
@@ -65,7 +65,7 @@ impl SheetApp {
|
||||
grid,
|
||||
name_box,
|
||||
keytips,
|
||||
selected_chart,
|
||||
selected_object,
|
||||
tab_run,
|
||||
open_switch_windows,
|
||||
ribbon_tab,
|
||||
@@ -81,7 +81,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.ui.ribbon_tab, ribbon_tab);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::model::{ObjKind, ObjRef};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -10,7 +11,7 @@ fn format_chart_pane_controls_the_engine_data_table() {
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap();
|
||||
let id = session.execute("insert.chart", json!({"range": "A1:C3", "type": "column", "at": "E3"})).unwrap()["chart"].as_u64().unwrap() as u32;
|
||||
let mut h = crate::support::harness(session, 4);
|
||||
h.state_mut().selected_chart = Some(id);
|
||||
h.state_mut().selected_object = Some(ObjRef::new(ObjKind::Chart, id));
|
||||
h.state_mut().grid.pane = Some("formatChart".into());
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(Role::DisclosureTriangle, "Data Table").click();
|
||||
|
||||
@@ -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();
|
||||
@@ -118,6 +118,51 @@ fn dragging_the_size_field_resizes_the_chart() {
|
||||
assert!(w1 > w0 + 20.0, "width went from {w0} to {w1}");
|
||||
}
|
||||
|
||||
/// Makes the chart 12,000 wide and reopens the workbook from its XLSX bytes, the way a file can
|
||||
/// carry a chart larger than the 4,000 points the Size fields otherwise reach; returns its id.
|
||||
fn imported_oversized_chart(h: &mut Harness, id: u32) -> u32 {
|
||||
{
|
||||
let wb = std::sync::Arc::make_mut(&mut h.state_mut().session.doc_mut().unwrap().wb);
|
||||
wb.sheet_mut(0).unwrap().charts.iter_mut().find(|c| c.id == id).unwrap().anchor.width = 12_000.0;
|
||||
}
|
||||
let saved = h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
h.state_mut().run("file.open", json!({"name": "wide.xlsx", "base64": saved["base64"]})).unwrap();
|
||||
h.run_steps(4);
|
||||
let id = h.state().session.doc().unwrap().wb.active().unwrap().charts[0].id;
|
||||
let (w, hh) = size(h, id);
|
||||
assert_eq!((w.round(), hh.round()), (12_000.0, 200.0), "the reader keeps the file's size");
|
||||
id
|
||||
}
|
||||
|
||||
/// A chart a file made 12,000 wide shows that width in the Size fields, and a new height sends
|
||||
/// the height alone, so nothing shrinks the width to the fields' 4,000 reach on the way.
|
||||
#[test]
|
||||
fn the_size_fields_keep_an_oversized_imported_chart_width_when_the_height_changes() {
|
||||
let (mut h, id) = fixture();
|
||||
let id = imported_oversized_chart(&mut h, id);
|
||||
open_size_fields(&mut h, id);
|
||||
let (width_value, height_field) = {
|
||||
let mut fields = h.get_all_by_role(Role::SpinButton);
|
||||
let width = fields.next().unwrap();
|
||||
let height = fields.next().unwrap();
|
||||
(width.value(), height.rect())
|
||||
};
|
||||
assert!(width_value.as_deref().is_some_and(|v| v.contains("12000")), "the field shows the file's width: {width_value:?}");
|
||||
h.input_mut().events.push(Event::PointerMoved(height_field.center()));
|
||||
button(&mut h, height_field.center(), true);
|
||||
button(&mut h, height_field.center(), false);
|
||||
for digit in ["2", "0", "1"] {
|
||||
h.input_mut().events.push(Event::Text(digit.into()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
let (w, hh) = size(&h, id);
|
||||
assert_eq!((w.round(), hh), (12_000.0, 201.0), "the width stays and the height is the typed one");
|
||||
let (_, p) = h.state().session.journal.iter().rev().find(|(cmd, _)| cmd == "chart.set").unwrap();
|
||||
assert!(p.get("width").is_none(), "a height edit sends no width: {p}");
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Harness, pos: egui::Pos2, wobble: egui::Vec2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(pos));
|
||||
h.run_steps(2);
|
||||
@@ -141,7 +186,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();
|
||||
|
||||
@@ -71,6 +71,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,426 @@
|
||||
//! 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:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handle_under_the_headers_does_not_take_the_press() {
|
||||
let (mut h, o) = fixture();
|
||||
h.state_mut().run("object.move", json!({"kind": "shape", "id": o.shape.id, "at": "A1", "dx": 0, "dy": 0})).unwrap();
|
||||
h.run_steps(2);
|
||||
let before = rect(&h, o.shape);
|
||||
click(&mut h, before.center());
|
||||
assert_eq!(h.state().selected_object, Some(o.shape));
|
||||
let cells = h.state().grid.cells_rect.unwrap();
|
||||
assert_eq!(before.left_top(), cells.left_top(), "the shape's top-left handle sits on the header corner");
|
||||
// Within the top-left handle's reach, but over the column header: the press is the header's.
|
||||
let grab = Handle::TopLeft.pos(before) + vec2(4.0, -4.0);
|
||||
assert!(grab.y < cells.top());
|
||||
drag(&mut h, grab, grab + vec2(30.0, 40.0));
|
||||
assert_eq!(rect(&h, o.shape), before, "the shape is neither moved nor resized");
|
||||
assert_eq!(h.state().selected_object, None, "the press went to the column header");
|
||||
// Within the left handle's reach, but over the row header.
|
||||
let grab = Handle::Left.pos(before) + vec2(-4.0, 0.0);
|
||||
assert!(grab.x < cells.left());
|
||||
h.state_mut().selected_object = Some(o.shape);
|
||||
drag(&mut h, grab, grab + vec2(40.0, 0.0));
|
||||
assert_eq!(rect(&h, o.shape), before, "the shape is neither moved nor resized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_drag_of_an_object_is_its_own_undo_step() {
|
||||
let (mut h, o) = fixture();
|
||||
let start = rect(&h, o.image);
|
||||
click(&mut h, start.center());
|
||||
drag(&mut h, start.center(), start.center() + vec2(30.0, 20.0));
|
||||
let moved = rect(&h, o.image);
|
||||
assert!((moved.min - start.min - vec2(30.0, 20.0)).length() < 1.0);
|
||||
let grab = Handle::BottomRight.pos(moved);
|
||||
drag(&mut h, grab, grab + vec2(24.0, 18.0));
|
||||
let resized = rect(&h, o.image);
|
||||
assert!((resized.size() - moved.size() - vec2(24.0, 18.0)).length() < 1.0);
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(rect(&h, o.image), moved, "one undo takes back the resize only");
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(rect(&h, o.image), start, "a second undo takes back the move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_an_unselected_object_by_its_edge_moves_it() {
|
||||
let (mut h, o) = fixture();
|
||||
assert_eq!(h.state().selected_object, None);
|
||||
let before = rect(&h, o.shape);
|
||||
// On the bottom-right corner, where the handle would be if the shape were selected; the
|
||||
// pointer shows Move there, and the press selects and moves the shape.
|
||||
let grab = Handle::BottomRight.pos(before) - vec2(2.0, 2.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(grab));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move);
|
||||
drag(&mut h, grab, grab + vec2(30.0, 20.0));
|
||||
let after = rect(&h, o.shape);
|
||||
assert_eq!(h.state().selected_object, Some(o.shape));
|
||||
assert!((after.size() - before.size()).length() < 0.5, "not resized: {before:?} {after:?}");
|
||||
assert!((after.min - before.min - vec2(30.0, 20.0)).length() < 1.0, "moved with the pointer: {before:?} {after:?}");
|
||||
}
|
||||
|
||||
fn anchor(h: &Harness, o: ObjRef) -> gridcraft_engine::model::Anchor {
|
||||
h.state().session.doc().unwrap().wb.active().unwrap().object_anchor(o).unwrap()
|
||||
}
|
||||
|
||||
/// Makes the fixture's picture 12,000 by 60 and reopens the workbook from its XLSX bytes, the way
|
||||
/// a file can carry an object wider than the 10,000 points `object.move` gives a new size.
|
||||
/// Returns the picture as reopened.
|
||||
fn imported_oversized_picture(h: &mut Harness, o: &Objects) -> ObjRef {
|
||||
{
|
||||
let wb = std::sync::Arc::make_mut(&mut h.state_mut().session.doc_mut().unwrap().wb);
|
||||
let a = wb.sheet_mut(0).unwrap().object_anchor_mut(o.image).unwrap();
|
||||
a.width = 12_000.0;
|
||||
a.height = 60.0;
|
||||
}
|
||||
let saved = h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
h.state_mut().run("file.open", json!({"name": "wide.xlsx", "base64": saved["base64"]})).unwrap();
|
||||
h.run_steps(4);
|
||||
let pic = ObjRef::new(ObjKind::Image, h.state().session.doc().unwrap().wb.active().unwrap().images[0].id);
|
||||
let a = anchor(h, pic);
|
||||
assert_eq!((a.width.round(), a.height.round()), (12_000.0, 60.0), "the reader keeps the file's size: {a:?}");
|
||||
pic
|
||||
}
|
||||
|
||||
/// Dragging the body of such a picture moves it and sends no size, so nothing shrinks it to the
|
||||
/// limit on the way.
|
||||
#[test]
|
||||
fn dragging_the_body_of_an_oversized_imported_object_keeps_its_size() {
|
||||
let (mut h, o) = fixture();
|
||||
let pic = imported_oversized_picture(&mut h, &o);
|
||||
let before = rect(&h, pic);
|
||||
// Well inside the body, on the part of the picture in view and clear of the handles' reach.
|
||||
let grab = before.left_top() + vec2(30.0, 30.0);
|
||||
click(&mut h, grab);
|
||||
assert_eq!(h.state().selected_object, Some(pic));
|
||||
drag(&mut h, grab, grab + vec2(30.0, 25.0));
|
||||
let a = anchor(&h, pic);
|
||||
assert_eq!((a.width.round(), a.height.round()), (12_000.0, 60.0), "the size is untouched: {a:?}");
|
||||
let after = rect(&h, pic);
|
||||
assert!((after.min - before.min - vec2(30.0, 25.0)).length() < 1.0, "moved with the pointer: {before:?} {after:?}");
|
||||
// The drag sent the position only: the engine saw no size to cap.
|
||||
let (_, p) = h.state().session.journal.iter().rev().find(|(id, _)| id == "object.move").unwrap();
|
||||
assert!(p.get("width").is_none() && p.get("height").is_none(), "a body move sends no size: {p}");
|
||||
}
|
||||
|
||||
/// A handle drag on such a picture resizes it by the drag only: the left handle moved 10 px to
|
||||
/// the right leaves it 11,990 wide, with no jump to 10,000.
|
||||
#[test]
|
||||
fn a_handle_drag_on_an_oversized_imported_object_resizes_by_the_drag_only() {
|
||||
let (mut h, o) = fixture();
|
||||
let pic = imported_oversized_picture(&mut h, &o);
|
||||
let before = rect(&h, pic);
|
||||
click(&mut h, before.left_top() + vec2(30.0, 30.0));
|
||||
let grab = Handle::Left.pos(before);
|
||||
drag(&mut h, grab, grab + vec2(10.0, 0.0));
|
||||
// In window points (the grid draws at its zoom): 10 narrower, the left edge 10 further right.
|
||||
let after = rect(&h, pic);
|
||||
assert!((before.width() - after.width() - 10.0).abs() < 1.0, "narrower by the drag only: {before:?} {after:?}");
|
||||
assert!((after.left() - before.left() - 10.0).abs() < 1.0 && (after.top() - before.top()).abs() < 0.5, "{before:?} {after:?}");
|
||||
let a = anchor(&h, pic);
|
||||
assert!(a.width > 11_900.0 && a.width < 12_000.0 && a.height.round() == 60.0, "no jump to the 10,000 limit: {a:?}");
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Chart parity
|
||||
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-11-01 · **Change:** minor (chart data tables, legend keys and borders, native XLSX round-trip and print/PDF; earlier: selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-11-02 · **Change:** minor (charts resize from eight handles; earlier: chart data tables, legend keys and borders, native XLSX round-trip and print/PDF; earlier: selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Excel's chart types and chart elements against GridCraft. Part of
|
||||
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
|
||||
@@ -37,7 +37,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate
|
||||
|
||||
| Element | Excel | GridCraft |
|
||||
|---|---|---|
|
||||
| Move and resize the chart object | yes | yes (corner handle, Format Chart Area Size fields) |
|
||||
| Move and resize the chart object | yes | yes (eight handles, each moving only its own edges; Format Chart Area Size fields) (#190) |
|
||||
| Selected chart source-data outline | visible on uncovered cells, beneath floating objects | painted above cells and below charts, shapes, pictures and slicers; retains pane clipping and right-to-left cell geometry (#2861) |
|
||||
| Chart title, axis titles | text, linked to cells, full formatting | text only |
|
||||
| Legend | position, overlay, formatting, per-entry delete | position only |
|
||||
@@ -93,18 +93,18 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690).
|
||||
|
||||
## Work (40–60 h)
|
||||
|
||||
1. Chart resizing (#190): 1–2 h.
|
||||
2. Preserve unmodelled chart XML on round-trip (keep the original part, patch only edited fields): 6–10 h.
|
||||
3. Axis options, label options, gridlines per axis, legend formatting: 10–15 h.
|
||||
4. Error bars; Format Trendline pane (trendlines landed): 4–6 h.
|
||||
5. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h.
|
||||
6. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h.
|
||||
7. Chart sheets, templates and PivotChart field buttons: 5–8 h. Filled map: owner decision on boundary data.
|
||||
1. Preserve unmodelled chart XML on round-trip (keep the original part, patch only edited fields): 6–10 h.
|
||||
2. Axis options, label options, gridlines per axis, legend formatting: 10–15 h.
|
||||
3. Error bars; Format Trendline pane (trendlines landed): 4–6 h.
|
||||
4. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h.
|
||||
5. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h.
|
||||
6. Chart sheets, templates and PivotChart field buttons: 5–8 h. Filled map: owner decision on boundary data.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Charts move and resize from eight handles, each moving only its own edges (#190); resizing leaves the work list (1–2 h, within the 40–60 h rounding) |
|
||||
| 2026-11-01 | minor | Chart data tables for column, bar, line, area and compatible combo charts: command/UI controls, formatted source values, blanks, grouped category headers, legend keys, independent borders, shared print/PDF rendering and native c:dTable import/export. Dense tables show a space notice; no readiness score change. |
|
||||
| 2026-10-29 | minor | Selected charts highlight their source cells beneath floating objects, so the blue outline no longer crosses the chart; uncovered source cells remain highlighted (#2861) |
|
||||
| 2026-10-24 | minor | 100% Stacked Area: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` |
|
||||
|
||||
@@ -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 and replies `{selected, rect}`, the rect in window points; with no `id` it clears the selection and replies `null` |
|
||||
| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 synthetic events or 8 MiB of synthetic input queued (pointer, key, text and move events share this budget) |
|
||||
| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same shared queue limits |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB |
|
||||
@@ -90,6 +91,16 @@ 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.
|
||||
`object.move` and `chart.set` cap `width` and `height` at 10,000 points, or at the object's
|
||||
current size when a file made it larger: such an object keeps its size when it is moved and
|
||||
shrinks by what a resize asks, but does not grow further.
|
||||
`selectedChart` remains for existing clients: the id when the selection is a chart, otherwise
|
||||
`null`. With an object selected, `ui.key {key: "Delete"}` (or Backspace) deletes the object
|
||||
instead of clearing cells, and Escape clears the selection.
|
||||
|
||||
### Windows
|
||||
|
||||
Every open workbook has one or more windows; View › New Window (`view.newWindow`) adds another
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (§13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (§4: chart resizing is no longer a gap; earlier: §13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
|
||||
using GridCraft for real work first). This is the work list: pick from the top, check the open
|
||||
@@ -46,10 +46,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
- **Missing:** axis scaling, label options, error bars, per-point formatting.
|
||||
- **Added:** data tables beneath column, bar, line, area and compatible combo charts, with legend keys, border controls, live source values, native XLSX round-trip and print/PDF.
|
||||
- **Evidence:** `Chart` holds only title, legend position, two flags and axis titles
|
||||
(`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn and written back.
|
||||
(`crates/model/src/features.rs`); all 2-D kinds survive XLSX round trip (#158, #161); charts
|
||||
can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read,
|
||||
written, drawn) landed.
|
||||
resize from eight handles (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines
|
||||
(read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn
|
||||
and written back.
|
||||
- **Impact:** charts are in most business workbooks.
|
||||
- **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md)
|
||||
|
||||
@@ -185,6 +185,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | §4: charts, like every floating object, move and resize from eight handles (#190), so resizing is no longer listed as missing; the evidence is one paragraph again, without the stale "PRs #158, #161 open" |
|
||||
| 2026-11-01 | minor | §13: the Analysis ToolPak has all 19 of Excel's tools (two-factor Anova with and without replication, z-Test, Fourier Analysis, Random Number Generation, Sampling); its chart output remains. The stale "`data.solver`, `data.analysisToolPak` missing" and "no Forecast Start" sentences removed |
|
||||
| 2026-11-01 | minor | §4: chart data tables implemented for column, bar, line, area and compatible combo charts, with legend keys and border settings, live source values, XLSX round-trip and print/PDF; other chart gaps remain. |
|
||||
| 2026-10-31 | minor | §13: Solver Options (Max Evaluations, Integer Optimality and the Evolutionary options), saved with the model |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Target-app parity: GridCraft vs Microsoft Excel
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (what-if and analysis: Analysis ToolPak has all 19 tools; earlier: page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (chart resizing no longer listed as missing; no score change; earlier: what-if and analysis: Analysis ToolPak has all 19 tools; earlier: page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
|
||||
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
|
||||
@@ -29,7 +29,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~50% | ~650–1,050 | Get & Transform and the data model, localization (twelve languages), charts depth, file fidelity (`.xls`, corpus, pass-through), PivotTables, collaboration, mobile |
|
||||
| Mainstream practitioner | ~50% | ~200–310 | File exchange with Excel users (corpus and Excel verification, pass-through, large files, `.xls`, encryption: 63–97 h), common-chart depth (30–45 h), PivotTables (20–30 h), keyboard and interaction (18–28 h), calc correctness and functions (20–33 h) |
|
||||
| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` import and saving with a password 24–36 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) |
|
||||
| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` import and saving with a password 24–36 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart polish (5–8 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) |
|
||||
|
||||
Each tier is a subset of the one above, so its hours are too. Calibrated as in [Effort and
|
||||
calibration](#effort-and-calibration) (~6–10 h per readiness point for the remaining, verification-heavy
|
||||
@@ -134,7 +134,7 @@ functions, co-authoring and admin, specialist hardware, and languages beyond the
|
||||
| Cell formatting, styles, conditional formatting | 13 | 80 | Feature-area table |
|
||||
| Sort, filter, tables, validation | 10 | 70 | Feature-area table |
|
||||
| PivotTables (core: fields, layouts, grouping, refresh) | 8 | 60 | Slicers and calculated fields excluded as less frequent |
|
||||
| Charts (column, bar, line, pie, scatter, combo) | 9 | 45 | Common kinds work and survive save; no axis options or trendlines; resize broken (#190) |
|
||||
| Charts (column, bar, line, pie, scatter, combo) | 9 | 45 | Common kinds work and survive save; trendlines read, written and drawn; no axis options; moved and resized from eight handles (#190) |
|
||||
| Page layout, print, PDF | 5 | 60 | Latin text exports |
|
||||
| Comments, notes, protection | 3 | 55 | Feature-area table |
|
||||
| Pictures and shapes | 2 | 45 | Feature-area table |
|
||||
@@ -144,7 +144,7 @@ Discounts for what still stops real work:
|
||||
|
||||
| Discount | Factor | Evidence |
|
||||
|---|---|---|
|
||||
| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); charts can't be resized (#190); missing horizontal scrollbar report (#176) |
|
||||
| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); missing horizontal scrollbar report (#176) |
|
||||
| Stability on real machines | × 0.92 | Never-crash lints, fuzzed commands and the `Session::execute` guard; but field reports of a startup crash on Intel UHD (#120), an error on close (#117), a freeze opening from Finder (#166) and screen flashing (#76) |
|
||||
| File exchange with Excel users | × 0.88 | Excel opening our output is unverified across features; unmodelled parts dropped on save; advanced chart kinds lose their type; 100 MB files fail (#175); `.xls` and encrypted files are refused with a clear message (#17) rather than opened. Harsher than a drawing app's discount because spreadsheets are exchanged constantly |
|
||||
|
||||
@@ -167,7 +167,7 @@ edge cases are left out, along with everything excluded from the mainstream numb
|
||||
| Rows and columns: insert, delete, widths, AutoFit | 10 | 82 | AutoFit fixed (#42, #88); rows auto-fit oversized fonts on open and row formatting (#833) |
|
||||
| Sort and filter | 10 | 82 | |
|
||||
| Save, Save As, reopen XLSX | 10 | 85 | |
|
||||
| A basic chart | 8 | 55 | Created easily; can't be resized (#190) |
|
||||
| A basic chart | 8 | 55 | Created easily; moved and resized from eight handles (#190) |
|
||||
| Print and PDF | 7 | 65 | Desktop works; web PDF gives no file or feedback (#141) |
|
||||
| Find and replace | 5 | 85 | |
|
||||
| **Average depth** | 100 | **80.2** | |
|
||||
@@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Charts move and resize from eight handles like every floating object (#190): the Essentials chart work is polish only (5–8 h), and the chart rows and the interaction discount no longer list resizing as missing; the Mainstream chart row no longer lists trendlines as missing (no score change) |
|
||||
| 2026-11-01 | minor | What-if and analysis: Analysis ToolPak has all 19 tools (was 13 of 19; no charts); no score change |
|
||||
| 2026-11-01 | minor | Charts: data tables for column, bar, line, area and compatible combo charts, with legend keys and borders; live source values, native XLSX round-trip and shared print/PDF rendering. Readiness estimates unchanged. |
|
||||
| 2026-11-01 | minor | Page layout and print: Page Setup's page order (Down, then over or Over, then down), kept in XLSX; no score change |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; 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-11-02 · **Change:** minor (every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; 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
|
||||
@@ -68,7 +68,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) |
|
||||
@@ -196,6 +196,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | 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-11-01 | minor | Chart Design › Add Chart Element offers Data Table with/no legend keys and None; Format Chart Area has a Data Table section with visibility, legend keys, horizontal/vertical borders and outline. Both use engine commands. |
|
||||
| 2026-11-01 | minor | Page Setup has a Page order choice (Down, then over, the default, or Over, then down) with a small picture of the order, also when Print Titles opens it on its Sheet tab; OK sets it with the margins in one undo step. Printing, PDF export, the print preview, Page Break Preview's page numbers and Page Layout view's headers and footers follow the order. The dialog is in German now too |
|
||||
| 2026-11-01 | minor | Formulas › Error Checking › Trace Error (`formulas.traceError`): red arrows from the cell an error starts in to the selected cell, through the first reference showing the same error at each step, and blue arrows from that cell's own references, as measured in Excel; the command returns the source and the arrows |
|
||||
|
||||
Reference in New Issue
Block a user