mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 14:08:23 -06:00
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:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")?;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user