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 <noreply@anthropic.com>
This commit is contained in:
RiceDeeandClaude Sonnet 5.5 committed 2026-11-01 16:33:17 +11:00
1 parent 47f42ba07b
commit 6734f9f3b4
5 files changed
+176 -72

No files matched your search

+2
View File
@@ -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")),
+130 -65
View File
@@ -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<f64> = 10.0..=400.0;
const CHAR_SPACING_RANGE: std::ops::RangeInclusive<f64> = -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<R>(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<R>(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() {
+6 -3
View File
@@ -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<f32> = 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<f32> = (crate::format_text_ui::MIN_ROW + TOOLS_GUTTERS)..=520.0;
const SIDE_RANGE: RangeInclusive<f32> = 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));
}
+10 -4
View File
@@ -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));
+28
View File
@@ -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::<Vec<_>>();
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;