diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..ca6bdb06 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -49,6 +49,15 @@ pub fn specs() -> Vec { 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 { }) } +/// 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 { + 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 { use deckcraft_model::text::{Action, Hyperlink}; let kind = str_param(p, "kind").unwrap_or("custom"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..ecd47563 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..de4208f5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 = 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::().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::().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?"); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..538da3d9 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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| {