From 5387c346a58adcb99cfe0b5af808a3bc5411cb77 Mon Sep 17 00:00:00 2001 From: Frumpy9 <32330391+Frumpy9@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:16:26 -0400 Subject: [PATCH] M3: Add middle-click autoscroll on Linux (#111) * M3: add middle-click autoscroll Co-Authored-By: Codex * M3: keep middle-click autoscroll toggled after release Keep scrolling enabled after releasing the wheel, including when the pointer moves before release. Anchor speed to the initial press event, so movement later in the same frame cannot shift the starting point. Cover toggle behavior and increasing speed with distance in both views. Co-Authored-By: Codex * M3: increase middle-click autoscroll speed Double scrolling speed at the same pointer displacement and raise the speed cap proportionally, preserving the dead zone and click toggle. Keep the sustained-scroll fixture visible at the faster response. Co-Authored-By: Codex * M3: match Chromium middle-click scrolling Use a 15-point dead zone and Chromium's nonlinear distance-to-speed curve in the document viewer and Organize Pages. Request continuous redraws so scrolling follows elapsed frame time and preserves fractional movement. Keep click toggling, cancellation, and the existing marker unchanged. Cover reference speeds, pathological inputs, pause/resume, and consistent travel at 30/60/120/144 fps with different display scales and document zoom. Co-Authored-By: Codex * M3: leave README unchanged in autoscroll PR Remove the feature bullet so README matches upstream main. Keep the feature explanation in the pull request description. Co-Authored-By: Codex * M3: limit custom middle-click scrolling to Linux Return before handling input on other targets so their existing middle-button behavior, widgets, cursor, and Escape shortcuts stay intact. Restrict the autoscroll UI regressions to Linux and add unsupported-platform input coverage plus a non-Linux UI regression for the native CI matrix. Co-Authored-By: Codex * ROADMAP: drop the log line about merging the rename Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Codex Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 5 +- crates/ui-egui/src/autoscroll.rs | 239 ++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 59 +++++- crates/ui-egui/src/control.rs | 38 +++- crates/ui-egui/src/lib.rs | 14 ++ crates/ui-egui/tests/control.rs | 310 ++++++++++++++++++++++++++++++- crates/ui-egui/tests/view.rs | 35 ++++ parity/acrobat-features.toml | 6 +- 8 files changed, 687 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/src/autoscroll.rs diff --git a/ROADMAP.md b/ROADMAP.md index 7274e57280..5f9e5ece1e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -92,7 +92,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 92% | 2–4 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro | -| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window. | +| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling, covered by headless UI regressions. | | M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | @@ -125,12 +125,11 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run ## Log -- **2026-10-06 (community contribution):** Ctrl+A / Cmd+A selects every page in the Organize grid, preserving the current page and leaving focused text inputs and dialogs alone. Regression coverage includes bulk rotation, restricted documents, selection anchors and the opt-in UI control channel; viewer text selection remains unchanged. - Newest first. One line per session: the date, what moved, and the new overall percentage. - **2026-10-07 (session 16):** PrintCraft is renamed PdfCraft: repo `storytold/pdfcraft`, crates and binaries `pdfcraft*`, bundle id `ai.storyteller.pdfcraft`. On first launch the app moves the old PrintCraft settings and crash-recovery folders to the new name, so upgrades keep recent files, preferences and unsaved work. No feature change. ≈ 30–35%. - **2026-10-07 (session 15):** Five community issues, each with a regression test: in two-page view, Next page moves a whole spread (#77, #70); Export to Word opens in Word again, with control characters dropped and receipt-length pages kept within Word's 22 in, checked in Microsoft Word (#78, #72); macOS opens PDFs from Finder, Open With and the Dock (Apple events through the audited `fmv-macos-events`, PDF declared in the Info.plist), and the packaging no longer describes the image editor it was copied from (#82, #73); Add text keeps the box being typed when you click elsewhere, with Done and Discard beside it, reproduced and checked with real OS input (#86, #74); sharper, higher-contrast text, most visible on Windows, with every text colour at WCAG AA in both themes (#84, #76; the console window was already gone in 0.2.1). Still open: #79 (layered "not verified" signature appearances), #80 (native macOS menu bar), #81 (Homebrew and Scoop). P0 88%, P1 53%; 805 features 50.2% shipped (53.7% weighted). ≈ 30–35%. +- **2026-10-06 (community navigation):** Linux-only middle-button vertical scrolling in the document and Organize grid, toggled by clicking the wheel, with cancellation that protects page tools and selection. Scrolling stays on after releasing the wheel, even if the pointer moved before release. Manual feedback led to a Chromium-style distance^2.2 speed curve, a 15-screen-point dead zone, and continuous redraws that use elapsed frame time; the former linear speed cap is removed. Headless UI/control regressions cover fractional movement, consistent travel at 30/60/120/144 fps, pause/resume and cancellation. Other platforms retain their existing middle-button input behavior, with a regression that checks the custom scroller neither blocks input nor changes the cursor or consumes Escape. No new dependency. Automatic scrolling remains partial because keyboard-started timed scrolling is absent. Overall effort estimate remains ≈ 30–35%. - **2026-10-06 (session 14):** Issues: move and resize existing text boxes in Edit text (#59, closes #40); Windows release builds open no console window and the Start Menu shortcut has its icon (#58, #57; rolled out to every Craft app); native Windows ARM64 installers, installed and run on an ARM64 runner in CI (#66, #71; the intermittent ARM64 test crash was a WARP shader-JIT race between parallel GPU-rendering tests, not our code); XFA forms are detected and explained instead of failing silently (#65, #60 stays open for real XFA). Also: squiggly underline tool, comment opacity, Preferences ▸ Identity, drag-and-drop/hover tests; community table export to Word/HTML/RTF (#62, with linear-time detection and a column cap). #63 (timestamps/LTV with outbound network) held for review fixes and an owner decision on network access. ≈ 30–35% (unchanged). - **2026-10-06:** M1: invalid object-number ranges in xref tables and streams take the existing reconstruction path, with the reason recorded, instead of overflowing or aliasing another object. Synthetic open/repair regression added. Overall estimate unchanged (≈ 30–35%). diff --git a/crates/ui-egui/src/autoscroll.rs b/crates/ui-egui/src/autoscroll.rs new file mode 100644 index 0000000000..e025733fa2 --- /dev/null +++ b/crates/ui-egui/src/autoscroll.rs @@ -0,0 +1,239 @@ +//! Linux middle-button scrolling for the document viewport. + +use egui::{Context, CursorIcon, Event, Key, PointerButton, Pos2, Stroke, Vec2, vec2}; + +const DEAD_ZONE: f32 = 15.0; +// Chromium's autoscroll_controller.cc uses distance^2.2 * 0.000008; its +// ui/events/gestures/fixed_velocity_curve.cc multiplies elapsed seconds by 5000. +const SPEED_EXPONENT: f32 = 2.2; +const SPEED_MULTIPLIER: f32 = 0.04; +// Bound hostile coordinates before exponentiation, far beyond ordinary screen distances. +const MAX_DISPLACEMENT: f32 = 1_000_000.0; + +#[derive(Default)] +pub(crate) struct AutoScroll { + anchor: Option, + organize: bool, + /// Own a cancelling click through its release, so it cannot also edit page content. + cancel_button: Option, + block_input: bool, +} + +impl AutoScroll { + pub(crate) fn active(&self) -> bool { + self.anchor.is_some() + } + + pub(crate) fn cancel(&mut self) { + self.anchor = None; + self.cancel_button = None; + self.block_input = false; + } + + pub(crate) fn blocks_input(&self) -> bool { + self.block_input + } + + /// Run before the document's widgets. Starting is restricted to the unobstructed viewport; + /// once started, moving outside that viewport still controls the speed. + pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool) -> Vec2 { + self.update_for_platform(ui, viewport, organize, cfg!(target_os = "linux")) + } + + fn update_for_platform(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, supported: bool) -> Vec2 { + // Leave middle-button events and widget interactions to the existing platform behavior. + if !supported { + self.cancel(); + return Vec2::ZERO; + } + let ctx = ui.ctx(); + let (pointer, middle_press, middle_down, middle_released, cancel, cancel_button, dt) = ctx.input(|i| { + let cancel_button = [PointerButton::Primary, PointerButton::Secondary, PointerButton::Extra1, PointerButton::Extra2] + .into_iter() + .find(|button| i.pointer.button_pressed(*button)); + ( + i.pointer.hover_pos(), + // Read the press event itself: later movement in this frame must not move the anchor. + i.events.iter().find_map(|event| match event { + Event::PointerButton { pos, button: PointerButton::Middle, pressed: true, .. } if pos.is_finite() => Some(*pos), + _ => None, + }), + i.pointer.button_down(PointerButton::Middle), + i.pointer.button_released(PointerButton::Middle), + !i.focused + || i.key_pressed(Key::Escape) + || cancel_button.is_some() + || i.events.iter().any(|e| matches!(e, Event::MouseWheel { .. } | Event::Zoom(_))), + cancel_button, + i.stable_dt, + ) + }); + self.block_input = self.active() || self.cancel_button.is_some() || middle_press.is_some() || middle_down || middle_released; + if let Some(button) = self.cancel_button { + if !ctx.input(|i| i.pointer.button_down(button)) { + self.cancel_button = None; + } + return Vec2::ZERO; + } + if self.active() && (cancel || pointer.is_none() || self.organize != organize || ctx.egui_wants_keyboard_input()) { + self.cancel(); + self.block_input = true; + self.cancel_button = cancel_button; + return Vec2::ZERO; + } + if let Some(pressed_at) = middle_press { + if self.active() { + self.cancel(); + self.block_input = true; + self.cancel_button = Some(PointerButton::Middle); + return Vec2::ZERO; + } else if !cancel + && !ctx.egui_wants_keyboard_input() + && viewport.intersect(ui.clip_rect()).contains(pressed_at) + && ctx.layer_id_at(pressed_at) == Some(ui.layer_id()) + { + self.anchor = Some(pressed_at); + self.organize = organize; + } + } + let (Some(anchor), Some(pointer)) = (self.anchor, pointer) else { return Vec2::ZERO }; + let displacement = pointer.y - anchor.y; + // Cap the elapsed time too: returning from an idle/hidden window must never jump pages. + let delta = scroll_delta(displacement, dt); + if displacement.is_finite() && displacement.abs() > DEAD_ZONE { + // Continuous redraws let stable_dt use measured frame time. Delayed redraws + // instead use predicted_dt, which can make speed depend on the actual frame rate. + ctx.request_repaint(); + } + vec2(0.0, delta) + } + + /// Draw an original geometric marker at the activation point, above page content. + pub(crate) fn paint(&self, ui: &egui::Ui, viewport: egui::Rect) { + let Some(anchor) = self.anchor else { return }; + let painter = ui.painter().with_clip_rect(viewport); + let ink = ui.visuals().text_color(); + painter.circle(anchor, 13.0, ui.visuals().window_fill(), Stroke::new(1.0, ink)); + painter.circle_filled(anchor, 2.0, ink); + for direction in [-1.0, 1.0] { + painter.add(egui::Shape::convex_polygon( + vec![anchor + vec2(-4.0, direction * 6.0), anchor + vec2(4.0, direction * 6.0), anchor + vec2(0.0, direction * 10.0)], + ink, + Stroke::NONE, + )); + } + ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); + } + + /// Escape belongs to autoscroll first, leaving selection/find/full-screen intact. + pub(crate) fn escape(&mut self, ctx: &Context) -> bool { + if self.active() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { + self.cancel(); + true + } else { + false + } + } +} + +fn scroll_delta(displacement: f32, dt: f32) -> f32 { + if !displacement.is_finite() || !dt.is_finite() { + return 0.0; + } + let distance = displacement.abs(); + if distance <= DEAD_ZONE { + return 0.0; + } + // Chromium uses the full distance outside the dead zone, without subtracting its radius. + let speed = distance.min(MAX_DISPLACEMENT).powf(SPEED_EXPONENT) * SPEED_MULTIPLIER; + // egui's delta moves content, the opposite of the scroll offset. + -displacement.signum() * speed * dt.clamp(0.0, 0.05) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unsupported_platform_leaves_middle_button_and_escape_input_untouched() { + for previously_active in [false, true] { + let ctx = Context::default(); + let pos = egui::pos2(100.0, 100.0); + let mut scroll = AutoScroll { anchor: previously_active.then_some(pos), block_input: previously_active, ..Default::default() }; + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), + events: vec![ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Middle, pressed: true, modifiers: egui::Modifiers::NONE }, + Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + ], + ..Default::default() + }, + |ui| { + assert_eq!(scroll.update_for_platform(ui, ui.max_rect(), false, false), Vec2::ZERO); + assert!(!scroll.active(), "unsupported platforms must not start or retain custom scrolling"); + assert!(!scroll.blocks_input(), "middle-button input must reach the existing widgets"); + scroll.paint(ui, ui.max_rect()); + assert_eq!(ctx.output(|o| o.cursor_icon), CursorIcon::Default, "no custom marker or cursor"); + assert!(!scroll.escape(&ctx)); + ctx.input(|i| { + assert!(i.pointer.button_pressed(PointerButton::Middle)); + assert!(i.key_pressed(Key::Escape), "Escape must remain available to the existing shortcuts"); + }); + }, + ); + // This input-only test has no renderer to apply the generated font texture. + output.textures_delta.clear(); + } + } + + #[test] + fn chromium_curve_is_gentle_near_the_anchor_and_accelerates_farther_away() { + // Reference speeds in screen points/second from Chromium's distance exponent (2.2), + // controller multiplier (0.000008), and fixed-velocity animation multiplier (5000). + for (distance, expected_speed) in [(25.0, 48.0), (50.0, 219.0), (100.0, 1005.0), (200.0, 4617.0)] { + let speed = -scroll_delta(distance, 0.01) / 0.01; + assert!((speed - expected_speed).abs() < 1.0, "distance={distance}, speed={speed}, expected={expected_speed}"); + } + for distance in [-15.0, 0.0, 15.0] { + assert_eq!(scroll_delta(distance, 0.01), 0.0); + } + assert!(scroll_delta(15.1, 0.01) < 0.0); + } + + #[test] + fn fractional_motion_covers_the_same_distance_at_different_frame_rates() { + for distance in [16.0, 50.0, 200.0] { + let expected = scroll_delta(distance, 0.01) * 100.0; + for frames in [30, 60, 120, 144] { + let delta = scroll_delta(distance, 1.0 / frames as f32); + let travelled: f32 = (0..frames).map(|_| delta).sum(); + assert!((travelled - expected).abs() < expected.abs() * 0.00001); + } + } + assert!(scroll_delta(16.0, 1.0 / 144.0).abs() < 1.0); + } + + #[test] + fn speed_has_a_dead_zone_is_symmetric_and_rejects_invalid_input() { + for y in [-15.0, -1.0, 0.0, 1.0, 15.0] { + assert_eq!(scroll_delta(y, 0.016), 0.0); + } + assert!(scroll_delta(30.0, 0.016) < 0.0); + for (near, far) in [(16.0, 30.0), (30.0, 100.0), (100.0, 200.0)] { + assert!(scroll_delta(far, 0.016).abs() > scroll_delta(near, 0.016).abs()); + } + assert_eq!(scroll_delta(30.0, 0.016), -scroll_delta(-30.0, 0.016)); + assert!(scroll_delta(200.0, 0.01).abs() / 0.01 > 3200.0, "ordinary distances have no linear-curve speed ceiling"); + assert_eq!(scroll_delta(1000.0, 10.0), scroll_delta(1000.0, 0.05)); + assert_eq!(scroll_delta(1000.0, -1.0), 0.0); + assert_eq!(scroll_delta(f32::MAX, 0.016), scroll_delta(MAX_DISPLACEMENT, 0.016)); + assert!(scroll_delta(f32::MAX, 0.016).is_finite()); + assert_eq!(scroll_delta(-f32::MAX, 0.016), -scroll_delta(f32::MAX, 0.016)); + for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(scroll_delta(invalid, 0.016), 0.0); + assert_eq!(scroll_delta(1000.0, invalid), 0.0); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e3c4a4c877..0d739e76cd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -133,6 +133,7 @@ pub struct DocView { viewport_screen: Rect, /// Pending zoom anchor: page, position within it (0..1), and offset from the viewport corner. zoom_anchor: Option<(usize, f32, f32, Vec2)>, + pub(crate) auto_scroll: crate::autoscroll::AutoScroll, /// Pages selected in the organize grid (0-based). Empty means "the current page". pub selected: BTreeSet, /// Anchor for ⇧-click range selection in the organize grid. @@ -201,6 +202,11 @@ impl DocView { self.viewport_screen } + /// Whether a middle-button scrolling gesture is active (tests and automation). + pub fn auto_scrolling(&self) -> bool { + self.auto_scroll.active() + } + /// Pages that could not be rendered, with the reason (for automation; 0-based pages). pub fn page_errors(&self) -> Vec<(usize, &str)> { let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); @@ -245,6 +251,7 @@ impl DocView { screen_xforms: Vec::new(), viewport_screen: Rect::NOTHING, zoom_anchor: None, + auto_scroll: Default::default(), selected: BTreeSet::new(), select_anchor: None, pending_edit: None, @@ -914,6 +921,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let Some(doc) = app.session.get(app.views[index].id) else { return }; let info = &doc.info; if info.pages.is_empty() { + app.views[index].auto_scroll.cancel(); ui.centered_and_justified(|ui| ui.label("This document has no pages.")); return; } @@ -934,7 +942,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { None => {} } if view.organize { - organize_grid(view, info, &doc.renderer, doc.allows_assembly(), ui, &t); + let auto_scroll_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open; + organize_grid(view, info, &doc.renderer, doc.allows_assembly(), auto_scroll_enabled, ui, &t); return; } @@ -953,6 +962,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } view.viewport_screen = avail; + let auto_delta = if app.dialog.is_none() && app.close_request.is_none() && !app.palette_open { + view.auto_scroll.update(ui, avail, false) + } else { + view.auto_scroll.cancel(); + Vec2::ZERO + }; let max_w = info.pages.iter().map(|p| view.display_size(p).0).fold(0.0, f32::max) * view.zoom @@ -972,8 +987,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { _ => (0.0, rects.last().map(|r| r.bottom() + MARGIN).unwrap_or(0.0)), }; + let middle_gesture = view.auto_scroll.blocks_input(); let mut scroll = egui::ScrollArea::both().auto_shrink([false, false]).scroll_source(egui::scroll_area::ScrollSource { - drag: if app.quick_tool == QuickTool::Hand { egui::scroll_area::DragScroll::Always } else { egui::scroll_area::DragScroll::OnTouch }, + drag: if middle_gesture { + egui::scroll_area::DragScroll::Never + } else if app.quick_tool == QuickTool::Hand { + egui::scroll_area::DragScroll::Always + } else { + egui::scroll_area::DragScroll::OnTouch + }, ..Default::default() }); if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { @@ -1050,6 +1072,17 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } let out = scroll.show_viewport(ui, |ui, viewport| { + // egui's drag responses accept every pointer button. Keep a wheel gesture from also + // selecting text, drawing a mark, or panning with the Hand tool. + if middle_gesture { + let opacity = ui.opacity(); + ui.disable(); + // Only interactions pause; the document must keep its original colours. + ui.set_opacity(opacity); + } + if auto_delta != Vec2::ZERO { + ui.scroll_with_delta_animation(auto_delta, egui::style::ScrollAnimation::none()); + } let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); // The Hand tool pans: the content widget takes every drag, so scroll by its delta. if hand { @@ -1501,6 +1534,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { (wanted, visible_now) }); + view.auto_scroll.paint(ui, avail); + // Bound texture memory: keep sharp rasters only near the current page. if view.pages.len() > 24 { let cur = view.current; @@ -2169,14 +2204,31 @@ fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option { Some(if p.x < r.center().x { *i } else { i + 1 }) } -fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable: bool, ui: &mut egui::Ui, t: &Tokens) { +fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable: bool, auto_scroll_enabled: bool, ui: &mut egui::Ui, t: &Tokens) { let ppp = ui.ctx().pixels_per_point(); let cell = vec2(190.0, 250.0); let mut open_page = None; organize_toolbar(view, info, editable, ui, t); + let viewport = ui.available_rect_before_wrap(); + view.viewport_screen = viewport; + let auto_delta = if auto_scroll_enabled { + view.auto_scroll.update(ui, viewport, true) + } else { + view.auto_scroll.cancel(); + Vec2::ZERO + }; + let middle_gesture = view.auto_scroll.blocks_input(); let mut cells: Vec<(usize, Rect)> = Vec::with_capacity(info.pages.len()); let mut drop = false; egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + if middle_gesture { + let opacity = ui.opacity(); + ui.disable(); + ui.set_opacity(opacity); + } + if auto_delta != Vec2::ZERO { + ui.scroll_with_delta_animation(auto_delta, egui::style::ScrollAnimation::none()); + } ui.add_space(20.0); let cols = ((ui.available_width() - 40.0) / cell.x).floor().max(1.0) as usize; let rows = info.pages.len().div_ceil(cols); @@ -2296,6 +2348,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable view.org_drag = None; } }); + view.auto_scroll.paint(ui, viewport); let s = THUMB_W * ppp / info.pages.iter().map(|p| p.width).fold(1.0, f32::max); let queue: Vec = (0..info.pages.len()) .filter(|p| (!view.thumbs.contains_key(p) || view.stale_thumbs.contains(p)) && !view.errors.contains_key(p)) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c644c36092..697560887e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -17,8 +17,9 @@ //! - `ui.inspect {query?, role?, limit?}`: widgets in tree order with `id` (a string), `role`, `label`, //! `value`, `rect` (points), `enabled`, `toggled`, `selected`, `clickable`, `depth`. //! - `ui.click {id}` | `{label}` | `{x, y, button?}`: click a widget (by its AccessKit action) or a -//! point (`button`: primary or secondary, for context menus). -//! - `ui.drag {from: [x, y], to: [x, y], steps?, modifiers?}`: press, move and release (drawing +//! point (`button`: primary, secondary, or middle). +//! - `ui.move {x, y}`: move the pointer without pressing a button (e.g. latched autoscroll). +//! - `ui.drag {from: [x, y], to: [x, y], steps?, modifiers?, button?}`: press, move and release (drawing //! comments, selecting text, moving comments). `ui.state` reports `pages_on_screen` to aim at. //! - `ui.type {text}`, `ui.key {key, modifiers?}`: keyboard input to the focused widget / app. //! - `ui.command {id}`: run a registry command (as the menu would). `ui.commands` lists them. @@ -275,6 +276,17 @@ impl Control { "ui.inspect" => Ok(Handled::Now(Ok(self.inspect(p)))), "ui.click" => self.click(p), "ui.drag" => self.drag(p), + "ui.move" => { + let point = |key: &str| -> Result { + p.get(key) + .and_then(Value::as_f64) + .map(|n| n as f32) + .filter(|n| n.is_finite()) + .ok_or_else(|| format!("ui.move: {key} must be a finite coordinate in points")) + }; + let pos = egui::pos2(point("x")?, point("y")?); + Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::PointerMoved(pos)]]), json!({ "moved": [pos.x, pos.y] }))) + } "ui.type" => { let text = str_param("text")?.to_string(); Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::Text(text)]]), json!({ "typed": true }))) @@ -296,7 +308,7 @@ impl Control { Ok(Handled::Screenshot(region)) } other => Err(format!( - "unknown method {other:?} (ui.state, ui.inspect, ui.click, ui.type, ui.key, ui.command, ui.commands, ui.set, ui.open, ui.screenshot)" + "unknown method {other:?} (ui.state, ui.inspect, ui.click, ui.move, ui.drag, ui.type, ui.key, ui.command, ui.commands, ui.set, ui.open, ui.screenshot)" )), })(); r.unwrap_or_else(|e| Handled::Now(Err(e))) @@ -342,7 +354,8 @@ impl Control { let (from, to) = (point("from")?, point("to")?); let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200) as usize; let modifiers = modifiers(p.get("modifiers"))?; - let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + let which = pointer_button(p, "ui.drag")?; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers }; let mut frames = vec![vec![egui::Event::PointerMoved(from)], vec![button(from, true)]]; for k in 1..=steps { frames.push(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))]); @@ -355,11 +368,7 @@ impl Control { fn click(&mut self, p: &Value) -> Result { if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { let pos = egui::pos2(x as f32, y as f32); - let which = match p.get("button").and_then(Value::as_str).unwrap_or("primary") { - "primary" | "left" => egui::PointerButton::Primary, - "secondary" | "right" => egui::PointerButton::Secondary, - other => return Err(format!("ui.click: unknown button {other:?} (primary, secondary)")), - }; + let which = pointer_button(p, "ui.click")?; let button = |pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers: egui::Modifiers::NONE }; let frames = self.inject(vec![vec![egui::Event::PointerMoved(pos)], vec![button(true)], vec![button(false)]]); return Ok(Handled::AfterFrames(frames, json!({ "clicked": [x, y] }))); @@ -448,6 +457,15 @@ fn widget(id: NodeId, n: &accesskit::Node, depth: usize, focused: bool) -> Value w } +fn pointer_button(p: &Value, method: &str) -> Result { + match p.get("button").and_then(Value::as_str).unwrap_or("primary") { + "primary" | "left" => Ok(egui::PointerButton::Primary), + "secondary" | "right" => Ok(egui::PointerButton::Secondary), + "middle" => Ok(egui::PointerButton::Middle), + other => Err(format!("{method}: unknown button {other:?} (primary, secondary, middle)")), + } +} + fn modifiers(v: Option<&Value>) -> Result { let mut m = egui::Modifiers::NONE; for name in v.and_then(Value::as_array).into_iter().flatten() { @@ -531,6 +549,8 @@ impl Host for crate::PdfCraftApp { "fit": format!("{:?}", v.fit), "layout": format!("{:?}", v.layout), "organize": v.organize, + "auto_scrolling": v.auto_scrolling(), + "viewport": [v.viewport_rect().min.x, v.viewport_rect().min.y, v.viewport_rect().max.x, v.viewport_rect().max.y], "find_open": v.find.is_some(), "page_errors": v.page_errors().iter().map(|(p, e)| json!({ "page": p + 1, "error": e })).collect::>(), // Where pages are on screen (points), to aim ui.click / ui.drag at page content. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 886cf8be71..c187c996b3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,6 +40,7 @@ pub use create_ui::Clip; pub use link_ui::LinkDraft; pub use optimize_ui::{OptimizeDraft, OptimizeTab}; pub use sign_ui::{DigitalIdEntry, SignDraft, SignStep}; +mod autoscroll; mod dialogs; mod edit_text_ui; mod editing; @@ -1170,6 +1171,11 @@ impl PdfCraftApp { } return; } + if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) + && view.auto_scroll.escape(ctx) + { + return; + } self.registry_shortcuts(ctx); if self.full_screen && ctx.input(|i| i.key_pressed(Key::Escape)) { self.set_full_screen(ctx, false); @@ -1245,6 +1251,14 @@ impl eframe::App for PdfCraftApp { self.autosave_tick(now); self.poll_updates(); self.shortcuts(ctx); + // Scrolling is transient: never resume after changing tabs, opening a modal/palette, + // or returning to a window that lost focus. + let blocked = self.dialog.is_some() || self.close_request.is_some() || self.palette_open || !ctx.input(|i| i.focused); + for (index, view) in self.views.iter_mut().enumerate() { + if blocked || self.active != Some(index) { + view.auto_scroll.cancel(); + } + } self.process_pending_edits(); self.poll_export(); self.poll_ocr(); diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index c671f0561d..f510ed30f5 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -34,12 +34,16 @@ fn fixture(n: usize) -> Vec { } fn harness() -> (Harness<'static, PdfCraftApp>, ControlClient) { + harness_pages(5) +} + +fn harness_pages(pages: usize) -> (Harness<'static, PdfCraftApp>, ControlClient) { let slot: Arc>> = Arc::default(); let s = slot.clone(); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| { let mut app = PdfCraftApp::new(); *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); - app.open_bytes("doc.pdf", None, fixture(5)).unwrap(); + app.open_bytes("doc.pdf", None, fixture(pages)).unwrap(); app }); h.run_steps(4); @@ -63,6 +67,310 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}")) } +#[cfg(target_os = "linux")] +fn start_autoscroll(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient) -> egui::Pos2 { + let p = h.state().views[0].viewport_rect().center(); + ok(h, c, "ui.click", json!({ "x": p.x, "y": p.y, "button": "middle" })); + assert!(h.state().views[0].auto_scrolling()); + p +} + +#[cfg(target_os = "linux")] +#[test] +fn middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions() { + let (mut h, c) = harness(); + // Keep the tracked page visible through the downward leg and reversal. + h.state_mut().set_option("zoom", "400").unwrap(); + h.run_steps(3); + h.state_mut().views[0].go_to_page(0); + h.run_steps(2); + let p = start_autoscroll(&mut h, &c); + let top = h.state().views[0].page_screen_rect(0).unwrap().top(); + ok(&mut h, &c, "ui.move", json!({ "x": p.x + 100.0, "y": p.y + 8.0 })); + h.run_steps(8); + assert_eq!(h.state().views[0].page_screen_rect(0).unwrap().top(), top, "horizontal motion and the dead zone do not scroll"); + ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 90.0 })); + h.run_steps(8); + let down = h.state().views[0].page_screen_rect(0).unwrap().top(); + assert!(down < top - 30.0, "moving below the anchor scrolls down: {top} -> {down}"); + ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y - 50.0 })); + // The control request itself takes frames while the previous downward motion continues. + // Measure reversal after it has arrived and the scroll area's layout has caught up. + h.run_steps(2); + let reversing = h.state().views[0].page_screen_rect(0).unwrap().top(); + h.run_steps(8); + let up = h.state().views[0].page_screen_rect(0).unwrap().top(); + assert!(up > reversing + 15.0, "moving above the anchor scrolls up: {reversing} -> {up}"); + ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y, "button": "middle" })); + assert!(!h.state().views[0].auto_scrolling(), "a second wheel click stops"); + h.run_steps(2); + let stopped = h.state().views[0].page_screen_rect(0).unwrap().top(); + h.run_steps(8); + assert_eq!(h.state().views[0].page_screen_rect(0).unwrap().top(), stopped, "no drift after cancellation"); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "scrolling never edits the PDF"); +} + +#[cfg(target_os = "linux")] +#[test] +fn moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools() { + let (mut h, c) = harness(); + // Keep the tracked page on screen while the faster gesture continues after release. + h.state_mut().set_option("zoom", "400").unwrap(); + h.run_steps(3); + h.state_mut().views[0].go_to_page(0); + h.run_steps(2); + // A middle drag must not also draw with a selected tool (egui accepts any drag button). + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Crop; + let p = h.state().views[0].viewport_rect().center(); + let top = h.state().views[0].page_screen_rect(0).unwrap().top(); + ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y + 60.0], "steps": 12, "button": "middle" })); + h.run_steps(2); + assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < top - 20.0); + assert!(h.state().views[0].auto_scrolling(), "release keeps scrolling toggled on even after movement"); + let released = h.state().views[0].page_screen_rect(0).unwrap().top(); + h.run_steps(8); + assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < released - 30.0, "scrolling continues with no button held"); + assert!(h.state().views[0].crop_drag.is_none(), "the Crop tool must not receive a wheel drag"); + assert!(h.state().dialog.is_none()); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); + ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y + 60.0, "button": "middle" })); + assert!(!h.state().views[0].auto_scrolling(), "the next middle click toggles scrolling off"); +} + +#[cfg(target_os = "linux")] +#[test] +fn autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame() { + let (mut h, c) = harness(); + let p = h.state().views[0].viewport_rect().center(); + let moved = p + egui::vec2(0.0, 60.0); + let top = h.state().views[0].page_screen_rect(0).unwrap().top(); + h.event(egui::Event::PointerMoved(p)); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Middle, pressed: true, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::PointerMoved(moved)); + h.event(egui::Event::PointerButton { pos: moved, button: egui::PointerButton::Middle, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(8); + assert!(h.state().views[0].auto_scrolling()); + assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < top - 20.0, "distance is measured from the click, not the last mouse event"); + ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); +} + +#[cfg(target_os = "linux")] +#[test] +fn farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid() { + use egui_kittest::kittest::Queryable; + for organize in [false, true] { + let (mut h, c) = harness_pages(40); + h.state_mut().views[0].organize = organize; + h.run_steps(3); + let top = |h: &Harness<'static, PdfCraftApp>| { + if organize { h.get_by_label("Page 1").rect().top() } else { h.state().views[0].page_screen_rect(0).unwrap().top() } + }; + let p = start_autoscroll(&mut h, &c); + ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 30.0 })); + let before_near = top(&h); + h.run_steps(8); + let near = before_near - top(&h); + ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 100.0 })); + let before_far = top(&h); + h.run_steps(8); + let far = before_far - top(&h); + assert!(near > 0.0 && far > near * 3.0, "farther movement must be faster: organize={organize}, near={near}, far={far}"); + ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y })); + h.run_steps(2); + let at_anchor = top(&h); + h.run_steps(8); + assert_eq!(top(&h), at_anchor, "returning to the original click pauses scrolling"); + assert!(h.state().views[0].auto_scrolling(), "the toggle stays on at the anchor"); + ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views() { + use egui_kittest::kittest::Queryable; + for (organize, scale, zoom) in [(false, 1.0, "100"), (false, 2.0, "100"), (false, 1.0, "400"), (true, 1.0, "100"), (true, 2.0, "100")] { + for frames in [30, 60, 120, 144] { + let (mut h, c) = harness_pages(40); + h.set_pixels_per_point(scale); + h.state_mut().set_option("zoom", zoom).unwrap(); + h.state_mut().views[0].organize = organize; + h.run_steps(3); + assert_eq!(h.ctx.pixels_per_point(), scale); + let top = |h: &Harness<'static, PdfCraftApp>| { + if organize { h.get_by_label("Page 1").rect().top() } else { h.state().views[0].page_screen_rect(0).unwrap().top() } + }; + let step = |h: &mut Harness<'static, PdfCraftApp>| { + // Deliberately differ from the harness's predicted frame interval: scrolling + // must follow the elapsed time rather than the display's predicted rate. + h.input_mut().time = Some(h.ctx.input(|i| i.time) + 1.0 / f64::from(frames)); + h.step(); + }; + let p = start_autoscroll(&mut h, &c); + for (distance, expected) in [(16.0, 17.83), (50.0, 218.67)] { + ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + distance })); + step(&mut h); + step(&mut h); + let before = top(&h); + for _ in 0..frames { + step(&mut h); + } + let travelled = before - top(&h); + assert!( + (travelled - expected).abs() < 1.0, + "organize={organize}, scale={scale}, zoom={zoom}, fps={frames}, distance={distance}, travelled={travelled}" + ); + assert_eq!( + h.output().viewport_output[&egui::ViewportId::ROOT].repaint_delay, + std::time::Duration::ZERO, + "motion schedules the next display frame" + ); + } + ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 15.0 })); + step(&mut h); + let paused = top(&h); + for _ in 0..frames { + step(&mut h); + } + assert_eq!(top(&h), paused, "the dead zone pauses immediately, without coasting"); + assert!(h.state().views[0].auto_scrolling()); + ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y - 50.0 })); + step(&mut h); + step(&mut h); + let before = top(&h); + for _ in 0..frames { + step(&mut h); + } + assert!((top(&h) - before - 218.67).abs() < 1.0, "resuming above the anchor reverses direction"); + ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); + } + } +} + +#[cfg(target_os = "linux")] +#[test] +fn escape_stops_autoscroll_without_closing_find() { + let (mut h, c) = harness(); + h.state_mut().views[0].open_find(); + h.run_steps(3); + // Focus the canvas so the text field no longer owns keyboard input. + let p = h.state().views[0].viewport_rect().center(); + ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y })); + h.run_steps(2); + start_autoscroll(&mut h, &c); + ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); + assert!(!h.state().views[0].auto_scrolling()); + assert!(h.state().views[0].find.is_some(), "Escape cancels the scrolling gesture first"); +} + +#[cfg(target_os = "linux")] +#[test] +fn autoscroll_cancels_on_click_wheel_focus_loss_and_pointer_exit() { + let (mut h, c) = harness(); + let p = start_autoscroll(&mut h, &c); + ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y })); + assert!(!h.state().views[0].auto_scrolling()); + start_autoscroll(&mut h, &c); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -20.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(2); + assert!(!h.state().views[0].auto_scrolling()); + start_autoscroll(&mut h, &c); + h.input_mut().focused = false; + h.run_steps(2); + assert!(!h.state().views[0].auto_scrolling()); + h.input_mut().focused = true; + h.run_steps(2); + start_autoscroll(&mut h, &c); + h.event(egui::Event::PointerGone); + h.run_steps(2); + assert!(!h.state().views[0].auto_scrolling()); +} + +#[cfg(target_os = "linux")] +#[test] +fn autoscroll_is_scoped_to_the_active_view_and_cannot_start_under_a_dialog() { + let (mut h, c) = harness(); + let p = start_autoscroll(&mut h, &c); + h.state_mut().open_bytes("other.pdf", None, fixture(2)).unwrap(); + h.run_steps(2); + assert!(!h.state().views[0].auto_scrolling()); + h.state_mut().active = Some(0); + h.run_steps(2); + assert!(!h.state().views[0].auto_scrolling(), "returning to the tab must not resume"); + start_autoscroll(&mut h, &c); + h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::About); + h.run_steps(2); + assert!(!h.state().views[0].auto_scrolling()); + ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y, "button": "middle" })); + assert!(!h.state().views[0].auto_scrolling(), "a modal owns its input"); + assert!(call(&mut h, &c, "ui.move", json!({ "x": "bad", "y": 2 })).is_err()); + assert!(call(&mut h, &c, "ui.drag", json!({ "from": [1, 2], "to": [3, 4], "button": "bad" })).is_err()); +} + +#[cfg(target_os = "linux")] +#[test] +fn the_click_that_stops_autoscroll_preserves_the_page_selection() { + use egui_kittest::kittest::Queryable; + let (mut h, c) = harness_pages(40); + h.state_mut().execute("page.organize"); + h.state_mut().views[0].select_pages(&[0]); + h.run_steps(3); + let page_two = h.get_by_label("Page 2").rect().center(); + start_autoscroll(&mut h, &c); + ok(&mut h, &c, "ui.click", json!({ "x": page_two.x, "y": page_two.y })); + assert!(!h.state().views[0].auto_scrolling()); + assert_eq!(h.state().views[0].target_pages(), vec![0], "the cancelling press and release belong to autoscroll"); + // The next ordinary click still selects normally. + ok(&mut h, &c, "ui.click", json!({ "x": page_two.x, "y": page_two.y })); + assert_eq!(h.state().views[0].target_pages(), vec![1]); +} + +#[cfg(target_os = "linux")] +#[test] +fn organize_pages_supports_autoscroll_without_selecting_or_reordering_pages() { + use egui_kittest::kittest::Queryable; + let (mut h, c) = harness_pages(40); + h.state_mut().execute("page.organize"); + h.run_steps(3); + let top = h.get_by_label("Page 1").rect().top(); + let p = start_autoscroll(&mut h, &c); + ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 80.0 })); + h.run_steps(8); + assert!(h.get_by_label("Page 1").rect().top() < top - 30.0, "the organize grid scrolls"); + assert!(h.state().views[0].selected.is_empty()); + assert!(h.state().views[0].org_drag.is_none()); + ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); + // Holding the wheel over a page also must not create an organize drag. + ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y + 80.0], "button": "middle" })); + assert!(h.state().views[0].selected.is_empty()); + assert!(h.state().views[0].org_drag.is_none()); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); + assert!(h.state().views[0].auto_scrolling(), "releasing the wheel leaves the grid scrolling on"); + h.state_mut().views[0].organize = false; + h.run_steps(2); + assert!(!h.state().views[0].auto_scrolling(), "changing canvas mode cancels the gesture"); +} + +#[cfg(not(target_os = "linux"))] +#[test] +fn middle_button_input_does_not_start_custom_scrolling_outside_linux() { + for organize in [false, true] { + let (mut h, c) = harness_pages(40); + h.state_mut().views[0].organize = organize; + h.run_steps(3); + let p = h.state().views[0].viewport_rect().center(); + ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y, "button": "middle" })); + ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 50.0 })); + h.run_steps(8); + assert!(!h.state().views[0].auto_scrolling(), "custom scrolling must be Linux-only: organize={organize}"); + } +} + #[test] fn state_and_view_options() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index ad9df9b15c..665d1fdb6e 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -46,6 +46,41 @@ fn rect(h: &Harness<'static, PdfCraftApp>, page: usize) -> Option { h.state().views[0].page_screen_rect(page) } +#[cfg(target_os = "linux")] +#[test] +fn middle_button_scrolling_keeps_page_colours_in_both_themes() { + use egui_kittest::kittest::Queryable; + let _gpu = gpu(); + for theme in ["light", "dark"] { + for organize in [false, true] { + let mut h = harness(&[("zoom", "50")]); + h.state_mut().set_option("theme", theme).unwrap(); + h.state_mut().set_option("organize", if organize { "on" } else { "off" }).unwrap(); + for _ in 0..100 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let page = if organize { h.get_by_label("Page 1").rect() } else { rect(&h, 0).unwrap() }; + let at = page.center(); + let ppp = h.ctx.pixels_per_point(); + let before = *h.render().unwrap().get_pixel((at.x * ppp) as u32, (at.y * ppp) as u32); + assert_eq!(before, image::Rgba([255, 255, 255, 255]), "the synthetic page is white"); + let anchor = h.state().views[0].viewport_rect().center(); + h.event(egui::Event::PointerMoved(anchor)); + h.event(egui::Event::PointerButton { pos: anchor, button: egui::PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: anchor, button: egui::PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().views[0].auto_scrolling()); + let during = *h.render().unwrap().get_pixel((at.x * ppp) as u32, (at.y * ppp) as u32); + assert_eq!(during, before, "scrolling preserves page colours: theme={theme}, organize={organize}"); + } + } +} + #[test] fn continuous_layout_stacks_pages_vertically() { let mut h = harness(&[("layout", "continuous"), ("zoom", "50")]); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 53cba97cc4..b445b5ecad 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -887,11 +887,11 @@ title = "Automatic scrolling" area = "B" tier = "P2" milestone = "M3" -status = "planned" +status = "partial" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views"] +notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; other targets retain their existing middle-button input behavior. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented." [[feature]] id = "view.zoom-in-out"