diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..1cc1e2689 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -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); diff --git a/crates/ui-egui/tests/drag_cursor.rs b/crates/ui-egui/tests/drag_cursor.rs new file mode 100644 index 000000000..3198f044f --- /dev/null +++ b/crates/ui-egui/tests/drag_cursor.rs @@ -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::>(), (1..=5).map(|n| Value::Number(n as f64)).collect::>()); +} + +#[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::>(), ["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) + ); + } + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..c6735f82b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 |