From 13e06cace2490d980e4d5a3668363ba7f3590283 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 19:53:39 +0200 Subject: [PATCH] =?UTF-8?q?Shapes:=20Edit=20Points=20=E2=80=94=20Straight/?= =?UTF-8?q?Curved=20Segment=20and=20Open/Close=20Path=20from=20the=20right?= =?UTF-8?q?-click=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 1 + crates/engine/src/cmd/points.rs | 192 ++++++++++++++++++++++++++++++-- crates/engine/src/keys.rs | 1 + crates/engine/src/tests.rs | 100 +++++++++++++++++ crates/engine/src/tools.rs | 12 ++ crates/ui-egui/src/canvas.rs | 63 ++++++++++- docs/gaps.md | 8 +- docs/ui-parity.md | 3 +- 8 files changed, 363 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8ea976e6..5e4b6335 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-15 | Edit Points: right-click a segment for Straight/Curved Segment, a point for Open/Close Path (`shape.segmentType`, `shape.openPath`, `shape.closePath`); Delete Segment and Add Point in the menu still to come | | 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | | 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) | | 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. | diff --git a/crates/engine/src/cmd/points.rs b/crates/engine/src/cmd/points.rs index e8385d39..37cb5dea 100644 --- a/crates/engine/src/cmd/points.rs +++ b/crates/engine/src/cmd/points.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { has_selection, edit_points ), - cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]]} vertices of a freeform", has_selection, points), + cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]], segments: [[straight|curved|null]]} vertices of a freeform and the segment ending at each", has_selection, points), cmd!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point), cmd!( "shape.addPoint", @@ -54,6 +54,33 @@ pub fn specs() -> Vec { move_handle ), cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point), + cmd!( + "shape.segmentType", + "Segment Type", + [], + None, + "{path?: 0, segment: index of the vertex ending it, type: straight|curved, id?} — straight drops the segment's handles; curved gives it handles at its thirds", + has_selection, + segment_type + ), + cmd!( + "shape.openPath", + "Open Path", + [], + None, + "{path?: 0, index, id?} — break a closed outline at a point, which becomes its two ends", + has_selection, + open_path + ), + cmd!( + "shape.closePath", + "Close Path", + [], + None, + "{path?: 0, index: any point of the open outline, id?} — join its ends with a straight segment (one point when they meet)", + has_selection, + close_path + ), ] } @@ -281,6 +308,76 @@ fn remove_vertex(segs: &mut Vec, si: usize) -> bool { true } +/// The sub-path holding segment `si`: from its move up to the next move (or the end). +fn subpath(segs: &[Seg], si: usize) -> (usize, usize) { + let from = segs.get(..=si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))).unwrap_or(0); + let to = (si + 1..segs.len()).find(|j| matches!(segs.get(*j), Some(Seg::M(_)))).unwrap_or(segs.len()); + (from, to) +} + +/// The segment that ends at the vertex ending segment `si`: the closing edge for a closed +/// sub-path's start, none for an open one's. +fn ending_segment(segs: &[Seg], si: usize) -> Option { + if matches!(segs.get(si), Some(Seg::M(_))) { + (si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j)) + } else { + Some(si) + } +} + +/// Whether segment `j` is a straight line or a curve. +fn segment_kind(segs: &[Seg], j: usize) -> Option<&'static str> { + match segs.get(j)? { + Seg::L(_) => Some("straight"), + Seg::C(..) | Seg::Q(..) => Some("curved"), + Seg::M(_) | Seg::Z => None, + } +} + +/// Makes segment `j` a straight line (dropping its handles) or a curve with handles at its +/// thirds, so it keeps its shape until a handle is dragged. +fn set_segment(segs: &mut [Seg], j: usize, curved: bool) { + if curved { + to_cubic(segs, j); + } else if let Some(s) = segs.get_mut(j) + && let Seg::C(_, _, p) | Seg::Q(_, p) = *s + { + *s = Seg::L(p); + } +} + +/// Opens the closed sub-path through the vertex ending segment `si` there: it now starts and ends +/// on that vertex, as two points. Returns false for an open sub-path. +fn open_at(segs: &mut Vec, si: usize) -> bool { + let (from, to) = subpath(segs, si); + let Some(z) = to.checked_sub(1).filter(|z| *z > si && matches!(segs.get(*z), Some(Seg::Z))) else { return false }; + let Some(v) = segs.get(si).and_then(Seg::end) else { return false }; + let (Some(after), Some(before)) = (segs.get(si + 1..z), segs.get(from + 1..=si)) else { return false }; + let mut out = vec![Seg::M(v)]; + out.extend_from_slice(after); + out.extend_from_slice(before); + segs.splice(from..to, out); + true +} + +/// Closes the open sub-path through the vertex ending segment `si`: a straight segment joins its +/// ends, or they become one point when they meet. Returns false for a closed sub-path. +fn close_at(segs: &mut Vec, si: usize) -> bool { + let (from, to) = subpath(segs, si); + let last = to.saturating_sub(1); + if last <= from || matches!(segs.get(last), Some(Seg::Z)) { + return false; + } + let (Some(Seg::M(start)), Some(end)) = (segs.get(from).copied(), segs.get(last).and_then(Seg::end)) else { return false }; + let mut add = Vec::new(); + if (start - end).hypot() >= 1e-6 { + add.push(Seg::L(start)); + } + add.push(Seg::Z); + segs.splice(to..to, add); + true +} + /// Custom paths of a preset shape, in shape-local points. fn to_custom(sh: &Shape, w: f64, h: f64) -> Vec { let geo = deckcraft_render::shape_geometry(sh, w, h); @@ -402,6 +499,7 @@ pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> { fn edit_points(s: &mut Session, p: &Value) -> Result { let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.edit_points.is_none()); s.tool.edit_vertex = None; + s.tool.edit_segment = None; if !on { s.tool.edit_points = None; return Ok(json!({"editing": Value::Null})); @@ -436,17 +534,16 @@ fn points(s: &mut Session, p: &Value) -> Result { let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?; let paths: Vec> = slide_vertices(&st.doc, &st.selection, sh).into_iter().map(|v| v.into_iter().map(|q| [q.x, q.y]).collect()).collect(); - let types: Vec> = match &sh.geom { - Geom::Custom { paths } => paths - .iter() - .map(|cp| { - let segs = parse(&cp.d); - vertices(&segs).iter().map(|si| point_kind(&segs, *si)).collect() - }) - .collect(), - Geom::Preset { .. } => vec![], - }; - Ok(json!({"id": id.0, "paths": paths, "types": types})) + let (mut types, mut kinds): (Vec>, Vec>>) = (vec![], vec![]); + if let Geom::Custom { paths } = &sh.geom { + for cp in paths { + let segs = parse(&cp.d); + let vs = vertices(&segs); + types.push(vs.iter().map(|si| point_kind(&segs, *si)).collect()); + kinds.push(vs.iter().map(|si| ending_segment(&segs, *si).and_then(|j| segment_kind(&segs, j))).collect()); + } + } + Ok(json!({"id": id.0, "paths": paths, "types": types, "segments": kinds})) } /// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box. @@ -784,3 +881,74 @@ pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> { pub(crate) fn move_handle_raw(s: &mut Session, p: &Value) -> Result { move_handle(s, p) } + +fn segment_type(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.segmentType")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let seg = index_param(p, "segment", "shape.segmentType")?; + let curved = match str_param(p, "type") { + Some("straight") => false, + Some("curved") => true, + _ => return Err(bad("shape.segmentType", "`type` is straight or curved")), + }; + // Already that type: nothing to change, so no undo step. + let want = if curved { "curved" } else { "straight" }; + if s.doc()?.shape(id).is_some_and(|sh| segment_kind_of(sh, path, seg) == Some(want)) { + return ok(); + } + edit_path(s, id, path, "shape.segmentType", move |segs, vs, _| { + let si = *vs.get(seg).ok_or_else(|| bad("shape.segmentType", "no such segment"))?; + let j = ending_segment(segs, si).ok_or_else(|| bad("shape.segmentType", "the first point of an open outline ends no segment"))?; + set_segment(segs, j, curved); + Ok(true) + }) +} + +fn open_path(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.openPath")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.openPath")?; + let r = edit_path(s, id, path, "shape.openPath", move |segs, vs, _| { + let si = *vs.get(index).ok_or_else(|| bad("shape.openPath", "no such point"))?; + if open_at(segs, si) { Ok(true) } else { Err(bad("shape.openPath", "the outline is open already")) } + })?; + forget_kinds(s, path); + Ok(r) +} + +fn close_path(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.closePath")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.closePath")?; + let r = edit_path(s, id, path, "shape.closePath", move |segs, vs, _| { + let si = *vs.get(index).ok_or_else(|| bad("shape.closePath", "no such point"))?; + if close_at(segs, si) { Ok(true) } else { Err(bad("shape.closePath", "the outline is closed already")) } + })?; + forget_kinds(s, path); + Ok(r) +} + +/// 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) { + s.tool.vertex_kind = None; + } +} + +/// Whether the segment ending at vertex `index` of path `path` of `sh` is straight or curved. +pub fn segment_kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> { + let Geom::Custom { paths } = &sh.geom else { return None }; + let segs = parse(&paths.get(path)?.d); + let si = *vertices(&segs).get(index)?; + segment_kind(&segs, ending_segment(&segs, si)?) +} + +/// Whether the outline through vertex `index` of path `path` of `sh` is closed. +pub fn closed_at(sh: &Shape, path: usize, index: usize) -> Option { + let Geom::Custom { paths } = &sh.geom else { return None }; + let segs = parse(&paths.get(path)?.d); + let si = *vertices(&segs).get(index)?; + let (_, to) = subpath(&segs, si); + Some(matches!(segs.get(to.checked_sub(1)?), Some(Seg::Z))) +} diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 38b5aa71..ebc2eae2 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -60,6 +60,7 @@ impl Session { if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") { self.tool.edit_points = None; self.tool.edit_vertex = None; + self.tool.edit_segment = None; return Ok(json!({"handled": true, "command": "shape.editPoints"})); } if editing && let Some((id, params)) = self.text_key(k, mods) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cc872e66..6c158628 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2048,6 +2048,106 @@ fn edit_points_types_and_handles() { assert!((ho3.unwrap() - ho).hypot() < 1e-3); } +#[test] +fn edit_points_straight_and_curved_segments() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let q = |s: &mut Session| s.execute("shape.points", &json!({})).unwrap(); + let before = q(&mut s)["paths"].clone(); + // The closing edge ends on the first point, so it is segment 0; an outline starts straight. + assert_eq!(q(&mut s)["segments"][0], json!(["straight", "straight", "straight", "straight"])); + // Curved Segment: the top edge gets handles at its thirds, so it looks the same until dragged. + s.execute("shape.segmentType", &json!({"segment": 1, "type": "curved"})).unwrap(); + assert_eq!(q(&mut s)["segments"][0], json!(["straight", "curved", "straight", "straight"])); + assert_eq!(q(&mut s)["paths"], before); + let id = deckcraft_model::ShapeId(q(&mut s)["id"].as_u64().unwrap() as u32); + let handles = |s: &Session, i| { + let st = s.doc().unwrap(); + crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, i).unwrap() + }; + let (out0, in1) = (handles(&s, 0).1.unwrap(), handles(&s, 1).0.unwrap()); + assert!((out0 - deckcraft_geom::Point::new(100.0 + 200.0 / 3.0, 100.0)).hypot() < 1e-3, "{out0:?}"); + assert!((in1 - deckcraft_geom::Point::new(100.0 + 400.0 / 3.0, 100.0)).hypot() < 1e-3, "{in1:?}"); + // Bend it, then Straight Segment drops its handles; the points stay where they are. + s.execute("shape.moveHandle", &json!({"index": 1, "side": "in", "x": in1.x, "y": 60})).unwrap(); + let bent = q(&mut s)["paths"].clone(); + s.execute("shape.segmentType", &json!({"segment": 1, "type": "straight"})).unwrap(); + assert_eq!(q(&mut s)["segments"][0][1], "straight"); + assert_eq!((handles(&s, 0).1, handles(&s, 1).0), (None, None)); + assert_eq!(q(&mut s)["paths"], before); + // Each change is one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(q(&mut s)["segments"][0][1], "curved"); + assert_eq!(q(&mut s)["paths"], bent); + // The closing edge is segment 0. + s.execute("shape.segmentType", &json!({"segment": 0, "type": "curved"})).unwrap(); + assert_eq!(q(&mut s)["segments"][0][0], "curved"); + // Asking for the type a segment already has is not an undo step. + let depth = s.doc().unwrap().history.undo.len(); + s.execute("shape.segmentType", &json!({"segment": 2, "type": "straight"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), depth); + assert!(s.execute("shape.segmentType", &json!({"segment": 9, "type": "curved"})).is_err()); + assert!(s.execute("shape.segmentType", &json!({"segment": 1, "type": "wavy"})).is_err()); +} + +#[test] +fn edit_points_open_and_close_path() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let pts = + |s: &mut Session| -> Vec<[f64; 2]> { serde_json::from_value(s.execute("shape.points", &json!({})).unwrap()["paths"][0].clone()).unwrap() }; + let d = |s: &Session| match &s.doc().unwrap().selected_shapes()[0].geom { + deckcraft_model::Geom::Custom { paths } => paths[0].d.clone(), + g => panic!("{g:?}"), + }; + 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(); + 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(); + assert!(!d(&s).contains('Z'), "{}", d(&s)); + assert_eq!(s.tool.vertex_kind, None); + let v = pts(&mut s); + assert_eq!(v, vec![[300.0, 200.0], [100.0, 200.0], [100.0, 100.0], [300.0, 100.0], [300.0, 200.0]]); + assert!(s.execute("shape.openPath", &json!({"index": 1})).is_err(), "the outline is open already"); + // The two ends move apart; Close Path joins them with a straight segment. + s.execute("shape.movePoint", &json!({"index": 4, "x": 320, "y": 180})).unwrap(); + s.execute("shape.closePath", &json!({"index": 1})).unwrap(); + assert!(d(&s).ends_with('Z'), "{}", d(&s)); + assert_eq!(pts(&mut s).len(), 5); + assert_eq!(s.execute("shape.points", &json!({})).unwrap()["segments"][0][0], "straight"); + // Ends that meet become one point again. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("shape.closePath", &json!({"index": 4})).unwrap(); + assert_eq!(pts(&mut s), vec![[300.0, 200.0], [100.0, 200.0], [100.0, 100.0], [300.0, 100.0]]); + // Opening at the first point keeps the order. + s.execute("shape.openPath", &json!({"index": 0})).unwrap(); + assert_eq!(pts(&mut s), vec![[300.0, 200.0], [100.0, 200.0], [100.0, 100.0], [300.0, 100.0], [300.0, 200.0]]); + assert!(s.execute("shape.openPath", &json!({"index": 9})).is_err()); +} + +#[test] +fn edit_points_right_click_picks_the_point_or_the_segment() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + // The middle of the right edge: the segment ending at point 2. + right_click(&mut s, 300.0, 150.0); + assert_eq!((s.tool.edit_segment, s.tool.edit_vertex), (Some((0, 2)), None)); + right_click(&mut s, 300.0, 100.0); + assert_eq!((s.tool.edit_segment, s.tool.edit_vertex), (None, Some((0, 1)))); + right_click(&mut s, 200.0, 150.0); + assert_eq!((s.tool.edit_segment, s.tool.edit_vertex), (None, None)); +} + /// #392: a `.deckcraft` whose embedded picture is damaged opens as an untitled "(Repaired)" /// copy with a message, so Save can't overwrite the original with the empty media. #[test] diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f1acd9e9..4d8a8ddc 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -261,6 +261,9 @@ pub struct ToolState { pub edit_points: Option, /// The vertex (path, index) whose control handles show in Edit Points. pub edit_vertex: Option<(usize, usize)>, + /// 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)>, /// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them. @@ -1199,6 +1202,11 @@ impl Session { ) => Some(*id), None => None, }; + // Edit Points: the point or else the segment under the pointer picks the menu. + let picked = self.tool.editing_points(st).and_then(|eid| st.shape(eid)).map(|sh| match &hit { + Some(Hit::Vertex { path, index, .. }) => (Some((*path, *index)), None), + _ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6).map(|(path, seg, _)| (path, seg))), + }); if !id.is_some_and(|id| st.selection.shapes.contains(&id)) { self.select(|_, sel| { sel.text = None; @@ -1206,6 +1214,10 @@ impl Session { sel.shapes = id.into_iter().collect(); })?; } + if let Some((vertex, segment)) = picked { + self.tool.edit_vertex = vertex; + self.tool.edit_segment = segment; + } let st = self.doc()?; Ok(json!({ "hit": serde_json::to_value(&hit).unwrap_or_default(), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba66aad8..da69e3ae 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1082,7 +1082,16 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { resp.context_menu(|ui| { let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned())); if let Some(sh) = editing { - if let Some((path, index)) = app.session.tool.edit_vertex { + if let Some((path, index)) = app.session.tool.edit_segment { + let cur = deckcraft_engine::cmd::points::segment_kind_of(&sh, path, index); + for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k})); + ui.close(); + } + } + ui.separator(); + } else if let Some((path, index)) = app.session.tool.edit_vertex { let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index); for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { if ui.radio(cur == Some(k), l).clicked() { @@ -1095,6 +1104,16 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { app.session.tool.edit_vertex = None; ui.close(); } + let (label, id) = match deckcraft_engine::cmd::points::closed_at(&sh, path, index) { + Some(false) => ("Close Path", "shape.closePath"), + _ => ("Open Path", "shape.openPath"), + }; + if ui.button(label).clicked() { + let _ = app.run(id, json!({"path": path, "index": index})); + // The points are numbered from the new start now. + app.session.tool.edit_vertex = None; + ui.close(); + } ui.separator(); } if ui.button("Exit Edit Points").clicked() { @@ -1354,6 +1373,7 @@ mod tests { use deckcraft_engine::Session; use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; use super::*; use crate::{Services, SlideApp}; @@ -1583,6 +1603,47 @@ mod tests { assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols); } + fn right_click(h: &mut Harness<'_, SlideApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + } + + /// In Edit Points, right-clicking a segment offers Straight/Curved Segment and right-clicking + /// a point offers Open Path, then Close Path once the outline is open. + #[test] + fn edit_points_menu_sets_segment_types_and_opens_the_path() { + let mut h = harness(); + h.state_mut().run("slide.layout", json!({"layout": "blank"})).unwrap(); + h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [200, 150, 300, 200]})).unwrap(); + h.state_mut().run("shape.editPoints", json!({"on": true})).unwrap(); + h.run_steps(3); + let q = |h: &mut Harness<'_, SlideApp>| h.state_mut().session.execute("shape.points", &json!({})).unwrap(); + // The middle of the right edge: the segment ending at point 2. + let p = screen(h.state(), 500.0, 250.0); + right_click(&mut h, p); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_SEGMENTS"), h.render()) { + let _ = img.save(path); + } + h.get_by_label("Curved Segment").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["segments"][0], json!(["straight", "straight", "curved", "straight"])); + // The bottom-right corner: Open Path breaks the outline there. + let p = screen(h.state(), 500.0, 350.0); + right_click(&mut h, p); + h.get_by_label("Open Path").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(5)); + right_click(&mut h, p); + h.get_by_label("Close Path").click(); + h.run_steps(3); + assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(4)); + } + #[test] fn grid_step_follows_spacing_and_rejects_unusable_values() { let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0); diff --git a/docs/gaps.md b/docs/gaps.md index 4e564951..62843644 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -232,7 +232,8 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8 ### Edit points, text warp, 3-D - **Evidence:** checklist rows "Edit points" (P1 partial: Edit Points converts a shape to a freeform, vertices drag, Ctrl/Cmd-click adds or deletes, smooth/straight/corner points and Bézier - handles; no Straight/Curved Segment or Open/Close Path), "Text warp transforms" (partial: all 40 + handles, Straight/Curved Segment on a segment and Open/Close Path on a point; the menu has no + Delete Segment or Add Point), "Text warp transforms" (partial: all 40 `prstTxWarp` presets render, their adjusts round-trip, Shape Format ▸ Text Effects ▸ Transform applies one with `format.textWarp`, and a selected warp shows a yellow handle per adjust that dragging sets; arc/circle placement can differ from PowerPoint by 1–3 % of the path length), @@ -242,8 +243,8 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8 shade bevels and sides approximately (fitted to PowerPoint for three-point/matte only); contours draw as a band round the face outline (not round the extruded silhouette); Format Shape ▸ Effects has 3-D Format and 3-D Rotation). -- **Estimate:** 1 h (edit points: segment types, open/close path), 1–2 h (fitting the other rigs - and materials to PowerPoint renders). +- **Estimate:** 1 h (edit points: Delete Segment and Add Point in the menu), 1–2 h (fitting the + other rigs and materials to PowerPoint renders). ### Proofing: spelling, thesaurus, AutoCorrect - **Evidence:** `review.spelling` checks with Hunspell-format dictionaries the system already has @@ -355,6 +356,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Edit Points: Straight/Curved Segment on a right-clicked segment and Open/Close Path on a point (`shape.segmentType`, `shape.openPath`, `shape.closePath`); left: Delete Segment and Add Point in the menu | | 2026-10-14 | trivial | Fixed #718: remote MCP no longer accepts unrelated or buffered stale success; loopback regressions cover invalid envelopes, incomplete and oversized frames | | 2026-10-14 | minor | Community PR wave folded: one entry each for the soak tests (engine and UI), the PPTX corpus (seven owned corpus files), edit points / text warp / 3-D, proofing (Hunspell spelling, thesaurus, AutoCorrect), the smaller missing rows, dialogs (41 vs ~60; Print and Insert Object left) and pen/trackpad input; ranked rows 3, 4 and 10 de-duplicated (comment threads #70 and the pane timeline are done); the fixed command-palette entry (#79) removed | | 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc97dae4..a3ca74c1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -86,7 +86,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 for Smooth/Straight/Corner Point and Delete Point; no Straight/Curved Segment or Open/Close Path yet | +| 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 | | 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 | @@ -165,6 +165,7 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Edit Points: the right-click menu has Straight/Curved Segment on a segment and Open/Close Path on a point | | 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) | | 2026-10-13 | trivial | Eyedropper in the colour menus (#367) |