Merge PR #833: Shapes: Edit Points — Straight/Curved Segment and Open/Close Path from the right-click menu

Conflicts: ROADMAP.md docs/gaps.md docs/ui-parity.md (union of both sides' rows)
This commit is contained in:
aiongg
2026-10-24 06:16:21 +00:00
8 changed files with 363 additions and 17 deletions
+1
View File
@@ -173,6 +173,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
| 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-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. |
+180 -12
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<Seg>, 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<usize> {
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<Seg>, 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<Seg>, 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<CustomPath> {
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<Value> {
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<Value> {
let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?;
let paths: Vec<Vec<[f64; 2]>> =
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<Vec<&str>> = 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<&str>>, Vec<Vec<Option<&str>>>) = (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<Value> {
move_handle(s, p)
}
fn segment_type(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<bool> {
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)))
}
+1
View File
@@ -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) {
+100
View File
@@ -2171,6 +2171,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]
+12
View File
@@ -266,6 +266,9 @@ pub struct ToolState {
pub edit_points: Option<ShapeId>,
/// 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.
@@ -1243,6 +1246,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;
@@ -1250,6 +1258,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(),
+62 -1
View File
@@ -1219,7 +1219,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() {
@@ -1232,6 +1241,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() {
@@ -1544,6 +1563,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};
@@ -1983,6 +2003,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);
+5 -3
View File
@@ -235,7 +235,8 @@ and starts at that effect. The pane has an advanced timeline with draggable bars
### 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),
@@ -245,8 +246,8 @@ and starts at that effect. The pane has an advanced timeline with draggable bars
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
@@ -364,6 +365,7 @@ Detail in [localization-parity.md](localization-parity.md).
| 2026-10-15 | trivial | Native saves retain chart package parts and relationships, including charts in groups; edited chart data still does not export a stale source workbook. Chart editing gaps and scores remain unchanged |
| 2026-10-15 | trivial | PPTX trigger sequences keep later On Click steps instead of converting them to After Previous; mixed starts, independent buttons and paragraph builds have synthetic round-trip regressions |
| 2026-10-16 | trivial | AutoFormat superscripts ordinals (1st, 22nd) as you type, with an AutoCorrect option to turn it off; automatic bulleted lists remain |
| 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 |
+2 -1
View File
@@ -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 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 |
@@ -172,6 +172,7 @@ consistency is open (#73, #56).
| 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) |
| 2026-10-22 | trivial | Group fill and outline commands reach members, including nested groups; selection and single-step undo are preserved (#1079). The existing Arrange / Group checklist score stays D |
| 2026-10-16 | minor | Export › Change File Type › PNG and the Export… dialog offer a transparent background (#784) |
| 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) |