diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index fb50563f..61502c57 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -259,6 +259,73 @@ impl Automation { Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns })) } + pub(crate) fn form_actions(&self, a: &Args) -> Result { + use printcraft_engine::FieldAction as A; + let list: Vec = self + .doc(a)? + .field_actions(a.str("field")?) + .iter() + .map(|(t, act)| { + let mut v = match act { + A::JavaScript(j) => json!({ "javascript": j }), + A::Uri(u) => json!({ "url": u }), + A::Reset(f) => json!({ "reset": f }), + A::Named(n) => json!({ "menu": n }), + A::GoTo(p) => json!({ "page": p + 1 }), + A::ShowHide { fields, hide: true } => json!({ "hide": fields }), + A::ShowHide { fields, hide: false } => json!({ "show": fields }), + A::Submit(u) => json!({ "submit": u }), + A::Other(s) => json!({ "other": s }), + }; + v["trigger"] = json!(t.id()); + v + }) + .collect(); + Ok(json!({ "field": a.str("field")?, "actions": list })) + } + + pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result { + use printcraft_engine::{FieldAction as A, FieldTrigger as T}; + let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?; + let bad = |m: String| ToolError::InvalidArgs(m); + let names = |v: &Value| -> Vec { + v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default() + }; + let mut acts = Vec::new(); + for it in items { + let tid = it["trigger"].as_str().unwrap_or(""); + let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?; + let act = if let Some(j) = it["javascript"].as_str() { + A::JavaScript(j.into()) + } else if let Some(u) = it["url"].as_str() { + A::Uri(u.into()) + } else if it.get("reset").is_some() { + A::Reset(names(&it["reset"])) + } else if let Some(m) = it["menu"].as_str() { + A::Named(m.into()) + } else if let Some(p) = it["page"].as_u64() { + A::GoTo((p.max(1) - 1) as usize) + } else if it.get("show").is_some() { + A::ShowHide { fields: names(&it["show"]), hide: false } + } else if it.get("hide").is_some() { + A::ShowHide { fields: names(&it["hide"]), hide: true } + } else if let Some(u) = it["submit"].as_str() { + A::Submit(u.into()) + } else { + return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)"))); + }; + acts.push((t, act)); + } + let name = a.str("field")?.to_string(); + let edit = printcraft_engine::Edit::SetFieldProps { + name, + props: Box::new(printcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }), + }; + let id = self.doc(a)?.id; + self.session.apply(id, edit).map_err(failed)?; + self.form_actions(a) + } + pub(crate) fn js_enabled(&mut self, a: &Args) -> Result { if let Some(on) = a.opt_bool("enabled")? { self.session.set_javascript(on); diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index c1ce0090..4f14c2c4 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -386,6 +386,7 @@ impl Automation { out } }, + actions: None, }; if props == FieldProps::default() { return Err(ToolError::InvalidArgs("nothing to change".into())); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index fe2e873d..d75aebbb 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -509,6 +509,8 @@ impl Automation { "js_enabled" => self.js_enabled(&a)?, "form_set_script" => self.form_set_script(&a)?, "form_merge_data" => self.form_merge_data(&a)?, + "form_actions" => self.form_actions(&a)?, + "form_set_actions" => self.form_set_actions(&a)?, "ocr_status" => self.ocr_status()?, "ocr_recognize_files" => self.ocr_recognize_files(&a)?, "accessibility_report" => self.a11y_report(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 77f99be9..51ef473b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -759,6 +759,20 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), + t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).") + .ro() + .cmd("form.prepare") + .with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])), + t("form_set_actions", "Set field actions", "Field Properties ▸ Actions: replace the field's actions. Each item: trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur) and one of javascript (source), url, reset (field names; [] for all), menu (Print, NextPage, PrevPage, FirstPage, LastPage), page (1-based), show / hide (field names), submit (URL). Triggers not listed lose their action. Undoable.") + .cmd("form.prepare") + .with(schema( + json!({ "doc": doc(), "field": { "type": "string" }, "actions": { "type": "array", "items": { "type": "object", "properties": { + "trigger": { "type": "string", "enum": ["mouse_up", "mouse_down", "mouse_enter", "mouse_exit", "on_focus", "on_blur"] }, + "javascript": { "type": "string" }, "url": { "type": "string" }, "reset": { "type": "array", "items": { "type": "string" } }, + "menu": { "type": "string" }, "page": { "type": "integer", "minimum": 1 }, "show": { "type": "array", "items": { "type": "string" } }, + "hide": { "type": "array", "items": { "type": "string" } }, "submit": { "type": "string" } }, "required": ["trigger"] } } }), + &["doc", "field", "actions"], + )), t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.") .with(schema(json!({ "enabled": { "type": "boolean" } }), &[])), t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 84e9986b..88c9656d 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1392,3 +1392,27 @@ fn merging_form_data_into_a_spreadsheet() { assert_eq!(r["rows"], 3); assert_eq!(std::fs::read_to_string(dir.join("report.csv")).unwrap(), "City\nParis\nOslo\nOslo\n"); } + +#[test] +fn field_actions_through_tools() { + let dir = workdir("field-actions"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 20, 120, 42], "name": "Go" })); + let r = ok( + &mut a, + "form_set_actions", + json!({ "doc": doc, "field": "Go", "actions": [ + { "trigger": "mouse_up", "javascript": "this.pageNum = 2;" }, + { "trigger": "mouse_enter", "hide": ["Go"] }, + { "trigger": "on_focus", "page": 3 } + ] }), + ); + assert_eq!(r["actions"].as_array().unwrap().len(), 3, "{r}"); + assert_eq!(r["actions"][0], json!({ "trigger": "mouse_up", "javascript": "this.pageNum = 2;" })); + assert_eq!(r["actions"][1], json!({ "trigger": "mouse_enter", "hide": ["Go"] })); + assert_eq!(r["actions"][2], json!({ "trigger": "on_focus", "page": 3 })); + let run = ok(&mut a, "js_run", json!({ "doc": doc, "script": "this.pageNum = 2;", "field": "Go" })); + assert_eq!(run["requests"][0]["page"], 3); + assert!(a.call("form_set_actions", &json!({ "doc": doc, "field": "Go", "actions": [{ "trigger": "wave" }] })).is_err()); +} diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 13bf0be3..df23e949 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -155,6 +155,11 @@ pub fn document_scripts(cos: &printcraft_cos::Document) -> Vec<(String, String)> } impl crate::Document { + /// Field Properties ▸ Actions: field `name`'s action per trigger. + pub fn field_actions(&self, name: &str) -> Vec<(printcraft_forms::Trigger, printcraft_forms::FieldAction)> { + self.editor.as_ref().and_then(|e| printcraft_forms::field_actions(&e.cos, name).ok()).unwrap_or_default() + } + /// Document JavaScripts (name, source), in name order. pub fn document_scripts(&self) -> Vec<(String, String)> { self.editor.as_ref().map(|e| document_scripts(&e.cos)).unwrap_or_default() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0352b575..4133be6c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,8 +28,8 @@ pub use printcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use printcraft_forms::{ - BorderStyle, Field as FormField, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, NewField, TabOrder, - Widget as FormWidget, af as form_scripts, flags as field_flags, + BorderStyle, Field as FormField, FieldAction, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, NewField, + TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, flags as field_flags, }; pub use printcraft_a11y as a11y; diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs new file mode 100644 index 00000000..d43102cb --- /dev/null +++ b/crates/forms/src/actions.rs @@ -0,0 +1,255 @@ +//! Field Properties ▸ Actions: what a field does on each trigger (§12.6.3, Table 197: the +//! widget's `/A` for Mouse Up and its `/AA` D, E, X, Fo, Bl for the others). + +use printcraft_cos::{Dict, Document, Object, PdfString}; + +use crate::{FormError, fields}; + +/// The Actions tab's triggers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Trigger { + MouseUp, + MouseDown, + MouseEnter, + MouseExit, + OnFocus, + OnBlur, +} + +impl Trigger { + pub const ALL: [Trigger; 6] = [Trigger::MouseUp, Trigger::MouseDown, Trigger::MouseEnter, Trigger::MouseExit, Trigger::OnFocus, Trigger::OnBlur]; + + pub fn label(self) -> &'static str { + match self { + Trigger::MouseUp => "Mouse Up", + Trigger::MouseDown => "Mouse Down", + Trigger::MouseEnter => "Mouse Enter", + Trigger::MouseExit => "Mouse Exit", + Trigger::OnFocus => "On Focus", + Trigger::OnBlur => "On Blur", + } + } + + /// Snake-case id (agents). + pub fn id(self) -> &'static str { + match self { + Trigger::MouseUp => "mouse_up", + Trigger::MouseDown => "mouse_down", + Trigger::MouseEnter => "mouse_enter", + Trigger::MouseExit => "mouse_exit", + Trigger::OnFocus => "on_focus", + Trigger::OnBlur => "on_blur", + } + } + + pub fn from_id(id: &str) -> Option { + Trigger::ALL.into_iter().find(|t| t.id() == id) + } + + /// The `/AA` key (`None`: Mouse Up lives in `/A`). + fn key(self) -> Option<&'static [u8]> { + match self { + Trigger::MouseUp => None, + Trigger::MouseDown => Some(b"D"), + Trigger::MouseEnter => Some(b"E"), + Trigger::MouseExit => Some(b"X"), + Trigger::OnFocus => Some(b"Fo"), + Trigger::OnBlur => Some(b"Bl"), + } + } +} + +/// The Actions tab's "Select Action" choices. +#[derive(Clone, Debug, PartialEq)] +pub enum FieldAction { + /// Run a JavaScript. + JavaScript(String), + /// Open a web link. + Uri(String), + /// Reset a form: the listed fields, or every field when empty. + Reset(Vec), + /// Execute a menu item (a named action: Print, NextPage, PrevPage, FirstPage, LastPage). + Named(String), + /// Go to a page view (0-based page). + GoTo(usize), + /// Show/hide a field. + ShowHide { fields: Vec, hide: bool }, + /// Submit a form. + Submit(String), + /// An action of another type, kept as it is. + Other(String), +} + +impl FieldAction { + /// How the Actions tab lists it. + pub fn describe(&self) -> String { + match self { + FieldAction::JavaScript(_) => "Run a JavaScript".into(), + FieldAction::Uri(u) => format!("Open a web link: {u}"), + FieldAction::Reset(f) if f.is_empty() => "Reset a form".into(), + FieldAction::Reset(f) => format!("Reset a form: {}", f.join(", ")), + FieldAction::Named(n) => format!("Execute a menu item: {n}"), + FieldAction::GoTo(p) => format!("Go to a page view: page {}", p + 1), + FieldAction::ShowHide { fields, hide } => format!("{} a field: {}", if *hide { "Hide" } else { "Show" }, fields.join(", ")), + FieldAction::Submit(u) => format!("Submit a form: {u}"), + FieldAction::Other(s) => s.clone(), + } + } +} + +fn texts(doc: &Document, o: &Object) -> Vec { + match &*doc.resolve(o) { + Object::Array(a) => a.iter().filter_map(|x| doc.resolve(x).as_string().map(|s| s.to_text())).collect(), + Object::String(s) => vec![s.to_text()], + _ => Vec::new(), + } +} + +fn read(doc: &Document, o: &Object, pages: &[printcraft_cos::ObjRef]) -> Option { + let a = doc.resolve(o); + let d = a.as_dict()?; + Some(match d.name(b"S")? { + b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o).unwrap_or_default()), + b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()), + b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()), + b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()), + b"GoTo" => { + let dest = d.get(b"D").map(|x| doc.resolve(x))?; + let target = dest.as_array()?.first()?.as_ref()?; + FieldAction::GoTo(pages.iter().position(|p| *p == target)?) + } + b"Hide" => FieldAction::ShowHide { + fields: d.get(b"T").map(|t| texts(doc, t)).unwrap_or_default(), + hide: !matches!(d.get(b"H"), Some(Object::Bool(false))), + }, + b"SubmitForm" => FieldAction::Submit( + d.get(b"F") + .map(|f| doc.resolve(f)) + .and_then(|f| { + f.as_string() + .map(|s| s.to_text()) + .or_else(|| f.as_dict().and_then(|fd| fd.get(b"F")).and_then(|x| doc.resolve(x).as_string().map(|s| s.to_text()))) + }) + .unwrap_or_default(), + ), + other => FieldAction::Other(String::from_utf8_lossy(other).into_owned()), + }) +} + +/// The actions of field `name` (its first widget), by trigger, in the tab's order. +pub fn field_actions(doc: &Document, name: &str) -> Result, FormError> { + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let pages = crate::page_refs(doc); + let Some(w) = f.widgets.first() else { return Ok(Vec::new()) }; + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + let mut out = Vec::new(); + for t in Trigger::ALL { + let o = match t.key() { + None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), + Some(k) => aa.get(k).cloned(), + }; + // An action chain (/Next) counts as its first action here. + if let Some(a) = o.and_then(|o| read(doc, &o, &pages)) { + out.push((t, a)); + } + } + Ok(out) +} + +fn write(doc: &mut Document, a: &FieldAction, pages: &[printcraft_cos::ObjRef]) -> Result, FormError> { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Action")); + let list = |v: &[String]| Object::Array(v.iter().map(|s| Object::String(PdfString::text(s))).collect()); + match a { + FieldAction::JavaScript(js) => { + d.set(b"S".to_vec(), Object::name("JavaScript")); + d.set(b"JS".to_vec(), PdfString::text(js)); + } + FieldAction::Uri(u) => { + d.set(b"S".to_vec(), Object::name("URI")); + d.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec())); + } + FieldAction::Reset(f) => { + d.set(b"S".to_vec(), Object::name("ResetForm")); + if !f.is_empty() { + d.set(b"Fields".to_vec(), list(f)); + } + } + FieldAction::Named(n) => { + d.set(b"S".to_vec(), Object::name("Named")); + d.set(b"N".to_vec(), Object::name(n)); + } + FieldAction::GoTo(p) => { + let page = *pages.get(*p).ok_or_else(|| FormError::Invalid(format!("the document has no page {}", p + 1)))?; + d.set(b"S".to_vec(), Object::name("GoTo")); + d.set(b"D".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")])); + } + FieldAction::ShowHide { fields, hide } => { + d.set(b"S".to_vec(), Object::name("Hide")); + d.set(b"T".to_vec(), list(fields)); + d.set(b"H".to_vec(), Object::Bool(*hide)); + } + FieldAction::Submit(u) => { + d.set(b"S".to_vec(), Object::name("SubmitForm")); + let mut fs = Dict::new(); + fs.set(b"FS".to_vec(), Object::name("URL")); + fs.set(b"F".to_vec(), PdfString::literal(u.as_bytes().to_vec())); + d.set(b"F".to_vec(), Object::Dict(fs)); + // HTML form format, as Acrobat's default for web servers (ExportFormat, bit 3). + d.set(b"Flags".to_vec(), Object::Int(4)); + } + FieldAction::Other(_) => return Ok(None), + } + Ok(Some(Object::Ref(doc.add(Object::Dict(d))))) +} + +/// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose +/// theirs. `Other` actions are left as they are. +pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, FieldAction)]) -> Result<(), FormError> { + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); + let pages = crate::page_refs(doc); + for w in &f.widgets { + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let mut aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + let mut a_entry = wd.get(b"A").cloned(); + for t in Trigger::ALL { + let new = actions.iter().find(|(x, _)| *x == t).map(|(_, a)| a); + if matches!(new, Some(FieldAction::Other(_))) { + continue; + } + let obj = match new { + Some(a) => write(doc, a, &pages)?, + None => None, + }; + match t.key() { + None => { + a_entry = obj; + aa.remove(b"U"); + } + Some(k) => match obj { + Some(o) => aa.set(k.to_vec(), o), + None => { + aa.remove(k); + } + }, + } + } + doc.update_dict(w.obj, |d| { + match &a_entry { + Some(a) => d.set(b"A".to_vec(), a.clone()), + None => { + d.remove(b"A"); + } + } + if aa.is_empty() { + d.remove(b"AA"); + } else { + d.set(b"AA".to_vec(), Object::Dict(aa.clone())); + } + })?; + } + Ok(()) +} diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 8aabb2bb..0179e2cb 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -87,6 +87,8 @@ pub struct FieldProps { pub default_value: Option>, /// Lock or unlock the field (its widgets' Locked flag). pub locked: Option, + /// Actions tab: every trigger's action (replacing the field's). + pub actions: Option>, } fn invalid(m: impl Into) -> Result { @@ -633,6 +635,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result = self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default(); d.others = others.clone(); + d.actions = self.session.get(id).map(|doc| doc.field_actions(name)).unwrap_or_default(); if let Some(o) = d.original.as_mut() { o.others = others; + o.actions = d.actions.clone(); } self.field_props = Some(d); self.dialog = Some(crate::Dialog::FieldProps); @@ -563,6 +565,54 @@ pub enum FieldTab { Format, Validate, Calculate, + Actions, +} + +/// Actions tab: the "Select Action" choices. +pub const ACTION_KINDS: [&str; 8] = [ + "Run a JavaScript", + "Open a web link", + "Reset a form", + "Execute a menu item", + "Go to a page view", + "Show a field", + "Hide a field", + "Submit a form", +]; + +/// Actions tab: the action being added. +#[derive(Clone, Debug, PartialEq)] +pub struct ActionDraft { + pub trigger: printcraft_engine::FieldTrigger, + pub kind: usize, + /// The script, URL, field names (comma-separated), menu item or page number. + pub text: String, +} + +impl Default for ActionDraft { + fn default() -> Self { + ActionDraft { trigger: printcraft_engine::FieldTrigger::MouseUp, kind: 0, text: String::new() } + } +} + +impl ActionDraft { + pub fn action(&self) -> Result { + use printcraft_engine::FieldAction as A; + let t = self.text.trim(); + let names = || t.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect::>(); + Ok(match self.kind { + 0 => A::JavaScript(self.text.clone()), + 1 if !t.is_empty() => A::Uri(t.to_string()), + 2 => A::Reset(names()), + 3 => A::Named(if t.is_empty() { "Print".into() } else { t.to_string() }), + 4 => A::GoTo(t.parse::().ok().filter(|p| *p >= 1).ok_or("Enter a page number")? - 1), + 5 if !t.is_empty() => A::ShowHide { fields: names(), hide: false }, + 6 if !t.is_empty() => A::ShowHide { fields: names(), hide: true }, + 7 if !t.is_empty() => A::Submit(t.to_string()), + 1 | 7 => return Err("Enter a URL".into()), + _ => return Err("Enter the field names".into()), + }) + } } /// The Field Properties dialog's working copy. @@ -601,6 +651,9 @@ pub struct FieldDraft { pub calculate: Calculate, /// Every other field's name (the Calculate tab picks from them). pub others: Vec, + /// Actions tab: each trigger's action, and the one being added. + pub actions: Vec<(printcraft_engine::FieldTrigger, printcraft_engine::FieldAction)>, + pub new_action: ActionDraft, original: Box>, } @@ -638,6 +691,8 @@ impl FieldDraft { validate: f.actions.validate.clone(), calculate: f.actions.calculate.clone(), others: Vec::new(), + actions: Vec::new(), + new_action: ActionDraft::default(), original: Box::new(None), }; d.original = Box::new(Some(d.clone())); @@ -664,6 +719,7 @@ impl FieldDraft { if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo) { t.extend([(FieldTab::Format, "Format"), (FieldTab::Validate, "Validate"), (FieldTab::Calculate, "Calculate")]); } + t.push((FieldTab::Actions, "Actions")); t } @@ -691,6 +747,7 @@ impl FieldDraft { flags: OPTION_FLAGS.iter().filter(|b| (self.flags ^ o.flags) & **b != 0).map(|b| (*b, self.flags & b != 0)).collect(), quadding: (self.quadding != o.quadding).then_some(self.quadding), locked: ch(self.locked, o.locked), + actions: (self.actions != o.actions).then(|| self.actions.clone()), default_value: (self.default != o.default).then(|| (!self.default.is_empty()).then(|| self.default.clone())), }; (p != FieldProps::default()).then_some(p) @@ -851,6 +908,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke FieldTab::Format => format_tab(ui, d, t), FieldTab::Validate => validate_tab(ui, d), FieldTab::Calculate => calculate_tab(ui, d, t), + FieldTab::Actions => actions_tab(ui, d, t), FieldTab::Options => match d.kind { FormFieldKind::Text => { use printcraft_engine::field_flags as ff; @@ -1190,6 +1248,83 @@ fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { } } +fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { + use printcraft_engine::FieldTrigger as T; + ui.label(egui::RichText::new("Add an Action").font(theme::semibold(13.0))); + let a = &mut d.new_action; + egui::Grid::new("field-actions-add").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label("Select Trigger:"); + egui::ComboBox::from_id_salt("action-trigger").selected_text(a.trigger.label()).width(220.0).show_ui(ui, |ui| { + for tr in T::ALL { + ui.selectable_value(&mut a.trigger, tr, tr.label()); + } + }); + ui.end_row(); + ui.label("Select Action:"); + egui::ComboBox::from_id_salt("action-kind").selected_text(ACTION_KINDS[a.kind]).width(220.0).show_ui(ui, |ui| { + for (i, k) in ACTION_KINDS.iter().enumerate() { + ui.selectable_value(&mut a.kind, i, *k); + } + }); + ui.end_row(); + let hint = match a.kind { + 0 => "JavaScript", + 1 => "https://…", + 2 => "Fields to reset (comma-separated; empty: all)", + 3 => "Print, NextPage, PrevPage, FirstPage or LastPage", + 4 => "Page number", + 5 | 6 => "Field names (comma-separated)", + _ => "URL to submit to", + }; + ui.label(if a.kind == 0 { "Script:" } else { "Value:" }); + if a.kind == 0 { + ui.add(egui::TextEdit::multiline(&mut a.text).code_editor().desired_rows(3).desired_width(340.0).hint_text(hint).id_salt("action-text")); + } else { + ui.add(egui::TextEdit::singleline(&mut a.text).desired_width(340.0).hint_text(hint).id_salt("action-text")); + } + ui.end_row(); + }); + let mut error = None; + if ui.button("Add").clicked() { + match d.new_action.action() { + Ok(act) => { + let tr = d.new_action.trigger; + d.actions.retain(|(x, _)| *x != tr); + d.actions.push((tr, act)); + d.actions.sort_by_key(|(x, _)| T::ALL.iter().position(|y| y == x)); + d.new_action.text.clear(); + } + Err(e) => error = Some(e), + } + } + if let Some(e) = error { + ui.label(egui::RichText::new(e).small().color(t.text_muted)); + } + ui.add_space(8.0); + ui.label(egui::RichText::new("Actions").font(theme::semibold(13.0))); + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + if d.actions.is_empty() { + ui.label(egui::RichText::new("No actions").color(t.text_muted)); + } + let mut remove = None; + for (i, (tr, act)) in d.actions.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tr.label()).strong()); + ui.label(act.describe()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.push_id(i, |ui| ui.button("Delete")).inner.clicked() { + remove = Some(i); + } + }); + }); + } + if let Some(i) = remove { + d.actions.remove(i); + } + }); +} + fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { #[derive(PartialEq, Clone, Copy)] enum Mode { diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index 522bb824..917f40b7 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -344,3 +344,47 @@ fn image_fields_take_a_picture_when_clicked() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Set the image of Image1")); } + +#[test] +fn actions_tab_adds_and_removes_actions() { + use printcraft_engine::{FieldAction, FieldTrigger}; + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.state_mut().open_field_props("city", 0); + h.run_steps(2); + h.get_by_label("Actions").click(); + h.run_steps(2); + h.get_by_label("Select Trigger:"); + h.get_by_label("No actions"); + { + let d = h.state_mut().field_props.as_mut().unwrap(); + d.new_action.trigger = FieldTrigger::OnBlur; + d.new_action.kind = 1; + d.new_action.text = "https://example.org".into(); + } + h.run_steps(3); + h.get_by_label("Add").click(); + h.run_steps(2); + h.get_by_label("Open a web link: https://example.org"); + { + let d = h.state_mut().field_props.as_mut().unwrap(); + d.new_action.trigger = FieldTrigger::MouseUp; + d.new_action.kind = 0; + d.new_action.text = "app.alert('x')".into(); + } + h.run_steps(3); + h.get_by_label("Add").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let acts = s.session.get(s.views[0].id).unwrap().field_actions("city"); + assert_eq!( + acts, + [ + (FieldTrigger::MouseUp, FieldAction::JavaScript("app.alert('x')".into())), + (FieldTrigger::OnBlur, FieldAction::Uri("https://example.org".into())) + ] + ); +} diff --git a/crates/ui-egui/tests/signing.rs b/crates/ui-egui/tests/signing.rs index 761031e4..661a9b15 100644 --- a/crates/ui-egui/tests/signing.rs +++ b/crates/ui-egui/tests/signing.rs @@ -17,8 +17,11 @@ endstream endobj trailer << /Root 1 0 R >> %%EOF"; +/// A fresh folder per call: tests run in parallel and must not remove each other's files. fn dir() -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("printcraft-signing-ui-{}", std::process::id())); + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("printcraft-signing-ui-{}-{n}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9aad132d..28222f62 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4803,11 +4803,11 @@ title = "Field properties: actions per trigger" area = "F" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.prepare"] +tools = ["form_actions", "form_set_actions"] +evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/prepare.rs::actions_tab_adds_and_removes_actions"] +notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept." [[feature]] id = "form.props-format"