From 5f07da441978ccdc8dcc98501e3996562ec8db56 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 09:33:03 +0530 Subject: [PATCH 01/10] ui: modernize preferences dialog with card-based layout - Replace cluttered settings list with clean, modular card containers - Integrate interactive real-time search box flush with card boundary - Combine page display and continuous scrolling into a unified segmented pill - Enable smooth scrolling with dedicated animation duration - Enhance vertical scrollbar spacing and layout geometry - Update unit and integration tests across layout, appearance, and theme - Credit @Combiler01 in contributors/people.toml --- contributors/people.toml | 3 + crates/ui-egui/src/dialogs.rs | 17 +- crates/ui-egui/src/dialogs/appearance.rs | 26 +- crates/ui-egui/src/dialogs/preferences.rs | 783 ++++++++++++++++++ crates/ui-egui/src/workflows/js_ui.rs | 443 +--------- crates/ui-egui/tests/it/preferences_layout.rs | 95 +++ crates/ui-egui/tests/it/theme.rs | 6 +- 7 files changed, 911 insertions(+), 462 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/preferences.rs diff --git a/contributors/people.toml b/contributors/people.toml index a5a47f5c52..fecfb68c90 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -180,3 +180,6 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.Combiler01] +sync_github_name = true diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 42bed83657..2e2457fbbf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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,11 @@ 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()); // 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).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 +1371,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 } diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 48ad0cb31c..135337aaba 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -5,7 +5,6 @@ use egui::{Rect, RichText, 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,9 +81,10 @@ 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. +/// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme. +/// Returns true when the card was clicked. fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { + let mut title_clicked = false; let inner = egui::Frame::new() .fill(t.card) .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.border })) @@ -93,7 +93,8 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The .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)); + let resp = ui.label(RichText::new(title).font(theme::semibold(12.5)).color(t.text)); + title_clicked = resp.interact(egui::Sense::click()).clicked(); if active { ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); } @@ -101,17 +102,10 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The 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. + // The whole card picks its theme. let whole = inner.response.interact(egui::Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); - inner.inner || whole.clicked() + title_clicked || whole.clicked() } /// The Appearance section of Preferences. @@ -175,7 +169,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 +193,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 +212,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)); diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs new file mode 100644 index 0000000000..4147417277 --- /dev/null +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -0,0 +1,783 @@ +//! Preferences dialog: card-based settings interface organized into clean, modular cards +//! with segmented pill controls and real-time search filtering. + +use egui::{Color32, CornerRadius, Margin, Rect, RichText, Sense, Stroke, pos2, vec2}; + +use crate::PdfCraftApp; +use crate::canvas::{Fit, PageLayout}; +use crate::motion::AnimationEffects; +use crate::scroll_speed::ScrollSpeed; +use crate::side_panels::ToolsSide; +use crate::theme::{self, ThemeKind, Tokens}; +use crate::units::Unit; +use crate::widgets::{self, icons}; + +/// The height the Preferences dialog keeps below its scrolling settings for the buttons row. +pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0; + +/// The least height the scrolling settings are given in very small windows. +pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0; + +/// A segmented pill control for mutually exclusive choices. +/// +/// Draws a rounded container with options inside. The active option is highlighted +/// with the accent colour. Emits AccessKit RadioButton widget info with the option's label. +pub fn segmented_pill_choice(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool { + let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect(); + segmented_pill_choice_a11y(ui, t, current, &with_a11y) +} + +/// A segmented pill selection bar with separate accessibility labels. +pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { + let mut changed = false; + egui::Frame::new() + .fill(t.field) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(14)) + .inner_margin(Margin::symmetric(2, 2)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.horizontal(|ui| { + for &(value, label, a11y_label) in options { + let is_active = *current == value; + let font = theme::medium(12.0); + let text_color = if is_active { Color32::WHITE } else { t.text }; + let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); + let w = label_galley.size().x; + let (rect, resp) = ui.allocate_exact_size(vec2(w + 18.0, 24.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, is_active, a11y_label)); + + if is_active { + ui.painter().rect(rect, CornerRadius::same(12), t.accent, Stroke::NONE, egui::StrokeKind::Inside); + } else { + let press = theme::Press::track(ui, &resp); + let fill = press.fill(Color32::TRANSPARENT, t.hover, t.pressed); + ui.painter().rect(rect, CornerRadius::same(12), fill, Stroke::NONE, egui::StrokeKind::Inside); + } + ui.painter().galley(rect.center() - label_galley.size() / 2.0, label_galley, text_color); + + let resp = theme::hand(resp); + if resp.clicked() && *current != value { + *current = value; + changed = true; + } + } + }); + }); + changed +} + +/// An independent toggle pill button (e.g. for standalone toggles). +#[allow(dead_code)] +pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { + let mut changed = false; + let is_active = *active; + let font = theme::medium(12.0); + let text_color = if is_active { Color32::WHITE } else { t.text }; + let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); + let w = label_galley.size().x; + let (rect, resp) = ui.allocate_exact_size(vec2(w + 20.0, 26.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, is_active, label)); + + let (fill, stroke) = if is_active { + (t.accent, Stroke::NONE) + } else { + let press = theme::Press::track(ui, &resp); + (press.fill(t.field, t.hover, t.pressed), Stroke::new(1.0, t.border)) + }; + ui.painter().rect(rect, CornerRadius::same(13), fill, stroke, egui::StrokeKind::Inside); + ui.painter().galley(rect.center() - label_galley.size() / 2.0, label_galley, text_color); + + let resp = theme::hand(resp); + if resp.clicked() { + *active = !*active; + changed = true; + } + changed +} + +/// A standard card container for a Preferences section. +/// Groups related settings with clean margins, subtle border and rounded corners. +pub fn preference_card(ui: &mut egui::Ui, t: &Tokens, title: &str, icon: Option<&str>, content: impl FnOnce(&mut egui::Ui) -> R) -> R { + let card_fill = if t.kind == ThemeKind::Light { Color32::from_rgb(0xF7, 0xF8, 0xFA) } else { Color32::from_rgb(0x24, 0x24, 0x28) }; + egui::Frame::new() + .fill(card_fill) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(8)) + .inner_margin(Margin::symmetric(14, 12)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + if let Some(ic) = icon { + ui.add(icons::image(ic, 16.0, t.accent)); + } + ui.label(RichText::new(title).font(theme::semibold(13.5)).color(t.text)); + }); + ui.add_space(8.0); + content(ui) + }) + .inner +} + +fn matches_search(query: &str, title: &str, keywords: &[&str]) -> bool { + let q = query.trim().to_lowercase(); + if q.is_empty() { + return true; + } + title.to_lowercase().contains(&q) || keywords.iter().any(|k| k.to_lowercase().contains(&q)) +} + +/// Whether the interface can draw a language picker's native name. +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. +pub(crate) 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, + } +} + +/// Interactive search box widget at the top of Preferences. +fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, rect: Rect) { + let stroke = Stroke::new(1.0, t.border); + ui.painter().rect(rect, CornerRadius::same(15), t.field, stroke, egui::StrokeKind::Inside); + + // Magnifying glass icon: 14pt icon centered vertically at (rect.min.x + 16, rect.center().y) + let icon_rect = Rect::from_center_size(pos2(rect.min.x + 16.0, rect.center().y), vec2(14.0, 14.0)); + icons::paint(ui, icon_rect, "search", 14.0, t.text_muted); + + let clear_w = if query.is_empty() { 12.0 } else { 30.0 }; + let edit_rect = Rect::from_min_max(pos2(rect.min.x + 28.0, rect.min.y), pos2(rect.max.x - clear_w, rect.max.y)); + let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); + let edit = child_ui.add( + egui::TextEdit::singleline(query) + .hint_text(tl!("Search settings…")) + .frame(egui::Frame::NONE) + .align(egui::Align2::LEFT_CENTER) + .desired_width(edit_rect.width()), + ); + edit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Search settings…"))); + + if !query.is_empty() { + let clear_rect = Rect::from_center_size(pos2(rect.max.x - 16.0, rect.center().y), vec2(16.0, 16.0)); + let clear_resp = ui.interact(clear_rect, ui.auto_id_with("clear_search"), Sense::click()); + clear_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear search"))); + icons::paint(ui, clear_rect, "x", 12.0, if clear_resp.hovered() { t.text } else { t.text_muted }); + if clear_resp.clicked() { + query.clear(); + } + } + + if edit.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + query.clear(); + } +} + +/// The body of the Preferences dialog with card layout and search filtering. +pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + let search_id = egui::Id::unique("pdfcraft_preferences_search_query"); + let mut query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + + // Top Header: Title on the left, Search bar on the right. + // Allocate the full available width so the search bar aligns flush with the card boxes below. + let full_w = ui.available_width(); + let header_h = 30.0; + let (header_rect, _) = ui.allocate_exact_size(vec2(full_w, header_h), Sense::hover()); + + // Title on the left, vertically centered in the header row: + let title_rect = Rect::from_min_max(header_rect.min, pos2(header_rect.min.x + 240.0, header_rect.max.y)); + let mut title_ui = ui.new_child(egui::UiBuilder::new().max_rect(title_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); + title_ui.label(RichText::new(tl!("Preferences")).font(theme::semibold(18.0)).color(t.text)); + + // Search bar on the right, right edge flush with the card box boundary: + let search_w = 210.0; + let search_rect = Rect::from_min_max(pos2(header_rect.max.x - search_w, header_rect.min.y), header_rect.max); + let before = query.clone(); + preference_search_box_at(ui, t, &mut query, search_rect); + if query != before { + ui.data_mut(|d| d.insert_temp(search_id, query.clone())); + } + + ui.add_space(10.0); + + let mut matches_count = 0; + + // Card 1: General & Interface + let kw1 = &[ + "general", + "interface", + "language", + "size", + "tools", + "panel", + "side", + "animation", + "motion", + "reopen", + "session", + "restore", + "reload", + "recent", + "title", + ]; + let title1 = tl!("General & Interface"); + if matches_search(&query, title1, kw1) { + matches_count += 1; + preference_card(ui, t, title1, Some("settings"), |ui| { + card_general(ui, app, t); + }); + ui.add_space(12.0); + } + + // Card 2: Appearance & Theme + let kw2 = &["appearance", "theme", "dark", "light", "shadow", "edge", "highlight", "field", "reading", "colors", "colours", "custom", "contrast"]; + let title2 = tl_ctx!("theme", "Appearance"); + if matches_search(&query, title2, kw2) { + matches_count += 1; + preference_card(ui, t, title2, Some("sun"), |ui| { + card_appearance(ui, app, t); + }); + ui.add_space(12.0); + } + + // Card 3: Documents and View + let kw3 = + &["documents", "view", "workspace", "mode", "page", "display", "continuous", "scrolling", "gaps", "zoom", "fit", "units", "scroll", "speed"]; + let title3 = tl!("Documents and view"); + if matches_search(&query, title3, kw3) { + matches_count += 1; + preference_card(ui, t, title3, Some("file-text"), |ui| { + card_documents_and_view(ui, app, t); + }); + ui.add_space(12.0); + } + + // Card 4: Comment + let kw4 = &["comment", "comments", "identity", "name", "author", "preview", "hover", "highlight"]; + let title4 = tl!("Comment"); + if matches_search(&query, title4, kw4) { + matches_count += 1; + preference_card(ui, t, title4, Some("message-square-text"), |ui| { + card_identity(ui, app, t); + }); + ui.add_space(12.0); + } + + // Card 5: Fill & Sign + let kw5 = &["fill", "sign", "flatten", "date", "format", "pattern", "today", "language", "token"]; + let title5 = tl!("Fill & Sign"); + if matches_search(&query, title5, kw5) { + matches_count += 1; + preference_card(ui, t, title5, Some("pen-tool"), |ui| { + card_fill_and_sign(ui, app, t); + }); + ui.add_space(12.0); + } + + // Card 6: JavaScript + let kw6 = &["javascript", "js", "scripts", "sandbox", "security", "acrobat"]; + let title6 = "JavaScript"; + if matches_search(&query, title6, kw6) { + matches_count += 1; + preference_card(ui, t, title6, Some("code-xml"), |ui| { + card_javascript(ui, app, t); + }); + ui.add_space(12.0); + } + + // Empty search state when nothing matches + if matches_count == 0 { + ui.add_space(30.0); + ui.vertical_centered(|ui| { + let (rect, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); + icons::paint(ui, rect, "search", 32.0, t.text_muted); + ui.add_space(8.0); + ui.label(RichText::new(format!("No settings match \"{query}\"")).font(theme::medium(14.0)).color(t.text_muted)); + ui.add_space(10.0); + if widgets::pill_button(ui, tl!("Clear search"), false).clicked() { + ui.data_mut(|d| d.insert_temp(search_id, String::new())); + ui.ctx().request_repaint(); + } + }); + } +} + +/// Content for Card 1: General & Interface +fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + ui.horizontal(|ui| { + let label = ui.label(tl!("Interface language")); + 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::>(); + (selected, picker) + }; + let before = app.language.clone(); + craft_ui::ComboBox::from_id_salt("interface-language") + .selected_text(selected) + .width(160.0) + .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); + if app.language != before { + crate::i18n::set_current(crate::i18n::Lang::from_pref(&app.language)); + } + + ui.add_space(16.0); + use crate::ui_scale; + let label_size = ui.label(tl!("Interface size")); + 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) + .width(130.0) + .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_size.id); + }); + ui.label( + 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.add_space(6.0); + + ui.horizontal(|ui| { + ui.label(tl!("Tools panel side")); + let right = app.side_panels.tools_on_right(); + let mut side = if right { ToolsSide::Right } else { ToolsSide::Left }; + if segmented_pill_choice(ui, t, &mut side, &[(ToolsSide::Left, tl!("Left")), (ToolsSide::Right, tl!("Right"))]) { + app.side_panels.tools_side = side; + } + + ui.add_space(16.0); + ui.label(tl!("Navigation rail side")); + let rail_right = app.side_panels.rail_on_right(); + let mut rside = if rail_right { ToolsSide::Right } else { ToolsSide::Left }; + if segmented_pill_choice_a11y( + ui, + t, + &mut rside, + &[(ToolsSide::Left, tl!("Left"), tl!("Navigation rail on left")), (ToolsSide::Right, tl!("Right"), tl!("Navigation rail on right"))], + ) { + app.side_panels.rail_side = rside; + } + }); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ui.label(tl!("Animation effects")); + segmented_pill_choice( + ui, + t, + &mut app.animation_effects, + &[ + (AnimationEffects::System, tl_ctx!("animation effects", AnimationEffects::System.label())), + (AnimationEffects::On, tl_ctx!("animation effects", AnimationEffects::On.label())), + (AnimationEffects::Off, tl_ctx!("animation effects", AnimationEffects::Off.label())), + ], + ); + }); + ui.add_space(6.0); + + #[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"))); + #[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"))); + #[cfg(not(target_arch = "wasm32"))] + ui.add(widgets::checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"))); + ui.add(widgets::checkbox(&mut app.show_secured_notice, tl!("Show a notice when a document is secured"))); + + #[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(RichText::new(tl!("Lowering this forgets the oldest files. At 0 there is no list.")).small().color(t.text_muted)); + } + + 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." + )); + } +} + +/// Content for Card 2: Appearance & Theme +fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + if ui.add(widgets::checkbox(&mut app.show_page_edge_shadows, tl!("Show Page Edge Shadows"))).changed() { + ui.ctx().request_repaint(); + } + ui.add_space(4.0); + crate::appearance::preferences_section(ui, app, t); + ui.add_space(8.0); + reading_colours_section(ui, app, t); +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DisplayModeChoice { + Single, + TwoUp, + Continuous, +} + +/// Content for Card 3: Documents and View +fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + ui.horizontal(|ui| { + ui.label(tl!("Default workspace mode")); + segmented_pill_choice( + ui, + t, + &mut app.default_mode, + &[ + (crate::Mode::AllTools, tl!("All tools")), + (crate::Mode::Read, tl!("Read")), + (crate::Mode::Edit, tl!("Edit")), + (crate::Mode::Convert, tl!("Convert")), + (crate::Mode::Sign, tl!("E-Sign")), + ], + ); + }); + ui.label( + RichText::new(tl!("Used when opening PDFs. An explicit launch or control mode takes precedence for the session.")) + .small() + .color(t.text_muted), + ); + ui.add_space(6.0); + + let defaults = &mut app.view_defaults; + ui.horizontal(|ui| { + ui.label(tl!("Default page display")); + let mut display_choice = if defaults.continuous { + DisplayModeChoice::Continuous + } else if defaults.layout == PageLayout::TwoUp { + DisplayModeChoice::TwoUp + } else { + DisplayModeChoice::Single + }; + if segmented_pill_choice( + ui, + t, + &mut display_choice, + &[ + (DisplayModeChoice::Single, tl!("Single page")), + (DisplayModeChoice::TwoUp, tl!("Two-page view")), + (DisplayModeChoice::Continuous, tl!("Continuous scrolling")), + ], + ) { + match display_choice { + DisplayModeChoice::Single => { + defaults.layout = PageLayout::Single; + defaults.continuous = false; + } + DisplayModeChoice::TwoUp => { + defaults.layout = PageLayout::TwoUp; + defaults.continuous = false; + } + DisplayModeChoice::Continuous => { + defaults.layout = PageLayout::Single; + defaults.continuous = true; + } + } + } + }); + ui.add(widgets::checkbox(&mut defaults.show_page_gaps, tl!("Show gaps between pages"))); + ui.label(RichText::new(tl!("Hide gaps to join page edges. Margins inside the PDF remain unchanged.")).small().color(t.text_muted)); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ui.label(tl!("Default zoom")); + segmented_pill_choice( + ui, + t, + &mut defaults.fit, + &[(Fit::Width, tl!("Fit to width")), (Fit::Page, tl!("Zoom to page level")), (Fit::None, tl!("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( + 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.add_space(6.0); + + ui.horizontal(|ui| { + ui.label(tl!("Units")); + segmented_pill_choice( + ui, + t, + &mut app.units, + &[ + (Unit::Inches, Unit::Inches.label()), + (Unit::Millimetres, Unit::Millimetres.label()), + (Unit::Centimetres, Unit::Centimetres.label()), + (Unit::Points, Unit::Points.label()), + ], + ); + }); + ui.label(RichText::new(tl!("For page sizes and margins.")).small().color(t.text_muted)); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ui.label(tl!("Scroll speed (mouse wheel and arrow keys)")); + segmented_pill_choice( + ui, + t, + &mut app.scroll_speed, + &[ + (ScrollSpeed::Slow, tl_ctx!("scroll speed", ScrollSpeed::Slow.label())), + (ScrollSpeed::Normal, tl_ctx!("scroll speed", ScrollSpeed::Normal.label())), + (ScrollSpeed::Fast, tl_ctx!("scroll speed", ScrollSpeed::Fast.label())), + ], + ); + }); +} + +/// Content for Card 4: Identity +fn card_identity(ui: &mut egui::Ui, app: &mut PdfCraftApp, _t: &Tokens) { + ui.horizontal(|ui| { + let label = ui.label(tl!("Name on new comments")); + ui.add(crate::widgets::line(&mut app.comment_prefs.author).desired_width(240.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"))); +} + +/// Content for Card 5: Fill & Sign +fn card_fill_and_sign(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"))); + ui.label(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); +} + +/// Content for Card 6: JavaScript +fn card_javascript(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + 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( + 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), + ); +} + +/// Preferences dialog buttons: Cancel and OK on the right. Returns true to close. +pub(crate) fn preferences_buttons(ui: &mut egui::Ui) -> bool { + ui.add_space(10.0); + let mut close = false; + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + }); + }); + close +} + +/// Fill & Sign date pattern and date language picker. +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(RichText::new(tl!("Date format")).font(theme::semibold(12.5))); + 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(¤t.as_str()) { sample(app, ¤t) } 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); + 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(RichText::new(crate::i18n::fmt(tl!("Today: {date}"), &[("date", &today)])).color(t.text_muted)), + Err(e) => ui.label(RichText::new(e).color(Color32::from_rgb(0xD1, 0x3B, 0x3B))), + }; + }); + let bad = app.date_text(None).map(|today| pdfcraft_engine::dates::unwritable(&today)).unwrap_or_default(); + if !bad.is_empty() { + ui.label( + 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(Color32::from_rgb(0xD1, 0x3B, 0x3B)), + ); + } + ui.horizontal(|ui| { + let label = ui.label(tl!("Language")); + 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::>(); + (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( + 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), + ); +} + +/// Reading colours settings: presets, preview and custom replacement options. +fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + ui.label(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(100.0); + let sample = RichText::new("Aa").size(20.0).color(Color32::from_rgb(params.text[0], params.text[1], params.text[2])); + let button = egui::Button::new(sample) + .fill(Color32::from_rgb(params.background[0], params.background[1], params.background[2])) + .min_size(vec2(90.0, 40.0)) + .stroke(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(4.0); + if let Some(params) = app.reading_colors.params { + let text = Color32::from_rgb(params.text[0], params.text[1], params.text[2]); + let background = Color32::from_rgb(params.background[0], params.background[1], params.background[2]); + egui::Frame::new().fill(background).inner_margin(12.0).corner_radius(CornerRadius::same(6)).show(ui, |ui| { + ui.set_min_width((ui.available_width() - 24.0).max(0.0)); + ui.label(RichText::new(tl!("Reading colours")).size(16.0).color(text)); + ui.label(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(RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted)); +} diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 70c3f775e9..0a9b07764c 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -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::>(); - (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(¤t.as_str()) { sample(app, ¤t) } 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::>(); - (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, +}; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index 1ebe06b7a0..a20d786965 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -102,3 +102,98 @@ 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); + + // 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); + + // Switch continuous scrolling pill + let continuous_before = h.state().view_defaults.continuous; + h.get_by_label("Continuous scrolling").click(); + h.run_steps(2); + assert_eq!(h.state().view_defaults.continuous, !continuous_before); + + // Switch to single page mode + 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!(!h.state().view_defaults.continuous); + + // 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 cancel_button_closes_preferences_dialog() { + let mut h = harness(900.0); + assert!(h.state().dialog.is_some()); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); +} + +#[test] +fn search_settings_filters_cards_in_real_time() { + 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 in search box to filter to JavaScript card + 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(3); + + // JavaScript card is visible, General & Interface is filtered out + assert!(h.query_by_label("JavaScript").is_some()); + assert!(h.query_by_label("General & Interface").is_none()); + + // Clearing search shows everything again + h.get_by_label("Clear search").click(); + h.run_steps(3); + 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(); + let cancel = h.get_by_label("Cancel").rect(); + assert!(cancel.min.x < ok.min.x); + assert!(on_screen(ok, 600.0)); + assert!(on_screen(cancel, 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); +} diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index a02c5c05ca..1f2709cd56 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -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"); From ec533af1bb0c66e560d16ed64a5ae6f5bc7d6459 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 10:43:27 +0530 Subject: [PATCH 02/10] ui: fix two-page continuous scrolling and add keyboard focus indicators --- crates/ui-egui/src/dialogs/appearance.rs | 59 ++++++++++------- crates/ui-egui/src/dialogs/preferences.rs | 65 +++++++------------ crates/ui-egui/tests/it/preferences_layout.rs | 7 +- 3 files changed, 63 insertions(+), 68 deletions(-) diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 135337aaba..2b0ac0651c 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -1,7 +1,7 @@ //! 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}; @@ -82,30 +82,41 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { } /// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme. -/// Returns true when the card was clicked. +/// Returns true when the card was clicked or activated via keyboard. fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { - let mut title_clicked = false; - 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| { - let resp = ui.label(RichText::new(title).font(theme::semibold(12.5)).color(t.text)); - title_clicked = resp.interact(egui::Sense::click()).clicked(); - 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()); - }); - // The whole card picks its theme. - let whole = inner.response.interact(egui::Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); - title_clicked || whole.clicked() + let card_w = ui.available_width(); + let (rect, resp) = ui.allocate_exact_size(vec2(card_w, 156.0), egui::Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, active, title)); + + let press = theme::Press::track(ui, &resp); + let fill = press.fill(t.card, t.hover, t.pressed); + let stroke = if resp.has_focus() { + Stroke::new(2.0, t.accent_text) + } else if active { + Stroke::new(1.5, t.accent) + } else { + Stroke::new(1.0, t.border) + }; + ui.painter().rect(rect, CornerRadius::same(6), fill, stroke, egui::StrokeKind::Inside); + + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(8.0)).layout(egui::Layout::top_down(egui::Align::Min))); + child.horizontal(|ui| { + let font = theme::semibold(12.5); + let galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); + 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()); + } + }); + child.add_space(4.0); + let preview_w = child.available_width(); + let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); + preview(&mut child, shown, preview_w); + + 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)); + resp.clicked() || key_select } /// The Appearance section of Preferences. diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 4147417277..d3bf0ed719 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -56,8 +56,18 @@ pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &To } ui.painter().galley(rect.center() - label_galley.size() / 2.0, label_galley, text_color); + if resp.has_focus() { + ui.painter().rect_stroke( + rect.expand(1.0), + CornerRadius::same(13), + Stroke::new(2.0, t.accent_text), + egui::StrokeKind::Outside, + ); + } + let resp = theme::hand(resp); - if resp.clicked() && *current != value { + 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) && *current != value { *current = value; changed = true; } @@ -68,7 +78,6 @@ pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &To } /// An independent toggle pill button (e.g. for standalone toggles). -#[allow(dead_code)] pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { let mut changed = false; let is_active = *active; @@ -88,8 +97,13 @@ pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str ui.painter().rect(rect, CornerRadius::same(13), fill, stroke, egui::StrokeKind::Inside); ui.painter().galley(rect.center() - label_galley.size() / 2.0, label_galley, text_color); + if resp.has_focus() { + ui.painter().rect_stroke(rect.expand(1.0), CornerRadius::same(14), Stroke::new(2.0, t.accent_text), egui::StrokeKind::Outside); + } + let resp = theme::hand(resp); - if resp.clicked() { + 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 { *active = !*active; changed = true; } @@ -451,13 +465,6 @@ fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { reading_colours_section(ui, app, t); } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DisplayModeChoice { - Single, - TwoUp, - Continuous, -} - /// Content for Card 3: Documents and View fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.horizontal(|ui| { @@ -485,37 +492,13 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) let defaults = &mut app.view_defaults; ui.horizontal(|ui| { ui.label(tl!("Default page display")); - let mut display_choice = if defaults.continuous { - DisplayModeChoice::Continuous - } else if defaults.layout == PageLayout::TwoUp { - DisplayModeChoice::TwoUp - } else { - DisplayModeChoice::Single - }; - if segmented_pill_choice( - ui, - t, - &mut display_choice, - &[ - (DisplayModeChoice::Single, tl!("Single page")), - (DisplayModeChoice::TwoUp, tl!("Two-page view")), - (DisplayModeChoice::Continuous, tl!("Continuous scrolling")), - ], - ) { - match display_choice { - DisplayModeChoice::Single => { - defaults.layout = PageLayout::Single; - defaults.continuous = false; - } - DisplayModeChoice::TwoUp => { - defaults.layout = PageLayout::TwoUp; - defaults.continuous = false; - } - DisplayModeChoice::Continuous => { - defaults.layout = PageLayout::Single; - defaults.continuous = true; - } - } + let mut layout_choice = defaults.layout; + if segmented_pill_choice(ui, t, &mut layout_choice, &[(PageLayout::Single, tl!("Single page")), (PageLayout::TwoUp, tl!("Two-page view"))]) { + defaults.layout = layout_choice; + } + let mut continuous = defaults.continuous; + if toggle_pill(ui, t, &mut continuous, tl!("Continuous scrolling")) { + defaults.continuous = continuous; } }); ui.add(widgets::checkbox(&mut defaults.show_page_gaps, tl!("Show gaps between pages"))); diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index a20d786965..6c693ddff0 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -120,17 +120,18 @@ fn segmented_pill_controls_switch_modes_and_layouts() { h.run_steps(2); assert_eq!(h.state().view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp); - // Switch continuous scrolling pill + // Switch continuous scrolling pill (can be combined with Two-page view) let continuous_before = h.state().view_defaults.continuous; 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 + // 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!(!h.state().view_defaults.continuous); + assert_eq!(h.state().view_defaults.continuous, !continuous_before); // Switch tools panel side via segmented pill h.get_by_label("Left").scroll_to_me(); From 8867d15d186281f188a086a4a02ab2448c2d0603 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 10:46:10 +0530 Subject: [PATCH 03/10] ui: align navigation rail accessibility labels with existing integration test --- crates/ui-egui/src/dialogs/preferences.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index d3bf0ed719..f43383f1ee 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -404,12 +404,10 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.label(tl!("Navigation rail side")); let rail_right = app.side_panels.rail_on_right(); let mut rside = if rail_right { ToolsSide::Right } else { ToolsSide::Left }; - if segmented_pill_choice_a11y( - ui, - t, - &mut rside, - &[(ToolsSide::Left, tl!("Left"), tl!("Navigation rail on left")), (ToolsSide::Right, tl!("Right"), tl!("Navigation rail on right"))], - ) { + let left_a11y = format!("{}: {}", tl!("Navigation rail side"), tl!("Left")); + let right_a11y = format!("{}: {}", tl!("Navigation rail side"), tl!("Right")); + if segmented_pill_choice_a11y(ui, t, &mut rside, &[(ToolsSide::Left, tl!("Left"), &left_a11y), (ToolsSide::Right, tl!("Right"), &right_a11y)]) + { app.side_panels.rail_side = rside; } }); From dac706f3b275bb824591505ced52183e10381eda Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 11:02:02 +0530 Subject: [PATCH 04/10] ui: fix vertical layout containment for theme preview cards --- crates/ui-egui/src/dialogs/appearance.rs | 57 ++++++++++++------------ 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 2b0ac0651c..07a522422f 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -84,39 +84,40 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { /// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme. /// Returns true when the card was clicked or activated via keyboard. fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { - let card_w = ui.available_width(); - let (rect, resp) = ui.allocate_exact_size(vec2(card_w, 156.0), egui::Sense::click()); + let mut card_clicked = false; + let frame_stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) }; + let inner = 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 galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); + 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 = ui.interact(inner.response.rect, inner.response.id, egui::Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, active, title)); - let press = theme::Press::track(ui, &resp); - let fill = press.fill(t.card, t.hover, t.pressed); - let stroke = if resp.has_focus() { - Stroke::new(2.0, t.accent_text) - } else if active { - Stroke::new(1.5, t.accent) - } else { - Stroke::new(1.0, t.border) - }; - ui.painter().rect(rect, CornerRadius::same(6), fill, stroke, egui::StrokeKind::Inside); - - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(8.0)).layout(egui::Layout::top_down(egui::Align::Min))); - child.horizontal(|ui| { - let font = theme::semibold(12.5); - let galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); - 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()); - } - }); - child.add_space(4.0); - let preview_w = child.available_width(); - let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); - preview(&mut child, shown, preview_w); + if resp.has_focus() { + ui.painter().rect_stroke(inner.response.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)); - resp.clicked() || key_select + if resp.clicked() || key_select { + card_clicked = true; + } + card_clicked } /// The Appearance section of Preferences. From 6aa88171d328381d33fc9c6e322d3f6e044a46df Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 11:24:53 +0530 Subject: [PATCH 05/10] ui: register theme card focus upfront and assert TwoUp continuous preservation --- crates/ui-egui/src/dialogs/appearance.rs | 42 ++++++++++--------- crates/ui-egui/tests/it/preferences_layout.rs | 4 +- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 07a522422f..67ea36ce66 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -86,30 +86,32 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { let mut card_clicked = false; let frame_stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) }; - let inner = 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 galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); - 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 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 galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); + 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 = ui.interact(inner.response.rect, inner.response.id, egui::Sense::click()); + 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(inner.response.rect, CornerRadius::same(6), Stroke::new(2.0, t.accent_text), egui::StrokeKind::Outside); + ui.painter().rect_stroke(resp.rect, CornerRadius::same(6), Stroke::new(2.0, t.accent_text), egui::StrokeKind::Outside); } let resp = theme::hand(resp); diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index 6c693ddff0..f43fdf49d8 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -113,15 +113,17 @@ fn segmented_pill_controls_switch_modes_and_layouts() { 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) - let continuous_before = h.state().view_defaults.continuous; h.get_by_label("Continuous scrolling").click(); h.run_steps(2); assert_eq!(h.state().view_defaults.continuous, !continuous_before); From 0c822af3b1f18c2f03e1b9531cf3773aee20b7a4 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 12:00:22 +0530 Subject: [PATCH 06/10] ui: implement browser-style in-situ text search and highlight in preferences --- crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/dialogs/appearance.rs | 36 +- crates/ui-egui/src/dialogs/preferences.rs | 620 ++++++++++++------ crates/ui-egui/src/workflows/js_ui.rs | 2 +- crates/ui-egui/tests/it/preferences_layout.rs | 34 +- 5 files changed, 477 insertions(+), 219 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2e2457fbbf..048d519222 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1360,9 +1360,11 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) 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 - preferences::PREFERENCES_BUTTON_ROW).max(preferences::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 diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 67ea36ce66..49888c9b2d 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -83,7 +83,7 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { /// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme. /// Returns true when the card was clicked or activated via keyboard. -fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { +fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)], query: &str) -> bool { 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| { @@ -93,7 +93,13 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The ui.set_width(ui.available_width()); ui.horizontal(|ui| { let font = theme::semibold(12.5); - let galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); + 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 { @@ -123,10 +129,21 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The } /// 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 _ = preferences_section_with_query(ui, app, t, ""); +} + +/// The Appearance section of Preferences with live search highlighting. +pub fn preferences_section_with_query(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, query: &str) -> usize { + let mut matches = 0; + let (app_label, c) = crate::dialogs::preferences::highlight_widget_text(tl_ctx!("theme", "Appearance"), query, theme::semibold(13.0), t.text); + matches += c; + ui.label(app_label); ui.horizontal(|ui| { - let label = ui.label(tl!("Appearance mode")); + let (label_text, c) = crate::dialogs::preferences::highlight_widget_text(tl!("Appearance mode"), query, theme::regular(13.0), t.text); + matches += c; + let label = ui.label(label_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() { @@ -140,18 +157,23 @@ 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; + matches += crate::dialogs::preferences::find_match_ranges(tl!("Light theme"), query).len(); + matches += crate::dialogs::preferences::find_match_ranges(tl!("Dark theme"), query).len(); ui.columns(2, |cols| { let [light, dark] = cols else { return }; - if card(light, t, tl!("Light theme"), light_active, LIGHT_THEMES) { + if card(light, t, tl!("Light theme"), light_active, LIGHT_THEMES, query) { app.execute(mode_command(ThemePreference::Light)); } - if card(dark, t, tl!("Dark theme"), !light_active, DARK_THEMES) { + if card(dark, t, tl!("Dark theme"), !light_active, DARK_THEMES, query) { app.execute(mode_command(ThemePreference::Dark)); } }); 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"))); + let (fields_text, c) = crate::dialogs::preferences::highlight_widget_text(tl!("Highlight fields"), query, theme::regular(13.0), t.text); + matches += c; + ui.add(crate::widgets::checkbox(&mut app.view_defaults.highlight_fields, fields_text)); + matches } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index f43383f1ee..5ee1c4f615 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -22,13 +22,26 @@ pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0; /// /// Draws a rounded container with options inside. The active option is highlighted /// with the accent colour. Emits AccessKit RadioButton widget info with the option's label. +#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] pub fn segmented_pill_choice(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool { let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect(); segmented_pill_choice_a11y(ui, t, current, &with_a11y) } /// A segmented pill selection bar with separate accessibility labels. +#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { + segmented_pill_choice_a11y_query(ui, t, current, options, "") +} + +/// A segmented pill selection bar with separate accessibility labels and optional query highlighting. +pub fn segmented_pill_choice_a11y_query( + ui: &mut egui::Ui, + t: &Tokens, + current: &mut T, + options: &[(T, &str, &str)], + query: &str, +) -> bool { let mut changed = false; egui::Frame::new() .fill(t.field) @@ -42,7 +55,7 @@ pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &To let is_active = *current == value; let font = theme::medium(12.0); let text_color = if is_active { Color32::WHITE } else { t.text }; - let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); + let label_galley = layout_pill_label(ui, label, query, font, text_color); let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 18.0, 24.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, is_active, a11y_label)); @@ -78,12 +91,18 @@ pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &To } /// An independent toggle pill button (e.g. for standalone toggles). +#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { + toggle_pill_query(ui, t, active, label, "") +} + +/// An independent toggle pill button with optional query highlighting. +pub fn toggle_pill_query(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str, query: &str) -> bool { let mut changed = false; let is_active = *active; let font = theme::medium(12.0); let text_color = if is_active { Color32::WHITE } else { t.text }; - let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); + let label_galley = layout_pill_label(ui, label, query, font, text_color); let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 20.0, 26.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, is_active, label)); @@ -110,9 +129,26 @@ pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str changed } +fn layout_pill_label(ui: &egui::Ui, label: &str, query: &str, font: egui::FontId, normal_color: Color32) -> std::sync::Arc { + let ranges = find_match_ranges(label, query); + if ranges.is_empty() { + crate::bidi::layout_ui(ui, label.to_owned(), font, normal_color) + } else { + let (job, _) = create_highlight_job(label, &ranges, font, normal_color); + ui.painter().layout_job(job) + } +} + /// A standard card container for a Preferences section. /// Groups related settings with clean margins, subtle border and rounded corners. -pub fn preference_card(ui: &mut egui::Ui, t: &Tokens, title: &str, icon: Option<&str>, content: impl FnOnce(&mut egui::Ui) -> R) -> R { +pub fn preference_card( + ui: &mut egui::Ui, + t: &Tokens, + title: &str, + icon: Option<&str>, + ctx: &mut PrefSearchContext, + content: impl FnOnce(&mut egui::Ui, &mut PrefSearchContext) -> R, +) -> R { let card_fill = if t.kind == ThemeKind::Light { Color32::from_rgb(0xF7, 0xF8, 0xFA) } else { Color32::from_rgb(0x24, 0x24, 0x28) }; egui::Frame::new() .fill(card_fill) @@ -126,20 +162,186 @@ pub fn preference_card(ui: &mut egui::Ui, t: &Tokens, title: &str, icon: Opti if let Some(ic) = icon { ui.add(icons::image(ic, 16.0, t.accent)); } - ui.label(RichText::new(title).font(theme::semibold(13.5)).color(t.text)); + ctx.card_title(ui, title, t); }); ui.add_space(8.0); - content(ui) + content(ui, ctx) }) .inner } -fn matches_search(query: &str, title: &str, keywords: &[&str]) -> bool { - let q = query.trim().to_lowercase(); +/// Finds non-overlapping (start_byte, end_byte) slices in `text` matching `query` case-insensitively. +/// All byte indices are guaranteed to align to valid char boundaries. +pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> { + let q = query.trim(); if q.is_empty() { - return true; + return Vec::new(); + } + let q_chars: Vec = q.chars().flat_map(char::to_lowercase).collect(); + if q_chars.is_empty() { + return Vec::new(); + } + let char_indices: Vec<(usize, char)> = text.char_indices().collect(); + let mut ranges = Vec::new(); + let mut i = 0; + while i < char_indices.len() { + let mut matched = true; + let mut text_char_count = 0; + let mut q_idx = 0; + + while q_idx < q_chars.len() { + if i + text_char_count >= char_indices.len() { + matched = false; + break; + } + let c = char_indices.get(i + text_char_count).map(|(_, ch)| *ch).unwrap_or('\0'); + let mut c_matched = false; + for lc in c.to_lowercase() { + if q_idx < q_chars.len() && lc == q_chars.get(q_idx).copied().unwrap_or('\0') { + q_idx += 1; + c_matched = true; + } else { + c_matched = false; + break; + } + } + if !c_matched { + matched = false; + break; + } + text_char_count += 1; + } + + if matched + && q_idx == q_chars.len() + && text_char_count > 0 + && let Some(start_byte) = char_indices.get(i).map(|(pos, _)| *pos) + { + let end_byte = if let Some((pos, _)) = char_indices.get(i + text_char_count) { *pos } else { text.len() }; + ranges.push((start_byte, end_byte)); + i += text_char_count; + continue; + } + i += 1; + } + ranges +} + +/// Builds an `egui::text::LayoutJob` with yellow background for matching spans. +pub fn create_highlight_job( + text: &str, + ranges: &[(usize, usize)], + normal_font: egui::FontId, + normal_color: Color32, +) -> (egui::text::LayoutJob, usize) { + let mut job = egui::text::LayoutJob::default(); + let normal_format = egui::text::TextFormat { font_id: normal_font.clone(), color: normal_color, ..Default::default() }; + let highlight_format = egui::text::TextFormat { + font_id: egui::FontId::new(normal_font.size, egui::FontFamily::Proportional), + color: Color32::BLACK, + background: Color32::from_rgb(255, 220, 60), + ..Default::default() + }; + + let mut cursor = 0; + for &(start, end) in ranges { + if start > cursor + && let Some(sub) = text.get(cursor..start) + { + job.append(sub, 0.0, normal_format.clone()); + } + if let Some(matched) = text.get(start..end) { + job.append(matched, 0.0, highlight_format.clone()); + } + cursor = end; + } + if cursor < text.len() + && let Some(sub) = text.get(cursor..) + { + job.append(sub, 0.0, normal_format); + } + let count = ranges.len(); + (job, count) +} + +/// Creates a `WidgetText` highlighting `query` in `text` and returning match count. +pub fn highlight_widget_text(text: &str, query: &str, normal_font: egui::FontId, normal_color: Color32) -> (egui::WidgetText, usize) { + let ranges = find_match_ranges(text, query); + if ranges.is_empty() { + (egui::WidgetText::from(egui::RichText::new(text).font(normal_font).color(normal_color)), 0) + } else { + let (job, count) = create_highlight_job(text, &ranges, normal_font, normal_color); + (egui::WidgetText::from(job), count) + } +} + +/// Context for browser-style in-situ text scanning, highlighting, and auto-scrolling in Preferences. +pub(crate) struct PrefSearchContext<'a> { + pub query: &'a str, + pub total_matches: usize, + pub first_match_scrolled: bool, + pub should_scroll: bool, +} + +impl<'a> PrefSearchContext<'a> { + pub fn new(query: &'a str, should_scroll: bool) -> Self { + Self { query, total_matches: 0, first_match_scrolled: false, should_scroll } + } + + pub fn checkbox(&mut self, ui: &mut egui::Ui, checked: &mut bool, label: &str, t: &Tokens) -> egui::Response { + let (widget_text, count) = highlight_widget_text(label, self.query, theme::regular(13.0), t.text); + self.total_matches += count; + let resp = ui.add(widgets::checkbox(checked, widget_text)); + if count > 0 && self.should_scroll && !self.first_match_scrolled { + resp.scroll_to_me(Some(egui::Align::Center)); + self.first_match_scrolled = true; + } + resp + } + + pub fn label(&mut self, ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32) -> egui::Response { + let (widget_text, count) = highlight_widget_text(text, self.query, font, color); + self.total_matches += count; + let resp = ui.label(widget_text); + if count > 0 && self.should_scroll && !self.first_match_scrolled { + resp.scroll_to_me(Some(egui::Align::Center)); + self.first_match_scrolled = true; + } + resp + } + + pub fn label_primary(&mut self, ui: &mut egui::Ui, text: &str, t: &Tokens) -> egui::Response { + self.label(ui, text, theme::regular(13.0), t.text) + } + + pub fn label_secondary(&mut self, ui: &mut egui::Ui, text: &str, t: &Tokens) -> egui::Response { + self.label(ui, text, theme::regular(11.5), t.text_muted) + } + + pub fn card_title(&mut self, ui: &mut egui::Ui, title: &str, t: &Tokens) -> egui::Response { + self.label(ui, title, theme::semibold(13.5), t.text) + } + + pub fn pill_choice(&mut self, ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool { + for &(_, label) in options { + self.total_matches += find_match_ranges(label, self.query).len(); + } + let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect(); + segmented_pill_choice_a11y_query(ui, t, current, &with_a11y, self.query) + } + + pub fn pill_choice_a11y(&mut self, ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { + for &(_, label, _) in options { + self.total_matches += find_match_ranges(label, self.query).len(); + } + segmented_pill_choice_a11y_query(ui, t, current, options, self.query) + } + + pub fn toggle_pill(&mut self, ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { + let matches = find_match_ranges(label, self.query).len(); + self.total_matches += matches; + toggle_pill_query(ui, t, active, label, self.query) } - title.to_lowercase().contains(&q) || keywords.iter().any(|k| k.to_lowercase().contains(&q)) } /// Whether the interface can draw a language picker's native name. @@ -164,7 +366,7 @@ pub(crate) fn date_language_picker_name(l: &pdfcraft_engine::dates::DateLanguage } /// Interactive search box widget at the top of Preferences. -fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, rect: Rect) { +fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, matches_count: Option, rect: Rect) { let stroke = Stroke::new(1.0, t.border); ui.painter().rect(rect, CornerRadius::same(15), t.field, stroke, egui::StrokeKind::Inside); @@ -172,11 +374,48 @@ fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, r let icon_rect = Rect::from_center_size(pos2(rect.min.x + 16.0, rect.center().y), vec2(14.0, 14.0)); icons::paint(ui, icon_rect, "search", 14.0, t.text_muted); - let clear_w = if query.is_empty() { 12.0 } else { 30.0 }; - let edit_rect = Rect::from_min_max(pos2(rect.min.x + 28.0, rect.min.y), pos2(rect.max.x - clear_w, rect.max.y)); + let badge_text = if !query.trim().is_empty() { + let count = matches_count.unwrap_or(0); + if count == 1 { Some("1 match".to_string()) } else { Some(format!("{count} matches")) } + } else { + None + }; + + let mut right_cursor = rect.max.x; + let edit_id = ui.auto_id_with("search_input"); + + if !query.is_empty() { + let clear_rect = Rect::from_center_size(pos2(right_cursor - 14.0, rect.center().y), vec2(16.0, 16.0)); + let clear_resp = ui.interact(clear_rect, ui.auto_id_with("clear_search"), Sense::click()); + clear_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear search"))); + icons::paint(ui, clear_rect, "x", 12.0, if clear_resp.hovered() { t.text } else { t.text_muted }); + if clear_resp.clicked() { + query.clear(); + let state = egui::text_edit::TextEditState::default(); + state.store(ui.ctx(), edit_id); + } + right_cursor -= 28.0; + } + + if let Some(ref text) = badge_text { + let font = theme::medium(11.0); + let count = matches_count.unwrap_or(0); + let badge_color = if count > 0 { t.accent_text } else { t.text_muted }; + let galley = crate::bidi::layout_ui(ui, text.clone(), font, badge_color); + let bw = galley.size().x; + let badge_pos = pos2(right_cursor - bw - 4.0, rect.center().y - galley.size().y / 2.0); + ui.painter().galley(badge_pos, galley, badge_color); + let badge_rect = Rect::from_min_size(badge_pos, vec2(bw, 16.0)); + let badge_resp = ui.interact(badge_rect, ui.auto_id_with("search_match_count"), Sense::hover()); + badge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text.clone())); + right_cursor -= bw + 8.0; + } + + let edit_rect = Rect::from_min_max(pos2(rect.min.x + 28.0, rect.min.y), pos2(right_cursor.max(rect.min.x + 30.0), rect.max.y)); let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); let edit = child_ui.add( egui::TextEdit::singleline(query) + .id(edit_id) .hint_text(tl!("Search settings…")) .frame(egui::Frame::NONE) .align(egui::Align2::LEFT_CENTER) @@ -184,25 +423,19 @@ fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, r ); edit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Search settings…"))); - if !query.is_empty() { - let clear_rect = Rect::from_center_size(pos2(rect.max.x - 16.0, rect.center().y), vec2(16.0, 16.0)); - let clear_resp = ui.interact(clear_rect, ui.auto_id_with("clear_search"), Sense::click()); - clear_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear search"))); - icons::paint(ui, clear_rect, "x", 12.0, if clear_resp.hovered() { t.text } else { t.text_muted }); - if clear_resp.clicked() { - query.clear(); - } - } - if edit.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { query.clear(); + let state = egui::text_edit::TextEditState::default(); + state.store(ui.ctx(), edit_id); } } -/// The body of the Preferences dialog with card layout and search filtering. -pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { +/// Top header for Preferences: Title on the left, search box and match count badge on the right. +pub(crate) fn preferences_header(ui: &mut egui::Ui, t: &Tokens) { let search_id = egui::Id::unique("pdfcraft_preferences_search_query"); let mut query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + let matches_id = egui::Id::unique("pdfcraft_preferences_search_matches"); + let last_matches: usize = ui.data(|d| d.get_temp(matches_id)).unwrap_or(0); // Top Header: Title on the left, Search bar on the right. // Allocate the full available width so the search bar aligns flush with the card boxes below. @@ -216,122 +449,87 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok title_ui.label(RichText::new(tl!("Preferences")).font(theme::semibold(18.0)).color(t.text)); // Search bar on the right, right edge flush with the card box boundary: - let search_w = 210.0; + let search_w = 230.0; let search_rect = Rect::from_min_max(pos2(header_rect.max.x - search_w, header_rect.min.y), header_rect.max); + let match_count = (!query.trim().is_empty()).then_some(last_matches); let before = query.clone(); - preference_search_box_at(ui, t, &mut query, search_rect); + preference_search_box_at(ui, t, &mut query, match_count, search_rect); if query != before { - ui.data_mut(|d| d.insert_temp(search_id, query.clone())); + ui.data_mut(|d| d.insert_temp(search_id, query)); + ui.ctx().request_repaint(); } +} - ui.add_space(10.0); +/// The body of the Preferences dialog with card layout and browser-style in-situ text scanning. +pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + let search_id = egui::Id::unique("pdfcraft_preferences_search_query"); + let query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + let matches_id = egui::Id::unique("pdfcraft_preferences_search_matches"); + let last_matches: usize = ui.data(|d| d.get_temp(matches_id)).unwrap_or(0); + let last_scrolled_id = egui::Id::unique("pdfcraft_pref_last_scrolled_query"); + let last_scrolled_query: String = ui.data(|d| d.get_temp(last_scrolled_id)).unwrap_or_default(); - let mut matches_count = 0; + let should_scroll = !query.trim().is_empty() && query != last_scrolled_query; + let mut ctx = PrefSearchContext::new(&query, should_scroll); // Card 1: General & Interface - let kw1 = &[ - "general", - "interface", - "language", - "size", - "tools", - "panel", - "side", - "animation", - "motion", - "reopen", - "session", - "restore", - "reload", - "recent", - "title", - ]; let title1 = tl!("General & Interface"); - if matches_search(&query, title1, kw1) { - matches_count += 1; - preference_card(ui, t, title1, Some("settings"), |ui| { - card_general(ui, app, t); - }); - ui.add_space(12.0); - } + preference_card(ui, t, title1, Some("settings"), &mut ctx, |ui, ctx| { + card_general(ui, app, t, ctx); + }); + ui.add_space(12.0); // Card 2: Appearance & Theme - let kw2 = &["appearance", "theme", "dark", "light", "shadow", "edge", "highlight", "field", "reading", "colors", "colours", "custom", "contrast"]; let title2 = tl_ctx!("theme", "Appearance"); - if matches_search(&query, title2, kw2) { - matches_count += 1; - preference_card(ui, t, title2, Some("sun"), |ui| { - card_appearance(ui, app, t); - }); - ui.add_space(12.0); - } + preference_card(ui, t, title2, Some("sun"), &mut ctx, |ui, ctx| { + card_appearance(ui, app, t, ctx); + }); + ui.add_space(12.0); // Card 3: Documents and View - let kw3 = - &["documents", "view", "workspace", "mode", "page", "display", "continuous", "scrolling", "gaps", "zoom", "fit", "units", "scroll", "speed"]; let title3 = tl!("Documents and view"); - if matches_search(&query, title3, kw3) { - matches_count += 1; - preference_card(ui, t, title3, Some("file-text"), |ui| { - card_documents_and_view(ui, app, t); - }); - ui.add_space(12.0); - } + preference_card(ui, t, title3, Some("file-text"), &mut ctx, |ui, ctx| { + card_documents_and_view(ui, app, t, ctx); + }); + ui.add_space(12.0); // Card 4: Comment - let kw4 = &["comment", "comments", "identity", "name", "author", "preview", "hover", "highlight"]; let title4 = tl!("Comment"); - if matches_search(&query, title4, kw4) { - matches_count += 1; - preference_card(ui, t, title4, Some("message-square-text"), |ui| { - card_identity(ui, app, t); - }); - ui.add_space(12.0); - } + preference_card(ui, t, title4, Some("message-square-text"), &mut ctx, |ui, ctx| { + card_identity(ui, app, t, ctx); + }); + ui.add_space(12.0); // Card 5: Fill & Sign - let kw5 = &["fill", "sign", "flatten", "date", "format", "pattern", "today", "language", "token"]; let title5 = tl!("Fill & Sign"); - if matches_search(&query, title5, kw5) { - matches_count += 1; - preference_card(ui, t, title5, Some("pen-tool"), |ui| { - card_fill_and_sign(ui, app, t); - }); - ui.add_space(12.0); - } + preference_card(ui, t, title5, Some("pen-tool"), &mut ctx, |ui, ctx| { + card_fill_and_sign(ui, app, t, ctx); + }); + ui.add_space(12.0); // Card 6: JavaScript - let kw6 = &["javascript", "js", "scripts", "sandbox", "security", "acrobat"]; let title6 = "JavaScript"; - if matches_search(&query, title6, kw6) { - matches_count += 1; - preference_card(ui, t, title6, Some("code-xml"), |ui| { - card_javascript(ui, app, t); - }); - ui.add_space(12.0); + preference_card(ui, t, title6, Some("code-xml"), &mut ctx, |ui, ctx| { + card_javascript(ui, app, t, ctx); + }); + ui.add_space(12.0); + + if ctx.first_match_scrolled { + ui.data_mut(|d| d.insert_temp(last_scrolled_id, query.clone())); + } else if query.trim().is_empty() { + ui.data_mut(|d| d.insert_temp(last_scrolled_id, String::new())); } - // Empty search state when nothing matches - if matches_count == 0 { - ui.add_space(30.0); - ui.vertical_centered(|ui| { - let (rect, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); - icons::paint(ui, rect, "search", 32.0, t.text_muted); - ui.add_space(8.0); - ui.label(RichText::new(format!("No settings match \"{query}\"")).font(theme::medium(14.0)).color(t.text_muted)); - ui.add_space(10.0); - if widgets::pill_button(ui, tl!("Clear search"), false).clicked() { - ui.data_mut(|d| d.insert_temp(search_id, String::new())); - ui.ctx().request_repaint(); - } - }); + if ctx.total_matches != last_matches { + ui.data_mut(|d| d.insert_temp(matches_id, ctx.total_matches)); + ui.ctx().request_repaint(); } } /// Content for Card 1: General & Interface -fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { +fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { ui.horizontal(|ui| { - let label = ui.label(tl!("Interface language")); + let label = ctx.label_primary(ui, tl!("Interface language"), t); let (selected, picker) = { let drawable = |text: &str| picker_text_is_drawable(ui, text); let selected = crate::i18n::Lang::from_code(&app.language) @@ -364,7 +562,7 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.add_space(16.0); use crate::ui_scale; - let label_size = ui.label(tl!("Interface size")); + let label_size = ctx.label_primary(ui, tl!("Interface size"), t); 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())]); @@ -387,35 +585,32 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { .response .labelled_by(label_size.id); }); - ui.label( - RichText::new(tl!("Auto follows the system's text size. Pages always render at the display's full resolution.")).small().color(t.text_muted), - ); + ctx.label_secondary(ui, tl!("Auto follows the system's text size. Pages always render at the display's full resolution."), t); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Tools panel side")); + ctx.label_primary(ui, tl!("Tools panel side"), t); let right = app.side_panels.tools_on_right(); let mut side = if right { ToolsSide::Right } else { ToolsSide::Left }; - if segmented_pill_choice(ui, t, &mut side, &[(ToolsSide::Left, tl!("Left")), (ToolsSide::Right, tl!("Right"))]) { + if ctx.pill_choice(ui, t, &mut side, &[(ToolsSide::Left, tl!("Left")), (ToolsSide::Right, tl!("Right"))]) { app.side_panels.tools_side = side; } ui.add_space(16.0); - ui.label(tl!("Navigation rail side")); + ctx.label_primary(ui, tl!("Navigation rail side"), t); let rail_right = app.side_panels.rail_on_right(); let mut rside = if rail_right { ToolsSide::Right } else { ToolsSide::Left }; let left_a11y = format!("{}: {}", tl!("Navigation rail side"), tl!("Left")); let right_a11y = format!("{}: {}", tl!("Navigation rail side"), tl!("Right")); - if segmented_pill_choice_a11y(ui, t, &mut rside, &[(ToolsSide::Left, tl!("Left"), &left_a11y), (ToolsSide::Right, tl!("Right"), &right_a11y)]) - { + if ctx.pill_choice_a11y(ui, t, &mut rside, &[(ToolsSide::Left, tl!("Left"), &left_a11y), (ToolsSide::Right, tl!("Right"), &right_a11y)]) { app.side_panels.rail_side = rside; } }); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Animation effects")); - segmented_pill_choice( + ctx.label_primary(ui, tl!("Animation effects"), t); + ctx.pill_choice( ui, t, &mut app.animation_effects, @@ -429,45 +624,45 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.add_space(6.0); #[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"))); + ctx.checkbox(ui, &mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"), t); #[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"))); + ctx.checkbox(ui, &mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"), t); #[cfg(not(target_arch = "wasm32"))] - ui.add(widgets::checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"))); - ui.add(widgets::checkbox(&mut app.show_secured_notice, tl!("Show a notice when a document is secured"))); + ctx.checkbox(ui, &mut app.reload_changed_files, tl!("Reload documents when their files change on disk"), t); + ctx.checkbox(ui, &mut app.show_secured_notice, tl!("Show a notice when a document is secured"), t); #[cfg(not(target_arch = "wasm32"))] { ui.horizontal(|ui| { - let label = ui.label(tl!("Recent files to keep")); + let label = ctx.label_primary(ui, tl!("Recent files to keep"), t); ui.add(egui::DragValue::new(&mut app.recent_limit).range(0..=crate::RECENT_FILES_MAX)).labelled_by(label.id); }); - ui.label(RichText::new(tl!("Lowering this forgets the oldest files. At 0 there is no list.")).small().color(t.text_muted)); + ctx.label_secondary(ui, tl!("Lowering this forgets the oldest files. At 0 there is no list."), t); } if app.window_controls.is_some() { - ui.add(widgets::checkbox(&mut app.system_title_bar, tl!("Use system title bar"))).on_hover_text(tl!( + ctx.checkbox(ui, &mut app.system_title_bar, tl!("Use system title bar"), t).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." )); } } /// Content for Card 2: Appearance & Theme -fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - if ui.add(widgets::checkbox(&mut app.show_page_edge_shadows, tl!("Show Page Edge Shadows"))).changed() { +fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + if ctx.checkbox(ui, &mut app.show_page_edge_shadows, tl!("Show Page Edge Shadows"), t).changed() { ui.ctx().request_repaint(); } ui.add_space(4.0); - crate::appearance::preferences_section(ui, app, t); + ctx.total_matches += crate::appearance::preferences_section_with_query(ui, app, t, ctx.query); ui.add_space(8.0); - reading_colours_section(ui, app, t); + reading_colours_section(ui, app, t, ctx); } /// Content for Card 3: Documents and View -fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { +fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { ui.horizontal(|ui| { - ui.label(tl!("Default workspace mode")); - segmented_pill_choice( + ctx.label_primary(ui, tl!("Default workspace mode"), t); + ctx.pill_choice( ui, t, &mut app.default_mode, @@ -480,32 +675,28 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ], ); }); - ui.label( - RichText::new(tl!("Used when opening PDFs. An explicit launch or control mode takes precedence for the session.")) - .small() - .color(t.text_muted), - ); + ctx.label_secondary(ui, tl!("Used when opening PDFs. An explicit launch or control mode takes precedence for the session."), t); ui.add_space(6.0); let defaults = &mut app.view_defaults; ui.horizontal(|ui| { - ui.label(tl!("Default page display")); + ctx.label_primary(ui, tl!("Default page display"), t); let mut layout_choice = defaults.layout; - if segmented_pill_choice(ui, t, &mut layout_choice, &[(PageLayout::Single, tl!("Single page")), (PageLayout::TwoUp, tl!("Two-page view"))]) { + if ctx.pill_choice(ui, t, &mut layout_choice, &[(PageLayout::Single, tl!("Single page")), (PageLayout::TwoUp, tl!("Two-page view"))]) { defaults.layout = layout_choice; } let mut continuous = defaults.continuous; - if toggle_pill(ui, t, &mut continuous, tl!("Continuous scrolling")) { + if ctx.toggle_pill(ui, t, &mut continuous, tl!("Continuous scrolling")) { defaults.continuous = continuous; } }); - ui.add(widgets::checkbox(&mut defaults.show_page_gaps, tl!("Show gaps between pages"))); - ui.label(RichText::new(tl!("Hide gaps to join page edges. Margins inside the PDF remain unchanged.")).small().color(t.text_muted)); + ctx.checkbox(ui, &mut defaults.show_page_gaps, tl!("Show gaps between pages"), t); + ctx.label_secondary(ui, tl!("Hide gaps to join page edges. Margins inside the PDF remain unchanged."), t); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Default zoom")); - segmented_pill_choice( + ctx.label_primary(ui, tl!("Default zoom"), t); + ctx.pill_choice( ui, t, &mut defaults.fit, @@ -516,17 +707,13 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) (defaults.fit, defaults.zoom) = (Fit::None, percent / 100.0); } }); - ui.label( - 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"))); + ctx.label_secondary(ui, tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages."), t); + ctx.checkbox(ui, &mut app.always_default_view, tl!("Always use my default zoom and page layout"), t); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Units")); - segmented_pill_choice( + ctx.label_primary(ui, tl!("Units"), t); + ctx.pill_choice( ui, t, &mut app.units, @@ -538,12 +725,12 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ], ); }); - ui.label(RichText::new(tl!("For page sizes and margins.")).small().color(t.text_muted)); + ctx.label_secondary(ui, tl!("For page sizes and margins."), t); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Scroll speed (mouse wheel and arrow keys)")); - segmented_pill_choice( + ctx.label_primary(ui, tl!("Scroll speed (mouse wheel and arrow keys)"), t); + ctx.pill_choice( ui, t, &mut app.scroll_speed, @@ -557,34 +744,34 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) } /// Content for Card 4: Identity -fn card_identity(ui: &mut egui::Ui, app: &mut PdfCraftApp, _t: &Tokens) { +fn card_identity(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { ui.horizontal(|ui| { - let label = ui.label(tl!("Name on new comments")); + let label = ctx.label_primary(ui, tl!("Name on new comments"), t); ui.add(crate::widgets::line(&mut app.comment_prefs.author).desired_width(240.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"))); + ctx.checkbox(ui, &mut app.comment_prefs.hide_empty_highlight_hover, tl!("Hide hover previews for highlights without comment text"), t); } /// Content for Card 5: Fill & Sign -fn card_fill_and_sign(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"))); - ui.label(RichText::new(tl!("Text, marks and signatures become part of the page. Other comments stay editable.")).small().color(t.text_muted)); +fn card_fill_and_sign(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + ctx.checkbox(ui, &mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"), t); + ctx.label_secondary(ui, tl!("Text, marks and signatures become part of the page. Other comments stay editable."), t); ui.add_space(8.0); - date_format(ui, app, t); + date_format(ui, app, t, ctx); } /// Content for Card 6: JavaScript -fn card_javascript(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { +fn card_javascript(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { let mut on = app.session.javascript(); - if ui.add(widgets::checkbox(&mut on, tl!("Enable Acrobat JavaScript"))).changed() { + if ctx.checkbox(ui, &mut on, tl!("Enable Acrobat JavaScript"), t).changed() { app.session.set_javascript(on); } - ui.label( - RichText::new(tl!( + ctx.label_secondary( + ui, + 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), + ), + t, ); } @@ -606,12 +793,12 @@ pub(crate) fn preferences_buttons(ui: &mut egui::Ui) -> bool { } /// Fill & Sign date pattern and date language picker. -fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { +fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { use pdfcraft_engine::dates::{DATE_FORMATS, DATE_LANGUAGES, MAX_DATE_FORMAT_CHARS}; - ui.label(RichText::new(tl!("Date format")).font(theme::semibold(12.5))); + ctx.label(ui, tl!("Date format"), theme::semibold(12.5), t.text); 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 label = ctx.label_primary(ui, tl!("Fill & Sign dates"), t); let current = app.session.date_format().to_string(); let selected = if DATE_FORMATS.contains(¤t.as_str()) { sample(app, ¤t) } else { tl!("Custom").to_string() }; craft_ui::ComboBox::from_id_salt("date-format") @@ -629,7 +816,7 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { .labelled_by(label.id); }); ui.horizontal(|ui| { - let label = ui.label(tl!("Pattern")); + let label = ctx.label_primary(ui, tl!("Pattern"), t); 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); if edit.changed() { @@ -640,24 +827,25 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { app.date_format_draft = None; } match app.date_text(Some(&text)) { - Ok(today) => ui.label(RichText::new(crate::i18n::fmt(tl!("Today: {date}"), &[("date", &today)])).color(t.text_muted)), - Err(e) => ui.label(RichText::new(e).color(Color32::from_rgb(0xD1, 0x3B, 0x3B))), + Ok(today) => { + let today_str = crate::i18n::fmt(tl!("Today: {date}"), &[("date", &today)]); + ctx.label_secondary(ui, &today_str, t); + } + Err(e) => { + ctx.label(ui, &e, theme::regular(13.0), Color32::from_rgb(0xD1, 0x3B, 0x3B)); + } }; }); let bad = app.date_text(None).map(|today| pdfcraft_engine::dates::unwritable(&today)).unwrap_or_default(); if !bad.is_empty() { - ui.label( - 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(Color32::from_rgb(0xD1, 0x3B, 0x3B)), + let bad_msg = 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)], ); + ctx.label(ui, &bad_msg, theme::regular(13.0), Color32::from_rgb(0xD1, 0x3B, 0x3B)); } ui.horizontal(|ui| { - let label = ui.label(tl!("Language")); + let label = ctx.label_primary(ui, tl!("Language"), t); let (follow, selected, current, names) = { let drawable = |text: &str| picker_text_is_drawable(ui, text); let interface = crate::i18n::current().picker_name(drawable); @@ -687,19 +875,18 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { .response .labelled_by(label.id); }); - ui.label( - 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), + let date_help = 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", "\\")], ); + ctx.label_secondary(ui, &date_help, t); } /// Reading colours settings: presets, preview and custom replacement options. -fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - ui.label(RichText::new(tl!("Reading colours")).font(theme::semibold(13.0))); +fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + ctx.label(ui, tl!("Reading colours"), theme::semibold(13.0), t.text); let mode = app.reading_colors.mode_name(); ui.horizontal_wrapped(|ui| { for (name, label, colors) in [ @@ -727,7 +914,7 @@ fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) if response.clicked() { app.set_reading_colors(colors); } - ui.label(tl!(label)); + ctx.label(ui, tl!(label), theme::regular(12.0), t.text); }); } }); @@ -737,28 +924,53 @@ fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) let background = Color32::from_rgb(params.background[0], params.background[1], params.background[2]); egui::Frame::new().fill(background).inner_margin(12.0).corner_radius(CornerRadius::same(6)).show(ui, |ui| { ui.set_min_width((ui.available_width() - 24.0).max(0.0)); - ui.label(RichText::new(tl!("Reading colours")).size(16.0).color(text)); - ui.label(RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).color(text)); + ctx.label(ui, tl!("Reading colours"), theme::semibold(16.0), text); + ctx.label(ui, tl!("Colours are remapped while rendering; the document itself never changes."), theme::regular(13.0), text); }); } else { - ui.label(tl!("Original")); + ctx.label_primary(ui, tl!("Original"), t); } 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")); + let text = ctx.label_primary(ui, tl!("Text"), t); changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed(); - let background = ui.label(tl!("Page background")); + let background = ctx.label_primary(ui, tl!("Page background"), t); 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(); + changed |= ctx.checkbox(ui, &mut params.only_black, tl!("Only change the colour of black text or line art"), t).changed(); + changed |= ctx.checkbox(ui, &mut params.line_art, tl!("Change the colour of line art as well as text"), t).changed(); } if changed { app.set_reading_colors(colors); } }); - ui.label(RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted)); + ctx.label_secondary(ui, tl!("Colours are remapped while rendering; the document itself never changes."), t); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_match_ranges_case_insensitive() { + let text = "Hide hover previews for highlights without comment text"; + let ranges = find_match_ranges(text, "hide hover"); + assert_eq!(ranges, vec![(0, 10)]); + assert_eq!(text.get(ranges[0].0..ranges[0].1), Some("Hide hover")); + + let text2 = "Highlight fields and highlight text"; + let ranges2 = find_match_ranges(text2, "highlight"); + assert_eq!(ranges2.len(), 2); + assert_eq!(text2.get(ranges2[0].0..ranges2[0].1), Some("Highlight")); + assert_eq!(text2.get(ranges2[1].0..ranges2[1].1), Some("highlight")); + + let ranges3 = find_match_ranges(text, "not found"); + assert!(ranges3.is_empty()); + + let ranges4 = find_match_ranges(text, ""); + assert!(ranges4.is_empty()); + } } diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 0a9b07764c..c19ffcb66a 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -318,7 +318,7 @@ pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok #[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_BUTTON_ROW, PREFERENCES_MIN_SCROLL, date_language_picker_name, preferences_body, preferences_buttons, preferences_header, }; #[cfg(test)] diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index f43fdf49d8..43e1019c10 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -160,26 +160,48 @@ fn cancel_button_closes_preferences_dialog() { } #[test] -fn search_settings_filters_cards_in_real_time() { +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 in search box to filter to JavaScript card + // 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(3); + h.run_steps(4); - // JavaScript card is visible, General & Interface is filtered out + // Cards remain visible and multiple matches are reported assert!(h.query_by_label("JavaScript").is_some()); - assert!(h.query_by_label("General & Interface").is_none()); + 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(3); + 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()); From 80ffea6df419c5006a9f05d5d73ede05e2997e67 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 12:28:19 +0530 Subject: [PATCH 07/10] ui: remove misleading cancel button from preferences dialog --- crates/ui-egui/src/dialogs/preferences.rs | 5 +---- crates/ui-egui/tests/it/preferences_layout.rs | 12 ------------ 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 5ee1c4f615..b8b85f3c47 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -775,7 +775,7 @@ fn card_javascript(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &m ); } -/// Preferences dialog buttons: Cancel and OK on the right. Returns true to close. +/// Preferences dialog buttons: OK on the right. Returns true to close. pub(crate) fn preferences_buttons(ui: &mut egui::Ui) -> bool { ui.add_space(10.0); let mut close = false; @@ -784,9 +784,6 @@ pub(crate) fn preferences_buttons(ui: &mut egui::Ui) -> bool { if widgets::pill_button(ui, tl!("OK"), true).clicked() { close = true; } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } }); }); close diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index 43e1019c10..cb5e2570dd 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -150,15 +150,6 @@ fn segmented_pill_controls_switch_modes_and_layouts() { assert_eq!(h.state().units, pdfcraft_ui_egui::units::Unit::Points); } -#[test] -fn cancel_button_closes_preferences_dialog() { - let mut h = harness(900.0); - assert!(h.state().dialog.is_some()); - h.get_by_label("Cancel").click(); - h.run_steps(3); - assert!(h.state().dialog.is_none()); -} - #[test] fn search_settings_scans_and_highlights_in_situ_without_hiding_cards() { let mut h = harness(900.0); @@ -213,10 +204,7 @@ fn preferences_dialog_scrollbar_spacing_and_controls() { 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(); - let cancel = h.get_by_label("Cancel").rect(); - assert!(cancel.min.x < ok.min.x); assert!(on_screen(ok, 600.0)); - assert!(on_screen(cancel, 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 From 7f42c375dd5e929a26955b545ce9d8e0be8211d4 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 12:45:46 +0530 Subject: [PATCH 08/10] ui: translate search match-count text and remove unused toggle_pill --- crates/ui-egui/src/dialogs/preferences.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index b8b85f3c47..baa7094b2d 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -90,12 +90,6 @@ pub fn segmented_pill_choice_a11y_query( changed } -/// An independent toggle pill button (e.g. for standalone toggles). -#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] -pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { - toggle_pill_query(ui, t, active, label, "") -} - /// An independent toggle pill button with optional query highlighting. pub fn toggle_pill_query(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str, query: &str) -> bool { let mut changed = false; @@ -376,7 +370,7 @@ fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, m let badge_text = if !query.trim().is_empty() { let count = matches_count.unwrap_or(0); - if count == 1 { Some("1 match".to_string()) } else { Some(format!("{count} matches")) } + Some(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} match", "{n} matches")) } else { None }; From b5e847d3b93a0cafc7bf606a6bbae23a9451825c Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 14:18:21 +0530 Subject: [PATCH 09/10] ui: scroll to matches in segmented pill options, toggle pills, and theme cards --- crates/ui-egui/src/dialogs/appearance.rs | 49 ++++++++------- crates/ui-egui/src/dialogs/preferences.rs | 60 +++++++++++++------ crates/ui-egui/tests/it/preferences_layout.rs | 41 +++++++++++++ 3 files changed, 110 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 49888c9b2d..2b1b35400f 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -82,8 +82,8 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { } /// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme. -/// Returns true when the card was clicked or activated via keyboard. -fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)], query: &str) -> bool { +/// 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| { @@ -125,25 +125,26 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The if resp.clicked() || key_select { card_clicked = true; } - card_clicked + (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) { - let _ = preferences_section_with_query(ui, app, t, ""); + 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. -pub fn preferences_section_with_query(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, query: &str) -> usize { - let mut matches = 0; - let (app_label, c) = crate::dialogs::preferences::highlight_widget_text(tl_ctx!("theme", "Appearance"), query, theme::semibold(13.0), t.text); - matches += c; - ui.label(app_label); +/// 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_text, c) = crate::dialogs::preferences::highlight_widget_text(tl!("Appearance mode"), query, theme::regular(13.0), t.text); - matches += c; - let label = ui.label(label_text); + 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() { @@ -157,23 +158,29 @@ pub fn preferences_section_with_query(ui: &mut egui::Ui, app: &mut PdfCraftApp, }); ui.add_space(4.0); let light_active = app.theme == ThemeKind::Light; - matches += crate::dialogs::preferences::find_match_ranges(tl!("Light theme"), query).len(); - matches += crate::dialogs::preferences::find_match_ranges(tl!("Dark theme"), query).len(); + 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, query) { + 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, query) { + 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. - let (fields_text, c) = crate::dialogs::preferences::highlight_widget_text(tl!("Highlight fields"), query, theme::regular(13.0), t.text); - matches += c; - ui.add(crate::widgets::checkbox(&mut app.view_defaults.highlight_fields, fields_text)); - matches + ctx.checkbox(ui, &mut app.view_defaults.highlight_fields, tl!("Highlight fields"), t); } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index baa7094b2d..42f9f17798 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -31,18 +31,20 @@ pub fn segmented_pill_choice(ui: &mut egui::Ui, t: &Tokens, /// A segmented pill selection bar with separate accessibility labels. #[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { - segmented_pill_choice_a11y_query(ui, t, current, options, "") + segmented_pill_choice_a11y_query(ui, t, current, options, "").0 } /// A segmented pill selection bar with separate accessibility labels and optional query highlighting. +/// Returns whether the selection changed and the Response of the first matching option, if any. pub fn segmented_pill_choice_a11y_query( ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)], query: &str, -) -> bool { +) -> (bool, Option) { let mut changed = false; + let mut first_match_resp: Option = None; egui::Frame::new() .fill(t.field) .stroke(Stroke::new(1.0, t.border)) @@ -60,6 +62,10 @@ pub fn segmented_pill_choice_a11y_query( let (rect, resp) = ui.allocate_exact_size(vec2(w + 18.0, 24.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, is_active, a11y_label)); + if first_match_resp.is_none() && !find_match_ranges(label, query).is_empty() { + first_match_resp = Some(resp.clone()); + } + if is_active { ui.painter().rect(rect, CornerRadius::same(12), t.accent, Stroke::NONE, egui::StrokeKind::Inside); } else { @@ -87,11 +93,12 @@ pub fn segmented_pill_choice_a11y_query( } }); }); - changed + (changed, first_match_resp) } /// An independent toggle pill button with optional query highlighting. -pub fn toggle_pill_query(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str, query: &str) -> bool { +/// Returns whether the active state changed and the control's Response. +pub fn toggle_pill_query(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str, query: &str) -> (bool, egui::Response) { let mut changed = false; let is_active = *active; let font = theme::medium(12.0); @@ -120,7 +127,7 @@ pub fn toggle_pill_query(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label *active = !*active; changed = true; } - changed + (changed, resp) } fn layout_pill_label(ui: &egui::Ui, label: &str, query: &str, font: egui::FontId, normal_color: Color32) -> std::sync::Arc { @@ -282,14 +289,18 @@ impl<'a> PrefSearchContext<'a> { Self { query, total_matches: 0, first_match_scrolled: false, should_scroll } } - pub fn checkbox(&mut self, ui: &mut egui::Ui, checked: &mut bool, label: &str, t: &Tokens) -> egui::Response { - let (widget_text, count) = highlight_widget_text(label, self.query, theme::regular(13.0), t.text); - self.total_matches += count; - let resp = ui.add(widgets::checkbox(checked, widget_text)); + pub fn scroll_if_first_match(&mut self, resp: &egui::Response, count: usize) { if count > 0 && self.should_scroll && !self.first_match_scrolled { resp.scroll_to_me(Some(egui::Align::Center)); self.first_match_scrolled = true; } + } + + pub fn checkbox(&mut self, ui: &mut egui::Ui, checked: &mut bool, label: &str, t: &Tokens) -> egui::Response { + let (widget_text, count) = highlight_widget_text(label, self.query, theme::regular(13.0), t.text); + self.total_matches += count; + let resp = ui.add(widgets::checkbox(checked, widget_text)); + self.scroll_if_first_match(&resp, count); resp } @@ -297,10 +308,7 @@ impl<'a> PrefSearchContext<'a> { let (widget_text, count) = highlight_widget_text(text, self.query, font, color); self.total_matches += count; let resp = ui.label(widget_text); - if count > 0 && self.should_scroll && !self.first_match_scrolled { - resp.scroll_to_me(Some(egui::Align::Center)); - self.first_match_scrolled = true; - } + self.scroll_if_first_match(&resp, count); resp } @@ -317,24 +325,38 @@ impl<'a> PrefSearchContext<'a> { } pub fn pill_choice(&mut self, ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool { + let mut matches = 0; for &(_, label) in options { - self.total_matches += find_match_ranges(label, self.query).len(); + matches += find_match_ranges(label, self.query).len(); } + self.total_matches += matches; let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect(); - segmented_pill_choice_a11y_query(ui, t, current, &with_a11y, self.query) + let (changed, match_resp) = segmented_pill_choice_a11y_query(ui, t, current, &with_a11y, self.query); + if let Some(resp) = match_resp { + self.scroll_if_first_match(&resp, matches); + } + changed } pub fn pill_choice_a11y(&mut self, ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { + let mut matches = 0; for &(_, label, _) in options { - self.total_matches += find_match_ranges(label, self.query).len(); + matches += find_match_ranges(label, self.query).len(); } - segmented_pill_choice_a11y_query(ui, t, current, options, self.query) + self.total_matches += matches; + let (changed, match_resp) = segmented_pill_choice_a11y_query(ui, t, current, options, self.query); + if let Some(resp) = match_resp { + self.scroll_if_first_match(&resp, matches); + } + changed } pub fn toggle_pill(&mut self, ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { let matches = find_match_ranges(label, self.query).len(); self.total_matches += matches; - toggle_pill_query(ui, t, active, label, self.query) + let (changed, resp) = toggle_pill_query(ui, t, active, label, self.query); + self.scroll_if_first_match(&resp, matches); + changed } } @@ -647,7 +669,7 @@ fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &m ui.ctx().request_repaint(); } ui.add_space(4.0); - ctx.total_matches += crate::appearance::preferences_section_with_query(ui, app, t, ctx.query); + crate::appearance::preferences_section_with_context(ui, app, t, ctx); ui.add_space(8.0); reading_colours_section(ui, app, t, ctx); } diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index cb5e2570dd..e0a5d4e1cb 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -210,3 +210,44 @@ fn preferences_dialog_scrollbar_spacing_and_controls() { // 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:?}" + ); +} From d266527b98b7f684aaa73339dbbb0e0f68be07cf Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 14:41:41 +0530 Subject: [PATCH 10/10] ui: fold dotted and dotless i in preferences text search --- crates/ui-egui/src/dialogs/preferences.rs | 59 ++++++++++++++++++----- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 42f9f17798..49609461d8 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -171,6 +171,20 @@ pub fn preference_card( .inner } +/// Folds a character for case-insensitive search, folding dotted/dotless i variants +/// ('I', 'i', 'İ', 'ı') to 'i', Greek sigmas to 'σ', and otherwise following `char::to_lowercase`. +fn fold_search_char(c: char, mut f: impl FnMut(char)) { + match c { + 'I' | 'i' | '\u{130}' | '\u{131}' => f('i'), + 'Σ' | 'σ' | 'ς' => f('σ'), + _ => { + for lc in c.to_lowercase() { + f(lc); + } + } + } +} + /// Finds non-overlapping (start_byte, end_byte) slices in `text` matching `query` case-insensitively. /// All byte indices are guaranteed to align to valid char boundaries. pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> { @@ -178,7 +192,10 @@ pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> { if q.is_empty() { return Vec::new(); } - let q_chars: Vec = q.chars().flat_map(char::to_lowercase).collect(); + let mut q_chars = Vec::new(); + for c in q.chars() { + fold_search_char(c, |ch| q_chars.push(ch)); + } if q_chars.is_empty() { return Vec::new(); } @@ -196,20 +213,19 @@ pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> { break; } let c = char_indices.get(i + text_char_count).map(|(_, ch)| *ch).unwrap_or('\0'); - let mut c_matched = false; - for lc in c.to_lowercase() { - if q_idx < q_chars.len() && lc == q_chars.get(q_idx).copied().unwrap_or('\0') { - q_idx += 1; - c_matched = true; - } else { - c_matched = false; - break; + let mut c_folded = ['\0'; 4]; + let mut len = 0; + fold_search_char(c, |lc| { + if len < c_folded.len() { + c_folded[len] = lc; + len += 1; } - } - if !c_matched { + }); + if len == 0 || q_idx + len > q_chars.len() || q_chars.get(q_idx..q_idx + len) != Some(&c_folded[..len]) { matched = false; break; } + q_idx += len; text_char_count += 1; } @@ -986,4 +1002,25 @@ mod tests { let ranges4 = find_match_ranges(text, ""); assert!(ranges4.is_empty()); } + + #[test] + fn test_find_match_ranges_turkish_i() { + let text = "Doldur ve İmzala"; + let ranges = find_match_ranges(text, "imzala"); + assert_eq!(ranges.len(), 1); + assert_eq!(text.get(ranges[0].0..ranges[0].1), Some("İmzala")); + + let ranges2 = find_match_ranges(text, "İMZALA"); + assert_eq!(ranges2.len(), 1); + assert_eq!(text.get(ranges2[0].0..ranges2[0].1), Some("İmzala")); + + let text2 = "Doldur ve imzala"; + let ranges3 = find_match_ranges(text2, "İmzala"); + assert_eq!(ranges3.len(), 1); + assert_eq!(text2.get(ranges3[0].0..ranges3[0].1), Some("imzala")); + + let ranges4 = find_match_ranges(text, "ımzala"); + assert_eq!(ranges4.len(), 1); + assert_eq!(text.get(ranges4[0].0..ranges4[0].1), Some("İmzala")); + } }