M3: Wayland: stop retaining Ctrl zoom for pixel-only wheel input (#1569)

* M3: release Ctrl zoom for pixel-only Wayland wheel events

Use the reported scroll source to avoid unterminated finger gestures for wheel, wheel-tilt and continuous input. Exercise the production policy through egui, including modifier release and preserved finger momentum.

Assisted-by: OpenAI Codex

* M3: keep Cargo lockfile dependency ordering canonical

Record the dependency ordering produced by the completed workspace and wasm gates.

Assisted-by: OpenAI Codex

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tomasmark79
2026-10-15 10:08:34 +02:00
committed by GitHub
parent cdf44d7999
commit afa8e1843c
7 changed files with 114 additions and 16 deletions
Generated
+1
View File
@@ -3951,6 +3951,7 @@ dependencies = [
"wayland-client",
"wayland-protocols",
"windows",
"winit",
"winresource",
"winsafe",
]
+1
View File
@@ -156,6 +156,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
+4
View File
@@ -48,6 +48,10 @@ raw-window-handle = "0.6"
# Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu).
pollster = "1.0"
[target.'cfg(target_os = "linux")'.dev-dependencies]
# Exercise the vendored Wayland scroll policy with the actual winit event type.
winit = { version = "0.30", default-features = false, features = ["wayland"] }
[target.'cfg(target_os = "windows")'.dependencies]
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
@@ -0,0 +1,76 @@
#![cfg(target_os = "linux")]
use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2};
use wayland_client::protocol::wl_pointer::AxisSource;
// Compile the production policy against winit's real event type, without a compositor.
mod event {
pub use winit::event::TouchPhase;
}
#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"]
mod scroll;
fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
match phase {
event::TouchPhase::Started => TouchPhase::Start,
event::TouchPhase::Moved => TouchPhase::Move,
event::TouchPhase::Ended => TouchPhase::End,
event::TouchPhase::Cancelled => TouchPhase::Cancel,
}
}
fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
let ctx = egui::Context::default();
let mut previous = event::TouchPhase::Ended;
let mut seen = Vec::new();
let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
for (frame, modifiers) in [ctrl, ctrl, Modifiers::NONE].into_iter().enumerate() {
let phase = scroll::phase(previous, Some(source), discrete, false);
previous = phase;
let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) };
let input = RawInput {
time: Some(frame as f64 / 60.0),
events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }],
..Default::default()
};
ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear();
}
seen
}
#[test]
fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] {
let seen = wheel_trace(source, false);
assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}");
assert_eq!(seen[2].0, 1.0, "releasing Ctrl must stop zooming: {source:?}");
assert!(seen[2].1.y > 0.0, "the next wheel event must scroll: {source:?}");
}
}
#[test]
fn discrete_wheels_still_follow_current_modifiers() {
let seen = wheel_trace(AxisSource::Wheel, true);
assert!(seen[1].0 > 1.0);
assert_eq!(seen[2].0, 1.0);
assert!(seen[2].1.y > 0.0);
}
#[test]
fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
// A finger scroll's momentum may arrive after Ctrl is released.
let seen = wheel_trace(AxisSource::Finger, false);
assert!(seen[1].0 > 1.0);
assert!(seen[2].0 > 1.0);
assert_eq!(seen[2].1, egui::Vec2::ZERO);
assert_eq!(scroll::phase(event::TouchPhase::Moved, Some(AxisSource::Finger), false, true), event::TouchPhase::Ended);
assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started);
}
#[test]
fn unknown_sources_keep_the_existing_phase_policy() {
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started);
assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved);
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved);
assert_eq!(scroll::phase(event::TouchPhase::Moved, None, false, true), event::TouchPhase::Ended);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -34,6 +34,7 @@ use crate::platform_impl::wayland::state::WinitState;
use crate::platform_impl::wayland::{self, DeviceId, WindowId};
pub mod relative_pointer;
mod scroll;
impl PointerHandler for WinitState {
fn pointer_frame(
@@ -169,26 +170,20 @@ impl PointerHandler for WinitState {
window_id,
);
},
PointerEventKind::Axis { horizontal, vertical, .. } => {
PointerEventKind::Axis { horizontal, vertical, source, .. } => {
// Get the current phase.
let mut pointer_data = pointer.winit_data().inner.lock().unwrap();
let has_discrete_scroll = horizontal.discrete != 0 || vertical.discrete != 0;
// Figure out what to do about start/ended phases here.
//
// Figure out how to deal with `Started`. Also the `Ended` is not guaranteed
// to be sent for mouse wheels.
let phase = if horizontal.stop || vertical.stop {
TouchPhase::Ended
} else {
match pointer_data.phase {
// Discrete scroll only results in moved events.
_ if has_discrete_scroll => TouchPhase::Moved,
TouchPhase::Started | TouchPhase::Moved => TouchPhase::Moved,
_ => TouchPhase::Started,
}
};
// PdfCraft patch (13, #991): use the source as well as discrete steps;
// pixel-only wheels do not promise an Ended event either.
let phase = scroll::phase(
pointer_data.phase,
source,
has_discrete_scroll,
horizontal.stop || vertical.stop,
);
// Update the phase.
pointer_data.phase = phase;
@@ -0,0 +1,21 @@
//! PdfCraft patch (13): Wayland scroll-phase policy, shared with the desktop regression tests.
use wayland_client::protocol::wl_pointer::AxisSource;
use crate::event::TouchPhase;
pub(crate) fn phase(previous: TouchPhase, source: Option<AxisSource>, has_discrete_scroll: bool, stopped: bool) -> TouchPhase {
// Wayland does not guarantee axis_stop for these sources. A pixel-only wheel must
// not start a finger gesture: consumers would retain its modifiers indefinitely.
// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_pointer-event-axis_source
if stopped {
TouchPhase::Ended
} else if has_discrete_scroll || matches!(source, Some(AxisSource::Wheel | AxisSource::WheelTilt | AxisSource::Continuous)) {
TouchPhase::Moved
} else {
match previous {
TouchPhase::Started | TouchPhase::Moved => TouchPhase::Moved,
_ => TouchPhase::Started,
}
}
}