Merge pull request #2088 from srt8hellcat2-ui/fix/1577-optional-page-edge-shadows-integration

M3: Add optional page-edge shadows setting (fixes #1577)
This commit is contained in:
FredApps
2026-10-31 23:42:20 +01:00
committed by GitHub
37 changed files with 451 additions and 6 deletions
+7
View File
@@ -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"
+2
View File
@@ -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.
+1
View File
@@ -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")]
+3
View File
@@ -32,6 +32,9 @@ impl PdfCraftApp {
};
self.set_theme_preference(preference);
}
("show_page_edge_shadows", _) => {
self.show_page_edge_shadows = value.parse::<bool>().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")?;
}
+4
View File
@@ -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;
+9 -2
View File
@@ -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,
+19 -2
View File
@@ -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<RenderRequest>) = (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));
+2
View File
@@ -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
+1
View File
@@ -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 })),
+1
View File
@@ -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 إظهار ظلال حواف الصفحات
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 Показване на сенки по краищата на страниците
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -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 Εμφάνιση σκιών στα άκρα των σελίδων
Can't render this file because it contains an unexpected character in line 485 and column 9.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 ページ端の影を表示
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 Бет жиектерінің көлеңкелерін көрсету
Can't render this file because it contains an unexpected character in line 881 and column 9.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 404 and column 9.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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 Показывать тени по краям страниц
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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 పేజీ అంచుల నీడలను చూపించు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 880 and column 9.
+1
View File
@@ -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 Показувати тіні на краях сторінок
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 490 and column 12.
+1
View File
@@ -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 显示页面边缘阴影
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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 顯示頁面邊緣陰影
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2
View File
@@ -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<pdfcraft_platform::desktop_theme::DesktopTheme>,
+4
View File
@@ -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
+19
View File
@@ -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();
+353
View File
@@ -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<egui::Rect> {
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::<Vec<_>>(),
)
})
.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::<serde_json::Value>(&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::<serde_json::Value>(&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::<Vec<_>>(), 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<egui::Rect> {
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<egui::Rect>) {
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<u8> {
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::<serde_json::Value>(&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);
}
+1
View File
@@ -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. |
+2 -2
View File
@@ -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"