diff --git a/ROADMAP.md b/ROADMAP.md index 6adffddd..890f36fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,6 +33,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Trim Media dialog follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box frame drag while editing; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **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 @@ -217,6 +218,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) | | 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) | | 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items | +| 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7bf11e86..4d55841a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2277,3 +2277,141 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?; Some((u32::from_be_bytes(w), u32::from_be_bytes(h))) } + +/// A blank slide holding one text box being edited, with smart guides and grid snapping off so +/// drags move by exactly the pointer delta. Returns the session, the box id and its box. +fn editing_text_box(rot: f64) -> (Session, u64, deckcraft_geom::Xfrm) { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("insert.textBox", &json!({"rect": [200.0, 150.0, 240.0, 60.0], "text": "Hello world"})).unwrap()["id"].as_u64().unwrap(); + if rot != 0.0 { + s.execute("shape.rotate", &json!({"ids": [id], "deg": rot})).unwrap(); + } + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let x = text_box_xfrm(&s, id); + (s, id, x) +} + +/// Slide point for a point in the box's own (rotated) frame. +fn on_box(x: &deckcraft_geom::Xfrm, lx: f64, ly: f64) -> (f64, f64) { + let p = x.affine() * deckcraft_geom::Point::new(lx, ly); + (p.x, p.y) +} + +fn drag_gesture(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> serde_json::Value { + let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let down = s.pointer(ev(PointerKind::Down, from)).unwrap(); + let mid = ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0); + s.pointer(ev(PointerKind::Drag, mid)).unwrap(); + s.pointer(ev(PointerKind::Drag, to)).unwrap(); + s.pointer(ev(PointerKind::Up, to)).unwrap(); + down +} + +#[test] +fn dragging_the_frame_of_edited_text_moves_the_box() { + let (mut s, id, x) = editing_text_box(0.0); + assert!(x.h > 12.0, "{x:?}"); + let sid = deckcraft_model::ShapeId(id as u32); + // Hovering the band on either side of the edge says "move"; the inside says "text". + for ly in [-2.0, 2.0, x.h + 2.0] { + let (px, py) = on_box(&x, x.w / 4.0, ly); + s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap(); + assert_eq!(s.tool.hover, Some(tools::Hit::Frame { id: sid }), "ly {ly}"); + } + let (px, py) = on_box(&x, x.w / 4.0, x.h / 2.0); + s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap(); + assert_eq!(s.tool.hover, Some(tools::Hit::Shape { id: sid, text: true })); + + // Press on the top edge, just outside, and drag: the box moves, text editing ends. + let undo_before = s.doc().unwrap().history.undo.len(); + let from = on_box(&x, x.w / 4.0, -1.0); + let down = drag_gesture(&mut s, from, (from.0 + 30.0, from.1 + 20.0)); + assert_eq!(down["hit"], json!({"kind": "frame", "id": id})); + let moved = text_box_xfrm(&s, id); + assert!((moved.x - (x.x + 30.0)).abs() < 1e-9 && (moved.y - (x.y + 20.0)).abs() < 1e-9, "{moved:?}"); + assert_eq!((moved.w, moved.h), (x.w, x.h)); + let st = s.doc().unwrap(); + assert!(st.selection.text.is_none()); + assert_eq!(st.selection.shapes, vec![sid]); + // One undo step, which puts the box back. + assert_eq!(st.history.undo.len(), undo_before + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_box_xfrm(&s, id), x); + + // The inner side of the band works too; a click there without a drag selects the box whole + // and changes nothing. + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let undo_before = s.doc().unwrap().history.undo.len(); + let (px, py) = on_box(&x, x.w * 0.75, x.h - 1.0); + let ev = |kind| PointerEvent { kind, x: px, y: py, mods: Mods::default(), tol: 3.0 }; + s.pointer(ev(PointerKind::Down)).unwrap(); + s.pointer(ev(PointerKind::Up)).unwrap(); + let st = s.doc().unwrap(); + assert!(st.selection.text.is_none()); + assert_eq!(st.selection.shapes, vec![sid]); + assert_eq!(st.history.undo.len(), undo_before); + assert_eq!(text_box_xfrm(&s, id), x); +} + +#[test] +fn dragging_inside_edited_text_selects_text() { + let (mut s, id, x) = editing_text_box(0.0); + let undo_before = s.doc().unwrap().history.undo.len(); + let down = drag_gesture(&mut s, on_box(&x, 8.0, x.h / 2.0), on_box(&x, x.w - 8.0, x.h / 2.0)); + assert!(down.get("caret").is_some(), "{down}"); + assert_eq!(text_box_xfrm(&s, id), x); + let st = s.doc().unwrap(); + let t = st.selection.text.as_ref().expect("still editing"); + assert!(t.is_range(), "{t:?}"); + assert_eq!(t.ordered(), ((0, 0), (0, 11))); + assert_eq!(st.history.undo.len(), undo_before); +} + +#[test] +fn frame_of_rotated_edited_text_follows_the_rotation() { + let (mut s, id, x) = editing_text_box(30.0); + assert_eq!(x.rot, 30.0); + let sid = deckcraft_model::ShapeId(id as u32); + // The rotated bottom edge is the frame; dragging it moves the box and keeps its rotation. + let from = on_box(&x, x.w / 4.0, x.h + 1.0); + assert_eq!(s.hit_test(deckcraft_geom::Point::new(from.0, from.1), 3.0), Some(tools::Hit::Frame { id: sid })); + let down = drag_gesture(&mut s, from, (from.0 + 25.0, from.1 - 10.0)); + assert_eq!(down["hit"]["kind"], "frame"); + let moved = text_box_xfrm(&s, id); + assert!((moved.x - (x.x + 25.0)).abs() < 1e-9 && (moved.y - (x.y - 10.0)).abs() < 1e-9, "{moved:?}"); + assert_eq!(moved.rot, 30.0); + // Inside the rotated box, pressing still places the caret. + s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap(); + let mx = text_box_xfrm(&s, id); + let (px, py) = on_box(&mx, mx.w / 2.0, mx.h / 2.0); + let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Up, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap(); + assert!(r.get("caret").is_some(), "{r}"); + assert_eq!(text_box_xfrm(&s, id), mx); + // Inside the axis-aligned bounds but off the rotated box: neither text nor frame, so a press + // there leaves editing instead of moving the caret. + let off = on_box(&mx, mx.w / 2.0, -mx.h); + let off_pt = deckcraft_geom::Point::new(off.0, off.1); + assert!(mx.bounds().contains(off_pt)); + assert_ne!(s.hit_test(off_pt, 3.0), Some(tools::Hit::Frame { id: sid })); + let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Up, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0 }).unwrap(); + assert!(r.get("caret").is_none(), "{r}"); + assert!(s.doc().unwrap().selection.text.is_none()); + assert_eq!(text_box_xfrm(&s, id), mx); +} + +#[test] +fn resize_handles_beat_the_frame_of_edited_text() { + let (mut s, id, x) = editing_text_box(0.0); + // The SE corner lies in the frame band, but its handle wins. + let from = on_box(&x, x.w, x.h); + let down = drag_gesture(&mut s, from, (from.0 + 40.0, from.1)); + assert_eq!(down["hit"]["kind"], "resize"); + let sized = text_box_xfrm(&s, id); + assert!((sized.w - (x.w + 40.0)).abs() < 1e-9, "{sized:?}"); + assert_eq!((sized.x, sized.y), (x.x, x.y)); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index feb7d641..76f45079 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -101,6 +101,11 @@ pub enum Hit { id: ShapeId, text: bool, }, + /// The frame of the box whose text is being edited (a band on either side of its edge): + /// pressing there leaves text editing and drags the box, like PowerPoint. + Frame { + id: ShapeId, + }, } #[derive(Clone, Debug)] @@ -305,6 +310,13 @@ impl Session { } } } + // The frame of the text being edited: grabbing it moves the box. + if let Some(e) = edited_box(st) + && e.movable + && edit_zone(&e.xfrm, p, tol) == EditZone::Frame + { + return Some(Hit::Frame { id: e.id }); + } // Shapes, topmost first. // Locked objects are still picked (to format them or type in them); they just don't move. for sh in st.shapes().iter().rev() { @@ -418,29 +430,25 @@ impl Session { ToolKind::Select => {} } let st = self.doc()?; - // Clicking inside the text being edited moves the caret / starts a text selection. - if let Some(t) = st.selection.text.clone() - && !t.notes - && t.cell.is_none() - && let Some(sh) = st.shape(t.shape) + // Clicking inside the text being edited moves the caret / starts a text selection. Its + // handles and its frame (see `Hit::Frame`) win: they size, rotate or move the box. + if let Some(e) = edited_box(st) + && edit_zone(&e.xfrm, p, tol) != EditZone::Outside + && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Frame { .. })) + && let Some(sh) = st.shape(e.id) { - let x = xfrm_of(&st.doc, &st.selection, sh); - if x.bounds().inflate(tol, tol).contains(p) - && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) - { - let pos = text_pos(st, sh, p); - let shift = mods.shift; - self.select(|_, sel| { - if let Some(tt) = sel.text.as_mut() { - tt.caret = pos; - if !shift { - tt.anchor = pos; - } + let pos = text_pos(st, sh, p); + let shift = mods.shift; + self.select(|_, sel| { + if let Some(tt) = sel.text.as_mut() { + tt.caret = pos; + if !shift { + tt.anchor = pos; } - })?; - self.tool.drag = Some(Drag::TextSelect { shape: t.shape }); - return Ok(json!({"caret": [pos.0, pos.1]})); - } + } + })?; + self.tool.drag = Some(Drag::TextSelect { shape: e.id }); + return Ok(json!({"caret": [pos.0, pos.1]})); } let hit = self.hit_test(p, tol); // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one; @@ -499,6 +507,17 @@ impl Session { self.begin("Adjust Shape"); self.tool.drag = Some(Drag::Adjust { id, index, orig }); } + Some(Hit::Frame { id }) => { + // Like PowerPoint: the box leaves text editing and is selected whole (a click + // without a drag stops there), and dragging moves it as one undo step. + let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect(); + self.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None }); + } Some(Hit::Shape { id, text }) => { let selected = st.selection.shapes.contains(&id); let toggle = mods.shift || mods.cmd; @@ -1302,6 +1321,46 @@ fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option Option { + let t = st.selection.text.as_ref()?; + if t.notes || t.cell.is_some() { + 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 }) +} + +/// Where a point falls against the box of the text being edited. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EditZone { + /// Well inside: the caret goes there. + Inside, + /// On the frame: a band `tol` wide on either side of the box edge. + Frame, + Outside, +} + +/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside +/// small boxes the band narrows to a quarter of the box so their text stays reachable. +fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone { + let l = x.affine().inverse() * p; + let (w, h) = (x.w.max(0.0), x.h.max(0.0)); + let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 }; + if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol { + return EditZone::Outside; + } + let inner = tol.min(w / 4.0).min(h / 4.0); + if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame } +} + fn contains_rect(outer: Rect, inner: Rect) -> bool { inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1 } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5271de34..ebda78fb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -480,7 +480,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { egui::CursorIcon::Move } } - (_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move, + (_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move, _ => egui::CursorIcon::Default, }; if resp.hovered() { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c0003628..b00cbd15 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -20,6 +20,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Trim Media dialog follows the selection; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (the frame of a text box being edited moves it; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (File › New starter decks; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -124,6 +125,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | | Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time | +| Drag the frame of a text box being edited to move it | yes | yes | +| Exact size/position, lock aspect | yes | yes | | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first | | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. No adjust handles | @@ -220,6 +223,7 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b | 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme (fills, lines and effects). Not a new dialog; dialogs stay 27 | | 2026-10-14 | minor | Slide Master: Preserve, and Title and Footers placeholders, on the open master or layout | | 2026-10-14 | trivial | Trim Media dialog: start, end and fades follow the selected clip | +| 2026-10-12 | minor | Dragging the frame of a text box being edited moves it, as in PowerPoint | | 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home | | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | | 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |