mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 02:21:45 +00:00
ui: implement browser-style in-situ text search and highlight in preferences
This commit is contained in:
1 parent
fa818ccc08
commit
0c822af3b1
5 files changed
+477
-219
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(¤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());
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user