Merge branch 'storytold:main' into fix/panel-resize-scrollbar-5730

This commit is contained in:
Harsh Verma authored and GitHub committed 2026-11-02 12:23:11 +00:00
commit ec2f59305b
7 files changed
+1277 -486

No files matched your search

+3
View File
@@ -180,3 +180,6 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.Combiler01]
sync_github_name = true
+16 -3
View File
@@ -14,6 +14,7 @@ pub(super) mod credits;
pub(super) mod marks_ui;
pub(super) mod pageboxes;
pub(super) mod palette;
pub(super) mod preferences;
mod prompts;
pub(super) mod protect;
pub(super) mod standards_ui;
@@ -127,6 +128,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
Dialog::Properties(_) => 640.0,
Dialog::Print => 820.0,
Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
Dialog::Preferences => 610.0,
Dialog::About => 780.0,
Dialog::PinTool => 540.0,
_ => 520.0,
@@ -1356,10 +1358,13 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending)
/// `Dialog::Preferences` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
ui.set_width(610.0);
let t = Tokens::get(ui.ctx());
preferences::preferences_header(ui, &t);
ui.add_space(10.0);
// The settings outgrow a short window: scroll them rather than run off the screen.
// The OK row stays below the scroll, in reach however far the settings are scrolled.
let max = (ui.ctx().content_rect().height() - 120.0 - crate::js_ui::PREFERENCES_BUTTON_ROW).max(crate::js_ui::PREFERENCES_MIN_SCROLL);
let max = (ui.ctx().content_rect().height() - 120.0 - preferences::PREFERENCES_BUTTON_ROW - 40.0).max(preferences::PREFERENCES_MIN_SCROLL);
// A scroll area starts from the room the dialog offers it, and a modal offers more
// each frame for the first few, so with OK outside the scroll the dialog would grow
// over those frames. Asking for the full height as the least it may scroll in
@@ -1368,9 +1373,17 @@ fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendin
// cards and other full-width rows (#3557).
ui.scope(|ui| {
ui.spacing_mut().scroll.floating = false;
egui::ScrollArea::vertical().max_height(max).min_scrolled_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t));
ui.spacing_mut().scroll.bar_inner_margin = 14.0;
ui.spacing_mut().scroll.bar_outer_margin = 2.0;
ui.style_mut().scroll_animation = egui::style::ScrollAnimation::duration(0.25);
egui::ScrollArea::vertical()
.id_salt("preferences-dialog-scroll")
.max_height(max)
.min_scrolled_height(max)
.animated(true)
.show(ui, |ui| preferences::preferences_body(ui, app, &t));
});
req.close = crate::js_ui::preferences_buttons(ui);
req.close = preferences::preferences_buttons(ui);
false
}
+76 -41
View File
@@ -1,11 +1,10 @@
//! Preferences › Appearance: the appearance mode (follow the system, light or dark) above a light
//! and a dark theme card, each with a small picture of PdfCraft in that theme.
use egui::{Rect, RichText, pos2, vec2};
use egui::{CornerRadius, Rect, RichText, Stroke, pos2, vec2};
use crate::PdfCraftApp;
use crate::theme::{self, ThemeKind, ThemePreference, Tokens};
use crate::widgets;
/// The themes each card offers. One of each today; a new theme is one more row here.
pub const LIGHT_THEMES: &[(ThemeKind, &str)] = &[(ThemeKind::Light, "Light gray")];
@@ -82,43 +81,70 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) {
}
}
/// One theme card: its title, Active when it is what shows now, a preview of the chosen theme
/// and a radio row per theme. Returns true when the card or one of its radio rows was clicked.
fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool {
let inner = egui::Frame::new()
.fill(t.card)
.stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.border }))
.corner_radius(6.0)
.inner_margin(egui::Margin::same(8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(RichText::new(title).font(theme::semibold(12.5)).color(t.text));
if active {
ui.label(RichText::new(tl!("Active")).color(t.accent_text).small());
}
});
ui.add_space(4.0);
let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind);
preview(ui, shown, ui.available_width());
ui.add_space(4.0);
let mut clicked = false;
for (kind, label) in themes {
// One theme per card today, so it is always the chosen one.
clicked |= ui.add(widgets::radio(*kind == shown, tl!(label))).clicked();
}
clicked
});
// The whole card picks its theme, not just the radio row.
let whole = inner.response.interact(egui::Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
inner.inner || whole.clicked()
/// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme.
/// Returns whether the card was clicked and the control's Response.
fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)], query: &str) -> (bool, egui::Response) {
let mut card_clicked = false;
let frame_stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) };
let scope_res = ui.scope_builder(egui::UiBuilder::new().id_salt(title).sense(egui::Sense::click()), |ui| {
egui::Frame::new().fill(t.card).stroke(frame_stroke).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(
ui,
|ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let font = theme::semibold(12.5);
let ranges = crate::dialogs::preferences::find_match_ranges(title, query);
let galley = if ranges.is_empty() {
crate::bidi::layout_ui(ui, title.to_owned(), font, t.text)
} else {
let (job, _) = crate::dialogs::preferences::create_highlight_job(title, &ranges, font, t.text);
ui.painter().layout_job(job)
};
let (title_rect, _) = ui.allocate_exact_size(galley.size(), egui::Sense::hover());
ui.painter().galley(title_rect.min, galley, t.text);
if active {
ui.label(RichText::new(tl!("Active")).color(t.accent_text).small());
}
});
ui.add_space(4.0);
let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind);
preview(ui, shown, ui.available_width());
},
);
});
let resp = scope_res.response;
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, active, title));
if resp.has_focus() {
ui.painter().rect_stroke(resp.rect, CornerRadius::same(6), Stroke::new(2.0, t.accent_text), egui::StrokeKind::Outside);
}
let resp = theme::hand(resp);
let key_select = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter));
if resp.clicked() || key_select {
card_clicked = true;
}
(card_clicked, resp)
}
/// The Appearance section of Preferences.
#[cfg_attr(not(test), expect(dead_code, reason = "called by unit tests"))]
pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) {
ui.label(RichText::new(tl_ctx!("theme", "Appearance")).font(theme::semibold(13.0)));
let mut ctx = crate::dialogs::preferences::PrefSearchContext::new("", false);
preferences_section_with_context(ui, app, t, &mut ctx);
}
/// The Appearance section of Preferences with live search highlighting and scrolling context.
pub(crate) fn preferences_section_with_context(
ui: &mut egui::Ui,
app: &mut PdfCraftApp,
t: &Tokens,
ctx: &mut crate::dialogs::preferences::PrefSearchContext,
) {
ctx.label(ui, tl_ctx!("theme", "Appearance"), theme::semibold(13.0), t.text);
ui.horizontal(|ui| {
let label = ui.label(tl!("Appearance mode"));
let label = ctx.label(ui, tl!("Appearance mode"), theme::regular(13.0), t.text);
let combo = craft_ui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| {
for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] {
if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() {
@@ -132,18 +158,29 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens)
});
ui.add_space(4.0);
let light_active = app.theme == ThemeKind::Light;
let light_title = tl!("Light theme");
let dark_title = tl!("Dark theme");
let light_matches = crate::dialogs::preferences::find_match_ranges(light_title, ctx.query).len();
let dark_matches = crate::dialogs::preferences::find_match_ranges(dark_title, ctx.query).len();
ctx.total_matches += light_matches + dark_matches;
ui.columns(2, |cols| {
let [light, dark] = cols else { return };
if card(light, t, tl!("Light theme"), light_active, LIGHT_THEMES) {
let (light_clicked, light_resp) = card(light, t, light_title, light_active, LIGHT_THEMES, ctx.query);
if light_clicked {
app.execute(mode_command(ThemePreference::Light));
}
if card(dark, t, tl!("Dark theme"), !light_active, DARK_THEMES) {
ctx.scroll_if_first_match(&light_resp, light_matches);
let (dark_clicked, dark_resp) = card(dark, t, dark_title, !light_active, DARK_THEMES, ctx.query);
if dark_clicked {
app.execute(mode_command(ThemePreference::Dark));
}
ctx.scroll_if_first_match(&dark_resp, dark_matches);
});
ui.add_space(4.0);
// Acrobat's "Highlight existing fields": shared with the toggle in the form bar.
ui.add(crate::widgets::checkbox(&mut app.view_defaults.highlight_fields, tl!("Highlight fields")));
ctx.checkbox(ui, &mut app.view_defaults.highlight_fields, tl!("Highlight fields"), t);
}
#[cfg(test)]
@@ -175,7 +212,6 @@ mod tests {
);
h.run();
assert!(h.query_by_label("Light theme").is_some() && h.query_by_label("Dark theme").is_some());
assert!(h.query_by_label("Light gray").is_some() && h.query_by_label("Dark gray").is_some());
assert_eq!(h.query_all_by_label("Active").count(), 1);
if let Ok(path) = std::env::var("PDFCRAFT_APPEARANCE_SHOT") {
h.render().unwrap().save(path).unwrap();
@@ -200,7 +236,7 @@ mod tests {
assert_eq!(h.state().theme_preference, ThemePreference::System);
}
/// Clicking a theme card, or its radio row, picks that theme, not only the mode combo box.
/// Clicking a theme card picks that theme, not only the mode combo box.
#[test]
fn clicking_a_theme_card_picks_that_theme() {
let mut app = PdfCraftApp::new();
@@ -219,10 +255,9 @@ mod tests {
app,
);
h.run();
h.get_by_label("Dark gray").click();
h.get_by_label("Dark theme").click();
h.run();
assert_eq!((h.state().theme_preference, h.state().theme), (ThemePreference::Dark, ThemeKind::Dark));
// The card's title is not a widget of its own: the click lands on the card.
h.get_by_label("Light theme").click();
h.run();
assert_eq!((h.state().theme_preference, h.state().theme), (ThemePreference::Light, ThemeKind::Light));
File diff suppressed because it is too large. Load diff
+4 -439
View File
@@ -316,445 +316,10 @@ pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
close
}
/// The height the Preferences dialog keeps below its scrolling settings for the OK row: the
/// button, and the space above it.
pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0;
/// The least height the scrolling settings are given. The dialog around them takes about 80 more,
/// so this still fits the smallest window (520 pt) at the largest interface size (300%, which
/// leaves 173 pt): a taller floor would push OK off the screen again.
pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0;
/// Whether the interface can draw a language picker's native name. On the web the browser's glyph
/// rasterizer draws scripts no embedded face has, so the native names stay; on native, ask the
/// fonts this build installed (including an installed fallback face, unless system fonts are off).
fn picker_text_is_drawable(ui: &egui::Ui, text: &str) -> bool {
#[cfg(target_arch = "wasm32")]
{
let _ = (ui, text);
true
}
#[cfg(not(target_arch = "wasm32"))]
{
ui.fonts_mut(|fonts| fonts.has_glyphs(&egui::FontId::proportional(13.0), text))
}
}
/// The date picker's displayed name. Its languages are the interface languages, so it uses the
/// same drawable-name fallback: a native name whose script this build has no face for would
/// otherwise abort under egui 0.37's `MissingGlyphPolicy::Panic` (for example Hindi without a
/// Devanagari face and installed system fonts turned off).
fn date_language_picker_name(l: &pdfcraft_engine::dates::DateLanguage, drawable: impl FnOnce(&str) -> bool) -> &'static str {
match crate::i18n::Lang::from_code(l.code) {
Some(language) => language.picker_name(drawable),
None => l.name,
}
}
/// Preferences: interface language and size, appearance, identity and JavaScript. Drawn in a scroll
/// 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
// keeps the list readable instead of aborting on a missing glyph (for example Hindi
// without a Devanagari face, and installed system fonts turned off).
let (selected, picker) = {
let drawable = |text: &str| picker_text_is_drawable(ui, text);
let selected = crate::i18n::Lang::from_code(&app.language)
.map_or_else(|| tl!("Auto").to_owned(), |language| language.picker_name(drawable).to_owned());
let picker = crate::i18n::Lang::for_picker_by(|language| language.picker_name(drawable))
.into_iter()
.map(|language| (language, language.picker_name(drawable)))
.collect::<Vec<_>>();
(selected, picker)
};
let before = app.language.clone();
craft_ui::ComboBox::from_id_salt("interface-language")
.selected_text(selected)
.show_ui(ui, |ui| {
if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() {
ui.close();
}
for (language, name) in &picker {
if ui.selectable_value(&mut app.language, language.code().to_string(), *name).clicked() {
ui.close();
}
}
})
.response
.labelled_by(label.id);
// Relabel the rest of this dialog in the new language right away, not next frame.
if app.language != before {
crate::i18n::set_current(crate::i18n::Lang::from_pref(&app.language));
}
});
ui.horizontal(|ui| {
use crate::ui_scale;
let label = ui.label(tl!("Interface size"));
// Auto shows the size it draws at: the display's scale times the system's text scale.
let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0);
let auto_percent = ui_scale::percent(ui_scale::drawn(None, app.system_text_scale, native));
let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &auto_percent.to_string())]);
let selected = app.ui_scale.map_or_else(|| auto.clone(), |f| format!("{}%", ui_scale::percent(f)));
craft_ui::ComboBox::from_id_salt("interface-size")
.selected_text(selected)
.show_ui(ui, |ui| {
if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() {
ui.close();
}
for p in ui_scale::PRESETS {
let factor = f32::from(p) / 100.0;
if ui.selectable_label(app.ui_scale.is_some_and(|f| ui_scale::percent(f) == u32::from(p)), format!("{p}%")).clicked() {
app.ui_scale = Some(factor);
ui.close();
}
}
})
.response
.labelled_by(label.id);
});
ui.label(
egui::RichText::new(tl!("Auto follows the system's text size. Pages always render at the display's full resolution."))
.small()
.color(t.text_muted),
);
ui.horizontal(|ui| {
ui.label(tl!("Tools panel side"));
let right = app.side_panels.tools_on_right();
if ui.selectable_label(!right, tl!("Left")).clicked() {
app.side_panels.tools_side = crate::side_panels::ToolsSide::Left;
}
if ui.selectable_label(right, tl!("Right")).clicked() {
app.side_panels.tools_side = crate::side_panels::ToolsSide::Right;
}
});
ui.horizontal(|ui| {
ui.label(tl!("Navigation rail side"));
let right = app.side_panels.rail_on_right();
let left_btn = ui.selectable_label(!right, tl!("Left"));
left_btn.widget_info(|| {
egui::WidgetInfo::selected(egui::Role::Button, true, !right, format!("{}: {}", tl!("Navigation rail side"), tl!("Left")))
});
if left_btn.clicked() {
app.side_panels.rail_side = crate::side_panels::ToolsSide::Left;
}
let right_btn = ui.selectable_label(right, tl!("Right"));
right_btn.widget_info(|| {
egui::WidgetInfo::selected(egui::Role::Button, true, right, format!("{}: {}", tl!("Navigation rail side"), tl!("Right")))
});
if right_btn.clicked() {
app.side_panels.rail_side = crate::side_panels::ToolsSide::Right;
}
});
ui.add_space(8.0);
crate::appearance::preferences_section(ui, app, t);
ui.add_space(8.0);
reading_colours_section(ui, app, t);
ui.add_space(8.0);
ui.label(egui::RichText::new(tl!("Documents and view")).font(theme::semibold(13.0)));
ui.horizontal(|ui| {
ui.label(tl!("Default workspace mode"));
for (mode, label) in [
(crate::Mode::AllTools, "All tools"),
(crate::Mode::Read, "Read"),
(crate::Mode::Edit, "Edit"),
(crate::Mode::Convert, "Convert"),
(crate::Mode::Sign, "E-Sign"),
] {
widgets::radio_value(ui, &mut app.default_mode, mode, tl!(label));
}
});
ui.label(
egui::RichText::new(tl!("Used when opening PDFs. An explicit launch or control mode takes precedence for the session."))
.small()
.color(t.text_muted),
);
let defaults = &mut app.view_defaults;
ui.horizontal(|ui| {
ui.label(tl!("Default page display"));
for l in crate::canvas::PageLayout::ORDER {
widgets::radio_value(ui, &mut defaults.layout, l, tl!(l.label()));
}
ui.add(widgets::checkbox(&mut defaults.continuous, tl!("Continuous scrolling")));
});
ui.add(widgets::checkbox(&mut defaults.show_page_gaps, tl!("Show gaps between pages")));
ui.label(egui::RichText::new(tl!("Hide gaps to join page edges. Margins inside the PDF remain unchanged.")).small().color(t.text_muted));
ui.horizontal(|ui| {
use crate::canvas::Fit;
ui.label(tl!("Default zoom"));
widgets::radio_value(ui, &mut defaults.fit, Fit::Width, tl!("Fit to width"));
widgets::radio_value(ui, &mut defaults.fit, Fit::Page, tl!("Zoom to page level"));
widgets::radio_value(ui, &mut defaults.fit, Fit::None, tl!("Custom"));
// Editing the percentage selects Custom.
let mut percent = defaults.zoom * 100.0;
if ui.add(egui::DragValue::new(&mut percent).range(8.0..=6400.0).max_decimals(0).suffix("%")).changed() {
(defaults.fit, defaults.zoom) = (Fit::None, percent / 100.0);
}
});
ui.label(
egui::RichText::new(tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages."))
.small()
.color(t.text_muted),
);
ui.add(widgets::checkbox(&mut app.always_default_view, tl!("Always use my default zoom and page layout")));
ui.horizontal(|ui| {
ui.label(tl!("Units"));
for u in crate::units::Unit::ALL {
widgets::radio_value(ui, &mut app.units, u, u.label());
}
});
ui.label(egui::RichText::new(tl!("For page sizes and margins.")).small().color(t.text_muted));
// The web build has no file paths to list.
#[cfg(not(target_arch = "wasm32"))]
{
ui.horizontal(|ui| {
let label = ui.label(tl!("Recent files to keep"));
ui.add(egui::DragValue::new(&mut app.recent_limit).range(0..=crate::RECENT_FILES_MAX)).labelled_by(label.id);
});
ui.label(egui::RichText::new(tl!("Lowering this forgets the oldest files. At 0 there is no list.")).small().color(t.text_muted));
}
// The web build has no file paths to reopen.
#[cfg(not(target_arch = "wasm32"))]
ui.add(widgets::checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed")));
// Acrobat's "Restore last view settings when reopening documents" (#1767).
#[cfg(not(target_arch = "wasm32"))]
ui.add(widgets::checkbox(&mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again")));
// The web build has no files on disk to watch.
#[cfg(not(target_arch = "wasm32"))]
ui.add(widgets::checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk")));
// The notice's "Don't show again" turns this off (#4035); the restrictions stay either way.
ui.add(widgets::checkbox(&mut app.show_secured_notice, tl!("Show a notice when a document is secured")));
ui.horizontal(|ui| {
ui.label(tl!("Scroll speed (mouse wheel and arrow keys)"));
for speed in crate::scroll_speed::ScrollSpeed::ALL {
widgets::radio_value(ui, &mut app.scroll_speed, speed, tl_ctx!("scroll speed", speed.label()));
}
});
// Whether anything animates, the wheel's scrolling and zooming included; left alone it
// follows the system's animation setting.
ui.horizontal(|ui| {
ui.label(tl!("Animation effects"));
for choice in crate::motion::AnimationEffects::ALL {
widgets::radio_value(ui, &mut app.animation_effects, choice, tl_ctx!("animation effects", choice.label()));
}
});
// Only where the app draws the title bar (GNOME on Wayland); elsewhere the desktop draws it.
if app.window_controls.is_some() {
ui.add(widgets::checkbox(&mut app.system_title_bar, tl!("Use system title bar")))
.on_hover_text(tl!("Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme."));
}
ui.add_space(8.0);
// Identity: the author of new comments (Acrobat: Preferences ▸ Identity).
ui.label(egui::RichText::new(tl!("Identity")).font(theme::semibold(13.0)));
ui.horizontal(|ui| {
let label = ui.label(tl!("Name on new comments"));
ui.add(crate::widgets::line(&mut app.comment_prefs.author).desired_width(220.0).char_limit(crate::MAX_AUTHOR_CHARS)).labelled_by(label.id);
});
ui.add(widgets::checkbox(&mut app.comment_prefs.hide_empty_highlight_hover, tl!("Hide hover previews for highlights without comment text")));
ui.add_space(8.0);
ui.label(egui::RichText::new(tl!("Fill & Sign")).font(theme::semibold(13.0)));
ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving")));
ui.label(
egui::RichText::new(tl!("Text, marks and signatures become part of the page. Other comments stay editable.")).small().color(t.text_muted),
);
ui.add_space(8.0);
date_format(ui, app, t);
ui.add_space(8.0);
ui.label(egui::RichText::new("JavaScript").font(theme::semibold(13.0)));
egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(ui.available_width());
let mut on = app.session.javascript();
if ui.add(widgets::checkbox(&mut on, tl!("Enable Acrobat JavaScript"))).changed() {
app.session.set_javascript(on);
}
ui.label(
egui::RichText::new(tl!("Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work."))
.small()
.color(t.text_muted),
);
});
}
/// The Preferences dialog's OK row. Returns `true` to close.
pub(crate) fn preferences_buttons(ui: &mut egui::Ui) -> bool {
ui.add_space(10.0);
buttons(ui, tl!("OK"), &[]).is_some()
}
/// Preferences ▸ Date format: a preset, or any pattern typed in, for Fill & Sign dates, and the
/// language of its month and weekday names. A valid pattern applies as it's typed; an invalid
/// one stays in the box with the reason, and the last valid one stays in use.
fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) {
use pdfcraft_engine::dates::{DATE_FORMATS, DATE_LANGUAGES, MAX_DATE_FORMAT_CHARS};
ui.label(egui::RichText::new(tl!("Date format")).font(theme::semibold(13.0)));
let sample = |app: &PdfCraftApp, f: &str| format!("{} {f}", app.date_text(Some(f)).unwrap_or_default());
ui.horizontal(|ui| {
let label = ui.label(tl!("Fill & Sign dates"));
let current = app.session.date_format().to_string();
let selected = if DATE_FORMATS.contains(&current.as_str()) { sample(app, &current) } else { tl!("Custom").to_string() };
craft_ui::ComboBox::from_id_salt("date-format")
.selected_text(selected)
.width(240.0)
.show_ui(ui, |ui| {
for f in DATE_FORMATS {
if ui.selectable_label(current == f, sample(app, f)).clicked() && app.session.set_date_format(f).is_ok() {
app.date_format_draft = None;
ui.close();
}
}
})
.response
.labelled_by(label.id);
});
ui.horizontal(|ui| {
let label = ui.label(tl!("Pattern"));
let mut text = app.date_format_draft.clone().unwrap_or_else(|| app.session.date_format().to_string());
let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(160.0).char_limit(MAX_DATE_FORMAT_CHARS)).labelled_by(label.id);
// The box keeps the text as typed (a trailing space included) while it has focus; the
// setting takes the trimmed pattern whenever it's valid.
if edit.changed() {
let _ = app.session.set_date_format(&text);
app.date_format_draft = Some(text.clone());
}
if !edit.has_focus() && pdfcraft_engine::dates::check_date_format(&text).is_ok() {
app.date_format_draft = None;
}
match app.date_text(Some(&text)) {
Ok(today) => ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Today: {date}"), &[("date", &today)])).color(t.text_muted)),
// The engine's reason, in English.
Err(e) => ui.label(egui::RichText::new(e).color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B))),
};
});
// Fill & Sign refuses a date it would save as "?"; say so before anyone tries.
let bad = app.date_text(None).map(|today| pdfcraft_engine::dates::unwritable(&today)).unwrap_or_default();
if !bad.is_empty() {
ui.label(
egui::RichText::new(crate::i18n::fmt(
tl!(
"{chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy."
),
&[("chars", &bad)],
))
.color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B)),
);
}
ui.horizontal(|ui| {
let label = ui.label(tl!("Language"));
// Build every label before the combo box mutably borrows `ui`, with the same drawable
// fallback the interface language picker uses.
let (follow, selected, current, names) = {
let drawable = |text: &str| picker_text_is_drawable(ui, text);
let interface = crate::i18n::current().picker_name(drawable);
let follow = crate::i18n::fmt(tl!("Same as interface ({language})"), &[("language", interface)]);
let current = app.session.date_language().map(str::to_string);
let selected = DATE_LANGUAGES
.iter()
.find(|l| current.as_deref() == Some(l.code))
.map_or_else(|| follow.clone(), |l| date_language_picker_name(l, drawable).to_string());
let names = DATE_LANGUAGES.iter().map(|l| (l, date_language_picker_name(l, drawable))).collect::<Vec<_>>();
(follow, selected, current, names)
};
craft_ui::ComboBox::from_id_salt("date-language")
.selected_text(selected)
.width(240.0)
.show_ui(ui, |ui| {
if ui.selectable_label(current.is_none(), follow).clicked() && app.session.set_date_language(None).is_ok() {
ui.close();
}
for (l, name) in &names {
if ui.selectable_label(current.as_deref() == Some(l.code), *name).clicked() && app.session.set_date_language(Some(l.code)).is_ok()
{
ui.close();
}
}
})
.response
.labelled_by(label.id);
});
ui.label(
egui::RichText::new(crate::i18n::fmt(
tl!("yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is."),
&[("escape", "\\")],
))
.small()
.color(t.text_muted),
);
}
fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) {
// Reading colours (Acrobat: Replace Document Colors): solid text and line-art paints are
// remapped while rendering, and the page clears to the background colour. The preference
// is app-wide: every open document follows it, and documents open with it.
ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0)));
let mode = app.reading_colors.mode_name();
ui.horizontal_wrapped(|ui| {
for (name, label, colors) in [
("off", "Off", pdfcraft_render::ReadingColors::default()),
("black-on-white", "Black on white", pdfcraft_render::ReadingColors::BLACK_ON_WHITE),
("white-on-black", "White on black", pdfcraft_render::ReadingColors::WHITE_ON_BLACK),
("yellow-on-black", "Yellow on black", pdfcraft_render::ReadingColors::YELLOW_ON_BLACK),
("green-on-black", "Green on black", pdfcraft_render::ReadingColors::GREEN_ON_BLACK),
] {
let params = colors.params.unwrap_or(pdfcraft_render::ReadingParams {
text: [0, 0, 0],
background: [255, 255, 255],
only_black: false,
line_art: true,
});
ui.vertical(|ui| {
ui.set_width(104.0);
let sample = egui::RichText::new("Aa").size(22.0).color(egui::Color32::from_rgb(params.text[0], params.text[1], params.text[2]));
let button = egui::Button::new(sample)
.fill(egui::Color32::from_rgb(params.background[0], params.background[1], params.background[2]))
.min_size(egui::vec2(94.0, 44.0))
.stroke(egui::Stroke::new(if mode == name { 2.0 } else { 1.0 }, if mode == name { t.accent } else { t.border }));
let response = ui.add(button);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, mode == name, tl!(label)));
if response.clicked() {
app.set_reading_colors(colors);
}
ui.label(tl!(label));
});
}
});
ui.add_space(8.0);
if let Some(params) = app.reading_colors.params {
let text = egui::Color32::from_rgb(params.text[0], params.text[1], params.text[2]);
let background = egui::Color32::from_rgb(params.background[0], params.background[1], params.background[2]);
egui::Frame::new().fill(background).inner_margin(16.0).show(ui, |ui| {
ui.set_min_width((ui.available_width() - 32.0).max(0.0));
ui.label(egui::RichText::new(tl!("Reading colours")).size(18.0).color(text));
ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).color(text));
});
} else {
ui.label(tl!("Original"));
}
egui::CollapsingHeader::new(tl!("Custom")).default_open(mode == "custom").show(ui, |ui| {
let mut colors = if app.reading_colors.params.is_some() { app.reading_colors } else { pdfcraft_render::ReadingColors::BLACK_ON_WHITE };
let mut changed = false;
if let Some(params) = colors.params.as_mut() {
ui.horizontal(|ui| {
let text = ui.label(tl!("Text"));
changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed();
let background = ui.label(tl!("Page background"));
changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed();
});
changed |= ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed();
changed |= ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed();
}
if changed {
app.set_reading_colors(colors);
}
});
ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted));
ui.add_space(8.0);
}
#[expect(unused_imports, reason = "re-exported for tests and backward compatibility")]
pub(crate) use crate::dialogs::preferences::{
PREFERENCES_BUTTON_ROW, PREFERENCES_MIN_SCROLL, date_language_picker_name, preferences_body, preferences_buttons, preferences_header,
};
#[cfg(test)]
mod tests {
@@ -102,3 +102,152 @@ fn a_very_tall_window_does_not_leave_a_blank_gap_above_ok() {
assert!(on_screen(ok, 2600.0) && on_screen(last, 2600.0), "{last:?} {ok:?}");
assert!(ok.min.y - last.max.y < 120.0, "a gap of {} pt between the last setting and OK", ok.min.y - last.max.y);
}
#[test]
fn segmented_pill_controls_switch_modes_and_layouts() {
let mut h = harness(900.0);
// Switch workspace mode via segmented pill
h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Convert").scroll_to_me();
h.run_steps(3);
h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Convert").click();
h.run_steps(2);
assert_eq!(h.state().default_mode, pdfcraft_ui_egui::Mode::Convert);
let continuous_before = h.state().view_defaults.continuous;
// Switch page layout via segmented pill
h.get_by_label("Two-page view").scroll_to_me();
h.run_steps(3);
h.get_by_label("Two-page view").click();
h.run_steps(2);
assert_eq!(h.state().view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp);
assert_eq!(h.state().view_defaults.continuous, continuous_before, "Two-page view retains continuous scrolling");
// Switch continuous scrolling pill (can be combined with Two-page view)
h.get_by_label("Continuous scrolling").click();
h.run_steps(2);
assert_eq!(h.state().view_defaults.continuous, !continuous_before);
assert_eq!(h.state().view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, "two-page continuous scrolling is supported");
// Switch to single page mode (continuous setting remains independent)
h.get_by_label("Single page").click();
h.run_steps(2);
assert_eq!(h.state().view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
assert_eq!(h.state().view_defaults.continuous, !continuous_before);
// Switch tools panel side via segmented pill
h.get_by_label("Left").scroll_to_me();
h.run_steps(3);
h.get_by_label("Left").click();
h.run_steps(2);
assert_eq!(h.state().side_panels.tools_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left);
// Switch measurement units via segmented pill
h.get_by_label("pt").scroll_to_me();
h.run_steps(3);
h.get_by_label("pt").click();
h.run_steps(2);
assert_eq!(h.state().units, pdfcraft_ui_egui::units::Unit::Points);
}
#[test]
fn search_settings_scans_and_highlights_in_situ_without_hiding_cards() {
let mut h = harness(900.0);
// Initially all card titles exist
assert!(h.query_by_label("General & Interface").is_some());
assert!(h.query_by_label("Comment").is_some());
assert!(h.query_by_label("JavaScript").is_some());
// Type "Hide hover" in search box
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").click();
h.run_steps(1);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").type_text("Hide hover");
h.run_steps(4);
// In browser-style search, cards are NOT hidden
assert!(h.query_by_label("General & Interface").is_some());
assert!(h.query_by_label("Comment").is_some());
assert!(h.query_by_label("JavaScript").is_some());
// The setting "Hide hover previews for highlights without comment text" is present and matched
assert!(h.query_by_label("Hide hover previews for highlights without comment text").is_some());
// Match badge displays 1 match
assert!(h.query_by_label("1 match").is_some());
// Clearing search clears match badge
h.get_by_label("Clear search").click();
h.run_steps(4);
assert!(h.query_by_label("1 match").is_none());
// Search for "javascript"
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").click();
h.run_steps(1);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").type_text("javascript");
h.run_steps(4);
// Cards remain visible and multiple matches are reported
assert!(h.query_by_label("JavaScript").is_some());
assert!(h.query_by_label("General & Interface").is_some());
assert!(h.query_by_label_contains("matches").is_some());
// Clearing search shows everything again
h.get_by_label("Clear search").click();
h.run_steps(4);
assert!(h.query_by_label("General & Interface").is_some());
assert!(h.query_by_label("Comment").is_some());
assert!(h.query_by_label("JavaScript").is_some());
}
#[test]
fn preferences_dialog_scrollbar_spacing_and_controls() {
let h = harness(600.0);
let title = h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect();
let search = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").rect();
let ok = h.get_by_label("OK").rect();
assert!(on_screen(ok, 600.0));
// Header title and search box are vertically aligned
assert!((title.center().y - search.center().y).abs() <= 2.0);
// Search box right edge is aligned within the dialog
assert!(search.max.x <= ok.max.x);
}
#[test]
fn search_scrolls_option_only_and_theme_card_matches_into_visible_viewport() {
let mut h = harness(480.0);
let title = h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect();
let ok = h.get_by_label("OK").rect();
// "Two-page view" is an option in Card 3 (Documents and view).
// In a 480 pt window, it starts out below the visible scroll viewport.
let opt_before = h.get_by_label("Two-page view").rect();
assert!(opt_before.min.y >= ok.min.y, "Two-page option starts out below the visible viewport: {opt_before:?} vs OK {ok:?}");
// Searching "Two-page" (which only occurs inside that segmented pill option)
// auto-scrolls the matching option into the visible viewport between title and OK.
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").click();
h.run_steps(1);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").type_text("Two-page");
h.run_steps(4);
let opt_after = h.get_by_label("Two-page view").rect();
assert!(
opt_after.min.y >= title.max.y && opt_after.max.y <= ok.min.y,
"Two-page option scrolled into visible scroll viewport: {opt_after:?} between title {title:?} and OK {ok:?}"
);
// Clear search and test a theme-card-only match
h.get_by_label("Clear search").click();
h.run_steps(4);
// Search for "Dark theme" (which occurs only in the dark theme card in Card 2).
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").click();
h.run_steps(1);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").type_text("Dark theme");
h.run_steps(4);
let dark_card = h.get_by_label("Dark theme").rect();
assert!(
dark_card.min.y >= title.max.y && dark_card.max.y <= ok.min.y,
"Dark theme card scrolled into visible viewport: {dark_card:?} between title {title:?} and OK {ok:?}"
);
}
+3 -3
View File
@@ -242,11 +242,11 @@ fn preferences_theme_cards_and_use_system_setting_stay_in_sync() {
assert_theme(&h, ThemePreference::System, ThemeKind::Dark);
assert_eq!(mode_shown(&h), "Use system setting");
// The OS is dark: the Dark card's radio row picks Dark, and the combo box says so.
// The OS is dark: the Dark card picks Dark, and the combo box says so.
// The cards sit below the fold of the Preferences scroll area at this window size.
h.get_by_label("Dark gray").scroll_to_me();
h.get_by_label("Dark theme").scroll_to_me();
h.run_steps(3);
h.get_by_label("Dark gray").click();
h.get_by_label("Dark theme").click();
h.run_steps(3);
assert_theme(&h, ThemePreference::Dark, ThemeKind::Dark);
assert_eq!(mode_shown(&h), "Dark");