Issue #28: retain quick selection drag endpoints and clarify fill handle

This commit is contained in:
Harrison O'Connor-Hoover 2026-10-09 23:05:46 -04:00
parent 13664fe66b
commit 198e8ecb05
3 changed files with 212 additions and 21 deletions

View File

@ -81,7 +81,8 @@ by open equivalents or left out, not cloned.
autocomplete and argument hints, copy/cut/paste and Paste Special (values, formats, transpose,
operations, link, skip blanks), AutoFill series (numbers, dates, months, weekdays, custom lists,
text+number), Fill Series, Flash Fill, find/replace with wildcards, Go To / Go To Special,
undo/redo with history, format painter.
undo/redo with history, format painter. Quick selection drags retain their final endpoint;
the fill handle explains how to fill versus select cells.
- Formatting: fonts, fills, all border styles, alignment (wrap, indent, rotation, merge), number
formats, 47 cell styles, 60 table styles, conditional formatting (cell rules, text, dates,
duplicates, top/bottom, averages, data bars, colour scales, icon sets, formulas), automatic

View File

@ -1006,6 +1006,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
ctx.set_cursor_icon(CursorIcon::ResizeRow);
} else if handle.contains(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_border(cur_rect, p) {
ctx.set_cursor_icon(CursorIcon::Move);
} else if in_cells {
@ -1140,6 +1143,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
ed.point_cell = Some(c);
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) {
@ -1193,6 +1201,12 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
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);
}
return;
}
@ -1221,26 +1235,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
);
match app.grid.drag.clone() {
Drag::Select => {
if let Some(ed) = app.editor.as_mut()
&& ed.point.is_some()
{
if let Some(start) = ed.point_cell {
let r = RangeRef::new(start, pc);
let text = if r.is_single() { r.start.a1() } else { r.a1() };
let keep = ed.point_cell;
ed.insert_ref(&text);
ed.point_cell = keep;
}
} else {
let anchor = sel.anchor;
let mut ranges: Vec<String> = sel.ranges.iter().map(|r| r.a1()).collect();
ranges.pop();
ranges.push(RangeRef::new(anchor, pc).a1());
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": sel.active.a1()}));
if let Some(d) = app.session.active_mut() {
d.selection.anchor = anchor;
}
}
select_to_pointer(app, geo, sh, wb, p);
scroll_by(app, auto);
}
Drag::Rows(anchor) => {
@ -1313,6 +1308,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
// Release.
if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) {
match std::mem::take(&mut app.grid.drag) {
Drag::Select if resp.drag_stopped() => {
if let Some(p) = pos {
select_to_pointer(app, geo, sh, wb, p);
}
}
Drag::Fill { target: Some(t) } => {
let src = sel.current();
if t != src {
@ -1392,6 +1392,35 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
keyboard(app, &ctx, resp, geo, sh);
}
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(ed) = app.editor.as_mut()
&& ed.point.is_some()
{
if let Some(start) = 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(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()

View File

@ -0,0 +1,161 @@
//! Cell selection consumes every pointer position without editing cell contents.
use egui::{Event, Modifiers, PointerButton, Pos2};
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, RangeRef};
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::{Value, json};
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(initial: &str) -> Harness {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1", "values": [["sentinel", 2], [3, "untouched"]]})).unwrap();
session.execute("cell.set", json!({"cell": "C3", "input": "123"})).unwrap();
session.execute("cell.set", json!({"cell": "F7", "input": "=C3*2"})).unwrap();
session.execute("selection.set", json!({"cell": initial})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn point(h: &Harness, a1: &str) -> Pos2 {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(a1).unwrap()).center()
}
fn cells(h: &Harness) -> Value {
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
json!(sheet.cells.iter().map(|(pos, cell)| (pos.a1(), cell)).collect::<Vec<_>>())
}
fn frame(h: &mut Harness, event: Event) {
h.input_mut().events.push(event);
h.run_steps(1);
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
}
fn press(h: &mut Harness, cell: &str) {
let from = point(h, cell);
frame(h, Event::PointerMoved(from));
frame(h, button(from, true));
}
fn assert_selection(h: &Harness, start: &str, end: &str) {
let selection = &h.state().session.active().unwrap().selection;
let start = CellRef::parse(start).unwrap();
assert_eq!(selection.current(), RangeRef::new(start, CellRef::parse(end).unwrap()));
assert_eq!(selection.anchor, start);
assert_eq!(selection.active, start);
}
#[test]
fn single_movement_selects_from_the_pressed_cell_in_both_directions() {
for (initial, start, end) in [("A1", "C3", "F7"), ("C3", "C3", "F7"), ("A1", "F7", "C3"), ("F7", "F7", "C3")] {
let mut h = harness(initial);
let before = cells(&h);
press(&mut h, start);
let to = point(&h, end);
frame(&mut h, Event::PointerMoved(to));
assert_selection(&h, start, end);
frame(&mut h, button(to, false));
h.run_steps(1);
assert_selection(&h, start, end);
assert_eq!(cells(&h), before);
}
}
#[test]
fn release_consumes_a_new_endpoint_without_an_intermediate_movement_frame() {
let mut h = harness("A1");
let before = cells(&h);
press(&mut h, "C3");
let mid = point(&h, "D4");
frame(&mut h, Event::PointerMoved(mid));
let to = point(&h, "F7");
frame(&mut h, button(to, false));
assert_selection(&h, "C3", "F7");
assert_eq!(cells(&h), before);
}
#[test]
fn multiple_movement_frames_keep_the_anchor_and_cell_contents() {
let mut h = harness("A1");
let before = cells(&h);
press(&mut h, "C3");
for end in ["D4", "F7", "B2"] {
let to = point(&h, end);
frame(&mut h, Event::PointerMoved(to));
assert_selection(&h, "C3", end);
}
let to = point(&h, "B2");
frame(&mut h, button(to, false));
assert_selection(&h, "C3", "B2");
assert_eq!(cells(&h), before);
}
#[test]
fn clicking_then_hovering_does_not_extend_selection() {
let mut h = harness("A1");
let before = cells(&h);
press(&mut h, "C3");
let from = point(&h, "C3");
frame(&mut h, button(from, false));
let to = point(&h, "F7");
frame(&mut h, Event::PointerMoved(to));
assert_selection(&h, "C3", "C3");
assert_eq!(cells(&h), before);
}
#[test]
fn formula_point_mode_consumes_first_movement_and_release() {
let mut h = harness("A1");
let before = cells(&h);
h.state_mut().begin_edit(Some("=".into()), false);
h.run_steps(2);
press(&mut h, "C3");
let mid = point(&h, "D4");
frame(&mut h, Event::PointerMoved(mid));
assert_eq!(h.state().editor.as_ref().unwrap().text, "=C3:D4");
let to = point(&h, "F7");
frame(&mut h, button(to, false));
h.run_steps(1);
let editor = h.state().editor.as_ref().unwrap();
assert_eq!(editor.text, "=C3:F7");
assert_eq!(editor.point_cell, CellRef::parse("C3"));
assert_eq!(cells(&h), before);
}
#[test]
fn formula_point_drag_keeps_the_sheet_qualifier() {
let mut h = harness("A1");
let before = cells(&h);
h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap();
h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
h.state_mut().begin_edit(Some("=".into()), false);
h.state_mut().session.execute("sheet.activate", json!({"sheet": "Other sheet"})).unwrap();
h.run_steps(2);
let other_before = cells(&h);
press(&mut h, "C3");
let mid = point(&h, "D4");
frame(&mut h, Event::PointerMoved(mid));
assert_eq!(h.state().editor.as_ref().unwrap().text, "='Other sheet'!C3:D4");
let to = point(&h, "F7");
frame(&mut h, button(to, false));
assert_eq!(h.state().editor.as_ref().unwrap().text, "='Other sheet'!C3:F7");
assert_eq!(cells(&h), other_before);
h.state_mut().session.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
assert_eq!(cells(&h), before);
}