From 2ed77831f9fe518351936bcc2b8a3852a69c19ed Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:01:20 +0200 Subject: [PATCH] Canvas: middle-click scrolls the document, like Word (#1121) A middle click on the canvas starts scrolling toward the pointer, faster the farther it is from the click, with a dead zone around it. Another click, Escape, any key, the wheel or losing focus stops it; held and dragged, the release stops it. Same gesture and speed curve as pdfcraft's autoscroll; on for Windows and Linux/BSD, off on macOS and the web. Co-authored-by: Brandon Thomas --- crates/ui-egui/src/autoscroll.rs | 195 +++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 99 ++++++++++++++-- crates/ui-egui/src/lib.rs | 1 + 3 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/autoscroll.rs diff --git a/crates/ui-egui/src/autoscroll.rs b/crates/ui-egui/src/autoscroll.rs new file mode 100644 index 000000000..4de5f59b5 --- /dev/null +++ b/crates/ui-egui/src/autoscroll.rs @@ -0,0 +1,195 @@ +//! Middle-click scrolling of the document canvas (#1065), the same gesture as Word on Windows +//! and pdfcraft's viewer: click the wheel and a marker shows where; moving the pointer above or +//! below it scrolls, faster the farther away. Another click, Escape, any other key, the wheel or +//! leaving the window stops it. Holding the wheel down and moving scrolls too, and then letting +//! go stops. +//! +//! WordCraft doesn't paste the primary selection on a middle click, so on Linux the button is +//! free for this. macOS apps don't scroll this way, and browsers have their own middle-click +//! behaviour, so there the button is left alone. + +use egui::{CursorIcon, Event, Key, PointerButton, Pos2, Stroke, Vec2, vec2}; + +/// Where the gesture exists at all (see the module docs). +const SUPPORTED: bool = !cfg!(any(target_os = "macos", target_os = "ios", target_arch = "wasm32")); + +/// Pointer distance (screen points) around the anchor that doesn't scroll. +const DEAD_ZONE: f32 = 15.0; +// Chromium's autoscroll_controller.cc uses distance^2.2 * 0.000008; its +// ui/events/gestures/fixed_velocity_curve.cc multiplies elapsed seconds by 5000. +const SPEED_EXPONENT: f32 = 2.2; +const SPEED_MULTIPLIER: f32 = 0.04; +// Bound hostile coordinates before exponentiation, far beyond ordinary screen distances. +const MAX_DISPLACEMENT: f32 = 1_000_000.0; + +#[derive(Debug)] +pub(crate) struct AutoScroll { + /// Where the wheel was clicked; `Some` while scrolling. + anchor: Option, + /// The press that started it is still down… + holding: bool, + /// …and the pointer has left the dead zone since: letting go ends it. + dragged: bool, + /// Own a stopping click through its release, so it can't also move the caret. + cancel_button: Option, + block_input: bool, + pub(crate) supported: bool, +} + +impl Default for AutoScroll { + fn default() -> Self { + Self { anchor: None, holding: false, dragged: false, cancel_button: None, block_input: false, supported: SUPPORTED } + } +} + +impl AutoScroll { + pub(crate) fn active(&self) -> bool { + self.anchor.is_some() + } + + pub(crate) fn cancel(&mut self) { + self.anchor = None; + self.holding = false; + self.dragged = false; + self.cancel_button = None; + self.block_input = false; + } + + /// Mouse buttons belong to the gesture this frame: the canvas mustn't act on them. + pub(crate) fn blocks_input(&self) -> bool { + self.block_input + } + + /// Run before the canvas scrolls. Returns this frame's delta (positive moves the content + /// down, as the canvas's wheel delta). Starting is restricted to the visible canvas; once + /// started, moving outside it still controls the speed. + pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect) -> Vec2 { + if !self.supported { + self.cancel(); + return Vec2::ZERO; + } + let ctx = ui.ctx(); + let (pointer, middle_press, middle_down, middle_released, cancel, cancel_button, dt) = ctx.input(|i| { + let cancel_button = [PointerButton::Primary, PointerButton::Secondary, PointerButton::Extra1, PointerButton::Extra2] + .into_iter() + .find(|button| i.pointer.button_pressed(*button)); + ( + i.pointer.hover_pos(), + // The press event itself: later movement in this frame mustn't move the anchor. + i.events.iter().find_map(|event| match event { + Event::PointerButton { pos, button: PointerButton::Middle, pressed: true, .. } if pos.is_finite() => Some(*pos), + _ => None, + }), + i.pointer.button_down(PointerButton::Middle), + i.pointer.button_released(PointerButton::Middle), + !i.focused + || cancel_button.is_some() + || i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, .. } | Event::MouseWheel { .. } | Event::Zoom(_))), + cancel_button, + i.stable_dt, + ) + }); + self.block_input = self.active() || self.cancel_button.is_some() || middle_press.is_some() || middle_down || middle_released; + if let Some(button) = self.cancel_button { + if !ctx.input(|i| i.pointer.button_down(button)) { + self.cancel_button = None; + } + return Vec2::ZERO; + } + if self.active() && (cancel || pointer.is_none()) { + // Escape only stops scrolling; any other key stops it and does its usual job. + ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); + self.cancel(); + self.block_input = true; + self.cancel_button = cancel_button; + return Vec2::ZERO; + } + if let Some(pressed_at) = middle_press { + if self.active() { + self.cancel(); + self.block_input = true; + self.cancel_button = Some(PointerButton::Middle); + return Vec2::ZERO; + } else if !cancel && viewport.intersect(ui.clip_rect()).contains(pressed_at) && ctx.layer_id_at(pressed_at) == Some(ui.layer_id()) { + self.anchor = Some(pressed_at); + self.holding = true; + self.dragged = false; + } + } + let (Some(anchor), Some(pointer)) = (self.anchor, pointer) else { return Vec2::ZERO }; + let displacement = pointer.y - anchor.y; + if self.holding { + self.dragged |= displacement.abs() > DEAD_ZONE; + if !middle_down || middle_released { + self.holding = false; + if self.dragged { + self.cancel(); + self.block_input = true; + return Vec2::ZERO; + } + } + } + if displacement.is_finite() && displacement.abs() > DEAD_ZONE { + // Continuous redraws let stable_dt use measured frame time, so the speed doesn't + // depend on the frame rate. + ctx.request_repaint(); + } + vec2(0.0, scroll_delta(displacement, dt)) + } + + /// Draw a marker at the anchor, above the pages, and the up-down cursor. Run after the + /// canvas has set its own cursor. + pub(crate) fn paint(&self, ui: &egui::Ui, viewport: egui::Rect) { + let Some(anchor) = self.anchor else { return }; + let painter = ui.painter().with_clip_rect(viewport); + let ink = ui.visuals().text_color(); + painter.circle(anchor, 13.0, ui.visuals().window_fill(), Stroke::new(1.0, ink)); + painter.circle_filled(anchor, 2.0, ink); + for direction in [-1.0, 1.0] { + painter.add(egui::Shape::convex_polygon( + vec![anchor + vec2(-4.0, direction * 6.0), anchor + vec2(4.0, direction * 6.0), anchor + vec2(0.0, direction * 10.0)], + ink, + Stroke::NONE, + )); + } + ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); + } +} + +fn scroll_delta(displacement: f32, dt: f32) -> f32 { + if !displacement.is_finite() || !dt.is_finite() { + return 0.0; + } + let distance = displacement.abs(); + if distance <= DEAD_ZONE { + return 0.0; + } + let speed = distance.min(MAX_DISPLACEMENT).powf(SPEED_EXPONENT) * SPEED_MULTIPLIER; + // Pointer below the anchor scrolls down: the content moves up. A stalled frame (an idle or + // hidden window) mustn't jump pages. + -displacement.signum() * speed * dt.clamp(0.0, 0.05) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speed_has_a_dead_zone_grows_with_distance_and_rejects_invalid_input() { + for y in [-15.0, -1.0, 0.0, 1.0, 15.0] { + assert_eq!(scroll_delta(y, 0.016), 0.0); + } + assert!(scroll_delta(30.0, 0.016) < 0.0, "below the anchor scrolls down"); + assert_eq!(scroll_delta(30.0, 0.016), -scroll_delta(-30.0, 0.016)); + for (near, far) in [(16.0, 30.0), (30.0, 100.0), (100.0, 200.0)] { + assert!(scroll_delta(far, 0.016).abs() > scroll_delta(near, 0.016).abs()); + } + assert_eq!(scroll_delta(1000.0, 10.0), scroll_delta(1000.0, 0.05)); + assert_eq!(scroll_delta(1000.0, -1.0), 0.0); + assert!(scroll_delta(f32::MAX, 0.016).is_finite()); + for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(scroll_delta(invalid, 0.016), 0.0); + assert_eq!(scroll_delta(1000.0, invalid), 0.0); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 44a32ef9d..7a6369096 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -98,6 +98,8 @@ pub struct CanvasState { pub(crate) ink_ui: crate::ink::InkUi, /// Wheel/touchpad scrolling (smooth notches, touchpad momentum). pub(crate) wheel: crate::scroll::CanvasScroll, + /// Middle-click scrolling (#1065). + pub(crate) auto_scroll: crate::autoscroll::AutoScroll, /// The scroll offset and its maximum at the end of last frame. pub(crate) scroll_offset: egui::Vec2, scroll_max: egui::Vec2, @@ -189,6 +191,7 @@ impl Default for CanvasState { pasted: false, swallow_text: false, wheel: Default::default(), + auto_scroll: Default::default(), scroll_offset: egui::Vec2::ZERO, scroll_max: egui::Vec2::ZERO, scroll_request: None, @@ -867,7 +870,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } else { egui::Vec2::ZERO }; - let delta = wheel_delta + auto_delta; + // Middle-click scrolling (#1065): Print Layout and the other editing views, not Read Mode. + let middle_delta = if app.session.view.read_mode || app.dialog.is_some() { + app.canvas.auto_scroll.cancel(); + egui::Vec2::ZERO + } else { + app.canvas.auto_scroll.update(ui, area) + }; + let delta = wheel_delta + auto_delta + middle_delta; let mut scroll_area = egui::ScrollArea::both() .id_salt("canvas_scroll") .auto_shrink([false, false]) @@ -1236,6 +1246,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.focused = resp.has_focus(); // Screen readers: the document under the canvas, with the caret (#488). crate::a11y::expose(app, ui.ctx(), resp.id, &layout, &rects, area); + // The buttons that start or stop middle-click scrolling don't also click the page. + let middle_gesture = app.canvas.auto_scroll.blocks_input(); // Double-click the space between two pages (or the line between them) to hide or show it. if resp.double_clicked() && app.session.view.mode == wordcraft_layout::ViewMode::Print @@ -1246,14 +1258,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("view.hideWhiteSpace", json!({"value": !app.session.view.hide_white_space})); } else // Draw Table / Eraser own the mouse while on (no caret moves); else the usual editing. - if !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) { + if !middle_gesture && !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) { mouse(app, ui, &resp, &rects, &layout, geo.scale); } // A click on a check box ticks it; on a list's button opens the list. - crate::content_controls::clicks(app, &resp); + if !middle_gesture { + crate::content_controls::clicks(app, &resp); + } + let secondary_clicked = resp.secondary_clicked() && !middle_gesture; // Selected table cells: a right-click off them drops them, even between the selection's ends // (beside a selected column), so the menu acts on the cell clicked. - if resp.secondary_clicked() + if secondary_clicked && let Some(range) = app.session.cell_range() && let Some(p) = resp.interact_pointer_pos() && let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p) @@ -1267,7 +1282,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } // Right-click: move the caret there (unless inside the selection), then the context menu. // Right-click in an equation puts the caret there and opens the equation menu. - if resp.secondary_clicked() + if secondary_clicked && let Some(p) = resp.interact_pointer_pos() && let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p) { @@ -1283,7 +1298,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } // Right-click on a picture, shape or text box selects it (its menu offers Wrap Text). - if resp.secondary_clicked() + if secondary_clicked && app.session.math.is_none() && let Some(p) = resp.interact_pointer_pos() && let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p) @@ -1304,10 +1319,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if from_keys { context_suggestions(app); app.canvas.context_menu_at = caret_screen(app).map(|(p, h)| p + egui::vec2(0.0, h)); - } else if resp.secondary_clicked() { + } else if secondary_clicked { app.canvas.context_menu_at = resp.interact_pointer_pos(); } - let open = if resp.secondary_clicked() || from_keys { + let open = if secondary_clicked || from_keys { Some(egui::SetOpenCommand::Bool(true)) } else if resp.clicked() { Some(egui::SetOpenCommand::Bool(false)) @@ -1339,6 +1354,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::table_resize::cursor(app, ui, &resp, &layout, &rects, geo.scale); crate::table_mouse::cursor(app, ui, &resp, &layout, &rects, geo.scale); crate::table_pen::cursor(app, ui, &resp); + app.canvas.auto_scroll.paint(ui, area); let _ = origin; if let (Some(h), Some(v)) = (hruler, vruler) { rulers(app, ui, h, v, &rects, &layout, geo.scale); @@ -3108,6 +3124,73 @@ mod tests { assert_eq!(clamped_scroll(egui::Vec2::ZERO, vec2(0.0, 50.0), egui::Vec2::ZERO).y, 0.0, "nothing to scroll"); } + /// #1065: a middle click starts scrolling, the pointer below the click scrolls down on its + /// own frame after frame, and a second middle click stops it; a primary click stops it without + /// moving the caret. Held down and dragged, letting go stops it. + #[test] + fn middle_click_scrolls_toward_the_pointer_until_clicked_again() { + let text = vec!["A line of text in a long document."; 400].join("\n"); + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text(&text)), Default::default()); + a.canvas.auto_scroll.supported = true; + let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(6); + let anchor = h.state().canvas.viewport.expect("the canvas is shown").center(); + let button = |h: &mut H, at: Pos2, button, pressed| { + h.event(egui::Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }); + h.step(); + }; + let offset = |h: &H| h.state().canvas.scroll_offset.y; + let below = anchor + vec2(0.0, 150.0); + + h.event(egui::Event::PointerMoved(anchor)); + button(&mut h, anchor, egui::PointerButton::Middle, true); + button(&mut h, anchor, egui::PointerButton::Middle, false); + h.run_steps(3); + assert!(h.state().canvas.auto_scroll.active()); + assert_eq!(offset(&h), 0.0, "the pointer at the click doesn't scroll"); + h.event(egui::Event::PointerMoved(below)); + h.run_steps(10); + let scrolled = offset(&h); + assert!(scrolled > 50.0, "the pointer below the click scrolls down: {scrolled}"); + button(&mut h, below, egui::PointerButton::Middle, true); + button(&mut h, below, egui::PointerButton::Middle, false); + let stopped = offset(&h); + h.run_steps(10); + assert!(!h.state().canvas.auto_scroll.active()); + assert_eq!(offset(&h), stopped, "a second click stops it"); + + // A primary click stops it too, and isn't a click on the page. + let caret = h.state().session.sel.clone(); + button(&mut h, anchor, egui::PointerButton::Middle, true); + button(&mut h, anchor, egui::PointerButton::Middle, false); + assert!(h.state().canvas.auto_scroll.active()); + button(&mut h, anchor, egui::PointerButton::Primary, true); + button(&mut h, anchor, egui::PointerButton::Primary, false); + h.run_steps(2); + assert!(!h.state().canvas.auto_scroll.active()); + assert_eq!(h.state().session.sel, caret, "the stopping click leaves the caret"); + + // Held down and dragged: scrolls while held, stops on release. + h.event(egui::Event::PointerMoved(anchor)); + button(&mut h, anchor, egui::PointerButton::Middle, true); + h.event(egui::Event::PointerMoved(below)); + h.run_steps(5); + let held = offset(&h); + assert!(held > stopped, "scrolls while the wheel is held"); + button(&mut h, below, egui::PointerButton::Middle, false); + let released = offset(&h); + h.run_steps(10); + assert!(!h.state().canvas.auto_scroll.active()); + assert_eq!(offset(&h), released, "letting go after a drag stops it"); + } + /// A right-click at `at`, the button pressed and released a frame apart. fn right_click(h: &mut H, at: Pos2) { h.event(egui::Event::PointerMoved(at)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 98084665d..15dee0aaa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ macro_rules! tl { pub mod a11y; pub mod a11y_pane; +pub mod autoscroll; pub mod backstage; pub mod canvas; pub mod caret_glide;