From 6734f9f3b4ef5c2fcc76265bb21949c57a3ca88d Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:33:17 +1100 Subject: [PATCH 1/5] fix(ui): Format text fields read as fields, line up, and fit the Tools pane (#5634) The font, line spacing, paragraph spacing, scale and character spacing fields get an outline and fill, with one box round each number and its arrow. Line and paragraph spacing text is centred, and the two-column rows end at the font list's right edge. The Tools pane has equal 14 pt margins and a minimum width worked out from its widest Format text row, so the alignment button is no longer cut off. The Edit and tool-detail rows stop short of the floating scrollbar, through a helper shared with All tools (#5133). Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/edit.rs | 2 + crates/ui-egui/src/panels/format_text_ui.rs | 195 +++++++++++++------- crates/ui-egui/src/panels/side_panels.rs | 9 +- crates/ui-egui/src/panels/tools.rs | 14 +- crates/ui-egui/tests/it/content.rs | 28 +++ 5 files changed, 176 insertions(+), 72 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index e95db85a72..57d8870397 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -55,6 +55,7 @@ pub(super) fn edit_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g // Format text at the top while text is selected or being added. format_section(app, ui, t); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + super::tools::clear_of_scrollbar(ui); widgets::section_title(ui, tl!("Modify page")); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; @@ -185,6 +186,7 @@ pub(super) fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens use pdfcraft_engine::{StampGroup, StampKind}; ui.label(egui::RichText::new(tl!("Choose a stamp, then click on the page to place it.")).small().color(t.text_faint)); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + super::tools::clear_of_scrollbar(ui); for (group, title) in [ (StampGroup::Dynamic, tl!("Dynamic")), (StampGroup::SignHere, tl!("Sign Here")), diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index eccef2aa79..dd60b3bcc8 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -102,6 +102,18 @@ const LABEL_ICON: f32 = 22.0; /// The button beside a number field that opens its list of common values: just an arrow, so the /// field keeps the room its value needs. const PRESET_LIST: f32 = 22.0; +/// The room a number field keeps for its value ("-5.0 pt"), beside the arrow in its box. +const NUMBER_MIN: f32 = 52.0; +/// The least width the panel's widest rows need, which sets how narrow the Tools pane may be +/// dragged. Size and alignment: the size field and its arrow (in their box), the swatch, the +/// divider, three 30 pt buttons, and the gaps between those six. The spacing rows: two icons and +/// two number fields with their arrows (in a box each), the space between the columns, and four +/// gaps. +pub(crate) const MIN_ROW: f32 = { + let size_row = (40.0 + PRESET_LIST + 2.0) + 28.0 + 9.0 + 3.0 * 30.0 + 5.0 * GAP; + let spacing_row = 2.0 * LABEL_ICON + 2.0 * (NUMBER_MIN + PRESET_LIST + 2.0) + 8.0 + 4.0 * GAP; + if size_row > spacing_row { size_row } else { spacing_row } +}; /// What can be typed: horizontal scale in percent, character spacing in points. const SCALE_RANGE: std::ops::RangeInclusive = 10.0..=400.0; const CHAR_SPACING_RANGE: std::ops::RangeInclusive = -5.0..=50.0; @@ -126,52 +138,54 @@ pub(crate) fn panel( // The font, across the panel. let width = ui.available_width(); - craft_ui::ComboBox::from_id_salt("format-font") - .selected_text(fonts.shown.as_str()) - .width(width) - .show_ui(ui, |ui| { - if let Some(sources) = fonts.sources { - if ui.selectable_label(false, tl!("Keep source fonts")).clicked() { - out.font = Some(FontChoice::KeepSource); - } - for (resource, face) in sources.iter().filter(|(r, _)| !r.starts_with("PCApproved")) { - if ui.selectable_label(false, face).clicked() { - out.font = Some(FontChoice::Resource(resource.clone())); + outlined(ui, |ui| { + craft_ui::ComboBox::from_id_salt("format-font") + .selected_text(fonts.shown.as_str()) + .width(width) + .show_ui(ui, |ui| { + if let Some(sources) = fonts.sources { + if ui.selectable_label(false, tl!("Keep source fonts")).clicked() { + out.font = Some(FontChoice::KeepSource); } - } - ui.separator(); - } - for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { - if ui.selectable_label(fonts.sources.is_none() && look.family == f, f.label()).clicked() { - out.font = Some(FontChoice::Family(f)); - } - } - if fonts.sources.is_some() { - ui.separator(); - if ui.selectable_label(false, tl!("Load font…")).clicked() { - out.font = Some(FontChoice::Load); - } - #[cfg(not(target_arch = "wasm32"))] - ui.menu_button(tl!("Installed fonts…"), |ui| match crate::files::installed_font_files(ui.ctx()) { - Some(list) if list.is_empty() => { - ui.label(tl!("No openly licensed document fonts found. Load a font file.")); - } - Some(list) => { - for font in list { - if ui.button(&font.face).clicked() { - out.font = Some(FontChoice::Installed(font.path)); - ui.close(); - } + for (resource, face) in sources.iter().filter(|(r, _)| !r.starts_with("PCApproved")) { + if ui.selectable_label(false, face).clicked() { + out.font = Some(FontChoice::Resource(resource.clone())); } } - None => { - ui.label(tl!("Finding document fonts…")); + ui.separator(); + } + for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { + if ui.selectable_label(fonts.sources.is_none() && look.family == f, f.label()).clicked() { + out.font = Some(FontChoice::Family(f)); } - }); - } - }) - .response - .on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available."))); + } + if fonts.sources.is_some() { + ui.separator(); + if ui.selectable_label(false, tl!("Load font…")).clicked() { + out.font = Some(FontChoice::Load); + } + #[cfg(not(target_arch = "wasm32"))] + ui.menu_button(tl!("Installed fonts…"), |ui| match crate::files::installed_font_files(ui.ctx()) { + Some(list) if list.is_empty() => { + ui.label(tl!("No openly licensed document fonts found. Load a font file.")); + } + Some(list) => { + for font in list { + if ui.button(&font.face).clicked() { + out.font = Some(FontChoice::Installed(font.path)); + ui.close(); + } + } + } + None => { + ui.label(tl!("Finding document fonts…")); + } + }); + } + }) + .response + .on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available."))) + }); ui.add_space(4.0); // Size and colour | bullets, numbering and alignment. @@ -253,25 +267,31 @@ pub(crate) fn panel( let e = extras.as_deref_mut().unwrap_or(&mut fallback); ui.add_enabled_ui(editing, |ui| { row(ui, |ui| { - labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing")); + let label = labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing")); let own = tl!("Auto").to_string(); let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) }; - craft_ui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| { - ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); - for v in LINE_SPACINGS { - ui.selectable_value(&mut e.line_spacing, v, number(v)); - } + outlined(ui, |ui| { + let list = craft_ui::ComboBox::from_id_salt("format-line-spacing").selected_text(centred(ui, &shown)).width(half).show_ui(ui, |ui| { + ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); + for v in LINE_SPACINGS { + ui.selectable_value(&mut e.line_spacing, v, number(v)); + } + }); + list.response.labelled_by(label.id); }); ui.add_space(8.0); ui.add_enabled_ui(false, |ui| { - labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing")); + let label = labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing")); let tip = format!("{} — {}", tl!("Paragraph spacing"), tl!("Planned")); - let _ = craft_ui::ComboBox::from_id_salt("format-paragraph-spacing") - .selected_text("0") - .width(half) - .show_ui(ui, |_| {}) - .response - .on_disabled_hover_text(tip); + let _ = outlined(ui, |ui| { + craft_ui::ComboBox::from_id_salt("format-paragraph-spacing") + .selected_text(centred(ui, "0")) + .width(half) + .show_ui(ui, |_| {}) + .response + .labelled_by(label.id) + .on_disabled_hover_text(tip) + }); }); }); }); @@ -296,6 +316,22 @@ pub(crate) fn panel( out } +/// Runs `add` with the panel's fields outlined and filled, so a list or number field reads as one +/// beside the plain icon buttons around it. +fn outlined(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + ui.scope(|ui| { + let t = Tokens::get(ui.ctx()); + let w = &mut ui.visuals_mut().widgets; + w.inactive.weak_bg_fill = t.field; + w.inactive.bg_stroke = Stroke::new(1.0, t.border); + w.hovered.bg_stroke = Stroke::new(1.0, t.text_muted); + // A list's text, pad and arrow touch: its gaps would widen it past the column it is given. + ui.spacing_mut().icon_spacing = 0.0; + add(ui) + }) + .inner +} + /// One row of the panel, [`ROW`] high, its controls [`GAP`] apart and centred on it. fn row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { let size = vec2(ui.available_width(), ROW); @@ -344,15 +380,45 @@ struct Number<'a> { suffix: &'static str, } +/// A combo box's selected text, centred in its button. The button lays out the text and then its +/// arrow, and every growing atom takes an equal share of the room left: a pad as wide as the arrow +/// before the text balances the arrow after it, and the text centres itself in its own share. +fn centred<'a>(ui: &egui::Ui, text: &str) -> egui::Atoms<'a> { + use egui::AtomExt as _; + let pad = egui::Atom::custom(egui::IdSalt::new("combo-pad"), vec2(ui.spacing().icon_width, 0.0)).atom_grow(true); + egui::Atoms::new((pad, egui::Atom::from(text.to_string()).atom_grow(true).atom_align(egui::Align2::CENTER_CENTER))) +} + +/// The arrow that lists a field's common values, centred on the 26-point field beside it. +fn preset_arrow(ui: &mut egui::Ui, label: &str) -> egui::Response { + let (slot, _) = ui.allocate_exact_size(vec2(PRESET_LIST, 26.0), Sense::hover()); + let at = Rect::from_center_size(slot.center(), vec2(PRESET_LIST, PRESET_LIST)); + ui.scope_builder(egui::UiBuilder::new().max_rect(at), |ui| icons::button(ui, "chevron-down", PRESET_LIST, false, label)).inner +} + +/// One box round a value and its arrow, like a list's, so the pair reads as a single control. +fn boxed(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { + let t = Tokens::get(ui.ctx()); + let edge = if ui.is_enabled() { t.border } else { t.border.gamma_multiply(0.5) }; + egui::Frame::new().fill(t.field).stroke(Stroke::new(1.0, edge)).corner_radius(CornerRadius::same(4)).show(ui, |ui| { + let w = &mut ui.visuals_mut().widgets; + for state in [&mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { + state.bg_stroke = Stroke::NONE; + } + w.inactive.weak_bg_fill = Color32::TRANSPARENT; + ui.spacing_mut().item_spacing.x = 0.0; + ui.spacing_mut().button_padding.x = 2.0; + add(ui); + }); +} + /// A number field with an arrow beside it that lists common values, in `width` all told: type any /// value in its range, or pick one. Both are named by `label`: the icon before them and its text. fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width: f32, label: (egui::Id, &str)) { - ui.scope(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; + boxed(ui, |ui| { let field = egui::DragValue::new(&mut *n.value).range(n.range).speed(n.speed).max_decimals(2).suffix(n.suffix); - let height = ui.spacing().interact_size.y; - ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), height], field).labelled_by(label.0); - let list = icons::button(ui, "chevron-down", PRESET_LIST, false, label.1); + ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), 26.0], field).labelled_by(label.0); + let list = preset_arrow(ui, label.1); egui::Popup::menu(&list).show(|ui| { for &v in presets { if ui.selectable_label(*n.value == v, number(v)).clicked() { @@ -379,15 +445,14 @@ fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Respon /// Commit a typed size once (Enter or leaving the field), rather than one undo step per key. /// The bounds match AddedText validation, so the panel also accepts coloured text below 8 pt. fn font_size_input(ui: &mut egui::Ui, size: &mut f64) { - ui.scope(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; + boxed(ui, |ui| { let id = ui.make_persistent_id("font-size-input"); let (previous, mut text) = ui.data(|d| d.get_temp::<(u64, String)>(id)).unwrap_or_else(|| (size.to_bits(), size.to_string())); if previous != size.to_bits() { text = size.to_string(); } - let field = egui::TextEdit::singleline(&mut text).id(id).desired_width(40.0); - let response = ui.add_sized([40.0, ui.spacing().interact_size.y], field).accessible_name(tl!("Font size:")); + let field = egui::TextEdit::singleline(&mut text).id(id).desired_width(40.0).frame(egui::Frame::NONE).align(egui::Align2::CENTER_CENTER); + let response = ui.add_sized([40.0, 26.0], field).accessible_name(tl!("Font size:")); let cancel = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); if cancel { text = size.to_string(); @@ -399,7 +464,7 @@ fn font_size_input(ui: &mut egui::Ui, size: &mut f64) { text = size.to_string(); } let before = *size; - let list = icons::button(ui, "chevron-down", PRESET_LIST, false, tl!("Font size")); + let list = preset_arrow(ui, tl!("Font size")); egui::Popup::menu(&list).show(|ui| { for value in SIZES { if ui.selectable_label(*size == value, format!("{value} pt")).clicked() { diff --git a/crates/ui-egui/src/panels/side_panels.rs b/crates/ui-egui/src/panels/side_panels.rs index 71c3baa648..1fdebdfaa1 100644 --- a/crates/ui-egui/src/panels/side_panels.rs +++ b/crates/ui-egui/src/panels/side_panels.rs @@ -17,7 +17,10 @@ use crate::{Mode, PdfCraftApp}; pub const TOOLS_WIDTH: f32 = 272.0; /// Default width of the side panel. pub const SIDE_WIDTH: f32 = 330.0; -const TOOLS_RANGE: RangeInclusive = 240.0..=520.0; +/// The Tools pane's own margins, left and right (14 pt each, and a pt of border), around what a panel lays out. +const TOOLS_GUTTERS: f32 = 30.0; +/// No narrower than Format text's widest row, so nothing in it is cut off. +const TOOLS_RANGE: RangeInclusive = (crate::format_text_ui::MIN_ROW + TOOLS_GUTTERS)..=520.0; const SIDE_RANGE: RangeInclusive = 260.0..=520.0; /// Version of the saved `side_panels` value. const VERSION: u64 = 1; @@ -481,11 +484,11 @@ mod tests { json!([]), json!([ ["inspector", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 380.0}, "stack": null, "sibling": ["canvas"]}], - ["tools", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 250.0}, "stack": null, "sibling": ["canvas"]}] + ["tools", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 300.0}, "stack": null, "sibling": ["canvas"]}] ]), ); let restored = restore(&old); - assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 }); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, tools_width: 300.0, side_width: 380.0 }); assert_eq!((restored.tools_open, restored.side_closed), (Some(false), true)); } diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 9d106e38b3..02b86ec688 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -17,7 +17,7 @@ pub fn left_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if app.left == LeftPanel::Tool("organize") && app.active.and_then(|i| app.views.get(i)).is_some_and(|v| !v.organize) { app.left = crate::organize_panel::workspace_panel(app.mode); } - egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 14, right: 10, top: 12, bottom: 10 }).stroke(Stroke::new(1.0, t.divider)).show( + egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 14, right: 14, top: 12, bottom: 10 }).stroke(Stroke::new(1.0, t.divider)).show( ui, |ui| match app.left { // Organize pages has its own panel whenever the grid shows (with a document open). @@ -58,9 +58,15 @@ pub(crate) fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, lead: Opt (go_back, close) } -/// Width kept free at the right of the tool list for the floating scrollbar. +/// Width kept free at the right of a scrolling list for the floating scrollbar. const SCROLLBAR_GUTTER: f32 = 18.0; +/// The scrollbar floats over the right edge: keeps the rows of the scroll area `ui` is in (and +/// their rounded hover wash) clear of it. +pub(super) fn clear_of_scrollbar(ui: &mut egui::Ui) { + ui.set_max_width((ui.available_width() - SCROLLBAR_GUTTER).max(0.0)); +} + /// The All tools sidebar: Expand (the full view) and Close, then the tool list, the first few /// tools until View more shows them all. fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { @@ -73,8 +79,7 @@ fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { app.left_open = false; } egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - // The scrollbar floats over the right edge: keep the rows (and their rounded hover wash) clear of it. - ui.set_max_width((ui.available_width() - SCROLLBAR_GUTTER).max(0.0)); + clear_of_scrollbar(ui); ui.add_space(2.0); let shown = if app.all_tools_expanded { TOOL_GROUPS.len() } else { COLLAPSED_TOOLS.min(TOOL_GROUPS.len()) }; for g in &TOOL_GROUPS[..shown] { @@ -210,6 +215,7 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static let marks = if footer { app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()) } else { 0 }; let list_h = if footer { (ui.available_height() - 52.0).max(80.0) } else { ui.available_height() }; egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(list_h).show(ui, |ui| { + clear_of_scrollbar(ui); ui.add_space(2.0); for s in g.sections { widgets::section_title(ui, tl!(s.title)); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index cb29ba99be..f7f595c2dd 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -721,6 +721,34 @@ fn format_text_rows_line_up_and_fit_the_panel() { } } +/// The Tools pane can't be dragged narrower than Format text needs: at its smallest width no +/// control reaches past the font list, which spans the panel. +#[test] +fn format_text_fits_the_tools_pane_at_its_narrowest() { + use egui::accesskit::Role; + let mut h = harness(); + h.state_mut().side_panels.tools_width = 0.0; + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let font = h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some("Helvetica")).map(|n| n.rect()).expect("the font list"); + let rects = ["Alignment", "Colour", "Font size", "Character spacing", "Horizontal scale", "Line spacing", "Paragraph spacing"] + .into_iter() + .flat_map(|l| h.query_all_by_label(l).map(move |n| (l, n.rect()))) + .chain(h.query_all_by_role(Role::ComboBox).map(|n| ("list", n.rect()))) + .collect::>(); + for (label, r) in rects { + assert!(r.right() <= font.right() + 0.5, "{label} {r:?} reaches past the font list ({font:?})"); + } + // The rows below scroll, and the scrollbar floats over their right edge: they stop short of it. + let image = h.get_by_label("Image").rect(); + assert!(image.right() <= font.right() - 17.0, "the Image row {image:?} runs under the scrollbar ({font:?})"); + // The right-hand column ends where the font list does, as the left one starts where it does. + for label in ["Paragraph spacing", "Character spacing"] { + let right = h.query_all_by_label(label).map(|n| n.rect().right()).fold(f32::MIN, f32::max); + assert!((right - font.right()).abs() < 1.5, "{label} ends at {right}, the font list at {}", font.right()); + } +} + #[test] fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { use egui_kittest::kittest::NodeT; From 8f1d23d991ff7445d6c7f0874db82393ec41f7e4 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:15:43 +1100 Subject: [PATCH 2/5] test(ui): the Format text narrow-pane test checks the font list against the pane's content edge (#5634) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/tests/it/content.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index f7f595c2dd..c4d2223824 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -739,6 +739,9 @@ fn format_text_fits_the_tools_pane_at_its_narrowest() { for (label, r) in rects { assert!(r.right() <= font.right() + 0.5, "{label} {r:?} reaches past the font list ({font:?})"); } + // The font list ends where the pane's own content does: the header's Close button is its right edge. + let close = h.get_by_label("Close panel").rect(); + assert!((font.right() - close.right()).abs() < 1.5, "the font list {font:?} is not at the pane's content edge ({close:?})"); // The rows below scroll, and the scrollbar floats over their right edge: they stop short of it. let image = h.get_by_label("Image").rect(); assert!(image.right() <= font.right() - 17.0, "the Image row {image:?} runs under the scrollbar ({font:?})"); From d90cf79a2a482c23b442dd5b3c88897aa9644eb2 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:02:38 +1100 Subject: [PATCH 3/5] fix(ui): the line spacing list keeps its column in every language (#5634) A longer "Auto" (Czech "Automaticky") pushed the list 46 pt past the Tools pane at its narrowest. It is now cut short, with the whole word in the list's menu and tooltip. A test checks the lists in every interface language the fonts can draw, and the narrow-pane test also runs at the largest interface size. --- crates/ui-egui/src/panels/format_text_ui.rs | 27 +++++++++++++------- crates/ui-egui/tests/it/content.rs | 28 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index dd60b3bcc8..7b2763bf32 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -271,13 +271,20 @@ pub(crate) fn panel( let own = tl!("Auto").to_string(); let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) }; outlined(ui, |ui| { - let list = craft_ui::ComboBox::from_id_salt("format-line-spacing").selected_text(centred(ui, &shown)).width(half).show_ui(ui, |ui| { - ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); - for v in LINE_SPACINGS { - ui.selectable_value(&mut e.line_spacing, v, number(v)); - } - }); - list.response.labelled_by(label.id); + // The column is `half` wide in every language: a longer "Auto" is cut short (the + // menu and the tooltip have it whole). + ui.set_max_width(half); + let list = craft_ui::ComboBox::from_id_salt("format-line-spacing") + .selected_text(centred(ui, &shown)) + .width(half) + .wrap_mode(egui::TextWrapMode::Truncate) + .show_ui(ui, |ui| { + ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); + for v in LINE_SPACINGS { + ui.selectable_value(&mut e.line_spacing, v, number(v)); + } + }); + list.response.labelled_by(label.id).on_hover_text(&shown); }); ui.add_space(8.0); ui.add_enabled_ui(false, |ui| { @@ -382,11 +389,13 @@ struct Number<'a> { /// A combo box's selected text, centred in its button. The button lays out the text and then its /// arrow, and every growing atom takes an equal share of the room left: a pad as wide as the arrow -/// before the text balances the arrow after it, and the text centres itself in its own share. +/// before the text balances the arrow after it, and the text centres itself in its own share. Text +/// longer than the list is wide (a translated "Auto") is cut short rather than widening it. fn centred<'a>(ui: &egui::Ui, text: &str) -> egui::Atoms<'a> { use egui::AtomExt as _; let pad = egui::Atom::custom(egui::IdSalt::new("combo-pad"), vec2(ui.spacing().icon_width, 0.0)).atom_grow(true); - egui::Atoms::new((pad, egui::Atom::from(text.to_string()).atom_grow(true).atom_align(egui::Align2::CENTER_CENTER))) + let text = egui::Atom::from(text.to_string()).atom_grow(true).atom_shrink(true).atom_align(egui::Align2::CENTER_CENTER); + egui::Atoms::new((pad, text)) } /// The arrow that lists a field's common values, centred on the 26-point field beside it. diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index c4d2223824..a798505551 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -727,6 +727,8 @@ fn format_text_rows_line_up_and_fit_the_panel() { fn format_text_fits_the_tools_pane_at_its_narrowest() { use egui::accesskit::Role; let mut h = harness(); + // At the largest interface size too: it scales points to pixels, and the layout is in points. + h.state_mut().set_option("ui-scale", "300").unwrap(); h.state_mut().side_panels.tools_width = 0.0; assert!(h.state_mut().execute("edit.text")); h.run_steps(3); @@ -752,6 +754,32 @@ fn format_text_fits_the_tools_pane_at_its_narrowest() { } } +/// The lists in Format text fit the narrowest Tools pane in every interface language: "Auto" in +/// the line spacing list is the one word in them that is translated, and in German, Ukrainian or +/// Lao it is twice as long. The font list spans the pane, so nothing may end past it. +#[test] +fn format_text_lists_fit_the_narrowest_tools_pane_in_every_language() { + use egui::accesskit::Role; + for lang in pdfcraft_ui_egui::i18n::Lang::all() { + let mut h = harness(); + // A script with no face on this machine (Lao without system fonts) can't be drawn at all. + if !h.ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(13.0), lang.name())) { + continue; + } + h.state_mut().set_option("language", lang.code()).unwrap(); + h.state_mut().side_panels.tools_width = 0.0; + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let lists: Vec = h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).collect(); + // Found by its value (a font name, never translated), not as the widest list: an overrun + // could make another list the widest. + let font = h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some("Helvetica")).map(|n| n.rect()).expect("the font list"); + for r in &lists { + assert!(r.right() <= font.right() + 0.5, "{}: {r:?} reaches past the font list ({font:?})", lang.code()); + } + } +} + #[test] fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { use egui_kittest::kittest::NodeT; From 89efc79ecde48da0b782bec4b3e848dea03a7924 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:23:20 +1100 Subject: [PATCH 4/5] fix(ui): the size field stays within its room in the row in every language (#5634) It reserves room for a translated "Mixed", and in Czech or French that pushed the alignment button up to 54 pt past the Tools pane. The field is now no wider than SIZE_FIELD, and a longer translation scrolls in it. The language test checks the size row too. --- crates/ui-egui/src/panels/format_text_ui.rs | 11 +++++++---- crates/ui-egui/tests/it/content.rs | 9 ++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index 38ccb96b16..669c9b5e9a 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -109,9 +109,10 @@ const LABEL_ICON: f32 = 22.0; const PRESET_LIST: f32 = 22.0; /// The room a number field keeps for its value ("-5.0 pt"), beside the arrow in its box. const NUMBER_MIN: f32 = 52.0; -/// The size field, which reserves room for "Mixed" so it doesn't resize while typing. -/// shortcut: measured for English "Mixed"; a longer translation widens the size row past the -/// narrowest pane. Upgrade by having the panel report its measured row width to the pane. +/// The size field's room in the row: English "Mixed", its margins and the 2 pt of its box. The +/// field reserves room for "Mixed" so it doesn't resize while typing, but no more than this. +/// shortcut: a longer translation of "Mixed" scrolls in the field. Upgrade by having the panel +/// report its measured row width to the pane, if that reads badly. const SIZE_FIELD: f32 = 62.0; /// The least width the panel's widest rows need, which sets how narrow the Tools pane may be /// dragged. Size and alignment: the size field and its arrow (in their box), the swatch, the @@ -558,7 +559,9 @@ fn font_size_input(ui: &mut egui::Ui, size: &mut f64, mixed: bool) -> bool { // Reserve the localized mixed label, field margins and caret without resizing while typing. let font = egui::TextStyle::Body.resolve(ui.style()); let mixed_width = ui.painter().layout_no_wrap(tl!("Mixed").into(), font.clone(), ui.visuals().text_color()).size().x; - let width = (mixed_width + margin.sum().x + 2.0).ceil().max(40.0); + // Within the row's allowance, so a long translation ("Valeurs différentes") scrolls in the + // field rather than pushing the row past the pane. + let width = (mixed_width + margin.sum().x + 2.0).ceil().clamp(40.0, SIZE_FIELD - 2.0); let field = egui::TextEdit::singleline(&mut text) .id(id) .font(font) diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 80d08d1993..dc287eb48b 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -757,9 +757,9 @@ fn format_text_fits_the_tools_pane_at_its_narrowest() { } } -/// The lists in Format text fit the narrowest Tools pane in every interface language: "Auto" in -/// the line spacing list is the one word in them that is translated, and in German, Ukrainian or -/// Lao it is twice as long. The font list spans the pane, so nothing may end past it. +/// The lists and the size row in Format text fit the narrowest Tools pane in every interface +/// language: "Auto" in the line spacing list and "Mixed" in the size field are translated, and in +/// Czech or French they are much longer. The font list spans the pane, so nothing may end past it. #[test] fn format_text_lists_fit_the_narrowest_tools_pane_in_every_language() { use egui::accesskit::Role; @@ -780,6 +780,9 @@ fn format_text_lists_fit_the_narrowest_tools_pane_in_every_language() { for r in &lists { assert!(r.right() <= font.right() + 0.5, "{}: {r:?} reaches past the font list ({font:?})", lang.code()); } + // The size row too: its field reserves room for a translated "Mixed". + let align = h.get_by_label(pdfcraft_ui_egui::i18n::t("Alignment")).rect(); + assert!(align.right() <= font.right() + 0.5, "{}: Alignment {align:?} reaches past the font list ({font:?})", lang.code()); } } From ecd1822525a84c95da228e42988f050629a83f83 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:37:29 +1100 Subject: [PATCH 5/5] fix(ui): the size field shows "Mixed" whole in a tooltip (#5634) A long translation scrolls in the capped field, so the tooltip gives the full status. --- crates/ui-egui/src/panels/format_text_ui.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index 669c9b5e9a..99ffc054e1 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -569,7 +569,11 @@ fn font_size_input(ui: &mut egui::Ui, size: &mut f64, mixed: bool) -> bool { .desired_width(width) .frame(egui::Frame::NONE) .align(egui::Align2::CENTER_CENTER); - let response = ui.add_sized([width, 26.0], field).accessible_name(tl!("Font size:")); + let mut response = ui.add_sized([width, 26.0], field).accessible_name(tl!("Font size:")); + // A long translation of "Mixed" scrolls in the field: the tooltip has it whole. + if mixed { + response = response.on_hover_text(tl!("Mixed")); + } let cancel = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); if cancel { text = initial_text();