M12: a focus script that changes the field being edited shows in its editor, so leaving it keeps the script's value

This commit is contained in:
Eric Kaden
2026-10-23 23:09:47 -04:00
parent 66c70c0599
commit be088865fc
4 changed files with 90 additions and 2 deletions
+12 -1
View File
@@ -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<Vec<String>> =
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);
+49
View File
@@ -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<Edit> {
(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"]);
}
}
+27
View File
@@ -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()
+2 -1
View File
@@ -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#"<script contentType="{js}">{name}.rawValue = Number({name}.rawValue) + 1;</script>"#);
@@ -681,6 +681,7 @@ pub fn focus_template() -> String {
<field name="large" w="0.5in" h="0.25in"><ui><checkButton/></ui><items><text>L</text></items><event activity="enter">{}</event><event activity="exit">{}</event></field>
</exclGroup>
{}{}{}{}
<field name="hint" w="2in" h="0.3in"><ui><textEdit/></ui><value><text>Type here</text></value><event activity="enter"><script contentType="{js}">if (this.rawValue == "Type here") this.rawValue = "";</script></event></field>
</subform>
</template>
</xdp:xdp>