mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 20:18:06 -06:00
M6: an editable combo box takes a typed value (ISO 32000-2 Table 233 Edit) (#4451)
A combo box with the Edit flag (bit 19) opened only its list, so a value that isn't one of its options could not be typed on the page, although form_fill already took one. ISO 32000-2 12.7.5.4 and Table 233: with Edit set, "the combo box shall include an editable text box as well as a drop-down list". - An editable combo box now opens a single-line text box over the field as well as its list. Enter, Tab or a click away commits the text as Edit::SetFieldValue with a Choice, the path form_fill and the list use, so undo, field scripts and the appearance follow; a pick from the list still sets an option. - The editor starts on the shown text of the picked option, and typing a shown text picks that option (the engine maps it to its export value). - A combo box without Edit is unchanged: a drop-down list only. - Tests: kittest typing into an editable combo (Enter, list pick, Tab, save, undo) and a non-editable one refusing typing; automation form_fill takes free text only with Edit, drawn before and after save. - Parity: form.fill-choice notes and evidence. Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
94a12715ce
commit
cc5fd54868
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Edit> {
|
||||
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))
|
||||
|
||||
@@ -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<u8> {
|
||||
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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user