mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
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:
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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?");
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user