mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 12:16:50 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f5bc2e6c7 | ||
|
|
08e6747087 |
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user