From a773fcfce9c95b40fc32c46e3eb91fd2ad9b6e03 Mon Sep 17 00:00:00 2001 From: Zetenypapp Date: Sat, 17 Oct 2026 22:03:06 +0200 Subject: [PATCH 1/2] Canvas: a shape in front of edited text takes the click (#931) While a placeholder's text was being edited, place_caret took every click inside the text box before the hit test, so a shape in front of it couldn't be selected. It now leaves the click to the hit test when a shape above the one holding the text is under the pointer; a click on the text where nothing is in front still moves the caret. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/tests.rs | 27 +++++++++++++++++++++++++++ crates/engine/src/tools.rs | 9 +++++++++ 3 files changed, 37 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 9e430a0c..a3f119b0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) | | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) | | 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a464fd8b..1f578c63 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3623,3 +3623,30 @@ fn table_column_width_of_a_huge_column_is_ignored() { assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0], "no column changes"); } } + +/// #931: while the text of a placeholder is being edited, a shape in front of it still takes a +/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret. +#[test] +fn a_shape_in_front_of_edited_text_takes_the_click() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); + let square = s.execute("shape.insert", &json!({"preset": "rect", "rect": [380, 260, 200, 120]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0, pressure: None }; + let click = |s: &mut Session, x: f64, y: f64| { + s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap(); + s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap(); + }; + // The content placeholder's edge: its (empty) text is now being edited. + click(&mut s, 66.0, 300.0); + let body = s.doc().unwrap().selection.text.as_ref().map(|t| t.shape).expect("editing the content placeholder"); + assert_ne!(body.0 as u64, square); + // A click on the text away from the square keeps editing it. + click(&mut s, 200.0, 300.0); + assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape), Some(body)); + // A click on the square, in front of the text, selects the square. + click(&mut s, 480.0, 320.0); + let st = s.doc().unwrap(); + assert_eq!(st.selection.shapes.iter().map(|id| id.0 as u64).collect::>(), vec![square]); + assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 == square), "{:?}", st.selection.text); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f1acd9e9..eb97164b 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1228,7 +1228,16 @@ impl Session { None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside), Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p), }; + // A shape in front of the edited text takes the click, as in PowerPoint (#931): the shapes + // above the one holding the text, topmost first. + let in_front = st + .shapes() + .iter() + .rev() + .take_while(|s| deckcraft_model::find_shape(std::slice::from_ref(*s), t.shape).is_none()) + .any(|s| !s.hidden && hit_shape(st, s, p, tol).is_some()); if !inside + || in_front || matches!( self.hit_test(p, tol), Some( From 051d3b4fd31c8dc7fd0eb2079670e2640bef24fe Mon Sep 17 00:00:00 2001 From: Zetenypapp Date: Sun, 18 Oct 2026 10:56:38 +0200 Subject: [PATCH 2/2] Canvas: shapes in front of grouped text and of the edited box's edge take the click too Review follow-up. The in-front check now also looks among the members of the group that holds the edited text, and the edited box's frame (which drags it) yields to a shape in front of the text at that spot; the box's own handles stay on top, as in PowerPoint. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 29 +++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 35 ++++++++++++++++++++++++++--------- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1f578c63..0c91b7b0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3649,4 +3649,33 @@ fn a_shape_in_front_of_edited_text_takes_the_click() { let st = s.doc().unwrap(); assert_eq!(st.selection.shapes.iter().map(|id| id.0 as u64).collect::>(), vec![square]); assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 == square), "{:?}", st.selection.text); + + // A shape over the edited box's edge takes the click there too: the edge would otherwise drag + // the box. + let edge = { + let st = s.doc().unwrap(); + let x = cmd::xfrm_of(&st.doc, &st.selection, st.shape(body).unwrap()); + (x.x, x.y + x.h / 2.0) + }; + let corner = + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [edge.0 - 10.0, edge.1 - 10.0, 20, 20]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + s.execute("text.edit", &json!({"id": body.0})).unwrap(); + // On the box's left edge, inside the band that drags it, clear of its middle handle (handles + // stay on top, as in PowerPoint). + click(&mut s, edge.0 + 1.0, edge.1 - 9.0); + let st = s.doc().unwrap(); + assert_eq!(st.selection.shapes.iter().map(|id| id.0 as u64).collect::>(), vec![corner], "{:?}", st.selection); + + // Text inside a group: a member in front of it takes the click instead of the caret. + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let tb = + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 150], "text": "Grouped text"})).unwrap()["id"].as_u64().unwrap(); + let cover = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 150, 60, 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("arrange.group", &json!({"ids": [tb, cover]})).unwrap(); + s.execute("text.edit", &json!({"id": tb})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape.0 as u64), Some(tb)); + click(&mut s, 230.0, 170.0); + let st = s.doc().unwrap(); + assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection); } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index eb97164b..d356e9d0 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -409,10 +409,12 @@ impl Session { } } } - // The frame of the text being edited: grabbing it moves the box. + // The frame of the text being edited: grabbing it moves the box, unless a shape in front + // of the text covers that spot (#931). if let Some(e) = edited_box(st) && e.movable && edit_zone(&e.xfrm, p, tol) == EditZone::Frame + && !shape_in_front(st, st.shapes(), e.id, p, tol, 0) { return Some(Hit::Frame { id: e.id }); } @@ -1228,14 +1230,8 @@ impl Session { None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside), Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p), }; - // A shape in front of the edited text takes the click, as in PowerPoint (#931): the shapes - // above the one holding the text, topmost first. - let in_front = st - .shapes() - .iter() - .rev() - .take_while(|s| deckcraft_model::find_shape(std::slice::from_ref(*s), t.shape).is_none()) - .any(|s| !s.hidden && hit_shape(st, s, p, tol).is_some()); + // A shape in front of the edited text takes the click, as in PowerPoint (#931). + let in_front = shape_in_front(st, st.shapes(), t.shape, p, tol, 0); if !inside || in_front || matches!( @@ -1659,6 +1655,27 @@ struct EditedBox { movable: bool, } +/// Whether a visible shape drawn in front of `target` is under `p`: the shapes above it in its +/// list, topmost first, and inside a group that holds it, the members above it. +fn shape_in_front(st: &crate::DocState, shapes: &[Shape], target: ShapeId, p: Point, tol: f64, depth: usize) -> bool { + if depth > 64 { + return false; + } + for s in shapes.iter().rev() { + if s.id == target { + return false; + } + if deckcraft_model::find_shape(std::slice::from_ref(s), target).is_some() { + // The group holding the text: look among its members. + return shape_in_front(st, s.children(), target, p, tol, depth + 1); + } + if !s.hidden && hit_shape(st, s, p, tol).is_some() { + return true; + } + } + false +} + fn edited_box(st: &crate::DocState) -> Option { let t = st.selection.text.as_ref()?; if t.notes || t.cell.is_some() {