View: Single Page wheel turns to the next page at the edge of a zoomed-in page (#1924) (#1986)

* View: in Single Page view the wheel turns the page at the edge of a zoomed-in page (#1924)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* ROADMAP: log edge paging (#1924)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* View: no jump past the last/first page, a turned page starts at its edge (#1924)

step_page is a no-op at either end (it re-aligned the view via goto), the page-view
ScrollArea is not animated (its zero-length offset target overwrote the turn's jump), and
the rest of a gesture that turned the page no longer pans the new page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-15 02:37:07 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5 Brandon Thomas
parent 16829991fe
commit bb2b03f263
5 changed files with 202 additions and 16 deletions
+1
View File
@@ -164,6 +164,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-13 (M5, signature pad undo):** The Draw tab of Create signature/initials has an Undo button and Ctrl/Cmd+Z that take back the last stroke (#1963). Pad is UI-only, so no automation tool. Estimates unchanged.
- **2026-10-13 (M2, raw crop and page-unit floor):** The renderer applies its existing one-point minimum in physical points for non-nearly-zero raw crop dimensions on scaled pages. Five synthetic regressions cover inspection, crop origins and rotations, reciprocal rounding and bounded raster controls. Existing UserUnit policy, nearly-zero fallback, feature statuses and estimates are unchanged.
- **2026-10-13 (M5, text box font size, #1908):** Comment Properties has a Font size control for text boxes, typewriter text and callouts (1-400 pt); it writes `/DA` and the rich-text strings, redraws the appearance and grows the box to fit. `comment_edit` takes `font_size`. Annot, tool and UI regressions. Estimates unchanged.
- **2026-10-13 (M3, Single Page edge paging, #1924):** In Single Page view a page taller than the window pans with the wheel, and one more notch (or a fresh trackpad swipe) at the bottom/top edge shows the next page at its top / the previous page at its bottom. A swipe that panned to the edge, momentum included, does not turn. Estimates unchanged.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
+64 -11
View File
@@ -1135,20 +1135,27 @@ impl DocView {
/// page of the spread is already on screen, so stepping to it wouldn't move the view (#70).
pub fn step_page(&mut self, forward: bool) {
let c = self.current;
let target = match self.layout {
let last = self.page_count.saturating_sub(1);
let (target, first) = match self.layout {
PageLayout::TwoUp => {
// Spreads are [0, 1], [2, 3], … or, with a cover page, [0], [1, 2], [3, 4], ….
let first = if self.cover { c.saturating_sub((c + 1) % 2) } else { c - c % 2 };
match (forward, self.cover && c == 0) {
let target = match (forward, self.cover && c == 0) {
(true, true) => 1,
(true, false) => first.saturating_add(2),
(false, _) if self.cover && first <= 1 => 0,
(false, _) => first.saturating_sub(2),
}
};
(target, first)
}
PageLayout::Single if forward => c + 1,
PageLayout::Single => c.saturating_sub(1),
PageLayout::Single if forward => (c + 1, c),
PageLayout::Single => (c.saturating_sub(1), c),
};
// Past either end there is nowhere to go: stay put rather than re-align the view, which
// would scroll the page back to its top.
if if forward { target > last } else { first == 0 } {
return;
}
self.go_to_page(target);
}
@@ -1284,6 +1291,33 @@ impl DocView {
self.current != before
}
/// One wheel event in a non-continuous view whose page is taller than the window, where the
/// wheel pans (#1924). Once the view is at the bottom (top) edge, scrolling on turns to the
/// next (previous) page, which shows its top (bottom). A smooth gesture that panned the page
/// has to end first (see `WheelPager::feed_edge`). `can_turn` is false when something else
/// owns the wheel. Returns whether the page turned.
pub fn single_page_wheel_edge(&mut self, unit: egui::MouseWheelUnit, dy: f32, phase: egui::TouchPhase, now: f64, can_turn: bool) -> bool {
if self.continuous {
return false;
}
// Only an up-to-date view counts: the page drawn is the shown one, with no jump pending.
let edge = self.goto.is_none()
&& self.goto_point.is_none()
&& self.single_edges.is_some_and(|(page, top, bottom)| page == self.current && if dy < 0.0 { bottom } else { top });
let Some(forward) = self.wheel.feed_edge(unit, dy, phase, now, can_turn && edge) else { return false };
let before = self.current;
self.step_page(forward);
if self.current == before {
return false;
}
if !forward {
// Going back lands on the bottom of the previous page.
self.goto = Some((self.current, 1.0));
}
self.single_edges = None;
true
}
/// Displayed page size in points for this view rotation.
fn display_size(&self, p: &pdfcraft_render::PageInfo) -> (f32, f32) {
if self.rotation % 180 == 90 { (p.height, p.width) } else { (p.width, p.height) }
@@ -2231,17 +2265,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
};
let middle_gesture = view.auto_scroll.blocks_input();
// Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns
// pages instead; zoomed in far enough to pan, it pans. Touch drags are untouched: with
// pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on
// (#1924, `single_page_wheel_edge`). Touch drags are untouched: with
// nothing to pan, touch users turn pages with the rail buttons, the page box and the arrow
// keys. Runs before `visible_pages` so the frame that turns the page draws it.
let mut single_paging = false;
let mut edge_turned = false;
if !view.continuous {
// The row's bounding box on screen (one page, or a two-page spread).
let row_rect = row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union);
// Within a point, so layout rounding can't stop a fitting page from turning.
let fits = row_rect.is_some_and(|r| r.height() + 2.0 * MARGIN <= avail.height() + 1.0);
let can_turn = fits && !middle_gesture && unobstructed && ui.rect_contains_pointer(avail);
single_paging = can_turn;
let can_turn = !middle_gesture && unobstructed && ui.rect_contains_pointer(avail);
single_paging = can_turn && fits;
// Every wheel event goes to the pager, so it follows each trackpad touch to its end
// even while the page can't turn.
ui.input(|i| {
@@ -2249,11 +2285,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if let egui::Event::MouseWheel { unit, delta, phase, modifiers } = e {
// Zooming (⌘ or Ctrl) and sideways scrolling (Shift) never turn pages.
let dy = if modifiers.command || modifiers.ctrl || modifiers.shift { 0.0 } else { delta.y };
view.single_page_wheel(*unit, dy, *phase, i.time, can_turn);
if fits {
view.single_page_wheel(*unit, dy, *phase, i.time, can_turn);
} else {
edge_turned |= view.single_page_wheel_edge(*unit, dy, *phase, i.time, can_turn);
}
}
}
});
if can_turn {
if single_paging {
// Paging owns vertical wheel motion here. A diagonal gesture keeps its sideways
// part, as `ScrollArea` itself handles each axis.
ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0);
@@ -2290,8 +2330,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.precise_scroll_until = now + 0.25;
}
}
if edge_turned {
// The turn leaves egui's eased copy of the notch to decay for a few frames; it must not
// scroll the page just shown away from its top (or bottom).
view.precise_scroll_until = now + 0.25;
}
// Paging owns the vertical axis in single-page view; leave it to the pager.
if single_paging {
// The rest of a gesture that turned the page is swallowed too, or it would pan the new page
// away from the edge it was shown at.
if single_paging || (!view.continuous && view.wheel.gesture_turned()) {
precise_scroll.y = 0.0;
}
if now < view.precise_scroll_until {
@@ -2338,6 +2385,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
});
// Each document keeps its own scroll position when switching tabs (#189).
scroll = scroll.id_salt(("page-view", view.id));
// Every scroll here asks for no animation. An animated area would still queue a zero-length
// offset target for the next frame, which overwrites the jump a page turn just set.
scroll = scroll.animated(false);
// A newly opened document starts at the top. eframe saves egui's memory with the settings
// and document ids start again at 1 every launch, so the offset stored under this id can be
// where an earlier session left another document.
@@ -2351,6 +2401,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let r = rects[page.min(rects.len() - 1)];
let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height());
scroll = scroll.scroll_offset(vec2((point.x - rel.x).max(0.0), (point.y - rel.y).max(0.0)));
} else if edge_turned {
// The row above was laid out for the page before the turn: place the new one next frame.
ui.ctx().request_repaint();
} else if let Some((page, frac)) = view.goto.take() {
let page = page.min(rects.len() - 1);
let r = rects[page];
+46 -3
View File
@@ -53,12 +53,16 @@ pub(crate) struct WheelPager {
last_event: f64,
/// egui time of the last smooth (non-notch) motion.
last_smooth: f64,
/// The current smooth gesture already panned the page (edge paging only): it must end
/// before it can turn, so momentum that carries the view to the page's edge doesn't go on
/// to turn the page.
panned: bool,
}
impl Default for WheelPager {
fn default() -> Self {
// Infinitely long ago, so the first event never looks like part of a stream.
Self { accum: 0.0, turned: None, touch: None, finger_up: None, last_event: f64::NEG_INFINITY, last_smooth: f64::NEG_INFINITY }
Self { accum: 0.0, turned: None, touch: None, finger_up: None, last_event: f64::NEG_INFINITY, last_smooth: f64::NEG_INFINITY, panned: false }
}
}
@@ -69,10 +73,27 @@ impl WheelPager {
self.accum = 0.0;
}
/// Whether the current gesture already turned a page: the rest of it must not pan the page
/// just shown away from its top (or bottom).
pub(crate) fn gesture_turned(&self) -> bool {
self.turned.is_some()
}
/// Feed one `Event::MouseWheel`: `dy` is its vertical delta (negative scrolls forward) and
/// `now` is egui time. With `can_turn` false (the page doesn't fit, a dialog is open…) the
/// gesture is followed but never turns. Returns the way to turn (`true` = forward).
pub(crate) fn feed(&mut self, unit: MouseWheelUnit, dy: f32, phase: TouchPhase, now: f64, can_turn: bool) -> Option<bool> {
self.feed_inner(unit, dy, phase, now, can_turn, false)
}
/// Like [`Self::feed`], for a page taller than the window, where `can_turn` means the view
/// is at the edge the motion pushes against. A smooth gesture that panned the page first
/// doesn't turn it: the user lifts and swipes again (a wheel notch still turns at once).
pub(crate) fn feed_edge(&mut self, unit: MouseWheelUnit, dy: f32, phase: TouchPhase, now: f64, can_turn: bool) -> Option<bool> {
self.feed_inner(unit, dy, phase, now, can_turn, true)
}
fn feed_inner(&mut self, unit: MouseWheelUnit, dy: f32, phase: TouchPhase, now: f64, can_turn: bool, edge: bool) -> Option<bool> {
if !now.is_finite() {
return None;
}
@@ -96,16 +117,17 @@ impl WheelPager {
self.touch = None;
None
}
TouchPhase::Move => self.motion(unit, dy, now, idle, can_turn),
TouchPhase::Move => self.motion(unit, dy, now, idle, can_turn, edge),
}
}
fn new_gesture(&mut self) {
self.accum = 0.0;
self.turned = None;
self.panned = false;
}
fn motion(&mut self, unit: MouseWheelUnit, dy: f32, now: f64, idle: f64, can_turn: bool) -> Option<bool> {
fn motion(&mut self, unit: MouseWheelUnit, dy: f32, now: f64, idle: f64, can_turn: bool, edge: bool) -> Option<bool> {
if !dy.is_finite() || dy == 0.0 {
return None;
}
@@ -136,6 +158,10 @@ impl WheelPager {
if !can_turn {
// Panning a page that doesn't fit: that motion must not count toward a turn later.
self.accum = 0.0;
self.panned |= edge;
return None;
}
if self.panned {
return None;
}
if self.turned == Some(forward) {
@@ -280,6 +306,23 @@ mod tests {
assert_eq!(turns(&mut p, &[(Line, -1.0, Move, 3.0)]), [true]);
}
#[test]
fn edge_paging_needs_a_gesture_that_did_not_pan() {
let mut p = WheelPager::default();
// A swipe that pans first (not at the edge yet), then reaches it: no turn, momentum included.
let mut n = 0;
for (unit, dy, phase, at) in swipe(1.0, -25.0) {
n += usize::from(p.feed_edge(unit, dy, phase, at, at > 1.2).is_some());
}
assert_eq!(n, 0);
// A fresh swipe that starts at the edge turns once.
let turned: Vec<_> = swipe(3.0, -25.0).into_iter().filter_map(|(u, dy, ph, at)| p.feed_edge(u, dy, ph, at, true)).collect();
assert_eq!(turned, [true]);
// A wheel notch at the edge turns at once, even right after panning.
assert_eq!(p.feed_edge(Line, -1.0, Move, 5.0, false), None);
assert_eq!(p.feed_edge(Line, -1.0, Move, 5.3, true), Some(true));
}
#[test]
fn hostile_input_never_turns_more_than_once() {
let mut p = WheelPager::default();
+89
View File
@@ -198,6 +198,95 @@ fn single_page_wheel_pans_instead_when_zoomed_in() {
assert_eq!(h.state().views[0].current, 0, "zoomed-in wheel pans, it does not turn the page");
}
#[test]
fn single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back() {
// #1924: zoomed in so the page is taller than the window, the wheel pans; at the bottom
// edge one more notch shows the next page at its top, and at the top edge one notch up
// shows the previous page at its bottom.
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]);
let at = h.state().views[0].viewport_rect().center();
h.hover_at(at);
h.run_steps(4);
// Notches pan page 1 down; the page turns only after the view stopped at the bottom.
let (mut notches, mut stalled) = (0, 0);
let mut last = rect(&h, 0).expect("page 1 on screen").min.y;
while h.state().views[0].current == 0 && notches < 80 {
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
h.run_steps(12);
notches += 1;
if let Some(r) = rect(&h, 0) {
stalled = if (r.min.y - last).abs() < 0.01 { stalled + 1 } else { 0 };
last = r.min.y;
}
}
assert!(notches > 2, "the page panned before it turned: {notches} notches");
assert_eq!(h.state().views[0].current, 1, "a notch at the bottom edge shows the next page");
assert!(stalled <= 1, "no notch was wasted at the edge");
let top = rect(&h, 1).expect("page 2 on screen").min.y;
assert!(top > h.state().views[0].viewport_rect().min.y, "page 2 shows its top edge: {top}");
h.event(wheel(MouseWheelUnit::Line, 1.0, TouchPhase::Move));
h.run_steps(30);
assert_eq!(h.state().views[0].current, 0, "a notch at the top edge shows the previous page");
let r = rect(&h, 0).expect("page 1 on screen");
assert!(r.max.y < h.state().views[0].viewport_rect().max.y + 40.0, "page 1 shows its bottom edge: {r:?}");
}
#[test]
fn single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does() {
// Momentum that carries a swipe to the bottom edge must not go on to turn the page.
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]);
let at = h.state().views[0].viewport_rect().center();
h.hover_at(at);
h.run_steps(4);
let send = |h: &mut Harness<'static, PdfCraftApp>, phase, dy| {
h.event(wheel(MouseWheelUnit::Point, dy, phase));
h.step();
};
// One long swipe: far more motion than the page has, in one gesture.
send(&mut h, TouchPhase::Start, 0.0);
for _ in 0..120 {
send(&mut h, TouchPhase::Move, -40.0);
}
send(&mut h, TouchPhase::End, 0.0);
h.run_steps(30);
assert_eq!(h.state().views[0].current, 0, "the swipe that panned to the edge does not turn");
// A new swipe at the edge turns exactly one page.
send(&mut h, TouchPhase::Start, 0.0);
for _ in 0..6 {
send(&mut h, TouchPhase::Move, -25.0);
}
send(&mut h, TouchPhase::End, 0.0);
h.run_steps(30);
assert_eq!(h.state().views[0].current, 1, "a fresh swipe at the edge turns one page");
let top = rect(&h, 1).expect("page 2 on screen").min.y;
assert!(
top >= h.state().views[0].viewport_rect().min.y,
"page 2 starts at its top after the swipe: {top} in {:?}",
h.state().views[0].viewport_rect()
);
}
#[test]
fn stepping_past_either_end_leaves_the_view_alone() {
// Paging on past the first or last page must not jump the page back to its top.
for (page, forward) in [(2, true), (0, false)] {
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]);
h.state_mut().views[0].go_to_page(page);
h.run_steps(20);
let at = h.state().views[0].viewport_rect().center();
h.hover_at(at);
h.run_steps(4);
// Pan the page off its edge so a jump would show.
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
h.run_steps(30);
let before = rect(&h, page).expect("page on screen");
h.state_mut().views[0].step_page(forward);
h.run_steps(30);
assert_eq!(h.state().views[0].current, page);
assert_eq!(rect(&h, page), Some(before), "stepping past the end (forward={forward}) moved the page");
}
}
#[test]
fn wheel_turns_one_page_per_notch_and_per_trackpad_swipe() {
// At a real frame rate: brisk notches each turn a page, and one trackpad swipe turns one
+2 -2
View File
@@ -866,8 +866,8 @@ milestone = "M3"
status = "shipped"
commands = ["view.layout.single"]
tools = []
evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/view.rs::view_menu_page_display_uses_the_same_commands"]
notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in, the wheel pans and never turns the page. Switched from the View menu, the rail's Page display button or the palette"
evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/view.rs::view_menu_page_display_uses_the_same_commands"]
notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, shows the next page at its top (the previous page at its bottom); a swipe that panned to the edge, momentum included, does not turn until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette"
[[feature]]
id = "view.layout-two-up"