Files
pdfcraft/crates/ui-egui/tests/forms.rs
T
9f2188bf7e Issue #166: Save, Print and Export include the text still being typed in a form field (#278)
* Issue #166: Save, Print and Export include the text still being typed in a form field

A field's editor kept its text in a draft until Enter, Tab or clicking away,
and ⌘S (which runs while a field has focus) saved the document first. Typing
also didn't count as unsaved, so closing or quitting didn't ask.

- commit_form_typing(): apply the tab's queued edit, then commit the open
  field's draft; if the field refuses the value, keep the editor and text
  and stop. Called where the document is read: Save, Save As (and its later
  callback), Print, Export, Reduce/Optimize, Sign, Extract, Split, Copy
  pages, Flatten, button scripts.
- Uncommitted typing counts as unsaved work for Close, Close All and Quit;
  saving from the prompt brings that tab forward first.
- Shortcuts pressed while a form field's editor is open run one frame later,
  for that document only, so the field takes the frame's typing and Escape.
- A value refused on Enter/Tab/blur reopens the field with the text.
- Notices show in full screen too.

Co-authored-by: Hal Goff <hal@hg.studio>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add my contributor name (AutoHenry)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 10:02:18 -04:00

400 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Filling in a form in the real shell (egui_kittest): typing, Tab, check boxes, radios, choices.
use egui_kittest::Harness;
use pdfcraft_ui_egui::PdfCraftApp;
fn 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.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
app.set_option("left", "closed").unwrap();
// The whole 300×400 pt page on screen.
app.set_option("zoom", "150").unwrap();
app
});
for _ in 0..60 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
h
}
fn value(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec<String> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone()
}
/// Click the centre of a field's widget.
fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str, widget: usize) {
let p = {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == name).unwrap();
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, widget).expect("on screen").center()
};
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
}
#[test]
fn typing_into_a_text_field_and_tabbing_on() {
let mut h = harness();
click_field(&mut h, "name", 0);
assert!(h.state().views[0].forms.focus.is_some(), "the editor opened");
h.event(egui::Event::Text("Ada Lovelace".into()));
h.run_steps(2);
h.key_press(egui::Key::Tab);
h.run_steps(4);
assert_eq!(value(&h, "name"), ["Ada Lovelace"]);
assert_eq!(
h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()),
Some("country"),
"Tab moves to the next field that takes typing or a choice"
);
// The country list is open; Escape closes it. Escape also abandons a draft.
h.key_press(egui::Key::Escape);
h.run_steps(2);
click_field(&mut h, "city", 0);
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
h.event(egui::Event::Text("Paris".into()));
h.run_steps(1);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(value(&h, "city").is_empty());
assert!(h.state().views[0].forms.focus.is_none());
}
#[test]
fn check_boxes_radios_and_choices() {
let mut h = harness();
click_field(&mut h, "agree", 0);
assert_eq!(value(&h, "agree"), ["Yes"]);
click_field(&mut h, "agree", 0);
assert!(value(&h, "agree").is_empty());
click_field(&mut h, "size", 1);
assert_eq!(value(&h, "size"), ["L"]);
click_field(&mut h, "size", 0);
assert_eq!(value(&h, "size"), ["S"]);
click_field(&mut h, "country", 0);
egui_kittest::kittest::Queryable::get_by_label(&h, "France").click();
h.run_steps(4);
assert_eq!(value(&h, "country"), ["fr"]);
// Clear form takes everything back; undo restores it.
assert!(h.state_mut().execute("form.clear"));
h.run_steps(2);
assert!(value(&h, "country").is_empty() && value(&h, "size").is_empty());
h.state_mut().undo();
h.run_steps(2);
assert_eq!(value(&h, "country"), ["fr"]);
}
#[test]
fn tabbing_into_a_filled_field_selects_it_so_typing_replaces() {
let mut h = harness();
click_field(&mut h, "country", 0);
egui_kittest::kittest::Queryable::get_by_label(&h, "Canada").click();
h.run_steps(3);
click_field(&mut h, "city", 0);
h.event(egui::Event::Text("Paris".into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(value(&h, "city"), ["Paris"]);
// name → (Tab) country → (Tab) city, whose text is selected on entry.
click_field(&mut h, "name", 0);
h.key_press(egui::Key::Tab);
h.run_steps(3);
h.key_press(egui::Key::Tab);
h.run_steps(4);
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
h.event(egui::Event::Text("Lyon".into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(value(&h, "city"), ["Lyon"]);
}
#[test]
fn date_fields_offer_a_calendar() {
use egui_kittest::kittest::Queryable;
let mut h = harness();
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
page: 0,
rect: [50.0, 40.0, 200.0, 60.0],
kind: pdfcraft_engine::NewField::Date,
name: Some("due".into()),
});
h.run_steps(4);
click_field(&mut h, "due", 0);
let (y, m, _) = h.state().session.today();
let month =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][(m - 1) as usize];
h.get_by_label(&format!("{month} {y}"));
h.get_by_label("›").click();
h.run_steps(2);
h.get_by_label("15").click();
h.run_steps(4);
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "picked in the field's format");
}
/// The value of `name` in a saved PDF, read back by opening it in a fresh app.
fn saved_value(path: &std::path::Path, name: &str) -> Vec<String> {
let mut app = PdfCraftApp::new();
app.open_bytes("saved.pdf", None, std::fs::read(path).unwrap()).unwrap();
app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone()
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-forms-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn saving_while_still_typing_in_a_field_saves_the_text() {
// Issue #166: ⌘S with the field still focused saved it blank.
let dir = scratch("save-focused");
let out = dir.join("saved.pdf");
let mut h = harness();
click_field(&mut h, "name", 0);
h.event(egui::Event::Text("Browser Form 123".into()));
h.run_steps(2);
assert!(h.state().views[0].forms.focus.is_some(), "still typing");
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
h.run_steps(3);
assert_eq!(saved_value(&out, "name"), ["Browser Form 123"], "the saved PDF has the typed text");
assert_eq!(value(&h, "name"), ["Browser Form 123"]);
let doc = h.state().session.get(h.state().views[0].id).unwrap();
assert!(!doc.dirty, "and the document is saved, not left with an unsaved change");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn closing_while_still_typing_in_a_field_asks_to_save() {
let mut h = harness();
click_field(&mut h, "name", 0);
h.event(egui::Event::Text("Not lost".into()));
h.run_steps(2);
h.state_mut().request_close_tab(0);
assert!(h.state().close_request.is_some(), "typed text is an unsaved change: the user is asked");
assert_eq!(h.state().views.len(), 1);
}
#[test]
fn close_all_saves_text_typed_in_a_tab_that_is_no_longer_active() {
let dir = scratch("close-all");
let out = dir.join("first.pdf");
let mut h = harness();
click_field(&mut h, "name", 0);
h.event(egui::Event::Text("Typed in tab one".into()));
h.run_steps(2);
// Another document becomes active while the first one's field still has its draft.
h.state_mut().open_bytes("other.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
h.run_steps(2);
h.state_mut().close_all();
assert!(h.state().close_request.is_some(), "the first document has an unsaved change");
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
let ctx = h.ctx.clone();
h.state_mut().resolve_close(&ctx, Some(true));
assert_eq!(saved_value(&out, "name"), ["Typed in tab one"]);
let _ = std::fs::remove_dir_all(&dir);
}
/// A key press as one event (kittest's `key_press` runs a frame per queued event).
fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
}
/// Deliver `events` together, in one frame.
fn one_frame(h: &mut Harness<'static, PdfCraftApp>, events: Vec<egui::Event>) {
h.input_mut().events.extend(events);
h.run_steps(1);
}
/// Type `text` into field `name`, then press ⌘S in the same frame as `last` (more input).
fn type_then_save_in_one_frame(h: &mut Harness<'static, PdfCraftApp>, name: &str, text: &str, last: egui::Event, out: &std::path::Path) {
click_field(h, name, 0);
h.event(egui::Event::Text(text.into()));
h.run_steps(2);
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
one_frame(h, vec![last, key(egui::Key::S, egui::Modifiers::COMMAND)]);
h.run_steps(3);
}
#[test]
fn typing_and_saving_in_the_same_frame_saves_the_last_keystrokes() {
let dir = scratch("same-frame");
let out = dir.join("saved.pdf");
let mut h = harness();
type_then_save_in_one_frame(&mut h, "name", "Browser", egui::Event::Text(" Form 123".into()), &out);
assert_eq!(saved_value(&out, "name"), ["Browser Form 123"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn escape_and_save_in_the_same_frame_discards_the_draft() {
let dir = scratch("escape");
let out = dir.join("saved.pdf");
let mut h = harness();
let before = value(&h, "name");
type_then_save_in_one_frame(&mut h, "name", "Discard me", key(egui::Key::Escape, egui::Modifiers::NONE), &out);
assert_eq!(value(&h, "name"), before, "Escape cancels typing, even with ⌘S right after it");
assert!(h.state().views[0].forms.focus.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_unrelated_command_leaves_the_field_being_typed_in_alone() {
let mut h = harness();
click_field(&mut h, "name", 0);
h.event(egui::Event::Text("Still typing".into()));
h.run_steps(2);
assert!(h.state_mut().execute("view.theme.dark"));
h.run_steps(2);
let focus = h.state().views[0].forms.focus.clone().expect("the editor stays open");
assert_eq!(focus.text, "Still typing");
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "nothing was committed");
}
#[test]
fn exporting_form_data_while_typing_includes_the_text() {
let dir = scratch("export");
let out = dir.join("data.xfdf");
let mut h = harness();
click_field(&mut h, "name", 0);
h.event(egui::Event::Text("Exported value".into()));
h.run_steps(2);
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.state_mut().export_data_dialog(false, true);
assert!(std::fs::read_to_string(&out).unwrap().contains("Exported value"));
let _ = std::fs::remove_dir_all(&dir);
}
/// One text field, `qty`, whose validation script refuses values over 100.
fn validated_form() -> Vec<u8> {
let objs = [
"<< /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 >>",
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn a_rejected_value_keeps_the_typing_and_stops_the_save() {
let dir = scratch("rejected");
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();
app.open_bytes("order.pdf", None, validated_form()).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.event(egui::Event::Text("500".into()));
h.run_steps(2);
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
h.run_steps(4);
assert!(!out.exists(), "nothing is saved without the typed value");
assert_eq!(value(&h, "qty"), ["4"], "the field keeps its valid value");
let focus = h.state().views[0].forms.focus.clone().expect("the editor stays open");
assert_eq!(focus.text, "4500", "with the text (typed after the 4), so the user can fix it");
// And closing asks rather than dropping it.
h.state_mut().request_close_tab(0);
assert!(h.state().close_request.is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_save_shortcut_pressed_while_typing_saves_only_the_document_it_was_pressed_in() {
let dir = scratch("deferred-doc");
let out = dir.join("saved.pdf");
let mut h = harness();
click_field(&mut h, "name", 0);
h.event(egui::Event::Text("Tab one".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::S, egui::Modifiers::COMMAND)]);
assert!(!out.exists(), "⌘S while typing waits a frame for the field");
// Before the deferred ⌘S runs, another document becomes active.
h.state_mut().open_bytes("other.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap();
h.run_steps(3);
assert!(!out.exists(), "the other document is not saved by a shortcut pressed in the first");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_value_refused_on_enter_keeps_save_in_the_same_frame_from_running() {
let dir = scratch("enter-refused");
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();
app.open_bytes("order.pdf", None, validated_form()).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.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)]);
h.run_steps(3);
assert_eq!(value(&h, "qty"), ["4"]);
assert!(!out.exists(), "nothing is saved right after the field refused its value");
// The refused text is back in the field, so a later ⌘S stops again instead of saving 4.
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some("4500"));
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
h.run_steps(4);
assert!(!out.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn flattening_while_typing_flattens_the_typed_text() {
let mut h = harness();
click_field(&mut h, "name", 0);
h.event(egui::Event::Text("Flat Ada".into()));
h.run_steps(2);
assert!(h.state_mut().execute("form.flatten"));
h.run_steps(2);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert!(doc.form.iter().all(|f| f.name != "name"), "the field is flattened");
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
let text = r
.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() })
.text
.map(|t| t.plain_text())
.unwrap_or_default();
assert!(text.contains("Flat Ada"), "into the page: {text:?}");
}