From b082f12f70bbd1da2d17123c093382abdd969a3b Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:01:04 -0500 Subject: [PATCH] #129: inspect slide actions --- ROADMAP.md | 4 +- crates/engine/src/cmd/inspect.rs | 139 +++++++++++++++++++++++++++++++ docs/control-protocol.md | 1 + 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index b04c4b69..2cd05e36 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI ``, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table | @@ -294,6 +295,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged | diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 8995e6b5..e09cf14a 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -1,6 +1,7 @@ //! Inspection for agents: the deck, slides and shapes as JSON (verify work without screenshots). use deckcraft_model::resolve::{self, Ctx}; +use deckcraft_model::text::Hyperlink; use deckcraft_model::{Shape, ShapeKind}; use serde_json::{Value, json}; @@ -11,6 +12,7 @@ pub fn specs() -> Vec { vec![ cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document), cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide), + cmd!(query "slide.actions", "Inspect Slide Actions", [], None, "{index?} → {index, slideId, actions: [{owner, event, type, target, hyperlink}]}; lists shape click/hover actions and text-run links/hover actions (including table cells) without executing them", has_doc, actions), cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape), cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands), ] @@ -135,6 +137,70 @@ fn slide(s: &mut Session, p: &Value) -> Result { })) } +fn actions(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let i = usize_param(p, "index").unwrap_or(st.selection.slide); + let sl = st.doc.slides.get(i).ok_or_else(|| bad("slide.actions", format!("no slide {i}")))?; + let mut found = Vec::new(); + for sh in &sl.shapes { + collect_actions(sh, &mut Vec::new(), &mut found); + } + Ok(json!({"index": i, "slideId": sl.id, "actions": found})) +} + +fn collect_actions(sh: &Shape, path: &mut Vec, found: &mut Vec) { + path.push(json!({"id": sh.id, "name": sh.name})); + let owner = json!({"shapeId": sh.id, "shapeName": sh.name, "path": path}); + if let Some(link) = &sh.click { + found.push(action_entry(owner.clone(), "click", link)); + } + if let Some(link) = &sh.hover { + found.push(action_entry(owner.clone(), "hover", link)); + } + if let Some(text) = &sh.text { + collect_text_actions(text, &owner, found); + } + if let ShapeKind::Table(table) = &sh.kind { + for (row_index, row) in table.rows.iter().enumerate() { + for (cell_index, cell) in row.cells.iter().enumerate() { + let mut cell_owner = owner.clone(); + cell_owner["tableRowIndex"] = json!(row_index); + cell_owner["tableCellIndex"] = json!(cell_index); + collect_text_actions(&cell.text, &cell_owner, found); + } + } + } + if let ShapeKind::Group { children, .. } = &sh.kind { + for child in children { + collect_actions(child, path, found); + } + } + path.pop(); +} + +fn collect_text_actions(text: &deckcraft_model::text::TextBody, owner: &Value, found: &mut Vec) { + for (paragraph_index, paragraph) in text.paragraphs.iter().enumerate() { + for (run_index, run) in paragraph.runs.iter().enumerate() { + for (event, link) in [("textLink", run.props.link.as_ref()), ("textHover", run.props.hover.as_ref())] { + if let Some(link) = link { + let mut text_owner = owner.clone(); + text_owner["paragraphIndex"] = json!(paragraph_index); + text_owner["runIndex"] = json!(run_index); + text_owner["text"] = json!(run.content()); + found.push(action_entry(text_owner, event, link)); + } + } + } + } +} + +fn action_entry(owner: Value, event: &str, link: &Hyperlink) -> Value { + let action = serde_json::to_value(&link.action).unwrap_or_default(); + let kind = action.get("kind").cloned().unwrap_or(Value::Null); + let target = ["url", "slide", "name", "path"].iter().find_map(|key| action.get(key).cloned()).unwrap_or(Value::Null); + json!({"owner": owner, "event": event, "type": kind, "target": target, "hyperlink": link}) +} + fn shape(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let id = @@ -154,3 +220,76 @@ fn commands(s: &mut Session, p: &Value) -> Result { .collect(), )) } + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_geom::Xfrm; + use deckcraft_model::table::Row; + use deckcraft_model::text::{Action, Paragraph, Run, RunProps, TextBody}; + use deckcraft_model::{Cell, Shape, ShapeId, ShapeKind, Table}; + + fn link(action: Action) -> Hyperlink { + Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None } + } + + #[test] + fn slide_actions_lists_group_shape_and_text_actions() { + let mut s = Session::with_new(); + let mut child = Shape { id: ShapeId(12), name: "Child".into(), ..Default::default() }; + child.click = Some(link(Action::NextSlide)); + child.hover = Some(link(Action::Url { url: "https://example.com/hover".into() })); + let mut run_props = RunProps::default(); + run_props.link = Some(link(Action::Slide { slide: deckcraft_model::SlideId(7) })); + let mut paragraph = Paragraph::new("linked"); + paragraph.runs = vec![Run::with("linked", run_props)]; + child.text = Some(TextBody { paragraphs: vec![paragraph], ..Default::default() }); + let group = Shape { + id: ShapeId(11), + name: "Group".into(), + kind: ShapeKind::Group { children: vec![child], child: Xfrm::default() }, + ..Default::default() + }; + let st = s.doc_mut().unwrap(); + let slide_index = st.selection.slide; + let presentation = std::sync::Arc::make_mut(&mut st.doc); + std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes = vec![group]; + + let v = s.execute("slide.actions", &json!({})).unwrap(); + let actions = v["actions"].as_array().unwrap(); + assert_eq!(actions.len(), 3, "{v}"); + assert_eq!(actions[0]["event"], "click"); + assert_eq!(actions[0]["type"], "nextSlide"); + assert_eq!(actions[0]["owner"]["path"][0]["name"], "Group"); + assert_eq!(actions[0]["owner"]["path"][1]["id"], 12); + assert_eq!(actions[1]["event"], "hover"); + assert_eq!(actions[1]["target"], "https://example.com/hover"); + assert_eq!(actions[2]["event"], "textLink"); + assert_eq!(actions[2]["owner"]["runIndex"], 0); + assert_eq!(actions[2]["target"], 7); + + let mut cell_props = RunProps::default(); + cell_props.hover = Some(link(Action::Url { url: "https://example.com/cell".into() })); + let mut cell_text = TextBody::from_text("cell link"); + cell_text.paragraphs[0].runs = vec![Run::with("cell link", cell_props)]; + let table = + Table { rows: vec![Row { cells: vec![Cell { text: cell_text, ..Default::default() }], ..Default::default() }], ..Default::default() }; + let table_shape = Shape { id: ShapeId(13), name: "Table".into(), kind: ShapeKind::Table(table), ..Default::default() }; + let st = s.doc_mut().unwrap(); + let presentation = std::sync::Arc::make_mut(&mut st.doc); + std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes.push(table_shape); + let v = s.execute("slide.actions", &json!({})).unwrap(); + let table_action = v["actions"].as_array().unwrap().last().cloned().unwrap(); + assert_eq!(table_action["event"], "textHover"); + assert_eq!(table_action["owner"]["tableRowIndex"], 0); + assert_eq!(table_action["owner"]["tableCellIndex"], 0); + assert_eq!(table_action["target"], "https://example.com/cell"); + } + + #[test] + fn slide_actions_returns_empty_array_for_slide_without_actions() { + let mut s = Session::with_new(); + let v = s.execute("slide.actions", &json!({})).unwrap(); + assert_eq!(v["actions"], json!([])); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9edb40a9..d1d130c7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -32,6 +32,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe COMMAND` documents one) | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason | | `document.inspect` | — | Slides, sections, layouts, shapes, selection | +| `slide.actions` | `{index?}` | Actions on one slide; returns `{index, slideId, actions}`. Each action has an `owner` (shape id/name and group path; text actions also include paragraph/run indexes and run text, plus table row/cell indexes for table text), `event` (`click`, `hover`, `textLink`, or `textHover`), action `type`, `target`, and the full `hyperlink`. Listing is read-only and never executes an action. An empty slide returns `"actions": []`. | | `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`, `goto`; `cells` while See All Slides is open), documents, perf (fps, render ms) | | `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, `animPreview` (canvas playback: slide, time, duration), documents, perf (fps, render ms) | | `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |