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.
This commit is contained in:
aiongg
2026-10-11 02:16:21 +00:00
parent a9124a0517
commit 3bda745a52
13 changed files with 564 additions and 24 deletions
+2
View File
@@ -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
+21 -7
View File
@@ -498,14 +498,9 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
})
}
fn corner_options(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
let ids = targets(s, p)?;
s.edit(|d, sel| {
+39
View File
@@ -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());
}
+1
View File
@@ -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(())
+191
View File
@@ -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<LiveFrame> {
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<Point> {
(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<usize> {
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<f64> {
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<Value> = opts.corners.iter().map(|c: &Corner| json!({"shape": shape_id(c.shape), "size": c.size})).collect();
json!({"ids": [id.0], "corners": corners})
}
+7
View File
@@ -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`.
+82 -4
View File
@@ -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<Overlay>,
/// Selected item to toggle if this Shift press never becomes a drag.
shift_release: Option<u64>,
/// Frame whose live corners are being edited (diamonds shown).
live: Option<ItemId>,
}
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<LiveFrame> {
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<LiveFrame> {
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<Action> {
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<Action> {
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<Overlay> {
match &self.drag {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
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,
+142
View File
@@ -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<Point> {
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<dyn Tool>| {
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:?}");
}
+70 -8
View File
@@ -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<bool> {
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()));
+3
View File
@@ -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,
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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 |
+4 -3
View File
@@ -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 |