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 <[email protected]>
This commit is contained in:
piero-83andBrandon Thomas authored and GitHub committed 2026-10-11 12:10:23 -04:00
1 parent f117cde040
commit ee1ee70463
4 files changed
+96 -4

No files matched your search

+2
View File
@@ -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.
+13
View File
@@ -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)
{
+78 -4
View File
@@ -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<u8> {
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<u8> {
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");
+3
View File
@@ -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.<br>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.<br>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<br>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.<br>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<br>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.<br>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.<br>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 |