diff --git a/ROADMAP.md b/ROADMAP.md index f8802c4b..2ed6f8c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.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-23 · **Change:** trivial (progress log: scroll bars on the zoomed slide pane, #1157; previous: 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-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: 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-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: 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-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: 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) @@ -161,6 +161,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-19 | README: the Linux runtime libraries a tarball or musl install has to provide — the `dlopen`ed X11/Vulkan/GL/dialog libraries with their Alpine and Void package names, the software-Vulkan fallback, and why fontconfig and freetype are not among them (#955) | | 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 | +| 2026-10-24 | Edit Points: hold Shift, Ctrl or Alt while dragging a point's handle to make it a smooth, straight or corner point, as in PowerPoint (Cmd and Option work as Ctrl and Alt on the Mac); the point keeps that type for the next drag | | 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | | 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-16 | Symbol-font characters: a run's U+F0xx symbol code (from ``) now draws from the installed Symbol face even when it has no private-use mapping — Apple's Symbol on macOS maps the glyphs to real Unicode, so the code is remapped to that character (the Adobe Symbol encoding) instead of falling to a look-alike. Bullets and metafile text take the same remap | @@ -285,6 +286,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged | | 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: the Columns dialog. Scores and milestone names unchanged | +| 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. Scores and milestone names unchanged | | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged | | 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names 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 | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 72dd9e6b..c7e2c81a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -44,16 +44,19 @@ pub fn specs() -> Vec { } fn restore(s: &mut Session, redo: bool) -> Result { + let vertex_kind = s.tool.vertex_kind.clone(); let st = s.doc_mut()?; let (from, to) = if redo { (&mut st.history.redo, &mut st.history.undo) } else { (&mut st.history.undo, &mut st.history.redo) }; let Some(e) = from.pop() else { return ok() }; - to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }); + to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind }); st.doc = e.doc; st.selection = e.selection; st.interaction = None; // A later shape can get an undone copy's id: Duplicate starts over with the usual offset. st.last_duplicate = None; st.revision += 1; + // The point type Edit Points remembered with the restored document: handle drags follow it. + s.tool.vertex_kind = e.vertex_kind; Ok(json!({"label": e.label})) } diff --git a/crates/engine/src/cmd/points.rs b/crates/engine/src/cmd/points.rs index 7354dff4..90c77e40 100644 --- a/crates/engine/src/cmd/points.rs +++ b/crates/engine/src/cmd/points.rs @@ -837,7 +837,7 @@ fn point_type(s: &mut Session, p: &Value) -> Result { _ => return Err(bad("shape.pointType", "`type` is smooth, straight or corner")), }; // A straight point's handles can be as long as each other; remember the choice for drags. - s.tool.vertex_kind = Some((path, index, kind.clone())); + s.tool.vertex_kind = Some((id, path, index, kind.clone())); edit_path(s, id, path, "shape.pointType", move |segs, vs, _| { let si = *vs.get(index).ok_or_else(|| bad("shape.pointType", "no such point"))?; set_type(segs, si, &kind); @@ -931,7 +931,7 @@ fn close_path(s: &mut Session, p: &Value) -> Result { /// Opening or closing renumbers the points of `path`: a point type remembered for one of them /// would follow the wrong point. fn forget_kinds(s: &mut Session, path: usize) { - if s.tool.vertex_kind.as_ref().is_some_and(|k| k.0 == path) { + if s.tool.vertex_kind.as_ref().is_some_and(|k| k.1 == path) { s.tool.vertex_kind = None; } } diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 2e3e2bda..c90e004e 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -1646,7 +1646,7 @@ mod tests { let mut s = Session::with_new(); s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 30.0, 30.0]})).unwrap(); let st = s.active_mut().unwrap(); - st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone() }); + st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind: None }); s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap(); assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move"); } diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 1c2ceac9..1005a1a9 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -36,11 +36,15 @@ impl Session { /// Run command `id` (`f`) inside the guard. pub(crate) fn guarded(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.revision)); + let vertex_kind = self.tool.vertex_kind.clone(); let depth = self.depth; match catch_unwind(AssertUnwindSafe(|| f(self))) { Ok(r) => r, Err(payload) => { self.depth = depth; + // The remembered point type names a shape of the document it was chosen in. + let same = before.as_ref().is_some_and(|b| self.active().is_some_and(|st| st.uid == b.0)); + self.tool.vertex_kind = if same { vertex_kind } else { None }; let msg = panic_message(payload.as_ref()); // Keep the document as it was before the command. if let (Some((uid, doc, selection, revision)), Some(st)) = (before, self.active_mut()) @@ -91,6 +95,29 @@ mod tests { s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [36, 236, 200, 400]})).unwrap(); } + #[test] + fn a_panicking_command_keeps_the_remembered_point_type() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let kept = Some((deckcraft_model::ShapeId(1), 0, 0, "straight".to_string())); + s.tool.vertex_kind = kept.clone(); + let r = s.guarded("test.panic", |s| { + s.tool.vertex_kind = Some((deckcraft_model::ShapeId(1), 0, 0, "corner".to_string())); + panic!("boom"); + }); + assert!(r.is_err()); + assert_eq!(s.tool.vertex_kind, kept); + // A command that opens another document and then panics leaves no point type behind: the + // one from before names a shape of the other document. + let r = s.guarded("test.panic", |s| { + s.execute("file.new", &json!({}))?; + s.tool.vertex_kind = Some((deckcraft_model::ShapeId(1), 0, 0, "corner".to_string())); + panic!("boom"); + }); + assert!(r.is_err()); + assert_eq!(s.tool.vertex_kind, None); + } + #[test] fn a_command_that_fails_after_editing_leaves_the_document_alone() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff478e8e..bf6d11de 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -163,6 +163,8 @@ pub struct HistoryEntry { pub label: String, pub doc: Arc, pub selection: Selection, + /// The point type Edit Points remembered with `doc` (`ToolState::vertex_kind`). + pub vertex_kind: Option<(ShapeId, usize, usize, String)>, } #[derive(Clone, Debug, Default)] @@ -184,6 +186,8 @@ pub struct Interaction { pub label: String, pub doc: Arc, pub selection: Selection, + /// The point type Edit Points remembered when it began. + pub vertex_kind: Option<(ShapeId, usize, usize, String)>, } /// Text an input method is composing (#101): its preedit sits in the edited text body, at `start` @@ -564,16 +568,21 @@ impl Session { self.active_mut().ok_or(EngineError::NoDocument) } pub fn set_active(&mut self, i: usize) { + let was = self.active().map(|d| d.uid); if i < self.docs.len() { self.active = Some(i); } + self.forget_vertex_kind_unless(was); } pub fn add_document(&mut self, d: DocState) -> usize { + let was = self.active().map(|d| d.uid); self.docs.push(d); self.active = Some(self.docs.len() - 1); + self.forget_vertex_kind_unless(was); self.docs.len() - 1 } pub fn close_document(&mut self, i: usize) { + let was = self.active().map(|d| d.uid); if i < self.docs.len() { let d = self.docs.remove(i); if let Some(dir) = &self.recovery_dir { @@ -581,6 +590,14 @@ impl Session { } self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) }; } + self.forget_vertex_kind_unless(was); + } + /// The point type Edit Points remembers names a shape of the active document, and another + /// document can have a shape with the same id: a change of active document forgets it. + fn forget_vertex_kind_unless(&mut self, was: Option) { + if self.active().map(|d| d.uid) != was { + self.tool.vertex_kind = None; + } } pub fn next_untitled(&mut self) -> String { self.untitled += 1; @@ -606,6 +623,7 @@ impl Session { } } let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone())); + let vertex_kind = self.tool.vertex_kind.clone(); let outer = self.depth == 0; self.depth += 1; let r = (spec.run)(self, params); @@ -624,6 +642,9 @@ impl Session { st.selection = sel; st.revision += 1; } + if outer { + self.tool.vertex_kind = vertex_kind; + } return Err(e); } }; @@ -637,7 +658,7 @@ impl Session { && spec.undoable && outer { - push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel }); + push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel, vertex_kind }); } if spec.journal && outer { self.journal.push((id.to_string(), params.clone())); @@ -668,10 +689,11 @@ impl Session { /// Keep an input method's composition as it stands (#101): its text stays and the whole /// composition becomes one undo step. Nothing happens without one. pub fn end_composition(&mut self) { + let vertex_kind = self.tool.vertex_kind.clone(); let Some(st) = self.active_mut() else { return }; let Some(c) = st.composition.take() else { return }; if !Arc::ptr_eq(&c.before, &st.doc) { - push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection }); + push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection, vertex_kind }); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cf869e06..3611cedb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2371,6 +2371,154 @@ fn edit_points_types_and_handles() { assert!((ho3.unwrap() - ho).hypot() < 1e-3); } +/// Alt, Shift or Ctrl held while dragging a handle makes the point a corner, smooth or straight +/// point, and the point keeps that type for the next drag. +#[test] +fn edit_points_handle_drag_modifiers_set_the_point_type() { + use crate::tools::{Mods, PointerEvent, PointerKind}; + use deckcraft_geom::Vec2; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let id = deckcraft_model::ShapeId(s.execute("shape.points", &json!({})).unwrap()["id"].as_u64().unwrap() as u32); + let handles = |s: &Session| { + let st = s.doc().unwrap(); + let (hi, ho, v) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 0).unwrap(); + (hi.unwrap(), ho.unwrap(), v) + }; + let ev = |kind, p: deckcraft_geom::Point, mods: Mods| PointerEvent { kind, x: p.x, y: p.y, mods, ..Default::default() }; + let drag = |s: &mut Session, from: deckcraft_geom::Point, to: deckcraft_geom::Point, mods: Mods| { + s.pointer(ev(PointerKind::Down, from, Mods::default())).unwrap(); + s.pointer(ev(PointerKind::Drag, to, mods)).unwrap(); + s.pointer(ev(PointerKind::Up, to, mods)).unwrap(); + }; + // Click the point to show its handles. + let (hi, ho, v) = handles(&s); + drag(&mut s, v, v, Mods::default()); + assert_eq!(s.tool.edit_vertex, Some((0, 0))); + // Alt: only the dragged handle moves. + let to = ho + Vec2::new(10.0, -25.0); + drag(&mut s, ho, to, Mods { alt: true, ..Default::default() }); + let (hi1, ho1, _) = handles(&s); + assert!((ho1 - to).hypot() < 1e-3, "{ho1:?}"); + assert!((hi1 - hi).hypot() < 1e-3, "the other handle stays: {hi1:?} {hi:?}"); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "corner"); + // A plain drag afterwards keeps it a corner point. + let to2 = ho1 + Vec2::new(5.0, 5.0); + drag(&mut s, ho1, to2, Mods::default()); + assert!((handles(&s).0 - hi).hypot() < 1e-3); + // Shift: the other handle mirrors the dragged one at the same length. + let to3 = v + Vec2::new(0.0, 40.0); + drag(&mut s, to2, to3, Mods { shift: true, ..Default::default() }); + let (hi3, ho3, _) = handles(&s); + assert!((ho3 - to3).hypot() < 1e-3, "{ho3:?}"); + assert!((hi3 - (v - Vec2::new(0.0, 40.0))).hypot() < 1e-3, "{hi3:?}"); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "smooth"); + // Ctrl: the other handle turns in line and keeps its length. + let to4 = v + Vec2::new(30.0, 0.0); + drag(&mut s, ho3, to4, Mods { cmd: true, ..Default::default() }); + let (hi4, ho4, _) = handles(&s); + assert!((ho4 - to4).hypot() < 1e-3, "{ho4:?}"); + assert!((hi4 - (v - Vec2::new(40.0, 0.0))).hypot() < 1e-3, "{hi4:?}"); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "straight"); +} + +/// A handle drag without a modifier follows the point type remembered with the document that +/// Undo or Redo restores, and only on the shape the type was chosen on. +#[test] +fn edit_points_remembered_point_type_follows_undo_redo_and_its_shape() { + use crate::tools::{Mods, PointerEvent, PointerKind}; + use deckcraft_geom::Vec2; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let id = deckcraft_model::ShapeId(s.execute("shape.points", &json!({})).unwrap()["id"].as_u64().unwrap() as u32); + let handles = |s: &Session| { + let st = s.doc().unwrap(); + let (hi, ho, v) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 0).unwrap(); + (hi.unwrap(), ho.unwrap(), v) + }; + let ev = |kind, p: deckcraft_geom::Point, mods: Mods| PointerEvent { kind, x: p.x, y: p.y, mods, ..Default::default() }; + let drag = |s: &mut Session, from: deckcraft_geom::Point, to: deckcraft_geom::Point, mods: Mods| { + s.pointer(ev(PointerKind::Down, from, Mods::default())).unwrap(); + s.pointer(ev(PointerKind::Drag, to, mods)).unwrap(); + s.pointer(ev(PointerKind::Up, to, mods)).unwrap(); + }; + let (_, ho, v) = handles(&s); + drag(&mut s, v, v, Mods::default()); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "smooth"); + drag(&mut s, ho, ho + Vec2::new(10.0, -25.0), Mods { alt: true, ..Default::default() }); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "corner"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "smooth"); + // A plain drag moves the other handle with the dragged one, as for a smooth point. + let (_, ho1, _) = handles(&s); + let to = v + Vec2::new(0.0, 40.0); + drag(&mut s, ho1, to, Mods::default()); + let (hi2, ho2, _) = handles(&s); + assert!((ho2 - to).hypot() < 1e-3, "{ho2:?}"); + assert!((hi2 - (v - Vec2::new(0.0, 40.0))).hypot() < 1e-3, "the other handle mirrors the dragged one: {hi2:?}"); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["types"][0][0], "smooth"); + // Undo of a Corner Point choice that left the outline as it was: the point is smooth again. + s.execute("shape.pointType", &json!({"index": 0, "type": "corner"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let to = v + Vec2::new(0.0, 50.0); + drag(&mut s, v + Vec2::new(0.0, 40.0), to, Mods::default()); + let (hi3, ho3, _) = handles(&s); + assert!((ho3 - to).hypot() < 1e-3, "{ho3:?}"); + assert!((hi3 - (v - Vec2::new(0.0, 50.0))).hypot() < 1e-3, "the other handle mirrors the dragged one: {hi3:?}"); + // Undo of a step that leaves the outline alone keeps a Straight choice, which equal handles + // cannot show: a plain drag keeps the other handle's length. + s.execute("shape.pointType", &json!({"index": 0, "type": "straight"})).unwrap(); + s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let to2 = v + Vec2::new(0.0, 70.0); + drag(&mut s, to, to2, Mods::default()); + let (hi4, ho4, _) = handles(&s); + assert!((ho4 - to2).hypot() < 1e-3, "{ho4:?}"); + assert!((hi4 - (v - Vec2::new(0.0, 50.0))).hypot() < 1e-3, "the other handle keeps its length: {hi4:?}"); + // Redo brings a Corner Point choice back with the step: a plain drag moves one handle. + s.execute("shape.pointType", &json!({"index": 0, "type": "corner"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + drag(&mut s, to2, to2 + Vec2::new(10.0, 0.0), Mods::default()); + let (hi5, ho5, _) = handles(&s); + assert!((ho5 - (to2 + Vec2::new(10.0, 0.0))).hypot() < 1e-3, "{ho5:?}"); + assert!((hi5 - hi4).hypot() < 1e-3, "the other handle stays: {hi5:?}"); + // A Point Type choice that fails leaves the remembered type as it was. + assert!(s.execute("shape.pointType", &json!({"index": 99, "type": "smooth"})).is_err()); + assert_eq!(s.tool.vertex_kind, Some((id, 0, 0, "corner".to_string()))); + // The remembered type belongs to its shape: the same point of another ellipse stays smooth. + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 300, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let other = deckcraft_model::ShapeId(s.execute("shape.points", &json!({})).unwrap()["id"].as_u64().unwrap() as u32); + assert_ne!(other, id); + let handles_of = |s: &Session| { + let st = s.doc().unwrap(); + let (hi, ho, v) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(other).unwrap(), 0, 0).unwrap(); + (hi.unwrap(), ho.unwrap(), v) + }; + let (_, ho6, v6) = handles_of(&s); + drag(&mut s, v6, v6, Mods::default()); + let to6 = v6 + Vec2::new(0.0, 40.0); + drag(&mut s, ho6, to6, Mods::default()); + let (hi6, _, _) = handles_of(&s); + assert!((hi6 - (v6 - Vec2::new(0.0, 40.0))).hypot() < 1e-3, "the other handle mirrors the dragged one: {hi6:?}"); + // Another document can have a shape with the same id: a change of active document forgets the + // remembered type, and choosing the active document again keeps it. + s.execute("shape.pointType", &json!({"index": 0, "type": "corner"})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(s.tool.vertex_kind, None); + s.tool.vertex_kind = Some((other, 0, 0, "corner".to_string())); + let new_doc = s.active_index().unwrap(); + s.set_active(new_doc); + assert!(s.tool.vertex_kind.is_some()); + s.set_active(0); + assert_eq!(s.tool.vertex_kind, None); +} + #[test] fn edit_points_straight_and_curved_segments() { let mut s = Session::with_new(); @@ -2429,8 +2577,7 @@ fn edit_points_open_and_close_path() { }; assert!(s.execute("shape.closePath", &json!({"index": 0})).is_err(), "the outline is closed already"); // A point type remembered for handle drags belongs to the old numbering: opening forgets it. - s.execute("shape.pointType", &json!({"index": 2, "type": "straight"})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); + s.tool.vertex_kind = s.tool.edit_points.map(|id| (id, 0, 2, "straight".to_string())); assert!(s.tool.vertex_kind.is_some()); // Open Path at the bottom-right corner: the outline now starts and ends there, as two points. s.execute("shape.openPath", &json!({"index": 2})).unwrap(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index c2ed729b..5161d649 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -272,8 +272,8 @@ pub struct ToolState { /// The segment (path, index of the vertex ending it) last right-clicked in Edit Points, for /// the segment menu. pub edit_segment: Option<(usize, usize)>, - /// The point type last chosen for a vertex (path, index, type): handle drags follow it. - pub vertex_kind: Option<(usize, usize, String)>, + /// The point type last chosen for a vertex (shape, path, index, type): handle drags follow it. + pub vertex_kind: Option<(ShapeId, usize, usize, String)>, /// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them. pub guides_visible: bool, /// The guide being dragged and where it is now (slide points); `None` once dropped. @@ -525,10 +525,11 @@ impl Session { } pub(crate) fn begin(&mut self, label: &str) { + let vertex_kind = self.tool.vertex_kind.clone(); if let Some(st) = self.active_mut() && st.interaction.is_none() { - st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone() }); + st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind }); } } @@ -537,7 +538,7 @@ impl Session { && let Some(i) = st.interaction.take() && !Arc::ptr_eq(&i.doc, &st.doc) { - crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection }); + crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection, vertex_kind: i.vertex_kind }); } } @@ -909,7 +910,18 @@ impl Session { Drag::Handle { id, path, index, out } => { self.tool.drag = Some(Drag::Handle { id, path, index, out }); let side = if out { "out" } else { "in" }; - if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p) { + // Shift, Ctrl or Alt held while dragging a handle makes the point smooth, straight or + // corner, as in PowerPoint; Cmd and Option count as Ctrl and Alt. + let held = if mods.shift { + Some("smooth") + } else if mods.cmd { + Some("straight") + } else if mods.alt { + Some("corner") + } else { + None + }; + if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p, held) { log::debug!("edit handle: {e}"); } return Ok(Value::Null); @@ -1049,9 +1061,19 @@ impl Session { Ok(()) } - fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point) -> Result { - let kind = self.tool.vertex_kind.clone().filter(|(pp, i, _)| (*pp, *i) == (path, index)).map(|k| k.2); - cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind})) + /// Moves a control handle during a drag. `held` is the point type set by the modifier held + /// during the drag; the point keeps that type for later drags, as after `shape.pointType`. + fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point, held: Option<&str>) -> Result { + let kind = match held { + Some(k) => Some(k.to_string()), + None => self.tool.vertex_kind.clone().filter(|(s, pp, i, _)| (*s, *pp, *i) == (id, path, index)).map(|k| k.3), + }; + let r = + cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind}))?; + if let Some(k) = held { + self.tool.vertex_kind = Some((id, path, index, k.to_string())); + } + Ok(r) } fn up(&mut self, p: Point, mods: Mods) -> Result { @@ -1727,8 +1749,9 @@ impl Session { } Ok(()) })?; + let vertex_kind = self.tool.vertex_kind.clone(); if let Some(st) = self.active_mut() { - crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before }); + crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before, vertex_kind }); } Ok(Value::Null) } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 82630b57..fe00bde8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (scroll bars on the zoomed slide pane, #1157; 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) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; 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) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs, @@ -88,7 +88,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Nudge (arrows), fine nudge (Option+arrows) | yes | yes | | Shift-constrained move, Option-drag to duplicate | yes | yes | | Connector glue and rerouting | yes | yes | -| Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices and their Bézier handles, Ctrl/Cmd-click to add or delete, right-click a point for Smooth/Straight/Corner Point, Delete Point and Open/Close Path, or a segment for Straight/Curved Segment; no Delete Segment or Add Point in the menu yet | +| Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices and their Bézier handles (hold Shift, Ctrl/Cmd or Alt/Option while dragging a handle for a smooth, straight or corner point), Ctrl/Cmd-click to add or delete, right-click a point for Smooth/Straight/Corner Point, Delete Point and Open/Close Path, or a segment for Straight/Curved Segment; no Delete Segment or Add Point in the menu yet | | WordArt adjust handles | yellow handles on transformed text | yes, one per adjust when the text has a warp; dragging sets that guide value | | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes | @@ -170,6 +170,7 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---| | 2026-10-24 | trivial | Table cells: dragging across cells selects a range of cells | +| 2026-10-24 | trivial | Edit Points: Shift, Ctrl/Cmd or Alt/Option held while dragging a handle makes the point smooth, straight or corner | | 2026-10-23 | trivial | Scroll bars on the zoomed slide pane (#1157) | | 2026-10-24 | trivial | Columns dialog: the number of text columns and the spacing between them. Dialog count 41 → 42 | | 2026-10-23 | minor | Shape Fill and Shape Outline (Home and Shape Format): the button applies the last colour picked from its menu, Accent 1 and Text 1 until one is picked. Line weight and dashes are not changed by the button; Font Color and Highlight stay as they were (#1080) |