fix(grid): keep cursor stable during cell dragging (#936)

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Zaxaerith
2026-10-13 23:14:56 +08:00
co-authored by Codex
parent 84acf6293b
commit 2b53301092
3 changed files with 400 additions and 46 deletions
+90 -45
View File
@@ -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);
+307
View File
@@ -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
View File
@@ -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 |