M-egui037: qualify capture fixes and document migration behavior

This commit is contained in:
skylar taylor-barrick committed 2026-10-25 15:39:43 +01:00
1 parent d13dd1db20
commit 0afb9628b2
55 files changed
+896 -38

No files matched your search

+6
View File
@@ -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
+3
View File
@@ -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");
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(" ")`,
@@ -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
@@ -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);
@@ -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
@@ -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
@@ -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
+2
View File
@@ -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).
@@ -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.
+4
View File
@@ -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()));
+4
View File
@@ -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;
+2
View File
@@ -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)));
+8
View File
@@ -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
+587 -23
View File
@@ -234,6 +234,18 @@ pub struct Find {
/// (page, glyph range) in document order.
pub matches: Vec<(usize, Range<usize>)>,
pub current: Option<usize>,
/// 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<usize>,
/// 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<usize>,
/// 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<usize> {
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<_>>(), 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<_>>(), 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<_>>(), 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);
+6
View File
@@ -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<f32> {
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 };
+2
View File
@@ -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,
+1
View File
@@ -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")
}
+2
View File
@@ -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,
+1
View File
@@ -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;
}
+27
View File
@@ -58,6 +58,7 @@ pub type Reply = Result<Value, String>;
/// 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<usize> {
width.checked_mul(height)?.checked_mul(core::mem::size_of::<egui::Color32>())
}
@@ -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<Vec<egui::Event>>, value: Value, since: web_time::Instant) -> Result<Handled, String> {
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<Handled, String> = (|| 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<Handled, String> {
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<Handled, String> {
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<egui::Rect>, 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();
+1
View File
@@ -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);
+2
View File
@@ -366,6 +366,8 @@ fn saved_split_path(saved: &Layout<Panel>, rendered: &Layout<Panel>, 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);
+1
View File
@@ -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;
+2
View File
@@ -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);
+1
View File
@@ -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| {
+4
View File
@@ -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");
+1
View File
@@ -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(),
+1
View File
@@ -353,6 +353,7 @@ fn finish(state: &mut MeasureView, pending: &mut Option<Edit>, 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 {
+2
View File
@@ -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")
}
+1
View File
@@ -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;
+1
View File
@@ -33,6 +33,7 @@ fn score(hay: &str, needle: &str) -> Option<usize> {
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 {
+4
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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<T: PartialEq + Copy>(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)
+5
View File
@@ -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] {
+1
View File
@@ -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();
+1
View File
@@ -678,6 +678,7 @@ fn error(ui: &mut egui::Ui, err: &Option<String>) {
}
}
/// 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:"));
+2
View File
@@ -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
+3
View File
@@ -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::<Tokens>(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")
}
+1
View File
@@ -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;
+2
View File
@@ -287,6 +287,7 @@ fn toggle<'a>(widget: impl egui::Widget + 'a, radius: Option<u8>) -> 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 = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود";
+1
View File
@@ -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")
}
+4
View File
@@ -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<u8>)> = (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);
+7
View File
@@ -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 [
+2
View File
@@ -33,6 +33,7 @@ fn fixture_with_info(info: Option<&str>) -> Vec<u8> {
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};
+7
View File
@@ -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);
+4
View File
@@ -173,6 +173,8 @@ fn encrypted(bytes: Vec<u8>, password: &str) -> Vec<u8> {
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");
+2
View File
@@ -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(|_| {});
+135
View File
@@ -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();
+21 -15
View File
@@ -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" }