diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index cf124f169a..9049299702 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -8911,6 +8911,34 @@ fn bookmark_list_reports_colour_and_style() { assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved"); } +/// An editable combo box (ISO 32000-2 Table 233, Edit) takes a value that isn't one of its +/// options, and its page appearance shows it, before and after a save; one without Edit takes +/// only its options. +#[test] +fn form_fill_takes_free_text_only_in_an_editable_combo_box() { + let dir = workdir("editable-combo"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let combo = |name: &str, top: f64, editable: bool| json!({ "doc": doc, "page": 1, "type": "combo", "rect": [20, top, 200, top + 22.0], "name": name, "options": ["Apple", "Banana"], "editable": editable }); + ok(&mut a, "form_add_field", combo("fruit", 20.0, true)); + ok(&mut a, "form_add_field", combo("veg", 60.0, false)); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "fruit": "Cherry" } })); + assert!(page_text(&mut a, doc)[0].contains("Cherry"), "the typed value is drawn"); + let refused = err(&mut a, "form_fill", json!({ "doc": doc, "values": { "veg": "Cherry" } })); + assert!(matches!(&refused, ToolError::Failed(m) | ToolError::InvalidArgs(m) if m.contains("no option")), "{refused:?}"); + // A choice from the list still works in both. + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "veg": "Banana" } })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "filled.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "filled.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": saved })); + let value = |name: &str| fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).map(|f| f["value"].clone()); + assert_eq!(value("fruit"), Some(json!("Cherry")), "{fields}"); + assert_eq!(value("veg"), Some(json!("Banana")), "{fields}"); + let text = page_text(&mut a, saved); + assert!(text[0].contains("Cherry") && text[0].contains("Banana"), "{text:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// `page_render`'s pixels show a rubber stamp written without an appearance as the stamp its /// `/Name` names (ISO 32000-2 Table 184), and one with an unknown name as a grey placeholder. #[test] diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 7ed6e07959..1e9c0d6b7b 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -493,7 +493,7 @@ impl PdfCraftApp { && committed.as_ref().is_some_and(|draft| draft.name == focus.name) && let Some(field) = self.session.get(view.id).and_then(|doc| doc.form.iter().find(|field| field.name == focus.name)) { - focus.text = field.value.first().cloned().unwrap_or_default(); + focus.text = crate::forms_ui::draft_text(field); focus.picked = field.value.clone(); } if let Some(page) = signature_page diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 760c0b6cb6..0d81e547ca 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -4,7 +4,8 @@ //! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click, or on //! Space once Tab or a click has focused them. List boxes are directly selectable in-place on the //! page (clicking picks an option; Ctrl-click toggles on multi-select lists). Combo boxes open a -//! list of their options. Every change is one undoable engine edit. +//! list of their options; an editable one (Edit flag) also takes typed text in a box over the +//! field. Every change is one undoable engine edit. use std::collections::HashSet; @@ -89,11 +90,29 @@ fn fillable(f: &FormField) -> bool { !f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature) } +/// Whether a combo box takes typed text as well as a choice from its list: the Edit flag +/// (ISO 32000-2 Table 233), which only means something with Combo set. +fn editable_combo(f: &FormField) -> bool { + f.kind == FormFieldKind::Combo && f.has(field_flags::EDIT) +} + +/// The text a field's editor starts with: its value, or for a combo box the shown text of the +/// option it picks (a free value typed into an editable one has no option, and shows as is). +pub(crate) fn draft_text(f: &FormField) -> String { + let value = f.value.first().cloned().unwrap_or_default(); + if f.kind == FormFieldKind::Combo + && let Some((_, shown)) = f.options.iter().find(|(export, _)| *export == value) + { + return shown.clone(); + } + value +} + fn open_focus(f: &FormField, widget: usize) -> Focus { Focus { name: f.name.clone(), widget, - text: f.value.first().cloned().unwrap_or_default(), + text: draft_text(f), picked: f.value.clone(), request_focus: true, select_all: false, @@ -273,6 +292,11 @@ pub(crate) fn draft_edit(focus: &Focus, form: &[FormField]) -> Option { let f = form.iter().find(|f| f.name == focus.name)?; let value = match f.kind { FormFieldKind::Text if f.value.first().map(String::as_str).unwrap_or("") != focus.text => FieldValue::Text(focus.text.clone()), + // Typed into an editable combo box: a shown text picks its option, anything else is the + // value itself (the engine maps it, and refuses free text when Edit is clear). + FormFieldKind::Combo if editable_combo(f) && focus.text != draft_text(f) && f.value.first() != Some(&focus.text) => { + FieldValue::Choice(if focus.text.is_empty() { Vec::new() } else { vec![focus.text.clone()] }) + } _ => return None, }; Some(Edit::SetFieldValue { name: f.name.clone(), value }) @@ -288,6 +312,49 @@ pub(crate) fn commit(view: &mut DocView, form: &[FormField]) { } } +/// The text box of an editable combo box, over the field. What is typed becomes the value when +/// Enter, Tab or a click away commits it; a pick from the list still sets an option. +#[allow(clippy::too_many_arguments)] +fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: &FormField, w: &FormWidget, rect: Rect, zoom: f32, widget: usize) { + let mut enter = false; + egui::Area::new(egui::Id::new(("form-editor", view.id.0))) + .fade_in(false) + .order(egui::Order::Foreground) + .fixed_pos(rect.min) + .constrain_to(view.viewport_rect()) + .constrain(false) + .show(ctx, |ui| { + ui.set_min_size(rect.size()); + let Some(fx) = view.forms.focus.as_mut() else { return }; + let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); + // The box is pale yellow in every theme, as a text field's is (#655). + ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); + let te = egui::TextEdit::singleline(&mut fx.text) + .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) + .font(egui::FontId::proportional(font)) + .background_color(FIELD_FILL) + .text_color(Color32::BLACK) + .margin(egui::Margin::symmetric(3, 1)) + .id(egui::Id::new(("form-field", view.id.0, &f.name, widget))); + let r = ui.add_sized(rect.size(), te); + if std::mem::take(&mut fx.request_focus) { + r.request_focus(); + // Entered with Tab: typing replaces the value. + if std::mem::take(&mut fx.select_all) + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + let n = fx.text.chars().count(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), r.id); + } + } + enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + }); + if enter { + commit(view, form); + } +} + const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; /// The date picker under a focused date field: a month of days, and a Today button under them. @@ -695,6 +762,11 @@ pub(crate) fn overlay( } } FormFieldKind::Combo => { + // An editable combo box has a text box over the field as well as its list (ISO + // 32000-2 Table 233, Edit). + if editable_combo(f) { + combo_editor(ctx, view, form, f, w, rect, zoom, focus.widget); + } let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0))) .order(egui::Order::Foreground) .fixed_pos(rect.left_bottom() + vec2(0.0, 2.0)) diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index a06d474e83..436792553a 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -1423,6 +1423,95 @@ fn mode_tabs_keep_the_hand_over_a_text_field_scrolled_under_them() { assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the field's editor"); } +/// Two combo boxes of Apple and Banana: `fruit` editable (Combo and Edit, ISO 32000-2 Table 233), +/// `veg` a drop-down list only. +fn combo_form() -> Vec { + pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (fruit) /Ff 393216 /Opt [(Apple) (Banana)] /Rect [20 350 280 372] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (veg) /Ff 131072 /Opt [(Apple) (Banana)] /Rect [20 300 280 322] /P 3 0 R /F 4 >>", + ]) +} + +fn combo_harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("combo.pdf", None, combo_form()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + crate::support::settle(&mut h); + h +} + +/// An editable combo box takes a typed value that isn't among its options, and still takes a +/// pick from its list; the typed value is what Save writes. +#[test] +fn an_editable_combo_box_takes_a_typed_value() { + let dir = scratch("editable-combo"); + let out = dir.join("saved.pdf"); + let mut h = combo_harness(); + click_field(&mut h, "fruit", 0); + assert_eq!(focused(&h), Some(("fruit".into(), 0))); + h.event(egui::Event::Text("Cherry".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(value(&h, "fruit"), ["Cherry"], "Enter commits the typed value"); + assert!(h.state().views[0].forms.focus.is_none(), "and closes the field"); + // Reopened, it shows the typed value, and the list still picks an option. + click_field(&mut h, "fruit", 0); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some("Cherry")); + h.get_by_label("Banana").click(); + h.run_steps(4); + assert_eq!(value(&h, "fruit"), ["Banana"], "a pick from the list"); + // Typed again, Tab commits it, and Save writes it. + click_field(&mut h, "fruit", 0); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Damson".into())); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(value(&h, "fruit"), ["Damson"], "Tab commits the typed value"); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); + wait_for_file(&mut h, &out); + assert_eq!(saved_value(&out, "fruit"), ["Damson"]); + // Undo takes the typing back as one step. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(value(&h, "fruit"), ["Banana"]); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A combo box without the Edit flag is a drop-down list only: typing changes nothing. +#[test] +fn a_combo_box_without_edit_takes_no_typing() { + let mut h = combo_harness(); + click_field(&mut h, "veg", 0); + assert_eq!(focused(&h), Some(("veg".into(), 0))); + h.event(egui::Event::Text("Cherry".into())); + h.run_steps(2); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some(""), "no text box took the typing"); + h.key_press(egui::Key::Enter); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert!(value(&h, "veg").is_empty(), "nothing typed reaches the field"); + // Tab went on to `fruit`, whose open list covers `veg`: close it first. + h.key_press(egui::Key::Escape); + h.run_steps(2); + click_field(&mut h, "veg", 0); + h.get_by_label("Apple").click(); + h.run_steps(4); + assert_eq!(value(&h, "veg"), ["Apple"], "its list still picks"); +} + /// A button script's `ShowHideFileAttachment` opens the attachments panel, and running it again /// closes it. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ecd2d75f30..2c9cf17197 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4511,8 +4511,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"] -notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text" +evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/ui-egui/tests/it/forms.rs::an_editable_combo_box_takes_a_typed_value", "crates/ui-egui/tests/it/forms.rs::a_combo_box_without_edit_takes_no_typing", "crates/automation/tests/it/automation.rs::form_fill_takes_free_text_only_in_an_editable_combo_box"] +notes = "combo and list boxes, multi-select lists; an editable combo box (Edit flag) has a text box over the field as well as its list, and takes typed text on the page and through form_fill; a combo box without Edit takes only its options. No type-ahead or spell check in the typed box" [[feature]] id = "form.push-buttons"