diff --git a/ROADMAP.md b/ROADMAP.md index 671da2d5..feb7fc87 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (presenter keyboard navigation; hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (SmartArt text on its text rectangle; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (title bar Export button; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (right click selects; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -143,6 +144,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | SmartArt import: text laid out on its `dsp:txXfrm` text rectangle, not squeezed into the shape (list layouts no longer show text in narrow columns) | | 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) | | 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) | +| 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3b8f7f14..0422105d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -958,3 +958,80 @@ fn plain_outlines_are_unchanged() { let p = crate::cmd::file::open_presentation("outline.txt", MD_OUTLINE.as_bytes()).unwrap(); assert_eq!(p.slides.first().map(|s| s.title()).as_deref(), Some("# Intro")); } + +fn text_box_xfrm(s: &Session, id: u64) -> deckcraft_geom::Xfrm { + let st = s.doc().unwrap(); + cmd::xfrm_of(&st.doc, &st.selection, st.shape(deckcraft_model::ShapeId(id as u32)).unwrap()) +} + +fn right_click(s: &mut Session, x: f64, y: f64) -> serde_json::Value { + s.pointer(PointerEvent { kind: PointerKind::ContextClick, x, y, mods: Mods::default(), tol: 3.0 }).unwrap() +} + +/// A blank slide with a rectangle A (100,100 200×100) and a text box B (400,100 200 wide), +/// nothing selected. +fn two_shapes() -> (Session, deckcraft_model::ShapeId, deckcraft_model::ShapeId) { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0], "text": "Shape A"})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("insert.textBox", &json!({"rect": [400.0, 100.0, 200.0, 40.0], "text": "Box B"})).unwrap()["id"].as_u64().unwrap(); + s.select(|_, sel| { + sel.shapes.clear(); + sel.text = None; + }) + .unwrap(); + (s, deckcraft_model::ShapeId(a as u32), deckcraft_model::ShapeId(b as u32)) +} + +#[test] +fn right_click_selects_the_shape_under_the_pointer() { + let (mut s, a, b) = two_shapes(); + let undo_before = s.doc().unwrap().history.undo.len(); + // An unselected shape: selected alone, no text editing, no drag. + let r = right_click(&mut s, 150.0, 150.0); + assert_eq!(r["selected"], json!([a])); + let st = s.doc().unwrap(); + assert_eq!(st.selection.shapes, vec![a]); + assert!(st.selection.text.is_none()); + assert!(!s.tool.dragging()); + s.pointer(PointerEvent { kind: PointerKind::Drag, x: 190.0, y: 190.0, mods: Mods::default(), tol: 3.0 }).unwrap(); + assert_eq!(text_box_xfrm(&s, a.0 as u64).x, 100.0); + // A text box, which a left click would start editing: only selected. + right_click(&mut s, 450.0, 115.0); + let st = s.doc().unwrap(); + assert_eq!(st.selection.shapes, vec![b]); + assert!(st.selection.text.is_none()); + // A multiple selection stays when the right click lands on one of its shapes. + s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); + right_click(&mut s, 450.0, 115.0); + assert_eq!(s.doc().unwrap().selection.shapes, vec![a, b]); + // Empty slide: nothing selected (the slide menu). + let r = right_click(&mut s, 700.0, 450.0); + assert!(r["hit"].is_null(), "{r}"); + assert!(s.doc().unwrap().selection.shapes.is_empty()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); +} + +#[test] +fn right_click_in_edited_text_keeps_the_text_selection() { + let (mut s, a, b) = two_shapes(); + s.execute("text.edit", &json!({"id": a, "at": [0, 0]})).unwrap(); + s.execute("text.move", &json!({"to": "wordRight", "extend": true})).unwrap(); + let before = s.doc().unwrap().selection.text.clone().unwrap(); + assert!(before.is_range()); + // Anywhere in the box, even away from the selected word. + let r = right_click(&mut s, 280.0, 185.0); + assert_eq!(r["editing"], true); + assert_eq!(s.doc().unwrap().selection.text.as_ref(), Some(&before)); + // The context menu's Copy and Cut then act on the selected text. + assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().trim(), "Shape"); + s.execute("edit.cut", &json!({})).unwrap(); + assert_eq!(s.execute("text.get", &json!({"id": a})).unwrap()["text"].as_str().unwrap().trim(), "A"); + // A right click on another shape leaves editing and selects that shape. + right_click(&mut s, 450.0, 115.0); + let st = s.doc().unwrap(); + assert!(st.selection.text.is_none()); + assert_eq!(st.selection.shapes, vec![b]); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 9c114eb8..d86b8798 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -21,6 +21,9 @@ pub enum PointerKind { Move, DoubleClick, TripleClick, + /// Secondary-button press (right click), before the context menu opens: selects what's under + /// the pointer the way PowerPoint does, without editing text or starting a drag. + ContextClick, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -299,6 +302,7 @@ impl Session { PointerKind::Drag => self.drag(p, ev.mods, tol), PointerKind::Up => self.up(p, ev.mods), PointerKind::DoubleClick => self.double_click(p, tol), + PointerKind::ContextClick => self.context_click(p, tol), PointerKind::TripleClick => { if self.active().is_some_and(|d| d.selection.text.is_some()) { self.execute("text.selectParagraph", &json!({})) @@ -837,6 +841,45 @@ impl Session { } } + /// Right-button press: the selection the context menu will act on, as in PowerPoint. In the + /// box being edited, the caret and text selection stay (the menu then acts on the text). On a + /// selected shape (or its handles) the selection stays, multiple selection included; on + /// another shape, that shape alone is selected; on empty slide, nothing is. Never edits text, + /// starts a drag or adds an undo step. + fn context_click(&mut self, p: Point, tol: f64) -> Result { + if !matches!(self.tool.kind, ToolKind::Select) || self.tool.drag.is_some() { + return Ok(Value::Null); + } + let st = self.doc()?; + // The same zone in which a left press places the caret (`down`). + if let Some(t) = &st.selection.text + && !t.notes + && t.cell.is_none() + && let Some(sh) = st.shape(t.shape) + && xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p) + { + return Ok(json!({"hit": {"kind": "text", "id": t.shape}, "selected": [t.shape], "editing": true})); + } + let hit = self.hit_test(p, tol); + let id = match &hit { + Some(Hit::Shape { id, .. } | Hit::Resize { id, .. } | Hit::Rotate { id } | Hit::Adjust { id, .. } | Hit::LineEnd { id, .. }) => Some(*id), + None => None, + }; + if !id.is_some_and(|id| st.selection.shapes.contains(&id)) { + self.select(|_, sel| { + sel.text = None; + sel.cells = None; + sel.shapes = id.into_iter().collect(); + })?; + } + let st = self.doc()?; + Ok(json!({ + "hit": serde_json::to_value(&hit).unwrap_or_default(), + "selected": st.selection.shapes, + "editing": st.selection.text.is_some(), + })) + } + fn is_picture(&self, id: ShapeId) -> bool { self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_))) } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 512e7cc4..483fc181 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -32,6 +32,7 @@ pub fn pointer_kind(k: &str) -> Option { "move" => PointerKind::Move, "doubleclick" | "dblclick" => PointerKind::DoubleClick, "tripleclick" => PointerKind::TripleClick, + "contextclick" | "rightclick" => PointerKind::ContextClick, _ => return None, }) } @@ -84,7 +85,8 @@ impl Headless { let mut last = Value::Null; for e in events { let kind = s(e, "kind").unwrap_or(""); - let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; + let kind = pointer_kind(kind) + .ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick|tripleclick|contextclick)"))?; let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs `y`")?; let mods = e.get("mods").map(mods_from).unwrap_or(base); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1a457dda..a71e8262 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -240,9 +240,9 @@ pub fn tool_definitions() -> Vec { tool( "pointer", "Pointer gesture", - "Drive the active tool with pointer events in slide points: down/drag/up/move/doubleclick, exactly like the mouse (draws, moves, resizes, rotates, places the caret).", + "Drive the active tool with pointer events in slide points: down/drag/up/move/doubleclick/contextclick, exactly like the mouse (draws, moves, resizes, rotates, places the caret; contextclick selects like a right click).", obj( - json!({"events": {"type": "array", "items": obj(json!({"kind": string("down|drag|up|move|doubleclick"), "x": num("x (pt)"), "y": num("y (pt)"), "mods": mods_schema()}), &["kind", "x", "y"])}, "mods": mods_schema()}), + json!({"events": {"type": "array", "items": obj(json!({"kind": string("down|drag|up|move|doubleclick|tripleclick|contextclick"), "x": num("x (pt)"), "y": num("y (pt)"), "mods": mods_schema()}), &["kind", "x", "y"])}, "mods": mods_schema()}), &["events"], ), false, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e323b585..70925171 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -394,7 +394,11 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { if resp.hovered() && !app.session.tool.dragging() { let _ = app.session.pointer(ev(PointerKind::Move)); } - let r = if resp.triple_clicked() { + let secondary = resp.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Secondary)); + let r = if secondary && !app.session.tool.dragging() { + // Select what's under the pointer before the context menu opens (on release). + app.session.pointer(ev(PointerKind::ContextClick)) + } else if resp.triple_clicked() { app.session.pointer(ev(PointerKind::TripleClick)) } else if resp.double_clicked() { app.session.pointer(ev(PointerKind::DoubleClick)) @@ -648,7 +652,29 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } } ui.separator(); - if has_sel { + // Right-clicking the text being edited keeps it (`PointerKind::ContextClick`): text items. + let editing = app.session.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.notes)); + if editing { + if ui.button("Paragraph…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); + ui.close(); + } + for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] { + if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + if ui.button("Link…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); + ui.close(); + } + ui.separator(); + if ui.button("Format Shape…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format"})); + ui.close(); + } + } else if has_sel { if ui.button("Edit Text").clicked() { let _ = app.run("text.edit", json!({})); ui.close(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d344d912..c66a0ea0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -342,6 +342,7 @@ fn deckcraft_mcp_kind(k: &str) -> Option { "move" => Move, "doubleclick" | "dblclick" => DoubleClick, "tripleclick" => TripleClick, + "contextclick" | "rightclick" => ContextClick, _ => return None, }) } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 07741fde..6c2d2ffe 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -17,7 +17,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `document.inspect` | — | Slides, sections, layouts, shapes, selection | | `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, documents, perf (fps, render ms) | | `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` | -| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse | +| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick\|contextclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse (`contextclick`: the right-button press that selects before the context menu) | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) | | `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels | | `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8c7c0413..3f38f2c5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -2,6 +2,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Eyedropper in the colour menus (#367); previously minor: File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Export button in the title bar; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (right click selects the shape under the pointer; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -85,7 +86,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | | Exact size/position, lock aspect | yes | yes | | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | -| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | +| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first | | Drag and drop of files and between thumbnails | yes | yes | | Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) | | Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) | @@ -152,6 +153,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been |---|---|---| | 2026-10-13 | trivial | Eyedropper in the colour menus (#367) | | 2026-10-12 | minor | Title bar: an Export button next to Save opens the Export dialog, a quick-access shortcut beside File › Export | +| 2026-10-12 | minor | A right click selects the shape under the pointer before its context menu opens, as in PowerPoint; edited text keeps its selection and gets text items | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |