fix(ui): Document Properties values line up and long fields wrap

The Description tab's plain values (File, Application, PDF producer) now
start where the text inside the Title/Author fields does, the fields use
the dialog's full width instead of a fixed 420 points, and Subject and
Keywords wrap onto more lines instead of clipping mid-word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehelandClaude Opus 5.5 committed 2026-10-15 20:59:29 +02:00
1 parent dc41044ba7
commit b349ef480f
3 files changed
+48 -7

No files matched your search

+11 -5
View File
@@ -118,11 +118,14 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some((_, id)) = app.active_ids() else { return };
let Some(doc) = app.session.get(id) else { return };
let i = &doc.info;
// Description shows plain values among text fields; they start where the fields' text does.
let editable_info = tab == PropsTab::Description && doc.allows_modification() && app.props_draft.is_some();
let inset = if editable_info { widgets::FIELD_TEXT_INSET } else { 0.0 };
let row = |ui: &mut egui::Ui, k: &str, v: String| {
// Row labels are UI chrome; values are document data and stay as they are.
ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
ui.scope(|ui| {
ui.set_max_width(420.0);
ui.horizontal(|ui| {
ui.add_space(inset);
ui.add(
egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap(),
);
@@ -134,11 +137,14 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
PropsTab::Description => {
row(ui, "File", crate::bidi::visual(&doc.name).into_owned());
match app.props_draft.as_mut() {
Some((_, draft)) if doc.allows_modification() => {
Some((_, draft)) if editable_info => {
for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) {
let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
ui.add(widgets::line(v).desired_width(420.0).background_color(t.field).id_salt(("info", *k)))
.labelled_by(l.id);
// Subject and Keywords are often a sentence or a list: they wrap and grow
// instead of hiding most of the value past the field's edge.
let field =
if matches!(*k, "Subject" | "Keywords") { widgets::block(v).desired_rows(1) } else { widgets::line(v) };
ui.add(field.desired_width(f32::INFINITY).background_color(t.field).id_salt(("info", *k))).labelled_by(l.id);
ui.end_row();
}
}
+2
View File
@@ -7,6 +7,8 @@ use crate::{PdfCraftApp, icons};
/// Horizontal and vertical inset of a single-line field.
const FIELD_MARGIN: egui::Margin = egui::Margin::symmetric(10, 6);
/// Where text starts inside a [`line`] or [`block`] field, so plain values beside fields can line up with it.
pub(crate) const FIELD_TEXT_INSET: f32 = FIELD_MARGIN.left as f32;
/// Outer height of a single-line field, including its inset.
const FIELD_MIN_HEIGHT: f32 = 30.0;
/// Padding inside every modal.
+35 -2
View File
@@ -6,11 +6,17 @@ use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::PdfCraftApp;
fn fixture() -> Vec<u8> {
let objs = [
fixture_with_info(None)
}
/// A one-page file, with `info` as its document information dictionary when given.
fn fixture_with_info(info: Option<&str>) -> Vec<u8> {
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>",
];
objs.extend(info);
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
@@ -22,7 +28,8 @@ fn fixture() -> Vec<u8> {
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let info = if info.is_some() { " /Info 4 0 R" } else { "" };
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R{info} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
@@ -96,3 +103,29 @@ fn document_properties_long_filename_does_not_overflow_card() {
let card = card(&h, "dialog");
assert!(card.width() <= 700.0, "dialog card should not blow out horizontally on long filename, width was: {}", card.width());
}
#[test]
fn document_properties_values_line_up_and_long_fields_wrap() {
let info = "<< /Title (Showcase) /Subject (A specimen document exercising typography, world scripts, vector art, forms and annotations) /Creator (demo-pdf) >>";
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture_with_info(Some(info))).unwrap();
app.set_option("dialog", "properties").unwrap();
app
});
h.run_steps(4);
let card = card(&h, "dialog");
let title = h.get_by_role_and_label(Role::TextInput, "Title").rect();
// The fields reach the card's padding instead of stopping short of it.
assert!(card.right() - title.right() <= 24.0, "title field {title:?} in {card:?}");
// Plain values start where the fields' text does.
for value in ["doc.pdf", "demo-pdf"] {
// The file name is also the document tab's label.
let v = h.query_all_by_label(value).map(|n| n.rect()).find(|r| card.contains_rect(*r)).unwrap_or_else(|| panic!("{value} in the dialog"));
assert!((v.left() - (title.left() + 10.0)).abs() <= 1.0, "{value} at {v:?}, title field {title:?}");
}
// A long subject wraps onto a second line rather than running past the field's edge.
let subject = h.get_by_role_and_label(Role::MultilineTextInput, "Subject").rect();
assert!(subject.height() > title.height() + 8.0, "subject field {subject:?}, title field {title:?}");
}