diff --git a/.config/nextest.toml b/.config/nextest.toml index 2a4cd2cb93..11ec94cc1a 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,3 +1,10 @@ # Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI # installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). nextest-version = "0.9.150" + +# These measurements require a quiet runner. Their in-process SERIAL mutex only +# serializes cargo test; nextest gives each test its own process. Reserve all slots +# so other tests cannot charge their CPU/render-worker time to a frame budget. +[[profile.default.overrides]] +filter = 'package(pdfcraft-ui-egui) & binary(perf)' +threads-required = "num-test-threads" diff --git a/ROADMAP.md b/ROADMAP.md index 00622a79b7..14419f9857 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -806,3 +806,5 @@ PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape af ## Button export-value refresh (2026-10-30) PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged. + +- **2026-10-31 (#1577):** Reconciled the page-shadow preference with split viewer, organizer, settings and control modules, preserving upstream page-gap shadow suppression. CI validation pending. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index dc564c0011..e00a11f430 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -91,6 +91,7 @@ impl PdfCraftApp { // A fresh install follows the desktop's appearance (#1248); saved settings override. theme_preference: ThemePreference::System, reading_colors: Default::default(), + show_page_edge_shadows: true, #[cfg(target_os = "linux")] desktop_theme: None, #[cfg(target_os = "linux")] diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 79bd2fa258..85fdae38af 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -32,6 +32,9 @@ impl PdfCraftApp { }; self.set_theme_preference(preference); } + ("show_page_edge_shadows", _) => { + self.show_page_edge_shadows = value.parse::().map_err(|_| "show_page_edge_shadows must be true or false")?; + } ("scroll_speed", _) => { self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?; } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index e5eaae5863..7dd9846a74 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -143,6 +143,7 @@ impl PdfCraftApp { v["redact_prefs"] = self.redact_defaults.to_json(); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); + v["show_page_edge_shadows"] = self.show_page_edge_shadows.into(); v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["organize_zoom"] = serde_json::json!(self.organize_zoom); @@ -267,6 +268,9 @@ impl PdfCraftApp { { self.fill_text_color = color; } + if let Some(show) = v["show_page_edge_shadows"].as_bool() { + self.show_page_edge_shadows = show; + } if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) { self.redact_prefs = prefs.clone(); self.redact_defaults = prefs; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8b280ba75d..5625587a69 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -862,6 +862,7 @@ struct PageEnv<'a> { modal: bool, navigation_enabled: bool, paper: Color32, + show_page_edge_shadows: bool, ppp: f32, precise_scroll: Vec2, prefs: &'a comments::CommentPrefs, @@ -952,7 +953,7 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra let info = frame.env.info; let painter = ui.painter(); // Shadows would darken the preceding page when pages touch. - if view.show_page_gaps { + if view.show_page_gaps && frame.env.show_page_edge_shadows { painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); } painter.rect_filled(r, CornerRadius::ZERO, paper); @@ -3049,7 +3050,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if view.organize { // One remembered grid size for every document, kept in the settings. view.set_grid_zoom(organize_zoom); - organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); + organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, ui, &t); app.organize_zoom = view.grid_zoom(); return; } @@ -3204,6 +3205,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { modal, navigation_enabled, paper, + show_page_edge_shadows: app.show_page_edge_shadows, ppp, precise_scroll, prefs, @@ -3229,9 +3231,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { today, }; let mut page_out = PageOut::default(); + let fade_strength = ui.spacing().scroll.fade.strength; + if !app.show_page_edge_shadows { + ui.spacing_mut().scroll.fade.strength = 0.0; + } let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview) }); + ui.spacing_mut().scroll.fade.strength = fade_strength; let PageOut { canvas_action, clicked_link, diff --git a/crates/ui-egui/src/canvas/organize.rs b/crates/ui-egui/src/canvas/organize.rs index 7f333b3e46..48c7e82d8f 100644 --- a/crates/ui-egui/src/canvas/organize.rs +++ b/crates/ui-egui/src/canvas/organize.rs @@ -267,7 +267,17 @@ fn request_sharper( } } -pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) { +pub(super) fn organize_grid( + view: &mut DocView, + doc: &pdfcraft_engine::Document, + unobstructed: bool, + show_page_edge_shadows: bool, + ui: &mut egui::Ui, + t: &Tokens, +) { + let info = &doc.info; + let editable = doc.allows_assembly(); + let dirty = doc.dirty; let ppp = ui.ctx().pixels_per_point(); // The page image scales with the zoom; the padding and the page number don't. let cell = vec2(146.0 * view.grid_zoom + 44.0, 194.0 * view.grid_zoom + 88.0); @@ -290,6 +300,10 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, let (mut in_view, mut sharper): (Vec<(usize, f32)>, Vec) = (Vec::new(), Vec::new()); // The source cell can scroll out of the virtualized rows during a drag. let mut drop = view.org_drag.is_some() && ui.input(|i| i.pointer.primary_released()); + let fade_strength = ui.spacing().scroll.fade.strength; + if !show_page_edge_shadows { + ui.spacing_mut().scroll.fade.strength = 0.0; + } egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, clip| { apply_auto_scroll(ui, middle_gesture, auto_delta); ui.add_space(20.0); @@ -329,7 +343,9 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, if view.selected.contains(&i) { ui.painter().rect_stroke(c.shrink(6.0), CornerRadius::same(8), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } - ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow); + if show_page_edge_shadows { + ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow); + } ui.painter().rect_filled(pr, CornerRadius::ZERO, Color32::WHITE); // A page drawn larger than its thumbnail gets a render at the size drawn, while // it is in view; until that arrives the thumbnail stands in. @@ -507,6 +523,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, view.org_drag = None; } }); + ui.spacing_mut().scroll.fade.strength = fade_strength; // Files dragged in from outside go to the gap under the pointer. Some platforms don't say // where the pointer is until the files are dropped; then the whole grid is the target. let pointer = ui.input(|i| i.pointer.hover_pos()).filter(|p| viewport.contains(*p)); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 43c41889e5..c3c730ed68 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -28,6 +28,8 @@ //! comments, selecting text, moving comments). `ui.state` reports `pages_on_screen` to aim at. //! - `ui.type {text}`, `ui.key {key, modifiers?}`: keyboard input to the focused widget / app. //! - `ui.command {id}`: run a registry command (as the menu would). `ui.commands` lists them. +//! - `ui.set {key: "show_page_edge_shadows", value: "true"|"false"}` controls page +//! decoration app-wide; `ui.state.show_page_edge_shadows` reports the saved preference. //! - `ui.set {key, value}`: view options (`--page`, `--zoom`, …), plus persistent preferences. //! `select` accepts physical page numbers and ranges such as `1,12,16-18`; blank clears the //! explicit selection. This is the same bounded parser as the Organize Pages field; invalid diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 1166c0e7ec..64b10b69fb 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -141,6 +141,7 @@ impl Host for crate::PdfCraftApp { "palette_open": self.palette_open, "theme": format!("{:?}", self.theme), "theme_preference": self.theme_preference, + "show_page_edge_shadows": self.show_page_edge_shadows, "language": self.language, "notice": self.toast.as_ref().map(|t| t.0.clone()), "progress": self.progress_notice.as_ref().map(|p| json!({ "label": p.label, "fraction": p.fraction })), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 56f62b7734..6e06c424df 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2854,3 +2854,4 @@ certificate Version الإصدار # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply خيارات هذا الرد Export value قيمة التصدير + Show Page Edge Shadows إظهار ظلال حواف الصفحات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ceccdf9582..8fe56514df 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2541,3 +2541,4 @@ animation effects Off Изключени # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Опции за този отговор Export value Стойност за експортиране + Show Page Edge Shadows Показване на сенки по краищата на страниците diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8bfde72a85..8ecd80d30b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2885,3 +2885,4 @@ certificate Version Verze # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Možnosti této odpovědi Export value Exportní hodnota + Show Page Edge Shadows Zobrazovat stíny okrajů stránek diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 83684c08ea..2d6afea2c8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2579,3 +2579,4 @@ animation effects Off Aus # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Optionen für diese Antwort Export value Exportwert + Show Page Edge Shadows Schatten an Seitenrändern anzeigen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 576cdfcd6d..3c2f2e2050 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2584,3 +2584,4 @@ animation effects Off Ανενεργά # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Επιλογές για αυτήν την απάντηση Export value Τιμή εξαγωγής + Show Page Edge Shadows Εμφάνιση σκιών στα άκρα των σελίδων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3216e4e448..f5f1b4b6ff 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2901,3 +2901,4 @@ theme Appearance Aspecto # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opciones de esta respuesta Export value Valor de exportación + Show Page Edge Shadows Mostrar sombras en los bordes de las páginas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6092231df3..f31b3741b6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2844,3 +2844,4 @@ certificate Version Version # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Options de cette réponse Export value Valeur d’exportation + Show Page Edge Shadows Afficher les ombres des bords des pages diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 53b81062bb..e74944e33e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2577,3 +2577,4 @@ animation effects Off Ki # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply A válasz beállításai Export value Exportérték + Show Page Edge Shadows Oldalszéli árnyékok megjelenítése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a3dc999d6d..82973756fa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2810,3 +2810,4 @@ certificate SHA-256 digest Impronta SHA-256 # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opzioni per questa risposta Export value Valore di esportazione + Show Page Edge Shadows Mostra le ombre ai bordi delle pagine diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e64a1a8ce..ef055d436f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2578,3 +2578,4 @@ animation effects Off オフ # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply この返信のオプション Export value 書き出し値 + Show Page Edge Shadows ページ端の影を表示 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 5c8e594759..47061d3a25 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2576,3 +2576,4 @@ animation effects Off Өшірулі # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Осы жауаптың параметрлері Export value Экспорт мәні + Show Page Edge Shadows Бет жиектерінің көлеңкелерін көрсету diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 61fd9dc9a2..9385c27907 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2839,3 +2839,4 @@ theme Appearance Išvaizda # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Šio atsakymo parinktys Export value Eksporto reikšmė + Show Page Edge Shadows Rodyti puslapių kraštų šešėlius diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 203cad58d8..e333b1075e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2674,3 +2674,4 @@ animation effects Off Wyłączone # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opcje tej odpowiedzi Export value Wartość eksportowa + Show Page Edge Shadows Pokaż cienie krawędzi stron diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 45f827171b..cd6d8f3e2f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2863,3 +2863,4 @@ theme Appearance Aparência # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opções desta resposta Export value Valor de exportação + Show Page Edge Shadows Mostrar sombras nas bordas das páginas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 59c483526a..047c01228e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2491,3 +2491,4 @@ animation effects Off Выкл # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Параметры этого ответа Export value Значение экспорта + Show Page Edge Shadows Показывать тени по краям страниц diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 83cb594158..63c3a3f77d 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2583,3 +2583,4 @@ animation effects Off Izklopljeni # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Možnosti tega odgovora Export value Izvozna vrednost + Show Page Edge Shadows Prikaži sence robov strani diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 0bb56f4793..6186eb1713 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2487,3 +2487,4 @@ animation effects Off ఆఫ్ # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply ఈ ప్రత్యుత్తరం ఎంపికలు Export value ఎగుమతి విలువ + Show Page Edge Shadows పేజీ అంచుల నీడలను చూపించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b52688606b..a8a993af80 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2574,3 +2574,4 @@ animation effects Off Kapalı # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Bu yanıtın seçenekleri Export value Dışa aktarma değeri + Show Page Edge Shadows Sayfa kenarı gölgelerini göster diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index dc65faf7a2..566e673176 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2600,3 +2600,4 @@ animation effects Off Вимкнено # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Параметри цієї відповіді Export value Значення експорту + Show Page Edge Shadows Показувати тіні на краях сторінок diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e9a9a73f2b..41063cc0da 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2845,3 +2845,4 @@ key Space Space # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Tùy chọn cho câu trả lời này Export value Giá trị xuất + Show Page Edge Shadows Hiển thị bóng viền trang diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5f08576b8e..6089c942fd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2542,3 +2542,4 @@ animation effects Off 关 # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply 此回复的选项 Export value 导出值 + Show Page Edge Shadows 显示页面边缘阴影 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2570f12f1c..292e399579 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2528,3 +2528,4 @@ animation effects Off 關 # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply 此回覆的選項 Export value 匯出值 + Show Page Edge Shadows 顯示頁面邊緣陰影 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4f2a87660c..d9b77dcc3f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -292,6 +292,8 @@ pub struct PdfCraftApp { /// The reading-colours preference (Preferences ▸ Reading colours): applied to every open /// document, and documents open with it (Acrobat's Replace Document Colors). pub reading_colors: pdfcraft_render::ReadingColors, + /// App-wide page decoration only; never part of a PDF edit or render request. + pub show_page_edge_shadows: bool, /// The Linux portal is enabled only by the native host, and starts on the first System choice. #[cfg(target_os = "linux")] desktop_theme: Option, diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index b8f5d51cc6..c876992785 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -355,6 +355,10 @@ fn date_language_picker_name(l: &pdfcraft_engine::dates::DateLanguage, drawable: /// area; [`preferences_buttons`] goes below it, outside the scroll, so OK is always in reach. pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(18.0))); + ui.label(egui::RichText::new(tl!("Appearance")).font(theme::semibold(13.0))); + if ui.add(widgets::checkbox(&mut app.show_page_edge_shadows, tl!("Show Page Edge Shadows"))).changed() { + ui.ctx().request_repaint(); + } ui.horizontal(|ui| { let label = ui.label(tl!("Interface language")); // A native name is only drawn when these fonts can draw it; otherwise the English name diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index e1a7e1f1db..2ca751f11a 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -140,6 +140,25 @@ fn thumbnail_size_is_settable_and_inspectable_through_the_control_channel() { assert!(ok(&mut h, &c, "ui.state", json!({}))["active"]["thumbnail_zoom"].as_f64().unwrap() > 0.75); } +#[test] +fn page_shadows_are_an_observable_app_wide_preference() { + let (mut h, c) = harness(); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], true); + for value in ["false", "true"] { + ok(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": value })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], value == "true"); + } + for value in ["off", "1", "False", ""] { + assert!(call(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": value })).is_err()); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], true); + } + ok(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": false })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], false); + h.state_mut().active = None; + ok(&mut h, &c, "ui.set", json!({ "key": "show_page_edge_shadows", "value": "false" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["show_page_edge_shadows"], false); +} + #[test] fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/page_shadows.rs b/crates/ui-egui/tests/page_shadows.rs new file mode 100644 index 0000000000..a83fa6ce05 --- /dev/null +++ b/crates/ui-egui/tests/page_shadows.rs @@ -0,0 +1,353 @@ +//! Decorative page shadows are app preferences, never PDF edits. + +use egui_kittest::{Harness, kittest::Queryable}; +use pdfcraft_ui_egui::PdfCraftApp; + +#[test] +fn viewer_shadows_follow_the_preference_without_moving_pages() { + for theme in ["light", "dark"] { + for layout in ["single", "two-page", "continuous", "two-up"] { + let mut h = harness(theme); + h.state_mut().set_option("layout", layout).unwrap(); + h.run_steps(3); + let rectangles = h.state().views[0].visible_page_rects().to_vec(); + let original = shadows(&h, false); + assert!(!original.is_empty(), "{theme} {layout}"); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(shadows(&h, false).is_empty(), "{theme} {layout}"); + assert_eq!(h.state().views[0].visible_page_rects(), rectangles); + h.state_mut().set_option("zoom", "125").unwrap(); + h.state_mut().set_option("page", "2").unwrap(); + h.run_steps(3); + assert!(shadows(&h, false).is_empty()); + h.state_mut().set_option("show_page_edge_shadows", "true").unwrap(); + h.run_steps(2); + assert!(!shadows(&h, false).is_empty()); + } + } +} + +#[test] +fn viewport_fades_follow_the_preference_and_keep_other_scroll_areas_styled() { + for theme in ["light", "dark"] { + for organize in [false, true] { + let mut h = harness(theme); + if organize { + h.state_mut().set_option("organize", "on").unwrap(); + // Make the three-page grid overflow vertically. + h.state_mut().set_option("grid-zoom", "300").unwrap(); + } + h.run_steps(4); + let original_style = h.ctx.global_style().spacing.scroll.fade; + assert!(original_style.strength > 0.0); + assert!(!viewport_fades(&h).is_empty(), "bottom fade at start: {theme}, organize={organize}"); + scroll_down(&mut h, 100.0); + let original = viewport_fades(&h); + let viewport = h.state().views[0].viewport_rect(); + assert!(original.iter().any(|r| (r.top() - viewport.top()).abs() < 2.0), "top fade: {original:?}"); + assert!(original.iter().any(|r| (r.bottom() - viewport.bottom()).abs() < 2.0), "bottom fade: {original:?}"); + let pages = h.state().views[0].visible_page_rects().to_vec(); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(viewport_fades(&h).is_empty(), "viewport fades remain: {theme}, organize={organize}"); + assert_eq!(h.state().views[0].visible_page_rects(), pages); + assert_eq!(h.ctx.global_style().spacing.scroll.fade, original_style); + h.state_mut().set_option("show_page_edge_shadows", "true").unwrap(); + h.run_steps(2); + assert_eq!(viewport_fades(&h), original); + scroll_down(&mut h, 100_000.0); + let at_end = viewport_fades(&h); + assert!(at_end.iter().any(|r| (r.top() - viewport.top()).abs() < 2.0)); + assert!(!at_end.iter().any(|r| (r.bottom() - viewport.bottom()).abs() < 2.0), "no bottom fade at the end"); + } + } +} + +#[test] +fn horizontal_viewport_fades_also_follow_the_preference() { + let mut h = harness("dark"); + h.state_mut().set_option("zoom", "400").unwrap(); + h.run_steps(4); + let viewport = h.state().views[0].viewport_rect(); + let horizontal: Vec<_> = viewport_fades(&h).into_iter().filter(|r| r.width() <= 21.0).collect(); + assert!(horizontal.iter().any(|r| (r.left() - viewport.left()).abs() < 2.0), "left fade: {horizontal:?}"); + assert!(horizontal.iter().any(|r| (r.right() - viewport.right()).abs() < 2.0), "right fade: {horizontal:?}"); + let pages = h.state().views[0].visible_page_rects().to_vec(); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(viewport_fades(&h).is_empty()); + assert_eq!(h.state().views[0].visible_page_rects(), pages); +} + +fn scroll_down(h: &mut Harness<'static, PdfCraftApp>, points: f32) { + h.hover_at(h.state().views[0].viewport_rect().center()); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -points), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(12); +} + +/// Inspect the actual egui painter output: untextured four-vertex gradients +/// with a transparent edge, spanning one dimension of the document viewport. +fn viewport_fades(h: &Harness<'static, PdfCraftApp>) -> Vec { + let viewport = h.state().views[0].viewport_rect(); + h.output() + .shapes + .iter() + .filter_map(|s| { + let egui::Shape::Mesh(mesh) = &s.shape else { return None }; + if mesh.texture_id != egui::TextureId::default() || mesh.vertices.len() != 4 || mesh.indices.len() != 6 { + return None; + } + let rect = mesh.calc_bounds(); + let transparent = mesh.vertices.iter().filter(|v| v.color == egui::Color32::TRANSPARENT).count(); + (transparent == 2 + && (((rect.width() - viewport.width()).abs() < 2.0 && rect.height() <= 21.0) + || ((rect.height() - viewport.height()).abs() < 2.0 && rect.width() <= 21.0)) + && rect.intersects(viewport)) + .then_some(rect) + }) + .collect() +} + +#[test] +fn organize_shadows_follow_the_preference() { + for theme in ["light", "dark"] { + let mut h = harness(theme); + h.state_mut().set_option("organize", "on").unwrap(); + h.run_steps(3); + let original = shadows(&h, true); + assert_eq!(original.len(), 3); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert!(shadows(&h, true).is_empty()); + h.state_mut().set_option("show_page_edge_shadows", "true").unwrap(); + h.run_steps(2); + assert_eq!(shadows(&h, true), original); + } +} + +#[test] +fn checkbox_and_multiple_documents_do_not_edit_pdf_state() { + let mut h = harness("light"); + h.state_mut().open_bytes("second.pdf", None, pdf()).unwrap(); + let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect(); + h.state_mut().session.apply(ids[0], pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Keep existing edits".into() }).unwrap(); + assert!(h.state().session.get(ids[0]).unwrap().dirty); + assert!(h.state().session.get(ids[0]).unwrap().can_undo().is_some()); + let saved: Vec<_> = ids.iter().map(|id| h.state_mut().session.save_bytes(*id).unwrap()).collect(); + let before: Vec<_> = h + .state() + .views + .iter() + .map(|v| { + let d = h.state().session.get(v.id).unwrap(); + ( + d.bytes.clone(), + d.dirty, + d.can_undo().map(str::to_owned), + d.can_redo().map(str::to_owned), + d.info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect::>(), + ) + }) + .collect(); + h.state_mut().set_option("dialog", "preferences").unwrap(); + h.run_steps(3); + let checkbox = h.get_by_label("Show Page Edge Shadows"); + checkbox.click(); + h.run_steps(2); + assert_eq!(serde_json::from_str::(&h.state().persist()).unwrap()["show_page_edge_shadows"], false); + h.get_by_label("Show Page Edge Shadows").focus(); + h.key_press(egui::Key::Space); + h.run_steps(2); + assert_eq!(serde_json::from_str::(&h.state().persist()).unwrap()["show_page_edge_shadows"], true); + let dialog_shadow = h.ctx.global_style().visuals.window_shadow; + assert_ne!(dialog_shadow, egui::Shadow::NONE); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + assert_eq!(h.ctx.global_style().visuals.window_shadow, dialog_shadow); + h.state_mut().set_option("dialog", "none").unwrap(); + for tab in [0, 1] { + h.state_mut().active = Some(tab); + h.run_steps(2); + assert!(shadows(&h, false).is_empty()); + let d = h.state().session.get(h.state().views[tab].id).unwrap(); + assert_eq!( + (&d.bytes, d.dirty, d.can_undo(), d.can_redo()), + (&before[tab].0, before[tab].1, before[tab].2.as_deref(), before[tab].3.as_deref()) + ); + assert_eq!(d.info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect::>(), before[tab].4); + } + for (id, bytes) in ids.iter().zip(&saved) { + assert_eq!(h.state_mut().session.save_bytes(*id).unwrap(), *bytes); + } + h.state_mut().open_bytes("new.pdf", None, pdf()).unwrap(); + h.run_steps(2); + assert!(shadows(&h, false).is_empty()); +} + +#[test] +fn rendered_page_content_is_identical_with_and_without_decoration() { + let mut h = harness("dark"); + h.state_mut().set_option("panel", "pages").unwrap(); + h.state_mut().set_option("zoom", "175").unwrap(); + h.run_steps(3); + scroll_down(&mut h, 20.0); + for _ in 0..400 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!h.state().render_pending(), "the actual PDF raster must be ready"); + let rect = h.state().views[0].page_screen_rect(0).unwrap(); + let fades = viewport_fades(&h); + assert_eq!(fades.iter().filter(|r| r.height() <= 21.0).count(), 2, "the screenshot comparison must contain both vertical fades"); + let enabled = h.render().unwrap(); + h.state_mut().set_option("show_page_edge_shadows", "false").unwrap(); + h.run_steps(2); + let disabled = h.render().unwrap(); + assert_eq!(h.state().views[0].page_screen_rect(0), Some(rect)); + let viewport = h.state().views[0].viewport_rect(); + assert!(viewport_fades(&h).is_empty()); + // Only the painter's actual decorative fade strips may change over the page. + // All remaining artwork, annotation and form pixels must be identical. + for (_, page) in h.state().views[0].visible_page_rects() { + let visible = page.intersect(viewport).shrink(2.0); + if !visible.is_positive() { + continue; + } + for y in visible.top().ceil() as u32..visible.bottom().floor() as u32 { + for x in visible.left().ceil() as u32..visible.right().floor() as u32 { + if !fades.iter().any(|fade| fade.expand(1.0).contains(egui::pos2(x as f32, y as f32))) { + assert_eq!(enabled.get_pixel(x, y), disabled.get_pixel(x, y), "page content at {x},{y}"); + } + } + } + } + // The top overlaps the first page's blue artwork; the bottom overlaps the + // blank second page. Sample clear of the unchanged scrollbar at the edge. + let x = rect.intersect(viewport).center().x as u32; + for (y, expected) in [(viewport.top() + 8.0, [204, 230, 255, 255]), (viewport.bottom() - 10.0, [255, 255, 255, 255])] { + assert_eq!(disabled.get_pixel(x, y as u32).0, expected, "unobscured PDF at y={y}"); + assert_ne!(enabled.get_pixel(x, y as u32).0, expected, "fade overlays the PDF at y={y}"); + } + // The Pages sidebar, including its thumbnails and selection borders, is unaffected. + for y in 0..enabled.height() { + for x in (0..viewport.left().floor() as u32).chain(viewport.right().ceil() as u32..enabled.width()) { + assert_eq!(enabled.get_pixel(x, y), disabled.get_pixel(x, y), "sidebar at {x},{y}"); + } + } + assert_ne!(enabled.as_raw(), disabled.as_raw(), "the decoration really changes the image"); + if let Ok(directory) = std::env::var("PDFCRAFT_SHADOW_SCREENSHOTS") { + enabled.save(format!("{directory}/page-shadows-enabled.png")).unwrap(); + disabled.save(format!("{directory}/page-shadows-disabled.png")).unwrap(); + } +} + +fn harness(theme: &str) -> Harness<'static, PdfCraftApp> { + let theme = theme.to_string(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| { + let mut app = PdfCraftApp::new(); + // Save comparisons must not depend on crossing a /ModDate second boundary. + app.session = app.session.with_clock(|| 1_700_000_000); + app.set_option("language", "en").unwrap(); + app.set_option("theme", &theme).unwrap(); + app.open_bytes("pages.pdf", None, pdf()).unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(3); + h +} + +fn shadows(h: &Harness<'static, PdfCraftApp>, grid: bool) -> Vec { + let color = pdfcraft_ui_egui::theme::Tokens::for_kind(h.state().theme).page_shadow; + fn collect(shape: &egui::Shape, color: egui::Color32, grid: bool, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => { + for s in shapes { + collect(s, color, grid, out); + } + } + egui::Shape::Rect(r) + if r.fill == color + && r.rect.width() > 20.0 + && r.rect.height() > 20.0 + && if grid { + r.blur_width == 0.0 && r.corner_radius == egui::CornerRadius::same(1) + } else { + r.blur_width == 14.0 && r.corner_radius == egui::CornerRadius::ZERO + } => + { + out.push(r.rect) + } + _ => {} + } + } + let mut out = Vec::new(); + for s in &h.output().shapes { + collect(&s.shape, color, grid, &mut out); + } + out +} + +/// Synthetic pages include colored artwork, an annotation and a form field. +fn pdf() -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 8 0 R /Annots [6 0 R 7 0 R] >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + "<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (Note) >>".to_string(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (field) /V (value) /Rect [50 50 100 70] /P 3 0 R >>".to_string(), + { + let body = "0.8 0.9 1 rg 0 0 300 400 re f 0.3 g 10 10 280 8 re f"; + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()) + }, + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +#[test] +fn page_shadow_preference_round_trips_and_ignores_invalid_settings() { + let mut app = PdfCraftApp::new(); + let enabled = |app: &PdfCraftApp| serde_json::from_str::(&app.persist()).unwrap()["show_page_edge_shadows"].clone(); + assert_eq!(enabled(&app), true); + app.restore("{}"); + assert_eq!(enabled(&app), true); + for value in [false, true] { + app.set_option("show_page_edge_shadows", if value { "true" } else { "false" }).unwrap(); + let mut restored = PdfCraftApp::new(); + restored.restore(&app.persist()); + assert_eq!(enabled(&restored), value); + } + for invalid in ["null", "0", "\"false\"", "[]", "{}"] { + app.restore(&format!("{{\"show_page_edge_shadows\":{invalid}}}")); + assert_eq!(enabled(&app), true); + } + app.restore("not json"); + assert_eq!(enabled(&app), true); + assert!(app.set_option("show_page_edge_shadows", "maybe").is_err()); + assert_eq!(enabled(&app), true); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index e3987182e2..4319796163 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -290,6 +290,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | | 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. | | 2026-10-31 | patch | M2, PR #4172: eight GBK font/alias cases compare exact pixels with independent installed-face and explicit-Unicode references; wrong-glyph and wrong-family negative controls fail. Estimates unchanged. | | 2026-10-31 | patch | M2, PR #4172: integrate main fa5f9f8c, preserve both raster test groups and restore explicit display_config imports; display-only CJK policy and estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9abb4e379c..18498399c9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9928,8 +9928,8 @@ milestone = "M14" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/it/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"] -notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it, unless Always use my default zoom and page layout is on. Missing: Acrobat's resolution, rendering and page-content options" +evidence = ["crates/ui-egui/tests/page_shadows.rs::viewport_fades_follow_the_preference_and_keep_other_scroll_areas_styled", "crates/ui-egui/tests/page_shadows.rs::rendered_page_content_is_identical_with_and_without_decoration", "crates/ui-egui/tests/page_shadows.rs::page_shadow_preference_round_trips_and_ignores_invalid_settings", "crates/ui-egui/tests/page_shadows.rs::viewer_shadows_follow_the_preference_without_moving_pages", "crates/ui-egui/tests/page_shadows.rs::organize_shadows_follow_the_preference", "crates/ui-egui/tests/it/control.rs::page_shadows_are_an_observable_app_wide_preference", "crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/it/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"] +notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it, unless Always use my default zoom and page layout is on. Preferences ▸ Appearance also has an app-wide, persistent Show Page Edge Shadows checkbox (on by default), exposed through ui.set and ui.state; it affects viewer and Organize decorations, including the viewport-edge gradients painted over pages, only. Missing: Acrobat's resolution, rendering and page-content options" [[feature]] id = "misc.prefs-documents"