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;