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:
mtlcl410
2026-10-24 10:44:38 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 94a12715ce
commit cc5fd54868
5 changed files with 194 additions and 5 deletions
+28
View File
@@ -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]
+1 -1
View File
@@ -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
+74 -2
View File
@@ -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))
+89
View File
@@ -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]
+2 -2
View File
@@ -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"