mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
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:
co-authored by
Brandon Thomas
parent
d6723153cc
commit
2ed77831f9
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user