diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2616e634..50511484 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -419,6 +419,22 @@ fn double_click_in_table_uses_grown_row_heights() { assert_eq!(s.doc().unwrap().selection.text.as_ref().and_then(|t| t.cell), Some((0, 0))); } +#[test] +fn double_click_edits_text_inside_group() { + // A text member of a group is still editable: double-click reaches it through the group + // (regression: it used to be a no-op, so grouped text boxes could not be edited). + let mut s = session(); + let a = + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100.0, 100.0, 120.0, 50.0], "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [300.0, 200.0, 50.0, 50.0]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); + s.execute("arrange.group", &json!({})).unwrap(); + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + // Double-click the grouped text box: it becomes editable and targets that member. + s.pointer(ev(PointerKind::DoubleClick, 160.0, 125.0)).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape.0 as u64), Some(a)); +} + #[test] fn grown_table_frame_covers_its_drawn_rows() { // Rows stored 4 pt tall are drawn taller: the box, its handles and hit tests follow the drawn rows. diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..9c114eb8 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -815,7 +815,18 @@ impl Session { self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?; self.execute("text.selectWord", &json!({})) } - ShapeKind::Group { .. } => Ok(Value::Null), + ShapeKind::Group { .. } => { + // A group is a single selection unit, but its text stays editable: + // double-click reaches the text-holding member under the cursor. + match text_member_at(st, &sh, p, tol).and_then(|mid| st.shape(mid).cloned().map(|m| (mid, m))) { + Some((mid, msh)) if !msh.is_line() => { + let pos = text_pos(st, &msh, p); + self.execute("text.edit", &json!({"id": mid, "at": [pos.0, pos.1]}))?; + self.execute("text.selectWord", &json!({})) + } + _ => Ok(Value::Null), + } + } _ => { self.ui_requests.push(crate::UiRequest::Pane { id: "format".into() }); Ok(Value::Null) @@ -1097,6 +1108,39 @@ fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool { st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box) } +/// The topmost text-holding shape under `p` inside group `sh`, recursing into nested groups. +/// Group children live in the group's child space (`ShapeKind::Group::child`), so the point is +/// mapped through `group_child_affine`, exactly as `hit_shape` does for group hit-testing. +fn text_member_at(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option { + let ShapeKind::Group { children, child } = &sh.kind else { return None }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + member_at(children, deckcraft_geom::group_child_affine(&x, ch), p, tol) +} + +/// Depth-first (topmost-first) search of `children` for a text-holding shape under `p`. +/// `to_abs` maps this level's child space to slide space. +fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option { + let q = to_abs.inverse() * p; + for c in children.iter().rev() { + let Some(cx) = c.xfrm else { continue }; + let local = cx.affine().inverse() * q; + if local.x < -tol || local.y < -tol || local.x > cx.w + tol || local.y > cx.h + tol { + continue; + } + if let ShapeKind::Group { children: gc, child: gch } = &c.kind { + let gch = Rect::new(gch.x, gch.y, gch.x + gch.w, gch.y + gch.h); + let nested = to_abs * deckcraft_geom::group_child_affine(&cx, gch); + if let Some(id) = member_at(gc, nested, p, tol) { + return Some(id); + } + } else if matches!(c.kind, ShapeKind::Shape | ShapeKind::Connector { .. }) && !c.is_line() { + return Some(c.id); + } + } + None +} + fn contains_rect(outer: Rect, inner: Rect) -> bool { inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1 }