From fbd2813d7a95be479ab4efb0ee56f29d3136c1b9 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 2 Nov 2026 06:19:05 -0700 Subject: [PATCH 01/12] M5.7: the Fill & Sign text controls use a colour dot and a size flyout The quick bar is too narrow for the "Font size:" label, number box and "Colour" text, which wrapped mid-word. They now work like Draw's controls: a divider, a colour dot that opens the swatches, and an icon that opens a Font size flyout with a slider. Picking a colour also recolours the selected Fill & Sign text, as Draw's colour does for its own marks. --- crates/ui-egui/src/hotbar.rs | 90 +++++++++++-------- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/menu.rs | 29 +++--- crates/ui-egui/tests/it/fill_sign.rs | 103 +++++++++++++++++++++- 4 files changed, 173 insertions(+), 51 deletions(-) diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index c66b7e30b0..636414502c 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -287,6 +287,30 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_ }); } +fn controls_divider(ui: &mut egui::Ui, t: &Tokens, vertical: bool) { + let (r, _) = if vertical { + ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover()) + } else { + ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover()) + }; + if vertical { + ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider)); + } else { + ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider)); + } +} + +fn selected_fill_text(app: &PdfCraftApp) -> Option<(usize, usize)> { + let (index, id) = app.active_ids()?; + let (page, annotation) = app.views.get(index)?.comments.selected?; + let doc = app.session.get(id).filter(|doc| doc.allows_annotation())?; + doc.info + .annotations + .iter() + .any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked) + .then_some((page, annotation)) +} + /// Extra controls rendered below/beside the buttons when a comment or fill tool is active. fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, vertical: bool) { let current_fill = match app.quick_tool { @@ -294,47 +318,37 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve _ => None, }; if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) { - if vertical { - ui.label(tl!("Font size:")); - } - let size = ui.add( - egui::DragValue::new(&mut app.fill_text_size) - .range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE) - .speed(0.25) - .suffix(" pt"), - ); - if ((size.changed() && !size.dragged()) || size.drag_stopped()) - && let Some((index, id)) = app.active_ids() - && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) - && app.session.get(id).is_some_and(|doc| { - doc.allows_annotation() - && doc - .info - .annotations - .iter() - .any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked) - }) - { - app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size }); - } - ui.menu_button(tl!("Colour"), |ui| { - if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) { - app.fill_text_color = color; - ui.close(); + controls_divider(ui, t, vertical); + if let Some(color) = comments::quick_bar_colour(ui, app.fill_text_color) { + app.fill_text_color = color; + if let Some((page, index)) = selected_fill_text(app) { + app.apply_edit(Edit::StyleAnnotation { page, index, color: Some(color), opacity: None, width: None, endings: None }); } - }); + } + let resp = icons::button(ui, "sliders-horizontal", 32.0, false, tl!("Font size")); + let resized = egui::Popup::menu(&resp) + .align(egui::RectAlign::RIGHT_START) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.set_min_width(180.0); + ui.label(egui::RichText::new(tl!("Font size")).font(theme::semibold(12.0))); + let size = ui.add( + egui::Slider::new(&mut app.fill_text_size, crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE) + .logarithmic(true) + .suffix(" pt"), + ); + // #1274: the size also applies to the Fill & Sign text that is selected, + // once per change (a typed value, or the end of a drag), so a drag adds + // one undo step and not one per frame. + (size.changed() && !size.dragged()) || size.drag_stopped() + }) + .is_some_and(|popup| popup.inner); + if resized && let Some((page, index)) = selected_fill_text(app) { + app.apply_edit(Edit::FontSizeAnnotation { page, index, size: app.fill_text_size }); + } } if let QuickTool::Comment(tool) = app.quick_tool { - let (r, _) = if vertical { - ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover()) - } else { - ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover()) - }; - if vertical { - ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider)); - } else { - ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider)); - } + controls_divider(ui, t, vertical); if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs) && let Some((index, id)) = app.active_ids() && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index cb8bafe642..95053a16fe 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -41,7 +41,7 @@ pub(crate) use input::{describe, fill_grabs, hit, keys, nudge_keys, page_after_t pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; -pub(crate) use menu::{attachment_items, context_menu, quick_bar_controls}; +pub(crate) use menu::{attachment_items, context_menu, quick_bar_colour, quick_bar_controls}; pub(crate) use paint::paint_page; #[cfg(test)] use paint::{changing_points, drawn_shape, edited_in_view}; diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index b577fbbd29..0e0da5caf3 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -286,25 +286,32 @@ pub fn fill_picker(ui: &mut egui::Ui, current: Option) -> Option Option { - let style = prefs.style(tool); - let mut selected_color = None; - if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() { - prefs.pinned = !prefs.pinned; - } +pub(crate) fn quick_bar_colour(ui: &mut egui::Ui, current: Rgb) -> Option { let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); - ui.painter().circle_filled(r.center(), 9.0, color32(style.color)); + ui.painter().circle_filled(r.center(), 9.0, color32(current)); ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50))); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour"))); let resp = resp.on_hover_text(tl!("Colour")); + let mut picked = None; egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(c) = swatch_grid(ui, Some(style.color)) { - prefs.set_color(tool, c); - selected_color = Some(c); + if let Some(c) = swatch_grid(ui, Some(current)) { + picked = Some(c); ui.close(); } }); + picked +} + +/// The comment tools' extra quick-bar controls: pin, colour, fill, opacity and thickness. +pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) -> Option { + let style = prefs.style(tool); + if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() { + prefs.pinned = !prefs.pinned; + } + let selected_color = quick_bar_colour(ui, style.color); + if let Some(c) = selected_color { + prefs.set_color(tool, c); + } if tool.has_fill() { let resp = fill_button(ui, style.fill); egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index ce21bd9f74..979ba0ff97 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -164,7 +164,7 @@ fn fill_text_size_controls_new_text_and_survives_settings_restore() { let mut h = harness(); h.state_mut().execute("sign.fill.text"); h.run_steps(2); - h.get_by_label("Font size:"); + h.get_by_label("Font size"); h.state_mut().fill_text_size = 24.0; click(&mut h, 50.0, 250.0); assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.size), Some(24.0)); @@ -201,6 +201,8 @@ fn fill_text_size_resizes_the_selected_text() { }; let before = height(&h); let set_size = |h: &mut Harness<'static, PdfCraftApp>, value: &str| { + h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click(); + h.run_steps(2); h.query_all_by_role(egui::accesskit::Role::SpinButton) .find(|n| n.value().is_some_and(|v| v.ends_with(" pt"))) .expect("the Font size control") @@ -211,6 +213,8 @@ fn fill_text_size_resizes_the_selected_text() { h.run_steps(1); h.key_press(egui::Key::Enter); h.run_steps(4); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click(); + h.run_steps(2); }; // Nothing selected: only the size for new text changes. h.state_mut().views[0].comments.selected = None; @@ -1812,3 +1816,100 @@ fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() { h.run_steps(3); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box"); } + +fn place_selected_text(h: &mut Harness<'static, PdfCraftApp>, text: &str) { + h.state_mut().execute("sign.fill.text"); + click(h, 50.0, 250.0); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let selected = { + let s = h.state(); + s.session + .get(s.views[0].id) + .unwrap() + .info + .annotations + .iter() + .find(|a| a.contents.as_deref() == Some(text)) + .map(|a| (a.page, a.index)) + .unwrap() + }; + h.state_mut().views[0].comments.selected = Some(selected); + h.run_steps(2); +} + +fn placed_height(h: &Harness<'static, PdfCraftApp>, text: &str) -> f32 { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).unwrap(); + a.rect[3] - a.rect[1] +} + +fn placed_color(h: &Harness<'static, PdfCraftApp>, text: &str) -> Option<[f32; 3]> { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).unwrap().color +} + +#[test] +fn the_fill_text_size_flyout_opens_from_an_icon_and_leaves_a_stored_size_alone() { + let mut h = harness(); + h.state_mut().fill_text_size = 9.1; + place_selected_text(&mut h, "Keep me"); + let height = placed_height(&h, "Keep me"); + assert_eq!(h.query_all_by_role(egui::accesskit::Role::SpinButton).filter(|n| n.value().is_some_and(|v| v.ends_with(" pt"))).count(), 0); + h.get_by_label("Font size").click(); + h.run_steps(4); + assert!( + h.query_all_by_role(egui::accesskit::Role::SpinButton).any(|n| n.value().is_some_and(|v| v.ends_with(" pt"))), + "the flyout holds the size" + ); + assert_eq!(h.state().fill_text_size, 9.1, "opening the flyout does not round the size"); + assert_eq!(placed_height(&h, "Keep me"), height, "opening the flyout does not resize the selected text"); +} + +#[test] +fn dragging_the_fill_text_size_slider_is_one_undo_step() { + let mut h = harness(); + place_selected_text(&mut h, "Drag me"); + let height = placed_height(&h, "Drag me"); + h.get_by_label("Font size").click(); + h.run_steps(2); + let slider = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let y = (slider.min.y + slider.max.y) / 2.0; + let from = pos2(slider.min.x + 10.0, y); + let to = pos2(slider.max.x - 10.0, y); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.run_steps(1); + } + assert_eq!(placed_height(&h, "Drag me"), height, "nothing is applied before the release"); + h.drop_at(to); + h.run_steps(3); + assert!(placed_height(&h, "Drag me") > height, "the release resizes the selected text"); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(placed_height(&h, "Drag me"), height, "one undo takes the whole drag back"); +} + +#[test] +fn the_fill_colour_dot_recolours_the_selected_text() { + let mut h = harness(); + place_selected_text(&mut h, "Paint me"); + let before = placed_color(&h, "Paint me"); + h.get_by_label("Colour").click(); + h.run_steps(2); + h.get_by_label("Blue").click(); + h.run_steps(3); + let color = h.state().fill_text_color.map(|v| v as f32); + assert!(color[2] > color[0], "blue was selected: {color:?}"); + assert_ne!(before, Some(color)); + assert_eq!(placed_color(&h, "Paint me"), Some(color)); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(placed_color(&h, "Paint me"), before, "one undo takes the colour back"); +} From b3047998125db72655e9b9441a7cfc5f29c4dba6 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 2 Nov 2026 06:37:33 -0700 Subject: [PATCH 02/12] M5.7: the Fill & Sign Font size button uses Lucide's a-large-small icon The sliders icon is also Draw's Line thickness and doesn't say "text". Adds one icon to the Lucide set, from lucide-static 1.49.0, with its attribution. --- ATTRIBUTION.md | 3 ++- ATTRIBUTION.toml | 11 +++++++++++ assets/icons/a-large-small.svg | 18 ++++++++++++++++++ crates/ui-egui/src/hotbar.rs | 2 +- crates/ui-egui/src/widgets/icon_data.rs | 1 + crates/ui-egui/src/widgets/icons.rs | 2 +- 6 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 assets/icons/a-large-small.svg diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index fc88e8b664..6327de11ca 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (289) +## In this repository (290) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -293,6 +293,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 76f4cf40a8..7c27c8c85d 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -3489,6 +3489,17 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8" +[[asset]] +path = "assets/icons/a-large-small.svg" +title = "Lucide icon \"a-large-small\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg)" +licence = "ISC AND MIT" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "d4985fc71174d108e322c3b25cb6abe2837a115fe3f239a1918645db436743ec" + [[asset]] path = "assets/icons/move-horizontal.svg" title = "Lucide icon \"move-horizontal\"" diff --git a/assets/icons/a-large-small.svg b/assets/icons/a-large-small.svg new file mode 100644 index 0000000000..7716db3cf2 --- /dev/null +++ b/assets/icons/a-large-small.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 636414502c..a6bf36ec9f 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -325,7 +325,7 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve app.apply_edit(Edit::StyleAnnotation { page, index, color: Some(color), opacity: None, width: None, endings: None }); } } - let resp = icons::button(ui, "sliders-horizontal", 32.0, false, tl!("Font size")); + let resp = icons::button(ui, "a-large-small", 32.0, false, tl!("Font size")); let resized = egui::Popup::menu(&resp) .align(egui::RectAlign::RIGHT_START) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) diff --git a/crates/ui-egui/src/widgets/icon_data.rs b/crates/ui-egui/src/widgets/icon_data.rs index 72829fe4b8..d58ddcbab6 100644 --- a/crates/ui-egui/src/widgets/icon_data.rs +++ b/crates/ui-egui/src/widgets/icon_data.rs @@ -187,6 +187,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("wrench", include_bytes!("../../../../assets/icons/wrench.svg")), ("move-horizontal", include_bytes!("../../../../assets/icons/move-horizontal.svg")), ("pilcrow", include_bytes!("../../../../assets/icons/pilcrow.svg")), + ("a-large-small", include_bytes!("../../../../assets/icons/a-large-small.svg")), ("subscript", include_bytes!("../../../../assets/icons/subscript.svg")), ("superscript", include_bytes!("../../../../assets/icons/superscript.svg")), ("x", include_bytes!("../../../../assets/icons/x.svg")), diff --git a/crates/ui-egui/src/widgets/icons.rs b/crates/ui-egui/src/widgets/icons.rs index 5ad5425e8d..13f50a0d6c 100644 --- a/crates/ui-egui/src/widgets/icons.rs +++ b/crates/ui-egui/src/widgets/icons.rs @@ -99,7 +99,7 @@ mod tests { #[test] fn shared_catalog_preserves_existing_icons_and_fallback_uri() { - assert_eq!(ICONS.len(), 190); + assert_eq!(ICONS.len(), 191); assert!(exists("minimize-2")); for (name, original) in ICONS { let expected = From 84aa947c0f97feb51dbef78b66a402d6aab071fa Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 2 Nov 2026 07:08:41 -0700 Subject: [PATCH 03/12] M5.7: the quick bar's comments no longer say Fill & Sign widens it The Text and Date controls now fit the 32 pt column, so the bar stays at its narrowest with every tool. The centring still measures the rendered width. --- crates/ui-egui/src/canvas/bars.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index dd3fe8c498..fbd4200217 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -315,7 +315,7 @@ mod tests { #[test] fn a_wider_quick_bar_centres_by_its_own_width_and_needs_a_wider_margin() { - // Fill & Sign's Text tool widens the bar to 70 pt: it centres by that, and 72 pt no longer holds it. + // A 70 pt bar centres by that, and 72 pt no longer holds it. assert_eq!(quick_bar_offset(120.0, 70.0), 15.0); assert_eq!(quick_bar_offset(72.0, 70.0), 14.0); } From 546a9d90a5572b2216143ac6a0328dcaedd463ff Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 2 Nov 2026 07:28:12 -0700 Subject: [PATCH 04/12] M5.7: the a-large-small attribution names where the icon is embedded icon_data.rs moved to crates/ui-egui/src/widgets/. ATTRIBUTION.md is regenerated with cargo xtask assets --write. --- ATTRIBUTION.md | 2 +- ATTRIBUTION.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6327de11ca..a3855b5c70 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -293,7 +293,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs | | `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 7c27c8c85d..f287a4aede 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -3497,7 +3497,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" -usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +usage = "UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs" sha256 = "d4985fc71174d108e322c3b25cb6abe2837a115fe3f239a1918645db436743ec" [[asset]] From 0a8aa374eb1aba1bc9f87b7ec7c554e52437cbc3 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 2 Nov 2026 10:15:33 -0700 Subject: [PATCH 05/12] M5.7: pressing the Fill & Sign size slider without moving it adds no undo step The slider senses drags only, so a press and release counts as a finished drag even when the value did not move. The edit is now skipped when the selected text already has that size, compared to the three decimals the size is saved with. --- crates/ui-egui/src/hotbar.rs | 11 +++++- crates/ui-egui/tests/it/fill_sign.rs | 56 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index a6bf36ec9f..328f85e706 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -343,8 +343,15 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve (size.changed() && !size.dragged()) || size.drag_stopped() }) .is_some_and(|popup| popup.inner); - if resized && let Some((page, index)) = selected_fill_text(app) { - app.apply_edit(Edit::FontSizeAnnotation { page, index, size: app.fill_text_size }); + let size = app.fill_text_size; + if resized + && let Some((page, index)) = selected_fill_text(app) + && app + .active_ids() + .and_then(|(_, id)| app.session.get(id)?.comment_props(page, index)?.font_size) + .is_none_or(|stored| format!("{stored:.3}") != format!("{size:.3}")) + { + app.apply_edit(Edit::FontSizeAnnotation { page, index, size }); } } if let QuickTool::Comment(tool) = app.quick_tool { diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 979ba0ff97..931018214a 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1896,6 +1896,62 @@ fn dragging_the_fill_text_size_slider_is_one_undo_step() { assert_eq!(placed_height(&h, "Drag me"), height, "one undo takes the whole drag back"); } +#[test] +fn pressing_the_fill_text_size_slider_without_moving_it_adds_no_undo_step() { + let mut h = harness(); + place_selected_text(&mut h, "Hold me"); + let height = placed_height(&h, "Hold me"); + h.get_by_label("Font size").click(); + h.run_steps(2); + let slider = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let at = pos2(slider.min.x + (slider.max.x - slider.min.x) * 0.75, (slider.min.y + slider.max.y) / 2.0); + let press = |h: &mut Harness<'static, PdfCraftApp>| { + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + }; + press(&mut h); + let resized = placed_height(&h, "Hold me"); + assert!(resized > height, "the first press moves the size: {height} -> {resized}"); + press(&mut h); + assert_eq!(placed_height(&h, "Hold me"), resized); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(placed_height(&h, "Hold me"), height, "one undo takes back the only change; the second press added no step"); +} + +#[test] +fn typing_a_fill_text_size_that_rounds_to_the_stored_one_adds_no_undo_step() { + let mut h = harness(); + place_selected_text(&mut h, "Round me"); + let height = placed_height(&h, "Round me"); + h.get_by_label("Font size").click(); + h.run_steps(2); + let type_size = |h: &mut Harness<'static, PdfCraftApp>, value: &str| { + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.value().is_some_and(|v| v.ends_with(" pt"))) + .expect("the Font size control") + .click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(value.into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(4); + }; + type_size(&mut h, "36.1234"); + let resized = placed_height(&h, "Round me"); + assert!(resized > height, "the first size applies: {height} -> {resized}"); + type_size(&mut h, "36.1231"); + assert_eq!(h.state().fill_text_size, 36.1231); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(placed_height(&h, "Round me"), height, "one undo takes back the only change; the rounded retype added no step"); +} + #[test] fn the_fill_colour_dot_recolours_the_selected_text() { let mut h = harness(); From e90c92124c1695dc87780f763f70a2556e63b81e Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 30 Oct 2026 07:31:31 -0600 Subject: [PATCH 06/12] M5.7: the quick bar's Fill & Sign button shows the tool it picks The button showed a generic pen until clicked, then swapped to Add text's icon. It now works like the Comment, Highlight and Draw groups: it shows the last Fill & Sign tool used (Signature at first) and clicking it picks that tool. The flyout marks the active saved signature or initials card, and the cards get the pointing-hand cursor like the other rows. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 9 ++++-- crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/hotbar.rs | 16 +++++++++-- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/tools/fill_sign.rs | 13 ++++++--- crates/ui-egui/tests/it/comments.rs | 2 +- crates/ui-egui/tests/it/fill_sign.rs | 40 +++++++++++++++++++++++---- 9 files changed, 69 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6ac8ea8783..bb1748f705 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -108,6 +108,7 @@ impl PdfCraftApp { ui_scale_applied: None, flatten_fill_sign_on_save: false, fill_text_size: fill_sign::TEXT_SIZE, + fill_tool: fill_sign::FillTool::Signature, fill_text_color: [0.0; 3], dialog: None, updates: updates::Updates::default(), diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 0309042bf1..fee5378b6e 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -333,9 +333,12 @@ impl PdfCraftApp { field if field.starts_with("field-") => QuickTool::Field( prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?, ), - fill if fill.starts_with("fill-") => QuickTool::Fill( - fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?, - ), + fill if fill.starts_with("fill-") => { + let f = + fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?; + self.fill_tool = f; + QuickTool::Fill(f) + } other => { let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?; self.comment_prefs.group_tool[t.group()] = t; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index f072b7febe..4fdec5af8a 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -697,6 +697,7 @@ impl PdfCraftApp { } fill if crate::fill_sign::FillTool::from_command(fill).is_some() => { let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return Some(false) }; + self.fill_tool = tool; self.quick_tool = crate::QuickTool::Fill(tool); let initials = tool == crate::fill_sign::FillTool::Initials; if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 048d519222..8105a22342 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1349,6 +1349,7 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) app.signature = d.saved(); crate::fill_sign::FillTool::Signature }; + app.fill_tool = tool; app.quick_tool = crate::QuickTool::Fill(tool); app.toast = None; } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index c66b7e30b0..bbd98be2ef 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -239,8 +239,20 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_ QuickTool::Fill(f) => Some(f), _ => None, }; - let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text); - let resp = icons::button(ui, if current_fill.is_some() { shown.icon() } else { "pen-line" }, cell, current_fill.is_some(), tl!("Fill & Sign")); + let shown = app.fill_tool; + let tip = match shown { + crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials => { + let initials = shown == crate::fill_sign::FillTool::Initials; + let saved = if initials { app.initials.is_some() } else { app.signature.is_some() }; + crate::i18n::fmt( + tl!(if saved { "Place saved {what}" } else { "Add {what}" }), + &[("what", tl!(if initials { "initials" } else { "signature" }))], + ) + } + _ => tl!(shown.label()).to_string(), + }; + let tip = format!("{}: {tip}", tl!("Fill & Sign")); + let resp = icons::button(ui, shown.icon(), cell, current_fill.is_some(), &tip); let r = resp.rect; let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if current_fill.is_some() { t.accent } else { t.text_faint }, Stroke::NONE)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 643d5e90ff..ae89f96bcb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -331,6 +331,7 @@ pub struct PdfCraftApp { pub flatten_fill_sign_on_save: bool, /// Point size for new Fill & Sign text and dates, kept across launches. pub fill_text_size: f64, + pub fill_tool: fill_sign::FillTool, /// Colour for new Fill & Sign text and dates, kept across launches. pub fill_text_color: [f64; 3], pub dialog: Option, diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..9beaa9b834 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -373,20 +373,22 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, } continue; }; - ui.horizontal(|ui| { + let on = app.quick_tool == crate::QuickTool::Fill(if i == 0 { FillTool::Signature } else { FillTool::Initials }); + let wash = ui.painter().add(egui::Shape::Noop); + let row = ui.horizontal(|ui| { let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click()); let use_label = label("Use {what}"); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, use_label.clone())); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, use_label.clone())); let painter = ui.painter_at(rect); painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE); painter.rect_stroke( rect, CornerRadius::same(4), - Stroke::new(1.0, if response.hovered() { t.accent } else { t.border }), + Stroke::new(1.0, if on || response.hovered() { t.accent } else { t.border }), egui::StrokeKind::Inside, ); paint_saved(ui, rect.shrink(8.0), saved, &mut app.saved_signature_previews[i], &format!("saved-{what}")); - if response.on_hover_text(label("Place saved {what}")).clicked() { + if crate::theme::hand(response).on_hover_text(label("Place saved {what}")).clicked() { command = Some(use_id); } if crate::icons::button(ui, "pencil", 26.0, false, &label("Change {what}")).clicked() { @@ -396,6 +398,9 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, command = Some(remove_id); } }); + if on { + ui.painter().set(wash, egui::Shape::rect_filled(row.response.rect.expand(3.0), CornerRadius::same(6), t.accent_soft)); + } ui.add_space(4.0); } command diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 939de58dff..51b9eabce1 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3280,7 +3280,7 @@ fn hotbar_docked_left_does_not_override_quick_tools() { }); h.run_steps(5); // Controls in the docked bar share one layout, so none may overlap (#5890). - let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign", "Hotbar settings"]; + let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign: Add signature", "Hotbar settings"]; let rects: Vec<_> = labels.iter().map(|&label| h.get_by_label(label).rect()).collect(); for (i, a) in rects.iter().enumerate() { for (j, b) in rects.iter().enumerate().skip(i + 1) { diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index ce21bd9f74..503080e71c 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -2,7 +2,7 @@ use egui::{Pos2, pos2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_ui_egui::fill_sign::{FillTool, SavedSig}; use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool}; @@ -423,9 +423,9 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() { let original = format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations); // The quick toolbar exposes replacement without changing the normal placement action. - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); h.get_by_label("Change signature").click(); h.run_steps(2); @@ -445,9 +445,9 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() { click(&mut h, 40.0, 200.0); assert_eq!(items(&h).len(), 2); - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); h.get_by_label("Change initials").click(); h.run_steps(2); @@ -1812,3 +1812,33 @@ fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() { h.run_steps(3); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box"); } + +#[test] +fn the_quick_bar_fill_button_shows_the_tool_it_picks() { + let mut h = harness(); + h.get_by_label("Fill & Sign: Add signature").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature)); + assert_eq!(h.state().dialog, Some(Dialog::Signature)); + + h.state_mut().dialog = None; + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(2); + h.get_by_label("Fill & Sign: Place saved signature").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature)); + assert_eq!(h.state().dialog, None); + h.get_by_label("Fill & Sign: Place saved signature").click(); + h.run_steps(2); + assert_eq!(h.get_by_label("Use signature").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the active tool's card is marked"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + + h.state_mut().execute("sign.fill.check"); + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(2); + h.get_by_label("Fill & Sign: Checkmark").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check), "the button keeps the last Fill & Sign tool"); +} From edee301ebf8f719a5574949e17919c1bb8cddd16 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 30 Oct 2026 07:55:28 -0600 Subject: [PATCH 07/12] M5.7: the Fill & Sign button follows the active tool and names it Tools restored without a command (leaving full screen, the temporary tool, the control channel) left the remembered Fill & Sign tool stale, so the button could show Checkmark while a click placed a signature. The bar now takes the remembered tool from the active one, which replaces the dialog's and control channel's own updates. While a Fill & Sign tool is active the button opens the flyout, so its label names the tool instead of describing placing or adding a signature. --- crates/ui-egui/src/app/control.rs | 9 +++------ crates/ui-egui/src/dialogs.rs | 1 - crates/ui-egui/src/hotbar.rs | 5 ++++- crates/ui-egui/tests/it/fill_sign.rs | 14 +++++++++++--- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index fee5378b6e..0309042bf1 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -333,12 +333,9 @@ impl PdfCraftApp { field if field.starts_with("field-") => QuickTool::Field( prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?, ), - fill if fill.starts_with("fill-") => { - let f = - fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?; - self.fill_tool = f; - QuickTool::Fill(f) - } + fill if fill.starts_with("fill-") => QuickTool::Fill( + fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?, + ), other => { let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?; self.comment_prefs.group_tool[t.group()] = t; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8105a22342..048d519222 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1349,7 +1349,6 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) app.signature = d.saved(); crate::fill_sign::FillTool::Signature }; - app.fill_tool = tool; app.quick_tool = crate::QuickTool::Fill(tool); app.toast = None; } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index bbd98be2ef..88c45bbd1e 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -239,9 +239,12 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_ QuickTool::Fill(f) => Some(f), _ => None, }; + if let Some(f) = current_fill { + app.fill_tool = f; + } let shown = app.fill_tool; let tip = match shown { - crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials => { + crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials if current_fill.is_none() => { let initials = shown == crate::fill_sign::FillTool::Initials; let saved = if initials { app.initials.is_some() } else { app.signature.is_some() }; crate::i18n::fmt( diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 503080e71c..bd8bd30939 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -425,7 +425,7 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() { // The quick toolbar exposes replacement without changing the normal placement action. h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); - h.get_by_label("Fill & Sign: Place saved signature").click(); + h.get_by_label("Fill & Sign: Sign").click(); h.run_steps(2); h.get_by_label("Change signature").click(); h.run_steps(2); @@ -447,7 +447,7 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() { h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); - h.get_by_label("Fill & Sign: Place saved signature").click(); + h.get_by_label("Fill & Sign: Sign").click(); h.run_steps(2); h.get_by_label("Change initials").click(); h.run_steps(2); @@ -1829,7 +1829,7 @@ fn the_quick_bar_fill_button_shows_the_tool_it_picks() { h.run_steps(2); assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature)); assert_eq!(h.state().dialog, None); - h.get_by_label("Fill & Sign: Place saved signature").click(); + h.get_by_label("Fill & Sign: Sign").click(); h.run_steps(2); assert_eq!(h.get_by_label("Use signature").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the active tool's card is marked"); h.key_press(egui::Key::Escape); @@ -1841,4 +1841,12 @@ fn the_quick_bar_fill_button_shows_the_tool_it_picks() { h.get_by_label("Fill & Sign: Checkmark").click(); h.run_steps(2); assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check), "the button keeps the last Fill & Sign tool"); + + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Initials); + h.run_steps(2); + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(2); + h.get_by_label("Fill & Sign: Add initials").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials), "a tool restored without a command still updates the button"); } From 2e699f489a093a4edb8e57cd7f6bd8c10cf455f6 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 2 Nov 2026 05:33:39 -0700 Subject: [PATCH 08/12] M5.7: a test that the Fill & Sign button keeps Date across documents Picks Date from the flyout, deselects it, and checks that the button still shows Date and that a click picks it, in a newly opened document and again after every document has been closed. --- crates/ui-egui/tests/it/fill_sign.rs | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index bd8bd30939..77e4420ba2 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1850,3 +1850,36 @@ fn the_quick_bar_fill_button_shows_the_tool_it_picks() { h.run_steps(2); assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials), "a tool restored without a command still updates the button"); } + +#[test] +fn the_quick_bar_fill_button_keeps_date_after_deselecting_and_across_documents() { + let mut h = harness(); + h.get_by_label("Fill & Sign: Add signature").click_secondary(); + h.run_steps(2); + h.get_by_label("Date").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date)); + h.get_by_label("Select (V)").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select); + + h.state_mut().open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap(); + h.state_mut().active = Some(1); + h.run_steps(2); + h.get_by_label("Fill & Sign: Date").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date), "a click picks the shown tool, not the default"); + assert_eq!(h.state().dialog, None, "the default tool's dialog stays closed"); + h.get_by_label("Select (V)").click(); + h.run_steps(2); + + h.state_mut().close_tab(1); + h.state_mut().close_tab(0); + h.run_steps(2); + h.state_mut().open_bytes("third.pdf", None, FIXTURE.to_vec()).unwrap(); + h.state_mut().active = Some(0); + h.run_steps(2); + h.get_by_label("Fill & Sign: Date").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date), "the tool outlives closing every document"); +} From 6028bdab7c13b0d11c5495f1eb91b1a10f898f41 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 2 Nov 2026 06:47:43 -0700 Subject: [PATCH 09/12] M5.7: the Fill & Sign button remembers tools where they are selected The quick bar copied the active tool into fill_tool on every draw, so a tool restored on leaving full screen replaced the last selection, and a tool selected while the bar was hidden was forgotten. The signature dialog and the control channel now record the tool when they select it, and the bar only reads: the active tool if there is one, otherwise the remembered one. --- crates/ui-egui/src/app/control.rs | 9 ++++-- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/hotbar.rs | 5 +-- crates/ui-egui/tests/it/fill_sign.rs | 46 ++++++++++++++++++++++++++-- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 0309042bf1..443b970148 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -333,9 +333,12 @@ impl PdfCraftApp { field if field.starts_with("field-") => QuickTool::Field( prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?, ), - fill if fill.starts_with("fill-") => QuickTool::Fill( - fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?, - ), + fill if fill.starts_with("fill-") => { + let tool = + fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?; + self.fill_tool = tool; + QuickTool::Fill(tool) + } other => { let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?; self.comment_prefs.group_tool[t.group()] = t; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 048d519222..8105a22342 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1349,6 +1349,7 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) app.signature = d.saved(); crate::fill_sign::FillTool::Signature }; + app.fill_tool = tool; app.quick_tool = crate::QuickTool::Fill(tool); app.toast = None; } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 88c45bbd1e..2b1ec38a96 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -239,10 +239,7 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_ QuickTool::Fill(f) => Some(f), _ => None, }; - if let Some(f) = current_fill { - app.fill_tool = f; - } - let shown = app.fill_tool; + let shown = current_fill.unwrap_or(app.fill_tool); let tip = match shown { crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials if current_fill.is_none() => { let initials = shown == crate::fill_sign::FillTool::Initials; diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 77e4420ba2..241726cc9e 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1841,14 +1841,54 @@ fn the_quick_bar_fill_button_shows_the_tool_it_picks() { h.get_by_label("Fill & Sign: Checkmark").click(); h.run_steps(2); assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check), "the button keeps the last Fill & Sign tool"); +} - h.state_mut().quick_tool = QuickTool::Fill(FillTool::Initials); +#[test] +fn a_restored_fill_tool_shows_without_replacing_the_last_selection() { + let mut h = harness(); + h.state_mut().execute("sign.fill.date"); h.run_steps(2); + h.state_mut().apply_full_screen(true); + h.run_steps(2); + h.state_mut().execute("sign.fill.check"); + h.run_steps(2); + h.state_mut().apply_full_screen(false); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date)); + h.get_by_label("Fill & Sign: Date"); + h.state_mut().quick_tool = QuickTool::Select; h.run_steps(2); - h.get_by_label("Fill & Sign: Add initials").click(); + h.get_by_label("Fill & Sign: Checkmark").click(); h.run_steps(2); - assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials), "a tool restored without a command still updates the button"); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check)); +} + +#[test] +fn a_fill_tool_selected_while_the_quick_bar_is_hidden_is_remembered() { + let mut h = harness(); + let mode = h.state().mode; + h.state_mut().set_option("mode", "read").unwrap(); + h.run_steps(2); + h.state_mut().set_option("quick", "fill-date").unwrap(); + h.run_steps(2); + h.state_mut().set_option("quick", "select").unwrap(); + h.state_mut().mode = mode; + h.run_steps(2); + h.get_by_label("Fill & Sign: Date"); + + h.state_mut().initials = Some(SavedSig::Typed("AL".into())); + h.state_mut().set_option("mode", "read").unwrap(); + h.run_steps(2); + h.state_mut().execute("sign.fill.initials.change"); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials)); + h.state_mut().quick_tool = QuickTool::Select; + h.state_mut().mode = mode; + h.run_steps(2); + h.get_by_label("Fill & Sign: Place saved initials"); } #[test] From 36d24eae6e78b1c7d154eebdf86ae411a98fca4f Mon Sep 17 00:00:00 2001 From: Hasan Date: Mon, 2 Nov 2026 18:04:58 +0500 Subject: [PATCH 10/12] M14: Preferences can turn off the update check at start (#5737, #5460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app asks for a newer release on its first frame, and nothing could stop it, though the README says PdfCraft works offline. A preference, on by default so nothing changes for anyone who leaves it, keeps the app off the network until Help ▸ Check for updates is chosen. It has its own settings key: check_updates_at_start was an opt-in, off unless chosen, so settings written then would turn this off unasked. --- ROADMAP.md | 2 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 3 + crates/ui-egui/src/app/prefs.rs | 6 ++ crates/ui-egui/src/dialogs/preferences.rs | 2 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/platform/updates.rs | 6 +- crates/ui-egui/tests/it/updates.rs | 76 +++++++++++++++++++++++ docs/roadmap.md | 1 + parity/acrobat-features.toml | 4 +- 10 files changed, 100 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5bfe1fc817..0a03866be3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -231,6 +231,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer. +- **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks. + - **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged. - **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6ac8ea8783..d2cec1a29f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -128,6 +128,7 @@ impl PdfCraftApp { last_session: Default::default(), quit_session: None, reload_changed_files: true, + update_check_at_start: true, show_secured_notice: true, always_default_view: false, #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 0309042bf1..403af6f4c6 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -47,6 +47,9 @@ impl PdfCraftApp { ("show_page_edge_shadows", _) => { self.show_page_edge_shadows = value.parse::().map_err(|_| "show_page_edge_shadows must be true or false")?; } + ("update_check_at_start", _) => { + self.update_check_at_start = value.parse::().map_err(|_| "update_check_at_start must be true or false")?; + } ("scroll_speed", _) => { self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?; } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 08a20afb45..0df7d6f30b 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -147,6 +147,9 @@ impl PdfCraftApp { // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); v["show_page_edge_shadows"] = self.show_page_edge_shadows.into(); + // Not `check_updates_at_start`: that key was an opt-in of its own, off unless chosen, and + // settings written then would turn this off unasked. + v["update_check_at_start"] = self.update_check_at_start.into(); v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default(); v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); @@ -219,6 +222,9 @@ impl PdfCraftApp { if let Some(on) = v["reload_changed_files"].as_bool() { self.reload_changed_files = on; } + if let Some(on) = v["update_check_at_start"].as_bool() { + self.update_check_at_start = on; + } if let Some(on) = v["show_secured_notice"].as_bool() { self.show_secured_notice = on; } diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 9e3182e416..ea985f3207 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -659,6 +659,8 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut 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"))] ctx.checkbox(ui, &mut app.reload_changed_files, tl!("Reload documents when their files change on disk"), t); + #[cfg(not(target_arch = "wasm32"))] + ctx.checkbox(ui, &mut app.update_check_at_start, tl!("Check for updates when PdfCraft starts"), t); ctx.checkbox(ui, &mut app.show_secured_notice, tl!("Show a notice when a document is secured"), t); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 643d5e90ff..cb6c99bbe5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -370,6 +370,9 @@ pub struct PdfCraftApp { quit_session: Option, /// Preferences: reload a document when its file changes on disk (#431). pub reload_changed_files: bool, + /// Preferences: ask for a newer release when the desktop app starts (#5737). Off, the app + /// asks only from Help ▸ Check for updates. + pub update_check_at_start: bool, /// Preferences: tell the user, above the pages, that a document is secured (its security /// settings restrict changes). Off only hides that notice (#4035); kept in the settings. pub show_secured_notice: bool, diff --git a/crates/ui-egui/src/platform/updates.rs b/crates/ui-egui/src/platform/updates.rs index 6b7ea291fa..622006cd6e 100644 --- a/crates/ui-egui/src/platform/updates.rs +++ b/crates/ui-egui/src/platform/updates.rs @@ -21,7 +21,8 @@ pub(crate) struct Updates { updater: Updater, /// The Updates dialog is showing. pub(crate) open: bool, - /// Ask for a newer release at start; the desktop app turns it on, so tests never reach the network. + /// Ask for a newer release at start, unless Preferences says not to; the desktop app turns + /// it on, so tests never reach the network. #[cfg(not(target_arch = "wasm32"))] startup_check: bool, #[cfg(not(target_arch = "wasm32"))] @@ -73,10 +74,11 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] { let Some(ctx) = self.ctx.clone() else { return }; + let at_start = self.update_check_at_start; let updates = &mut self.updates; if std::mem::take(&mut updates.check_requested) { updates.updater.check_now(&ctx); - } else if updates.startup_check { + } else if updates.startup_check && at_start { updates.updater.check_at_start(&ctx); } updates.updater.tick(&ctx); diff --git a/crates/ui-egui/tests/it/updates.rs b/crates/ui-egui/tests/it/updates.rs index 3ea55e434e..469a42f805 100644 --- a/crates/ui-egui/tests/it/updates.rs +++ b/crates/ui-egui/tests/it/updates.rs @@ -100,6 +100,82 @@ fn nothing_is_asked_until_the_user_checks() { h.get_by_label_contains("PdfCraft 99.0.0 is available"); } +/// The desktop app's start, with `settings` restored, and the count of what the source was asked. +fn started_with(settings: &'static str) -> (Harness<'static, PdfCraftApp>, Arc) { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counted = calls.clone(); + let h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.set_startup_update_check(true); + app.restore(settings); + let counted = counted.clone(); + app.set_update_source(Arc::new(move || { + counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(Some(release("v99.0.0"))) + })); + app + }); + (h, calls) +} + +/// Run frames for `time`, or until the source has been asked. The check at start shows nothing +/// while it runs, so `settle` can't wait for it. +fn run_until_asked(h: &mut Harness<'static, PdfCraftApp>, calls: &std::sync::atomic::AtomicUsize, time: std::time::Duration) { + let deadline = std::time::Instant::now() + time; + while calls.load(std::sync::atomic::Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +#[test] +fn the_preference_keeps_the_check_at_start_from_reaching_the_network() { + use std::sync::atomic::Ordering::SeqCst; + assert!(PdfCraftApp::new().update_check_at_start, "on by default"); + + // The same start with the preference left on does ask, so a count of none below means + // the preference held the check back and not that it hadn't run yet. + let (mut h, calls) = started_with("{}"); + run_until_asked(&mut h, &calls, std::time::Duration::from_secs(30)); + assert_eq!(calls.load(SeqCst), 1, "on, the app asks at start"); + + let (mut h, calls) = started_with(r#"{"update_check_at_start": false}"#); + run_until_asked(&mut h, &calls, std::time::Duration::from_millis(500)); + assert_eq!(calls.load(SeqCst), 0, "nothing is asked at start"); + + let mut restarted = PdfCraftApp::new(); + restarted.restore(&h.state().persist()); + assert!(!restarted.update_check_at_start, "the choice is kept"); + + // Help ▸ Check for updates still asks. + h.state_mut().execute("help.check_updates"); + settle(&mut h); + assert_eq!(calls.load(SeqCst), 1); + h.get_by_label_contains("PdfCraft 99.0.0 is available"); +} + +#[test] +fn preferences_turn_the_check_at_start_off() { + let (mut h, _calls) = started_with(r#"{"update_check_at_start": true}"#); + h.state_mut().execute("app.preferences"); + h.run_steps(3); + h.get_by_label("Check for updates when PdfCraft starts").click(); + h.run_steps(3); + assert!(!h.state().update_check_at_start); + // The control channel's option, as an agent sets it. + h.state_mut().set_option("update_check_at_start", "true").unwrap(); + assert!(h.state().update_check_at_start); + assert!(h.state_mut().set_option("update_check_at_start", "maybe").is_err()); +} + +#[test] +fn settings_from_the_old_opt_in_leave_the_check_at_start_on() { + let mut app = PdfCraftApp::new(); + app.restore(r#"{"check_updates_at_start": false}"#); + assert!(app.update_check_at_start, "a key of the removed option decides nothing"); +} + #[test] fn the_check_at_start_offers_the_prompt_and_later_dismisses_it() { let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { diff --git a/docs/roadmap.md b/docs/roadmap.md index f376f7cb00..1c99dddeb8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -307,6 +307,7 @@ Estimates unchanged. | 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | | 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | | 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer | +| 2026-11-02 | patch | M14 #5737, #5460: Preferences turns off the update check at start, on by default | | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | | 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8b7ac909a0..00b3dae2c2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1778,8 +1778,8 @@ milestone = "M3" status = "partial" commands = ["help.check_updates"] tools = [] -evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks"] -notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved" +evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks", "crates/ui-egui/tests/it/updates.rs::the_preference_keeps_the_check_at_start_from_reaching_the_network", "crates/ui-egui/tests/it/updates.rs::settings_from_the_old_opt_in_leave_the_check_at_start_on"] +notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved; Preferences ▸ Check for updates when PdfCraft starts (update_check_at_start, on by default) turns the check at start off, leaving only the request from the Help menu" [[feature]] id = "view.single-key-accelerators" From 1c4ef83491d414b09eba4a033836815b066dcd87 Mon Sep 17 00:00:00 2001 From: Hasan Date: Mon, 2 Nov 2026 21:04:08 +0500 Subject: [PATCH 11/12] M14: decide the update check at start once, on the first frame (#5737) Review of #5758: with the preference off, the startup request stayed pending, so turning the preference on later in the session sent a network request at once. The request is now consumed on the first poll whatever the preference says, and a later change waits for the next start. --- crates/ui-egui/src/platform/updates.rs | 5 +++-- crates/ui-egui/tests/it/updates.rs | 11 +++++++++++ parity/acrobat-features.toml | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/platform/updates.rs b/crates/ui-egui/src/platform/updates.rs index 622006cd6e..bcf6783cdb 100644 --- a/crates/ui-egui/src/platform/updates.rs +++ b/crates/ui-egui/src/platform/updates.rs @@ -74,11 +74,12 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] { let Some(ctx) = self.ctx.clone() else { return }; - let at_start = self.update_check_at_start; + // Settled on the first frame: a preference turned on later waits for the next start. + let at_start = std::mem::take(&mut self.updates.startup_check) && self.update_check_at_start; let updates = &mut self.updates; if std::mem::take(&mut updates.check_requested) { updates.updater.check_now(&ctx); - } else if updates.startup_check && at_start { + } else if at_start { updates.updater.check_at_start(&ctx); } updates.updater.tick(&ctx); diff --git a/crates/ui-egui/tests/it/updates.rs b/crates/ui-egui/tests/it/updates.rs index 469a42f805..e6cc09d7c5 100644 --- a/crates/ui-egui/tests/it/updates.rs +++ b/crates/ui-egui/tests/it/updates.rs @@ -155,6 +155,17 @@ fn the_preference_keeps_the_check_at_start_from_reaching_the_network() { h.get_by_label_contains("PdfCraft 99.0.0 is available"); } +/// The setting is about starting: turning it on asks nothing until the next start. +#[test] +fn turning_the_check_at_start_on_waits_for_the_next_start() { + use std::sync::atomic::Ordering::SeqCst; + let (mut h, calls) = started_with(r#"{"update_check_at_start": false}"#); + h.run_steps(3); + h.state_mut().set_option("update_check_at_start", "true").unwrap(); + run_until_asked(&mut h, &calls, std::time::Duration::from_millis(500)); + assert_eq!(calls.load(SeqCst), 0, "nothing is asked this session"); +} + #[test] fn preferences_turn_the_check_at_start_off() { let (mut h, _calls) = started_with(r#"{"update_check_at_start": true}"#); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 00b3dae2c2..4aef4fdcc6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1778,8 +1778,8 @@ milestone = "M3" status = "partial" commands = ["help.check_updates"] tools = [] -evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks", "crates/ui-egui/tests/it/updates.rs::the_preference_keeps_the_check_at_start_from_reaching_the_network", "crates/ui-egui/tests/it/updates.rs::settings_from_the_old_opt_in_leave_the_check_at_start_on"] -notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved; Preferences ▸ Check for updates when PdfCraft starts (update_check_at_start, on by default) turns the check at start off, leaving only the request from the Help menu" +evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks", "crates/ui-egui/tests/it/updates.rs::turning_the_check_at_start_on_waits_for_the_next_start", "crates/ui-egui/tests/it/updates.rs::the_preference_keeps_the_check_at_start_from_reaching_the_network", "crates/ui-egui/tests/it/updates.rs::settings_from_the_old_opt_in_leave_the_check_at_start_on"] +notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved; Preferences ▸ Check for updates when PdfCraft starts (update_check_at_start, on by default) turns the check at start off, leaving only the request from the Help menu; turning it on takes effect at the next start" [[feature]] id = "view.single-key-accelerators" From 943718ea9854d217da9db7305a96087397a5ea7c Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:33:21 +0100 Subject: [PATCH 12/12] fix(ui): About credits tables fit without side-scrolling and their columns resize (#6052) * fix(ui): About credits tables fit without side-scrolling and their columns resize The Contributors table and the Models table were egui Grids inside a two-way scroll area: they opened wider than the dialog, so they scrolled sideways, and Company and Model sat almost touching. Both are now egui_extras tables. Each column opens at the width of its header and widest cell, the spare width goes to the text columns (Name; Company and Model), and every column edge can be dragged. Widening a column grows the table like a spreadsheet, and only then does it scroll sideways; the header stays put while the rows scroll. Counts and dates are right-aligned and every cell is vertically centred. The sorted column stays highlighted and its direction shows on the toolbar's sort-order button, so headers no longer reserve room for an arrow. The opening widths come from fit_widths, a pure function with unit tests that a fitted table fills its room exactly and that only the text columns give way when room is short. Co-Authored-By: Claude Opus 5.5 * Let every credits column be dragged, and refit changed tables The last column was egui's remainder column, which has no drag handle, so it could only be resized from the edge before it. It is now an ordinary column with its own handle. A table keeps its column widths, so switching to a longer name left the Name column at its old width and clipped the text. The table now goes back to the opening fit whenever that fit changes: a longer widest cell or a resized window. Dragged widths are dropped at that point. fit_widths could overflow the room when one column hit the minimum, because the other columns did not take up its share. Columns now give way in proportion to their width above the minimum. Co-Authored-By: Claude Opus 5.5 * Refit credits tables on what they measure, not the fitted widths Comparing the fitted widths missed the case it was meant for. Name is the only growing column, so a longer name takes its extra width from the spare room and the fit comes out the same: a dragged Name width stayed, clipping the longer name. It could also drop dragged widths by accident. The fit comes from the available width inside the scroll area, which scrolling can nudge, so scrolling a widened table could reset it. The table now compares the natural widths and the clip rect's width, which scrolling does not move. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs/credits.rs | 380 ++++++++++++++++++++++---- 2 files changed, 327 insertions(+), 55 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1c09dee05..0e7e2c0ed0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -132,7 +132,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { Dialog::Print => 820.0, Dialog::FieldProps | Dialog::BulkFieldProps => 600.0, Dialog::Preferences => 610.0, - Dialog::About => 780.0, + Dialog::About => 820.0, Dialog::PinTool => 540.0, _ => 520.0, }); diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index ad45ec77b6..d69d545b3f 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -266,12 +266,17 @@ pub fn contributors_ui(ui: &mut egui::Ui) { let counts = crate::i18n::fmt(crate::i18n::t("{n} contributors · {c} commits"), &[("n", &list.len().to_string()), ("c", &group(TOTAL_COMMITS))]); ui.label(RichText::new(counts).small().color(t.text_muted)); ui.separator(); - egui::ScrollArea::both().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { - if list.is_empty() { - ui.label(crate::i18n::t("No contributor data was built into this copy.")); - } else if v.table { - table(ui, &list, &mut v); - } else { + if list.is_empty() { + ui.label(crate::i18n::t("No contributor data was built into this copy.")); + } else if v.table { + // The table scrolls vertically itself, under a fixed header; it only scrolls sideways + // once its columns are dragged wider than the dialog. + ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| { + ui.set_max_height(LIST_HEIGHT); + egui::ScrollArea::horizontal().id_salt("credits_list").auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v)); + }); + } else { + egui::ScrollArea::vertical().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { ui.horizontal_wrapped(|ui| { for (i, c) in list.iter().enumerate() { if i > 0 { @@ -280,43 +285,154 @@ pub fn contributors_ui(ui: &mut egui::Ui) { ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); } }); - } - }); + }); + } ui.data_mut(|d| d.insert_temp(id, v)); } -fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { - egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { - for k in SortKey::ALL { - let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui - .button(RichText::new(format!("{}{arrow}", crate::i18n::t(k.label().1))).strong()) - .on_hover_text(crate::i18n::t(k.label().0)) - .clicked() - { - if v.key == k { - v.ascending = !v.ascending; - } else { - v.key = k; - v.ascending = k.default_ascending(); - } +/// How the contributor table's spare width is shared out: all of it to Name. +const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + +/// How the model table's spare width is shared out: to Company and Model, the text columns. +const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0]; + +/// The narrowest a column can be dragged. +const MIN_COLUMN: f32 = 28.0; + +/// The width a column needs for its header and its widest cell. +fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator) -> f32 { + let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x; + let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max); + widest + 2.0 * ui.spacing().button_padding.x +} + +/// Opening column widths: each column's natural width, with the spare room shared out by `grow`, +/// so the columns fill `room` exactly. When there is too little room, only the growing columns +/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that. +fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec { + let spare = room - natural.iter().sum::(); + let weight = grow.iter().sum::(); + let slack = |w: f32| (w - MIN_COLUMN).max(0.0); + let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum(); + natural + .iter() + .zip(grow) + .map(|(w, g)| { + if *g > 0.0 && spare >= 0.0 { + w + spare * g / weight + } else if *g > 0.0 && flexible > 0.0 { + w - (-spare).min(flexible) * slack(*w) / flexible + } else { + *w } - } - ui.end_row(); - for c in list { - ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); - ui.label(group(c.prs)); - ui.label(group(c.commits)); - ui.label(group(c.lines_added)); - ui.label(group(c.lines_deleted)); - ui.label(signed(c.lines_delta())); - ui.label(group(c.binary_added)); - ui.label(group(c.binary_deleted)); - ui.label(day(c.first_commit)); - ui.label(day(c.last_commit)); - ui.end_row(); - } - }); + }) + .collect() +} + +/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls +/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge: +/// only a table dragged wider than the window scrolls sideways. Text too long for its column is +/// clipped. When a column's natural width or the window changes, the columns go back to a new fit, +/// dropping any dragged widths. +fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> { + // Every cell is followed by the item gap, and the vertical scrollbar keeps its margins. + let s = ui.spacing(); + let gaps = s.item_spacing.x * natural.len() as f32; + let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin; + let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0)); + // The table keeps its own column widths, so a change in what they were fitted to has to clear + // them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available + // width, so this compares the natural widths and the clip rect, which scrolling never moves. + let fit = egui::Id::unique(("credits_fit", id)); + let measured = (natural.to_vec(), ui.clip_rect().width()); + let refit = ui.data_mut(|d| d.get_temp::<(Vec, f32)>(fit)).as_ref() != Some(&measured); + ui.data_mut(|d| d.insert_temp(fit, measured)); + let mut t = + egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY); + if refit { + t.reset(); + } + for w in widths { + t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true)); + } + t +} + +/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred. +fn aligned(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }; + ui.with_layout(layout, add).inner +} + +fn row_height(ui: &egui::Ui) -> f32 { + ui.text_style_height(&egui::TextStyle::Body) + 6.0 +} + +/// One contributor's table cells after the name, in `SortKey::ALL` order. +fn cells(c: &Contributor) -> [String; 9] { + [ + group(c.prs), + group(c.commits), + group(c.lines_added), + group(c.lines_deleted), + signed(c.lines_delta()), + group(c.binary_added), + group(c.binary_deleted), + day(c.first_commit).to_string(), + day(c.last_commit).to_string(), + ] +} + +/// A contributor-table header. The sorted column is highlighted; its direction shows on the +/// toolbar's sort-order button, so no arrow widens the column. +fn header_text(k: SortKey) -> RichText { + RichText::new(crate::i18n::t(k.label().1)).strong() +} + +fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { + let h = row_height(ui); + let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect(); + let widths: Vec = SortKey::ALL + .iter() + .enumerate() + .map(|(i, &k)| match i.checked_sub(1) { + None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))), + Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())), + }) + .collect(); + fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW) + .header(h + 4.0, |mut header| { + for k in SortKey::ALL { + header.col(|ui| { + let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))) + .on_hover_text(crate::i18n::t(k.label().0)); + + if r.clicked() { + if v.key == k { + v.ascending = !v.ascending; + } else { + v.key = k; + v.ascending = k.default_ascending(); + } + } + }); + } + }) + .body(|mut body| { + for (c, cells) in list.iter().zip(rows) { + body.row(h, |mut row| { + row.col(|ui| { + aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login))) + .on_hover_text(c.summary()); + }); + for text in cells { + row.col(|ui| { + aligned(ui, true, |ui| ui.label(text)); + }); + } + }); + } + }); } /// About ▸ Models: AI models credited in Co-Authored-By trailers. @@ -326,21 +442,50 @@ pub fn models_ui(ui: &mut egui::Ui) { return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); - egui::ScrollArea::both().id_salt("credits_models").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { - egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { - for h in MODEL_COLUMNS { - ui.label(RichText::new(crate::i18n::t(h)).strong()); - } - ui.end_row(); - for m in MODELS { - ui.label(m.company); - ui.label(m.model); - ui.label(m.version); - ui.label(group(m.commits)); - ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64)); - ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted))); - ui.end_row(); - } + let h = row_height(ui); + let titles = MODEL_COLUMNS; + // Company, Model and Version read as text; the counts line up on the right. + let right = [false, false, false, true, true, true]; + let rows: Vec<[String; 6]> = MODELS + .iter() + .map(|m| { + [ + m.company.to_string(), + m.model.to_string(), + m.version.to_string(), + group(m.commits), + format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64), + format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)), + ] + }) + .collect(); + let widths: Vec = titles + .iter() + .enumerate() + .map(|(i, title)| natural(ui, RichText::new(crate::i18n::t(title)).strong(), rows.iter().filter_map(|r| r.get(i).cloned()))) + .collect(); + ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| { + ui.set_max_height(LIST_HEIGHT); + egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| { + fitted_table(ui, "credits_models", &widths, &MODEL_GROW) + .header(h + 4.0, |mut header| { + for (title, r) in titles.into_iter().zip(right) { + header.col(|ui| { + aligned(ui, r, |ui| ui.label(RichText::new(crate::i18n::t(title)).strong())); + }); + } + }) + .body(|mut body| { + for cells in rows { + body.row(h, |mut row| { + for (text, r) in cells.into_iter().zip(right) { + row.col(|ui| { + aligned(ui, r, |ui| ui.label(text)); + }); + } + }); + } + }); }); }); } @@ -421,4 +566,131 @@ mod tests { assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07"); assert_eq!(day(""), "—"); } + + #[test] + fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() { + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]); + let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0); + assert!((w.iter().sum::() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}"); + } + + #[test] + fn too_little_room_shrinks_only_growing_columns() { + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]); + } + + #[test] + fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() { + let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0); + assert!((w.iter().sum::() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}"); + assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}"); + assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]); + } + + const WINDOW: f32 = 640.5; + const NATURAL: [f32; 3] = [120.0, 80.0, 80.0]; + const GROW: [f32; 3] = [1.0, 0.0, 0.0]; + + /// One frame of a table long enough to scroll; the rects of its header cells. + fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec) -> Vec { + scrolled_frame(ctx, width, natural, 0.0, events) + } + + /// [`table_frame`], scrolled `offset` points sideways. + fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec) -> Vec { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0))); + let raw = egui::RawInput { screen_rect, events, ..Default::default() }; + let mut cells = Vec::new(); + let out = ctx.run_ui(raw, |ui| { + cells.clear(); + egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| { + fitted_table(ui, "test", natural, &GROW) + .header(20.0, |mut header| { + for _ in natural { + header.col(|ui| cells.push(ui.max_rect())); + } + }) + .body(|mut body| { + for _ in 0..40 { + body.row(20.0, |mut row| { + for _ in natural { + row.col(|ui| { + ui.label("x"); + }); + } + }); + } + }); + }); + }); + out.drop_without_applying_deltas(); + cells + } + + /// A new context with the table open and settled, and the rects of its header cells. + fn opened_table() -> (egui::Context, Vec) { + let ctx = egui::Context::default(); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + (ctx, cells) + } + + /// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go. + fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) { + use egui::{Event, Modifiers, PointerButton}; + let start = egui::pos2(cell.right() + 4.0, cell.center().y); + let halfway = start + egui::vec2(by / 2.0, 0.0); + let end = start + egui::vec2(by, 0.0); + let moved = Event::PointerMoved; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] { + table_frame(ctx, WINDOW, &NATURAL, vec![event]); + } + } + + #[test] + fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() { + let (_, opened) = opened_table(); + for (dragged, cell) in opened.iter().enumerate() { + let (ctx, _) = opened_table(); + drag_edge(&ctx, *cell, -30.0); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + for (column, (was, now)) in opened.iter().zip(&after).enumerate() { + let want = if column == dragged { was.width() - 30.0 } else { was.width() }; + assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}"); + } + } + } + + #[test] + fn a_changed_fit_replaces_the_column_widths() { + let (ctx, opened) = opened_table(); + drag_edge(&ctx, opened[0], -30.0); + let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new()); + assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}"); + let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new()); + let widths: Vec = wider.iter().map(|c| c.width().round()).collect(); + assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}"); + assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}"); + let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new()); + assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}"); + } + + #[test] + fn scrolling_sideways_keeps_dragged_widths() { + let (ctx, opened) = opened_table(); + drag_edge(&ctx, opened[0], 100.0); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}"); + for offset in [13.3, 27.77, 41.1, 0.6] { + scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new()); + let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new()); + assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}"); + } + } }