Compare commits

...
Author SHA1 Message Date
Claude 6f5bc2e6c7 Merge main into issue-635-ctrl-scroll-pan (main's automation test fixed in #1074) 2026-10-08 18:21:01 +00:00
bflatasticandClaude Opus 5.5 08e6747087 Ctrl/Cmd-scroll pans sideways; canvas scroll bars always shown (#635, #620)
As in Photoshop, the wheel scrolls vertically, Shift or Cmd/Ctrl + wheel
scrolls sideways and Alt + wheel zooms; Zoom with Scroll Wheel swaps the
wheel and Alt + wheel. egui no longer turns Cmd/Ctrl + wheel into zoom,
so only a real pinch (Event::Zoom) zooms. Windows delivers a touchpad
pinch as Ctrl + wheel in fractions of a notch: the raw input hook turns
those back into pinch events, so pinch-zoom keeps working there while
Ctrl + a notched mouse wheel pans.

The canvas scroll bars are always shown in Standard Screen Mode (none in
the full screen modes, as in Photoshop). With Overscroll on, an image
that fits the window can be scrolled edge to edge with them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 12:07:59 +07:00
6 changed files with 309 additions and 84 deletions

No files matched your search

+3 -2
View File
@@ -1721,7 +1721,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// Under an open dialog the canvas widget is inert, but the image still pans and zooms.
let under_dialog = !app.ui.dialogs.is_empty();
let free_hover = under_dialog && crate::dialogs::free_pointer_over(&ctx, rect).is_some();
// Navigation (wheel_nav.rs): scroll pans; pinch, ⌘-scroll and ⌥-scroll zoom around the pointer.
// Navigation (wheel_nav.rs): scroll pans (⇧ or ⌘/Ctrl sideways); pinch and ⌥-scroll zoom around the pointer.
let wheel = crate::wheel_nav::read(&ctx, app.session.prefs().general.zoom_with_scroll_wheel);
// The wheel also scrolls over the scrollbars drawn on top of the canvas (last frame's hover).
let bars_id = egui::Id::new(("pc-canvas-bars-hover", idx));
@@ -2093,7 +2093,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// Scrollbars (scrollbars.rs): drawn over the canvas edges, they take the pointer there.
let t0 = crate::gpu_canvas::now_ms();
let before = view.center;
let over_bars = crate::scrollbars::show(ui, rect, &mut view, flip, egui::Id::new(("pc-canvas", idx)));
let over_bars = app.ui.view.shows_scrollbars()
&& crate::scrollbars::show(ui, rect, &mut view, flip, app.session.prefs().tools.overscroll, egui::Id::new(("pc-canvas", idx)));
ctx.data_mut(|d| d.insert_temp(bars_id, over_bars));
if view.center != before {
ctx.request_repaint();
+13 -4
View File
@@ -254,10 +254,19 @@ fn alt_scroll_zooms_gently_around_the_pointer() {
assert_eq!(zoom(&h), z2, "a plain scroll never zooms");
assert!(h.state().ui.views[0].center[1] > c2[1], "a plain scroll pans");
// ⌘/Ctrl + scroll is still the faster gesture zoom.
wheel(&h, 1.0, Modifiers::COMMAND);
h.run_steps(40);
assert!(zoom(&h) / z2 > 1.05, "⌘-scroll zooms in bigger steps: {z2} -> {}", zoom(&h));
// ⌘/Ctrl + scroll pans sideways (#635), on Windows (Ctrl) and macOS (⌘) alike.
for m in [Modifiers { ctrl: true, command: true, ..Modifiers::NONE }, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }] {
let c = h.state().ui.views[0].center;
wheel(&h, -1.0, m);
h.run_steps(40);
let c1 = h.state().ui.views[0].center;
assert_eq!(zoom(&h), z2, "⌘/Ctrl-scroll never zooms");
assert!(c1[0] > c[0] && c1[1] == c[1], "⌘/Ctrl-scroll pans sideways: {c:?} -> {c1:?}");
}
// A pinch zooms around the pointer.
h.event(egui::Event::Zoom(1.25));
h.run_steps(2);
assert!((zoom(&h) / z2 - 1.25).abs() < 1e-3, "pinch zooms: {z2} -> {}", zoom(&h));
}
#[test]
+6
View File
@@ -1038,6 +1038,10 @@ impl eframe::App for PhotocraftApp {
menu.raw_input(raw_input);
}
shortcuts::clipboard_keys(ctx, ctx.text_edit_focused() || self.ui.text_edit.is_some(), raw_input);
// Windows sends a touchpad pinch as Ctrl + wheel; make it a pinch again (wheel_nav.rs).
if cfg!(target_os = "windows") {
wheel_nav::fold_legacy_pinch(ctx, raw_input);
}
raw_input.events.extend(self.take_synthetic_step());
}
@@ -1309,6 +1313,8 @@ impl PhotocraftApp {
// egui's own ⌘+ / ⌘- / ⌘0 scale the whole interface; PhotoCraft zooms the canvas instead
// (shortcuts.rs), like Photoshop.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
// ⌘/Ctrl + wheel scrolls sideways, as in Photoshop; only a pinch zooms (wheel_nav.rs).
wheel_nav::configure(ctx);
}
}
+99 -54
View File
@@ -1,12 +1,14 @@
//! Canvas scrollbars (#300) and Preferences › Tools › Overscroll.
//! Canvas scrollbars (#300, #620) and Preferences › Tools › Overscroll.
//!
//! Like Photoshop's document window: a horizontal bar along the bottom of the view and a
//! vertical one along the right, each shown only when the image doesn't fit that way. The thumb
//! is the visible fraction of the scrollable extent (the image, plus wherever the view has been
//! scrolled past it); drag it to scroll, or click the track to page by one screenful.
//! vertical one along the right, always shown in Standard Screen Mode. The thumb is the visible
//! fraction of the scrollable extent (the image, plus wherever the view has been scrolled past
//! it); drag it to scroll, or click the track to page by one screenful. With Overscroll on, an
//! image smaller than the window can also be scrolled from one edge of the window to the other.
//!
//! With Overscroll off the view is clamped before anything is drawn: the image can't be scrolled
//! past its edges, and an image smaller than the window stays centred on that axis.
//! past its edges, and an image smaller than the window stays centred on that axis (its thumb
//! then fills the track).
//!
//! Everything here is a few rectangles per frame: no allocation and no per-pixel work.
@@ -18,8 +20,6 @@ use crate::state::View;
pub const THICKNESS: f32 = 12.0;
/// The thumb never gets shorter than this, so it stays easy to grab on huge images.
pub const MIN_THUMB: f32 = 24.0;
/// A visible span within this many screen points of the image edge counts as fitting.
const FIT_SLACK: f32 = 0.5;
/// One axis of the view, in document pixels.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -40,13 +40,15 @@ impl Span {
(s.len.is_finite() && s.len > 0.0 && s.v0.is_finite() && s.v1.is_finite() && s.v1 > s.v0).then_some(s)
}
/// The scrollable extent: the image and the visible span. `None` when the image fits (no bar).
pub fn extent(&self, zoom: f32) -> Option<(f32, f32)> {
let slack = FIT_SLACK / zoom.max(1e-6);
if self.v0 <= slack && self.v1 >= self.len - slack {
return None;
/// The scrollable extent: the image and the visible span. With `overscroll`, an image shorter
/// than the span may slide edge to edge within it.
pub fn extent(&self, overscroll: bool) -> (f32, f32) {
let (lo, hi) = (self.v0.min(0.0), self.v1.max(self.len));
if !overscroll {
return (lo, hi);
}
Some((self.v0.min(0.0), self.v1.max(self.len)))
let visible = self.v1 - self.v0;
(lo.min(self.len - visible), hi.max(visible))
}
/// The thumb's start and length along a track of `track` points, for extent `e`.
@@ -120,17 +122,17 @@ pub fn bar_rects(rect: Rect, horizontal: bool, vertical: bool) -> (Option<Rect>,
}
/// Show the scrollbars of the canvas `rect` and apply their drags and page clicks to `view`.
/// `flip` is View › Flip Horizontal (the bar then runs the other way through the image).
/// `flip` is View › Flip Horizontal (the bar then runs the other way through the image);
/// `overscroll` is Preferences › Tools › Overscroll.
/// Returns true when the pointer is over a bar (the canvas then leaves the pointer alone).
pub fn show(ui: &Ui, rect: Rect, view: &mut View, flip: bool, key: Id) -> bool {
pub fn show(ui: &Ui, rect: Rect, view: &mut View, flip: bool, overscroll: bool, key: Id) -> bool {
let [w, h] = view.doc_size;
let zoom = view.zoom;
// Horizontal axis in "screen order": with the view flipped, run the image backwards.
let hx = |c: f32| if flip { w as f32 - c } else { c };
let sx = Span::of(w as f32, hx(view.center[0]), rect.width(), zoom);
let sy = Span::of(h as f32, view.center[1], rect.height(), zoom);
let ex = sx.and_then(|s| s.extent(zoom).map(|e| (s, e)));
let ey = sy.and_then(|s| s.extent(zoom).map(|e| (s, e)));
let with_extent = |s: Span| (s, s.extent(overscroll));
let ex = Span::of(w as f32, hx(view.center[0]), rect.width(), zoom).map(with_extent);
let ey = Span::of(h as f32, view.center[1], rect.height(), zoom).map(with_extent);
if ex.is_none() && ey.is_none() {
return false;
}
@@ -225,25 +227,37 @@ mod tests {
use super::*;
#[test]
fn hidden_when_the_image_fits() {
// 1000 px image, 500 points at 0.4 zoom shows 1250 px: fits.
fn a_fitting_image_scrolls_edge_to_edge_with_overscroll() {
// 1000 px image, 500 points at 0.4 zoom shows 1250 px: fits, centred.
let s = Span::of(1000.0, 500.0, 500.0, 0.4).unwrap();
assert_eq!(s.extent(0.4), None);
// At 100 % only half is visible: a bar, its thumb half the track.
// Overscroll on: the image can slide until either edge meets the window's (#620).
let e = s.extent(true);
assert_eq!(e, (-250.0, 1250.0));
let (at, len) = s.thumb(e, 300.0);
assert!((len - 250.0).abs() < 1e-3 && (at - 25.0).abs() < 1e-3, "a centred thumb with room: {at} {len}");
assert!((s.doc_per_point(e, 300.0) - 5.0).abs() < 1e-3, "50 points of travel cover 250 px");
// Overscroll off: nothing to scroll, the thumb fills the track.
let e = s.extent(false);
assert_eq!(e, (-125.0, 1125.0));
assert_eq!(s.thumb(e, 300.0), (0.0, 300.0));
assert_eq!(s.doc_per_point(e, 300.0), 0.0);
// At 100 % only half is visible: the image's extent either way, the thumb half the track.
let s = Span::of(1000.0, 500.0, 500.0, 1.0).unwrap();
let e = s.extent(1.0).unwrap();
assert_eq!(e, (0.0, 1000.0));
let (at, len) = s.thumb(e, 400.0);
assert!((len - 200.0).abs() < 1e-3 && (at - 100.0).abs() < 1e-3, "{at} {len}");
for overscroll in [true, false] {
let e = s.extent(overscroll);
assert_eq!(e, (0.0, 1000.0));
let (at, len) = s.thumb(e, 400.0);
assert!((len - 200.0).abs() < 1e-3 && (at - 100.0).abs() < 1e-3, "{at} {len}");
}
}
#[test]
fn thumb_reaches_both_ends_and_has_a_minimum() {
let left = Span::of(10_000.0, 250.0, 500.0, 1.0).unwrap();
let e = left.extent(1.0).unwrap();
let e = left.extent(true);
assert_eq!(left.thumb(e, 300.0), (0.0, MIN_THUMB));
let right = Span::of(10_000.0, 9750.0, 500.0, 1.0).unwrap();
let (at, len) = right.thumb(right.extent(1.0).unwrap(), 300.0);
let (at, len) = right.thumb(right.extent(true), 300.0);
assert!((at + len - 300.0).abs() < 1e-3);
// Dragging the thumb the whole travel scrolls the whole room.
let k = left.doc_per_point(e, 300.0);
@@ -254,7 +268,7 @@ mod tests {
fn overscrolled_views_extend_the_extent() {
// Scrolled 300 px past the left edge.
let s = Span::of(1000.0, -50.0, 500.0, 1.0).unwrap();
let e = s.extent(1.0).unwrap();
let e = s.extent(true);
assert_eq!(e, (-300.0, 1000.0));
assert_eq!(s.thumb(e, 400.0).0, 0.0);
}
@@ -328,17 +342,28 @@ mod tests {
h.run_steps(2);
}
/// Centre of the horizontal thumb, in screen points.
fn h_thumb(h: &Harness<'static, PhotocraftApp>) -> Option<Pos2> {
/// Centre and length of the horizontal thumb, in screen points.
fn h_thumb(h: &Harness<'static, PhotocraftApp>) -> (Pos2, f32) {
let r = h.state().last_canvas_rect;
let v = view(h);
let s = Span::of(v.doc_size[0] as f32, v.center[0], r.width(), v.zoom)?;
let e = s.extent(v.zoom)?;
let sy = Span::of(v.doc_size[1] as f32, v.center[1], r.height(), v.zoom).and_then(|s| s.extent(v.zoom));
let (bar, _) = bar_rects(r, true, sy.is_some());
let bar = bar?;
let s = Span::of(v.doc_size[0] as f32, v.center[0], r.width(), v.zoom).unwrap();
let e = s.extent(h.state().session.prefs().tools.overscroll);
let bar = bar_rects(r, true, true).0.unwrap();
let (at, len) = s.thumb(e, bar.width());
Some(pos2(bar.left() + at + len / 2.0, bar.center().y))
(pos2(bar.left() + at + len / 2.0, bar.center().y), len)
}
fn drag(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, by: f32) {
h.event(egui::Event::PointerMoved(p));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for k in 1..=10 {
h.event(egui::Event::PointerMoved(p + vec2(by * k as f32 / 10.0, 0.0)));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: p + vec2(by, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2) {
@@ -351,9 +376,38 @@ mod tests {
}
#[test]
fn hidden_when_the_image_fits() {
let h = harness(true);
assert!(h_thumb(&h).is_none(), "fit on screen: no bars");
fn shown_and_scrollable_when_the_image_fits() {
for overscroll in [true, false] {
let mut h = harness(overscroll);
let r = h.state().last_canvas_rect;
let (p, len) = h_thumb(&h);
let c0 = view(&h).center;
drag(&mut h, p, -40.0);
let c1 = view(&h).center;
if overscroll {
// Fit on screen, the bar still moves the image (#620).
assert!(len < r.width() - 2.0 * THICKNESS, "the thumb leaves room: {len}");
assert!(c1[0] < c0[0] - 10.0, "{c0:?} -> {c1:?}");
} else {
assert!((len - (r.width() - THICKNESS)).abs() < 1.0, "the thumb fills the track: {len}");
assert_eq!(c1, c0, "Overscroll off keeps a fitting image centred");
}
}
}
#[test]
fn full_screen_modes_have_no_bars() {
let mut h = harness(true);
h.state_mut().ui.tool = crate::state::Tool::Hand;
let (p, _) = h_thumb(&h);
for mode in ["fullScreenWithMenuBar", "fullScreen"] {
h.state_mut().ui.view.screen_mode = mode.into();
h.run_steps(2);
// The drag over where the bar was reaches the Hand: the image follows the pointer.
let c0 = view(&h).center;
drag(&mut h, p, -40.0);
assert!(view(&h).center[0] > c0[0], "{mode}: {c0:?} -> {:?}", view(&h).center);
}
}
#[test]
@@ -361,24 +415,15 @@ mod tests {
let mut h = harness(true);
h.state_mut().ui.tool = crate::state::Tool::Brush;
set_view(&mut h, 1.0, [700.0, 750.0]);
let p = h_thumb(&h).expect("a horizontal bar at 100 %");
let (p, _) = h_thumb(&h);
let c0 = view(&h).center;
h.event(egui::Event::PointerMoved(p));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for k in 1..=10 {
h.event(egui::Event::PointerMoved(p + vec2(10.0 * k as f32, 0.0)));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: p + vec2(100.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
drag(&mut h, p, 100.0);
let c1 = view(&h).center;
// 100 points of thumb travel scroll about 2 px per point (1000 px of room, ~500 of travel).
assert!(c1[0] - c0[0] > 150.0, "{c0:?} -> {c1:?}");
assert_eq!(c1[1], c0[1]);
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "paint.stroke"), "the bar never reaches the tool");
let t = h_thumb(&h).unwrap();
let (t, _) = h_thumb(&h);
assert!((t.x - (p.x + 100.0)).abs() < 3.0, "the thumb followed the pointer: {t:?} vs {p:?}");
}
@@ -386,7 +431,7 @@ mod tests {
fn clicking_the_track_pages_by_a_screenful() {
let mut h = harness(true);
set_view(&mut h, 1.0, [500.0, 750.0]);
let p = h_thumb(&h).unwrap();
let (p, _) = h_thumb(&h);
let r = h.state().last_canvas_rect;
let x0 = view(&h).center[0];
click(&mut h, pos2(r.right() - 30.0, p.y));
+4
View File
@@ -127,6 +127,10 @@ impl ViewOptions {
pub fn hides_tabs(&self) -> bool {
self.screen_mode != "standard"
}
/// Photoshop's full screen modes show no scroll bars.
pub fn shows_scrollbars(&self) -> bool {
self.screen_mode == "standard"
}
}
const SCREEN_MODES: [&str; 3] = ["standard", "fullScreenWithMenuBar", "fullScreen"];
+184 -24
View File
@@ -1,20 +1,31 @@
//! Mouse wheel and trackpad navigation over the canvas (#293).
//! Mouse wheel and trackpad navigation over the canvas (#293, #635), as in Photoshop:
//!
//! - Scrolling pans (Shift scrolls sideways; egui folds that in).
//! - Pinch, or ⌘/Ctrl + scroll, zooms around the pointer (egui's zoom gesture).
//! - ⌥/Alt + scroll zooms around the pointer in gentle steps, about 5% per wheel notch, as in
//! Photoshop with Preferences › General › Zoom with Scroll Wheel off.
//! - With Zoom with Scroll Wheel on, a plain scroll zooms too.
//! - The wheel scrolls up and down; ⇧ or ⌘/Ctrl + wheel scrolls sideways.
//! - ⌥/Alt + wheel zooms around the pointer in gentle steps, about 5% per wheel notch.
//! - Preferences › General › Zoom with Scroll Wheel swaps the two: the wheel zooms and
//! ⌥/Alt + wheel scrolls.
//! - A trackpad pinch zooms around the pointer.
//!
//! [`configure`] makes egui fold ⌘/Ctrl + wheel into a sideways scroll as it does ⇧ + wheel, so
//! only a real pinch (egui's `Event::Zoom`, or a touch screen) reaches `zoom_delta`. macOS and
//! browsers deliver a pinch as its own event, but Windows hands a precision touchpad pinch to apps
//! as Ctrl + wheel in fractions of a notch: [`fold_legacy_pinch`] turns those back into a pinch
//! before egui sees them, so Ctrl + a notched mouse wheel scrolls sideways and the pinch still
//! zooms.
//!
//! egui smooths a wheel notch over several frames, so the Alt state is taken from the wheel
//! events themselves and kept until the next wheel event: releasing Alt while the last notch
//! is still settling must not turn the rest of it into a pan.
use egui::{Context, Event, Id, Vec2};
use egui::{Context, Event, Id, Modifiers, MouseWheelUnit, RawInput, Vec2};
/// Zoom factor of one wheel notch with ⌥/Alt held.
pub const ALT_NOTCH: f32 = 1.05;
/// A Ctrl + wheel event this soon (seconds) after a pinch step still belongs to the pinch, even
/// when it happens to be a whole notch.
const PINCH_GAP: f64 = 0.25;
/// What the wheel does to the view this frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Wheel {
@@ -29,7 +40,7 @@ pub enum Wheel {
pub struct Input {
/// Smoothed scroll delta (points, content direction).
pub scroll: Vec2,
/// egui's pinch / ⌘-scroll zoom factor (1 = none).
/// egui's pinch zoom factor (1 = none).
pub zoom_delta: f32,
/// ⌥/Alt was held for the wheel gesture in progress.
pub alt: bool,
@@ -47,18 +58,69 @@ pub fn classify(i: Input) -> Option<Wheel> {
if !(i.scroll.x.is_finite() && i.scroll.y.is_finite()) || i.scroll == Vec2::ZERO {
return None;
}
if i.alt {
// egui folds a scroll with Alt held into y. One notch (`notch` points) is 5%.
let notch = if i.notch.is_finite() && i.notch > 0.0 { i.notch } else { 40.0 };
let dy = i.scroll.y + i.scroll.x;
let f = ALT_NOTCH.powf(dy / notch);
return (f.is_finite() && f > 0.0 && f != 1.0).then_some(Wheel::Zoom(f));
let f = match (i.alt, i.zoom_with_wheel) {
(true, false) => {
// egui folds a scroll with Alt held into y. One notch (`notch` points) is 5%.
let notch = if i.notch.is_finite() && i.notch > 0.0 { i.notch } else { 40.0 };
ALT_NOTCH.powf((i.scroll.y + i.scroll.x) / notch)
}
// ⇧ and ⌘/Ctrl fold the wheel into x: those still scroll sideways.
(false, true) if i.scroll.y != 0.0 => (i.scroll.y / 200.0).exp(),
_ => return Some(Wheel::Pan(i.scroll)),
};
(f.is_finite() && f > 0.0 && f != 1.0).then_some(Wheel::Zoom(f))
}
/// Set egui's wheel modifiers (once, at setup): ⌘/Ctrl + wheel scrolls sideways like ⇧ + wheel
/// instead of zooming, everywhere in the app.
pub fn configure(ctx: &Context) {
ctx.options_mut(|o| {
o.input_options.zoom_modifier = Modifiers::NONE;
o.input_options.horizontal_scroll_modifier = Modifiers::SHIFT | Modifiers::COMMAND;
});
}
/// Turn a Windows touchpad pinch, which arrives as Ctrl + wheel in fractions of a notch, into
/// pinch events (`Event::Zoom`) at egui's Ctrl-scroll zoom speed (`notch` points per line,
/// `speed` per point). Ctrl + wheel in whole notches (a mouse wheel) is left to scroll sideways,
/// unless it comes within [`PINCH_GAP`] of a pinch step. `last` is the time of the previous pinch
/// step; returns the time of the latest one.
pub fn legacy_pinch_to_zoom(events: &mut [Event], now: f64, last: Option<f64>, notch: f32, speed: f32) -> Option<f64> {
let ctrl_wheel = |e: &Event| match e {
Event::MouseWheel { unit: MouseWheelUnit::Line, delta, modifiers, .. } if modifiers.ctrl => Some(*delta),
_ => None,
};
let fraction = |d: Vec2| d.x.fract() != 0.0 || d.y.fract() != 0.0;
let pinching = last.is_some_and(|t| (0.0..PINCH_GAP).contains(&(now - t))) || events.iter().filter_map(ctrl_wheel).any(fraction);
if !pinching {
return last;
}
if i.zoom_with_wheel && i.scroll.y != 0.0 {
let f = (i.scroll.y / 200.0).exp();
return f.is_finite().then_some(Wheel::Zoom(f));
let mut latest = last;
for e in events.iter_mut() {
if let Some(d) = ctrl_wheel(e) {
let f = (speed * notch * (d.x + d.y)).exp();
*e = Event::Zoom(if f.is_finite() && f > 0.0 { f } else { 1.0 });
latest = Some(now);
}
}
latest
}
fn pinch_id() -> Id {
Id::new("pc-wheel-legacy-pinch")
}
/// [`legacy_pinch_to_zoom`] for one frame's raw input (`raw_input_hook`, on Windows).
pub fn fold_legacy_pinch(ctx: &Context, raw: &mut RawInput) {
let (notch, speed) = ctx.options(|o| (o.input_options.line_scroll_speed, o.input_options.scroll_zoom_speed));
let now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time));
let last = ctx.data(|d| d.get_temp::<f64>(pinch_id()));
let latest = legacy_pinch_to_zoom(&mut raw.events, now, last, notch, speed);
if latest != last
&& let Some(t) = latest
{
ctx.data_mut(|d| d.insert_temp(pinch_id(), t));
}
Some(Wheel::Pan(i.scroll))
}
fn alt_id() -> Id {
@@ -88,12 +150,48 @@ pub fn read(ctx: &Context, zoom_with_wheel: bool) -> Option<Wheel> {
#[cfg(test)]
mod tests {
use egui::TouchPhase;
use super::*;
fn input(scroll: Vec2, alt: bool) -> Input {
Input { scroll, zoom_delta: 1.0, alt, zoom_with_wheel: false, notch: 40.0 }
}
fn wheel(delta: Vec2, modifiers: Modifiers) -> Event {
Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers }
}
const CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
const CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
/// Feed `events` through an egui context set up like the app's (`legacy`: the Windows pinch
/// fold on) and add up what the wheel did to the view over the following second.
fn gesture(frames: Vec<Vec<Event>>, zoom_with_wheel: bool, legacy: bool) -> (Vec2, f32) {
let ctx = Context::default();
configure(&ctx);
let (mut pan, mut zoom) = (Vec2::ZERO, 1.0);
let mut frames = frames.into_iter();
for n in 0..60 {
let mut raw =
RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events: frames.next().unwrap_or_default(), ..Default::default() };
if legacy {
fold_legacy_pinch(&ctx, &mut raw);
}
let mut out = ctx.run_ui(raw, |ui| match read(ui.ctx(), zoom_with_wheel) {
Some(Wheel::Pan(d)) => pan += d,
Some(Wheel::Zoom(f)) => zoom *= f,
None => {}
});
out.textures_delta.clear();
}
(pan, zoom)
}
fn near(a: Vec2, b: Vec2) -> bool {
(a - b).length() < 0.01
}
#[test]
fn plain_scroll_pans() {
assert_eq!(classify(input(Vec2::new(3.0, -40.0), false)), Some(Wheel::Pan(Vec2::new(3.0, -40.0))));
@@ -119,19 +217,75 @@ mod tests {
}
#[test]
fn pinch_and_command_scroll_still_zoom() {
fn pinch_zooms() {
let i = Input { zoom_delta: 1.2, ..input(Vec2::ZERO, false) };
assert_eq!(classify(i), Some(Wheel::Zoom(1.2)));
// The gesture zoom wins over Alt (⌘⌥ + scroll).
// The pinch wins over Alt.
let i = Input { zoom_delta: 0.8, ..input(Vec2::new(0.0, 40.0), true) };
assert_eq!(classify(i), Some(Wheel::Zoom(0.8)));
}
#[test]
fn zoom_with_scroll_wheel_preference() {
let i = Input { zoom_with_wheel: true, ..input(Vec2::new(0.0, 200.0), false) };
let Some(Wheel::Zoom(f)) = classify(i) else { panic!("zoom") };
fn zoom_with_scroll_wheel_preference_swaps_wheel_and_alt_wheel() {
let pref = |scroll, alt| Input { zoom_with_wheel: true, ..input(scroll, alt) };
let Some(Wheel::Zoom(f)) = classify(pref(Vec2::new(0.0, 200.0), false)) else { panic!("zoom") };
assert!((f - std::f32::consts::E).abs() < 1e-4);
assert_eq!(classify(pref(Vec2::new(0.0, 40.0), true)), Some(Wheel::Pan(Vec2::new(0.0, 40.0))), "⌥ + wheel scrolls");
assert_eq!(classify(pref(Vec2::new(40.0, 0.0), false)), Some(Wheel::Pan(Vec2::new(40.0, 0.0))), "sideways still scrolls");
}
#[test]
fn each_modifier_maps_like_photoshop() {
let notch = Vec2::new(0.0, 1.0);
let one = |m: Modifiers| gesture(vec![vec![wheel(notch, m)]], false, false);
let (pan, zoom) = one(Modifiers::NONE);
assert!(near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0, "wheel scrolls vertically: {pan:?} {zoom}");
for m in [Modifiers::SHIFT, CTRL, CMD, Modifiers::CTRL] {
let (pan, zoom) = one(m);
assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "{m:?} + wheel scrolls sideways: {pan:?} {zoom}");
}
let (pan, zoom) = one(Modifiers::ALT);
assert!(pan == Vec2::ZERO && (zoom - 1.05).abs() < 1e-4, "⌥ + wheel zooms 5%: {pan:?} {zoom}");
// Zoom with Scroll Wheel on: swapped.
let (pan, zoom) = gesture(vec![vec![wheel(notch, Modifiers::ALT)]], true, false);
assert!(near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0, "{pan:?} {zoom}");
let (pan, zoom) = gesture(vec![vec![wheel(notch, Modifiers::NONE)]], true, false);
assert!(pan == Vec2::ZERO && zoom > 1.1, "{pan:?} {zoom}");
let (pan, zoom) = gesture(vec![vec![wheel(notch, CTRL)]], true, false);
assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "⌘/Ctrl + wheel still scrolls sideways: {pan:?} {zoom}");
}
#[test]
fn pinch_zooms_and_ctrl_wheel_pans() {
// macOS trackpad and browsers: the pinch is its own event.
let (pan, zoom) = gesture(vec![vec![Event::Zoom(1.1)], vec![Event::Zoom(1.1)]], false, false);
assert!(pan == Vec2::ZERO && (zoom - 1.21).abs() < 1e-4, "{pan:?} {zoom}");
// Windows: a touchpad pinch is Ctrl + wheel in fractions of a notch, a mouse wheel whole notches.
let (pan, zoom) = gesture(vec![vec![wheel(Vec2::new(0.0, 0.25), CTRL)], vec![wheel(Vec2::new(0.0, 0.5), CTRL)]], false, true);
let speed = egui::InputOptions::default().scroll_zoom_speed;
assert!(pan == Vec2::ZERO && (zoom - (speed * 40.0 * 0.75).exp()).abs() < 1e-4, "the pinch zooms: {pan:?} {zoom}");
let (pan, zoom) = gesture(vec![vec![wheel(Vec2::new(0.0, 1.0), CTRL)]], false, true);
assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "Ctrl + mouse wheel scrolls sideways: {pan:?} {zoom}");
}
#[test]
fn a_whole_notch_inside_a_pinch_stays_a_pinch() {
let mut ev = [wheel(Vec2::new(0.0, 0.5), CTRL)];
let t = legacy_pinch_to_zoom(&mut ev, 1.0, None, 40.0, 0.005);
assert_eq!(t, Some(1.0));
assert!(matches!(ev[0], Event::Zoom(f) if (f - 0.1f32.exp()).abs() < 1e-5), "{ev:?}");
// The next step lands on a whole notch: still the pinch.
let mut ev = [wheel(Vec2::new(0.0, -1.0), CTRL)];
assert_eq!(legacy_pinch_to_zoom(&mut ev, 1.1, t, 40.0, 0.005), Some(1.1));
assert!(matches!(ev[0], Event::Zoom(f) if f < 1.0), "{ev:?}");
// Long after: a mouse wheel notch, untouched.
let mut ev = [wheel(Vec2::new(0.0, -1.0), CTRL)];
assert_eq!(legacy_pinch_to_zoom(&mut ev, 2.0, Some(1.1), 40.0, 0.005), Some(1.1));
assert!(matches!(ev[0], Event::MouseWheel { .. }));
// Without Ctrl nothing is a pinch.
let mut ev = [wheel(Vec2::new(0.0, 0.5), Modifiers::NONE)];
assert_eq!(legacy_pinch_to_zoom(&mut ev, 2.0, None, 40.0, 0.005), None);
assert!(matches!(ev[0], Event::MouseWheel { .. }));
}
#[test]
@@ -140,11 +294,17 @@ mod tests {
for alt in [false, true] {
for notch in [0.0, -1.0, f32::NAN, 40.0] {
for zd in [f32::NAN, 0.0, -1.0, f32::INFINITY, 1.0] {
let i = Input { scroll: Vec2::new(0.0, s), zoom_delta: zd, alt, zoom_with_wheel: alt, notch };
let i = Input { scroll: Vec2::new(0.0, s), zoom_delta: zd, alt, zoom_with_wheel: !alt, notch };
if let Some(Wheel::Zoom(f)) = classify(i) {
assert!(f.is_finite() && f > 0.0, "{f} from {i:?}");
}
}
let mut ev = [wheel(Vec2::new(s, 0.5), CTRL)];
let _ = legacy_pinch_to_zoom(&mut ev, f64::NAN, Some(f64::NAN), notch, 0.005);
let _ = legacy_pinch_to_zoom(&mut ev, 0.0, None, notch, f32::NAN);
if let Event::Zoom(f) = ev[0] {
assert!(f.is_finite() && f > 0.0, "{f}");
}
}
}
}