mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
f117cde040
commit
ee1ee70463
4 files changed
+96
-4
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user