Merge pull request #5637 from RiceDee/fix/format-text-fields-fit-pane

fix(ui): Format text fields read as fields, line up, and fit the Tools pane (#5634)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-02 14:45:46 +01:00
commit 52574b4fe5
5 files changed
+295 -125

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")),
+215 -118
View File
@@ -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<f64> = 10.0..=400.0;
const CHAR_SPACING_RANGE: std::ops::RangeInclusive<f64> = -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::<String>(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::<String>(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<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);
@@ -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() {
+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;
@@ -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));
}
+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] {
@@ -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));
+62
View File
@@ -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::<Vec<_>>();
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<egui::Rect> = 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;