mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 12:46:53 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user