diff --git a/ROADMAP.md b/ROADMAP.md index f8ead1deb..5d32b5652 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 909160e13..33e1c25c5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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]; diff --git a/crates/ui-egui/src/wheel_pager.rs b/crates/ui-egui/src/wheel_pager.rs index a05af23bf..d12c445a8 100644 --- a/crates/ui-egui/src/wheel_pager.rs +++ b/crates/ui-egui/src/wheel_pager.rs @@ -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 { + 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 { + 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 { 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 { + fn motion(&mut self, unit: MouseWheelUnit, dy: f32, now: f64, idle: f64, can_turn: bool, edge: bool) -> Option { 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(); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 1f27a7314..9fc490262 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -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 diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c177114e1..cefacacd8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"