From 0c822af3b1f18c2f03e1b9531cf3773aee20b7a4 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 12:00:22 +0530 Subject: [PATCH] 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());