Merge PR #557 from NikitkaRa: Insert: Action Settings dialog and shape.action

# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/dialogs.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:29:43 -04:00
4 changed files with 209 additions and 0 deletions
+58
View File
@@ -59,6 +59,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",
@@ -651,6 +660,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
@@ -1853,3 +1853,27 @@ fn smartart_converts_to_and_from_text_and_shapes() {
let g = st.doc.slides[1].shapes.iter().find(|x| x.id.0 as u64 == gid).unwrap();
assert!(g.diagram.is_none() && !g.children().is_empty(), "a plain group of the same shapes");
}
/// 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
@@ -58,6 +58,7 @@ fn title(id: &str) -> &'static str {
"trim" => "Trim Media",
"smartart" => "Choose a SmartArt Graphic",
"bullets" => "Bullets and Numbering",
"action" => "Action Settings",
"quit" => "DeckCraft",
_ => "DeckCraft",
}
@@ -460,6 +461,127 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel || none
}
"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
@@ -1171,6 +1171,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| {