From e8eb840ec53d8fa1e1ac070b2e3b67c86bc21ecf Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 20:59:14 -0400
Subject: [PATCH] =?UTF-8?q?M6:=20form=20buttons=20show=20the=20side=20pane?=
=?UTF-8?q?ls=20(execMenuItem=20ShowHide=E2=80=A6)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
crates/automation/src/a11y.rs | 1 +
crates/engine/src/js.rs | 11 ++++++++--
crates/engine/src/tests.rs | 27 ++++++++++++++++++++++++
crates/engine/src/xfa.rs | 2 ++
crates/js/src/lib.rs | 39 ++++++++++++++++++++++++++++++++++-
crates/js/src/tests.rs | 36 ++++++++++++++++++++++++++++++++
crates/js/src/xfa.rs | 9 +++++---
crates/ui-egui/src/js_ui.rs | 13 +++++++++++-
crates/ui-egui/tests/forms.rs | 15 ++++++++++++++
parity/acrobat-features.toml | 2 +-
10 files changed, 147 insertions(+), 8 deletions(-)
diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs
index 212454973..3a1422ec7 100644
--- a/crates/automation/src/a11y.rs
+++ b/crates/automation/src/a11y.rs
@@ -657,6 +657,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
R::Focus(f) => json!({ "focus": f }),
R::Beep => json!({ "beep": true }),
R::SaveAs => json!({ "save_as": true }),
+ R::ShowPanel(p) => json!({ "panel": format!("{p:?}").to_lowercase() }),
}
}
diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs
index b797bc8d8..82ee90c5d 100644
--- a/crates/engine/src/js.rs
+++ b/crates/engine/src/js.rs
@@ -7,7 +7,7 @@
//! off, scripts don't run and AF calls keep working natively.
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
-pub use pdfcraft_js::{Limits, Outcome, Request};
+pub use pdfcraft_js::{Limits, Outcome, Panel, Request};
use crate::{DocId, Edit, EditError, Session};
@@ -29,7 +29,14 @@ impl JsOutput {
fn absorb(&mut self, o: &Outcome) {
self.alerts.extend(o.alerts.iter().cloned());
self.console.extend(o.console.iter().cloned());
- self.requests.extend(o.requests.iter().cloned());
+ // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
+ // button's may show or hide a side panel.
+ for r in &o.requests {
+ match r {
+ Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()),
+ r => self.requests.push(r.clone()),
+ }
+ }
self.errors.extend(o.error.iter().cloned());
}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index f125c41da..72854e37a 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -6106,3 +6106,30 @@ fn undo_never_makes_an_unsaved_document_clean() {
s.undo(new).unwrap();
assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
}
+
+#[test]
+fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() {
+ let xdp = r##"
+
+
+
+
+ Attachments
+
+
+
+
+
+
+"##;
+ let mut s = Session::new();
+ let id = s.open("files.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens");
+ s.take_js_output(id);
+ s.apply(id, Edit::XfaEvent { som: "form[0].files[0]".into(), activity: "click".into() }).unwrap();
+ let out = s.take_js_output(id);
+ assert_eq!(out.requests, [pdfcraft_js::Request::ShowPanel(pdfcraft_js::Panel::Attachments)], "{out:?}");
+ s.apply(id, Edit::SetFieldValue { name: "note".into(), value: FieldValue::Text("x".into()) }).unwrap();
+ let out = s.take_js_output(id);
+ assert!(out.requests.is_empty(), "only a click shows a panel: {out:?}");
+ assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console);
+}
diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs
index 0f37933af..313bfd1ca 100644
--- a/crates/engine/src/xfa.rs
+++ b/crates/engine/src/xfa.rs
@@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> {
XfaEffect::Print => Some("print"),
XfaEffect::SaveAs => Some("save"),
XfaEffect::LaunchUrl(_) => Some("open a link"),
+ XfaEffect::ShowPanel(_) => Some("show a side panel"),
XfaEffect::SetFocus(_) => Some("move the focus"),
_ => None,
}
@@ -626,6 +627,7 @@ impl<'a, 'b> Runner<'a, 'b> {
XfaEffect::Print => self.run.out.requests.push(Request::Print),
XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs),
XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())),
+ XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)),
XfaEffect::SetFocus(som) => {
if let Some(name) = self.by_som().get(som).cloned() {
self.run.out.requests.push(Request::Focus(name));
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index d8d9fa8ba..987d28da4 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -13,7 +13,7 @@
//! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`,
//! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`,
//! `setFocus`.
-//! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`,
+//! - `app` (`alert`, `beep`, `response`, `launchURL`, `execMenuItem`, `viewerType`, `viewerVersion`,
//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`), `console` (`println`,
//! `show`, `clear`), `display` and `color`.
//!
@@ -159,6 +159,19 @@ pub enum Request {
Beep,
/// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons).
SaveAs,
+ /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing.
+ ShowPanel(Panel),
+}
+
+/// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Panel {
+ Bookmarks,
+ /// Page thumbnails.
+ Pages,
+ Attachments,
+ Signatures,
+ Layers,
}
/// What running a script did.
@@ -565,6 +578,29 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Option {
+ Some(match name {
+ "ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments),
+ "ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks),
+ "ShowHideThumbnails" => Request::ShowPanel(Panel::Pages),
+ "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures),
+ "ShowHideOptCont" => Request::ShowPanel(Panel::Layers),
+ "Print" => Request::Print,
+ "SaveAs" | "Save" => Request::SaveAs,
+ _ => return None,
+ })
+}
+
+fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult {
+ let item = text(&arg(args, 0), ctx)?;
+ if let Some(r) = menu_item(&item) {
+ host(ctx)?.borrow_mut().requests.push(r);
+ }
+ Ok(JsValue::undefined())
+}
+
fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult {
// No one to answer: as if the dialog was cancelled.
Ok(JsValue::null())
@@ -931,6 +967,7 @@ fn install(ctx: &mut Context) -> JsResult<()> {
.function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0)
.function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1)
.function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2)
+ .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1)
.function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2)
.function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2)
.function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1)
diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs
index 61d3a71e5..bfb53996f 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -1132,3 +1132,39 @@ mod session {
assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}");
}
}
+
+mod menu_items {
+ use super::*;
+ use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa};
+
+ #[test]
+ fn exec_menu_item_shows_side_panels_prints_and_saves() {
+ let o = go(
+ "app.execMenuItem('ShowHideFileAttachment'); app.execMenuItem('ShowHideBookmarks'); app.execMenuItem('ShowHideThumbnails'); \
+ app.execMenuItem('ShowHideSignatures'); app.execMenuItem('ShowHideOptCont'); app.execMenuItem('Print'); \
+ app.execMenuItem('SaveAs'); app.execMenuItem('NoSuchItem');",
+ &Event::field("Mouse Up", "total", ""),
+ );
+ assert_eq!(o.error, None);
+ assert_eq!(
+ o.requests,
+ [
+ Request::ShowPanel(Panel::Attachments),
+ Request::ShowPanel(Panel::Bookmarks),
+ Request::ShowPanel(Panel::Pages),
+ Request::ShowPanel(Panel::Signatures),
+ Request::ShowPanel(Panel::Layers),
+ Request::Print,
+ Request::SaveAs,
+ ]
+ );
+ }
+
+ #[test]
+ fn xfa_scripts_show_the_attachments_panel() {
+ let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() };
+ let o = run_xfa("app.execMenuItem('ShowHideFileAttachment')", &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default());
+ assert_eq!(o.error, None, "{o:?}");
+ assert!(o.effects.contains(&XfaEffect::ShowPanel(Panel::Attachments)), "{:?}", o.effects);
+ }
+}
diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs
index a2361da7e..8f64f9049 100644
--- a/crates/js/src/xfa.rs
+++ b/crates/js/src/xfa.rs
@@ -112,6 +112,8 @@ pub enum XfaEffect {
MessageBox(String),
/// `xfa.host.resetData()`: the named fields, or every field when empty.
ResetData(Vec),
+ /// `app.execMenuItem("ShowHide…")`: show or hide a side panel.
+ ShowPanel(crate::Panel),
Print,
SaveAs,
LaunchUrl(String),
@@ -1049,9 +1051,10 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult {
let item = text(&arg(args, 0), ctx)?;
- match item.as_str() {
- "SaveAs" | "Save" => effect(ctx, XfaEffect::SaveAs)?,
- "Print" => effect(ctx, XfaEffect::Print)?,
+ match crate::menu_item(&item) {
+ Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?,
+ Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?,
+ Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?,
_ => {}
}
Ok(JsValue::undefined())
diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs
index df461fc6e..98b2afbd8 100644
--- a/crates/ui-egui/src/js_ui.rs
+++ b/crates/ui-egui/src/js_ui.rs
@@ -2,7 +2,7 @@
//! the JavaScript console (⌘J), Document JavaScripts, and Preferences ▸ JavaScript.
use egui::{Align, Layout};
-use pdfcraft_engine::js::{JsOutput, Request};
+use pdfcraft_engine::js::{JsOutput, Panel, Request};
use pdfcraft_engine::{DocId, Edit};
use crate::theme::{self, Tokens};
@@ -67,6 +67,17 @@ impl PdfCraftApp {
&[("u", &u)],
),
Request::SaveAs => self.run_command("file.save_as"),
+ Request::ShowPanel(p) => {
+ let panel = match p {
+ Panel::Bookmarks => crate::RightPanel::Bookmarks,
+ Panel::Pages => crate::RightPanel::Pages,
+ Panel::Attachments => crate::RightPanel::Attachments,
+ Panel::Signatures => crate::RightPanel::Signatures,
+ Panel::Layers => crate::RightPanel::Layers,
+ };
+ // Show/Hide: a panel already showing closes.
+ self.right = (self.right != Some(panel)).then_some(panel);
+ }
Request::Focus(_) | Request::Beep | Request::Reset(_) => {}
}
}
diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs
index e153dcca9..6b2add486 100644
--- a/crates/ui-egui/tests/forms.rs
+++ b/crates/ui-egui/tests/forms.rs
@@ -1072,3 +1072,18 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() {
// An explicit size is kept, as in Acrobat: the text may then run past the box.
assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0);
}
+
+#[test]
+fn a_form_button_shows_and_hides_the_attachments_panel() {
+ let mut h = harness();
+ h.state_mut().set_option("panel", "none").unwrap();
+ h.run_steps(2);
+ let id = h.state().views[0].id;
+ h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');");
+ h.run_steps(2);
+ assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Attachments));
+ // Show/Hide, as its name says: asked again, it closes.
+ h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');");
+ h.run_steps(2);
+ assert_eq!(h.state().right, None);
+}
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 7aca7d5c8..f47140442 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -5137,7 +5137,7 @@ status = "partial"
commands = ["tools.js_console"]
tools = ["js_run"]
evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/automation.rs::javascript_through_tools"]
-notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet."
+notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels, Print and Save As, from a button only), util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet."
[[feature]]
id = "form.js-console"