diff --git a/ROADMAP.md b/ROADMAP.md index 1b0cd34..f9fa010 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..e76e006 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -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 = 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 = 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() diff --git a/crates/ui-egui/tests/selection.rs b/crates/ui-egui/tests/selection.rs new file mode 100644 index 0000000..faebebf --- /dev/null +++ b/crates/ui-egui/tests/selection.rs @@ -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::>()) +} + +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); +}