Merge PR #244 from pierrevbc: Canvas: right click selects the shape under the pointer

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-14 12:58:01 -04:00
9 changed files with 160 additions and 7 deletions
+77
View File
@@ -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]);
}
+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!({}))
@@ -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<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(_)))
}