Files
gridcraft/crates/ui-egui/src/grid/interact.rs
T
Pranav f6a0556b9e 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".
2026-11-02 09:30:13 +01:00

1047 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use gridcraft_engine::model::{ObjKind, ObjRef};
use super::editor::validation_arrow;
use super::geo::Geo;
use super::keyboard::keyboard;
use super::menus::set_fill_tag;
use super::selection::select_ranges;
use super::tables::{is_filter_button, table_headings};
use super::{
CellRef, Color32, CursorIcon, Drag, EditState, MAX_COLS, MAX_ROWS, Painter, Pos2, RangeRef, Rect, Sheet, SheetApp, Stroke, Tokens, Workbook,
json, pos2, vec2,
};
// ------------------------------------------------------------------ interaction
struct InteractionInput<'a> {
ui: &'a mut egui::Ui,
response: &'a egui::Response,
geo: &'a Geo,
sheet: &'a Sheet,
workbook: &'a Workbook,
context: egui::Context,
press_origin: Option<Pos2>,
any_pointer_down: bool,
modifiers: egui::Modifiers,
position: Option<Pos2>,
primary_clicked: bool,
in_cells: bool,
in_column_header: bool,
in_row_header: bool,
in_corner: bool,
selection: gridcraft_engine::Selection,
current_rect: Rect,
handle: Rect,
handle_panes: Vec<Rect>,
border_panes: Vec<Rect>,
freeze_columns: Rect,
freeze_rows: Rect,
}
impl InteractionInput<'_> {
fn on_handle(&self, p: Pos2) -> bool {
self.handle.contains(p) && self.handle_panes.iter().any(|r| r.contains(p))
}
fn on_current_border(&self, p: Pos2) -> bool {
on_border(self.current_rect, p) && self.border_panes.iter().any(|r| r.contains(p))
}
}
pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
// Text selection and editing must not also move the object or edit worksheet cells.
if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() {
return;
}
let ctx = ui.ctx().clone();
// Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates.
let primary_clicked = resp.clicked_by(egui::PointerButton::Primary);
let pointer = ctx.input(|i| i.pointer.clone());
let mods = ctx.input(|i| i.modifiers);
// Hit-testing works in logical coordinates (see `Geo::rtl`).
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
let in_cells = pos.is_some_and(|p| geo.in_cells(p));
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left());
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top());
let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top());
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
let cur_rect = geo.range_rect(sh, sel.current());
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
// The fill handle and the move border only react in the panes where they are painted, not
// over the frozen rows/columns that a scrolled selection lies under.
let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end));
let border_panes = geo.range_panes(sel.current());
let (freeze_cols, freeze_rows) = freeze_handles(geo);
let input = InteractionInput {
ui,
response: resp,
geo,
sheet: sh,
workbook: wb,
context: ctx.clone(),
press_origin: pointer.press_origin(),
any_pointer_down: pointer.any_down(),
modifiers: mods,
position: pos,
primary_clicked,
in_cells,
in_column_header: in_col_header,
in_row_header,
in_corner,
selection: sel.clone(),
current_rect: cur_rect,
handle,
handle_panes,
border_panes,
freeze_columns: freeze_cols,
freeze_rows,
};
if handle_draw_tool(app, &input) {
return;
}
// A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear
// button resets it. Runs before the generic object select/drag so tiles stay clickable.
if primary_clicked
&& !resp.drag_started()
&& let Some(p) = pos
&& let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p)
{
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}));
}
crate::chartview::SlicerAction::Clear => {
let _ = app.run("slicer.clear", json!({"slicer": id}));
}
}
return;
}
// Objects (charts, pictures, shapes, slicers) take clicks first. Only the primary button moves
// them: a right-click that wobbles into a drag still opens the object's menu (#2067).
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().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((obj, _)) = on_handle.map(|(o, r, _)| (o, r)).or_else(|| crate::chartview::hit(app, geo, sh, p))
{
app.selected_object = Some(obj);
if resp.double_clicked() && app.open_object(obj) != crate::objects::OpenAction::Select {
return;
}
if primary_drag && let Some(a) = sh.object_anchor(obj) {
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_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((obj, _)) = crate::chartview::hit(app, geo, sh, p)
{
app.selected_object = Some(obj);
app.grid.object_menu = Some((geo.mirror_pos(p), obj.kind.name(), obj.id));
return;
}
if handle_header_autofit(app, &input) {
return;
}
// Double-click the fill handle: a double-click is also a click, so run it before the handle press returns.
if resp.double_clicked()
&& let Some(p) = pos
&& input.on_handle(p)
{
// Fill down as far as the neighbouring column goes.
let src = sel.current();
let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 };
let cap = src.end.row.saturating_add(1_000_000).min(MAX_ROWS - 1);
let last = if src.end.row < cap { sh.last_contiguous_nonempty(neighbour, src.end.row + 1, cap) } else { src.end.row };
if last > src.end.row {
let target = RangeRef::new(src.start, CellRef::new(last, src.end.col));
let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()}));
}
app.grid.drag = Drag::None;
app.grid.drag_select = false;
resp.request_focus();
return;
}
// Press: choose the interaction mode before pointer movement is applied.
if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) {
handle_press(app, &input);
return;
}
handle_dragging(app, &input);
handle_release(app, &input);
handle_context_menu_and_keyboard(app, &input);
}
fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
let (in_cells, pos, resp, geo, sh, primary_clicked) =
(input.in_cells, input.position, input.response, input.geo, input.sheet, input.primary_clicked);
let ctx = input.context.clone();
if !app.session.draw_tool.is_empty() && in_cells {
ctx.set_cursor_icon(CursorIcon::Crosshair);
if app.session.draw_tool == "pen" {
if let Some(p) = pos
&& resp.dragged()
{
// Through the cell under the pointer, so frozen panes and pages map right.
let c = geo.cell_at(sh, p);
let sx = sh.col_left(c.col) as f32 + (p.x - geo.x(sh, c.col)) / geo.z;
let sy = sh.row_top(c.row) as f32 + (p.y - geo.y(sh, c.row)) / geo.z;
app.grid.ink.push([sx, sy]);
}
if !app.grid.ink.is_empty() {
// Live preview.
let pts: Vec<Pos2> = app
.grid
.ink
.iter()
.map(|q| {
let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64));
geo.mirror_pos(pos2(
geo.x(sh, col) + (q[0] - sh.col_left(col) as f32) * geo.z,
geo.y(sh, row) + (q[1] - sh.row_top(row) as f32) * geo.z,
))
})
.collect();
input
.ui
.painter()
.add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9))));
}
if resp.drag_stopped() && app.grid.ink.len() >= 2 {
let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink);
let _ = app.run("draw.stroke", json!({"points": pts}));
}
return true;
}
if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) {
if let Some(p) = pos
&& 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}));
}
return true;
}
}
false
}
fn handle_header_autofit(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position);
let (in_col_header, in_row_header) = (input.in_column_header, input.in_row_header);
let resp = input.response;
if !resp.double_clicked() {
return false;
}
let Some(p) = pos else { return false };
let unhide = if in_col_header {
hidden_col_edge(geo, sh, p.x).map(|col| ("home.unhideColumns", json!({"cols": RangeRef::cols(col, col).a1()})))
} else if in_row_header {
hidden_row_edge(geo, sh, p.y).map(|row| ("home.unhideRows", json!({"rows": RangeRef::rows(row, row).a1()})))
} else {
None
};
let autofit = if unhide.is_some() {
unhide
} else if in_col_header {
col_edge(geo, sh, p.x).map(|(col, _)| {
let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col);
("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) })
})
} else if in_row_header {
row_edge(geo, sh, p.y).map(|(row, _)| {
let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row);
("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) })
})
} else {
None
};
if let Some((command, params)) = autofit {
app.grid.drag = Drag::None;
app.grid.drag_select = false;
app.run_or_alert(command, params);
resp.request_focus();
return true;
}
false
}
fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p: Pos2) {
let (geo, sh, wb, sel) = (input.geo, input.sheet, input.workbook, &input.selection);
let mods = input.modifiers;
let resp = input.response;
let in_col_header = input.in_column_header;
if in_col_header {
if let Some((c, x)) = col_edge(geo, sh, p.x) {
app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z };
return;
}
let c = geo.col_at(sh, p.x);
// A Table heading's filter button, while the header names stand in for the letters.
if let Some(h) = table_headings(geo, sh, sel.active).into_iter().find(|h| h.col == c && h.button.is_some_and(|b| b.contains(p))) {
app.grid.filter_menu = Some((c, h.header_row, geo.mirror_pos(p)));
return;
}
if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) {
return;
}
// An armed Format Painter paints the columns dragged over instead of moving them.
if resp.drag_started()
&& !mods.any()
&& app.session.format_painter.is_none()
&& sel.ranges.len() == 1
&& sel.current().is_full_cols()
&& sel.current().contains(CellRef::new(0, c))
{
app.grid.drag = Drag::MoveCols { source: sel.current(), before: None };
return;
}
let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) };
select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c));
app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c });
}
}
fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p: Pos2) {
let (geo, sh, wb, sel) = (input.geo, input.sheet, input.workbook, &input.selection);
let mods = input.modifiers;
let in_row_header = input.in_row_header;
if in_row_header {
if let Some((r, y)) = row_edge(geo, sh, p.y) {
app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z };
return;
}
let r = geo.row_at(sh, p.y);
if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) {
return;
}
let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) };
select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0)));
app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r });
}
}
fn handle_press(app: &mut SheetApp, input: &InteractionInput<'_>) {
let (geo, sh, wb, resp) = (input.geo, input.sheet, input.workbook, input.response);
let (pos, mods, sel) = (input.position, input.modifiers, &input.selection);
let (primary_clicked, in_cells, in_col_header, in_row_header, in_corner) =
(input.primary_clicked, input.in_cells, input.in_column_header, input.in_row_header, input.in_corner);
let (freeze_cols, freeze_rows) = (input.freeze_columns, input.freeze_rows);
let on_handle = |p| input.on_handle(p);
let on_cur_border = |p| input.on_current_border(p);
let pointer = input.press_origin;
// Where the press began (a drag is only recognised after the pointer has moved).
let Some(p) = pointer.map(|p| geo.mirror_pos(p)).or(pos) else { return };
// The outline band's − / + and level buttons act on release, like buttons.
if let Some(action) = crate::outline::hit(geo, sh, p) {
if primary_clicked {
let (command, params) = action.command();
app.run_or_alert(command, params);
}
return;
}
// Freeze handles sit on the header edges (and the corner's edges when nothing is frozen).
if freeze_cols.contains(p) || freeze_rows.contains(p) {
app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None };
return;
}
if in_corner {
let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()}));
return;
}
if in_col_header {
handle_column_header_press(app, input, p);
return;
}
if in_row_header {
handle_row_header_press(app, input, p);
return;
}
if !in_cells {
return;
}
let c = geo.cell_at(sh, p);
// Data validation list arrow next to the active cell.
if let Some(r) = validation_arrow(sh, geo, sel.active)
&& r.contains(p)
{
app.grid.list_picker = Some(sel.active);
return;
}
// Filter dropdown buttons.
if is_filter_button(sh, geo, p) {
app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p)));
return;
}
// A dialog's formula box in point mode (New Formatting Rule) takes the reference; the
// selection, which the dialog acts on, stays.
if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) {
d.pick_reference(c, &sh.name, false);
app.grid.drag = Drag::Select;
app.grid.drag_select = true;
if resp.drag_started()
&& let Some(p) = pos
{
select_to_pointer(app, geo, sh, wb, p);
}
return;
}
// Point mode while editing a formula: clicking inserts a reference.
if let Some(ed) = app.editor.as_mut()
&& ed.can_point()
{
let anchor = if mods.shift && ed.point_at_caret() {
ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(c)
} else {
c
};
let range = RangeRef::new(anchor, c);
let text = if range.is_single() { c.a1() } else { range.a1() };
let sheet_name = (ed.sheet != wb.active_sheet()).then(|| sh.name.clone());
let r = match sheet_name {
Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)),
None => text,
};
if mods.command && !mods.shift {
ed.append_ref(&r);
} else {
ed.insert_ref(&r);
}
ed.request_focus = true;
ed.point_cell = Some(c);
ed.point_anchor = Some((wb.active_sheet(), anchor));
app.grid.drag = Drag::Select;
app.grid.drag_select = true;
if resp.drag_started()
&& let Some(p) = pos
{
select_to_pointer(app, geo, sh, wb, p);
}
return;
}
if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {
return;
}
if on_handle(p) {
app.grid.drag = Drag::Fill { target: None, menu: resp.drag_started_by(egui::PointerButton::Secondary) };
return;
}
if on_cur_border(p) && resp.drag_started() {
app.grid.drag = Drag::Move { grab: c, to: None };
return;
}
let now = crate::now_ms();
if handle_cell_double_click(app, input, c, now) {
return;
}
app.grid.last_click = Some((c, now));
if mods.shift {
let _ = app.session.run("selection.set", json!({"range": RangeRef::new(sel.anchor, c).a1(), "active": sel.active.a1()}));
if let Some(d) = app.session.active_mut() {
d.selection.anchor = sel.anchor;
}
} else if mods.command {
// Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection.
if let Some(h) = sh.hyperlinks.get(&c).cloned()
&& primary_clicked
&& !mods.alt
&& p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8
{
follow_link(app, &h.target);
} else {
select_ranges(app, RangeRef::cell(c), true, c);
}
} else {
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()}));
}
// A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects.
if let Some(h) = sh.hyperlinks.get(&c).cloned()
&& primary_clicked
&& !mods.any()
{
let r = geo.cell_rect(sh, c);
if p.x < r.left() + r.width() * 0.8 {
follow_link(app, &h.target);
}
}
}
app.grid.drag = Drag::Select;
app.grid.drag_select = true;
if resp.drag_started()
&& let Some(p) = pos
{
// The first recognised drag already includes movement away from the press.
select_to_pointer(app, geo, sh, wb, p);
}
}
fn handle_cell_double_click(app: &mut SheetApp, input: &InteractionInput<'_>, c: CellRef, now: f64) -> bool {
let (sh, mods) = (input.sheet, input.modifiers);
if let Some((last, at)) = app.grid.last_click
&& last == c
&& now - at < 450.0
&& !mods.shift
{
app.grid.last_click = None;
// A PivotTable's value cell shows its source records on a new sheet (Excel's Show
// Details); its labels and headers still edit.
if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) {
let not_a_value_cell =
gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string();
match app.run("pivot.showDetails", json!({"cell": c.a1()})) {
Ok(_) => return true,
Err(e) if e == not_a_value_cell => {}
Err(e) => {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
return true;
}
}
}
// Following a hyperlink requires a single click in Excel; double-click edits.
app.begin_edit(None, false);
return true;
}
false
}
fn handle_context_menu_and_keyboard(app: &mut SheetApp, input: &InteractionInput<'_>) {
let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position);
let resp = input.response;
let primary_clicked = input.primary_clicked;
let (in_cells, in_col_header, in_row_header) = (input.in_cells, input.in_column_header, input.in_row_header);
let ctx = input.context.clone();
// Right-click: context menu.
if resp.secondary_clicked()
&& let Some(p) = pos
{
if in_cells && !sel.contains(geo.cell_at(sh, p)) {
let _ = app.session.run("selection.set", json!({"cell": geo.cell_at(sh, p).a1()}));
}
if in_col_header || in_row_header {
let (rows, idx) = if in_row_header { (true, geo.row_at(sh, p.y)) } else { (false, geo.col_at(sh, p.x)) };
let inside = sel.ranges.iter().any(|r| {
if rows {
r.is_full_rows() && idx >= r.start.row && idx <= r.end.row
} else {
r.is_full_cols() && idx >= r.start.col && idx <= r.end.col
}
});
if !inside {
let range = if rows { RangeRef::rows(idx, idx) } else { RangeRef::cols(idx, idx) };
let _ = app.session.run("selection.set", json!({"range": range.a1()}));
}
app.grid.header_menu = Some((geo.mirror_pos(p), rows));
} else {
app.grid.context_menu = Some(geo.mirror_pos(p));
app.grid.context_menu_frame = input.ui.ctx().cumulative_frame_nr();
}
}
if primary_clicked || resp.drag_started() {
resp.request_focus();
}
keyboard(app, &ctx, resp, geo, sh);
}
fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) {
let (geo, sh, wb, sel, pos) = (input.geo, input.sheet, input.workbook, &input.selection, input.position);
let resp = input.response;
if resp.dragged()
&& let Some(p) = pos
{
// Auto-scroll near edges.
let edge = 24.0;
let mut auto = egui::Vec2::ZERO;
if p.y > geo.cells.bottom() - edge {
auto.y = 20.0;
}
if p.y < geo.cells.top() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeRow { .. }) {
auto.y = -20.0;
}
if p.x > geo.cells.right() - edge {
auto.x = 30.0;
}
if p.x < geo.cells.left() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeCol { .. }) {
auto.x = -30.0;
}
let pc = CellRef::new(
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)),
);
match app.grid.drag.clone() {
Drag::Select => {
select_to_pointer(app, geo, sh, wb, p);
scroll_by(app, auto);
}
Drag::Rows(anchor) => {
let r = geo.row_at(sh, p.y);
let _ =
app.session.run("selection.set", json!({"range": RangeRef::rows(anchor.min(r), anchor.max(r)).a1(), "active": sel.active.a1()}));
scroll_by(app, vec2(0.0, auto.y));
}
Drag::Cols(anchor) => {
let c = geo.col_at(sh, p.x);
let _ =
app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()}));
scroll_by(app, vec2(auto.x, 0.0));
}
Drag::MoveCols { source, .. } => {
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5;
let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1);
let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before);
app.grid.drag = Drag::MoveCols { source, before };
scroll_by(app, vec2(auto.x, 0.0));
}
Drag::ResizeCol { col, start, orig } => {
let w = (orig + (p.x - start) / geo.z).max(0.0);
app.toast =
Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms()));
// Inside a selection of whole columns, every selected column takes the new width
// (Ctrl-selected areas included); otherwise only the dragged one.
let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) {
sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::<Vec<_>>().join(",")
} else {
RangeRef::cols(col, col).a1()
};
let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()}));
coalesce_last_undo(app);
}
Drag::ResizeRow { row, start, orig } => {
let h = (orig + (p.y - start) / geo.z).max(0.0);
app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms()));
let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) {
sel.ranges
.iter()
.filter(|r| r.is_full_rows())
.map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1))
.collect::<Vec<_>>()
.join(",")
} else {
format!("{}:{}", row + 1, row + 1)
};
let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()}));
coalesce_last_undo(app);
}
Drag::Freeze { cols, .. } => {
app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) };
}
Drag::Fill { menu, .. } => {
let src = sel.current();
let target = fill_target(src, pc);
app.grid.drag = Drag::Fill { target: Some(target), menu };
scroll_by(app, auto);
}
Drag::Move { grab, .. } => {
let src = sel.current();
let dr = pc.row as i64 - grab.row as i64;
let dc = pc.col as i64 - grab.col as i64;
app.grid.drag = Drag::Move { grab, to: Some(src.start.offset_clamped(dr, dc)) };
scroll_by(app, auto);
}
Drag::Object { .. } => drag_object(app, geo, sh, p),
Drag::None => {}
}
}
}
fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) {
let (geo, sh, wb, sel, pos, mods, resp) =
(input.geo, input.sheet, input.workbook, &input.selection, input.position, input.modifiers, input.response);
let ctx = input.context.clone();
if resp.drag_stopped() || (!input.any_pointer_down && app.grid.drag != Drag::None && !resp.dragged()) {
match std::mem::take(&mut app.grid.drag) {
Drag::Select => {
if resp.drag_stopped()
&& let Some(p) = pos
{
select_to_pointer(app, geo, sh, wb, p);
}
format_painter_apply(app);
}
// Whole rows or columns clicked or dragged over by their headers.
Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app),
// Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is
// spent on the drag so the grid's Esc doesn't also drop the copy.
Drag::Fill { target: Some(t), menu: true } => {
if let Some(p) = pos
&& t != sel.current()
&& !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
{
app.grid.fill_menu = Some((sel.current(), t, geo.mirror_pos(p)));
}
}
Drag::Fill { target: Some(t), menu: false } => {
let src = sel.current();
if t != src {
// Ctrl (Cmd on macOS) swaps copying and extending, as in Excel.
let mode = if mods.command { "alternate" } else { "series" };
match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) {
Ok(_) => set_fill_tag(app, src, t),
Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))),
}
}
}
Drag::Freeze { cols, to: Some(to) } => {
let (rows_now, cols_now) = (geo.fr, geo.fc);
let (rows, cols) = if cols { (rows_now, to) } else { (to, cols_now) };
if (rows, cols) != (rows_now, cols_now) {
// A1 (nothing left frozen) unfreezes.
app.run_or_alert("view.freezePanes", json!({"cell": CellRef::new(rows, cols).a1()}));
}
}
Drag::Move { to: Some(to), .. } => {
let src = sel.current();
if to != src.start {
let _ = app.session.run("edit.cut", json!({"range": src.a1()}));
let _ = app.run("edit.paste", json!({"at": to.a1()}));
}
}
Drag::MoveCols { source, before: Some(before) } => {
if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
}
}
_ => {}
}
app.grid.drag_select = false;
}
}
/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while
/// pointing a formula reference, which also drags a Select).
fn format_painter_apply(app: &mut SheetApp) {
if app.session.format_painter.is_some()
&& !app.editor.as_ref().is_some_and(|ed| ed.can_point())
&& !app.dialog.as_ref().is_some_and(|d| d.picks_references())
{
let dest = app.session.active().map(|d| d.selection.current().a1());
if let Some(dest) = dest
&& let Err(e) = app.run("edit.formatPainter", json!({"apply": dest}))
{
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
}
}
}
/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting.
fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool {
let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false };
let edit_sheet = ed.sheet;
let active_sheet = wb.active_sheet();
let range = if extend {
ed.point
.and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point))
.and_then(|(_, _, previous, previous_sheet, _)| {
let previous_sheet = previous_sheet.as_deref().map(|name| wb.sheet_index(name)).unwrap_or(Some(edit_sheet));
if previous_sheet != Some(active_sheet) {
return None;
}
if range.is_full_cols() && previous.is_full_cols() {
Some(RangeRef::cols(previous.start.col.min(range.start.col), previous.end.col.max(range.end.col)))
} else if range.is_full_rows() && previous.is_full_rows() {
Some(RangeRef::rows(previous.start.row.min(range.start.row), previous.end.row.max(range.end.row)))
} else {
None
}
})
.unwrap_or(range)
} else {
range
};
let reference = range.a1();
let reference =
if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference };
if add {
ed.append_ref(&reference);
} else {
ed.insert_ref(&reference);
}
ed.point_anchor = None;
ed.point_cell = Some(range.start);
ed.request_focus = true;
true
}
fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) {
let cell = CellRef::new(
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)),
);
if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) {
d.pick_reference(cell, &sh.name, true);
} else if let Some(ed) = app.editor.as_mut()
&& ed.point.is_some()
{
if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).or(ed.point_cell) {
let range = RangeRef::new(start, cell);
let text = if range.is_single() { range.start.a1() } else { range.a1() };
let text = if ed.sheet != wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text };
ed.insert_ref(&text);
ed.point_cell = Some(cell);
ed.point_anchor = Some((wb.active_sheet(), start));
}
} else if let Some(d) = app.session.active() {
// Read the current anchor: starting a drag may have just replaced the selection.
let anchor = d.selection.anchor;
let active = d.selection.active;
let mut ranges: Vec<String> = d.selection.ranges.iter().map(|r| r.a1()).collect();
ranges.pop();
ranges.push(RangeRef::new(anchor, cell).a1());
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()}));
if let Some(d) = app.session.active_mut() {
d.selection.anchor = anchor;
}
}
}
fn coalesce_last_undo(app: &mut SheetApp) {
// Merge consecutive drag steps into one undo entry.
if let Some(d) = app.session.active_mut()
&& d.undo.len() >= 2
{
let n = d.undo.len();
if d.undo[n - 1].label == d.undo[n - 2].label {
d.undo.remove(n - 1);
}
}
}
fn scroll_by(app: &mut SheetApp, d: egui::Vec2) {
if d != egui::Vec2::ZERO
&& let Some(v) = app.view_mut()
{
v.scroll = (v.scroll + d).max(egui::Vec2::ZERO);
}
}
fn fill_target(src: RangeRef, pc: CellRef) -> RangeRef {
let below = pc.row as i64 - src.end.row as i64;
let above = src.start.row as i64 - pc.row as i64;
let right = pc.col as i64 - src.end.col as i64;
let left = src.start.col as i64 - pc.col as i64;
let vert = below.max(above);
let horiz = right.max(left);
if vert <= 0 && horiz <= 0 {
return src;
}
if vert >= horiz {
if below > 0 {
RangeRef::new(src.start, CellRef::new(pc.row, src.end.col))
} else {
RangeRef::new(CellRef::new(pc.row, src.start.col), src.end)
}
} else if right > 0 {
RangeRef::new(src.start, CellRef::new(src.end.row, pc.col))
} else {
RangeRef::new(CellRef::new(src.start.row, pc.col), src.end)
}
}
pub(super) fn on_border(r: Rect, p: Pos2) -> bool {
let outer = r.expand(3.0);
let inner = r.shrink(3.0);
outer.contains(p) && !inner.contains(p)
}
/// The freeze handles, as in Excel for the web: a bar in the upper part of the column-header strip
/// on the frozen columns' right edge, and one in the left part of the row-header strip on the
/// frozen rows' bottom edge. With nothing frozen they sit on the corner's edges. The rest of the
/// header edge keeps resizing the column or row.
pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) {
let x = geo.cells.left() + geo.frozen_w;
let y = geo.cells.top() + geo.frozen_h;
let cols = Rect::from_min_max(pos2(x - 3.0, geo.head_top()), pos2(x + 3.0, geo.head_top() + geo.header_h * 0.4));
let rows = Rect::from_min_max(pos2(geo.head_left(), y - 3.0), pos2(geo.head_left() + geo.header_w * 0.4, y + 3.0));
(cols, rows)
}
/// How many columns (or rows) a freeze handle dropped at `p` freezes: the line boundary nearest
/// the pointer, limited so the frozen pane leaves room for the scrolling one.
fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 {
if cols {
if p.x <= geo.cells.left() {
return 0;
}
let c = geo.col_at(sh, p.x);
let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1)));
let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c };
let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64);
b.min(room).min(MAX_COLS - 1)
} else {
if p.y <= geo.cells.top() {
return 0;
}
let rr = geo.row_at(sh, p.y);
let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1)));
let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr };
let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64);
b.min(room).min(MAX_ROWS - 1)
}
}
/// Paints the freeze handles and, while one is dragged, where the freeze line will go.
pub(super) fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tokens) {
let (cols, rows) = freeze_handles(geo);
let bar = Color32::from_gray(150);
p.rect_filled(Rect::from_center_size(cols.center(), vec2(3.0, cols.height())), 1.0, bar);
p.rect_filled(Rect::from_center_size(rows.center(), vec2(rows.width(), 3.0)), 1.0, bar);
if let Drag::Freeze { cols, to: Some(to) } = *drag {
let stroke = Stroke::new(2.0, t.accent);
if cols {
let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z;
p.line_segment([pos2(x, geo.head_top()), pos2(x, geo.cells.bottom())], stroke);
} else {
let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z;
p.line_segment([pos2(geo.head_left(), y), pos2(geo.cells.right(), y)], stroke);
}
}
}
pub(super) fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> {
let c = geo.col_at(sh, x);
for cc in [c, c.saturating_sub(1)] {
let edge = geo.x(sh, cc) + sh.col_width(cc) * geo.z;
if (x - edge).abs() <= 4.0 {
return Some((cc, edge));
}
}
None
}
pub(super) fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> {
let r = geo.row_at(sh, y);
for rr in [r, r.saturating_sub(1)] {
let edge = geo.y(sh, rr) + sh.row_height(rr) * geo.z;
if (y - edge).abs() <= 3.0 {
return Some((rr, edge));
}
}
None
}
/// The hidden column whose (zero-width) edge is at `x`: only just right of the boundary, as the
/// edge just left of it belongs to the visible column there.
pub(super) fn hidden_col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<u32> {
col_edge(geo, sh, x).map(|(c, _)| c).filter(|&c| sh.col_width(c) <= 0.0)
}
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)
}
/// 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]
}
};
// 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
/// OK runs `link.open`); every other kind is refused with a message. The decision is the
/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies.
pub fn follow_link(app: &mut SheetApp, target: &str) {
use gridcraft_engine::links::{LinkKind, classify};
match classify(target) {
LinkKind::Internal(r) => {
let _ = app.session.run("edit.goTo", json!({"reference": r.trim_start_matches('#')}));
app.grid.ensure_visible = true;
}
LinkKind::External(url) => app.open_dialog("openLink", json!({"url": url})),
LinkKind::Refused(why) => app.message = Some(("Hyperlink".into(), why)),
}
}
/// Moves the pointed cell one step, or with `jump` (Ctrl+arrow) to the edge of its data region;
/// `extend` (Shift) moves the far end of the pointed range instead. Keeps the pointed cell visible.
pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool, jump: bool) {
let Some(d) = app.session.active() else { return };
let Some(sheet) = d.wb.active() else { return };
let from = ed.point_cell.unwrap_or(ed.cell);
let to = if jump { gridcraft_engine::selection::jump(sheet, from, dr.signum(), dc.signum()) } else { from.offset_clamped(dr, dc) };
let anchor = if extend {
// The anchor is the corner opposite the moving end: the written range is normalized, so
// after extending up or left its first cell is the moving end, not the anchor.
let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse);
let inferred_anchor = pointed
.map(|r| {
CellRef::new(
if from.row == r.start.row { r.end.row } else { r.start.row },
if from.col == r.start.col { r.end.col } else { r.start.col },
)
})
.unwrap_or(from);
ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(inferred_anchor)
} else {
to
};
let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() };
let sheet = d.wb.active_sheet();
ed.insert_ref(&text);
ed.point_cell = Some(to);
ed.point_anchor = Some((sheet, anchor));
app.grid.ensure_visible = true;
}