M6: Field Properties ▸ Actions tab (six triggers, JavaScript/link/reset/menu/page/show-hide/submit actions); form_actions, form_set_actions tools; signing UI tests get their own temp folders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 05:08:28 -04:00
parent 6ff0bf8f64
commit 620ef4fcad
15 changed files with 590 additions and 8 deletions

View File

@ -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<Value> {
use printcraft_engine::FieldAction as A;
let list: Vec<Value> = 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<Value> {
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<String> {
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<Value> {
if let Some(on) = a.opt_bool("enabled")? {
self.session.set_javascript(on);

View File

@ -386,6 +386,7 @@ impl Automation {
out
}
},
actions: None,
};
if props == FieldProps::default() {
return Err(ToolError::InvalidArgs("nothing to change".into()));

View File

@ -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)?,

View File

@ -759,6 +759,20 @@ pub fn tools() -> Vec<ToolDef> {
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.")

View File

@ -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());
}

View File

@ -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()

View File

@ -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;

255
crates/forms/src/actions.rs Normal file
View File

@ -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> {
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<String>),
/// 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<String>, 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<String> {
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<FieldAction> {
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<Vec<(Trigger, FieldAction)>, 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<Option<Object>, 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(())
}

View File

@ -87,6 +87,8 @@ pub struct FieldProps {
pub default_value: Option<Option<String>>,
/// Lock or unlock the field (its widgets' Locked flag).
pub locked: Option<bool>,
/// Actions tab: every trigger's action (replacing the field's).
pub actions: Option<Vec<(crate::Trigger, crate::FieldAction)>>,
}
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
@ -633,6 +635,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
return Ok(name.to_string());
}
}
if let Some(acts) = &props.actions {
crate::set_field_actions(doc, name, acts)?;
}
let mut new_name = name.to_string();
if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) {
if n.is_empty() || n.contains('.') {

View File

@ -13,10 +13,12 @@
use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
mod actions;
pub mod af;
pub mod appearance;
mod author;
mod scripting;
pub use actions::{FieldAction, Trigger, field_actions, set_field_actions};
pub use author::{
BorderStyle, FieldFont, FieldProps, Look, NewField, add_field, delete_field, duplicate_field, look, redraw_field, set_button_icon, set_props,
};

View File

@ -634,3 +634,28 @@ fn image_fields_ask_for_a_picture_and_show_it() {
assert!(text.contains("/Icon Do") && text.contains("49.000000 0 0 49.000000 2.000 1.000 cm"), "{text}");
assert!(set_button_icon(&mut doc, "nope", img, (1, 1)).is_err());
}
#[test]
fn field_actions_round_trip_on_every_trigger() {
let mut doc = fixture();
let name = "go".to_string();
let acts = vec![
(Trigger::MouseUp, FieldAction::JavaScript("app.alert('hi')".into())),
(Trigger::MouseEnter, FieldAction::ShowHide { fields: vec!["other".into()], hide: false }),
(Trigger::OnFocus, FieldAction::Uri("https://example.org".into())),
(Trigger::OnBlur, FieldAction::Reset(vec![])),
(Trigger::MouseDown, FieldAction::GoTo(0)),
];
set_field_actions(&mut doc, &name, &acts).unwrap();
let mut got = field_actions(&doc, &name).unwrap();
let mut want = acts.clone();
let key = |x: &(Trigger, FieldAction)| Trigger::ALL.iter().position(|t| *t == x.0);
got.sort_by_key(key);
want.sort_by_key(key);
assert_eq!(got, want);
set_field_actions(&mut doc, &name, &[(Trigger::MouseExit, FieldAction::Named("NextPage".into()))]).unwrap();
assert_eq!(field_actions(&doc, &name).unwrap(), [(Trigger::MouseExit, FieldAction::Named("NextPage".into()))]);
// The Mouse Up script is what the button runs.
set_field_actions(&mut doc, &name, &[(Trigger::MouseUp, FieldAction::JavaScript("this.print();".into()))]).unwrap();
assert_eq!(fields(&doc).iter().find(|f| f.name == "go").unwrap().button, Some(af::ButtonAction::Named("Print".into())));
}

View File

@ -546,8 +546,10 @@ impl crate::PrintCraftApp {
let others: Vec<String> =
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<printcraft_engine::FieldAction, String> {
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::<Vec<_>>();
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::<usize>().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<String>,
/// 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<Option<FieldDraft>>,
}
@ -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 {

View File

@ -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()))
]
);
}

View File

@ -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

View File

@ -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"