mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
fix(grid): keep cursor stable during cell dragging (#936)
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
+90
-45
@@ -752,6 +752,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu);
|
||||
crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb);
|
||||
interact(app, ui, &resp, &geo, sh, &wb);
|
||||
update_cursor(app, ui, &resp, &geo, sh);
|
||||
crate::text_box::show(app, ui, &geo, sh);
|
||||
if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) {
|
||||
in_cell_editor(app, ui, &geo, sh, &wb);
|
||||
@@ -1774,6 +1775,95 @@ pub(crate) fn paint_headers(
|
||||
/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it.
|
||||
const OBJECT_RESIZE_RADIUS: f32 = 10.0;
|
||||
|
||||
/// Decide after interaction so drag start and release use the new state in the same frame.
|
||||
fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) {
|
||||
if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() {
|
||||
return;
|
||||
}
|
||||
let ctx = ui.ctx();
|
||||
let pointer = ctx.input(|i| i.pointer.clone());
|
||||
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 (freeze_cols, freeze_rows) = freeze_handles(geo);
|
||||
// Hover bookkeeping also serves cell notes, independently of who owns the cursor.
|
||||
if resp.hovered()
|
||||
&& let Some(p) = pos
|
||||
&& !freeze_cols.contains(p)
|
||||
&& !freeze_rows.contains(p)
|
||||
&& !matches!(app.grid.drag, Drag::Freeze { .. })
|
||||
{
|
||||
app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p));
|
||||
}
|
||||
// Draw tools own the cursor over cells, including while recording a stroke.
|
||||
if !app.session.draw_tool.is_empty() && in_cells {
|
||||
ctx.set_cursor_icon(CursorIcon::Crosshair);
|
||||
return;
|
||||
}
|
||||
// A held drag owns the cursor even outside the grid; a press below egui's threshold still
|
||||
// has Drag::None and uses hover. A click can briefly leave a drag state after release.
|
||||
if resp.dragged() && pointer.primary_down() {
|
||||
let cursor = match app.grid.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::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),
|
||||
};
|
||||
if let Some(cursor) = cursor {
|
||||
ctx.set_cursor_icon(cursor);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if !resp.hovered() {
|
||||
return;
|
||||
}
|
||||
let Some(p) = pos else { return };
|
||||
if freeze_cols.contains(p) || freeze_rows.contains(p) {
|
||||
let cols = freeze_cols.contains(p);
|
||||
ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical });
|
||||
if app.grid.drag == Drag::None && !pointer.any_down() {
|
||||
let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" };
|
||||
resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left();
|
||||
let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.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));
|
||||
let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end));
|
||||
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);
|
||||
} else if in_col_header && col_edge(geo, sh, p.x).is_some() {
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeColumn);
|
||||
} else if in_row_header && row_edge(geo, sh, p.y).is_some() {
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeRow);
|
||||
} else if on_handle && app.editor.is_none() {
|
||||
ctx.set_cursor_icon(CursorIcon::Crosshair);
|
||||
if app.grid.drag == Drag::None && !pointer.any_down() {
|
||||
resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range.");
|
||||
}
|
||||
} else if in_cells && app.editor.is_none() && on_cur_border {
|
||||
ctx.set_cursor_icon(CursorIcon::Move);
|
||||
} else if in_cells {
|
||||
ctx.set_cursor_icon(CursorIcon::Cell);
|
||||
} else if in_col_header {
|
||||
let col = geo.col_at(sh, p.x);
|
||||
let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col));
|
||||
ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default });
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -1801,51 +1891,6 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p));
|
||||
|
||||
let (freeze_cols, freeze_rows) = freeze_handles(geo);
|
||||
// Hover cursors.
|
||||
if resp.hovered()
|
||||
&& let Some(p) = pos
|
||||
&& (freeze_cols.contains(p) || freeze_rows.contains(p) || matches!(app.grid.drag, Drag::Freeze { .. }))
|
||||
{
|
||||
let cols = match app.grid.drag {
|
||||
Drag::Freeze { cols, .. } => cols,
|
||||
_ => freeze_cols.contains(p),
|
||||
};
|
||||
ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical });
|
||||
if app.grid.drag == Drag::None && !pointer.any_down() {
|
||||
let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" };
|
||||
resp.clone().on_hover_text_at_pointer(crate::i18n::current(&ctx).tr(tip));
|
||||
}
|
||||
} else if resp.hovered()
|
||||
&& let Some(p) = pos
|
||||
{
|
||||
let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some();
|
||||
let near_row_edge = in_row_header && row_edge(geo, sh, p.y).is_some();
|
||||
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);
|
||||
let near_object_corner = app.grid.drag == Drag::None && near_corner;
|
||||
if near_object_corner || matches!(app.grid.drag, Drag::Object { resize: true, .. }) {
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeNwSe);
|
||||
} else if near_col_edge {
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeColumn);
|
||||
} else if near_row_edge {
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeRow);
|
||||
} else if on_handle(p) && app.editor.is_none() {
|
||||
ctx.set_cursor_icon(CursorIcon::Crosshair);
|
||||
if app.grid.drag == Drag::None && !pointer.any_down() {
|
||||
resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range.");
|
||||
}
|
||||
} else if in_cells && app.editor.is_none() && on_cur_border(p) {
|
||||
ctx.set_cursor_icon(CursorIcon::Move);
|
||||
} else if in_cells {
|
||||
ctx.set_cursor_icon(CursorIcon::Cell);
|
||||
} else if in_col_header {
|
||||
let col = geo.col_at(sh, p.x);
|
||||
let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col));
|
||||
ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default });
|
||||
}
|
||||
app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p));
|
||||
}
|
||||
|
||||
// Draw tab tools: the pen records a stroke, the eraser deletes ink under the pointer.
|
||||
if !app.session.draw_tool.is_empty() && in_cells {
|
||||
ctx.set_cursor_icon(CursorIcon::Crosshair);
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Drag cursors are asserted frame by frame through real egui pointer input (#936).
|
||||
|
||||
use egui::{CursorIcon, Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::grid::{Drag, Geo, freeze_handles};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"], [1, 2]]})).unwrap();
|
||||
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn geo(h: &Harness) -> Geo {
|
||||
h.state().grid.geo.clone().unwrap()
|
||||
}
|
||||
|
||||
fn cell(h: &Harness, a1: &str) -> Rect {
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
geo(h).screen_cell_rect(sh, CellRef::parse(a1).unwrap())
|
||||
}
|
||||
|
||||
fn selection_rect(h: &Harness) -> Rect {
|
||||
let d = h.state().session.active().unwrap();
|
||||
geo(h).range_rect(d.wb.active().unwrap(), d.selection.current())
|
||||
}
|
||||
|
||||
fn frame(h: &mut Harness, event: Event) {
|
||||
h.input_mut().events.push(event);
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
fn move_to(h: &mut Harness, pos: Pos2) {
|
||||
frame(h, Event::PointerMoved(pos));
|
||||
}
|
||||
|
||||
fn button(h: &mut Harness, pos: Pos2, pressed: bool) {
|
||||
frame(h, Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness, pos: Pos2) {
|
||||
move_to(h, pos);
|
||||
button(h, pos, true);
|
||||
}
|
||||
|
||||
fn assert_cursor(h: &Harness, expected: CursorIcon) {
|
||||
assert_eq!(h.output().platform_output.cursor_icon, expected, "drag state: {:?}", h.state().grid.drag);
|
||||
}
|
||||
|
||||
fn move_with_cursor(h: &mut Harness, pos: Pos2, expected: CursorIcon) {
|
||||
move_to(h, pos);
|
||||
assert_cursor(h, expected);
|
||||
// Check the following idle frame too: hit targets may have moved with the selection.
|
||||
h.run_steps(1);
|
||||
assert_cursor(h, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_keeps_cell_cursor_over_borders_handles_frozen_panes_and_outside() {
|
||||
for rtl in [false, true] {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("view.rightToLeft", json!({"on": rtl})).unwrap();
|
||||
h.state_mut().session.execute("view.freezePanes", json!({"cell": "B2"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let start = cell(&h, "C3").center();
|
||||
press(&mut h, start);
|
||||
assert_eq!(h.state().grid.drag, Drag::None, "a press below the threshold is not a drag");
|
||||
move_with_cursor(&mut h, start + vec2(1.0, 1.0), CursorIcon::Cell);
|
||||
assert_eq!(h.state().grid.drag, Drag::None);
|
||||
let inside = cell(&h, "D4").center();
|
||||
move_with_cursor(&mut h, inside, CursorIcon::Cell);
|
||||
assert_eq!(h.state().grid.drag, Drag::Select);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C3:D4");
|
||||
|
||||
let r = selection_rect(&h);
|
||||
let g = geo(&h);
|
||||
let border = g.mirror_pos(pos2(r.right() - 1.0, r.bottom() - 8.0));
|
||||
let handle = g.mirror_pos(r.right_bottom() - vec2(1.0, 1.0));
|
||||
move_with_cursor(&mut h, border, CursorIcon::Cell);
|
||||
move_with_cursor(&mut h, handle, CursorIcon::Cell);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "C3:D4");
|
||||
// Cross into the frozen pane, then briefly leave the grid while still holding.
|
||||
let frozen = cell(&h, "A1").center();
|
||||
move_with_cursor(&mut h, frozen, CursorIcon::Cell);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "A1:C3");
|
||||
let outside = pos2(g.rect.left() - 20.0, g.rect.center().y);
|
||||
move_with_cursor(&mut h, outside, CursorIcon::Cell);
|
||||
// Leaving the mirrored grid can auto-scroll, so use its current geometry on return.
|
||||
let inside = cell(&h, "D4").center();
|
||||
move_with_cursor(&mut h, inside, CursorIcon::Cell);
|
||||
let handle = geo(&h).mirror_pos(selection_rect(&h).right_bottom() - vec2(1.0, 1.0));
|
||||
move_with_cursor(&mut h, handle, CursorIcon::Cell);
|
||||
button(&mut h, handle, false);
|
||||
assert_eq!(h.state().grid.drag, Drag::None);
|
||||
assert_cursor(&h, CursorIcon::Crosshair);
|
||||
let r = selection_rect(&h);
|
||||
let border = geo(&h).mirror_pos(pos2(r.right() - 1.0, r.bottom() - 8.0));
|
||||
move_with_cursor(&mut h, border, CursorIcon::Move);
|
||||
let inside = cell(&h, "D4").center();
|
||||
move_with_cursor(&mut h, inside, CursorIcon::Cell);
|
||||
let d = h.state().session.active().unwrap();
|
||||
assert_eq!(d.selection.current().a1(), "C3:D4");
|
||||
assert_eq!(d.wb.active().unwrap().value(CellRef::parse("B2").unwrap()), Value::Number(2.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_press_does_not_treat_a_clicks_transient_state_as_a_drag() {
|
||||
let mut h = harness();
|
||||
let start = cell(&h, "C3").center();
|
||||
press(&mut h, start);
|
||||
button(&mut h, start, false);
|
||||
assert_eq!(h.state().grid.drag, Drag::Select, "a click briefly leaves a selection state");
|
||||
let handle = selection_rect(&h).right_bottom();
|
||||
// Another press can arrive before an idle frame clears that state.
|
||||
h.input_mut().events.push(Event::PointerMoved(handle));
|
||||
button(&mut h, handle, true);
|
||||
assert_cursor(&h, CursorIcon::Crosshair);
|
||||
button(&mut h, handle, false);
|
||||
assert_eq!(h.state().grid.drag, Drag::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_keeps_crosshair_and_still_extends_a_series() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "A2:B2"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let start = selection_rect(&h).right_bottom();
|
||||
press(&mut h, start);
|
||||
let target = cell(&h, "E2").center();
|
||||
move_with_cursor(&mut h, target, CursorIcon::Crosshair);
|
||||
assert!(matches!(h.state().grid.drag, Drag::Fill { .. }));
|
||||
move_with_cursor(&mut h, target + vec2(1.0, 0.0), CursorIcon::Crosshair);
|
||||
button(&mut h, target, false);
|
||||
assert_eq!(h.state().grid.drag, Drag::None);
|
||||
assert_cursor(&h, CursorIcon::Cell);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!((0..5).map(|c| sh.value(CellRef::new(1, c))).collect::<Vec<_>>(), (1..=5).map(|n| Value::Number(n as f64)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_selection_keeps_move_and_still_moves_cells() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "A2:B2"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let r = selection_rect(&h);
|
||||
let start = pos2(r.left() + 10.0, r.top() + 1.0);
|
||||
press(&mut h, start);
|
||||
let target = cell(&h, "D5").center();
|
||||
move_with_cursor(&mut h, target, CursorIcon::Move);
|
||||
assert!(matches!(h.state().grid.drag, Drag::Move { .. }));
|
||||
move_with_cursor(&mut h, target + vec2(1.0, 0.0), CursorIcon::Move);
|
||||
button(&mut h, target, false);
|
||||
assert_eq!(h.state().grid.drag, Drag::None);
|
||||
h.run_steps(1);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Empty);
|
||||
assert_eq!(sh.value(CellRef::parse("D5").unwrap()), Value::Number(1.0));
|
||||
assert_eq!(sh.value(CellRef::parse("E5").unwrap()), Value::Number(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_headers_keeps_the_axis_cursor_when_leaving_the_header() {
|
||||
for cols in [true, false] {
|
||||
let mut h = harness();
|
||||
let g = geo(&h);
|
||||
let r = cell(&h, "C3");
|
||||
let start = if cols { pos2(r.right(), g.head_top() + g.header_h * 0.5) } else { pos2(g.head_left() + g.header_w * 0.5, r.bottom()) };
|
||||
let cursor = if cols { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow };
|
||||
press(&mut h, start);
|
||||
let first = start + if cols { vec2(12.0, 0.0) } else { vec2(0.0, 12.0) };
|
||||
move_with_cursor(&mut h, first, cursor);
|
||||
assert!(if cols { matches!(h.state().grid.drag, Drag::ResizeCol { .. }) } else { matches!(h.state().grid.drag, Drag::ResizeRow { .. }) });
|
||||
let target = cell(&h, "E6").center();
|
||||
move_with_cursor(&mut h, target, cursor);
|
||||
button(&mut h, target, false);
|
||||
assert_eq!(h.state().grid.drag, Drag::None);
|
||||
assert_cursor(&h, CursorIcon::Cell);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert!(if cols { sh.col_width(2) > sh.default_col_width } else { sh.row_height(2) > sh.default_row_height });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freezing_keeps_the_axis_cursor_away_from_the_handle() {
|
||||
for cols in [true, false] {
|
||||
let mut h = harness();
|
||||
let g = geo(&h);
|
||||
let handles = freeze_handles(&g);
|
||||
let start = if cols { handles.0.center() } else { handles.1.center() };
|
||||
let cursor = if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical };
|
||||
press(&mut h, start);
|
||||
let r = cell(&h, "C4");
|
||||
let target = if cols { pos2(r.left() + 2.0, r.center().y) } else { pos2(r.center().x, r.top() + 2.0) };
|
||||
move_with_cursor(&mut h, target, cursor);
|
||||
assert!(matches!(h.state().grid.drag, Drag::Freeze { .. }));
|
||||
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);
|
||||
assert_cursor(&h, CursorIcon::Cell);
|
||||
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().freeze, Some(if cols { (0, 2) } else { (3, 0) }));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_columns_keeps_move_over_unselected_headers_and_cells() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "B:C"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let g = geo(&h);
|
||||
let start = pos2(cell(&h, "B1").center().x, g.head_top() + g.header_h * 0.5);
|
||||
press(&mut h, start);
|
||||
let target = pos2(cell(&h, "E1").left() + 10.0, start.y);
|
||||
move_with_cursor(&mut h, target, CursorIcon::Move);
|
||||
assert!(matches!(h.state().grid.drag, Drag::MoveCols { .. }));
|
||||
let inside = pos2(target.x, cell(&h, "E5").center().y);
|
||||
move_with_cursor(&mut h, inside, CursorIcon::Move);
|
||||
button(&mut h, target, false);
|
||||
assert_eq!(h.state().grid.drag, Drag::None);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!((0..5).map(|c| sh.value(CellRef::new(0, c))).collect::<Vec<_>>(), ["A", "D", "B", "C", "E"].map(|s| Value::Text(s.into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_headers_keeps_default_cursor_and_extends_whole_lines() {
|
||||
for cols in [true, false] {
|
||||
let mut h = harness();
|
||||
let g = geo(&h);
|
||||
let r = cell(&h, "C3");
|
||||
let start = if cols { pos2(r.center().x, g.head_top() + g.header_h * 0.5) } else { pos2(g.head_left() + g.header_w * 0.5, r.center().y) };
|
||||
press(&mut h, start);
|
||||
move_with_cursor(&mut h, start + if cols { vec2(12.0, 0.0) } else { vec2(0.0, 7.0) }, CursorIcon::Default);
|
||||
assert!(if cols { matches!(h.state().grid.drag, Drag::Cols(_)) } else { matches!(h.state().grid.drag, Drag::Rows(_)) });
|
||||
let target = cell(&h, "E6").center();
|
||||
move_with_cursor(&mut h, target, CursorIcon::Default);
|
||||
button(&mut h, target, false);
|
||||
assert_eq!(h.state().grid.drag, Drag::None);
|
||||
let expected = if cols { RangeRef::cols(2, 4) } else { RangeRef::rows(2, 5) };
|
||||
assert_eq!(h.state().session.active().unwrap().selection.current(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_move_and_resize_keep_their_cursors_and_update_the_object() {
|
||||
for resize in [false, true] {
|
||||
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.run_steps(2);
|
||||
let g = geo(&h);
|
||||
let min = cell(&h, "D3").min;
|
||||
let start = if resize { min + vec2(300.0, 200.0) * g.z } else { min + vec2(40.0, 40.0) };
|
||||
press(&mut h, start);
|
||||
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));
|
||||
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);
|
||||
let a = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().anchor;
|
||||
if resize {
|
||||
assert!(a.width > 340.0 && a.height > 220.0);
|
||||
} else {
|
||||
assert_ne!((a.cell, a.dx, a.dy), (CellRef::parse("D3").unwrap(), 0.0, 0.0));
|
||||
assert_eq!((a.width, a.height), (300.0, 200.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_tools_keep_crosshair_and_pen_still_creates_ink() {
|
||||
for tool in ["draw.pen", "draw.eraser"] {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute(tool, json!({"on": true})).unwrap();
|
||||
let start = cell(&h, "C3").center();
|
||||
press(&mut h, start);
|
||||
assert_cursor(&h, CursorIcon::Crosshair);
|
||||
for a1 in ["D4", "E5", "F6"] {
|
||||
let p = cell(&h, a1).center();
|
||||
move_with_cursor(&mut h, p, CursorIcon::Crosshair);
|
||||
assert_eq!(h.state().grid.hover_cell, Some(CellRef::parse(a1).unwrap()), "drawing still tracks the hovered cell for notes");
|
||||
}
|
||||
let end = cell(&h, "F6").center();
|
||||
button(&mut h, end, false);
|
||||
assert_cursor(&h, CursorIcon::Crosshair);
|
||||
if tool == "draw.pen" {
|
||||
assert!(
|
||||
h.state().session.active().unwrap().wb.active().unwrap().shapes.iter().any(|s| s.kind == gridcraft_engine::model::ShapeKind::Ink)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (stable grid drag cursors, #936) · **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
|
||||
@@ -44,6 +44,7 @@ this logic, with one undo step.
|
||||
| Behaviour | Status |
|
||||
|---|---|
|
||||
| Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) |
|
||||
| Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented |
|
||||
| Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet |
|
||||
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing |
|
||||
| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) |
|
||||
@@ -161,6 +162,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Grid drags keep their own cursor over selection borders, fill handles and outside the grid; hover resumes on release (#936) |
|
||||
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
|
||||
| 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) |
|
||||
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |
|
||||
|
||||
Reference in New Issue
Block a user