From 3bda745a520e6e2ed99da8e8675f9272a8d2c30d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 02:16:21 +0000 Subject: [PATCH] Tools: live corners round a frame's corners by dragging A selected, unlocked rectangle frame at least 40 px on screen shows the yellow widget near its top-right corner. Clicking it shows a diamond at each corner. Dragging a diamond along its edge sets the size of all four corners as one undo step (Shift: that corner only), clamped to half the shorter side. Alt-click steps that corner's shape; Shift+Alt-click steps every corner. Escape, a click on empty canvas, another selection or another tool ends corner editing. Positions follow the frame's transform. object.cornerOptions takes corners: four {shape, size} entries. Tools gain Action::Handled and the LiveCornerWidget/LiveCornerDiamond overlays, drawn by the canvas in the live_corner theme token. Fixes #186. --- ROADMAP.md | 2 + crates/engine/src/cmd/object.rs | 28 +++-- crates/engine/src/tests.rs | 39 +++++++ crates/engine/src/tooling.rs | 1 + crates/tools/src/corners.rs | 191 ++++++++++++++++++++++++++++++++ crates/tools/src/lib.rs | 7 ++ crates/tools/src/select.rs | 86 +++++++++++++- crates/tools/src/tests.rs | 142 ++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 78 +++++++++++-- crates/ui-egui/src/theme.rs | 3 + docs/roadmap.md | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 7 +- 13 files changed, 564 insertions(+), 24 deletions(-) create mode 100644 crates/tools/src/corners.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0d1c1ef5e..78dac7edb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-11** · Live corners: the yellow widget on a selected rectangle frame shows corner + diamonds; dragging one rounds the corners, Alt-click steps the corner shape (#186). - **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI smoke-tested under QEMU). - **2026-10-10** · Readiness per audience: full ~47% (method aligned with the standard, was diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 17c44c27a..afc8db384 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -498,14 +498,9 @@ pub fn specs() -> Vec { "Corner Options…", ["Object"], None, - "{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, ids?}", + "{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, corners?: [{shape, size}] (4 entries in path order: top-left, top-right, bottom-right, bottom-left; replaces shape and size), ids?}", has_selection, - |s, p| { - let shape = - p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(designcraft_geom::corners::CornerShape::Rounded); - let size = f64_or(p, "size", 12.0); - set_flag(s, p, move |i| i.corners = designcraft_geom::corners::CornerOptions::uniform(shape, size), false) - } + corner_options ), cmd!( "object.textWrap", @@ -1352,6 +1347,25 @@ fn ungroup(s: &mut Session, p: &Value) -> Result { }) } +fn corner_options(s: &mut Session, p: &Value) -> Result { + use designcraft_geom::corners::{Corner, CornerOptions, CornerShape}; + let opts = match p.get("corners") { + Some(v) => { + let corners: [Corner; 4] = + serde_json::from_value(v.clone()).map_err(|e| bad("object.cornerOptions", format!("corners: four {{shape, size}} entries ({e})")))?; + if corners.iter().any(|c| !c.size.is_finite() || c.size < 0.0) { + return Err(bad("object.cornerOptions", "corner sizes must be finite and not negative")); + } + CornerOptions { corners } + } + None => { + let shape = p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(CornerShape::Rounded); + CornerOptions::uniform(shape, f64_or(p, "size", 12.0)) + } + }; + set_flag(s, p, move |i| i.corners = opts, false) +} + fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) -> Result { let ids = targets(s, p)?; s.edit(|d, sel| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c1032bccf..9a1707f0e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -561,3 +561,42 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn live_corner_gestures_are_one_undo_step_each() { + use designcraft_geom::corners::CornerShape; + use designcraft_tools::{Mods, PointerEvent, PointerKind}; + let mut s = session(); + s.execute("frame.create", &json!({"rect": [100, 100, 300, 250], "content": "graphic"})).unwrap(); + let id = s.doc().unwrap().doc.spreads[0].items[0].id; + s.execute("selection.set", &json!({"ids": [id.0]})).unwrap(); + s.set_tool("selection"); + let v = ViewInfo::at_zoom(1.0); + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let corners = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().corners.corners; + let before = undo(&s); + // Click the widget, then drag the top-left diamond 20 pt toward the centre. + s.pointer(&PointerEvent::new(PointerKind::Down, 300.0, 111.5), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 300.0, 111.5), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 112.0, 100.0), v).unwrap(); + for x in [116.0, 124.0, 132.0] { + s.pointer(&PointerEvent::new(PointerKind::Drag, x, 100.0), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 132.0, 100.0), v).unwrap(); + assert_eq!(undo(&s), before + 1); + assert!(corners(&s).iter().all(|c| c.shape == CornerShape::Rounded && (c.size - 20.0).abs() < 1e-9), "{:?}", corners(&s)); + // Alt-click the top-left diamond, now 20 pt along the edge. + let alt = Mods { alt: true, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 120.0, 100.0).with_mods(alt), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 120.0, 100.0).with_mods(alt), v).unwrap(); + assert_eq!(undo(&s), before + 2); + assert_eq!(corners(&s)[0].shape, CornerShape::InverseRounded); + assert_eq!(corners(&s)[1].shape, CornerShape::Rounded); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(corners(&s)[0].shape, CornerShape::Rounded); + // Malformed per-corner parameters are errors, not silent defaults. + let c = json!({"shape": "rounded", "size": 5}); + assert!(s.execute("object.cornerOptions", &json!({"corners": [c, c, c]})).is_err()); + assert!(s.execute("object.cornerOptions", &json!({"corners": [c, c, c, {"shape": "rounded", "size": -1}]})).is_err()); + assert!(s.execute("object.cornerOptions", &json!({"corners": "rounded"})).is_err()); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1bf8db930..508e767fa 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -138,6 +138,7 @@ impl Session { Action::Dialog(id, params) => self.ui_requests.push(UiRequest::Dialog { id, params }), Action::SwitchTool(id) => self.set_tool(&id), Action::View(params) => self.ui_requests.push(UiRequest::View { params }), + Action::Handled => {} } } Ok(()) diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs new file mode 100644 index 000000000..48f6dca84 --- /dev/null +++ b/crates/tools/src/corners.rs @@ -0,0 +1,191 @@ +//! Live corners: the yellow widget on a selected rectangle frame and the four corner diamonds. +//! +//! Geometry works in the frame's local coordinates (the space its path and corner sizes live in) +//! and maps through the frame's canvas transform, so rotated and sheared frames work unchanged. + +use designcraft_doc::{ItemId, Shape}; +use designcraft_geom::corners::{Corner, CornerOptions, CornerShape}; +use designcraft_geom::{Affine, Point, Vec2}; +use serde_json::{Value, json}; + +use crate::ToolContext; + +/// Screen distance of the widget below the frame's top-right corner, along the right edge. +const WIDGET_OFFSET_PX: f64 = 11.5; +/// Both sides of the frame must be at least this long on screen to show the widget. +const MIN_SIDE_PX: f64 = 40.0; +/// Diamonds stay at least this far (screen) from their corner, clear of the resize handles. +const DIAMOND_MIN_PX: f64 = 12.0; +/// Hit radius of the widget and the diamonds. +pub(crate) const HIT_PX: f64 = 5.0; +/// Corner size an Alt-click gives a corner that has none. +const DEFAULT_SIZE: f64 = 12.0; +/// Shapes an Alt-click steps through. +const CYCLE: [CornerShape; 5] = [CornerShape::Rounded, CornerShape::InverseRounded, CornerShape::Inset, CornerShape::Bevel, CornerShape::Fancy]; + +/// A selected rectangle frame that can show live corners. +#[derive(Clone, Debug)] +pub(crate) struct LiveFrame { + pub id: ItemId, + /// Local → canvas. + pub m: Affine, + /// Path corners in path order (top-left, top-right, bottom-right, bottom-left for a new rectangle). + pub pts: [Point; 4], + pub corners: CornerOptions, +} + +/// The single selected, unlocked rectangle frame, when it is large enough on screen. +pub(crate) fn live_frame(cx: &ToolContext) -> Option { + let [id] = cx.selection.items.as_slice() else { return None }; + if cx.selection.text.is_some() { + return None; + } + let it = cx.doc.item(*id)?; + let layer_locked = cx.doc.layer(it.layer).is_some_and(|l| l.locked); + if !matches!(it.shape, Shape::Rectangle) || !it.children().is_empty() || it.locked || layer_locked { + return None; + } + let [sp] = it.path.subpaths.as_slice() else { return None }; + let [a, b, c, d] = sp.anchors.as_slice() else { return None }; + if !sp.closed || sp.anchors.iter().any(|a| a.has_in() || a.has_out()) { + return None; + } + let m = cx.item_canvas_xf(*id)? * it.xf; + let det = m.determinant(); + if !det.is_finite() || det.abs() < 1e-12 { + return None; + } + let pts = [a.p, b.p, c.p, d.p]; + let short = (0..4).map(|i| ((m * pts[i]) - (m * pts[(i + 1) % 4])).hypot()).fold(f64::INFINITY, f64::min); + if short.is_nan() || short * cx.zoom < MIN_SIDE_PX { + return None; + } + Some(LiveFrame { id: *id, m, pts, corners: it.corners }) +} + +impl LiveFrame { + /// The other end of the edge corner `i` slides along: corners 0 and 1 share one edge, 2 and 3 the opposite one. + fn neighbour(i: usize) -> usize { + if i.is_multiple_of(2) { (i + 1) % 4 } else { (i + 3) % 4 } + } + + /// Unit direction (local) from corner `i` along its edge, and that edge's local length. + fn edge(&self, i: usize) -> Option<(Vec2, f64)> { + let p = *self.pts.get(i)?; + let q = *self.pts.get(Self::neighbour(i))?; + let len = (q - p).hypot(); + (len.is_finite() && len > 1e-9).then(|| ((q - p) / len, len)) + } + + /// Largest corner size: half the shorter side. + pub fn max_size(&self) -> f64 { + (0..4).map(|i| (self.pts[(i + 1) % 4] - self.pts[i]).hypot()).fold(f64::INFINITY, f64::min) / 2.0 + } + + /// Corner `i`'s effective size (0 without a shape), clamped to the frame. + pub fn size(&self, i: usize) -> f64 { + self.corners.corners.get(i).filter(|c| !c.is_none()).map_or(0.0, |c| c.size.clamp(0.0, self.max_size().max(0.0))) + } + + /// The widget's canvas position: on the right edge, a little below the top-right corner. + pub fn widget(&self, zoom: f64) -> Point { + let (mut x1, mut y0) = (f64::NEG_INFINITY, f64::INFINITY); + for p in &self.pts { + x1 = x1.max(p.x); + y0 = y0.min(p.y); + } + let tr = self.m * Point::new(x1, y0); + let down = (self.m * Point::new(x1, y0 + 1.0)) - tr; + let len = down.hypot(); + if len.is_nan() || len <= 1e-12 { + return tr; + } + tr + down / len * (WIDGET_OFFSET_PX / zoom.max(1e-9)) + } + + /// Canvas positions of the four diamonds: each at its corner size along its edge, but at least + /// a few screen pixels from the corner. + pub fn diamonds(&self, zoom: f64) -> Vec { + (0..4) + .filter_map(|i| { + let (u, len) = self.edge(i)?; + let p = self.pts[i]; + let per_unit = ((self.m * (p + u)) - (self.m * p)).hypot(); + let min = if per_unit > 1e-12 { DIAMOND_MIN_PX / zoom.max(1e-9) / per_unit } else { 0.0 }; + let d = self.size(i).max(min).min(len / 2.0); + Some(self.m * (p + u * d)) + }) + .collect() + } + + /// Index of the diamond under canvas point `p`. + pub fn diamond_at(&self, p: Point, zoom: f64, tol: f64) -> Option { + let ds = self.diamonds(zoom); + if ds.len() != 4 { + return None; + } + ds.iter().enumerate().map(|(i, d)| (i, (*d - p).hypot())).filter(|(_, dist)| *dist <= tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) + } + + /// Change of corner size when the pointer moves from `start` to `p` (canvas) while dragging + /// corner `i`: the movement along that corner's edge, in local units. + pub fn drag_delta(&self, i: usize, start: Point, p: Point) -> Option { + let (u, _) = self.edge(i)?; + let inv = self.m.inverse(); + let d = ((inv * p) - (inv * start)).dot(u); + d.is_finite().then_some(d) + } + + /// Options after dragging corner `i` to `size`: every corner takes that size, or only corner + /// `i` with `only`. A corner without a shape becomes rounded. + pub fn dragged(&self, i: usize, size: f64, only: bool) -> CornerOptions { + let size = size.clamp(0.0, self.max_size().max(0.0)); + let mut out = self.corners; + for (k, c) in out.corners.iter_mut().enumerate() { + if only && k != i { + continue; + } + if c.shape == CornerShape::None { + c.shape = CornerShape::Rounded; + } + c.size = size; + } + out + } + + /// Options after an Alt-click on corner `i`: that corner (or every corner, with `all`) takes + /// the shape after corner `i`'s. A corner without a size gets a default one. + pub fn cycled(&self, i: usize, all: bool) -> CornerOptions { + let cur = self.corners.corners.get(i).map_or(CornerShape::None, |c| c.shape); + let next = CYCLE.iter().position(|s| *s == cur).map_or(CornerShape::Rounded, |k| CYCLE[(k + 1) % CYCLE.len()]); + let fallback = DEFAULT_SIZE.min(self.max_size().max(0.0)); + let mut out = self.corners; + for (k, c) in out.corners.iter_mut().enumerate() { + if !all && k != i { + continue; + } + c.shape = next; + if c.size.is_nan() || c.size <= 0.0 { + c.size = fallback; + } + } + out + } +} + +fn shape_id(s: CornerShape) -> &'static str { + match s { + CornerShape::None => "none", + CornerShape::Rounded => "rounded", + CornerShape::InverseRounded => "inverseRounded", + CornerShape::Inset => "inset", + CornerShape::Bevel => "bevel", + CornerShape::Fancy => "fancy", + } +} + +/// `object.cornerOptions` parameters that set every corner of item `id`. +pub(crate) fn params(id: ItemId, opts: &CornerOptions) -> Value { + let corners: Vec = opts.corners.iter().map(|c: &Corner| json!({"shape": shape_id(c.shape), "size": c.size})).collect(); + json!({"ids": [id.0], "corners": corners}) +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f84..1cbb63b04 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -11,6 +11,7 @@ mod anchors; pub mod catalog; +mod corners; mod frame; mod gradient; pub mod layout; @@ -105,6 +106,8 @@ pub enum Action { SwitchTool(String), /// Ask the UI to pan/zoom: `{"pan":[dx,dy]}` (screen px) or `{"zoomAt":[x,y], "factor":f}` (canvas). View(Value), + /// The tool used the event for its own state; the engine has nothing to do. + Handled, } /// Which snap categories are on, and the snap zone in screen pixels. @@ -309,6 +312,10 @@ pub enum Overlay { b: Point, label: String, }, + /// The live-corner widget (yellow square): clicking it starts corner editing. + LiveCornerWidget(Point), + /// A live-corner diamond: dragging it sets the corner size. + LiveCornerDiamond(Point), } /// Correction from a snap, in spread coordinates for `delta`. diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8f59c3503..8a1198f97 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -3,11 +3,15 @@ //! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains), //! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content), //! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool. +//! A selected rectangle frame shows the live-corner widget: clicking it shows a diamond at each +//! corner; dragging a diamond sets the corner size (Shift: that corner only), Alt-clicking one +//! steps its shape (Shift+Alt: every corner). Escape or clicking empty canvas ends corner editing. use designcraft_doc::{ItemId, SpreadRef}; use designcraft_geom::{Affine, Point, Rect, Vec2}; use serde_json::{Value, json}; +use crate::corners::{self, LiveFrame}; use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, SnapRequest, Tool, ToolContext, ToolKey, rect_json, spread_json}; #[derive(Clone, Debug)] @@ -38,6 +42,13 @@ enum Drag { start: Point, cur: Point, }, + /// Dragging live-corner diamond `corner`. `frame` is read at pointer down: during the drag + /// the document is the previous preview. + Corner { + frame: LiveFrame, + corner: usize, + start: Point, + }, Anchor { id: u64, si: usize, @@ -100,11 +111,37 @@ pub struct SelectionTool { guides: Vec, /// Selected item to toggle if this Shift press never becomes a drag. shift_release: Option, + /// Frame whose live corners are being edited (diamonds shown). + live: Option, } impl SelectionTool { pub fn new(direct: bool) -> Self { - Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None } + Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, live: None } + } + + /// The frame in live-corner editing, while it is still the selection. + fn live_corners(&self, cx: &ToolContext) -> Option { + let id = self.live?; + corners::live_frame(cx).filter(|f| f.id == id) + } + + /// The frame whose live-corner widget is under `p` (not while editing its corners). + fn widget_at(&self, cx: &ToolContext, p: Point) -> Option { + if self.direct || self.live_corners(cx).is_some() { + return None; + } + corners::live_frame(cx).filter(|f| (f.widget(cx.zoom) - p).hypot() <= cx.tol(corners::HIT_PX)) + } + + /// The frame and the index of the live-corner diamond under `p`. + fn diamond_at(&self, cx: &ToolContext, p: Point) -> Option<(LiveFrame, usize)> { + if self.direct { + return None; + } + let f = self.live_corners(cx)?; + let i = f.diamond_at(p, cx.zoom, cx.tol(corners::HIT_PX))?; + Some((f, i)) } } @@ -371,6 +408,12 @@ impl Tool for SelectionTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; + // Selecting something else ends live-corner editing. + if let Some(id) = self.live + && cx.selection.items != [id] + { + self.live = None; + } match ev.kind { PointerKind::Move => { self.hover_handle = handle_at(cx, p); @@ -385,6 +428,20 @@ impl Tool for SelectionTool { 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() })]; } + if let Some((frame, corner)) = self.diamond_at(cx, p) { + if ev.mods.alt { + self.drag = Drag::None; + let opts = frame.cycled(corner, ev.mods.shift); + return vec![Action::Exec("object.cornerOptions".into(), corners::params(frame.id, &opts))]; + } + self.drag = Drag::Corner { frame, corner, start: p }; + return vec![Action::Begin("Corner Options".into())]; + } + if let Some(frame) = self.widget_at(cx, p) { + self.live = Some(frame.id); + self.drag = Drag::None; + return vec![Action::Handled]; + } if !self.direct && handle_at(cx, p).is_none() && let Some(center) = rotate_zone(cx, p) @@ -431,6 +488,7 @@ impl Tool for SelectionTool { out } None => { + self.live = None; self.drag = Drag::Pending { start: p, hit: false }; if ev.mods.shift { vec![] } else { vec![Action::Exec("selection.set".into(), json!({"ids": []}))] } } @@ -592,6 +650,11 @@ impl Tool for SelectionTool { self.drag = Drag::Marquee { start, cur: p }; vec![] } + Drag::Corner { frame, corner, start } => { + let Some(d) = frame.drag_delta(corner, start, p) else { return vec![] }; + let opts = frame.dragged(corner, frame.size(corner) + d, ev.mods.shift); + vec![Action::Preview("object.cornerOptions".into(), corners::params(frame.id, &opts))] + } Drag::Rotate { center, start_angle, start_rotation } => { let mut a = ((p - center).atan2() - start_angle).to_degrees(); self.guides.clear(); @@ -682,7 +745,7 @@ impl Tool for SelectionTool { let release = self.shift_release.take(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { - Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } => vec![Action::Commit], + Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } | Drag::Corner { .. } => vec![Action::Commit], Drag::Pending { .. } => match release { Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))], None => vec![], @@ -724,6 +787,9 @@ impl Tool for SelectionTool { fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { let inc = cx.doc.settings.keyboard_increment * if mods.shift { 10.0 } else { 1.0 }; + 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}))]; if cx.selection.items.is_empty() { return vec![]; @@ -739,12 +805,20 @@ impl Tool for SelectionTool { } } - fn overlays(&self, _cx: &ToolContext) -> Vec { - match &self.drag { + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut out = match &self.drag { Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), _ => vec![], + }; + if !self.direct { + if let Some(f) = self.live_corners(cx) { + out.extend(f.diamonds(cx.zoom).into_iter().map(Overlay::LiveCornerDiamond)); + } else if let Some(f) = corners::live_frame(cx) { + out.push(Overlay::LiveCornerWidget(f.widget(cx.zoom))); + } } + out } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { @@ -755,8 +829,12 @@ impl Tool for SelectionTool { Drag::Move { .. } => return Cursor::Move, Drag::Resize { handle, .. } => return handle_cursor(handle), Drag::Rotate { .. } => return Cursor::Rotate, + Drag::Corner { .. } => return Cursor::Arrow, _ => {} } + if self.diamond_at(cx, p).is_some() || self.widget_at(cx, p).is_some() { + return Cursor::Arrow; + } match handle_at(cx, p) { Some(h) => handle_cursor(h), None if rotate_zone(cx, p).is_some() => Cursor::Rotate, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0da6..8fe9d9bd2 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,145 @@ fn pen_click_snaps_to_a_guide() { let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } + +/// A 200 × 150 pt rectangle frame, selected. +fn live_corner_doc() -> (Document, ItemId) { + let mut d = Document::new(&NewDocument::default()); + let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 250.0)); + (d, id) +} + +fn corner_preview(a: &[Action]) -> Vec<(String, f64)> { + let p = match a { + [Action::Preview(cmd, p)] | [Action::Exec(cmd, p)] if cmd == "object.cornerOptions" => p, + other => panic!("{other:?}"), + }; + p["corners"].as_array().unwrap().iter().map(|c| (c["shape"].as_str().unwrap().to_string(), c["size"].as_f64().unwrap())).collect() +} + +fn diamonds(o: &[Overlay]) -> Vec { + o.iter().filter_map(|o| if let Overlay::LiveCornerDiamond(p) = o { Some(*p) } else { None }).collect() +} + +#[test] +fn live_corners_click_widget_then_drag_rounds_all_corners() { + let (d, id) = live_corner_doc(); + let s = Selection { items: vec![id], ..Default::default() }; + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let at = |x, y| spread_pt(&l, x, y); + let mut t = create("selection"); + // The widget sits on the right edge, a little below the top-right corner. + let w = at(300.0, 111.5); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::LiveCornerWidget(p) if near(*p, w)))); + assert!(diamonds(&t.overlays(&cx)).is_empty()); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)), vec![Action::Handled]); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty()); + // Without corner sizes the diamonds sit 12 px along the top and bottom edges. + let want = [at(112.0, 100.0), at(288.0, 100.0), at(288.0, 250.0), at(112.0, 250.0)]; + let got = diamonds(&t.overlays(&cx)); + assert!(got.len() == 4 && got.iter().zip(want).all(|(g, w)| near(*g, w)), "{got:?}"); + assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::LiveCornerWidget(_)))); + // Drag the top-left diamond 20 pt toward the centre. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, want[0].x, want[0].y)); + assert_eq!(a, vec![Action::Begin("Corner Options".into())]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, want[0].x + 20.0, want[0].y + 3.0)); + assert_eq!(corner_preview(&a), vec![("rounded".to_string(), 20.0); 4]); + // The size stops at half the shorter side. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, want[0].x + 500.0, want[0].y)); + assert_eq!(corner_preview(&a), vec![("rounded".to_string(), 75.0); 4]); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, want[0].x + 500.0, want[0].y)), vec![Action::Commit]); +} + +#[test] +fn live_corners_shift_drag_and_alt_click_change_one_corner() { + let (mut d, id) = live_corner_doc(); + d.item_mut(id).unwrap().corners = designcraft_geom::corners::CornerOptions::uniform(designcraft_geom::corners::CornerShape::Rounded, 30.0); + let s = Selection { items: vec![id], ..Default::default() }; + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let at = |x, y| spread_pt(&l, x, y); + let mut t = create("selection"); + let w = at(300.0, 111.5); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)); + // With 30 pt corners the diamonds sit 30 pt along the edges. + let tr = at(270.0, 100.0); + assert!(diamonds(&t.overlays(&cx)).iter().any(|p| near(*p, tr))); + let shift = Mods { shift: true, ..Default::default() }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tr.x, tr.y).with_mods(shift)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tr.x - 10.0, tr.y).with_mods(shift)); + let r = |n: f64| ("rounded".to_string(), n); + assert_eq!(corner_preview(&a), vec![r(30.0), r(40.0), r(30.0), r(30.0)]); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, tr.x - 10.0, tr.y)); + // Alt-click steps the bottom-right corner's shape; Shift+Alt steps every corner. + let br = at(270.0, 250.0); + let alt = Mods { alt: true, ..Default::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, br.x, br.y).with_mods(alt)); + assert_eq!(corner_preview(&a), vec![r(30.0), r(30.0), ("inverseRounded".to_string(), 30.0), r(30.0)]); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, br.x - 30.0, br.y).with_mods(alt)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, br.x - 30.0, br.y)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, br.x, br.y).with_mods(Mods { alt: true, shift: true, ..Default::default() })); + assert_eq!(corner_preview(&a), vec![("inverseRounded".to_string(), 30.0); 4]); +} + +#[test] +fn live_corners_end_on_escape_empty_click_and_other_selection() { + let (mut d, id) = live_corner_doc(); + let other = add_rect(&mut d, Rect::new(400.0, 100.0, 500.0, 200.0)); + let s = Selection { items: vec![id], ..Default::default() }; + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let w = spread_pt(&l, 300.0, 111.5); + let mut t = create("selection"); + let enter = |t: &mut Box| { + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)); + assert_eq!(diamonds(&t.overlays(&cx)).len(), 4); + }; + // Escape leaves corner editing and keeps the selection; the next Escape deselects. + enter(&mut t); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Handled]); + assert!(diamonds(&t.overlays(&cx)).is_empty()); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": []}))]); + // A click on empty canvas. + enter(&mut t); + let e = spread_pt(&l, 350.0, 400.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, e.x, e.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, e.x, e.y)); + assert!(diamonds(&t.overlays(&cx)).is_empty()); + // Another selection. + enter(&mut t); + let s2 = Selection { items: vec![other], ..Default::default() }; + let cx2 = ctx(&d, &s2, &c, &l); + assert!(diamonds(&t.overlays(&cx2)).is_empty()); + t.pointer(&cx2, &PointerEvent::new(PointerKind::Move, 0.0, 0.0)); + assert!(diamonds(&t.overlays(&cx)).is_empty()); + // A frame too small on screen has no widget. + let mut far = ctx(&d, &s, &c, &l); + far.zoom = 0.1; + assert!(!t.overlays(&far).iter().any(|o| matches!(o, Overlay::LiveCornerWidget(_)))); +} + +#[test] +fn live_corner_diamonds_follow_a_rotated_frame() { + let mut d = Document::new(&NewDocument::default()); + let id = add_rotated(&mut d, Rect::new(100.0, 100.0, 300.0, 250.0), 30.0); + let s = Selection { items: vec![id], ..Default::default() }; + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let m = l.xf(SpreadRef::Doc(0)) * d.item(id).unwrap().xf; + let mut t = create("selection"); + // The widget: 11.5 pt from the top-right corner along the turned right edge. + let w = m * Point::new(300.0, 111.5); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)); + let tl = m * Point::new(112.0, 100.0); + assert!(diamonds(&t.overlays(&cx)).iter().any(|p| near(*p, tl)), "{:?}", t.overlays(&cx)); + // Dragging along the turned top edge sets the size. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tl.x, tl.y)); + let to = m * Point::new(137.0, 100.0); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + let got = corner_preview(&a); + assert!(got.iter().all(|(shape, size)| shape == "rounded" && (size - 25.0).abs() < 1e-6), "{got:?}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34dd61442..d1f892594 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -771,6 +771,13 @@ fn draw_item_edges(painter: &egui::Painter, xf: &Xf, doc: &Document, it: &Item, } } +/// Layer colour of the first selected item: live-corner marks are outlined in it. +fn selection_color(app: &DesignApp) -> Color32 { + let fallback = Color32::from_rgb(79, 153, 255); + let Some(st) = app.session.active() else { return fallback }; + st.selection.items.first().and_then(|id| st.doc.item(*id)).map_or(fallback, |it| layer_color(&st.doc, it)) +} + fn layer_color(doc: &Document, it: &Item) -> Color32 { doc.layer(it.layer).map(|l| c32(l.color)).unwrap_or(Color32::from_rgb(79, 153, 255)) } @@ -849,15 +856,8 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum painter.rect_stroke(u, 0.0, Stroke::new(1.0, color), StrokeKind::Middle); } let hs = 6.5; - // Centre point and the live-corner widget. + // Centre point. The Selection tool draws the live-corner widget as an overlay. painter.rect_filled(Rect::from_center_size(u.center(), vec2(3.5, 3.5)), 0.0, color); - if sel.items.len() == 1 - && doc.item(sel.items[0]).is_some_and(|i| matches!(i.shape, designcraft_doc::Shape::Rectangle) && i.children().is_empty()) - { - let lc = Rect::from_center_size(pos2(u.max.x, u.min.y + 11.5), vec2(6.0, 6.0)); - painter.rect_filled(lc, 0.0, Color32::from_rgb(0xff, 0xe5, 0x00)); - painter.rect_stroke(lc, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); - } for h in designcraft_tools::select::handles(DRect::new(u.min.x as f64, u.min.y as f64, u.max.x as f64, u.max.y as f64)) { let r = Rect::from_center_size(pos2(h.x as f32, h.y as f32), vec2(hs, hs)); painter.rect_filled(r, 0.0, Color32::WHITE); @@ -1171,6 +1171,7 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { } let tok = Tokens::get(painter.ctx()); let ov = app.session.overlays(app.view_info()); + let sel_color = selection_color(app); for o in ov { match o { Overlay::Marquee(r) => { @@ -1212,6 +1213,17 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.rect_filled(r, 3.0, tok.measure_bg); painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); } + Overlay::LiveCornerWidget(p) => { + let r = Rect::from_center_size(xf.to_screen(p), vec2(6.0, 6.0)); + painter.rect_filled(r, 0.0, tok.live_corner); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, sel_color), StrokeKind::Inside); + } + Overlay::LiveCornerDiamond(p) => { + let c = xf.to_screen(p); + let h = 4.5; + let pts = vec![c + vec2(0.0, -h), c + vec2(h, 0.0), c + vec2(0.0, h), c + vec2(-h, 0.0)]; + painter.add(egui::Shape::convex_polygon(pts, tok.live_corner, Stroke::new(1.0, sel_color))); + } Overlay::Path { .. } => {} } } @@ -1798,6 +1810,56 @@ mod tests { } } + /// The live-corner widget draws on a selected rectangle frame; clicking it draws the diamonds. + #[test] + fn live_corner_widget_and_diamonds_draw() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [100, 100, 300, 250], "content": "graphic"})).unwrap(); + let id = app.session.doc().unwrap().doc.spreads[0].items[0].id; + app.run("selection.set", json!({"ids": [id.0]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_pixels_per_point(1.0).wgpu().build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + let screen = |h: &Harness<'_, DesignApp>, x: f64, y: f64| { + let app = h.state(); + let layout = CanvasLayout::new(&app.session.doc().unwrap().doc, false); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + xf.to_screen(layout.to_canvas(SpreadRef::Doc(0), Point::new(x, y))) + }; + let widget = screen(&h, 300.0, 100.0) + vec2(0.0, 11.5); + let diamond = screen(&h, 100.0, 100.0) + vec2(12.0, 0.0); + let yellow = |h: &mut Harness<'_, DesignApp>, p: Pos2| -> Option { + let img = match h.render() { + Ok(img) => img, + Err(e) => { + eprintln!("skipped: no offscreen renderer ({e})"); + return None; + } + }; + let px = img.get_pixel(p.x.round() as u32, p.y.round() as u32).0; + Some(px[0] > 200 && px[1] > 180 && px[2] < 100) + }; + let Some(w) = yellow(&mut h, widget) else { return }; + assert!(w, "no widget at {widget:?}"); + assert_eq!(yellow(&mut h, diamond), Some(false)); + h.hover_at(widget); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: widget, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(3); + assert_eq!(yellow(&mut h, diamond), Some(true), "no diamond at {diamond:?}"); + assert_eq!(yellow(&mut h, widget), Some(false)); + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3dd50180f..5f4cbf02e 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -83,6 +83,8 @@ pub struct Tokens { /// Measurement labels on the canvas (smart dimensions, gaps). pub measure_bg: Color32, pub measure_text: Color32, + /// Live-corner widget and corner diamonds. + pub live_corner: Color32, } fn hex(s: u32) -> Color32 { @@ -125,6 +127,7 @@ impl Tokens { smart_guide: hex(0x00c853), measure_bg: Color32::from_rgba_unmultiplied(70, 70, 70, 230), measure_text: Color32::WHITE, + live_corner: hex(0xffe500), }; match b { Brightness::Dark => dark, diff --git a/docs/roadmap.md b/docs/roadmap.md index e738da950..17f09e555 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -57,7 +57,7 @@ set exists; depth gaps are in gaps.md. | # | Milestone | State | |---|---|---| | M0 | Skeleton + vertical slice | ✅ | -| M1 | Selection, transform, layers, pages, MCP | ✅ core; handle and snapping issues (#186, #188, #337) | +| M1 | Selection, transform, layers, pages, MCP | ✅ core; handle and snapping issues (#188, #337) | | M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool and composer ✅; **manual threading missing** (alpha blocker); caret formatting (#289); no IME (#322) | | M3 | Styles (nested/GREP, bullets, keeps, span columns) | ✅; style editing reliability (#169) | | M4 | Color & effects | ✅ core; colour management depth | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8044ed51b..c9b548aeb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -206,7 +206,7 @@ table is the one summed. | Application shell & workspace | 99% | 55% | 40–60 | context menus not opening on some systems; window controls (#333); System theme (#237); New Window/Arrange; Help menu | | Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, Go Back/Forward, layout grid (CJK) | | Layers | 100% | 75% | 5–10 | IDML layer order fixed 2026-10-09; Layer panel options depth | -| Frames, shapes & paths | 100% | 60% | 25–40 | object copy/paste failures, live-corner handle (#186), reference point relocation (#337), Join/Reverse Path/Convert Point menu | +| Frames, shapes & paths | 100% | 60% | 25–40 | object copy/paste failures, reference point relocation (#337), Join/Reverse Path/Convert Point menu | | Transform | 100% | 70% | 10–20 | Rotate 180°; transform panel depth | | Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS | | Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ee6db4232..1910ec29e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (live corners work) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -30,7 +30,7 @@ basics of editing fail for too many users on Windows and Linux. | Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143) | ✓ | | | Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | | | Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) | -| Live corners (yellow handle) | ✓ | ✓ | ~ | not working for some users (#186) | +| Live corners (yellow handle) | ✓ | ✓ widget on a selected rectangle frame; diamonds drag the corner size (Shift: one corner), Alt-click steps the shape (Shift+Alt: all) | ✓ | #186 | | Content grabber, frame fitting by double-click | ✓ | ✓ | ✓ | | | Smart guides, snapping to guides/grid/baseline | ✓ | ✓ (2,049-line `snap.rs`) | ~ | vertical smart spacing between two rectangles (#188) | | Nudging with arrows, Shift ×10 | ✓ | ✓ | ✓ | | @@ -65,7 +65,7 @@ context menus, typing at a caret) are things a layout artist does every minute, | Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 | | Canvas IME (port from VectorCraft) | 10–20 | | Missing menu items (~150) and panels (Tabs, Navigator, Trap Presets) | 40–70 | -| Handle/tool feel fixes (#186, #188, #337), font search focus | 10–20 | +| Handle/tool feel fixes (#188, #337), font search focus | 10–20 | | Dialog depth pass against 07-dialogs.md | 30–40 | | **Total** | **120–180** (overlaps gaps 1, 8, 16) | @@ -73,5 +73,6 @@ context menus, typing at a caret) are things a layout artist does every minute, | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Live corners row to ✓: widget, diamonds, drag, Shift-drag, Alt-click shape cycling (#186) | | 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) | | 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues |