Merge PR #243 from pierrevbc: Canvas: drag the frame of a text box being edited to move it

# Conflicts:
#	crates/engine/src/tests.rs
#	crates/engine/src/tools.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:26:59 -04:00
5 changed files with 225 additions and 22 deletions
+2
View File
@@ -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 |
+138
View File
@@ -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));
}
+80 -21
View File
@@ -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<S
None
}
/// The box whose text is being edited on the slide (not speaker notes, not a table cell).
struct EditedBox {
id: ShapeId,
xfrm: Xfrm,
/// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked.
movable: bool,
}
fn edited_box(st: &crate::DocState) -> Option<EditedBox> {
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
}
+1 -1
View File
@@ -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() {
+4
View File
@@ -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 |