Browse / github.com/storytold/deckcraft / patch
Fix text editing coordinates inside transformed groups
Fix text editing coordinates inside transformed groups Double-clicking text inside a moved or scaled group can draw the editing outline, caret, and selection highlight away from the visible text. The renderer applies the enclosing groups' child-space transforms, while editing used the member's stored transform alone. This change accumulates the enclosing group transforms for text editing and canvas overlays. Caret placement, selection, underlines, handle hit testing, and frame gestures use the same page coordinates as the rendered content, including nested, rotated, and flipped groups. Pointer movement is converted back into the member's group sp
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- TreyTrusko (imported from pull request #1095, open)
- Base
a31b990d32f2c8562f1588562f922960c3fde48b- Size
- +214 −32 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
35d7f21268c8ffe55648f8f9
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/mod.rs crates/engine/src/cmd/text.rs crates/engine/src/tests.rs crates/engine/src/tools.rs crates/ui-egui/src/canvas.rs docs/ui-parity.md
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** minor (grouped-text editing follows the enclosing group transforms; native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere@@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---|+| 2026-10-20 | Grouped text: editing outlines, caret/selection, hit tests and frame gestures follow moved, scaled, rotated, flipped and nested groups. Synthetic regression coverage; breadth and readiness scores unchanged | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |@@ -239,6 +240,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---|+| 2026-10-20 | minor | Progress log: grouped-text editing uses the enclosing group transforms; parity scores unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged | | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |@@ -415,6 +415,40 @@ pub fn xfrm_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shap x } +/// Map a shape's containing group space onto the edited page. Root shapes use page coordinates.+/// Keep the same child-space mapping and nesting limit as the renderer; a group's own box+/// transform is not its children's transform when its child rectangle differs from its box.+pub fn parent_transform(st: &crate::DocState, id: ShapeId) -> deckcraft_geom::Affine {+ use deckcraft_geom::{Affine, Rect};+ use deckcraft_model::{Shape, ShapeKind};++ fn find(st: &crate::DocState, shapes: &[Shape], id: ShapeId, parent: Affine, depth: usize) -> Option<Affine> {+ if depth > 32 {+ return None;+ }+ for sh in shapes {+ if sh.id == id {+ return Some(parent);+ }+ if let ShapeKind::Group { children, child } = &sh.kind {+ 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);+ if let Some(m) = find(st, children, id, parent * deckcraft_geom::group_child_affine(&x, ch), depth + 1) {+ return Some(m);+ }+ }+ }+ None+ }++ find(st, st.shapes(), id, Affine::IDENTITY, 0).unwrap_or(Affine::IDENTITY)+}++/// Shape-local coordinates to the edited page, including every enclosing group's transform.+pub fn shape_to_slide(st: &crate::DocState, shape: &deckcraft_model::Shape) -> deckcraft_geom::Affine {+ parent_transform(st, shape.id) * xfrm_of(&st.doc, &st.selection, shape).affine()+}+ /// Row heights of table `t` as drawn: a stored height is a minimum, rows grow to fit their text. pub fn table_drawn_heights(doc: &deckcraft_model::Presentation, sel: &crate::Selection, t: &deckcraft_model::Table) -> Vec<f64> { match text::ctx_for(doc, sel) {@@ -262,7 +262,7 @@ pub fn text_frame(st: &DocState, t: &TextSel) -> Option<(deckcraft_text::TextLay let x = xfrm_of(&st.doc, &st.selection, sh); let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; let l = lay(sh, body, tr);- let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);+ let m = deckcraft_render::text_transform(super::shape_to_slide(st, sh), tr, l.rotation); Some((l, m)) } @@ -308,7 +308,7 @@ pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, // After End on a wrapped line the caret is drawn at that line's end. let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?;- let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation);+ let m = deckcraft_render::text_transform(super::shape_to_slide(st, sh), geo.text_rect, l.rotation); Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot))) } @@ -908,7 +908,7 @@ pub fn range_underlines( let Some(l) = layout_for(st, &TextSel { shape: id, ..Default::default() }) else { return vec![] }; let x = xfrm_of(&st.doc, &st.selection, sh); let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;- let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);+ let m = deckcraft_render::text_transform(super::shape_to_slide(st, sh), tr, l.rotation); let mut out = vec![]; for &(para, a, b) in ranges { for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) {@@ -941,8 +941,7 @@ pub fn cell_range_underlines( &body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, );- let x = xfrm_of(&st.doc, &st.selection, sh);- let m = deckcraft_render::text_transform(x.affine(), rect, l.rotation);+ let m = deckcraft_render::text_transform(super::shape_to_slide(st, sh), rect, l.rotation); let mut out = vec![]; for &(para, a, b) in ranges { for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) {@@ -968,7 +967,7 @@ pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deck let Some(l) = layout_for(st, t) else { return vec![] }; let x = xfrm_of(&st.doc, &st.selection, sh); let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;- let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);+ let m = deckcraft_render::text_transform(super::shape_to_slide(st, sh), tr, l.rotation); underline_segments(&l, m, c) } @@ -1010,8 +1009,7 @@ fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option<C &body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, );- let x = xfrm_of(&st.doc, &st.selection, sh);- let to_slide = deckcraft_render::text_transform(x.affine(), rect, layout.rotation);+ let to_slide = deckcraft_render::text_transform(super::shape_to_slide(st, sh), rect, layout.rotation); Some(CellText { layout, to_slide }) } @@ -600,6 +600,106 @@ fn double_click_edits_text_inside_group() { assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape.0 as u64), Some(a)); } +#[test]+fn grouped_text_editing_matches_rendered_text() {+ use deckcraft_geom::{Point, Xfrm};+ use deckcraft_model::{ShapeId, ShapeKind};++ for nested in [false, true] {+ for (rotation, flip_h, flip_v) in [(0.0, false, false), (27.0, false, false), (15.0, true, false), (0.0, false, true)] {+ let mut s = session();+ let id = ShapeId(+ s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 20.0, 140.0, 60.0], "text": "Hello group"})).unwrap()["id"]+ .as_u64()+ .unwrap() as u32,+ );+ s.edit(|doc, sel| {+ let group_id = ShapeId(doc.alloc_id().unwrap());+ let outer_id = ShapeId(doc.alloc_id().unwrap());+ let shapes = crate::shapes_mut(doc, sel).unwrap();+ let child = shapes.remove(shapes.iter().position(|sh| sh.id == id).unwrap());+ let group = Shape {+ id: group_id,+ xfrm: Some(Xfrm { rot: rotation, flip_h, flip_v, ..Xfrm::new(300.0, 100.0, 280.0, 120.0) }),+ kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 140.0, 60.0) },+ ..Default::default()+ };+ shapes.push(if nested {+ Shape {+ id: outer_id,+ xfrm: Some(Xfrm { rot: -12.0, ..Xfrm::new(100.0, 60.0, 450.0, 200.0) }),+ kind: ShapeKind::Group { children: vec![group], child: Xfrm::new(250.0, 80.0, 350.0, 170.0) },+ ..Default::default()+ }+ } else {+ group+ });+ sel.shapes.clear();+ sel.text = None;+ Ok(())+ })+ .unwrap();+ let placed = deckcraft_render::place_slide(&s.doc().unwrap().doc, 0);+ let drawn = placed.texts.iter().find(|t| t.body.text() == "Hello group").unwrap();+ let t = TextSel { shape: id, caret: (0, 3), anchor: (0, 0), ..Default::default() };+ let st = s.doc().unwrap();+ let (_, editing) = cmd::text::text_frame(st, &t).unwrap();+ for q in [Point::ZERO, Point::new(100.0, 40.0)] {+ assert!(+ (editing * q - drawn.transform * q).hypot() < 1e-8,+ "editing transform differs from rendering (nested={nested}, rotation={rotation}, flips={flip_h}/{flip_v})"+ );+ }+ let (cx, top, bot) = drawn.layout.caret_drawn(deckcraft_text::Pos { para: 0, ch: 3 }, deckcraft_text::Affinity::Downstream).unwrap();+ let (a, b) = cmd::text::caret_line(st, &t).unwrap();+ assert!((a - drawn.transform * Point::new(cx, top)).hypot() < 1e-8);+ assert!((b - drawn.transform * Point::new(cx, bot)).hypot() < 1e-8);+ let p = drawn.transform * Point::new(cx, (top + bot) / 2.0);+ assert_eq!(cmd::text::text_pos_at(st, &t, p), (0, 3));+ let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: Mods::default(), tol: 1.0, pressure: None };+ s.pointer(ev(PointerKind::DoubleClick)).unwrap();+ assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().shape, id);+ s.pointer(ev(PointerKind::Down)).unwrap();+ s.pointer(ev(PointerKind::Up)).unwrap();+ let t = s.doc().unwrap().selection.text.as_ref().unwrap();+ assert_eq!(t.shape, id, "clicking the displayed text keeps editing its group member");+ assert_eq!(t.caret, (0, 3));+ s.pointer(ev(PointerKind::ContextClick)).unwrap();+ assert_eq!(s.doc().unwrap().selection.text.as_ref().unwrap().shape, id);++ // The displayed corner resizes in the member's own coordinates, and grabbing its+ // displayed frame moves it by the pointer's page-space displacement.+ let st = s.doc().unwrap();+ let x = cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap());+ let parent = cmd::parent_transform(st, id);+ let corner = parent * tools::box_handles(&x).0[4];+ assert!(matches!(s.hit_test(corner, 1.0), Some(tools::Hit::Resize { id: hit, handle: 4 }) if hit == id));+ let end = parent * Point::new(x.x + x.w + 16.0, x.y + x.h + 8.0);+ let event =+ |kind, p: Point| PointerEvent { kind, x: p.x, y: p.y, mods: Mods { cmd: true, ..Default::default() }, tol: 1.0, pressure: None };+ s.pointer(event(PointerKind::Down, corner)).unwrap();+ s.pointer(event(PointerKind::Drag, end)).unwrap();+ s.pointer(event(PointerKind::Up, end)).unwrap();+ let resized = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap();+ assert!((resized.w - x.w - 16.0).abs() < 1e-8);+ assert!((resized.h - x.h - 8.0).abs() < 1e-8);+ s.execute("edit.undo", &json!({})).unwrap();++ let frame = parent * x.affine() * Point::new(20.0, 0.0);+ assert!(matches!(s.hit_test(frame, 1.0), Some(tools::Hit::Frame { id: hit }) if hit == id));+ let end = frame + deckcraft_geom::Vec2::new(30.0, 15.0);+ s.pointer(event(PointerKind::Down, frame)).unwrap();+ s.pointer(event(PointerKind::Drag, end)).unwrap();+ s.pointer(event(PointerKind::Up, end)).unwrap();+ let moved = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap();+ let displacement = parent * moved.center() - parent * x.center();+ assert!((displacement - deckcraft_geom::Vec2::new(30.0, 15.0)).hypot() < 1e-8);+ s.execute("edit.undo", &json!({})).unwrap();+ assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm, Some(x));+ }+ }+}+ #[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.@@ -383,8 +383,9 @@ impl Session { continue; } let x = xfrm_of(&st.doc, &st.selection, sh);+ let parent = cmd::parent_transform(st, *id); if sh.is_line() {- let a = x.affine();+ let a = parent * x.affine(); for (end, q) in [(0u8, a * Point::new(0.0, 0.0)), (1u8, a * Point::new(x.w, x.h))] { if (q - p).hypot() <= tol * 1.6 { return Some(Hit::LineEnd { id: *id, end });@@ -394,21 +395,21 @@ impl Session { } let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); for hd in &geo.handles {- if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 {+ if (parent * x.affine() * hd.pos - p).hypot() <= tol * 1.6 { return Some(Hit::Adjust { id: *id, index: hd.index }); } } for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) {- if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 {+ if (parent * x.affine() * hd.pos - p).hypot() <= tol * 1.6 { return Some(Hit::Warp { id: *id, index: hd.index }); } } let (pts, rot) = box_handles(&x);- if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() {+ if (parent * rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() { return Some(Hit::Rotate { id: *id }); } for (i, q) in pts.iter().enumerate() {- if (*q - p).hypot() <= tol * 1.6 {+ if (parent * *q - p).hypot() <= tol * 1.6 { return Some(Hit::Resize { id: *id, handle: i as u8 }); } }@@ -418,7 +419,7 @@ impl Session { // of the text covers that spot (#931). if let Some(e) = edited_box(st) && e.movable- && edit_zone(&e.xfrm, p, tol) == EditZone::Frame+ && e.zone(p, tol) == EditZone::Frame && !shape_in_front(st, st.shapes(), e.id, p, tol, 0) { return Some(Hit::Frame { id: e.id });@@ -593,7 +594,8 @@ impl Session { Some(Hit::Resize { id, handle }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); self.begin("Size");- self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p });+ let start = cmd::parent_transform(self.doc()?, id).inverse() * p;+ self.tool.drag = Some(Drag::Resize { id, handle, orig, start }); } Some(Hit::LineEnd { id, end }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();@@ -604,7 +606,8 @@ impl Session { let ids: Vec<ShapeId> = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] }; let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); let center = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s).center()).unwrap_or(p);- let start = (p.y - center.y).atan2(p.x - center.x);+ let local = cmd::parent_transform(st, id).inverse() * p;+ let start = (local.y - center.y).atan2(local.x - center.x); self.begin("Rotate"); self.tool.drag = Some(Drag::Rotate { ids, center, start, origs }); }@@ -734,18 +737,31 @@ impl Session { } } // Smart guides on the moved group's bounds.- let bounds = origs.iter().map(|(_, x)| x.bounds()).reduce(|a, b| a.union(b)).unwrap_or_default();+ let st = self.doc()?;+ let bounds = origs+ .iter()+ .map(|(id, x)| (cmd::parent_transform(st, *id) * x.affine()).transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h)))+ .reduce(|a, b| a.union(b))+ .unwrap_or_default(); let moved_b = bounds + d; let (snap, guides) = self.snap_rect(moved_b, &ids_now, tol * 1.5, !mods.cmd); d += snap; self.tool.guides = guides; let origs2: Vec<(ShapeId, Xfrm)> = if duplicate { ids_now.iter().copied().zip(origs.iter().map(|o| o.1)).collect() } else { origs.clone() };+ let st = self.doc()?;+ let deltas: Vec<_> = origs2+ .iter()+ .map(|(id, _)| {+ let inv = cmd::parent_transform(st, *id).inverse();+ inv * (Point::ZERO + d) - inv * Point::ZERO+ })+ .collect(); self.edit(|doc, sel| { let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("move", "no slide"))?;- for (id, o) in &origs2 {+ for ((id, o), delta) in origs2.iter().zip(&deltas) { if let Some(sh) = deckcraft_model::find_shape_mut(list, *id) {- sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o });+ sh.xfrm = Some(Xfrm { x: o.x + delta.x, y: o.y + delta.y, ..*o }); } } // A connector dragged away from shapes that stay put comes unglued at those ends.@@ -765,11 +781,14 @@ impl Session { Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) }); } Drag::Resize { id, handle, orig, start } => {- let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);- let nx = self.snap_resize(nx, &orig, handle, id, tol);+ let parent = cmd::parent_transform(self.doc()?, id);+ let local = parent.inverse() * p;+ let nx = resize_box(&orig, handle, local - start, mods.shift || self.is_picture(id), mods.alt);+ let nx = if parent == Affine::IDENTITY { self.snap_resize(nx, &orig, handle, id, tol) } else { nx }; self.set_xfrm(id, nx)?; } Drag::LineEnd { id, end, orig } => {+ let p = cmd::parent_transform(self.doc()?, id).inverse() * p; let a = orig.affine(); let (p0, p1) = (a * Point::new(0.0, 0.0), a * Point::new(orig.w, orig.h)); let mut q = p;@@ -791,6 +810,9 @@ impl Session { })?; } Drag::Rotate { ids, center, start, origs } => {+ let st = self.doc()?;+ let parent = ids.first().map(|id| cmd::parent_transform(st, *id));+ let p = parent.unwrap_or(Affine::IDENTITY).inverse() * p; let ang = (p.y - center.y).atan2(p.x - center.x); let mut delta = (ang - start).to_degrees(); for (id, o) in &origs {@@ -812,6 +834,7 @@ impl Session { } } Drag::Adjust { id, index, orig } => {+ let p = cmd::parent_transform(self.doc()?, id).inverse() * p; let st = self.doc()?; let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; let local = orig.affine().inverse() * p;@@ -858,6 +881,7 @@ impl Session { return Ok(Value::Null); } Drag::Warp { id, index, name, base, orig } => {+ let p = cmd::parent_transform(self.doc()?, id).inverse() * p; let v = { let st = self.doc()?; let Some(sh) = st.shape(id) else { return Ok(Value::Null) };@@ -1184,8 +1208,7 @@ impl Session { 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)+ && edited_box(st).is_some_and(|e| e.zone(p, tol) != EditZone::Outside) { return Ok(json!({"hit": {"kind": "text", "id": t.shape}, "selected": [t.shape], "editing": true})); }@@ -1232,7 +1255,7 @@ impl Session { // A text box: in its own (rotated) frame, short of the band that moves it (`Hit::Frame`). // A table cell: anywhere over the table. let inside = match t.cell {- None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside),+ None => edited_box(st).is_some_and(|e| e.zone(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).@@ -1656,6 +1679,7 @@ fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option<S struct EditedBox { id: ShapeId, xfrm: Xfrm,+ parent: Affine, /// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked. movable: bool, }@@ -1687,7 +1711,21 @@ fn edited_box(st: &crate::DocState) -> Option<EditedBox> { return None; } let sh = st.shape(t.shape)?;- Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked })+ Some(EditedBox {+ id: t.shape,+ xfrm: xfrm_of(&st.doc, &st.selection, sh),+ parent: cmd::parent_transform(st, t.shape),+ movable: !sh.is_line() && !sh.locked,+ })+}++impl EditedBox {+ fn zone(&self, p: Point, tol: f64) -> EditZone {+ let inv = self.parent.inverse();+ let [a, b, c, d, _, _] = inv.as_coeffs();+ let scale = a.hypot(b).max(c.hypot(d));+ edit_zone(&self.xfrm, inv * p, tol * scale)+ } } /// Where a point falls against the box of the text being edited.@@ -811,7 +811,8 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To for id in &st.selection.shapes { let Some(sh) = st.shape(*id) else { continue }; let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);- let a = x.affine();+ let parent = deckcraft_engine::cmd::parent_transform(st, *id);+ let a = parent * x.affine(); let corners = [Point::new(0.0, 0.0), Point::new(x.w, 0.0), Point::new(x.w, x.h), Point::new(0.0, x.h)].map(|q| xf.to_screen(a * q)); if sh.is_line() { let (p0, p1) = (xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h)));@@ -868,20 +869,20 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To if sh.locked && !editing_this { // Locked: greyed handles that don't respond, no rotate or adjust handles. for h in box_handles(&x).0 {- painter.circle(xf.to_screen(h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill));+ painter.circle(xf.to_screen(parent * h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill)); } continue; } if st.selection.shapes.len() == 1 || !editing_this { let (hs, rot) = box_handles(&x);- let rot_s = xf.to_screen(rot);+ let rot_s = xf.to_screen(parent * rot); if !editing_this && sh.ph.as_ref().is_none_or(|_| true) {- let top_mid = xf.to_screen(hs[1]);+ let top_mid = xf.to_screen(parent * hs[1]); painter.line_segment([top_mid, rot_s], sel_stroke); rotate_handle(painter, rot_s, t); } for h in hs {- painter.circle(xf.to_screen(h), 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke));+ painter.circle(xf.to_screen(parent * h), 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke)); } let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); for hd in &geo.handles {@@ -1,12 +1,20 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** minor (grouped-text editing coordinates; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs, direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in `plan/powerpoint/01-observed-ui.md` and `menu-tree.txt` (gitignored, clean-room rules in AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to parity. +## Editing text inside groups++Double-clicking text inside a moved, scaled, rotated, flipped or nested group keeps the editing+outline, caret, selection highlight and mouse hit testing at the text's drawn position. Moving+its frame and resizing its handles map pointer movement back into the member's group space.+Synthetic engine tests compare editing coordinates with the renderer and cover caret placement,+context clicks, frame movement, resizing and undo. Feature and readiness scores are unchanged.+ ## Empty list paragraphs While editing slide text, empty bulleted and numbered paragraphs show gray markers at the list's@@ -165,6 +173,7 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---|+| 2026-10-20 | minor | Grouped-text editing coordinates and pointer gestures follow enclosing group transforms | | 2026-10-16 | minor | Export › Change File Type › PNG and the Export… dialog offer a transparent background (#784) | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) |