diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3b9c7261a3..89af853c56 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b41ad4a0b8d7596cd0e9b731f7b418ad40ce1142213241d4a893419b3bb1bf2c" +sha256 = "0be99cfad8d84f76c8a46e2f8da8b2fd8d89d30cac34310198859c195eb5cea0" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "defdda2a59cfdffd539e5c8905f1b65e98f96dad89e48c843387482353629634" +sha256 = "37f3dc97e716eb9f9d115e0a6de47a3c9ec82897aace3fa0d0fb7b237224197b" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1137fb2a70a57314006cc245d413e44895815a1d65f42c1fb841fa2650de43d5" +sha256 = "accf181cff1f0ea86bb33528f6158d1aec18c9c3ff54de62dc758536a24cb677" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "17c70bf17a9230c73fdc872eb16e1877369c7a8ad718dc40739c40007b066201" +sha256 = "97777f7f3156f7ecb341dedfc612bf805e2dff8b85cc001d2f5b36a32d1d3b7a" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/ROADMAP.md b/ROADMAP.md index de7ebfad33..053edf61ef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,6 +127,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run Newest first. One line per session: the date, what moved, and the new overall percentage. +- **2026-10-08 (community, M3, #214):** In single-page view, when the whole page fits, the wheel turns pages: one per wheel notch, one per trackpad swipe including its momentum. The rail gets a Page display button between rotate and zoom with Acrobat's view choices (Fit to width scrolling, Fit one full page, Actual size, Zoom to page level, Fit visible, Read mode, Full screen) and the page layouts. Preferences gets Default page display and Default zoom, which every new tab uses. In continuous view, Fit page and Fit height fit the largest page, so mixed page sizes no longer jump while scrolling. Overall estimate unchanged (about 30–35%). + - **2026-10-08 (M14, Windows Chinese Auto):** Auto now reads the Windows display language after locale environment overrides, selecting Simplified or Traditional Chinese through the existing locale mapping. Added Chinese region/script and Windows query regressions. Chinese font delivery remains incomplete. Localization remains partial; overall ≈ 30–35% (unchanged). - **2026-10-08 (M3, themes):** Toolbar and Display theme now share System, Light and Dark preferences, one command path and persistent settings; the duplicate View toggle is removed. Effective colours track the OS only in System mode, with egui popup styles kept in sync. Overall estimate unchanged (about 30–35%). diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 3dd792fa2a..f4ced71bb8 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -6,7 +6,8 @@ //! View options (applied after the files open; also the seed of the UI control channel): //! `--page N --zoom 150 --layout continuous|two-up|single --panel comments|bookmarks|pages|fields|layers|attachments|none //! --theme light|dark|system --language auto| --mode all|read|edit|convert|sign --tool --left open|closed -//! --organize on --fields on --dialog properties|shortcuts|about --palette --home on` +//! --organize on --fields on --dialog properties|shortcuts|about --palette --home on +//! --cover on|off --default-layout continuous|two-up|single --default-zoom fit-width|fit-page|` //! //! `--control ` enables the UI control channel (off by default): the app listens on a random //! loopback port and writes `{"port", "token", "pid"}` to `` (owner-only permissions). diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 23ca8bf1fe..ae68cecca7 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -42,6 +42,9 @@ pub enum Needs { Undo, /// There is something to redo. Redo, + /// A document is open in two-page view. The engine has no views, so it checks the + /// document and the frontend checks the view. + TwoPageView, } /// A keyboard shortcut. `command` is ⌘ on macOS and Ctrl elsewhere. @@ -173,6 +176,15 @@ pub const COMMANDS: &[CommandSpec] = &[ c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"), c("edit.advanced_search", "Advanced search…", EDIT, Some(Shortcut::cmd_shift("F")), Document, "search"), c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"), + // Page display: View ▸ Page display and the rail's button list these as radios, so + // `menu = None` keeps them out of the generated menus. The palette still runs them. + c("view.layout.continuous", "Continuous scrolling", None, None, Document, "arrow-up-down"), + c("view.layout.single", "Single page", None, None, Document, "file-text"), + c("view.layout.two_up", "Two-page view", None, None, Document, "columns-2"), + c("view.layout.cover", "Show cover page in two-page view", None, None, TwoPageView, "bookmark"), + // Acrobat's view modes, a page display and a zoom at once (the rail's Page display menu). + c("view.fit_width_scrolling", "Fit to width scrolling", None, None, Document, "arrow-left-right"), + c("view.fit_one_page", "Fit one full page", None, None, Document, "maximize-2"), c("view.fit_visible", "Fit visible", VIEW, Some(Shortcut::cmd("3")), Document, "scan"), c("view.marquee_zoom", "Marquee zoom", VIEW, None, Document, "zoom-in"), c("edit.snapshot", "Take a snapshot", EDIT, None, Document, "camera"), @@ -333,7 +345,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) let doc = active.and_then(|id| session.get(id)); match spec.needs { Nothing => true, - Document => doc.is_some(), + Document | TwoPageView => doc.is_some(), Assembly => doc.is_some_and(|d| d.allows_assembly()), Modification => doc.is_some_and(|d| d.allows_modification()), Annotate => doc.is_some_and(|d| d.allows_annotation()), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f511e8ed5c..5f8ae5d353 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -47,6 +47,102 @@ pub enum PageLayout { Single, } +impl PageLayout { + /// Every page display, in the order the View menu, the rail's Page display button and + /// Preferences list them. + pub const ORDER: [Self; 3] = [Self::Continuous, Self::Single, Self::TwoUp]; + + /// The name settings and view options use: `continuous`, `single` or `two-up`. + pub fn as_str(self) -> &'static str { + match self { + Self::Continuous => "continuous", + Self::Single => "single", + Self::TwoUp => "two-up", + } + } + + /// The layout an [`as_str`](Self::as_str) name stands for, in any case. `None` for + /// anything else, so callers can reject a typo rather than switch layouts silently. + pub fn try_parse(value: &str) -> Option { + Self::ORDER.into_iter().find(|l| l.as_str().eq_ignore_ascii_case(value.trim())) + } + + /// Rail icon for this layout. + pub fn icon(self) -> &'static str { + match self { + Self::Continuous => "arrow-up-down", + Self::Single => "file-text", + Self::TwoUp => "columns-2", + } + } + + /// Registry command that switches to this layout (`PdfCraftApp::execute`). + pub fn command(self) -> &'static str { + match self { + Self::Continuous => "view.layout.continuous", + Self::Single => "view.layout.single", + Self::TwoUp => "view.layout.two_up", + } + } + + /// The layout a [`command`](Self::command) id switches to. + pub fn from_command(id: &str) -> Option { + Self::ORDER.into_iter().find(|l| l.command() == id) + } + + /// Menu label: the label of [`command`](Self::command), which the catalogs translate. + pub fn label(self) -> &'static str { + match self { + Self::Continuous => "Continuous scrolling", + Self::Single => "Single page", + Self::TwoUp => "Two-page view", + } + } +} + +/// How a newly opened document is shown (Preferences ▸ Documents and view). A PDF that asks +/// for its own layout or zoom gets that instead. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewDefaults { + pub layout: PageLayout, + /// [`Fit::Width`] or [`Fit::Page`], or [`Fit::None`] for a fixed `zoom`. + pub fit: Fit, + /// The zoom with [`Fit::None`] (1.0 = 100%). + pub zoom: f32, +} + +impl Default for ViewDefaults { + fn default() -> Self { + Self { layout: PageLayout::Continuous, fit: Fit::Width, zoom: 1.0 } + } +} + +impl ViewDefaults { + /// The zoom as settings and view options name it: `fit-width`, `fit-page` or a percentage. + pub fn zoom_name(&self) -> String { + match self.fit { + Fit::Page => "fit-page".into(), + Fit::None => format!("{}%", (self.zoom * 100.0).round()), + Fit::Width | Fit::Height => "fit-width".into(), + } + } + + /// These defaults with the zoom a [`zoom_name`](Self::zoom_name) names, in any case. `None` + /// for anything else, including a percentage outside the 8–6400% the view can show. + pub fn with_zoom(self, name: &str) -> Option { + let name = name.trim(); + let (fit, zoom) = if name.eq_ignore_ascii_case("fit-width") { + (Fit::Width, self.zoom) + } else if name.eq_ignore_ascii_case("fit-page") { + (Fit::Page, self.zoom) + } else { + let percent = name.strip_suffix('%').unwrap_or(name).trim().parse::().ok().filter(|p| (8.0..=6400.0).contains(p))?; + (Fit::None, percent / 100.0) + }; + Some(Self { fit, zoom, ..self }) + } +} + /// The find bar (⌘F): query, matches across the document, current match. #[derive(Default)] pub struct Find { @@ -133,6 +229,8 @@ 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)>, + /// Turns wheel input into page turns in single-page view. + wheel: crate::wheel_pager::WheelPager, pub(crate) auto_scroll: crate::autoscroll::AutoScroll, /// Pages selected in the organize grid (0-based). Empty means "the current page". pub selected: BTreeSet, @@ -215,12 +313,14 @@ impl DocView { v } - pub fn new(id: DocId, info: &DocInfo) -> Self { + /// A view of a newly opened document, shown as `defaults` say. Opening a file then applies + /// any layout or zoom the PDF itself asks for. + pub fn new(id: DocId, info: &DocInfo, defaults: ViewDefaults) -> Self { Self { id, - zoom: 1.0, - fit: Fit::Width, - layout: PageLayout::Continuous, + zoom: defaults.zoom, + fit: defaults.fit, + layout: defaults.layout, rotation: 0, current: 0, organize: false, @@ -252,6 +352,7 @@ impl DocView { screen_xforms: Vec::new(), viewport_screen: Rect::NOTHING, zoom_anchor: None, + wheel: Default::default(), auto_scroll: Default::default(), selected: BTreeSet::new(), select_anchor: None, @@ -502,6 +603,8 @@ impl DocView { } self.forward.clear(); } + // Partial wheel motion belongs to the old place. + self.wheel.clear_motion(); self.goto = Some((page, 0.0)); self.current = page; self.page_input = (page + 1).to_string(); @@ -580,6 +683,48 @@ impl DocView { self.goto = Some((self.current, 0.0)); } + /// Switch the zoom mode, keeping the current page in view. + pub fn set_fit(&mut self, fit: Fit) { + self.fit = fit; + self.goto = Some((self.current, 0.0)); + } + + /// Switch the page display. A no-op when the layout is already current, so re-selecting + /// it never yanks a scrolled view back to the top. + pub fn set_layout(&mut self, layout: PageLayout) { + if self.layout == layout { + return; + } + self.layout = layout; + self.goto = Some((self.current, 0.0)); + } + + /// Set the cover page in two-page view. A no-op unless something changed. + pub fn set_cover(&mut self, cover: bool) { + if self.cover != cover { + self.cover = cover; + self.goto = Some((self.current, 0.0)); + } + } + + /// Whether the cover page setting applies: only two-page view has a cover page. + pub fn cover_applies(&self) -> bool { + self.layout == PageLayout::TwoUp + } + + /// One wheel event in single-page view (the rules are in `wheel_pager`): `dy` is its + /// vertical delta (negative scrolls down) and `now` is egui time. With `can_turn` false + /// the gesture is followed but the page stays. Returns whether the page turned. + pub fn single_page_wheel(&mut self, unit: egui::MouseWheelUnit, dy: f32, phase: egui::TouchPhase, now: f64, can_turn: bool) -> bool { + if self.layout != PageLayout::Single { + return false; + } + let Some(forward) = self.wheel.feed(unit, dy, phase, now, can_turn) else { return false }; + let before = self.current; + self.step_page(forward); + self.current != before + } + /// 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) } @@ -692,21 +837,24 @@ impl DocView { } fn fit_zoom(&mut self, info: &DocInfo) { - let max_w = info.pages.iter().map(|p| self.display_size(p).0).fold(1.0, f32::max); + let largest = |side: fn((f32, f32)) -> f32| info.pages.iter().map(|p| side(self.display_size(p))).fold(1.0, f32::max); + let max_w = largest(|s| s.0); + // Single-page view fits the page it shows. The scrolling views fit their largest page, + // so the zoom holds still while pages of other sizes scroll past. + let (w, h) = match self.layout { + PageLayout::Single => info.pages.get(self.current).map_or_else(|| (max_w, largest(|s| s.1)), |p| self.display_size(p)), + PageLayout::Continuous | PageLayout::TwoUp => (max_w, largest(|s| s.1)), + }; let avail_w = (self.viewport_w - 2.0 * SIDE).max(100.0); let per_row = if self.layout == PageLayout::TwoUp { 2.0 } else { 1.0 }; match self.fit { Fit::Width => self.zoom = (avail_w - GAP * (per_row - 1.0)) / (max_w * PT * per_row), Fit::Page => { - let (w, h) = self.display_size(&info.pages[self.current.min(info.pages.len() - 1)]); let zw = (avail_w - GAP * (per_row - 1.0)) / (w * PT * per_row); let zh = (self.viewport_h - 2.0 * MARGIN) / (h * PT); self.zoom = zw.min(zh); } - Fit::Height => { - let (_, h) = self.display_size(&info.pages[self.current.min(info.pages.len() - 1)]); - self.zoom = (self.viewport_h - 2.0 * MARGIN) / (h * PT); - } + Fit::Height => self.zoom = (self.viewport_h - 2.0 * MARGIN) / (h * PT), Fit::None => {} } self.zoom = self.zoom.clamp(0.08, 64.0); @@ -943,9 +1091,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), None => {} } + // No dialog, close prompt or palette over the page: only then does page input count. + let unobstructed = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open; if view.organize { - 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); + organize_grid(view, info, &doc.renderer, doc.allows_assembly(), unobstructed, ui, &t); return; } @@ -964,7 +1113,7 @@ 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 { + let auto_delta = if unobstructed { view.auto_scroll.update(ui, avail, false) } else { view.auto_scroll.cancel(); @@ -977,6 +1126,32 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { * if view.layout == PageLayout::TwoUp { 2.0 } else { 1.0 }; let content_w = (max_w + 2.0 * SIDE).max(avail.width()); let rects = view.layout(info, content_w); + let middle_gesture = view.auto_scroll.blocks_input(); + // Single page: when the whole page fits, the wheel would do nothing, so it turns pages + // instead; zoomed in far enough to pan, it pans. 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. + if view.layout == PageLayout::Single { + // Within a point, so layout rounding can't stop a fitting page from turning. + let fits = rects.get(view.current.min(rects.len().saturating_sub(1))).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); + // 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| { + for e in &i.events { + 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 can_turn { + // 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); + } + } let visible_pages: Vec = match view.layout { PageLayout::Single => vec![view.current.min(rects.len() - 1)], _ => (0..rects.len()).collect(), @@ -989,7 +1164,6 @@ 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 middle_gesture { egui::scroll_area::DragScroll::Never @@ -2436,3 +2610,100 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable view.go_to_page(p); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn view(pages: usize, layout: PageLayout) -> DocView { + let info = pdfcraft_render::DocInfo { + pages: (0..pages) + .map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }) + .collect(), + ..Default::default() + }; + DocView::new(DocId(1), &info, ViewDefaults { layout, ..Default::default() }) + } + + /// One mouse-wheel notch (a line) at `at` seconds. + fn notch(v: &mut DocView, dy: f32, at: f64) -> bool { + v.single_page_wheel(egui::MouseWheelUnit::Line, dy, egui::TouchPhase::Move, at, true) + } + + #[test] + fn notches_turn_pages_and_stop_at_the_ends() { + let mut v = view(3, PageLayout::Single); + assert!(notch(&mut v, -1.0, 1.0) && notch(&mut v, -1.0, 1.05)); + assert_eq!(v.current, 2); + assert!(!notch(&mut v, -1.0, 1.1), "nothing turns past the last page"); + assert!(notch(&mut v, 1.0, 1.2)); + assert_eq!(v.current, 1); + } + + #[test] + fn wheel_is_single_page_only() { + for layout in [PageLayout::Continuous, PageLayout::TwoUp] { + let mut v = view(3, layout); + assert!(!notch(&mut v, -1.0, 1.0), "{layout:?} ignores the wheel"); + assert_eq!(v.current, 0); + } + } + + #[test] + fn navigation_drops_partial_wheel_motion() { + use egui::{MouseWheelUnit::Point, TouchPhase::Move, TouchPhase::Start}; + let mut v = view(3, PageLayout::Single); + assert!(!v.single_page_wheel(Point, 0.0, Start, 1.0, true)); + assert!(!v.single_page_wheel(Point, -30.0, Move, 1.02, true)); + v.go_to_page(1); + // The same touch has to move a full notch's worth again before it turns. + assert!(!v.single_page_wheel(Point, -30.0, Move, 1.04, true)); + assert!(v.single_page_wheel(Point, -30.0, Move, 1.06, true)); + assert_eq!(v.current, 2); + } + + #[test] + fn reselecting_the_layout_does_not_scroll_to_top() { + let mut v = view(3, PageLayout::Continuous); + v.goto = None; + v.set_layout(PageLayout::Continuous); + assert!(v.goto.is_none(), "re-selecting the current layout is a no-op"); + v.set_layout(PageLayout::Single); + assert_eq!(v.goto, Some((0, 0.0))); + } + + #[test] + fn fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past() { + let page = + |width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 }; + let info = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 800.0)], ..Default::default() }; + for fit in [Fit::Page, Fit::Height] { + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + (v.fit, v.viewport_w, v.viewport_h) = (fit, 1000.0, 800.0); + v.fit_zoom(&info); + let zoom = v.zoom; + v.current = 1; + v.fit_zoom(&info); + assert_eq!(v.zoom, zoom, "{fit:?}: scrolling onto a bigger page keeps the zoom"); + // Single-page view shows one page at a time, so it fits each one. + v.layout = PageLayout::Single; + v.fit_zoom(&info); + let big = v.zoom; + v.current = 0; + v.fit_zoom(&info); + assert!(v.zoom > big, "{fit:?}: single-page view fits the page it shows"); + } + } + + #[test] + fn layouts_match_their_commands_and_names() { + for l in PageLayout::ORDER { + let spec = pdfcraft_engine::commands::command(l.command()).expect("registered"); + assert_eq!((l.label(), l.icon()), (spec.label, spec.icon), "{l:?}"); + assert_eq!(PageLayout::from_command(l.command()), Some(l)); + assert_eq!(PageLayout::try_parse(l.as_str()), Some(l)); + } + assert_eq!(PageLayout::try_parse(" Two-Up "), Some(PageLayout::TwoUp)); + assert_eq!(PageLayout::try_parse("facing"), None); + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 465dae9054..a045866498 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -2,7 +2,7 @@ use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; -use crate::canvas::{Fit, PageLayout}; +use crate::canvas::{DocView, Fit, PageLayout}; use crate::theme::{self, ThemePreference, Tokens}; use crate::{Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, icons, widgets}; @@ -210,18 +210,8 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { v.view_history(true); } ui.separator(); - ui.label(egui::RichText::new(tl!("Page display")).color(t.text_faint).small()); - for (l, label) in - [(PageLayout::Continuous, "Continuous scrolling"), (PageLayout::TwoUp, "Two-page view"), (PageLayout::Single, "Single page")] - { - if ui.radio(v.layout == l, tl!(label)).clicked() { - v.layout = l; - v.goto = Some((v.current, 0.0)); - } - } - if ui.add_enabled(v.layout == PageLayout::TwoUp, egui::Checkbox::new(&mut v.cover, tl!("Show cover page in two-page view"))).changed() - { - v.goto = Some((v.current, 0.0)); + if let Some(id) = page_display_menu(ui, &app.views[i]) { + app.execute(id); } ui.separator(); } @@ -295,6 +285,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { // Page navigation cluster at the bottom (as in Acrobat's rail). let view = &mut app.views[index]; + let mut page_display = None; ui.with_layout(Layout::bottom_up(Align::Center), |ui| { ui.spacing_mut().item_spacing.y = 2.0; if icons::button(ui, "zoom-out", 32.0, false, tl!("Zoom out (⌘−)")).clicked() { @@ -303,10 +294,20 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if icons::button(ui, "zoom-in", 32.0, false, tl!("Zoom in (⌘+)")).clicked() { view.zoom_step(true); } + // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. + let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); + let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); + page_display = egui::Popup::menu(&resp) + .show(|ui| { + ui.set_min_width(220.0); + rail_view_menu(ui, view) + }) + .and_then(|r| r.inner); if icons::button(ui, "rotate-cw", 32.0, false, tl!("Rotate view clockwise (⇧⌘+)")).clicked() { view.rotate_view(true); } - let fit_icon = if view.fit == Fit::Width { "maximize" } else { "columns-2" }; + // Not `columns-2` while fitting the page: that is the two-page view's icon. + let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; view.goto = Some((view.current, 0.0)); @@ -341,5 +342,64 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } }); + if let Some(id) = page_display { + app.execute(id); + } }); } + +/// View ▸ Page display and the rail's Page display menu: the layouts and the cover page. +/// Returns the command a click asks for, for the caller to run. +fn page_display_menu(ui: &mut egui::Ui, view: &DocView) -> Option<&'static str> { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("Page display")).color(t.text_faint).small()); + let mut picked = None; + for l in PageLayout::ORDER { + if ui.radio(view.layout == l, tl!(l.label())).clicked() { + picked = Some(l.command()); + } + } + let mut cover = view.cover; + if ui.add_enabled(view.cover_applies(), egui::Checkbox::new(&mut cover, tl!("Show cover page in two-page view"))).changed() { + picked = Some("view.layout.cover"); + } + if picked.is_some() { + ui.close(); + } + picked +} + +/// The rail's Page display menu: Acrobat's view choices around the page display. Zoom changes +/// apply at once; the command a click asks for is returned for the caller to run. +fn rail_view_menu(ui: &mut egui::Ui, view: &mut DocView) -> Option<&'static str> { + let mut picked = None; + for (id, label) in [("view.fit_width_scrolling", "Fit to width scrolling"), ("view.fit_one_page", "Fit one full page")] { + if ui.button(tl!(label)).clicked() { + picked = Some(id); + } + } + ui.separator(); + if ui.button(tl!("Actual size")).clicked() { + view.set_zoom(1.0); + ui.close(); + } + if ui.button(tl!("Zoom to page level")).clicked() { + view.set_fit(Fit::Page); + ui.close(); + } + if ui.button(tl!("Fit visible")).clicked() { + picked = Some("view.fit_visible"); + } + ui.separator(); + picked = page_display_menu(ui, view).or(picked); + ui.separator(); + for (id, label) in [("view.read_mode", "Read mode"), ("view.full_screen", "Full screen mode")] { + if ui.button(tl!(label)).clicked() { + picked = Some(id); + } + } + if picked.is_some() { + ui.close(); + } + picked +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index db033bd6fb..34a86cc557 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -14,8 +14,11 @@ use crate::{ }; impl PdfCraftApp { + /// Whether a registered command can run now. The engine judges the document (security, + /// contents, undo history); view state it can't see is checked here. pub(crate) fn command_enabled(&self, spec: &CommandSpec) -> bool { commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) + && (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies)) } /// Run a registered command by id. Returns `false` when the id is unknown or the command @@ -63,6 +66,7 @@ impl PdfCraftApp { commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { tl!("The document's security settings don't allow this change").to_string() } + commands::Needs::TwoPageView if self.active.is_some() => tl!("Switch to two-page view first to show the cover page").to_string(), _ => tl!("Open a document first").to_string(), }; self.notify(why); @@ -120,6 +124,25 @@ impl PdfCraftApp { } } "view.palette" => self.palette_open = !self.palette_open, + layout if crate::canvas::PageLayout::from_command(layout).is_some() => { + if let (Some(i), Some(layout)) = (active, crate::canvas::PageLayout::from_command(layout)) { + self.views[i].set_layout(layout); + } + } + "view.layout.cover" => { + if let Some(i) = active { + let v = &mut self.views[i]; + v.set_cover(!v.cover); + } + } + "view.fit_width_scrolling" | "view.fit_one_page" => { + use crate::canvas::{Fit, PageLayout}; + let (layout, fit) = if id == "view.fit_one_page" { (PageLayout::Single, Fit::Page) } else { (PageLayout::Continuous, Fit::Width) }; + if let Some(i) = active { + self.views[i].set_layout(layout); + self.views[i].set_fit(fit); + } + } "view.full_screen" => { let on = !self.full_screen; match self.ctx.clone() { diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 2ea75cb8a0..7e0c094241 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -120,7 +120,7 @@ impl PdfCraftApp { let bytes = created?; let id = self.session.open_new(name, bytes).map_err(|e| e.to_string())?; let info = &self.session.get(id).ok_or("the new document could not be opened")?.info; - self.views.push(crate::DocView::new(id, info)); + self.views.push(crate::DocView::new(id, info, self.view_defaults)); self.active = Some(self.views.len() - 1); Ok(()) } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 9966d496b3..a12061da82 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -364,7 +364,7 @@ impl PdfCraftApp { match self.session.open_new(name, bytes) { Ok(id) => { let Some(doc) = self.session.get(id) else { return }; - self.views.push(crate::DocView::new(id, &doc.info)); + self.views.push(crate::DocView::new(id, &doc.info, self.view_defaults)); self.active = Some(self.views.len() - 1); self.notify_tr(message); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 13ff814a0b..2f70d40f80 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -92,3 +92,15 @@ Check for updates… Zkontrolovat aktualizace… About PdfCraft O aplikaci PdfCraft Auto Automaticky + Continuous scrolling Plynulé posouvání + Single page Jednostránkové zobrazení + Two-page view Dvoustránkové zobrazení + Show cover page in two-page view Zobrazit titulní stránku ve dvoustránkovém zobrazení + Page display Zobrazení stránek + Page display: {layout} Zobrazení stránek: {layout} + Default page display Výchozí zobrazení stránek + Default zoom Výchozí zvětšení + Custom Vlastní + Fit to width scrolling Plynulé posouvání s přizpůsobením šířce + Fit one full page Celá jedna stránka + Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index aecd4969b6..b106176cee 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1867,3 +1867,7 @@ {n} measurements use an unsupported format and are left out. {n} 件の測定は未対応の形式のため除外されました。 This area has too many overlapping paths; intersection snapping is limited. この領域は重なるパスが多すぎるため、交点へのスナップを制限します。 This measurement needs at least {n} points. この測定には少なくとも{n}点が必要です。 + Switch to two-page view first to show the cover page 表紙を表示するには、先に見開き表示に切り替えてください + Page display: {layout} ページ表示:{layout} + Fit to width scrolling 幅に合わせてスクロール + Fit one full page 1ページずつ全体を表示 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0a8bb0120d..9b12288b6b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1858,14 +1858,21 @@ Change {what} 更改{what} Cut and stack 裁切后堆叠 Default workspace mode 默认工作区模式 + Default page display 默认页面显示 + Default zoom 默认缩放 Documents and view 文档与视图 + Fit one full page 单页完整显示 + Fit to width scrolling 适合宽度连续滚动 Open email app 打开邮件应用 Open link 打开链接 Open this web page? 是否打开此网页? + Page display: {layout} 页面显示:{layout} Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. 脚本在沙盒中运行,无法访问文件或网络。脚本请求打开网页时,需要先获得您的许可。关闭 JavaScript 后,Acrobat 的标准格式、验证和计算函数仍可使用。 Sign yourself 自行签名 + Switch to two-page view first to show the cover page 请先切换到双页视图,才能显示封面页 Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). 尚未安装文本识别功能:缺少模型(请运行 `cargo xtask models` 或设置 PDFCRAFT_MODELS)。 Used when opening PDFs. An explicit launch or control mode takes precedence for the session. 用于打开 PDF 时。如果启动参数或控制通道明确指定了模式,则本次会话优先使用该模式。 + Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF 未指定布局或缩放时使用。连续滚动时页面不会逐页吸附。 Write this email? 是否撰写此邮件? {who} in this document wants to open this address in your browser: 此文档中的{who}请求在浏览器中打开以下地址: {who} in this document wants to open your email app with this address: 此文档中的{who}请求打开邮件应用并使用以下地址: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e502b78d35..3f0717de7b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1860,3 +1860,7 @@ {n} measurements use an unsupported format and are left out. {n} 個測量使用了不支援的格式,已被排除。 This area has too many overlapping paths; intersection snapping is limited. 此區域有太多重疊路徑;交點吸附受到限制。 This measurement needs at least {n} points. 此測量至少需要 {n} 個點。 + Switch to two-page view first to show the cover page 請先切換到雙頁檢視,才能顯示封面 + Page display: {layout} 頁面顯示:{layout} + Fit to width scrolling 符合寬度連續捲動 + Fit one full page 單頁完整顯示 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c3058b685d..543af1db08 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -70,4 +70,11 @@ mod tests { } } } + + #[test] + fn every_command_icon_exists() { + for c in pdfcraft_engine::commands::COMMANDS { + assert!(super::exists(c.icon), "missing icon {} ({})", c.icon, c.id); + } + } } diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 34210b3924..eb72fa0550 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -267,6 +267,30 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok .small() .color(t.text_muted), ); + let defaults = &mut app.view_defaults; + ui.horizontal(|ui| { + ui.label(tl!("Default page display")); + for l in crate::canvas::PageLayout::ORDER { + ui.radio_value(&mut defaults.layout, l, tl!(l.label())); + } + }); + ui.horizontal(|ui| { + use crate::canvas::Fit; + ui.label(tl!("Default zoom")); + ui.radio_value(&mut defaults.fit, Fit::Width, tl!("Fit to width")); + ui.radio_value(&mut defaults.fit, Fit::Page, tl!("Zoom to page level")); + ui.radio_value(&mut defaults.fit, Fit::None, tl!("Custom")); + // Editing the percentage selects Custom. + let mut percent = defaults.zoom * 100.0; + if ui.add(egui::DragValue::new(&mut percent).range(8.0..=6400.0).max_decimals(0).suffix("%")).changed() { + (defaults.fit, defaults.zoom) = (Fit::None, percent / 100.0); + } + }); + ui.label( + egui::RichText::new(tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages.")) + .small() + .color(t.text_muted), + ); ui.add_space(8.0); // Identity: the author of new comments (Acrobat: Preferences ▸ Identity). ui.label(egui::RichText::new(tl!("Identity")).font(theme::semibold(13.0))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1213d86a24..0583abe43d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -77,6 +77,7 @@ mod protect; mod recovery; pub mod theme; pub mod updates; +mod wheel_pager; mod widgets; use pdfcraft_engine::{DocId, Session}; @@ -319,6 +320,10 @@ pub struct PdfCraftApp { pub mode: Mode, /// Workspace used for newly opened PDFs; independent of PDF Initial View metadata. pub default_mode: Mode, + /// Page display and zoom for newly opened PDFs that don't ask for their own (Preferences ▸ + /// Documents and view). Continuous scrolling at fit width by default, which never snaps + /// between pages. + pub view_defaults: canvas::ViewDefaults, /// Explicit CLI/control mode lasts for this session and is never persisted. mode_override: Option, pub left: LeftPanel, @@ -540,6 +545,7 @@ impl PdfCraftApp { active: None, mode: Mode::AllTools, default_mode: Mode::AllTools, + view_defaults: Default::default(), mode_override: None, left: LeftPanel::AllTools, left_open: true, @@ -669,6 +675,7 @@ impl PdfCraftApp { } let view = &mut self.views[index]; match v.layout { + // The view opened in Default page display already. L::Default => {} L::SinglePage => view.layout = canvas::PageLayout::Single, L::SinglePageContinuous => view.layout = canvas::PageLayout::Continuous, @@ -679,6 +686,7 @@ impl PdfCraftApp { } } match v.magnification { + // The view opened at the default zoom already. M::Default => {} M::ActualSize => view.set_zoom(1.0), M::Percent(p) => view.set_zoom((p / 100.0) as f32), @@ -726,7 +734,7 @@ impl PdfCraftApp { }; } let initial = doc.initial_view(); - self.views.push(DocView::new(id, &doc.info)); + self.views.push(DocView::new(id, &doc.info, self.view_defaults)); self.active = Some(self.views.len() - 1); self.apply_initial_view(self.views.len() - 1, &initial); if let Some(mode) = self.mode_override { @@ -1010,6 +1018,8 @@ impl PdfCraftApp { "recent": self.recent, "theme": self.theme_preference, "default_mode": self.default_mode, + "default_layout": self.view_defaults.layout.as_str(), + "default_zoom": self.view_defaults.zoom_name(), "language": self.language, "author": self.comment_prefs.author, // Drawn signatures keep their original form (older settings read the same). @@ -1041,6 +1051,12 @@ impl PdfCraftApp { if let Ok(mode) = serde_json::from_value::(v["default_mode"].clone()) { self.default_mode = mode; } + if let Some(layout) = v["default_layout"].as_str().and_then(canvas::PageLayout::try_parse) { + self.view_defaults.layout = layout; + } + if let Some(defaults) = v["default_zoom"].as_str().and_then(|zoom| self.view_defaults.with_zoom(zoom)) { + self.view_defaults = defaults; + } if let Some(language) = v["language"].as_str().and_then(i18n::normalize_pref) { self.language = language.to_string(); } @@ -1180,12 +1196,24 @@ impl PdfCraftApp { } ("page", Some(v)) => v.go_to_page(value.parse::().map_err(|e| e.to_string())?.saturating_sub(1)), ("zoom", Some(v)) => v.set_zoom(value.trim_end_matches('%').parse::().map_err(|e| e.to_string())? / 100.0), - ("layout", Some(v)) => { - v.layout = match value { - "two-up" => canvas::PageLayout::TwoUp, - "single" => canvas::PageLayout::Single, - _ => canvas::PageLayout::Continuous, + ("layout", Some(v)) => v.set_layout(canvas::PageLayout::try_parse(value).ok_or("layout must be continuous, single or two-up")?), + ("cover", Some(v)) => { + let on = match value { + "on" => true, + "off" => false, + _ => return Err("cover must be on or off".into()), + }; + if on && !v.cover_applies() { + return Err("switch to two-page view first to show the cover page".into()); } + v.set_cover(on); + } + ("default-layout", _) => { + self.view_defaults.layout = canvas::PageLayout::try_parse(value).ok_or("default-layout must be continuous, single or two-up")?; + } + ("default-zoom", _) => { + self.view_defaults = + self.view_defaults.with_zoom(value).ok_or("default-zoom must be fit-width, fit-page or a percentage from 8 to 6400")?; } ("organize", Some(v)) => v.organize = value != "off", ("rotate", Some(v)) => { @@ -1274,7 +1302,7 @@ impl PdfCraftApp { v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1))); v.comments.reveal = true; } - (k, None) if ["page", "zoom", "layout", "organize", "fields", "find", "rotate", "select", "notice", "comment"].contains(&k) => { + (k, None) if ["page", "zoom", "layout", "cover", "organize", "fields", "find", "rotate", "select", "notice", "comment"].contains(&k) => { return Err(format!("`{k}` needs an open document")); } (other, _) => return Err(format!("unknown option {other}")), diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 4baf7ad0d8..d7b5f80270 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -328,7 +328,7 @@ impl PdfCraftApp { match self.session.open_revision(id, n) { Ok(new) => { let Some(doc) = self.session.get(new) else { return }; - self.views.push(DocView::new(new, &doc.info)); + self.views.push(DocView::new(new, &doc.info, self.view_defaults)); self.active = Some(self.views.len() - 1); } Err(e) => self.notify_fmt("Couldn't open revision {n}: {e}", &[("n", &n.to_string()), ("e", &e.to_string())]), @@ -344,7 +344,7 @@ impl PdfCraftApp { match self.session.open(format!("{name}.pdf"), None, std::sync::Arc::new(bytes), doc.password.clone().as_deref()) { Ok(new) => { let Some(doc) = self.session.get(new) else { return }; - self.views.push(DocView::new(new, &doc.info)); + self.views.push(DocView::new(new, &doc.info, self.view_defaults)); self.active = Some(self.views.len() - 1); } Err(e) => self.notify_fmt("Couldn't open the signed version: {e}", &[("e", &e.to_string())]), diff --git a/crates/ui-egui/src/wheel_pager.rs b/crates/ui-egui/src/wheel_pager.rs new file mode 100644 index 0000000000..423ac1d0d2 --- /dev/null +++ b/crates/ui-egui/src/wheel_pager.rs @@ -0,0 +1,295 @@ +//! Wheel paging in single-page view: when the whole page fits, the mouse wheel and the +//! trackpad turn pages instead of doing nothing. +//! +//! A wheel notch turns one page, however fast the notches come. That needs the raw +//! `MouseWheel` events: egui's smoothed scroll delta spreads one notch over several frames, so +//! notches in quick succession blur together. Smooth input (trackpads, high-resolution wheels) +//! turns one page per gesture and direction once it has moved as far as a notch scrolls. A +//! gesture is a touch from [`TouchPhase::Start`] to `End`, together with the momentum phase +//! the OS sends after it (macOS and Wayland report touch phases), or, without phases, a burst +//! of input with no pause longer than `BURST_GAP_SECS`. + +use egui::{MouseWheelUnit, TouchPhase}; + +/// How far one wheel notch scrolls, in points (egui's native `line_scroll_speed`). Smooth +/// input turns a page once it has moved this far, so a light swipe turns and resting fingers +/// don't; smooth line deltas count this many points per line. +const NOTCH_POINTS: f32 = 40.0; +/// A phase-less point delta at least this big is a wheel notch: egui draws the same line +/// between wheel steps, which it smooths, and trackpad input, which it passes through. +const MIN_NOTCH_POINTS: f32 = 8.0; +/// Phase-less smooth input belongs to one gesture until it pauses this long, in seconds (egui +/// uses the same gap to decide that such a scroll has ended). +const BURST_GAP_SECS: f64 = 0.15; +/// A big point delta this soon (seconds) after another wheel event belongs to a fast trackpad +/// stream, not to a separate notch. +const DENSE_GAP_SECS: f64 = 0.02; +/// The OS starts its momentum phase this soon (seconds) after the finger lifts, so a touch +/// starting sooner continues the swipe rather than starting another. +const MOMENTUM_GAP_SECS: f64 = 0.1; +/// A touch with no events for this long (seconds) has ended unseen: its `End` went elsewhere. +const TOUCH_STALE_SECS: f64 = 1.0; + +/// The touch in progress. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Touch { + Finger, + /// The momentum phase after a finger touch: still the same swipe. + Momentum, +} + +/// Turns wheel and trackpad input into page turns; see the module docs. +pub(crate) struct WheelPager { + /// Smooth motion toward the next turn, in points (negative scrolls forward). + accum: f32, + /// The way the current gesture already turned (`true` = forward). + turned: Option, + /// The touch in progress, on platforms that report touch phases. + touch: Option, + /// When the last finger touch ended, so its momentum phase can be recognised. + finger_up: Option, + /// egui time of the last wheel event. + last_event: f64, + /// egui time of the last smooth (non-notch) motion. + last_smooth: f64, +} + +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 } + } +} + +impl WheelPager { + /// Drop partial motion toward a turn (the view moved elsewhere). The gesture goes on: a + /// swipe that already turned doesn't turn again after a jump. + pub(crate) fn clear_motion(&mut self) { + self.accum = 0.0; + } + + /// 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 { + if !now.is_finite() { + return None; + } + let idle = now - self.last_event; + self.last_event = now; + if idle > TOUCH_STALE_SECS { + self.touch = None; + } + match phase { + TouchPhase::Start => { + let momentum = self.finger_up.take().is_some_and(|up| now - up <= MOMENTUM_GAP_SECS); + self.touch = Some(if momentum { Touch::Momentum } else { Touch::Finger }); + if !momentum { + self.new_gesture(); + } + None + } + TouchPhase::End | TouchPhase::Cancel => { + // Only a finger touch is followed by momentum. + self.finger_up = (self.touch == Some(Touch::Finger)).then_some(now); + self.touch = None; + None + } + TouchPhase::Move => self.motion(unit, dy, now, idle, can_turn), + } + } + + fn new_gesture(&mut self) { + self.accum = 0.0; + self.turned = None; + } + + fn motion(&mut self, unit: MouseWheelUnit, dy: f32, now: f64, idle: f64, can_turn: bool) -> Option { + if !dy.is_finite() || dy == 0.0 { + return None; + } + let forward = dy < 0.0; + let smooth_stream = self.touch.is_some() || now - self.last_smooth <= BURST_GAP_SECS; + let notch = !smooth_stream + && match unit { + // macOS reports precise devices in points, so its line deltas are wheel notches, + // however small its acceleration makes them. Elsewhere a fraction of a line is + // touchpad motion. + MouseWheelUnit::Line => cfg!(target_os = "macos") || dy.abs() >= 1.0, + MouseWheelUnit::Page => true, + MouseWheelUnit::Point => dy.abs() >= MIN_NOTCH_POINTS && idle >= DENSE_GAP_SECS, + }; + if notch { + if !can_turn { + return None; + } + // Every notch turns a page; a smooth tail of the same gesture won't add another. + self.accum = 0.0; + self.turned = Some(forward); + return Some(forward); + } + if self.touch.is_none() && idle > BURST_GAP_SECS { + self.new_gesture(); + } + self.last_smooth = now; + if !can_turn { + // Panning a page that doesn't fit: that motion must not count toward a turn later. + self.accum = 0.0; + return None; + } + if self.turned == Some(forward) { + return None; + } + let points = if unit == MouseWheelUnit::Point { dy } else { dy * NOTCH_POINTS }; + // One event never counts for more than a turn, which also bounds hostile deltas. Net + // motion counts, so trackpad jitter cancels out. + self.accum += points.clamp(-NOTCH_POINTS, NOTCH_POINTS); + if self.accum.abs() < NOTCH_POINTS { + return None; + } + self.accum = 0.0; + self.turned = Some(forward); + Some(forward) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use MouseWheelUnit::{Line, Point}; + use TouchPhase::{End, Move, Start}; + + /// Feeds `(unit, dy, phase, seconds)` events and returns the turns, in order. + fn turns(pager: &mut WheelPager, events: &[(MouseWheelUnit, f32, TouchPhase, f64)]) -> Vec { + events.iter().filter_map(|&(unit, dy, phase, at)| pager.feed(unit, dy, phase, at, true)).collect() + } + + /// A trackpad swipe at 60 fps from `t`: six finger frames of `dy`, then momentum decaying + /// to nothing, as macOS reports it (each phase its own Start…End). + fn swipe(t: f64, dy: f32) -> Vec<(MouseWheelUnit, f32, TouchPhase, f64)> { + let frame = 1.0 / 60.0; + let mut events = vec![(Point, 0.0, Start, t)]; + let mut at = t; + for _ in 0..6 { + at += frame; + events.push((Point, dy, Move, at)); + } + events.extend([(Point, 0.0, End, at), (Point, 0.0, Start, at)]); + let mut d = dy; + while d.abs() > 0.5 { + at += frame; + events.push((Point, d, Move, at)); + d *= 0.92; + } + events.push((Point, 0.0, End, at)); + events + } + + #[test] + fn every_notch_turns_one_page_however_fast() { + let mut p = WheelPager::default(); + // A brisk spin: five notches 50 ms apart, then one back. + let mut events: Vec<_> = (0..5).map(|i| (Line, -1.0, Move, 1.0 + f64::from(i) * 0.05)).collect(); + events.push((Line, 1.0, Move, 1.5)); + assert_eq!(turns(&mut p, &events), [true, true, true, true, true, false]); + // Pixel-unit wheels (a Logitech mouse on a Mac reports ~14 points a notch). + assert_eq!(turns(&mut p, &[(Point, -14.0, Move, 3.0), (Point, -14.0, Move, 3.1)]), [true, true]); + } + + #[test] + fn a_swipe_turns_one_page_momentum_included() { + let mut p = WheelPager::default(); + assert_eq!(turns(&mut p, &swipe(1.0, -25.0)), [true]); + // A slow, steady scroll is one gesture too. + let mut slow = vec![(Point, 0.0, Start, 3.0)]; + slow.extend((1..=60).map(|i| (Point, -3.0, Move, 3.0 + f64::from(i) / 60.0))); + slow.push((Point, 0.0, End, 4.0)); + assert_eq!(turns(&mut p, &slow), [true]); + // Resting fingers that drift a little don't turn. + let nudge: Vec<_> = [(Point, 0.0, Start, 6.0)].into_iter().chain((1..=5).map(|i| (Point, -2.0, Move, 6.0 + f64::from(i) / 60.0))).collect(); + assert!(turns(&mut p, &nudge).is_empty()); + } + + #[test] + fn each_new_swipe_turns_again_even_during_momentum() { + let mut p = WheelPager::default(); + let mut events = swipe(1.0, -25.0); + // Touching the trackpad again ends the momentum at once (End) and starts a new finger + // touch: the first swipe's momentum is over, so the second swipe turns. + let cut = events.iter().position(|e| e.3 > 1.3).unwrap(); + events.truncate(cut); + let t = events.last().unwrap().3; + events.push((Point, 0.0, End, t)); + events.extend(swipe(t, -25.0)); + assert_eq!(turns(&mut p, &events), [true, true]); + } + + #[test] + fn reversing_mid_gesture_turns_back_but_jitter_does_not() { + let mut p = WheelPager::default(); + let mut events = vec![(Point, 0.0, Start, 1.0), (Point, -45.0, Move, 1.02)]; + // Jitter right after the turn neither turns back nor re-arms the forward turn. + events.extend([(Point, 2.0, Move, 1.04), (Point, -20.0, Move, 1.06), (Point, -20.0, Move, 1.08)]); + // A deliberate push the other way turns back, once. + events.extend([(Point, 25.0, Move, 1.10), (Point, 25.0, Move, 1.12), (Point, 25.0, Move, 1.14)]); + assert_eq!(turns(&mut p, &events), [true, false]); + } + + #[test] + fn phase_less_streams_turn_once_per_burst() { + // Web and some touchpads send no touch phases: small dense deltas, possibly big ones + // in a fast flick, all one gesture until the input pauses. + let mut p = WheelPager::default(); + let mut burst: Vec<_> = (0..30).map(|i| (Point, -5.0, Move, 1.0 + f64::from(i) * 0.008)).collect(); + burst.extend((0..10).map(|i| (Point, -30.0, Move, 1.3 + f64::from(i) * 0.008))); + assert_eq!(turns(&mut p, &burst), [true]); + // A burst that starts big is one gesture too. + let fast: Vec<_> = (0..20).map(|i| (Point, -30.0, Move, 3.0 + f64::from(i) * 0.008)).collect(); + assert_eq!(turns(&mut p, &fast), [true]); + // After a pause, the next burst turns again. + let next: Vec<_> = (0..20).map(|i| (Point, -5.0, Move, 4.0 + f64::from(i) * 0.008)).collect(); + assert_eq!(turns(&mut p, &next), [true]); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn fractional_lines_are_touchpad_motion_off_macos() { + // Windows and X11 touchpads report many small line deltas. + let mut p = WheelPager::default(); + let stream: Vec<_> = (0..40).map(|i| (Line, -0.1, Move, 1.0 + f64::from(i) * 0.008)).collect(); + assert_eq!(turns(&mut p, &stream), [true]); + } + + #[test] + fn nothing_turns_while_it_cannot_and_that_motion_does_not_count_later() { + let mut p = WheelPager::default(); + assert_eq!(p.feed(Line, -1.0, Move, 1.0, false), None); + assert_eq!(p.feed(Point, 0.0, Start, 2.0, false), None); + assert_eq!(p.feed(Point, -30.0, Move, 2.02, false), None); + // The page fits now: the same touch has to move a full notch's worth again. + assert_eq!(p.feed(Point, -30.0, Move, 2.04, true), None); + assert_eq!(p.feed(Point, -30.0, Move, 2.06, true), Some(true)); + } + + #[test] + fn a_touch_whose_end_was_missed_expires() { + let mut p = WheelPager::default(); + assert!(turns(&mut p, &[(Point, 0.0, Start, 1.0)]).is_empty()); + // Seconds later a wheel notch is a notch again, not part of that touch. + assert_eq!(turns(&mut p, &[(Line, -1.0, Move, 3.0)]), [true]); + } + + #[test] + fn hostile_input_never_turns_more_than_once() { + let mut p = WheelPager::default(); + for dy in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -0.0] { + assert_eq!(p.feed(Point, dy, Move, 1.0, true), None, "dy={dy}"); + } + for now in [f64::NAN, f64::INFINITY] { + assert_eq!(p.feed(Point, -50.0, Move, now, true), None, "now={now}"); + } + // A huge notch is still one notch; a huge smooth delta is still one turn. + assert_eq!(turns(&mut p, &[(Point, -1e30, Move, 2.0)]), [true]); + assert_eq!(turns(&mut p, &[(Point, 0.0, Start, 3.0), (Point, -1e30, Move, 3.01), (Point, -1e30, Move, 3.02)]), [true]); + } +} diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 290d8ffc3b..d012eaeddb 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -124,6 +124,10 @@ fn every_registered_command_is_implemented() { let p = pdfcraft_engine::Protection { open_password: Some("pw".into()), ..Default::default() }; app.apply_edit(pdfcraft_engine::Edit::Protect(p)); } + // The cover toggle needs two-page view first (it is disabled elsewhere). + if spec.id == "view.layout.cover" { + app.set_option("layout", "two-up").unwrap(); + } let dir = std::env::temp_dir().join(format!("pdfcraft-cmd-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); app.save_override = Some(dir.join("out.pdf").to_string_lossy().into_owned()); @@ -139,6 +143,9 @@ fn disabled_commands_explain_themselves() { app.open_bytes("doc.pdf", None, fixture(2)).unwrap(); assert!(!app.execute("edit.undo")); assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Nothing to undo")); + // View state counts too: the cover page exists only in two-page view. + assert!(!app.execute("view.layout.cover")); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Switch to two-page view first to show the cover page")); assert!(!app.execute("no.such.command")); } diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index ff5bcd4591..0c3f2c70b3 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -600,6 +600,20 @@ fn inspect_and_click_by_label_and_id() { assert!(call(&mut h, &c, "ui.click", json!({ "id": 12345 })).is_err()); } +#[test] +fn cover_page_command_needs_two_page_view() { + // Agents see the cover toggle as disabled, and get an error, until two-page view. + let (mut h, c) = harness(); + let enabled = |list: Value| list["commands"].as_array().unwrap().iter().find(|x| x["id"] == "view.layout.cover").unwrap()["enabled"].clone(); + assert_eq!(enabled(ok(&mut h, &c, "ui.commands", json!({}))), false); + let err = call(&mut h, &c, "ui.command", json!({ "id": "view.layout.cover" })).unwrap_err(); + assert!(err.contains("disabled"), "{err}"); + ok(&mut h, &c, "ui.command", json!({ "id": "view.layout.two_up" })); + assert_eq!(enabled(ok(&mut h, &c, "ui.commands", json!({}))), true); + ok(&mut h, &c, "ui.command", json!({ "id": "view.layout.cover" })); + assert!(h.state().views[0].cover); +} + #[test] fn commands_keys_and_typing() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 01728407f9..f5b81dda4f 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -197,7 +197,7 @@ fn organize_select_all_works_without_page_editing_permission() { assert_eq!(h.state().views[0].selected.len(), 1); // The opener refuses zero-page PDFs, but the view method also handles empty geometry. - let mut empty = pdfcraft_ui_egui::canvas::DocView::new(pdfcraft_engine::DocId(0), &Default::default()); + let mut empty = pdfcraft_ui_egui::canvas::DocView::new(pdfcraft_engine::DocId(0), &Default::default(), Default::default()); empty.organize = true; assert!(!empty.select_all()); assert!(empty.selected.is_empty()); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 665d1fdb6e..9ad5d1d105 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -1,6 +1,6 @@ //! Page layouts, zoom modes and link navigation, checked by where pages land on screen. -use egui::{Key, Modifiers}; +use egui::{Key, Modifiers, MouseWheelUnit, TouchPhase}; use egui_kittest::Harness; use pdfcraft_ui_egui::PdfCraftApp; @@ -28,7 +28,12 @@ fn gpu() -> std::sync::MutexGuard<'static, ()> { } fn harness(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + harness_stepping(1.0 / 4.0, options) +} + +/// [`harness`] with frames `step_dt` seconds apart (a quarter second is kittest's default). +fn harness_stepping(step_dt: f32, options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(step_dt).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.open_bytes("pages.pdf", None, PAGES.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); @@ -46,6 +51,11 @@ fn rect(h: &Harness<'static, PdfCraftApp>, page: usize) -> Option { h.state().views[0].page_screen_rect(page) } +/// A vertical wheel event; negative `dy` scrolls down. +fn wheel(unit: MouseWheelUnit, dy: f32, phase: TouchPhase) -> egui::Event { + egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase, modifiers: Modifiers::NONE } +} + #[cfg(target_os = "linux")] #[test] fn middle_button_scrolling_keeps_page_colours_in_both_themes() { @@ -111,6 +121,235 @@ fn single_page_layout_shows_one_page_at_a_time() { assert!(rect(&h, 0).is_none() && rect(&h, 1).is_some(), "only page 2"); } +#[test] +fn single_page_wheel_pans_instead_when_zoomed_in() { + // The zoomed-in guard: with room to pan, the wheel pans and must not turn pages. + let mut h = harness(&[("layout", "single"), ("zoom", "200")]); + h.run_steps(4); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(1); + h.event(wheel(MouseWheelUnit::Point, -60.0, TouchPhase::Move)); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 0, "zoomed-in wheel pans, it does not turn 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 + // page, its momentum included (macOS sends the momentum as a second Start…End). + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "50")]); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + for _ in 0..2 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + h.run_steps(5); + } + h.run_steps(30); + assert_eq!(h.state().views[0].current, 2, "two notches 80 ms apart turn two pages"); + let mut send = |phase, dy| { + h.event(wheel(MouseWheelUnit::Point, dy, phase)); + h.step(); + }; + send(TouchPhase::Start, 0.0); + for _ in 0..6 { + send(TouchPhase::Move, 25.0); + } + send(TouchPhase::End, 0.0); + send(TouchPhase::Start, 0.0); + let mut d = 25.0; + while d > 0.5 { + send(TouchPhase::Move, d); + d *= 0.92; + } + send(TouchPhase::End, 0.0); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 1, "one swipe back turns one page back"); +} + +#[test] +fn page_display_commands_switch_layouts() { + let mut h = harness(&[]); + h.state_mut().execute("view.layout.single"); + assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + // The cover toggle refuses outside two-page view instead of arming a hidden flag. + assert!(!h.state().views[0].cover); + assert!(!h.state_mut().execute("view.layout.cover")); + assert!(!h.state().views[0].cover); + h.state_mut().execute("view.layout.two_up"); + assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp); + assert!(h.state_mut().execute("view.layout.cover")); + assert!(h.state().views[0].cover, "the cover toggle flips"); + assert!(h.state_mut().execute("view.layout.cover")); + assert!(!h.state().views[0].cover, "toggling twice restores"); + h.state_mut().execute("view.layout.continuous"); + h.run_steps(3); + assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Continuous); +} + +#[test] +fn layout_option_rejects_typos() { + let mut h = harness(&[]); + assert!(h.state_mut().set_option("layout", "singel").is_err()); + assert!(h.state_mut().set_option("default-layout", "bogus").is_err()); + assert!(h.state_mut().set_option("cover", "maybe").is_err()); + assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Continuous); + assert!(h.state_mut().set_option("cover", "on").is_err(), "cover needs two-page view first"); + h.state_mut().set_option("layout", "two-up").unwrap(); + h.state_mut().set_option("cover", "on").unwrap(); + assert!(h.state().views[0].cover); + h.state_mut().set_option("cover", "off").unwrap(); + assert!(!h.state().views[0].cover); +} + +#[test] +fn view_options_without_a_document() { + let mut app = PdfCraftApp::new(); + assert!(app.set_option("default-layout", "single").is_ok(), "the default needs no document"); + assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + assert!(app.set_option("cover", "on").is_err(), "cover needs an open document"); + assert!(app.set_option("layout", "single").is_err(), "layout needs an open document"); +} + +#[test] +fn default_page_display_is_used_for_new_documents() { + use pdfcraft_ui_egui::canvas::PageLayout; + // The factory default is continuous scrolling without snap jumps. + assert_eq!(PdfCraftApp::new().view_defaults.layout, PageLayout::Continuous); + let mut h = harness(&[]); + h.state_mut().set_option("default-layout", "single").unwrap(); + h.state_mut().open_bytes("other.pdf", None, PAGES.to_vec()).expect("opens"); + assert_eq!(h.state().views[1].layout, PageLayout::Single); + // So do documents the app creates itself (blank, combined, extracted, from images…). + assert!(h.state_mut().execute("create.blank")); + assert_eq!(h.state().views[2].layout, PageLayout::Single); + // Persisted preferences survive a restart. + let saved = h.state().persist(); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&saved); + assert_eq!(fresh.view_defaults.layout, PageLayout::Single); + // Garbage keeps the previous default; casing is forgiven; legacy files lack the key. + fresh.restore(r#"{"default_layout":"bogus"}"#); + assert_eq!(fresh.view_defaults.layout, PageLayout::Single); + fresh.restore(r#"{"default_layout":"TWO-UP"}"#); + assert_eq!(fresh.view_defaults.layout, PageLayout::TwoUp); + let mut legacy = PdfCraftApp::new(); + legacy.restore("{}"); + assert_eq!(legacy.view_defaults.layout, PageLayout::Continuous); +} + +#[test] +fn default_zoom_is_used_for_new_documents() { + use pdfcraft_ui_egui::canvas::Fit; + let fit_zoom = |app: &PdfCraftApp, i: usize| (app.views[i].fit, app.views[i].zoom); + let mut h = harness(&[]); + // The factory default fits the width. + assert_eq!(h.state().views[0].fit, Fit::Width); + h.state_mut().set_option("default-zoom", "fit-page").unwrap(); + assert!(h.state_mut().execute("create.blank")); + assert_eq!(h.state().views[1].fit, Fit::Page); + h.state_mut().set_option("default-zoom", "150%").unwrap(); + h.state_mut().open_bytes("other.pdf", None, PAGES.to_vec()).expect("opens"); + assert_eq!(fit_zoom(h.state(), 2), (Fit::None, 1.5)); + // A PDF that asks for a zoom gets it. + let asks = String::from_utf8_lossy(PAGES).replace("/Pages 2 0 R >>", "/Pages 2 0 R /OpenAction [3 0 R /Fit] >>"); + h.state_mut().open_bytes("asks.pdf", None, asks.into_bytes()).expect("opens"); + assert_eq!(h.state().views[3].fit, Fit::Page); + // Typos and zooms the view can't show are refused. + for bad in ["huge", "fit-height", "5", "9000%"] { + assert!(h.state_mut().set_option("default-zoom", bad).is_err(), "{bad}"); + } + // Saved settings keep it, and garbage leaves it alone. + let saved = h.state().persist(); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&saved); + fresh.restore(r#"{"default_zoom":"bogus"}"#); + fresh.open_bytes("again.pdf", None, PAGES.to_vec()).expect("opens"); + assert_eq!(fit_zoom(&fresh, 0), (Fit::None, 1.5)); +} + +#[test] +fn preferences_set_the_default_zoom() { + use egui_kittest::kittest::Queryable; + let mut h = harness(&[]); + assert!(h.state_mut().execute("app.preferences")); + h.run_steps(3); + h.get_by_label("Zoom to page level").click(); + h.run_steps(2); + assert!(h.state_mut().execute("create.blank")); + assert_eq!(h.state().views[1].fit, pdfcraft_ui_egui::canvas::Fit::Page); +} + +#[test] +fn rail_page_display_menu_offers_acrobats_view_choices() { + use egui_kittest::kittest::Queryable; + use pdfcraft_ui_egui::canvas::{Fit, PageLayout}; + let mut h = harness(&[]); + h.run_steps(4); + let pick = |h: &mut Harness<'static, PdfCraftApp>, item: &str| { + h.get_by_label_contains("Page display:").click(); + h.run_steps(3); + h.get_by_label(item).click(); + h.run_steps(4); + }; + let shown = |h: &Harness<'static, PdfCraftApp>| (h.state().views[0].layout, h.state().views[0].fit); + pick(&mut h, "Fit one full page"); + assert_eq!(shown(&h), (PageLayout::Single, Fit::Page)); + // The whole page fits at once, so the wheel turns pages without zooming out first. + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(1); + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 1); + pick(&mut h, "Actual size"); + assert_eq!((h.state().views[0].fit, h.state().views[0].zoom), (Fit::None, 1.0)); + pick(&mut h, "Zoom to page level"); + assert_eq!(h.state().views[0].fit, Fit::Page); + pick(&mut h, "Fit to width scrolling"); + assert_eq!(shown(&h), (PageLayout::Continuous, Fit::Width)); +} + +#[test] +fn rail_page_display_button_switches_layouts() { + use egui_kittest::kittest::Queryable; + let mut h = harness(&[]); + h.run_steps(4); + h.get_by_label_contains("Page display").click(); + h.run_steps(3); + h.get_by_label("Single page").click(); + h.run_steps(4); + assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); +} + +#[test] +fn view_menu_page_display_uses_the_same_commands() { + use egui_kittest::kittest::Queryable; + let mut h = harness(&[]); + h.run_steps(4); + h.get_by_label("Menu").click(); + h.run_steps(2); + h.get_by_label("View ⏵").hover(); + h.run_steps(3); + h.get_by_label("Single page").click(); + h.run_steps(4); + assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); +} + +#[test] +fn palette_runs_page_display_commands_from_the_keyboard() { + use egui_kittest::kittest::Queryable; + let mut h = harness(&[]); + h.state_mut().set_option("palette", "single page").unwrap(); + h.run_steps(3); + h.get_by_label("Single page"); + h.key_press(Key::Enter); + h.run_steps(3); + assert!(!h.state().palette_open, "Enter runs the top hit and closes the palette"); + assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); +} + #[test] fn actual_size_fit_width_and_fit_page() { let mut h = harness(&[]); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7f696fa200..667e31f96c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -816,10 +816,10 @@ area = "B" tier = "P0" milestone = "M3" status = "shipped" -commands = [] +commands = ["view.layout.continuous"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically"] -notes = "" +evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past"] +notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes" [[feature]] id = "view.layout-single-page" @@ -828,10 +828,10 @@ area = "B" tier = "P0" milestone = "M3" status = "shipped" -commands = [] +commands = ["view.layout.single"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time"] -notes = "" +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" [[feature]] id = "view.layout-two-up" @@ -840,7 +840,7 @@ area = "B" tier = "P0" milestone = "M3" status = "shipped" -commands = [] +commands = ["view.layout.two_up"] tools = [] evidence = ["crates/ui-egui/tests/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/viewer.rs::two_page_view_steps_a_spread_at_a_time"] notes = "next/previous page step a spread at a time" @@ -864,10 +864,10 @@ area = "B" tier = "P1" milestone = "M3" status = "shipped" -commands = [] +commands = ["view.layout.cover"] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme"] -notes = "View ▸ Show cover page in two-page view" +evidence = ["crates/ui-egui/tests/viewer.rs::layouts_fit_height_labels_and_system_theme", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts"] +notes = "View ▸ Page display, the rail's Page display button or the palette; disabled outside two-page view" [[feature]] id = "view.page-gaps" @@ -936,10 +936,10 @@ area = "B" tier = "P0" milestone = "M3" status = "shipped" -commands = [] +commands = ["view.fit_one_page"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page"] -notes = "" +evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page", "crates/ui-egui/tests/view.rs::rail_page_display_menu_offers_acrobats_view_choices"] +notes = "View ▸ Zoom to page level (⌘0) and the rail's Page display menu, which also offers Fit one full page (single-page view at Fit page)" [[feature]] id = "view.zoom-fit-width" @@ -948,10 +948,10 @@ area = "B" tier = "P0" milestone = "M3" status = "shipped" -commands = [] +commands = ["view.fit_width_scrolling"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page"] -notes = "" +evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page", "crates/ui-egui/tests/view.rs::rail_page_display_menu_offers_acrobats_view_choices"] +notes = "View ▸ Fit to width (⌘2) and the rail's Page display menu's Fit to width scrolling (continuous scrolling at Fit width)" [[feature]] id = "view.zoom-fit-height" @@ -9683,11 +9683,11 @@ title = "Preferences: page display" area = "N" tier = "P1" milestone = "M14" -status = "planned" +status = "partial" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/view.rs::preferences_set_the_default_zoom"] +notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it. Missing: Acrobat's resolution, rendering and page-content options" [[feature]] id = "misc.prefs-documents"