diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..7e485d1ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9ee963317..3add6af64 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,6 +8,21 @@ use serde_json::{Value, json}; use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok}; use crate::{HistoryEntry, Result, Session}; +/// `anchors` of `selection.set`: the points that exist, once each. +fn anchors_param(doc: &designcraft_doc::Document, p: &Value) -> Vec<(ItemId, usize, usize)> { + let mut out: Vec<(ItemId, usize, usize)> = Vec::new(); + for v in p.get("anchors").and_then(Value::as_array).into_iter().flatten() { + let n = |i: usize| v.get(i).and_then(Value::as_u64); + let (Some(id), Some(si), Some(ai)) = (n(0), n(1), n(2)) else { continue }; + let (Ok(si), Ok(ai)) = (usize::try_from(si), usize::try_from(ai)) else { continue }; + let a = (ItemId(id), si, ai); + if doc.item(a.0).is_some_and(|it| it.path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) && !out.contains(&a) { + out.push(a); + } + } + out +} + fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) } @@ -193,11 +208,17 @@ pub fn specs() -> Vec { s.typing_format = None; Ok(json!({"redone": e.label})) }), - cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| { - let ids = ids_param(p, "ids").unwrap_or_default(); + cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool, anchors?: [[id, subpath, index]] (Direct Selection: the selected anchor points; their paths are selected too)}", has_doc, |s, p| { + let mut ids = ids_param(p, "ids").unwrap_or_default(); let add = bool_or(p, "add", false); let content = bool_or(p, "content", false); let st = s.doc_mut()?; + let anchors = anchors_param(&st.doc, p); + for (id, _, _) in &anchors { + if !ids.contains(id) { + ids.push(*id); + } + } let ids: Vec = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect(); if add { for i in ids { @@ -205,8 +226,13 @@ pub fn specs() -> Vec { st.selection.items.push(i); } } + for a in anchors { + if !st.selection.anchors.contains(&a) { + st.selection.anchors.push(a); + } + } } else { - st.selection = Selection { items: ids, content, ..Default::default() }; + st.selection = Selection { items: ids, content, anchors, ..Default::default() }; } st.selection.text = None; st.selection.cells = None; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..357e95272 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -680,7 +680,15 @@ pub fn specs() -> Vec { path_reverse ), cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close), - cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors), + cmd!( + "path.moveAnchors", + "Move Anchors", + [], + None, + "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index]], dx, dy} (anchors of several paths, as Direct Selection drags them: one step)", + has_doc, + path_move_anchors + ), cmd!( "object.align", "Align", @@ -3338,38 +3346,59 @@ fn path_close(s: &mut Session, p: &Value) -> Result { } fn path_move_anchors(s: &mut Session, p: &Value) -> Result { - let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?; - let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + // The anchors to move, by path: `points` (several paths) or `id` with `anchors`. + let mut targets: Vec<(ItemId, Vec<(usize, usize)>)> = Vec::new(); + if let Some(points) = p.get("points").and_then(Value::as_array) { + for v in points { + let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()); + let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else { + return Err(bad("path.moveAnchors", "each point is [id, subpath, index]")); + }; + match targets.iter_mut().find(|(i, _)| i.0 == id) { + Some((_, which)) => which.push((si, ai)), + None => targets.push((ItemId(id), vec![(si, ai)])), + } + } + } else { + let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?; + let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + targets.push((id, which)); + } let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let handle = str_param(p, "handle").map(str::to_string); s.edit(|d, _| { - path_editable(d, id, "path.moveAnchors")?; - let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); - // Spread-space delta → inner-space delta (linear part only). - let inv = m.inverse(); - let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO); - let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - for (si, ai) in &which { - let Some(a) = it.path.anchor_mut(*si, *ai) else { continue }; - match handle.as_deref() { - Some("in") => { - a.h_in += dv; - if a.kind == designcraft_geom::AnchorKind::Smooth { - a.h_out = a.p - (a.h_in - a.p); - } - } - Some("out") => { - a.h_out += dv; - if a.kind == designcraft_geom::AnchorKind::Smooth { - a.h_in = a.p - (a.h_out - a.p); - } - } - _ => a.translate(dv), - } + for (id, _) in &targets { + path_editable(d, *id, "path.moveAnchors")?; } - if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon { - it.shape = Shape::Path; + for (id, which) in &targets { + let id = *id; + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); + // Spread-space delta → inner-space delta (linear part only). + let inv = m.inverse(); + let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO); + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + for (si, ai) in which { + let Some(a) = it.path.anchor_mut(*si, *ai) else { continue }; + match handle.as_deref() { + Some("in") => { + a.h_in += dv; + if a.kind == designcraft_geom::AnchorKind::Smooth { + a.h_out = a.p - (a.h_in - a.p); + } + } + Some("out") => { + a.h_out += dv; + if a.kind == designcraft_geom::AnchorKind::Smooth { + a.h_in = a.p - (a.h_out - a.p); + } + } + _ => a.translate(dv), + } + } + if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon { + it.shape = Shape::Path; + } } ok() }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aacf469d..9aeae9989 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1650,3 +1650,38 @@ fn a_line_fits_when_its_baseline_does() { assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } + +/// Direct Selection: a marquee over the top edges of two frames selects their top points, and a +/// drag on one of them slants both frames in one undo step (InDesign's way to make a row of +/// parallelograms). +#[test] +fn direct_selection_moves_the_points_of_several_frames_at_once() { + use designcraft_tools::{Mods, PointerEvent, PointerKind}; + let mut s = session(); + let v = ViewInfo::at_zoom(1.0); + let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + s.set_tool("directSelection"); + s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 280.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 320.0, 320.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 320.0, 320.0), v).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.anchors.len(), 4, "{:?}", sel.anchors); + assert!(sel.items.contains(&designcraft_doc::ItemId(a)) && sel.items.contains(&designcraft_doc::ItemId(b))); + let shift = Mods { shift: true, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 136.0, 302.0).with_mods(shift), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 136.0, 302.0).with_mods(shift), v).unwrap(); + let corners = |s: &Session, id: u64| -> Vec<(f64, f64)> { + let st = s.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap(); + it.path.subpaths[0].anchors.iter().map(|an| ((it.xf * an.p).x.round(), (it.xf * an.p).y.round())).collect() + }; + // Both frames: top points 36 pt to the right, bottom points where they were. + assert_eq!(corners(&s, a), vec![(136.0, 300.0), (236.0, 300.0), (200.0, 400.0), (100.0, 400.0)]); + assert_eq!(corners(&s, b), vec![(236.0, 300.0), (336.0, 300.0), (300.0, 400.0), (200.0, 400.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(corners(&s, a), vec![(100.0, 300.0), (200.0, 300.0), (200.0, 400.0), (100.0, 400.0)]); + assert_eq!(corners(&s, b), vec![(200.0, 300.0), (300.0, 300.0), (300.0, 400.0), (200.0, 400.0)]); +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 891f1d4ab..b18126a97 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -90,6 +90,8 @@ enum Drag { /// None when that point could not be read. The live document during a drag is the /// previous preview, so the snap must not read the anchor from it again. at: Option<(SpreadRef, Point)>, + /// The other selected anchor points, which move with it: (item, subpath, anchor). + others: Vec<(u64, usize, usize)>, }, } @@ -179,6 +181,60 @@ fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 { (xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN) } +/// The anchor points inside `r` (canvas) of the unlocked, visible paths, groups' members included. +fn anchors_in(cx: &ToolContext, r: Rect) -> Vec<(ItemId, usize, usize)> { + fn walk(cx: &ToolContext, items: &[std::sync::Arc], r: Rect, out: &mut Vec<(ItemId, usize, usize)>) { + for it in items { + let locked = it.locked || cx.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible); + if locked || it.hidden { + continue; + } + if let Content::Group { items } = &it.content { + walk(cx, items, r, out); + continue; + } + let Some(xf) = cx.item_canvas_xf(it.id) else { continue }; + let m = xf * it.xf; + for (si, ai, a) in it.path.anchors() { + let q = m * a.p; + if q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1 { + out.push((it.id, si, ai)); + } + } + } + } + let mut out = Vec::new(); + for slot in &cx.layout.slots { + if let Some(sp) = cx.doc.spread(slot.spread) { + walk(cx, &sp.items, r, &mut out); + } + } + out +} + +/// `selection.set` params selecting `points`: their paths, and those points. +fn anchor_selection(cx: &ToolContext, points: &[(ItemId, usize, usize)]) -> Value { + let mut ids: Vec = cx.selection.items.iter().map(|i| i.0).collect(); + for (i, _, _) in points { + if !ids.contains(&i.0) { + ids.push(i.0); + } + } + let anchors: Vec = points.iter().map(|(i, s, a)| json!([i.0, s, a])).collect(); + json!({"ids": ids, "anchors": anchors}) +} + +/// `d` held to the nearest multiple of 45°, as Shift does. +fn constrain_45(d: Vec2) -> Vec2 { + if d.hypot() < 1e-9 { + return d; + } + let step = std::f64::consts::FRAC_PI_4; + let a = (d.y.atan2(d.x) / step).round() * step; + let proj = d.x * a.cos() + d.y * a.sin(); + Vec2::new(proj * a.cos(), proj * a.sin()) +} + pub struct SelectionTool { direct: bool, drag: Drag, @@ -709,8 +765,40 @@ impl Tool for SelectionTool { && let Some((id, si, ai, handle)) = anchor_at(cx, p) { let at = anchor_spread_point(cx, id, si, ai); - self.drag = Drag::Anchor { id, si, ai, handle, start: p, at }; - return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })]; + let mut out = vec![]; + let mut others = vec![]; + if handle.is_none() { + // InDesign's anchor point selection: a click selects the point, Shift+click + // adds it or takes it away, and dragging a selected point moves all of them. + let me = (ItemId(id), si, ai); + let mut points = cx.selection.anchors.clone(); + let was = points.contains(&me); + if ev.mods.shift && was { + points.retain(|a| *a != me); + } else if ev.mods.shift { + points.push(me); + } else if !was { + points = vec![me]; + } + if points != cx.selection.anchors { + out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points))); + } + if ev.mods.shift && was { + self.drag = Drag::None; + return out; + } + others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect(); + } + let label = if handle.is_some() { + "Move Direction Handle" + } else if others.is_empty() { + "Move Anchor" + } else { + "Move Anchors" + }; + self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others }; + out.push(Action::Begin(label.into())); + return out; } // A selected placed graphic's own handles resize it inside its frame (the // frame's anchors, under Direct Selection, come first). @@ -1035,8 +1123,12 @@ impl Tool for SelectionTool { // Screen y points down: a positive screen angle is clockwise. vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))] } - Drag::Anchor { id, si, ai, handle, start, at } => { - let d = p - start; + Drag::Anchor { id, si, ai, handle, start, at, others } => { + let mut d = p - start; + // Shift: horizontal, vertical or at 45°. + if ev.mods.shift { + d = constrain_45(d); + } let mut dx = d.x; let mut dy = d.y; self.guides.clear(); @@ -1075,6 +1167,9 @@ impl Tool for SelectionTool { let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy}); if let Some(h) = handle { params["handle"] = json!(h); + } else if !others.is_empty() { + let points: Vec = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| json!([i, s, a])).collect(); + params = json!({"points": points, "dx": dx, "dy": dy}); } vec![Action::Preview("path.moveAnchors".into(), params)] } @@ -1100,6 +1195,21 @@ impl Tool for SelectionTool { }, Drag::Marquee { start, cur } => { let r = Rect::from_points(start, cur); + // Direct Selection: the anchor points inside the marquee, on every path it + // reaches (InDesign's way to select points of several frames at once). + if self.direct { + let mut points = anchors_in(cx, r); + if !points.is_empty() { + if ev.mods.shift { + for a in &cx.selection.anchors { + if !points.contains(a) { + points.push(*a); + } + } + } + return vec![Action::Exec("selection.set".into(), anchor_selection(cx, &points))]; + } + } // Items whose bounds intersect the marquee. let mut ids: Vec = Vec::new(); for slot in &cx.layout.slots { @@ -1131,7 +1241,15 @@ impl Tool for SelectionTool { if key == ToolKey::Escape && self.live.take().is_some_and(|id| cx.selection.items == [id]) { return vec![Action::Handled]; } - let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))]; + let points: Vec = cx.selection.anchors.iter().map(|(i, s, a)| json!([i.0, s, a])).collect(); + let direct_points = self.direct && !points.is_empty(); + let mv = |dx: f64, dy: f64| { + // Direct Selection with anchor points selected: the arrow keys move the points. + if direct_points { + return vec![Action::Exec("path.moveAnchors".into(), json!({"points": points.clone(), "dx": dx, "dy": dy}))]; + } + vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))] + }; if cx.selection.items.is_empty() { return vec![]; } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..a8f98b7e1 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1792,7 +1792,13 @@ fn direct_selection_drags_the_end_anchors_of_type_on_a_path() { let p = Point::new(x + off.x, 300.0 + off.y); let mut direct = create("directSelection"); assert_ne!(direct.cursor(&cx, p, Mods::default()), Cursor::PathBracket, "anchor at {x}"); - assert_eq!(direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)), vec![Action::Begin("Move Anchor".into())]); + // The press selects that anchor point and starts moving it. + let a = direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + assert!( + matches!(a.first(), Some(Action::Exec(cmd, p)) if cmd == "selection.set" && p["anchors"].as_array().map(Vec::len) == Some(1)), + "{a:?}" + ); + assert_eq!(a.last(), Some(&Action::Begin("Move Anchor".into()))); } } @@ -1936,3 +1942,52 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() { assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group"); assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); } + +/// Direct Selection selects anchor points with a marquee across several frames, and dragging one of +/// them moves them all, as InDesign does when the top points of a row of frames are dragged to slant +/// them (Zero2Graphic's page layout tutorial, 1:15). +#[test] +fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_together() { + let mut d = Document::new(&NewDocument::default()); + let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let b = add_rect(&mut d, Rect::new(200.0, 100.0, 300.0, 200.0)); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let none = Selection::default(); + let mut t = create("directSelection"); + // A marquee around the top edges: the four top points, not the frames' bottom ones. + let (p0, p1) = (spread_pt(&l, 80.0, 90.0), spread_pt(&l, 320.0, 110.0)); + t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y)); + t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y)); + let up = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y)); + let Some(Action::Exec(cmd, p)) = up.last() else { panic!("{up:?}") }; + assert_eq!(cmd, "selection.set"); + assert_eq!(p["ids"], serde_json::json!([a.0, b.0])); + let mut got: Vec<(u64, u64, u64)> = + p["anchors"].as_array().unwrap().iter().map(|v| (v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap())).collect(); + got.sort(); + assert_eq!(got, vec![(a.0, 0, 0), (a.0, 0, 1), (b.0, 0, 0), (b.0, 0, 1)]); + // Those points selected: dragging one moves all four, in one preview. + let sel = Selection { items: vec![a, b], anchors: vec![(a, 0, 0), (a, 0, 1), (b, 0, 0), (b, 0, 1)], ..Default::default() }; + let cx = ctx(&d, &sel, &c, &l); + let at = spread_pt(&l, 200.0, 100.0); + let down = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y)); + assert_eq!(down, vec![Action::Begin("Move Anchors".into())]); + let shift = Mods { shift: true, ..Default::default() }; + let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 36.0, at.y + 3.0).with_mods(shift)); + let Some(Action::Preview(cmd, p)) = drag.last() else { panic!("{drag:?}") }; + assert_eq!(cmd, "path.moveAnchors"); + assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}"); + // Shift keeps the move horizontal. + assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(36.0), Some(0.0))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]); + // Shift+click on a selected point takes it out of the selection; the arrow keys move the points. + let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift)); + let Some(Action::Exec(cmd, p)) = out.first() else { panic!("{out:?}") }; + assert_eq!(cmd, "selection.set"); + assert_eq!(p["anchors"].as_array().map(Vec::len), Some(3), "{p}"); + let keys = t.key(&cx, ToolKey::Right, Mods::default()); + assert!( + matches!(keys.as_slice(), [Action::Exec(cmd, p)] if cmd == "path.moveAnchors" && p["points"].as_array().map(Vec::len) == Some(4)), + "{keys:?}" + ); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..f0c6d30a7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1229,9 +1229,11 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue }; let m = a * it.xf; let col = layer_color(doc, it); - for sp in &it.path.subpaths { - for an in &sp.anchors { + for (si, sp) in it.path.subpaths.iter().enumerate() { + for (ai, an) in sp.anchors.iter().enumerate() { let p = xf.to_screen(m * an.p); + // A selected anchor point is solid, the others hollow (InDesign). + let chosen = sel.anchors.contains(&(*id, si, ai)); for (h, has) in [(an.h_in, an.has_in()), (an.h_out, an.has_out())] { if has { let hp = xf.to_screen(m * h); @@ -1240,7 +1242,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum } } let r = Rect::from_center_size(p, vec2(5.0, 5.0)); - painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_filled(r, 0.0, if chosen { col } else { Color32::WHITE }); painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); } }