From ee1ee704639738edaade7fdb71609b77f87ecaac Mon Sep 17 00:00:00 2001 From: piero-83 Date: Sun, 11 Oct 2026 18:10:23 +0200 Subject: [PATCH] Forms: keep the accepted value when Tab wraps to the same field (#1045) * fix(forms): refresh the editor after accepted Tab edits * test(forms): keep values through same-field Tab and Save * docs: record accepted-value Tab and Save behavior * docs: log verified Tab save regression --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/ui-egui/src/editing.rs | 13 ++++++ crates/ui-egui/tests/forms.rs | 82 +++++++++++++++++++++++++++++++++-- docs/roadmap.md | 3 ++ 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 393a69d9..0c074c78 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -142,6 +142,8 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M6):** Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. Estimates unchanged. +- **2026-10-11 (M6):** Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Estimates unchanged. + - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 2cf6041b..dedb5584 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -238,6 +238,19 @@ impl PdfCraftApp { _ => false, }; if self.apply_edit(edit) { + // Tab can wrap to the same field (including another widget of that field). + // Its new editor was opened before this edit ran, so it still holds the old + // value. Refresh from the accepted form before Save can commit that stale draft. + if typed + && let Some(view) = self.views.get_mut(i) + && let Some(focus) = view.forms.focus.as_mut() + && focus.request_focus + && 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.picked = field.value.clone(); + } if let Some(page) = signature_page && let Some(view) = self.views.get_mut(i) { diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 57a80dff..0c7fb32a 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -181,6 +181,68 @@ fn saving_while_still_typing_in_a_field_saves_the_text() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn tabbing_back_to_the_same_field_then_saving_keeps_the_committed_value() { + // A form with one text field wraps Tab back to that field. Multiple widgets of one + // field share its value too: opening the next widget must see the accepted edit. + for (case, shared_widgets, backwards, same_frame) in [ + ("single-forward", false, false, false), + ("single-backward", false, true, false), + ("shared-forward", true, false, false), + ("shared-backward", true, true, false), + ("same-frame", false, false, true), + ] { + let dir = scratch(case); + let out = dir.join("saved.pdf"); + let bytes = if shared_widgets { + pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 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 [6 0 R 7 0 R] >>", + "<< /FT /Tx /T (qty) /V (4) /Kids [6 0 R 7 0 R] >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [20 350 280 372] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [20 250 280 272] /P 3 0 R /F 4 >>", + ]) + } else { + validated_form() + }; + 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("tab.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(6); + click_field(&mut h, "qty", 0); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("42".into())); + h.run_steps(2); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + let modifiers = if backwards { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; + if same_frame { + one_frame(&mut h, vec![key(egui::Key::Tab, modifiers), key(egui::Key::S, egui::Modifiers::COMMAND)]); + } else { + h.key_press_modifiers(modifiers, egui::Key::Tab); + h.run_steps(3); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); + } + h.run_steps(4); + assert_eq!(saved_value(&out, "qty"), ["42"], "{case}: saving cannot restore the old value"); + assert_eq!(value(&h, "qty"), ["42"], "{case}"); + // One typing action is one history entry, even after saving. Escape closes the + // untouched editor before undoing the document rather than the TextEdit buffer. + h.key_press(egui::Key::Escape); + h.state_mut().undo(); + assert_eq!(value(&h, "qty"), ["4"], "{case}: a single undo restores the original"); + h.state_mut().redo(); + assert_eq!(value(&h, "qty"), ["42"], "{case}: redo restores the committed value"); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[test] fn closing_while_still_typing_in_a_field_asks_to_save() { let mut h = harness(); @@ -284,13 +346,16 @@ fn exporting_form_data_while_typing_includes_the_text() { /// One text field, `qty`, whose validation script refuses values over 100. fn validated_form() -> Vec { - let objs = [ + pdf_from_objects(&[ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 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] >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (qty) /V (4) /Rect [20 350 280 372] /P 3 0 R /F 4 /AA << /V << /S /JavaScript /JS (if (event.value > 100) { app.alert('At most 100'); event.rc = false; }) >> >> >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", - ]; + ]) +} + +fn pdf_from_objects(objs: &[&str]) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -355,7 +420,16 @@ fn a_save_shortcut_pressed_while_typing_saves_only_the_document_it_was_pressed_i #[test] fn a_value_refused_on_enter_keeps_save_in_the_same_frame_from_running() { - let dir = scratch("enter-refused"); + refused_value_stops_save(egui::Key::Enter, "enter-refused"); +} + +#[test] +fn a_value_refused_on_tab_keeps_save_in_the_same_frame_from_running() { + refused_value_stops_save(egui::Key::Tab, "tab-refused"); +} + +fn refused_value_stops_save(commit_key: egui::Key, case: &str) { + let dir = scratch(case); let out = dir.join("saved.pdf"); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); @@ -370,7 +444,7 @@ fn a_value_refused_on_enter_keeps_save_in_the_same_frame_from_running() { h.event(egui::Event::Text("500".into())); h.run_steps(2); h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); - one_frame(&mut h, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::S, egui::Modifiers::COMMAND)]); + one_frame(&mut h, vec![key(commit_key, egui::Modifiers::NONE), key(egui::Key::S, egui::Modifiers::COMMAND)]); h.run_steps(3); assert_eq!(value(&h, "qty"), ["4"]); assert!(!out.exists(), "nothing is saved right after the field refused its value"); diff --git a/docs/roadmap.md b/docs/roadmap.md index 3785300d..c5086b64 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,7 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M6: keep accepted form values when Tab reopens the same field before Save; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md), @@ -73,6 +74,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | @@ -124,6 +126,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| | 2026-10-11 | minor | M6: respect existing form-font encodings; retain source fonts on appearance fallback. Estimates unchanged. | +| 2026-10-11 | minor | M6: keep accepted form values when Tab reopens the same field before Save. Estimates unchanged. | | 2026-10-10 | minor | M14: localized shortcut key names (#496), including runtime switching and native layout checks. Estimates unchanged. | | 2026-10-10 | minor | Re-judged convert and share with the cross-app gate rule (only a workflow that can't be completed at all blocks): partial, not blocking → stage alpha | | 2026-10-10 | minor | Added the alpha gate (six core workflows; convert and share fails → pre-alpha, 40–70 h to alpha) and rank 0 in the upcoming milestones |