diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2d2407629c..7e6966d848 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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(); } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f5aaa79b6..852a28b953 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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. diff --git a/crates/ui-egui/tests/dialog_chrome.rs b/crates/ui-egui/tests/dialog_chrome.rs index 57f549305d..1cbc534a7f 100644 --- a/crates/ui-egui/tests/dialog_chrome.rs +++ b/crates/ui-egui/tests/dialog_chrome.rs @@ -6,11 +6,17 @@ use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::PdfCraftApp; fn fixture() -> Vec { - 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 { + 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 { 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:?}"); +}