ui: implement browser-style in-situ text search and highlight in preferences

This commit is contained in:
Combiler01 committed 2026-11-02 12:00:53 +05:30
1 parent fa818ccc08
commit 0c822af3b1
5 files changed
+477 -219

No files matched your search

+3 -1
View File
@@ -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
+29 -7
View File
@@ -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)]
+416 -204
View File
@@ -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<T: PartialEq + Copy>(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<T: PartialEq + Copy>(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<T: PartialEq + Copy>(
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<T: PartialEq + Copy>(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<T: PartialEq + Copy>(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<egui::Galley> {
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<R>(ui: &mut egui::Ui, t: &Tokens, title: &str, icon: Option<&str>, content: impl FnOnce(&mut egui::Ui) -> R) -> R {
pub fn preference_card<R>(
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<R>(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<char> = 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<T: PartialEq + Copy>(&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<T: PartialEq + Copy>(&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<usize>, 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(&current.as_str()) { sample(app, &current) } 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());
}
}
+1 -1
View File
@@ -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)]
+28 -6
View File
@@ -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());