From be088865fc1984ec6d6e84a9fb16ea91760633b4 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:09:47 -0400 Subject: [PATCH] M12: a focus script that changes the field being edited shows in its editor, so leaving it keeps the script's value --- crates/ui-egui/src/editing.rs | 13 ++++++++- crates/ui-egui/src/forms_ui.rs | 49 ++++++++++++++++++++++++++++++++++ crates/ui-egui/tests/it/xfa.rs | 27 +++++++++++++++++++ crates/xfa/src/fixtures.rs | 3 ++- 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 540ce4da53..34c9fac2f3 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -474,7 +474,9 @@ impl PdfCraftApp { /// however it moved (a click, Tab or Shift+Tab, Escape, a click on the page): the /// field it left runs its exit, then the field it reached runs its enter. The engine skips an /// exit the field's committed change already ran. A tab that closes runs nothing: its - /// document is going away, and a script changing it then would only ask to save it. + /// document is going away, and a script changing it then would only ask to save it. A script + /// that changes the field being edited (an enter clearing a placeholder) shows in its editor + /// ([`crate::forms_ui::refresh_draft`]), so leaving the field doesn't write the old value back. fn run_focus_scripts(&mut self, i: usize) { let Some(view) = self.views.get_mut(i) else { return }; let now = view.forms.focus.as_ref().map(|f| (f.name.clone(), f.widget)); @@ -483,6 +485,9 @@ impl PdfCraftApp { } let left = std::mem::replace(&mut view.forms.focused, now.clone()); let id = view.id; + // The edited field's value before the scripts, to tell its untouched draft from typing. + let before: Option> = + now.as_ref().and_then(|(name, _)| self.session.get(id)?.form.iter().find(|f| &f.name == name).map(|f| f.value.clone())); use pdfcraft_engine::js::FieldFocus; for ((name, widget), focus) in [left.map(|l| (l, FieldFocus::Exit)), now.map(|n| (n, FieldFocus::Enter))].into_iter().flatten() { match self.session.field_focus(id, &name, widget, focus) { @@ -492,6 +497,12 @@ impl PdfCraftApp { && let Some(view) = self.views.get_mut(i) { view.document_changed(&info); + if let Some(before) = &before + && let Some(draft) = view.forms.focus.as_mut() + && let Some(f) = self.session.get(id).and_then(|d| d.form.iter().find(|f| f.name == draft.name)) + { + crate::forms_ui::refresh_draft(draft, before, &f.value); + } } let out = self.session.take_js_output(id); self.handle_js(id, out); diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 42754753aa..1e171431cc 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -108,6 +108,16 @@ fn open_focus(f: &FormField, widget: usize) -> Focus { } } +/// After a script changed the value of the field being edited from `before` to `after`: the +/// editor shows `after` (as [`open_focus`] would), unless the person has typed there already +/// (the draft no longer holds `before`), whose typing then stays theirs to commit. +pub(crate) fn refresh_draft(focus: &mut Focus, before: &[String], after: &[String]) { + if focus.picked.as_slice() == before && focus.text == before.first().map(String::as_str).unwrap_or_default() { + focus.text = after.first().cloned().unwrap_or_default(); + focus.picked = after.to_vec(); + } +} + /// Select a radio button, if it isn't selected already. fn select_edit(f: &FormField, w: &FormWidget) -> Option { (w.on_state.as_ref() != f.value.first()).then(|| Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Radio(w.on_state.clone()) }) @@ -812,3 +822,42 @@ pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget: let xf = view.page_xform(page)?; Some(widget_rect(&xf, info, page, w.rect)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A draft as the editor opens it on a field holding `value`. + fn draft(value: &[&str]) -> Focus { + Focus { + name: "hint".into(), + widget: 0, + text: value.first().map(|s| s.to_string()).unwrap_or_default(), + picked: value.iter().map(|s| s.to_string()).collect(), + request_focus: true, + select_all: false, + press: None, + calendar: None, + calendar_rect: None, + calendar_text: None, + } + } + + /// An untouched draft takes the value a script gave the field; typing is kept. + #[test] + fn a_draft_follows_a_scripts_change_until_something_is_typed() { + let before = vec!["Type here".to_string()]; + let mut untouched = draft(&["Type here"]); + refresh_draft(&mut untouched, &before, &[]); + assert_eq!((untouched.text.as_str(), untouched.picked.len()), ("", 0)); + let mut typed = draft(&["Type here"]); + typed.text = "Ann".into(); + refresh_draft(&mut typed, &before, &[]); + assert_eq!(typed.text, "Ann", "the person's typing stays"); + // A list's choices count as typing too. + let mut picked = draft(&["a"]); + picked.picked.push("b".into()); + refresh_draft(&mut picked, &["a".to_string()], &["c".to_string()]); + assert_eq!(picked.picked, ["a", "b"]); + } +} diff --git a/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index 7ac6deea67..e98d3df14e 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -188,6 +188,33 @@ fn xfa_enter_and_exit_scripts_run_whenever_the_focus_moves() { assert!(h.query_by_label_contains("left quiet").is_some(), "console: {:?}", h.state().js_console.log); } +/// An enter script that changes the field it enters (clearing a "Type here") shows in the editor, +/// so leaving the field keeps the script's value rather than writing the old one back; typing +/// after it is kept as usual. +#[test] +fn an_enter_script_that_changes_its_field_shows_in_the_editor() { + use egui::{Key, Modifiers}; + let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())); + wait_for_pages(&mut h); + let value = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "hint").and_then(|f| f.value.first().cloned()).unwrap_or_default() + }; + assert_eq!(value(&h), "Type here"); + click_field(&mut h, "hint"); + assert_eq!(value(&h), "", "the enter script cleared it"); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).as_deref(), Some(""), "and the editor shows that"); + h.key_press_modifiers(Modifiers::NONE, Key::Tab); + h.run_steps(4); + assert_eq!(value(&h), "", "leaving it doesn't write \"Type here\" back"); + click_field(&mut h, "hint"); + h.event(egui::Event::Text("Ann".into())); + h.run_steps(2); + h.key_press_modifiers(Modifiers::NONE, Key::Tab); + h.run_steps(4); + assert_eq!(value(&h), "Ann"); +} + #[test] fn messages_from_scripts_run_on_open_show_right_away() { let tpl = pdfcraft_xfa::fixtures::scripted_template() diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 7ca2d9d0c4..885ef9831c 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -662,7 +662,7 @@ if (this.rawValue == "1") { /// A form whose text field `probe` counts its enters in `enters` and its exits in `exits`, whose /// text field `quiet` only shows a message when it is left, and whose radio button `size.large` /// counts its enters in `picks` and its exits in `drops` (each button of a radio group is a field -/// with scripts of its own). +/// with scripts of its own), and whose text field `hint` clears its "Type here" when it gains focus. pub fn focus_template() -> String { let js = "application/x-javascript"; let count = |name: &str| format!(r#""#); @@ -681,6 +681,7 @@ pub fn focus_template() -> String { L{}{} {}{}{}{} + Type here