diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index bd0b1d966c..8bc4d32f73 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1101,6 +1101,8 @@ mod tests { } } + /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest + /// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch. #[test] fn vendor_provenance_egui_wgpu_surface_fit_identity() { // Issue #577: egui-wgpu configured a window's surface at the window's size, and @@ -1167,6 +1169,8 @@ mod tests { assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); } + /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest + /// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch. #[test] fn vendor_provenance_egui_wgpu_catch_errors_identity() { // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37 @@ -1212,6 +1216,8 @@ mod tests { } } + /// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy + /// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded. #[test] fn winit_carries_the_windows_11_monitor_scale_fix() { // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another diff --git a/apps/pdfcraft/tests/it/vendor_graph.rs b/apps/pdfcraft/tests/it/vendor_graph.rs index 47c9fae1f3..a6a4d4b292 100644 --- a/apps/pdfcraft/tests/it/vendor_graph.rs +++ b/apps/pdfcraft/tests/it/vendor_graph.rs @@ -7,6 +7,7 @@ //! instead of treating the copies as the runtime source. The winit fork's own W17 tests own patch //! behaviour. +/// Return the workspace root above the PdfCraft app crate. fn root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") } @@ -43,6 +44,8 @@ fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) { assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} cannot stay a published path patch"); } +/// Regression test: the resolved window stack points at the reviewed forks and the retained vendor provenance +/// files remain. #[test] fn vendor_provenance_window_stack_graph_is_resolved() { assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc"); diff --git a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs index f61e322a64..b95058ba94 100644 --- a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs +++ b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs @@ -2,6 +2,8 @@ //! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. //! Retained vendor provenance for winit patch (8) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits text inserted outside a key press and leaves `selectedRange` +/// unmarked. #[test] fn vendor_provenance_winit_macos_text_inserted_outside_a_key_press() { // The macOS emoji picker inserts its text with `insertText` outside any key press and without diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs index c8856d6408..877f9e2a22 100644 --- a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs @@ -2,6 +2,8 @@ //! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. //! Retained vendor provenance for winit patch (6) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits a composition that ended without marked text and keeps the +/// marked-text underline. #[test] fn vendor_provenance_winit_macos_compositions_that_end_without_marked_text() { // winit 0.30.13 treats `insertText` as an IME commit only while marked text is left. Input diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs index e039e9fa65..89efbd3d81 100644 --- a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs @@ -2,6 +2,7 @@ //! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. //! Retained vendor provenance for winit patch (7) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: disallowing IME in the vendored winit discards the macOS input method's marked text. #[test] fn vendor_provenance_winit_discards_the_macos_composition_when_ime_is_disallowed() { // egui disallows IME when a text field loses focus. winit 0.30.13 then forgets the marked text diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs index e682c61209..f18dde0f2a 100644 --- a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs @@ -2,6 +2,7 @@ //! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. //! Retained vendor provenance for winit patch (10) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit retries the first Apple Korean key once so it starts a composition. #[test] fn vendor_provenance_winit_retries_the_first_apple_korean_key_once() { // On the first key in a freshly started app, Apple Korean can answer with `insertText` and the diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs index 5656e244d8..f5f2aada2f 100644 --- a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs @@ -2,6 +2,7 @@ //! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. //! Retained vendor provenance for winit patch (9) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit sends a key committed through macOS IME only once. #[test] fn vendor_provenance_winit_sends_a_key_committed_through_macos_ime_only_once() { // Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, diff --git a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs index ecf10b1616..5c8e892f25 100644 --- a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs +++ b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs @@ -2,6 +2,8 @@ //! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. //! Retained vendor provenance for winit patch (12) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit lets title-bar drags reach the app while the empty tab strip still +/// moves the window. #[test] fn vendor_provenance_winit_lets_macos_title_bar_drags_reach_the_app() { // On macOS the tab strip is the title bar: a transparent title bar over a full-size content diff --git a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs index d6c1ab80cd..8087219b21 100644 --- a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs @@ -18,6 +18,7 @@ fn egui_phase(phase: event::TouchPhase) -> TouchPhase { } } +/// Run three wheel frames through egui and return each frame's zoom and smooth-scroll deltas. fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { let ctx = egui::Context::default(); let mut previous = event::TouchPhase::Ended; @@ -43,6 +44,8 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { seen } +/// Regression test: releasing Ctrl after a pixel-wheel zoom makes the next wheel event scroll instead of +/// zooming. #[test] fn vendor_provenance_releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] { @@ -53,6 +56,7 @@ fn vendor_provenance_releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling( } } +/// Regression test: discrete wheels follow the modifiers of the current event, just like pixel wheels. #[test] fn vendor_provenance_discrete_wheels_still_follow_current_modifiers() { let seen = wheel_trace(AxisSource::Wheel, true); @@ -61,6 +65,7 @@ fn vendor_provenance_discrete_wheels_still_follow_current_modifiers() { assert!(seen[2].1.y > 0.0); } +/// Regression test: a finger gesture keeps its Ctrl zoom through momentum until the gesture ends. #[test] fn vendor_provenance_finger_gestures_keep_their_modifier_until_the_gesture_ends() { // A finger scroll's momentum may arrive after Ctrl is released. @@ -72,6 +77,7 @@ fn vendor_provenance_finger_gestures_keep_their_modifier_until_the_gesture_ends( assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started); } +/// Regression test: wheel sources without a phase keep the existing start, move and end policy. #[test] fn vendor_provenance_unknown_sources_keep_the_existing_phase_policy() { assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started); diff --git a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs index 4b6d0cbe1f..6469354cdc 100644 --- a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs @@ -2,6 +2,7 @@ //! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. //! Retained vendor provenance for winit patch (2) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit keeps a Wayland seat's text input until the seat itself is removed. #[test] fn vendor_provenance_winit_keeps_the_wayland_text_input_until_the_seat_goes() { // winit 0.30.13 destroys a Wayland seat's text input whenever the seat loses any capability (a diff --git a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs index 2bb39d2737..a8009591bf 100644 --- a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs +++ b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs @@ -2,6 +2,7 @@ //! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. //! Retained vendor provenance for winit patch (4) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit maximizes a hidden Windows window only when it is shown. #[test] fn vendor_provenance_winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() { // eframe creates PdfCraft's window hidden, restores the saved maximized state and shows the diff --git a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs index 10615b4c2f..1df11d8a68 100644 --- a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs +++ b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs @@ -2,6 +2,7 @@ //! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. //! Retained vendor provenance for winit patch (3) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit reads the Windows IME cursor only when the composition reports it. #[test] fn vendor_provenance_winit_reads_the_windows_ime_cursor_only_when_it_is_reported() { // winit 0.30.13 reads `GCS_CURSORPOS` for every composition without a target clause, whether diff --git a/apps/pdfcraft/tests/it/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs index a572ab07b6..a9bde6a1ee 100644 --- a/apps/pdfcraft/tests/it/winit_x11_dpi.rs +++ b/apps/pdfcraft/tests/it/winit_x11_dpi.rs @@ -1,5 +1,7 @@ //! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is //! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +/// Regression test: the vendored winit keeps the X11 server-DPI fallback and the desktop file does not +/// override scaling. #[test] fn vendor_provenance_winit_carries_the_x11_server_dpi_fallback() { // The behavior tests live beside the policy in winit (util::randr::tests). diff --git a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs index 50a810e2c7..61e5c8ebfa 100644 --- a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs +++ b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs @@ -2,6 +2,7 @@ //! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. //! Retained vendor provenance for winit patch (5) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit focuses the X11 input context it replaces. #[test] fn vendor_provenance_winit_focuses_the_x11_input_context_it_replaces() { // egui allows IME when a text field gains focus and disallows it when the field loses focus. diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 5e891edf9a..980c195709 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -12,6 +12,8 @@ use std::path::PathBuf; #[path = "src/cjk_scripts.rs"] mod cjk_scripts; +/// Build-script entry point: embed craft-fonts from `CRAFT_FONTS_DIR`, make a missing or invalid input fatal +/// when `CRAFT_FONTS_REQUIRED` is set, and write `craft_fonts.rs` to `OUT_DIR`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); @@ -135,6 +137,8 @@ fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool, stric mod manifest_tests { use super::*; + /// Regression test: a required native-font manifest must declare at least one Hans face, while optional + /// input accepts another script and a missing asset still fails. #[test] fn required_native_manifest_rejects_non_hans_faces_after_parsing() { let root = std::env::temp_dir().join(format!("pdfcraft-required-hans-{}", std::process::id())); diff --git a/crates/fonts/src/cjk_scripts.rs b/crates/fonts/src/cjk_scripts.rs index 6bec7cdff2..1058c6e61b 100644 --- a/crates/fonts/src/cjk_scripts.rs +++ b/crates/fonts/src/cjk_scripts.rs @@ -68,6 +68,8 @@ pub fn cjk_size_decision(family: &str, bytes: u64, wasm: bool, required: bool) - mod tests { use super::*; + /// Check that a required Chinese manifest needs an exact `Hans` script tag and does not accept + /// Traditional or region-suffixed tags. #[test] fn a_required_chinese_manifest_needs_an_exact_hans_entry() { assert!(has_script_tag("Hans,Latn", "Hans")); @@ -77,6 +79,8 @@ mod tests { assert!(!has_script_tag("Hani,Jpan,Kore", "Hans")); } + /// Check that a native Chinese face over its cap is rejected when required, skipped when optional, and + /// uses the web cap on the web. #[test] fn a_required_native_chinese_face_over_its_cap_is_rejected_not_skipped() { let at_cap = MAX_NATIVE_UI_CJK_BYTES; diff --git a/crates/ui-egui/src/actions_ui.rs b/crates/ui-egui/src/actions_ui.rs index 0ff61e590e..ddd8a0736c 100644 --- a/crates/ui-egui/src/actions_ui.rs +++ b/crates/ui-egui/src/actions_ui.rs @@ -301,6 +301,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool close } +/// Draw the custom-action editor: name, description and ordered steps; Save stays disabled until the name is +/// unique and non-empty and at least one step exists. fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let Some((original, a)) = app.wizard.editing.as_mut() else { return false }; ui.label(egui::RichText::new(if original.is_some() { tl!("Edit Action") } else { tl!("New Action") }).font(theme::semibold(18.0))); diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs index 323c07223f..1a1d14ebb5 100644 --- a/crates/ui-egui/src/bidi.rs +++ b/crates/ui-egui/src/bidi.rs @@ -521,6 +521,8 @@ mod tests { Line { text: text.to_owned(), rtl: false } } + /// Check that a Persian paragraph, including words joined by a zero-width non-joiner, reaches epaint as + /// one logical right-to-left run. #[test] fn persian_paragraph_keeps_logical_order_for_epaint() { // The line stays in reading order; epaint shapes and reorders it (see `on_screen`). @@ -529,6 +531,7 @@ mod tests { assert_eq!(wrap_at("کتاب‌ها", 80), [rtl("کتاب‌ها")]); } + /// Check that Arabic text wraps into lines in logical reading order rather than visual order. #[test] fn arabic_lines_break_in_reading_order() { // The first words go on the first line, in logical order for epaint to reorder. @@ -536,6 +539,8 @@ mod tests { assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 80), [rtl("واحد اثنين ثلاثة أربعة")]); } + /// Check that each paragraph takes its base direction from its first strong character, not from leading + /// numbers or neutral text. #[test] fn base_direction_comes_from_the_first_strong_character() { // Latin first: a left-to-right paragraph with an Arabic phrase inside it. @@ -549,6 +554,8 @@ mod tests { assert_eq!(wrap_at("واحد اثنين\nOK then\n\nשלום עולם", 80), [rtl("واحد اثنين"), ltr("OK then"), ltr(""), rtl("שלום עולם")]); } + /// Check that right-to-left punctuation and brackets stay in logical order for epaint to mirror when + /// displaying. #[test] fn rtl_punctuation_and_brackets() { // Logical text: epaint mirrors punctuation and brackets for display. @@ -557,6 +564,7 @@ mod tests { assert_eq!(wrap_at("آیا آماده است؟", 80), [rtl("آیا آماده است؟")]); } + /// Check that Persian and Arabic-Indic digits stay in logical order inside a right-to-left paragraph. #[test] fn persian_digits_go_to_the_shaper_reversed() { // Persian and Arabic-Indic digits stay in logical order; epaint's shaper preserves their diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cbc83a6d9e..d77c3fafd6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -234,6 +234,18 @@ pub struct Find { /// (page, glyph range) in document order. pub matches: Vec<(usize, Range)>, pub current: Option, + /// The page a search started from while its match is picked automatically: a text layer + /// that arrives out of order can still supply an earlier match, and the automatic choice + /// follows this page. Cleared when the user picks a match or moves the view themselves. + pub auto_from: Option, + /// The page the automatic choice last put the view on (the search's starting page until it + /// chose anything). Compared with the viewed page, it tells an automatic scroll from one + /// the reader made themselves; `None` with `auto_from` once the choice is the user's. + pub auto_page: Option, + /// The reader ended automatic following (by navigating, scrolling, or picking a match). + /// This is distinct from a search that never followed: a late match must be listed without + /// being revealed by scrolling, even when the reader moved before the first match arrived. + pub follow_ended: bool, pub focus: bool, pub case_query: String, /// Find options (Acrobat's: case-sensitive, whole words only). @@ -243,6 +255,51 @@ pub struct Find { pub in_panel: bool, } +/// What a text layer arriving after a search began may do with the automatic choice. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Follow { + /// The reader has not moved the view since the automatic choice: it may follow `from`. + Active(usize), + /// The reader navigated, scrolled or picked a match, so no automatic choice may reveal. + Ended, + /// No automatic choice is being followed (the search has not started, or the reader chose). + Never, +} + +impl Find { + /// The first match at or after `page`, or the first match when none follows (the search + /// wraps round the document). + fn first_at_or_after(&self, page: usize) -> Option { + self.matches.iter().position(|(p, _)| *p >= page).or_else(|| (!self.matches.is_empty()).then_some(0)) + } + + /// How a text layer arriving now relates to the automatic choice. Once the reader has moved + /// the view (page box, Pages panel, outline, view history or any scrolling), automatic + /// following ends: a later layer must not pull them back to an anchor-derived match. + /// `Ended` is deliberately distinct from `Never`, so a first match arriving after that move + /// is not revealed by a flash meant only for a search that is still following. + fn follow_page(&mut self, viewed: usize) -> Follow { + if self.follow_ended { + return Follow::Ended; + } + match (self.auto_from, self.auto_page) { + (Some(from), Some(page)) if page == viewed => Follow::Active(from), + (Some(_), _) | (_, Some(_)) => { + self.stop_following(); + Follow::Ended + } + (None, None) => Follow::Never, + } + } + + /// Ends automatic following: the selected match is now the user's choice to keep. + fn stop_following(&mut self) { + self.auto_from = None; + self.auto_page = None; + self.follow_ended = true; + } +} + /// A text selection on one page. #[derive(Clone, Copy, Debug, PartialEq)] struct Selection { @@ -1059,6 +1116,10 @@ impl DocView { self.last_queue.clear(); self.selection = None; self.all_text = false; + // A content change is not reader navigation: keep the automatic anchor so the + // re-extracted layers still follow it, and keep `follow_ended` so a reader who already + // took over stays in control. `document_changed` clamps `current`; if the view no longer + // sits where the automatic choice left it, `follow_page` ends it. if let Some(f) = self.find.as_mut() { f.matches.clear(); f.current = None; @@ -1249,6 +1310,16 @@ impl DocView { f.case_query = f.query.clone(); f.matches.clear(); f.current = None; + // Refining the query keeps the page the reader started searching from, even when an + // automatic choice has already scrolled them to a match. Only a view the reader moved + // themselves re-anchors the search on the page now shown. + let from = match (f.auto_from, f.auto_page) { + (Some(from), Some(page)) if !f.follow_ended && page == self.current => from, + _ => self.current, + }; + f.auto_from = Some(from); + f.auto_page = Some(self.current); + f.follow_ended = false; if f.case_query.trim().is_empty() { return; } @@ -1260,12 +1331,12 @@ impl DocView { } } f.matches.sort_by_key(|(p, r)| (*p, r.start)); - if !f.matches.is_empty() { - // First match at or after the page being viewed. - let cur = self.current; - let i = f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0); + // First match at or after the page the search started from. + if let Some(i) = f.first_at_or_after(from) + && let Some(&(p, _)) = f.matches.get(i) + { f.current = Some(i); - let (p, _) = f.matches[i]; + f.auto_page = Some(p); self.flash_match(p); } } @@ -1275,21 +1346,33 @@ impl DocView { if f.case_query.trim().is_empty() { return; } - let current_key = f.current.and_then(|c| f.matches.get(c).cloned()); + let previous = f.current.and_then(|c| f.matches.get(c).cloned()); + // An automatic choice follows the page it was anchored on, so a page whose text + // arrives late can still supply an earlier match. Once the reader has moved the view, + // this arrival must not pull them back; the choice then becomes theirs to keep. + let follow = f.follow_page(self.current); f.matches.retain(|(p, _)| *p != page); f.matches.extend(text.find_opts(&f.case_query, f.case_sensitive, f.whole_words).into_iter().map(|r| (page, r))); f.matches.sort_by_key(|(p, r)| (*p, r.start)); - f.current = current_key.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); - if f.current.is_none() && !f.matches.is_empty() { - // First match at or after the page being viewed. - let cur = self.current; - let i = f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0); - f.current = Some(i); - let (p, _) = f.matches[i]; - if current_key.is_none() { + if let Follow::Active(from) = follow { + f.current = f.first_at_or_after(from); + let selected = f.current.and_then(|i| f.matches.get(i).cloned()); + // Follow a changed automatic choice; the view is still where the last one left it. + if selected != previous + && let Some((p, _)) = selected + { + f.auto_page = Some(p); self.flash_match(p); } + return; } + // A match the user picked keeps its identity, exactly as before automatic following. + // When it is gone (or none was picked), the first match at or after the viewed page is + // selected for the panel, but the view stays put: `Ended` and `Never` have no automatic + // choice to reveal, and a reader who moved before the first layer arrived must not be + // pulled back by that late match. + let preserved = previous.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); + f.current = preserved.or_else(|| f.first_at_or_after(self.current)); } /// Go to match `i` (the Search panel's results). @@ -1297,6 +1380,7 @@ impl DocView { let Some(f) = self.find.as_mut() else { return }; let Some(&(p, _)) = f.matches.get(i) else { return }; f.current = Some(i); + f.stop_following(); self.go_to_page(p); self.goto_point = None; self.flash_match(p); @@ -1312,6 +1396,7 @@ impl DocView { let c = f.current.unwrap_or(0); let next = if forward { (c + 1) % n } else { (c + n - 1) % n }; f.current = Some(next); + f.stop_following(); let p = f.matches[next].0; // Only explicit search navigation joins history; background refresh uses flash_match alone. self.go_to_page(p); @@ -1365,8 +1450,18 @@ impl DocView { self.wheel_boost_y.reset(); } + /// Ends automatic search following after the reader navigated or scrolled the view. A + /// programmatic search flash never comes through here; it sets `goto` and `current` directly + /// so that its own scroll cannot be mistaken for the reader's. + fn end_find_following(&mut self) { + if let Some(f) = self.find.as_mut() { + f.stop_following(); + } + } + pub fn go_to_page(&mut self, page: usize) { let page = page.min(self.page_count.saturating_sub(1)); + self.end_find_following(); if page != self.current { // The view history (Previous view / Next view). if self.back.last() != Some(&self.current) { @@ -1528,6 +1623,11 @@ impl DocView { /// View ▸ Page navigation ▸ Previous view (`false`) / Next view (`true`). pub fn view_history(&mut self, forward: bool) -> bool { + let has_target = if forward { !self.forward.is_empty() } else { !self.back.is_empty() }; + if !has_target { + return false; + } + self.end_find_following(); let (from, to) = if forward { (&mut self.forward, &mut self.back) } else { (&mut self.back, &mut self.forward) }; let Some(page) = from.pop() else { return false }; to.push(self.current); @@ -2151,12 +2251,17 @@ impl DocView { log::warn!("page {} text: {}", r.request.page + 1, r.error.unwrap_or_default()); self.text_failed.insert(r.request.page); if let Some(f) = self.find.as_mut() { - let current_key = f.current.and_then(|c| f.matches.get(c).cloned()); + let previous = f.current.and_then(|c| f.matches.get(c).cloned()); + let follow = f.follow_page(self.current); f.matches.retain(|(p, _)| *p != r.request.page); - f.current = current_key.and_then(|k| f.matches.iter().position(|m| *m == k)); - if f.current.is_none() && !f.matches.is_empty() { - let cur = self.current; - f.current = Some(f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0)); + match follow { + // An automatic choice keeps following its anchor; a failure never + // flashes, so the view stays exactly where it was. + Follow::Active(from) => f.current = f.first_at_or_after(from), + Follow::Ended | Follow::Never => { + let preserved = previous.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); + f.current = preserved.or_else(|| f.first_at_or_after(self.current)); + } } } } @@ -2742,6 +2847,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // Keyboard scrolling applies to this frame's page view only, never later. let key_scroll = std::mem::take(&mut app.views[index].key_scroll); + if key_scroll != 0.0 { + app.views[index].end_find_following(); + } // The Search panel closed: its search moves to the find bar. let search_open = app.right == Some(crate::RightPanel::Search); if let Some(f) = app.views[index].find.as_mut() @@ -3042,6 +3150,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if view.fling.gliding() { ui.ctx().request_repaint(); } + // Any reader-owned motion ends automatic search following, even when it stays on the same + // page: the wheel, keyboard, middle-button auto-scroll, a drawing's edge scroll, or the + // momentum left by a touchpad swipe. The automatic flash uses `goto`, not these deltas, so + // its programmatic scroll is never mistaken for a reader action. + let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; + if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { + view.end_find_following(); + } // The wheel may have turned the page above: draw the row it turned to. Measured from the old // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. let row = shown_row(view); @@ -3091,6 +3207,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { ui.input(|i| i.pointer.delta()) }; + if moved != Vec2::ZERO { + view.end_find_following(); + } // Within the content, as egui keeps it: past an end the page would show there for a // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); @@ -4042,6 +4161,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { { let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); if dy != 0.0 { + view.end_find_following(); ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); // Keep scrolling while the pointer rests there and sends no events. ui.ctx().request_repaint(); @@ -4192,6 +4312,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; // pages with nothing on screen yet still queue. let scroll_offset = out.state.clamped_offset(); + // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas + // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag + // background. The output gives us the area id, and egui's pinned ScrollArea registers those + // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. + // Ask for their exact responses instead of treating any held drag as scrolling: a text + // selection, a panel splitter or another widget's drag must not excuse the programmatic jump + // a search flash makes, while a track click (which moves the offset with no pointer motion) + // must still count as the reader scrolling. + let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); + let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); + if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { + view.end_find_following(); + } let now_settle = ui.input(|i| i.time); if (scroll_offset - view.last_scroll).length() > 2.0 { view.pan_hold_until = now_settle + 0.25; @@ -6183,14 +6316,445 @@ trailer << /Root 1 0 R >> assert_eq!(v.goto, Some((3, 0.5))); assert!(v.back.is_empty()); assert_eq!(v.forward, vec![4]); - v.current = 0; v.find.as_mut().unwrap().current = None; - v.refresh_find_page(3); - assert_eq!(v.current, 3); - assert!(v.back.is_empty()); + v.texts.insert(1, Arc::new(find_test_text("match"))); + v.refresh_find_page(1); + assert_eq!(v.current, 1, "an automatic late match still follows its anchor"); + assert!(v.back.is_empty(), "automatic search updates do not record history"); assert_eq!(v.forward, vec![4]); } + /// One glyph per character on one line, for the search-order tests below. + fn find_test_text(s: &str) -> PageText { + PageText { + glyphs: s + .chars() + .map(|c| pdfcraft_render::TextGlyph { text: c.into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] }) + .collect(), + line_of: s.chars().map(|_| 0).collect(), + space_before: s.chars().map(|_| false).collect(), + } + } + + /// A text-layer result for `page`, for the search tests below. + fn find_text_result(page: usize, text: &str) -> pdfcraft_render::RenderedPage { + pdfcraft_render::RenderedPage { + request: RenderRequest { page, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }, + width: 0, + height: 0, + rgba: Default::default(), + error: None, + text: Some(Arc::new(find_test_text(text))), + warnings: Vec::new(), + millis: 0, + } + } + + /// Text layers arrive in whatever order the workers finish, not in page order. A search + /// started on page 1 must settle on page 1's match even when page 2's text arrives first, + /// instead of keeping the later match picked only because page 1 had not been read yet. + #[test] + fn automatic_search_match_follows_the_viewed_page_when_text_arrives_out_of_order() { + let mut v = view(2, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!(v.find.as_ref().unwrap().current, None, "no text yet, so no match"); + + let ctx = egui::Context::default(); + let request = |page| RenderRequest { page, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + let result = |page, text: &str| pdfcraft_render::RenderedPage { + request: request(page), + width: 0, + height: 0, + rgba: Default::default(), + error: None, + text: Some(Arc::new(find_test_text(text))), + warnings: Vec::new(), + millis: 0, + }; + v.last_queue = vec![request(1), request(0)]; + // Page 2's text finishes first, as it can when the workers race. + v.receive_result(&ctx, result(1, "Second page sample text")); + assert_eq!(v.find.as_ref().unwrap().current, Some(0), "the only known match is picked while page 1 is pending"); + + v.receive_result(&ctx, result(0, "Panel sample draft notes")); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![0, 1]); + assert_eq!(f.current, Some(0), "page 1's match takes over once its text arrives"); + assert_eq!(v.current, 0, "the view follows the first match at or after the page being viewed"); + } + + /// A match the user picked (a click in the Search panel, or ⌘G) is not moved by a text + /// layer that arrives later: only automatic choices follow the viewed page. + #[test] + fn a_user_picked_search_match_stays_put_when_earlier_text_arrives() { + let mut v = view(2, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + v.texts.insert(1, Arc::new(find_test_text("Second page sample text"))); + v.refresh_find_page(1); + v.go_to_match(0); + assert_eq!(v.find.as_ref().unwrap().auto_from, None, "an explicit choice is not automatic"); + + v.texts.insert(0, Arc::new(find_test_text("Panel sample draft notes"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![0, 1]); + assert_eq!(f.current, Some(1), "the picked match keeps its identity after page 1 is inserted"); + assert_eq!(v.current, 1, "the user's page does not change under them"); + } + + /// The automatic choice may move the view only while the reader has left it where that + /// choice put it. Navigating by the page box/Pages panel/outline or by view history is the + /// reader's decision: a text layer arriving afterwards must not pull them back to an + /// anchor-derived match. (The harness covers same-page wheel and pointer scrolling.) + #[test] + fn a_late_automatic_match_does_not_override_the_readers_navigation() { + enum Nav { + Page, + History, + } + for nav in [Nav::Page, Nav::History] { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(2, Arc::new(find_test_text("Second page sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (2, Some(2)), "the automatic match was shown"); + match nav { + // The page box, the Pages panel and outline destinations all end here. + Nav::Page => v.go_to_page(1), + // View ▸ Page navigation ▸ Previous view writes `current` directly. + Nav::History => { + v.back = vec![1]; + assert!(v.view_history(false)); + } + } + assert_eq!(v.current, 1); + v.texts.insert(0, Arc::new(find_test_text("First page sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, None, "the reader's navigation ends automatic following"); + assert_eq!(f.auto_page, None); + assert_eq!(v.current, 1, "a late match cannot pull the reader back"); + } + } + + /// Typing on while the results arrive must not re-anchor the search on a page the automatic + /// flash picked. The anchor stays the page the reader was on when they started searching. + #[test] + fn refining_the_query_keeps_the_page_the_search_started_from() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(2, Arc::new(find_test_text("Second page sample text"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_from), (2, Some(0)), "the first query's match scrolled the view"); + + // Page 1 has a match for the refined query, and so has page 3. Anchored on the page the + // automatic flash reached, the search would pick page 3; the starting page picks page 1. + v.texts.insert(0, Arc::new(find_test_text("Panel draft text"))); + v.find.as_mut().unwrap().query = "text".into(); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, Some(0), "refinement keeps the page the reader searched from"); + assert_eq!(f.matches.first().map(|(p, _)| *p), Some(0)); + assert_eq!(f.current, Some(0)); + assert_eq!(v.current, 0); + } + + /// ⌘G / Ctrl+G goes through `find_step`, an explicit choice like a Search-panel click: a + /// text layer arriving later must not undo it. + #[test] + fn keyboard_find_step_is_explicit_and_a_late_text_layer_cannot_undo_it() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.texts.insert(2, Arc::new(find_test_text("second sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_from), (1, Some(0))); + + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + i.events.push(egui::Event::Key { + key: egui::Key::G, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::COMMAND, + }); + }); + shortcuts(&mut v, &ctx, Default::default()); + + assert_eq!(v.current, 2, "the keyboard step chose the next match"); + assert_eq!(v.find.as_ref().unwrap().auto_from, None, "the keyboard step is the user's choice"); + assert_eq!(v.back, vec![1], "an explicit search jump records its origin"); + + v.texts.insert(0, Arc::new(find_test_text("intro sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, 2, "the chosen match keeps the view"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(2)); + } + + /// A page whose text extraction failed keeps the automatic anchor: the failure selects the + /// next match without moving the view, and a later text layer can still follow the anchor. + #[test] + fn a_failed_text_layer_keeps_the_automatic_anchor_without_jitter() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.texts.insert(2, Arc::new(find_test_text("second sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1))); + + let goto = v.goto; + let ctx = egui::Context::default(); + let request = RenderRequest { page: 1, kind: RequestKind::Text, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result( + &ctx, + pdfcraft_render::RenderedPage { + request, + width: 0, + height: 0, + rgba: Default::default(), + error: Some("extraction failed".into()), + text: None, + warnings: Vec::new(), + millis: 0, + }, + ); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![2]); + assert_eq!(f.current, Some(0), "the remaining match is selected"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(1)), "a failure does not end automatic following"); + assert_eq!(v.current, 1, "a failure never moves the view"); + assert_eq!(v.goto, goto, "a failure never flashes the match"); + + // The search still follows its original page when that page's text arrives. + v.last_queue = vec![RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + assert_eq!(v.current, 0, "the anchor's earlier match takes over"); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0)); + } + + /// A reader who navigated before a page's text failed is not pulled back by a later + /// matching layer: the failure path must preserve the same `Ended` state as a successful + /// one, and the result list may select the late match without revealing it. + #[test] + fn a_failed_text_layer_after_navigation_does_not_reveal_a_late_match() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + v.go_to_page(2); + let moved = v.goto; + assert!(v.find.as_ref().unwrap().follow_ended, "the reader navigated before any match arrived"); + + let ctx = egui::Context::default(); + let failed = RenderRequest { page: 1, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![failed]; + v.receive_result( + &ctx, + pdfcraft_render::RenderedPage { + request: failed, + width: 0, + height: 0, + rgba: Default::default(), + error: Some("extraction failed".into()), + text: None, + warnings: Vec::new(), + millis: 0, + }, + ); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a failure after navigation does not restart following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 2, "a failure never moves the view"); + assert_eq!(v.goto, moved); + + let arrived = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![arrived]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 2, "the late matching layer does not pull the reader back"); + assert_eq!(v.goto, moved, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the late match"); + } + + /// The reader can navigate before any text layer has supplied a match. That navigation + /// still ends automatic following, so the first late match is listed but not revealed by + /// a flash that would pull the reader back. + #[test] + fn navigation_before_the_first_match_is_not_overridden_by_a_late_text_layer() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current, None, "no text layer has arrived yet"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(0))); + + v.go_to_page(2); + assert_eq!(v.goto, Some((2, 0.0)), "the reader moved to page 3"); + assert!(v.find.as_ref().unwrap().follow_ended, "moving the view ends automatic following"); + + let ctx = egui::Context::default(); + let request = RenderRequest { page: 1, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(1, "late sample")); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, 2, "the late first match does not pull the reader back"); + assert_eq!(v.goto, Some((2, 0.0)), "no flash was issued"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(1), "the result list still selects the match"); + } + + /// A page-box jump to the page already shown is still the reader's navigation: automatic + /// following must end, so a late earlier match is listed without moving the view. (The + /// harness covers wheel, scrollbar and pointer scrolling through the production paint path.) + #[test] + fn a_same_page_page_box_jump_ends_automatic_following() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1)), "the automatic choice is on page 2"); + + // Choosing the page already shown still ends following; it stays on page 2 at its top. + v.go_to_page(1); + let shown = v.goto; + assert_eq!(shown, Some((1, 0.0))); + + v.texts.insert(0, Arc::new(find_test_text("intro sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 1, "the late earlier match does not move the reader within the page"); + assert_eq!(v.goto, shown, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(1), "the page-2 match stays selected"); + } + + /// A document-wide invalidation (a layer toggle, an edit, undo/redo) clears results for + /// re-extraction but is not the reader navigating. Automatic following keeps its anchor so + /// the re-extracted layers still reveal the automatic choice; a reader who already took + /// over stays in control. If `document_changed` clamps the view away from that anchor, the + /// next layer ends following instead of revealing a match from the stale page. + #[test] + fn content_invalidation_keeps_the_following_state() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1)), "the automatic choice is on page 2"); + + v.invalidate_content(); + let f = v.find.as_ref().unwrap(); + assert!(f.matches.is_empty() && f.current.is_none(), "results wait for the re-extracted text"); + assert!(!f.follow_ended, "a content change is not reader navigation"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(1))); + + // Re-extracted text still follows the page the search started from. + let ctx = egui::Context::default(); + let request = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + assert_eq!(v.current, 0, "the re-extracted match is revealed by the still-active search"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(0))); + + // A reader who navigated first keeps the view they chose across the invalidation. + v.go_to_page(2); + let moved = v.goto; + assert!(v.find.as_ref().unwrap().follow_ended); + v.invalidate_content(); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "late sample")); + assert_eq!(v.current, 2, "no delayed reveal after the reader navigated"); + assert_eq!(v.goto, moved, "no flash was issued"); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the late match"); + } + + /// A document edit can shrink the page count. `document_changed` clamps `current` but leaves + /// the automatic anchor's page index alone; a re-extracted layer must then end following + /// rather than reveal a match by scrolling from a page that no longer exists. + #[test] + fn a_page_count_shrink_ends_following_before_a_late_reveal() { + let mut v = view(5, PageLayout::Single, true); + v.current = 0; + v.texts.insert(4, Arc::new(find_test_text("last sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (4, Some(4)), "the automatic choice is on the last page"); + + let info = pdfcraft_render::DocInfo { + pages: (0..2) + .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() + }; + v.document_changed(&info); + assert_eq!(v.current, 1, "the view clamps to the last remaining page"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended, "the content change itself keeps the automatic state"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(4)), "the anchor still names the removed page"); + let goto = v.goto; + + let ctx = egui::Context::default(); + let request = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a layer for the remaining pages ends the stale follow"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 1, "the late layer cannot pull the view back"); + assert_eq!(v.goto, goto, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the match"); + } + + /// Typing refinements while text layers keep arriving must keep the page the search began + /// on, not re-anchor on whichever page an earlier automatic flash happened to show. + #[test] + fn rapid_query_edits_interleaved_with_text_results_keep_the_start_anchor() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sam".into(), ..Default::default() }); + v.rerun_find(); + assert!(v.find.as_ref().unwrap().current.is_none()); + + let edits = [("samp", 2, "Page three sample", 2), ("sampl", 1, "Page two sample", 1), ("sample", 0, "Page one sample", 0)]; + for (query, page, text, shown) in edits { + v.find.as_mut().unwrap().query = query.into(); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, Some(0), "refinement keeps the page the reader started from"); + assert!(!f.follow_ended); + v.texts.insert(page, Arc::new(find_test_text(text))); + v.refresh_find_page(page); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, shown, "the late page follows the automatic anchor {shown}"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(shown))); + assert!(!f.follow_ended, "the automatic flash itself does not end following"); + } + assert!(v.back.is_empty(), "automatic search updates do not record history"); + } + #[test] fn text_layers_evict_far_pages_but_keep_visible_and_matches() { let info = thumbnail_info(100); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e087e7aef0..5ea6c20881 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -26,6 +26,8 @@ const TAB_STRIP: f32 = 38.0; const TAB_HEIGHT: f32 = 30.0; const STRIP_BUTTON: f32 = 28.0; +/// Draw the window's tab strip: home and open controls, shrinkable and scrollable document tabs with drag +/// reordering, and platform window controls. pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // macOS: the strip is the 28 pt title bar, so the traffic lights are centred in it; tabs and @@ -411,6 +413,8 @@ fn tab_cap(natural: &[f32], budget: f32, gap: f32, min: f32) -> Option { None } +/// Draw one tab with its icon, elided label, close button and unsaved dot; records a close request and +/// returns the tab response for drag handling. #[allow(clippy::too_many_arguments)] fn tab( ui: &mut egui::Ui, @@ -632,6 +636,8 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } +/// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the +/// active view. pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some((index, id)) = app.active_ids() else { return }; diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 5392e04b46..e7f7f26a19 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -1395,6 +1395,8 @@ fn heading(ui: &mut egui::Ui, t: &Tokens, key: SortKey, sort: Option<(SortKey, b event } +/// Draw the Combine files table body: reorder handles, editable page ranges with validation, file metadata +/// and per-row warnings. #[allow(clippy::too_many_arguments)] fn table_body( app: &mut PdfCraftApp, diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index cf1399866b..bf9b5fe72c 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -2058,6 +2058,7 @@ pub enum CanvasAction { /// How many custom colours the "Recent colours" row keeps. const RECENT_CUSTOM: usize = 5; +/// Return the stable egui id for the recent custom-colour palette. fn recent_custom_id() -> egui::Id { egui::Id::unique("pdfcraft-recent-custom-colours") } diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index fcb1c7de21..54ca9e5152 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -292,6 +292,8 @@ pub(crate) fn show( edit } +/// Draw one comment thread card, including author, body or inline editor, replies, status and per-comment +/// actions; returns an edit to apply. #[allow(clippy::too_many_arguments)] fn card( ui: &mut egui::Ui, diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 410ca50778..68d219ff22 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -91,6 +91,7 @@ pub struct ContentView { } impl ContentView { + /// Forget an in-progress pointer grab for content editing. pub(crate) fn cancel_pointer_gesture(&mut self) { self.grab = None; } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 25f732d100..81d7b38c69 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -58,6 +58,7 @@ pub type Reply = Result; /// here uses a monotonic `web_time::Instant` so hidden logic cannot stall or restart it. const NOT_DRAWN_TIMEOUT: f64 = 5.0; +/// Return the deadline used to reject control requests that wait for a drawn window. fn timeout() -> std::time::Duration { std::time::Duration::from_secs_f64(NOT_DRAWN_TIMEOUT) } @@ -68,6 +69,7 @@ fn not_drawn(what: &str) -> String { ) } +/// Return the actionable error text for a control request whose window was not drawn in time. fn still_starting() -> String { format!("the app was still starting after {NOT_DRAWN_TIMEOUT:.0} s; the control request was not applied, retry when the window is drawn") } @@ -218,6 +220,8 @@ impl egui::Plugin for ControlPlugin { "pdfcraft-control" } + /// Inject admitted control batches into the current pass, dropping stale requests, applying cancellations + /// and scheduling repaints. fn input_hook(&mut self, ctx: &egui::Context, input: &mut egui::RawInput) { let Ok(mut s) = self.shared.lock() else { return }; expire_input(&mut s, web_time::Instant::now()); @@ -267,6 +271,8 @@ impl egui::Plugin for ControlPlugin { } } + /// Record presented passes and the accesskit tree and focus after a pass, so control replies observe only + /// drawn frames. fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) { let Ok(mut s) = self.shared.lock() else { return }; s.in_pass = false; @@ -322,6 +328,7 @@ struct Geometry { } impl Geometry { + /// Capture the current viewport size and effective pixels-per-point for the request epoch. fn current(ctx: &egui::Context, epoch: u64) -> Self { // Viewport info is refreshed by `run_logic`; the last UI pass's `viewport_rect` can be // stale while the window is hidden. Fall back to it only when the host cannot say. Only @@ -359,6 +366,8 @@ impl Geometry { } } +/// Return the byte size of an RGBA image of the given dimensions, or `None` when the multiplication +/// overflows. fn checked_image_bytes(width: usize, height: usize) -> Option { width.checked_mul(height)?.checked_mul(core::mem::size_of::()) } @@ -411,6 +420,7 @@ struct PendingCapture { } impl PendingCapture { + /// Convert a screenshot request into the pending form that later frames finish. fn into_pending(self) -> Pending { Pending::Screenshot { slot: self.slot, @@ -560,12 +570,19 @@ pub fn attach(ctx: &egui::Context) -> (Control, ControlClient) { /// What a request needs from the app (implemented by `PdfCraftApp`). pub(crate) trait Host { + /// Return the UI-state snapshot reported by `ui.state`. fn state(&self) -> Value; + /// Run one registered command by id and return its reply. fn command(&mut self, id: &str) -> Reply; + /// Apply or reset the panel docking layout from a control request. fn dock(&mut self, reset: bool, params: &Value) -> Reply; + /// List the commands available to the control channel. fn commands(&self) -> Value; + /// Set one application option from a control request. fn set(&mut self, key: &str, value: &str) -> Reply; + /// Open a document path requested through the control channel. fn open(&mut self, path: &str) -> Reply; + /// Request application exit; returns whether it closed without asking for confirmation. fn quit(&mut self, ctx: &egui::Context) -> bool; /// A generation that changes when the input target (document, dialog, focus) does. @@ -760,6 +777,7 @@ impl Control { } } + /// Return the total screenshot bytes reserved by pending captures without overflowing. fn reserved_bytes(&self) -> usize { self.pending .iter() @@ -767,6 +785,7 @@ impl Control { .fold(0usize, usize::saturating_add) } + /// Count the screenshot requests still waiting for a capture. fn pending_screenshots(&self) -> usize { self.pending.iter().filter(|p| matches!(p, Pending::Screenshot { .. })).count() } @@ -807,6 +826,8 @@ impl Control { self.pending.push(Pending::Screenshot { slot, callback: Some(callback), region, reply, since, geometry, reserved: bytes, attempts: 0 }); } + /// Resolve completed frame and screenshot requests while retaining those that still need a pass or a + /// retry. fn finish_pending(&mut self, ctx: &egui::Context, host: &mut impl Host) { let now = web_time::Instant::now(); let (dropped, expired, in_pass) = self.take_finished_shared(ctx, host, now); @@ -878,12 +899,15 @@ impl Control { Ok(vec![id]) } + /// Queue synthetic input batches and reply only after the requested number of frames has been presented. fn after_input(&self, batches: Vec>, value: Value, since: web_time::Instant) -> Result { let frames = batches.len() as u32; let batches = self.inject(batches, since)?; Ok(Handled::AfterInput { batches, frames, value }) } + /// Validate and dispatch one control method, rejecting unknown methods and malformed parameters with + /// actionable errors. fn handle(&mut self, ctx: &egui::Context, host: &mut impl Host, method: &str, p: &Value, since: web_time::Instant) -> Handled { let str_param = |k: &str| p.get(k).and_then(Value::as_str).ok_or_else(|| format!("{method}: missing string parameter {k}")); let r: Result = (|| match method { @@ -1042,6 +1066,7 @@ impl Control { self.after_input(frames, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] }), since) } + /// Click at a point, or on an accesskit widget by id or unique enabled label. fn click(&mut self, p: &Value, since: web_time::Instant) -> Result { if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { let point = |key: &str, n: f64| { @@ -1092,6 +1117,7 @@ impl Control { self.after_input(frames, json!({ "clicked": id.0.to_string(), "label": label }), since) } + /// Focus an accesskit widget by id or unique enabled label. fn focus(&mut self, p: &Value, since: web_time::Instant) -> Result { only_params(p, "ui.focus", &["id", "label"])?; let s = self.shared.lock().map_err(|_| "control state poisoned")?; @@ -1318,6 +1344,7 @@ fn screenshot_png(image: &egui::ColorImage, region: Option, geometry } impl Host for crate::PdfCraftApp { + /// Cancel any in-progress pointer gesture in every open view. fn cancel_pointer_gestures(&mut self) { for view in &mut self.views { view.cancel_pointer_gestures(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d310a1682b..b3437246a0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -21,6 +21,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { unlock(app, ctx); } +/// Draw the active modal dialog and apply the action the user chose. fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { password(app, ctx); save_prompt(app, ctx); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 8ca9c9403b..5f94540e0b 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -366,6 +366,8 @@ fn saved_split_path(saved: &Layout, rendered: &Layout, path: &[boo None } +/// Render the docking workspace, restoring or reporting a broken saved layout, and apply the panel actions it +/// emits. pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let mut workspace = std::mem::take(&mut app.docking); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 046dd937b6..4f5630c0b9 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -472,6 +472,7 @@ pub struct ImageSelection { } impl ImageSelection { + /// Cancel an in-progress text, rotation or crop drag and restore the crop it was previewing. pub(crate) fn cancel_pointer_gesture(&mut self) { self.drag = None; self.turning = false; diff --git a/crates/ui-egui/src/fling.rs b/crates/ui-egui/src/fling.rs index 0ffd18ee14..b5eafe69d1 100644 --- a/crates/ui-egui/src/fling.rs +++ b/crates/ui-egui/src/fling.rs @@ -219,6 +219,7 @@ mod tests { assert!(!fling.gliding(), "the finger rested before it lifted"); } + /// Check that new finger or wheel input stops an in-progress glide. #[test] fn new_input_stops_a_glide() { let mut fling = Fling::new(true); @@ -239,6 +240,7 @@ mod tests { assert!(!fling.gliding(), "a wheel notch"); } + /// Check that disabled flinging, a false glide flag, and a command-modified zoom gesture prevent glide. #[test] fn no_glide_where_it_is_off_or_not_wanted() { let mut off = Fling::new(false); diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index d5f868ad60..bfabf701e6 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -18,6 +18,7 @@ pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Pre } } +/// Draw the Home page with the welcome text, community card and recent documents. pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 1117adabdf..0cbb4ab23b 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -261,6 +261,8 @@ impl LangInfo { (0..=1000).map(self.plural).max().unwrap_or(0).saturating_add(1) } + /// Return the parsed catalogue, building it on first use and warning about entries that fall back to + /// English. fn catalog(&self) -> &Catalog { self.catalog.get_or_init(|| { let (catalog, errors) = Catalog::parse(self.source, self.plural_forms()); @@ -766,6 +768,8 @@ mod tests { set_current(previous); } + /// Regression test: a language picker uses its native name only when a face can draw it, otherwise its + /// English name. #[test] fn picker_names_fall_back_to_english_when_their_script_has_no_face() { let hi = Lang::from_code("hi").expect("hi registered"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f34fecc1af..20481650c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1092,6 +1092,7 @@ pub enum OpenOutcome { } impl PdfCraftApp { + /// Construct an application with the default session, panels, theme and control state. pub fn new() -> Self { Self { session: Session::new(), diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index 34cdda6032..79670ad304 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -353,6 +353,7 @@ fn finish(state: &mut MeasureView, pending: &mut Option, doc: &Document, p pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { egui::ScrollArea::vertical().id_salt("measure-panel").show(ui, |ui| panel_body(app, ui, t)); } +/// Draw the measurement panel: tool selection, scale calibration, snapping and the current reading. fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { crate::widgets::begin_dialog(ui); let Some((index, id)) = app.active_ids() else { diff --git a/crates/ui-egui/src/motion.rs b/crates/ui-egui/src/motion.rs index d32aca1283..f9f0d70302 100644 --- a/crates/ui-egui/src/motion.rs +++ b/crates/ui-egui/src/motion.rs @@ -96,6 +96,7 @@ pub struct Motion { } impl Default for Motion { + /// Start motion handling with animations enabled and no desktop preference sampled yet. fn default() -> Self { Self { reduced: false, source: None, asked_at: f64::NEG_INFINITY, egui_times: None, wheel: WheelReader::default(), frame: None } } @@ -164,6 +165,7 @@ pub(crate) fn animated(ctx: &egui::Context) -> bool { ctx.global_style().animation_time > 0.0 } +/// Return the stable egui id holding the last wheel-zoom input state. fn zoom_id() -> egui::Id { egui::Id::unique("pdfcraft-motion-wheel-zoom") } diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index e4cc9249d8..d495116c6c 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -36,6 +36,7 @@ struct Gesture { } impl ObjectSelection { + /// Clear any object-selection gesture and the page it was blocking. pub(crate) fn cancel_pointer_gesture(&mut self) { self.gesture = None; self.blocked_page = None; diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 46f4aa2d9e..eee6d6c7f2 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -33,6 +33,7 @@ fn score(hay: &str, needle: &str) -> Option { needle.chars().all(|c| it.any(|x| x == c)).then_some(100) } +/// Draw the command palette and dispatch the chosen command or tool group. pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let palette_rect_id = egui::Id::unique("palette_rect"); if !app.palette_open { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 98d0d9230d..045e607382 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -108,6 +108,7 @@ fn row_label(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32, elided } +/// Draw one tool-group row with its icon, label, badge and availability hint. fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); @@ -155,6 +156,7 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { theme::hand(resp).on_hover_text(tip) } +/// Draw the detail view for one tool group, dispatching its commands and tool-specific panels. fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) { if g.id == "edit" { edit_panel(app, ui, t, g); @@ -654,6 +656,7 @@ pub(crate) enum Nav { Flash(usize, [f32; 4]), } +/// Draw the selected right-hand panel and apply its navigation, layer, bookmark or edit actions. pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some((index, id)) = app.active_ids() else { return }; @@ -1146,6 +1149,7 @@ fn title_format(item: &OutlineItem, depth: usize, t: &Tokens) -> egui::TextForma egui::TextFormat { font_id, color, italics: item.italic, ..Default::default() } } +/// Draw one bookmark row with expansion, inline rename and the editable context menu. fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineItem, path: &[usize], siblings: usize, cx: &mut OutlineCtx<'_>) { if cx.searching && !cx.matches.contains(path) { return; diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index c77c13e8b8..2635693676 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -151,6 +151,7 @@ pub struct PrepareView { } impl PrepareView { + /// Cancel a preparation marquee without injecting the terminal pointer release that Escape does. pub(crate) fn cancel_pointer_gesture(&mut self) { cancel_marquee(self); // Unlike Escape, control cancellation never injects a terminal pointer release. @@ -1377,6 +1378,7 @@ const SEPARATORS: [&str; 5] = ["1,234.56", "1234.56", "1.234,56", "1234,56", "1' const NEGATIVES: [&str; 4] = ["-1,234.01", "1,234.01 (red)", "(1,234.01)", "(1,234.01) (red)"]; const SPECIALS: [&str; 5] = ["Zip Code", "Zip Code + 4", "Phone Number", "Social Security Number", "Arbitrary Mask"]; +/// Draw the field-format editor for the selected category and its options. fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { let cat = category(&d.format); let mut picked = cat; diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 6d7ca2be6c..5bf8737fcc 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -923,6 +923,7 @@ fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { }); } +/// Draw a combo box whose accessible name does not depend on the selected value. fn combo(ui: &mut egui::Ui, id: &str, name: &str, value: &mut T, choices: &[(T, &str)], width: f32) { let shown = choices.iter().find(|c| c.0 == *value).map_or("", |c| c.1); egui::ComboBox::from_id_salt(id) diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 24d721edd2..71759c4599 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -488,6 +488,8 @@ mod idle_tests { out.viewport_output[&egui::ViewportId::ROOT].repaint_delay } + /// Check that an idle dirty document schedules a bounded write and autosaves without input, then stops + /// waking the window. #[test] fn idle_dirty_document_requests_its_deadline_and_writes_without_input() { let dir = TestDir::new(); @@ -515,6 +517,8 @@ mod idle_tests { assert_eq!(delay, Duration::MAX, "unchanged snapshot needs no periodic wake"); } + /// Check that an edit after autosave schedules the next deadline, keeps the old snapshot until then, and + /// replaces it on time. #[test] fn second_edit_after_autosave_schedules_next_deadline_and_updates_snapshot() { let dir = TestDir::new(); @@ -564,6 +568,7 @@ mod idle_tests { assert_eq!(delay, Duration::MAX, "the second snapshot also lets the window sleep"); } + /// Check that a clean document and a document with recovery disabled never schedule an autosave. #[test] fn clean_documents_and_disabled_recovery_do_not_schedule_autosave() { for recovery_enabled in [false, true] { diff --git a/crates/ui-egui/src/search_ui.rs b/crates/ui-egui/src/search_ui.rs index 1ebaef54ae..85a9b46052 100644 --- a/crates/ui-egui/src/search_ui.rs +++ b/crates/ui-egui/src/search_ui.rs @@ -11,6 +11,7 @@ use crate::widgets; /// Glyphs of context shown on each side of a match. const CONTEXT: usize = 36; +/// Draw the Search panel with its query, options, progress and clickable result excerpts. pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: usize) { crate::widgets::begin_dialog(ui); let searched = view.texts.len() + view.text_failed.len(); diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 16cc89dac8..8f6b2c4a0c 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -678,6 +678,7 @@ fn error(ui: &mut egui::Ui, err: &Option) { } } +/// Draw the digital-ID chooser, including unusable certificates and removal confirmation. fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { title(ui, "Sign with a Digital ID"); ui.label(tl!("Choose the digital ID that you want to use for signing:")); diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index 1582d1ca22..971683887a 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -503,6 +503,8 @@ mod tests { std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") } + /// Check that the first readable fallback candidate wins, so reversing the preference list reverses the + /// chosen face. #[test] fn the_first_readable_candidate_wins_not_the_last() { // List order *is* the preference, so a loader that scanned back-to-front would pick a diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0949a12ec1..7d70214841 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -122,6 +122,7 @@ impl Tokens { } } + /// Return the cached theme tokens, falling back to the light palette before the first apply. pub fn get(ctx: &egui::Context) -> Self { ctx.data(|d| d.get_temp::(egui::Id::unique("pdfcraft-theme"))).unwrap_or_else(|| Self::for_kind(ThemeKind::Light)) } @@ -421,6 +422,7 @@ pub fn semibold(size: f32) -> FontId { FontId::new(size, FontFamily::Name("semibold".into())) } +/// Install the palette, widget styling, text sizes and right-to-left flag for the chosen theme. pub fn apply(ctx: &egui::Context, kind: ThemeKind) { // Keep the native window inherited. A concrete egui theme pins macOS's // NSWindow appearance and prevents winit from delivering ThemeChanged. @@ -492,6 +494,7 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { ctx.data_mut(|d| d.insert_temp(direction_key(), rtl)); } +/// Return the stable egui id holding the current right-to-left style flag. fn direction_key() -> egui::Id { egui::Id::unique("pdfcraft-style-right-to-left") } diff --git a/crates/ui-egui/src/wheel_boost.rs b/crates/ui-egui/src/wheel_boost.rs index 3e696b88fe..10ccbbbaa7 100644 --- a/crates/ui-egui/src/wheel_boost.rs +++ b/crates/ui-egui/src/wheel_boost.rs @@ -391,6 +391,7 @@ mod tests { assert_eq!(input.page, egui::vec2(-1.0, 0.0)); } + /// Check that NaN, zero, negative or infinite scales and non-wheel events add nothing. #[test] fn invalid_scales_and_events_contribute_nothing() { use egui::MouseWheelUnit::Line; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 373c79f055..01cab03314 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -287,6 +287,7 @@ fn toggle<'a>(widget: impl egui::Widget + 'a, radius: Option) -> impl egui:: } } +/// Draw a panel section heading with explicit paragraph direction and letter spacing. pub fn section_title(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); @@ -887,6 +888,7 @@ mod tests { .drop_without_applying_deltas(); } + /// Check right-aligned wrapping and a single collapsed line whose logical text ends with an ellipsis. #[test] fn rtl_comment_wraps_against_the_right_edge() { let persian = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود"; diff --git a/crates/ui-egui/src/window_controls.rs b/crates/ui-egui/src/window_controls.rs index d5d7cccc0b..1b68dcc829 100644 --- a/crates/ui-egui/src/window_controls.rs +++ b/crates/ui-egui/src/window_controls.rs @@ -231,6 +231,7 @@ pub fn resize_edges(ctx: &egui::Context) { }); } +/// Return the stable egui id for desktop window-grab state. fn grab_id() -> egui::Id { egui::Id::unique("window_controls_desktop_grab") } diff --git a/crates/ui-egui/tests/it/combine_grid.rs b/crates/ui-egui/tests/it/combine_grid.rs index dbd40defad..c6f2e3e721 100644 --- a/crates/ui-egui/tests/it/combine_grid.rs +++ b/crates/ui-egui/tests/it/combine_grid.rs @@ -702,6 +702,7 @@ fn a_card_dragged_to_the_bottom_edge_scrolls_the_grid_and_still_drops() { assert_eq!(order.get(at + 1), Some(&target_name), "dropped before {target_name}: {order:?}"); } +/// Check that right-clicking a locked card selects that card and offers Unlock for it. #[test] fn a_locked_card_offers_unlock_on_a_right_click() { let mut h = grid_of(vec![("one.pdf", fixture(1)), ("secret.pdf", protected("pw", "owner"))], vec2(1400.0, 900.0)); @@ -846,6 +847,8 @@ fn ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone() { assert_eq!(h.state().combine_zoom, now); } +/// Check that wheel zoom keeps the card under the pointer fixed and never blanks its thumbnail while a +/// sharper render arrives. #[test] fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank() { let files: Vec<(String, Vec)> = (0..40).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); @@ -1028,6 +1031,7 @@ fn zooming_from_the_toolbar_while_scrolled_keeps_the_top_cards_in_place() { } } +/// Check that zooming out at the bottom keeps the last card in place without leaving a gap below it. #[test] fn zooming_out_at_the_bottom_leaves_no_gap_below_the_last_card() { let (mut h, _) = scrolled_grid(20_000.0); diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index 6346de73e3..ff82820148 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -293,6 +293,7 @@ fn palette_arrow_keys_select_results_and_enter_runs_the_selection() { assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages[0].rotation, 270); } +/// Check that choosing Delete pages from the Pages menu reduces the document page count. #[test] fn the_pages_menu_comes_from_the_registry() { let mut h = harness(); @@ -306,6 +307,8 @@ fn the_pages_menu_comes_from_the_registry() { assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages.len(), 2); } +/// Check that Open Recent lists a recorded file and opens it in a new active tab without replacing the +/// original. #[test] fn the_open_recent_menu_lists_files_and_opens_one() { let dir = std::env::temp_dir().join(format!("pdfcraft-recent-{}", std::process::id())); @@ -341,6 +344,7 @@ fn the_open_recent_menu_lists_files_and_opens_one() { assert_eq!(app.session.get(original).unwrap().bytes.as_slice(), fixture(3).as_slice()); } +/// Check that Open Recent is disabled while no file has been opened. #[test] fn the_open_recent_menu_is_disabled_while_the_list_is_empty() { let mut h = harness(); @@ -358,6 +362,7 @@ fn recent_file(name: &str) -> pdfcraft_ui_egui::RecentFile { pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0 } } +/// Check that Clear Recent Files empties the list and disables Open Recent. #[test] fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { @@ -530,6 +535,8 @@ fn the_about_dialog_shows_the_running_version() { h.get_by_label_contains(&format!("Version {}", env!("CARGO_PKG_VERSION"))); } +/// Check that the quit shortcut needs exact platform modifiers and that unsaved work opens the save prompt +/// instead of closing. #[test] fn quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work() { for (os, modifiers) in [ diff --git a/crates/ui-egui/tests/it/dialog_chrome.rs b/crates/ui-egui/tests/it/dialog_chrome.rs index 641dda2eb7..47462fcf2b 100644 --- a/crates/ui-egui/tests/it/dialog_chrome.rs +++ b/crates/ui-egui/tests/it/dialog_chrome.rs @@ -33,6 +33,7 @@ fn fixture_with_info(info: Option<&str>) -> Vec { out } +/// Return the rectangle recorded for a dialog card, failing the test when the card is missing. fn card(h: &Harness<'_, PdfCraftApp>, id: &str) -> egui::Rect { h.ctx.data(|d| d.get_temp(egui::Id::unique(id).with("card"))).unwrap_or_else(|| panic!("missing dialog card {id}")) } @@ -202,6 +203,7 @@ fn space_audit_tab_measures_again_after_an_edit() { assert!(h.query_all_by_label("Bookmarks").any(|n| card.contains_rect(n.rect())), "a Bookmarks row in {card:?}"); } +/// Check that a dialog's Close button follows the content when switching from a taller tab to a shorter one. #[test] fn dialog_buttons_follow_the_content_after_a_taller_tab() { use pdfcraft_ui_egui::{Dialog, PropsTab}; diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index a690d9e61c..9fdbd09567 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -300,6 +300,8 @@ fn a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor() { // --------------------------------------------------------------------------------------------- // Things drawn over the page keep the page's cursor, tips and presses off. +/// Check that the command palette's frame keeps the default cursor over the page while the page beside it +/// still shows the stamp tool. #[test] fn stamp_cursor_stays_off_the_command_palette() { let mut h = open(link_pdf(), "300", false); @@ -385,6 +387,7 @@ fn a_dragged_panel_tab_passes_over_a_link_without_its_tip() { assert!(tip_shown(&h), "and shows its tip"); } +/// Send one wheel event over the page and let its scrolling settle. fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) { h.hover_at(egui::pos2(800.0, 500.0)); h.event(egui::Event::MouseWheel { @@ -399,6 +402,8 @@ fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) { } } +/// Check that the quick bar's frame keeps the default cursor over the page while the page below it still +/// shows the stamp tool. #[test] fn stamp_cursor_stays_off_the_quick_bar() { let mut h = open(link_pdf(), "400", true); @@ -444,6 +449,7 @@ fn open_unlock_prompt(h: &mut Harness<'_, PdfCraftApp>) { h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None }); } +/// Check that the link tip and pointing cursor disappear under the Unlock prompt. #[test] fn link_tip_stays_under_the_unlock_prompt() { let mut h = open(link_pdf(), "300", true); @@ -466,6 +472,7 @@ fn link_tip_stays_under_the_unlock_prompt() { assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); } +/// Check that moving onto a link's floating tip still reports the link's pointing-hand cursor. #[test] fn the_link_tip_lets_the_pointer_through_to_the_link() { let mut h = open(link_pdf(), "150", false); diff --git a/crates/ui-egui/tests/it/recovery.rs b/crates/ui-egui/tests/it/recovery.rs index 06c142750a..2d8258eeda 100644 --- a/crates/ui-egui/tests/it/recovery.rs +++ b/crates/ui-egui/tests/it/recovery.rs @@ -173,6 +173,8 @@ fn encrypted(bytes: Vec, password: &str) -> Vec { pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() } +/// Check that an encrypted document is autosaved without plaintext and recovered only after the password is +/// entered. #[test] fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password() { let bytes = encrypted(fixture(2), "pw"); @@ -327,6 +329,8 @@ fn missing_corrupt_or_mismatched_metadata_recovers_the_snapshot_without_an_origi } } +/// Check that an encrypted snapshot without metadata still asks for its password and is recovered without +/// rewriting its bytes. #[test] fn an_encrypted_snapshot_without_metadata_still_requires_its_password() { let s = store("encrypted-orphan"); diff --git a/crates/ui-egui/tests/it/text_select.rs b/crates/ui-egui/tests/it/text_select.rs index e5e0f91e60..7f7358bfeb 100644 --- a/crates/ui-egui/tests/it/text_select.rs +++ b/crates/ui-egui/tests/it/text_select.rs @@ -189,6 +189,7 @@ fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool { h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == want)) } +/// Check that both the Edit menu and the context menu copy the selected page text. #[test] fn selected_text_copy_edit_and_context_menus() { use egui_kittest::kittest::{NodeT, Queryable}; @@ -336,6 +337,7 @@ fn selected_text_copy_preserves_unicode_and_page_clipboard() { assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![0])); } +/// Check that a focused search input owns Copy and the selected page text does not also consume it. #[test] fn selected_text_copy_keeps_focused_input_clipboard_ownership() { let (mut h, at) = harness(|_| {}); diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index e991742cad..cb301db90c 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -201,6 +201,141 @@ fn find_shortcuts_work_from_the_find_field() { assert!(h.ctx.egui_wants_keyboard_input(), "and keeps it"); } +/// Open the find bar on the first document, search for the single uppercase "PAGE" on each +/// page, and wait until every text layer has answered. Returns the document's view index. +fn find_one_match_per_page(h: &mut Harness<'static, PdfCraftApp>) -> usize { + h.state_mut().active = Some(0); + h.run_steps(2); + let view = h.state().active.expect("a document is shown"); + { + let v = &mut h.state_mut().views[view]; + v.open_find(); + let f = v.find.as_mut().expect("the find bar is open"); + f.query = "PAGE".into(); + f.case_sensitive = true; + v.rerun_find(); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + if h.state().views[view].find.as_ref().is_some_and(|f| f.matches.len() >= 5) { + break; + } + } + assert_eq!(h.state().views[view].find.as_ref().unwrap().matches.len(), 5, "one PAGE on each of five pages"); + view +} + +/// Automatic following ends when the reader scrolls inside the viewed page, not only when the +/// page changes. This drives the production document area with a real wheel event. +#[test] +fn wheel_scrolling_ends_automatic_search_following_on_the_same_page() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + { + let f = h.state().views[view].find.as_ref().unwrap(); + assert_eq!((f.auto_page, f.follow_ended), (Some(0), false), "the automatic choice is following page 1"); + } + let center = h.state().views[view].viewport_rect().center(); + h.event(egui::Event::PointerMoved(center)); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -20.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(12); + let v = &h.state().views[view]; + assert_eq!(v.current, 0, "a small wheel scroll stays within page 1"); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "the reader's wheel ends automatic following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); +} + +/// A programmatic search flash is not reader scrolling, even while an unrelated drag is held. +/// Holding a text selection on the page keeps a pointer drag active; the flash still needs to +/// move the viewport, and automatic following must survive so a late layer can still follow it. +#[test] +fn a_search_flash_preserves_following_while_an_unrelated_drag_is_held() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + let start = h.state().views[view].page_screen_rect(0).expect("page 1 is on screen").center(); + h.event(egui::Event::PointerMoved(start)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + // Move enough to turn the press into a text-selection drag. That drag owns the pointer but + // does not scroll the document; only the document scroll area scrolls it. + let held = start + egui::vec2(24.0, 0.0); + h.event(egui::Event::PointerMoved(held)); + h.run_steps(2); + assert!(h.ctx.dragged_id().is_some(), "an unrelated page drag is held"); + assert!(h.state().views[view].page_screen_rect(1).is_none(), "page 2 starts off screen"); + + // A flash to page 2 visibly moves the viewport from under the held drag. + { + let f = h.state_mut().views[view].find.as_mut().unwrap(); + f.query = "Page 2".into(); + f.case_sensitive = true; + } + h.state_mut().views[view].rerun_find(); + h.run_steps(3); + let v = &h.state().views[view]; + assert_eq!(v.current, 1, "the automatic choice moved to page 2"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended, "a flash's own jump is not the reader scrolling"); + assert_eq!(f.auto_page, Some(1)); + assert!(v.page_screen_rect(1).is_some(), "page 2 is visibly on screen after the flash"); + + // Releasing the unrelated drag leaves the automatic follow in place. + h.event(egui::Event::PointerButton { pos: held, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert!(!h.state().views[view].find.as_ref().unwrap().follow_ended, "the page drag never ended following"); +} + +/// The document scroll bar is its own scroll surface: a track click moves the offset with no +/// pointer motion, and dragging from that press keeps moving it. Both are the reader scrolling, +/// so automatic following ends. A global "any drag is scrolling" check would miss the click. +#[test] +fn scroll_bar_track_click_and_drag_end_automatic_search_following() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + let vp = h.state().views[view].viewport_rect(); + // Floating bars sit in the outer few points of the document area (see theme). Aim 75% down + // the vertical track: below the handle at this scroll position, and clear of the corner + // where the horizontal bar's track overlaps. + let track = egui::pos2(vp.right() - 4.0, vp.top() + vp.height() * 0.75); + let before = h.state().views[view].page_screen_rect(0).expect("page 1 is on screen").top(); + h.event(egui::Event::PointerMoved(track)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: track, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + { + let v = &h.state().views[view]; + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a track click that jumps the view ends automatic following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + let after = v.page_screen_rect(0).map(|r| r.top()); + assert!(after.is_none_or(|top| top < before - 100.0), "the track click moved the viewport ({before} -> {after:?})"); + } + + // Keep dragging from the press: the scroll bar follows the pointer, still reader scrolling. + let dragged = egui::pos2(vp.right() - 4.0, vp.top() + vp.height() * 0.4); + h.event(egui::Event::PointerMoved(dragged)); + h.run_steps(2); + let v = &h.state().views[view]; + assert!(v.find.as_ref().unwrap().follow_ended, "the track drag is the reader scrolling"); + assert!(v.page_screen_rect(0).is_none_or(|r| r.top() < before - 100.0), "dragging the track kept moving the viewport"); + h.event(egui::Event::PointerButton { pos: dragged, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); +} + #[test] fn marking_while_searching_does_not_jitter_or_reset_search_state() { let mut h = harness(); diff --git a/packaging/windows/font-notices.ps1 b/packaging/windows/font-notices.ps1 index 4cb0a9a934..6d8fdc5a1e 100644 --- a/packaging/windows/font-notices.ps1 +++ b/packaging/windows/font-notices.ps1 @@ -1,3 +1,22 @@ +# Validate one non-comment manifest row before adding its family notice. +function Get-CraftFontNoticeRow([string] $rootPath, [string] $manifest, [string] $line) { + $fields = @($line -split ' \| ' | ForEach-Object { $_.Trim() }) + if ($fields.Count -lt 6 -or $fields[4] -ne 'OFL-1.1') { throw "Missing OFL metadata in ${manifest}: $line" } + # Manifest notice paths must name a family-local file, never escape the font input. + $relative = $fields[5].Replace('\', '/') + if ($relative -notmatch '^fonts/([a-zA-Z0-9_-]+)/OFL\.txt$') { throw "Invalid font notice path: $relative" } + $family = $Matches[1] + $fontPath = $fields[2].Replace('\', '/') + if ($fontPath -notmatch ('^fonts/' + [regex]::Escape($family) + '/[^/]+$')) { + throw "Font and notice must belong to the same family directory: $fontPath / $relative" + } + $notice = Join-Path $rootPath $relative + if (-not (Test-Path -LiteralPath $notice -PathType Leaf) -or (Get-Item -LiteralPath $notice).Length -eq 0) { + throw "Missing or empty OFL notice for $($fields[0]): $notice" + } + return @{ Family = $family; Notice = Get-Item -LiteralPath $notice } +} + # Validate every declared native font notice before either Windows package is created. # Checking all declared faces is deliberately conservative: size-capped faces may not be embedded. function Get-CraftFontNotices([string] $FontRoot, [bool] $RequireChinese = $false) { @@ -12,21 +31,8 @@ function Get-CraftFontNotices([string] $FontRoot, [bool] $RequireChinese = $fals foreach ($line in Get-Content -LiteralPath $manifest) { $line = $line.Trim() if (-not $line -or $line.StartsWith('#')) { continue } - $fields = @($line -split ' \| ' | ForEach-Object { $_.Trim() }) - if ($fields.Count -lt 6 -or $fields[4] -ne 'OFL-1.1') { throw "Missing OFL metadata in ${manifest}: $line" } - # Manifest notice paths must name a family-local file, never escape the font input. - $relative = $fields[5].Replace('\', '/') - if ($relative -notmatch '^fonts/([a-zA-Z0-9_-]+)/OFL\.txt$') { throw "Invalid font notice path: $relative" } - $family = $Matches[1] - $fontPath = $fields[2].Replace('\', '/') - if ($fontPath -notmatch ('^fonts/' + [regex]::Escape($family) + '/[^/]+$')) { - throw "Font and notice must belong to the same family directory: $fontPath / $relative" - } - $notice = Join-Path $rootPath $relative - if (-not (Test-Path -LiteralPath $notice -PathType Leaf) -or (Get-Item -LiteralPath $notice).Length -eq 0) { - throw "Missing or empty OFL notice for $($fields[0]): $notice" - } - $notices[$family] = Get-Item -LiteralPath $notice + $entry = Get-CraftFontNoticeRow $rootPath $manifest $line + $notices[$entry.Family] = $entry.Notice } } if ($notices.Count -eq 0) { throw "No declared font notices in $rootPath" }