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 <echelon@gmail.com>
This commit is contained in:
Marco Arnulfo
2026-10-14 02:01:20 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent d6723153cc
commit 2ed77831f9
3 changed files with 287 additions and 8 deletions
+195
View File
@@ -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<Pos2>,
/// 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<PointerButton>,
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);
}
}
}
+91 -8
View File
@@ -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));
+1
View File
@@ -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;