Edit Points: right-click a segment for Add Point, Delete Segment and Open/Close Path

The segment menu in Edit Points offered only Straight Segment and Curved
Segment. It now also has Add Point, which adds a point where the right
click was, Delete Segment, which removes the segment from a closed outline
so that the outline opens there (grayed out on an open outline), and Open
Path, which opens the outline at the segment's start point (Close Path on
an open outline).
`shape.deleteSegment` does the deletion for the CLI and MCP.

The place on a segment that a click picks was found only in steps of 1/32
of the segment, so Add Point and Ctrl-click could add the point as far
as 1/64 of a straight segment's length from the pointer. It is now narrowed
down after that first pass.

Adding or deleting a point now forgets a point type remembered for handle
drags, as opening and closing the outline already did. `shape.addPoint` on
the first point of an open outline no longer splits the closing edge of
another outline later in the same path.
This commit is contained in:
dotCooCoo
2026-10-24 13:07:42 -07:00
parent 38d2ade2b7
commit 92ce587cd6
7 changed files with 265 additions and 21 deletions
+1
View File
@@ -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 |
+82 -13
View File
@@ -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",
@@ -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 {
@@ -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).
@@ -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))
@@ -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;
+109
View File
@@ -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();
+5 -2
View File
@@ -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.
@@ -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| {
@@ -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!({
+64 -1
View File
@@ -1227,6 +1227,29 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
ui.close();
}
}
// Each of these renumbers the points, so the picked segment is dropped.
if ui.button("Add Point").clicked() {
let t = app.session.tool.edit_segment_t.unwrap_or(0.5);
let _ = app.run("shape.addPoint", json!({"path": path, "segment": index, "t": t}));
app.session.tool.edit_segment = None;
ui.close();
}
let closed = deckcraft_engine::cmd::points::closed_at(&sh, path, index);
if ui.add_enabled(closed == Some(true), egui::Button::new("Delete Segment")).clicked() {
let _ = app.run("shape.deleteSegment", json!({"path": path, "segment": index}));
app.session.tool.edit_segment = None;
ui.close();
}
// Open Path opens the outline at the segment's start point.
let (label, id, at) = match closed {
Some(false) => ("Close Path", "shape.closePath", index),
_ => ("Open Path", "shape.openPath", deckcraft_engine::cmd::points::segment_start_of(&sh, path, index).unwrap_or(index)),
};
if ui.button(label).clicked() {
let _ = app.run(id, json!({"path": path, "index": at}));
app.session.tool.edit_segment = None;
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);
@@ -1563,7 +1586,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 egui_kittest::kittest::{NodeT, Queryable};
use super::*;
use crate::{Services, SlideApp};
@@ -2041,6 +2064,46 @@ mod tests {
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(4));
}
/// In Edit Points, right-clicking a segment offers Add Point at the pointer, Delete Segment on
/// a closed outline, and Open Path at the segment's start point.
#[test]
fn edit_points_segment_menu_adds_points_and_deletes_segments() {
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();
// A quarter of the way along the top edge: the new point goes there.
let p = screen(h.state(), 275.0, 150.0);
right_click(&mut h, p);
h.get_by_label("Add Point").click();
h.run_steps(3);
assert_eq!(q(&mut h)["paths"][0], json!([[200.0, 150.0], [275.0, 150.0], [500.0, 150.0], [500.0, 350.0], [200.0, 350.0]]));
// The right edge: Open Path opens the outline at its top end.
let p = screen(h.state(), 500.0, 250.0);
right_click(&mut h, p);
h.get_by_label("Open Path").click();
h.run_steps(3);
assert_eq!(q(&mut h)["paths"][0][0], json!([500.0, 150.0]));
assert_eq!(q(&mut h)["paths"][0].as_array().map(Vec::len), Some(6));
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(3);
// Delete Segment removes the right edge: the outline now runs from its bottom end to its top end.
right_click(&mut h, p);
h.get_by_label("Delete Segment").click();
h.run_steps(3);
assert_eq!(q(&mut h)["paths"][0], json!([[500.0, 350.0], [200.0, 350.0], [200.0, 150.0], [275.0, 150.0], [500.0, 150.0]]));
assert_eq!(q(&mut h)["segments"][0][0], json!(null));
// On the open outline Delete Segment is grayed out; Close Path joins the ends again.
let p = screen(h.state(), 350.0, 350.0);
right_click(&mut h, p);
assert!(h.get_by_label("Delete Segment").accesskit_node().is_disabled());
h.get_by_label("Close Path").click();
h.run_steps(3);
assert_eq!(q(&mut h)["segments"][0][0], json!("straight"));
}
#[test]
fn grid_step_follows_spacing_and_rejects_unusable_values() {
let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0);
+3 -4
View File
@@ -235,8 +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, 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
handles, Add Point, Delete Segment (closed outlines), Straight/Curved Segment and Open/Close Path
on a segment, and Open/Close Path on a 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),
@@ -246,8 +246,7 @@ 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: Delete Segment and Add Point in the menu), 1–2 h (fitting the
other rigs and materials to PowerPoint renders).
- **Estimate:** 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
+1 -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 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, 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, Add Point, Delete Segment (grayed out on an open outline) and Open/Close Path |
| 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 |