diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..eadebc61 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,80 @@ 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"); } + +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 bf8b5c31..5728a58a 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!({})) @@ -826,6 +830,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 253765bf..1691286f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -376,7 +376,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)) @@ -630,7 +634,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 |