Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) |
| 2026-10-15 | Canvas: the Format and Animation Painter override the cursor to show the tool is armed (#830) |
| 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-24 | Edit Points: right-click a segment for Add Point (at the pointer), Delete Segment (a closed outline opens there; grayed out on an open one) and Open/Close Path, as in PowerPoint (`shape.deleteSegment`) |
| 2026-10-15 | Design › Browse for Themes… with a `.pptx` or `.potx` brings its slide master and layouts, not just the theme: the master the current slide uses is replaced (a preserved one stays beside it), the template stretches to the deck's slide size, and each slide moves to the layout with the same name or kind and keeps its content. One undo step (#807) |
| 2026-10-15 | Clicking the frame of a text box or placeholder selects the box whole, so it can be moved, duplicated or deleted; only a click inside puts the caret in the text (#787) |
| 2026-10-15 | Review › Compare: Reject and Reject All drop revisions without applying them (`review.reject`); Accept All skips what was rejected |
Expand Down
95 changes: 82 additions & 13 deletions crates/engine/src/cmd/points.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ pub fn specs() -> Vec<CommandSpec> {
move_handle
),
cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point),
cmd!(
"shape.deleteSegment",
"Delete Segment",
[],
None,
"{path?: 0, segment: index of the vertex ending it, id?}: removes a segment of a closed outline, which opens there; it starts at the segment's end and ends at its start",
has_selection,
delete_segment
),
cmd!(
"shape.segmentType",
"Segment Type",
Expand Down Expand Up @@ -360,6 +369,22 @@ fn open_at(segs: &mut Vec<Seg>, si: usize) -> bool {
true
}

/// Deletes the segment ending at the vertex ending segment `si` from its closed sub-path, which
/// opens there: it now starts at the segment's end and ends at its start. Returns false for an
/// open sub-path.
fn delete_segment_at(segs: &mut Vec<Seg>, si: usize) -> bool {
let (from, _) = subpath(segs, si);
if !open_at(segs, si) {
return false;
}
// The deleted segment now ends the sub-path.
let (_, to) = subpath(segs, from);
if to > from + 1 {
segs.remove(to - 1);
}
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<Seg>, si: usize) -> bool {
Expand Down Expand Up @@ -599,26 +624,26 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let seg = index_param(p, "segment", "shape.addPoint")?;
let t = f64_param(p, "t").filter(|t| t.is_finite()).unwrap_or(0.5).clamp(0.01, 0.99);
edit_path(s, id, path, "shape.addPoint", move |segs, vs, _| {
let r = edit_path(s, id, path, "shape.addPoint", move |segs, vs, _| {
let si = *vs.get(seg).ok_or_else(|| bad("shape.addPoint", "no such segment"))?;
// The closing edge of a closed outline ends at the start: split it, not the start.
let si = if matches!(segs.get(si), Some(Seg::M(_))) {
(si + 1..segs.len()).find(|j| closes_onto_start(segs, *j)).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))?
} else {
si
};
let si = ending_segment(segs, si).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))?;
if split(segs, si, t) { Ok(true) } else { Err(bad("shape.addPoint", "no segment ends at that point")) }
})
})?;
forget_kinds(s, path);
Ok(r)
}

fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.deletePoint")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let index = index_param(p, "index", "shape.deletePoint")?;
edit_path(s, id, path, "shape.deletePoint", move |segs, vs, _| {
let r = edit_path(s, id, path, "shape.deletePoint", move |segs, vs, _| {
let si = *vs.get(index).ok_or_else(|| bad("shape.deletePoint", "no such point"))?;
if remove_vertex(segs, si) { Ok(true) } else { Err(bad("shape.deletePoint", "an outline keeps at least two points")) }
})
})?;
forget_kinds(s, path);
Ok(r)
}

/// The vertex of `sh` near slide point `p`: (path, index).
Expand Down Expand Up @@ -665,15 +690,35 @@ pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s
vs.iter().position(|v| *v == si)
};
let Some(ev) = end_vertex else { continue };
let mut near: Option<(f64, f64)> = None;
for k in 1..32 {
let t = k as f64 / 32.0;
if let Some(q) = at(t) {
let d = (q - local).hypot();
if d <= tol && best.is_none_or(|b| d < b.0) {
best = Some((d, pi, ev, t));
if d <= tol && near.is_none_or(|n| d < n.0) {
near = Some((d, t));
}
}
}
let Some((mut d, mut t)) = near else { continue };
// Narrow `t` down to the nearest place on the segment, where Add Point puts the point.
let mut step = 1.0 / 32.0;
for _ in 0..4 {
step /= 8.0;
let c = t;
for k in -8..=8 {
let u = (c + f64::from(k) * step).clamp(0.0, 1.0);
if let Some(q) = at(u) {
let e = (q - local).hypot();
if e < d {
(d, t) = (e, u);
}
}
}
}
if best.is_none_or(|b| d < b.0) {
best = Some((d, pi, ev, t));
}
}
}
best.map(|(_, pi, ev, t)| (pi, ev, t))
Expand Down Expand Up @@ -928,8 +973,32 @@ fn close_path(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}

/// Opening or closing renumbers the points of `path`: a point type remembered for one of them
/// would follow the wrong point.
fn delete_segment(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "shape.deleteSegment")?;
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
let seg = index_param(p, "segment", "shape.deleteSegment")?;
let r = edit_path(s, id, path, "shape.deleteSegment", move |segs, vs, _| {
let si = *vs.get(seg).ok_or_else(|| bad("shape.deleteSegment", "no such segment"))?;
if ending_segment(segs, si).is_none() {
return Err(bad("shape.deleteSegment", "the first point of an open outline ends no segment"));
}
if delete_segment_at(segs, si) { Ok(true) } else { Err(bad("shape.deleteSegment", "only a segment of a closed outline can be deleted")) }
})?;
forget_kinds(s, path);
Ok(r)
}

/// The point where the segment ending at vertex `index` of path `path` of `sh` starts.
pub fn segment_start_of(sh: &Shape, path: usize, index: usize) -> Option<usize> {
let Geom::Custom { paths } = &sh.geom else { return None };
let segs = parse(&paths.get(path)?.d);
let vs = vertices(&segs);
let j = ending_segment(&segs, *vs.get(index)?)?;
vs.iter().position(|v| Some(*v) == j.checked_sub(1))
}

/// Adding, deleting, 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;
Expand Down
109 changes: 109 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2274,6 +2274,115 @@ fn edit_points_open_and_close_path() {
assert!(s.execute("shape.openPath", &json!({"index": 9})).is_err());
}

#[test]
fn edit_points_delete_segment_opens_the_outline() {
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:?}"),
};
// A point type remembered for handle drags belongs to the old numbering: deleting forgets it.
s.execute("shape.pointType", &json!({"index": 2, "type": "straight"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
// The right edge ends at point 2: the outline now starts at its end and ends at its start.
s.execute("shape.deleteSegment", &json!({"segment": 2})).unwrap();
assert!(!d(&s).contains('Z'), "{}", d(&s));
assert_eq!(s.tool.vertex_kind, None);
assert_eq!(pts(&mut s), vec![[300.0, 200.0], [100.0, 200.0], [100.0, 100.0], [300.0, 100.0]]);
assert_eq!(s.execute("shape.points", &json!({})).unwrap()["segments"][0], json!([null, "straight", "straight", "straight"]));
// A segment of an open outline cannot be deleted, as in PowerPoint, where the item is grayed out.
assert!(s.execute("shape.deleteSegment", &json!({"segment": 1})).is_err());
// One undo step brings the closed outline back.
s.execute("edit.undo", &json!({})).unwrap();
assert!(d(&s).ends_with('Z'), "{}", d(&s));
assert_eq!(pts(&mut s), vec![[100.0, 100.0], [300.0, 100.0], [300.0, 200.0], [100.0, 200.0]]);
// The closing edge ends at point 0.
s.execute("shape.deleteSegment", &json!({"segment": 0})).unwrap();
assert!(!d(&s).contains('Z'), "{}", d(&s));
assert_eq!(pts(&mut s), vec![[100.0, 100.0], [300.0, 100.0], [300.0, 200.0], [100.0, 200.0]]);
assert!(s.execute("shape.deleteSegment", &json!({"segment": 0})).is_err(), "the first point of an open outline ends no segment");
assert!(s.execute("shape.deleteSegment", &json!({"segment": 9})).is_err());
}

/// Adding or deleting a point renumbers the points: a point type remembered for handle drags is
/// forgotten, as it is when the outline opens or closes.
#[test]
fn edit_points_add_and_delete_point_forget_the_point_type() {
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();
s.execute("shape.pointType", &json!({"index": 2, "type": "straight"})).unwrap();
assert!(s.tool.vertex_kind.is_some());
s.execute("shape.addPoint", &json!({"segment": 1})).unwrap();
assert_eq!(s.tool.vertex_kind, None);
s.execute("shape.pointType", &json!({"index": 2, "type": "straight"})).unwrap();
s.execute("shape.deletePoint", &json!({"index": 1})).unwrap();
assert_eq!(s.tool.vertex_kind, None);
}

/// A right click on a segment records where on it the click was, for Add Point.
#[test]
fn edit_points_right_click_records_the_place_on_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();
// A quarter of the way along the top edge, which ends at point 1.
right_click(&mut s, 150.0, 100.0);
assert_eq!(s.tool.edit_segment, Some((0, 1)));
let t = s.tool.edit_segment_t.expect("the place on the segment");
assert!((t - 0.25).abs() < 1e-9, "{t}");
s.execute("shape.addPoint", &json!({"segment": 1, "t": t})).unwrap();
let v = s.execute("shape.points", &json!({})).unwrap()["paths"][0].clone();
assert_eq!(v, json!([[100.0, 100.0], [150.0, 100.0], [300.0, 100.0], [300.0, 200.0], [100.0, 200.0]]));
// Between the places the hit test samples (40% of the way along the edge that now ends at
// point 2) and on a curve, the new point goes where the click was.
let added = |s: &mut Session, x: f64, y: f64| {
right_click(s, x, y);
let (path, seg) = s.tool.edit_segment.expect("a segment is picked");
let t = s.tool.edit_segment_t.expect("the place on the segment");
let before: Vec<[f64; 2]> = serde_json::from_value(s.execute("shape.points", &json!({})).unwrap()["paths"][path].clone()).unwrap();
s.execute("shape.addPoint", &json!({"path": path, "segment": seg, "t": t})).unwrap();
let after: Vec<[f64; 2]> = serde_json::from_value(s.execute("shape.points", &json!({})).unwrap()["paths"][path].clone()).unwrap();
let new = after.iter().find(|q| !before.contains(q)).copied().expect("a new point");
((new[0] - x).hypot(new[1] - y), new)
};
let (miss, new) = added(&mut s, 210.0, 100.0);
assert!(miss < 1e-3, "{new:?}");
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 300, 200, 100]})).unwrap();
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
// 30° up from the right-most point; the outline is a Bézier copy of the ellipse.
let (x, y) = (200.0 + 100.0 * 30f64.to_radians().cos(), 350.0 - 50.0 * 30f64.to_radians().sin());
let (miss, new) = added(&mut s, x, y);
assert!(miss < 0.1, "{new:?}");
}

/// No segment ends at the first point of an open outline, also when a closed outline follows it
/// in the same path: Add Point there returns an error and leaves the path unchanged.
#[test]
fn edit_points_add_point_at_the_start_of_an_open_outline() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
// The donut's two rings are one path.
s.execute("shape.insert", &json!({"preset": "donut", "rect": [100, 100, 200, 200]})).unwrap();
s.execute("shape.editPoints", &json!({"on": true})).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:?}"),
};
s.execute("shape.openPath", &json!({"index": 0})).unwrap();
let before = d(&s);
assert_eq!(before.matches('Z').count(), 1, "{before}");
assert!(s.execute("shape.addPoint", &json!({"segment": 0})).is_err());
assert_eq!(d(&s), before);
}

#[test]
fn edit_points_right_click_picks_the_point_or_the_segment() {
let mut s = Session::with_new();
Expand Down
7 changes: 5 additions & 2 deletions crates/engine/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,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)>,
/// Where on that segment the right click was (0..1), where Add Point puts the new point.
pub edit_segment_t: Option<f64>,
/// 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.
Expand Down Expand Up @@ -1255,7 +1257,7 @@ impl Session {
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),
Some(Hit::Handle { .. }) => (None, None),
_ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6).map(|(path, seg, _)| (path, seg))),
_ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6)),
});
if !id.is_some_and(|id| st.selection.shapes.contains(&id)) {
self.select(|_, sel| {
Expand All @@ -1269,7 +1271,8 @@ impl Session {
if !matches!(hit, Some(Hit::Handle { .. })) {
self.tool.edit_vertex = vertex;
}
self.tool.edit_segment = segment;
self.tool.edit_segment = segment.map(|(path, seg, _)| (path, seg));
self.tool.edit_segment_t = segment.map(|(_, _, t)| t);
}
let st = self.doc()?;
Ok(json!({
Expand Down
Loading
Loading