diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 2ba86ee5f5..2c2fb7a09a 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 dbb50f73ec..99ffc054e1 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -107,6 +107,23 @@ 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 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 +/// 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 = (SIZE_FIELD + 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; @@ -132,105 +149,112 @@ pub(crate) fn panel( // The font, across the panel. ui.spacing_mut().combo_height = 360.0; let width = ui.available_width(); - let font_resp = 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); + let font_resp = 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); + } + 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())); + } + } + ui.separator(); } - 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())); + 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)); } } - 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| { - ui.set_min_width(260.0); - let reload = ui.button(tl!("Reload")).clicked(); - match crate::files::installed_font_files(ui.ctx(), reload) { - Some(list) if list.is_empty() => { - ui.label(tl!("No openly licensed document fonts found. Load a font file.")); - } - Some(list) => { - let search_id = ui.scope_id().with("installed-font-search"); - let mut query = ui.data(|d| d.get_temp::(search_id)).unwrap_or_default(); - ui.horizontal(|ui| { - let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); - crate::icons::paint(ui, icon_rect, "search", 13.0, ui.visuals().text_color().gamma_multiply(0.6)); - let resp = ui - .add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search…")).desired_width(ui.available_width() - 8.0)); - if resp.changed() { - ui.data_mut(|d| d.insert_temp(search_id, query.clone())); - } - }); - ui.separator(); - - let query_lower = query.trim().to_lowercase(); - let mut families: std::collections::BTreeMap<&str, Vec<&pdfcraft_engine::text_fonts::FontFile>> = - std::collections::BTreeMap::new(); - for font in &list { - if query_lower.is_empty() - || font.family.to_lowercase().contains(&query_lower) - || font.face.to_lowercase().contains(&query_lower) - { - families.entry(&font.family).or_default().push(font); - } - } - - if families.is_empty() { + 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| { + ui.set_min_width(260.0); + let reload = ui.button(tl!("Reload")).clicked(); + match crate::files::installed_font_files(ui.ctx(), reload) { + Some(list) if list.is_empty() => { ui.label(tl!("No openly licensed document fonts found. Load a font file.")); - } else { - egui::ScrollArea::vertical().max_height(350.0).show(ui, |ui| { - for (family, faces) in families { - if faces.len() == 1 { - if let Some(&font) = faces.first() { - let label = - if font.face != font.family && !font.face.is_empty() { font.face.as_str() } else { family }; - if ui.button(label).clicked() { - out.font = Some(FontChoice::Installed(font.path.clone())); - ui.close(); - } - } - } else { - ui.menu_button(family, |ui| { - ui.set_min_width(180.0); - for font in faces { + } + Some(list) => { + let search_id = ui.scope_id().with("installed-font-search"); + let mut query = ui.data(|d| d.get_temp::(search_id)).unwrap_or_default(); + ui.horizontal(|ui| { + let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui, icon_rect, "search", 13.0, ui.visuals().text_color().gamma_multiply(0.6)); + let resp = ui.add( + egui::TextEdit::singleline(&mut query).hint_text(tl!("Search…")).desired_width(ui.available_width() - 8.0), + ); + if resp.changed() { + ui.data_mut(|d| d.insert_temp(search_id, query.clone())); + } + }); + ui.separator(); + + let query_lower = query.trim().to_lowercase(); + let mut families: std::collections::BTreeMap<&str, Vec<&pdfcraft_engine::text_fonts::FontFile>> = + std::collections::BTreeMap::new(); + for font in &list { + if query_lower.is_empty() + || font.family.to_lowercase().contains(&query_lower) + || font.face.to_lowercase().contains(&query_lower) + { + families.entry(&font.family).or_default().push(font); + } + } + + if families.is_empty() { + ui.label(tl!("No openly licensed document fonts found. Load a font file.")); + } else { + egui::ScrollArea::vertical().max_height(350.0).show(ui, |ui| { + for (family, faces) in families { + if faces.len() == 1 { + if let Some(&font) = faces.first() { let label = - font.face.strip_prefix(family).map(str::trim).filter(|s| !s.is_empty()).unwrap_or(&font.face); + if font.face != font.family && !font.face.is_empty() { font.face.as_str() } else { family }; if ui.button(label).clicked() { out.font = Some(FontChoice::Installed(font.path.clone())); ui.close(); } } - }); + } else { + ui.menu_button(family, |ui| { + ui.set_min_width(180.0); + for font in faces { + let label = font + .face + .strip_prefix(family) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(&font.face); + if ui.button(label).clicked() { + out.font = Some(FontChoice::Installed(font.path.clone())); + ui.close(); + } + } + }); + } } - } - }); + }); + } + } + None => { + ui.label(tl!("Finding document fonts…")); } } - None => { - ui.label(tl!("Finding document fonts…")); - } - } - }); - ui.add_space(4.0); - } - }) - .response; + }); + ui.add_space(4.0); + } + }) + .response + }); font_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), tl!("Font"))); font_resp.on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available."))); ui.add_space(4.0); @@ -318,36 +342,49 @@ pub(crate) fn panel( // Line and paragraph spacing; horizontal scale and character spacing: two columns, each an // icon and its control, `half` wide. - let half = ((ui.available_width() - 2.0 * LABEL_ICON - 8.0 - 3.0 * GAP) / 2.0).max(40.0); + // From the font list's width, not what is left now: a row above that overflows (a long + // translation of "Mixed" in the size field) widens the panel's layout. + let half = ((width - 2.0 * LABEL_ICON - 8.0 - 3.0 * GAP) / 2.0).max(40.0); let editing = extras.is_some(); let mut fallback = Extras::default(); 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) }; - let spacing_resp = 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)); - } - }) - .response; - spacing_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), tl!("Line spacing"))); + outlined(ui, |ui| { + // 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 spacing_resp = 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)); + } + }) + .response + .labelled_by(label.id); + spacing_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), tl!("Line spacing"))); + spacing_resp.on_hover_text(&shown); + }); 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) + }); }); }); }); @@ -373,6 +410,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); @@ -421,16 +474,48 @@ 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. 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); + 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. +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; - let drag_resp = ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), height], field).labelled_by(label.0); + let drag_resp = ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), 26.0], field).labelled_by(label.0); drag_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::SpinButton, ui.is_enabled(), label.1)); - let list = icons::button(ui, "chevron-down", PRESET_LIST, false, label.1); + 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() { @@ -458,8 +543,9 @@ fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Respon /// 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, mixed: bool) -> bool { let mut committed = false; - ui.scope(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; + // The panel's own padding, read before the box tightens it, so "Mixed" keeps its margins. + let margin = egui::Margin::from(ui.spacing().button_padding); + boxed(ui, |ui| { let id = ui.make_persistent_id("font-size-input"); let initial_text = || { if mixed || *size <= 0.0 { tl!("Mixed").to_string() } else { size.to_string() } @@ -472,11 +558,22 @@ 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 margin = egui::Margin::from(ui.spacing().button_padding); 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); - let field = egui::TextEdit::singleline(&mut text).id(id).font(font).margin(margin).desired_width(width); - let response = ui.add_sized([width, ui.spacing().interact_size.y], field).accessible_name(tl!("Font size:")); + // 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) + .margin(margin) + .desired_width(width) + .frame(egui::Frame::NONE) + .align(egui::Align2::CENTER_CENTER); + 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(); @@ -493,7 +590,7 @@ fn font_size_input(ui: &mut egui::Ui, size: &mut f64, mixed: bool) -> bool { } } 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 447aeac9fb..78cf967dfa 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; @@ -505,11 +508,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, rail_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 }); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, rail_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 73326fe571..fce62fb2ab 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] { @@ -242,6 +247,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 ccc3945aaf..dc287eb48b 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -724,6 +724,68 @@ 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(); + // 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); + 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 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:?})"); + // 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()); + } +} + +/// 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; + 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()); + } + // 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()); + } +} + #[test] fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { use egui_kittest::kittest::NodeT;