Canvas: right click selects the shape under the pointer

A right click on an unselected shape opened the slide's context menu
(Layout, Reset Slide...) because only the primary button reached the
pointer tool; the shape had to be left-clicked first. The right-button
press now goes through Session::pointer as PointerKind::ContextClick
(`contextclick` on the control channel and MCP) and selects like
PowerPoint before the menu opens: an unselected shape is selected alone
(a text box or placeholder is not put into text editing, nothing is
dragged), a selected shape keeps the selection, multiple selection
included, and empty slide clears it. In the box whose text is being
edited, the caret and text selection stay, and the context menu offers
text items (Paragraph, Bullets, Numbering, Link) next to Cut, Copy and
Paste, which act on the selected text.
This commit is contained in:
pierrevbc
2026-10-13 11:26:20 +02:00
parent 1660982e58
commit ab81f6bc73
7 changed files with 155 additions and 6 deletions
+77
View File
@@ -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]);
}
+43
View File
@@ -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<Value> {
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(_)))
}
+3 -1
View File
@@ -32,6 +32,7 @@ pub fn pointer_kind(k: &str) -> Option<PointerKind> {
"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);
+2 -2
View File
@@ -240,9 +240,9 @@ pub fn tool_definitions() -> Vec<Value> {
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,
+28 -2
View File
@@ -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();
+1
View File
@@ -342,6 +342,7 @@ fn deckcraft_mcp_kind(k: &str) -> Option<deckcraft_engine::PointerKind> {
"move" => Move,
"doubleclick" | "dblclick" => DoubleClick,
"tripleclick" => TripleClick,
"contextclick" | "rightclick" => ContextClick,
_ => return None,
})
}
+1 -1
View File
@@ -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 |