Insert: Action Settings dialog and shape.action

Shapes could only get an action from an action button preset or a
hyperlink to a URL or slide. Insert > Action > Action Settings… opens
PowerPoint's dialog with Mouse Click and Mouse Over tabs: None,
Hyperlink to (next, previous, first, last, last viewed, end show, a
custom show, a slide or a URL), Run program, Play media, and Highlight
click, starting from the shape's current setting.

The new shape.action command sets or clears the click or hover action
of the selected shapes and checks slides, custom shows and required
arguments.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 23:45:04 +03:00
co-authored by Cursor
parent 457406a7a5
commit d500d045b5
4 changed files with 209 additions and 0 deletions
+58
View File
@@ -49,6 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number),
cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time),
cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol),
cmd!(
"shape.action",
"Action Settings",
["Insert", "Links"],
None,
"{on?: click|hover, action: none|next|previous|first|last|lastViewed|endShow|playMedia|slide|url|customShow|program, slide?: index, url?, show?: custom show name, path?, highlight?: bool, tooltip?, ids?}",
has_selection,
shape_action
),
cmd!(
"insert.hyperlink",
"Link",
@@ -599,6 +608,55 @@ fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Insert ▸ Action (Action Settings): what clicking the selected shapes, or pointing at them, does
/// in a show.
fn shape_action(s: &mut Session, p: &Value) -> Result<Value> {
use deckcraft_model::text::{Action, Hyperlink};
const CMD: &str = "shape.action";
let hover = match str_param(p, "on").unwrap_or("click") {
"click" => false,
"hover" | "over" => true,
o => return Err(bad(CMD, format!("`on` is click or hover, not `{o}`"))),
};
let need = |k: &str| str_param(p, k).map(str::to_string).filter(|v| !v.trim().is_empty()).ok_or_else(|| bad(CMD, format!("missing `{k}`")));
let action = match str_param(p, "action").unwrap_or("none") {
"none" => None,
"next" => Some(Action::NextSlide),
"previous" => Some(Action::PreviousSlide),
"first" => Some(Action::FirstSlide),
"last" => Some(Action::LastSlide),
"lastViewed" => Some(Action::LastViewed),
"endShow" => Some(Action::EndShow),
"playMedia" => Some(Action::PlayMedia),
"url" => Some(Action::Url { url: need("url")? }),
"customShow" => {
let name = need("show")?;
if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) {
return Err(bad(CMD, format!("no custom show `{name}`")));
}
Some(Action::CustomShow { name })
}
"program" => Some(Action::Program { path: need("path")? }),
"slide" => {
let i = usize_param(p, "slide").ok_or_else(|| bad(CMD, "missing `slide`"))?;
let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad(CMD, "no such slide"))?;
Some(Action::Slide { slide: slide.id })
}
a => return Err(bad(CMD, format!("unknown action `{a}`"))),
};
let highlight = bool_param(p, "highlight").unwrap_or(true);
let tooltip = str_param(p, "tooltip").unwrap_or("").to_string();
let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight });
edit_shapes(s, p, CMD, |sh| {
if hover {
sh.hover = link.clone();
} else {
sh.click = link.clone();
}
Ok(())
})
}
fn action_button(s: &mut Session, p: &Value) -> Result<Value> {
use deckcraft_model::text::{Action, Hyperlink};
let kind = str_param(p, "kind").unwrap_or("custom");
+24
View File
@@ -835,3 +835,27 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
}
/// Action Settings: click and hover actions on the selected shape, removed again with `none`.
#[test]
fn action_settings_set_click_and_hover() {
use deckcraft_model::text::Action;
let mut s = Session::with_new();
s.execute("slide.new", &json!({})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 50, 50]})).unwrap();
let shape = |s: &Session| s.doc().unwrap().selected_shapes()[0].clone();
s.execute("shape.action", &json!({"action": "slide", "slide": 0, "highlight": false})).unwrap();
let first = s.doc().unwrap().doc.slides[0].id;
let sh = shape(&s);
assert!(matches!(sh.click.as_ref().map(|h| (&h.action, h.highlight_click)), Some((Action::Slide { slide }, false)) if *slide == first));
s.execute("shape.action", &json!({"on": "hover", "action": "url", "url": "https://example.com"})).unwrap();
assert!(matches!(shape(&s).hover.map(|h| h.action), Some(Action::Url { url }) if url == "https://example.com"));
assert!(shape(&s).click.is_some(), "hover leaves the click action");
s.execute("shape.action", &json!({"action": "none"})).unwrap();
assert!(shape(&s).click.is_none());
// Bad input is refused.
assert!(s.execute("shape.action", &json!({"action": "slide", "slide": 99})).is_err());
assert!(s.execute("shape.action", &json!({"action": "url"})).is_err());
assert!(s.execute("shape.action", &json!({"action": "customShow", "show": "nope"})).is_err());
assert!(s.execute("shape.action", &json!({"on": "drag", "action": "next"})).is_err());
}
+122
View File
@@ -55,6 +55,7 @@ fn title(id: &str) -> &'static str {
"chartData" => "Chart Data",
"trim" => "Trim Media",
"smartart" => "Choose a SmartArt Graphic",
"action" => "Action Settings",
"quit" => "DeckCraft",
_ => "DeckCraft",
}
@@ -344,6 +345,127 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
"action" => {
use deckcraft_model::text::Action;
let tab = d.get("tab", "click");
// Start from the selected shape's current setting for this tab.
let (cur, slides, shows) = app
.session
.active()
.map(|st| {
let sh = st.selected_shapes().first().map(|x| if tab == "hover" { x.hover.clone() } else { x.click.clone() }).unwrap_or(None);
let slides: Vec<(deckcraft_model::SlideId, String)> =
st.doc.slides.iter().enumerate().map(|(i, sl)| (sl.id, format!("{}. {}", i + 1, sl.title()))).collect();
let shows: Vec<String> = st.doc.custom_shows.iter().map(|c| c.name.clone()).collect();
(sh, slides, shows)
})
.unwrap_or_default();
let (cur_kind, cur_arg) = match cur.as_ref().map(|h| &h.action) {
None => ("none", String::new()),
Some(Action::NextSlide) => ("next", String::new()),
Some(Action::PreviousSlide) => ("previous", String::new()),
Some(Action::FirstSlide) => ("first", String::new()),
Some(Action::LastSlide) => ("last", String::new()),
Some(Action::LastViewed) => ("lastViewed", String::new()),
Some(Action::EndShow) => ("endShow", String::new()),
Some(Action::PlayMedia) => ("playMedia", String::new()),
Some(Action::Url { url }) => ("url", url.clone()),
Some(Action::CustomShow { name }) => ("customShow", name.clone()),
Some(Action::Program { path }) => ("program", path.clone()),
Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()),
};
ui.horizontal(|ui| {
for (k, l) in [("click", "Mouse Click"), ("hover", "Mouse Over")] {
if ui.selectable_label(tab == k, l).clicked() && tab != k {
d.fields.clear();
d.fields.insert("tab".into(), k.into());
}
}
});
ui.separator();
ui.label(if tab == "hover" { "Action on mouse over" } else { "Action on click" });
let kind = d.get("kind", cur_kind);
let hyperlink = !matches!(kind.as_str(), "none" | "program" | "playMedia");
let pick = |d: &mut Dialog, ui: &mut Ui, k: &str, l: &str| {
if ui.radio(d.get("kind", cur_kind) == k, l).clicked() {
d.fields.insert("kind".into(), k.into());
}
};
pick(d, ui, "none", "None");
ui.horizontal(|ui| {
if ui.radio(hyperlink, "Hyperlink to:").clicked() && !hyperlink {
d.fields.insert("kind".into(), "next".into());
}
let targets = [
("next", "Next Slide"),
("previous", "Previous Slide"),
("first", "First Slide"),
("last", "Last Slide"),
("lastViewed", "Last Slide Viewed"),
("endShow", "End Show"),
("customShow", "Custom Show…"),
("slide", "Slide…"),
("url", "URL…"),
];
let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1);
ui.add_enabled_ui(hyperlink, |ui| {
egui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| {
for (k, l) in targets {
if ui.selectable_label(kind == k, l).clicked() {
d.fields.insert("kind".into(), k.into());
}
}
});
});
});
let kind = d.get("kind", cur_kind);
match kind.as_str() {
"url" => field(ui, d, "Address:", "arg", if cur_kind == "url" { &cur_arg } else { "https://" }),
"slide" => {
let sel = d.get("arg", if cur_kind == "slide" { &cur_arg } else { "0" }).parse::<usize>().unwrap_or(0);
egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| {
for (i, (_, t)) in slides.iter().enumerate() {
if ui.selectable_label(sel == i, t).clicked() {
d.fields.insert("arg".into(), i.to_string());
}
}
});
}
"customShow" => {
if shows.is_empty() {
ui.label("This presentation has no custom shows (Slide Show ▸ Custom Slide Show).");
}
let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) });
for n in &shows {
if ui.selectable_label(&sel == n, n).clicked() {
d.fields.insert("arg".into(), n.clone());
}
}
}
_ => {}
}
pick(d, ui, "program", "Run program:");
if d.get("kind", cur_kind) == "program" {
field(ui, d, "Program:", "arg", if cur_kind == "program" { &cur_arg } else { "" });
}
pick(d, ui, "playMedia", "Play the shape's media");
let hl = check(ui, d, "Highlight click", "highlight", cur.as_ref().is_none_or(|h| h.highlight_click));
let (ok, cancel) = buttons(ui, "OK");
if ok {
let kind = d.get("kind", cur_kind);
let arg = d.get("arg", &cur_arg);
let mut p = json!({"on": tab, "action": kind, "highlight": hl});
match kind.as_str() {
"url" => p["url"] = json!(arg),
"slide" => p["slide"] = json!(arg.parse::<usize>().unwrap_or(0)),
"customShow" => p["show"] = json!(arg),
"program" => p["path"] = json!(arg),
_ => {}
}
run(app, "shape.action", p);
}
ok || cancel
}
"altText" => {
let cur = app.session.active().and_then(|s| s.selected_shapes().first().map(|x| x.descr.clone())).unwrap_or_default();
ui.label("How would you describe this object and its context to someone who is blind or has low vision?");
+5
View File
@@ -1062,6 +1062,11 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) {
run(app, "insert.actionButton", json!({"kind": k}));
}
}
ui.separator();
let has_sel = app.session.active().is_some_and(|s| !s.selection.shapes.is_empty());
if ui.add_enabled(has_sel, egui::Button::new("Action Settings…")).clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("action"));
}
});
});
group(ui, |ui| {