diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 90a39cb06..6f5c5246f 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -149,6 +149,8 @@ pub struct GridState { pub hscroll: Option, /// Ctrl+wheel zoom in progress (see [`WheelZoom`]). pub wheel_zoom: Option, + /// A one-finger touch drag in progress: it pans the sheet instead of selecting. + pub touch_pan: bool, } /// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and @@ -641,6 +643,19 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { view.scroll -= d / z; } } + // A one-finger touch drag pans the sheet, the content following the finger; it doesn't select. + // (Two fingers pinch-zoom through `zoom_delta` above.) + if resp.drag_started() && ui.input(|i| i.any_touches()) { + app.grid.touch_pan = true; + } + if app.grid.touch_pan && resp.dragged() && ui.input(|i| i.multi_touch().is_none()) { + let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; + let mut d = resp.drag_delta(); + if sh.right_to_left { + d.x = -d.x; + } + view.scroll -= d / z; + } // The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes // there at once without animations (as for the selection outline below: View › Animations // off, egui's off in the headless harness, or a screenshot on its way). @@ -754,8 +769,15 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } // Interaction. 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); + if app.grid.touch_pan { + // Skip the frames of a touch pan, its release included. + if !resp.dragged() { + app.grid.touch_pan = false; + } + } else { + crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb); + interact(app, ui, &resp, &geo, sh, &wb); + } 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); diff --git a/crates/ui-egui/tests/touch_pan.rs b/crates/ui-egui/tests/touch_pan.rs new file mode 100644 index 000000000..f55e84246 --- /dev/null +++ b/crates/ui-egui/tests/touch_pan.rs @@ -0,0 +1,91 @@ +//! Touch input on the grid: one finger pans, a tap selects (issue #1907). + +use egui::{Event, Modifiers, PointerButton, TouchDeviceId, TouchId, TouchPhase}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::SheetApp; + +fn harness(session: Session) -> egui_kittest::Harness<'static, SheetApp> { + let mut h = egui_kittest::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 blank() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +/// One finger, sent the way egui-winit does: a touch event plus the emulated pointer events. +fn touch(h: &mut egui_kittest::Harness<'static, SheetApp>, phase: TouchPhase, pos: egui::Pos2) { + let ev = &mut h.input_mut().events; + ev.push(Event::Touch { device_id: TouchDeviceId(1), id: TouchId(0), phase, pos, force: None }); + match phase { + TouchPhase::Start => { + ev.push(Event::PointerMoved(pos)); + ev.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + } + TouchPhase::Move => ev.push(Event::PointerMoved(pos)), + TouchPhase::End | TouchPhase::Cancel => { + ev.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + ev.push(Event::PointerGone); + } + } +} + +fn swipe(h: &mut egui_kittest::Harness<'static, SheetApp>, from: egui::Pos2, by: egui::Vec2) { + touch(h, TouchPhase::Start, from); + h.run_steps(2); + for i in 1..=10 { + touch(h, TouchPhase::Move, from + by * (i as f32 / 10.0)); + h.run_steps(1); + } + touch(h, TouchPhase::End, from + by); + h.run_steps(3); +} + +fn assert_selection_untouched(h: &egui_kittest::Harness<'static, SheetApp>) { + let sel = &h.state().session.active().unwrap().selection; + assert_eq!(sel.active, CellRef::new(0, 0), "a touch pan must not move the active cell: {sel:?}"); + assert_eq!(sel.ranges.len(), 1, "{sel:?}"); + assert_eq!(sel.ranges[0].start, CellRef::new(0, 0), "{sel:?}"); + assert_eq!(sel.ranges[0].end, CellRef::new(0, 0), "a touch pan must not select cells: {sel:?}"); +} + +#[test] +fn one_finger_drag_pans_the_grid_instead_of_selecting() { + let mut h = harness(blank()); + let cells = h.state().grid.cells_rect.unwrap(); + swipe(&mut h, cells.center() + egui::vec2(0.0, 100.0), egui::vec2(0.0, -200.0)); + let scroll = h.state().view().scroll; + assert!(scroll.y > 0.0, "dragging a finger up scrolls down: scroll = {scroll:?}"); + assert_selection_untouched(&h); + + swipe(&mut h, cells.center() + egui::vec2(100.0, 0.0), egui::vec2(-200.0, 0.0)); + let scroll = h.state().view().scroll; + assert!(scroll.x > 0.0, "dragging a finger left scrolls right: scroll = {scroll:?}"); + assert_selection_untouched(&h); +} + +#[test] +fn one_finger_tap_still_selects_the_cell() { + let mut h = harness(blank()); + let cells = h.state().grid.cells_rect.unwrap(); + let p = cells.min + egui::vec2(5.0, 5.0) + egui::vec2(0.0, cells.height() / 2.0); + touch(&mut h, TouchPhase::Start, p); + h.run_steps(1); + touch(&mut h, TouchPhase::End, p); + h.run_steps(3); + let sel = &h.state().session.active().unwrap().selection; + assert_eq!(sel.active.col, 0, "{sel:?}"); + assert!(sel.active.row > 0, "a tap selects the tapped cell: {sel:?}"); + assert_eq!(h.state().view().scroll, egui::Vec2::ZERO); +}