M1: edit smart-selection spacing from fields and canvas (#402)

This commit is contained in:
Ivan Shishkanov
2026-10-15 22:07:53 +03:00
parent 9dcfe4ecbc
commit 97d09369d1
25 changed files with 778 additions and 3 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Smart-selection spacing fields and on-canvas gap handles (#402); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Smart-selection spacing (#402): Layout fields and pink on-canvas handles adjust horizontal/vertical gaps for movable sibling rows, columns and aligned grids through `arrange.spacing`; mixed gaps normalize, track contents and sizes stay intact, one Undo step per scrub/drag, Escape cancels. Auto-layout flow children and locked/instance-internal layers are excluded. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
+57
View File
@@ -19,6 +19,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("align.bottom", "Align bottom", "{ids?}", true, |s, p| align(s, p, Al::Bottom)),
spec!("distribute.horizontal", "Distribute horizontal spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, true)),
spec!("distribute.vertical", "Distribute vertical spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, false)),
spec!(
"arrange.spacing",
"Spacing",
"{ids?, horizontal?, vertical?, fromGesture?} (page units; fromGesture uses the interaction snapshot)",
true,
spacing
),
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)),
spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)),
@@ -546,3 +553,53 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
}
ok()
}
/// Set the distances between smart-selection tracks without rebuilding/reparenting the layers.
fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("arrange.spacing", s, p)?;
// A scrub is absolute from the interaction snapshot, even if tracks overlap or cross.
let ds = s.ds()?;
let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone());
let layout = crate::selection_spacing::selection_spacing(&base, &ids);
let mut offsets = std::collections::BTreeMap::<NodeId, (Option<f64>, Option<f64>)>::new();
let mut any = false;
for (key, axis, vertical) in [("horizontal", &layout.horizontal, false), ("vertical", &layout.vertical, true)] {
if get(p, key).is_none() {
continue;
}
let value = req_num("arrange.spacing", p, key)?;
if value.abs() > 1_000_000.0 {
return Err(bad("arrange.spacing", "spacing out of range"));
}
let axis = axis.as_ref().ok_or_else(|| bad("arrange.spacing", "selection has no spacing on this axis"))?;
any = true;
for (id, delta) in axis.offsets(value) {
if !delta.is_finite() {
return Err(bad("arrange.spacing", "non-finite spacing result"));
}
let pair = offsets.entry(id).or_default();
if vertical {
pair.1 = Some(delta);
} else {
pair.0 = Some(delta);
}
}
}
if !any {
return Err(bad("arrange.spacing", "pass horizontal or vertical spacing"));
}
let doc = s.doc_mut()?;
let mut moves = Vec::new();
for (id, (dx, dy)) in offsets {
let (Some(before), Some(now)) = (base.world_bounds(id), doc.world_bounds(id)) else { continue };
let (dx, dy) = (dx.map_or(0.0, |d| before.x0 + d - now.x0), dy.map_or(0.0, |d| before.y0 + d - now.y0));
if !dx.is_finite() || !dy.is_finite() {
return Err(bad("arrange.spacing", "non-finite spacing result"));
}
moves.push((id, dx, dy));
}
for (id, dx, dy) in moves {
move_world(doc, id, dx, dy)?;
}
ok()
}
+4
View File
@@ -43,6 +43,7 @@ pub mod plugins;
pub mod proto_run;
pub mod rename;
pub mod rotation_origin;
pub mod selection_spacing;
pub mod shape_handles;
pub mod shapebuilder;
pub mod snap;
@@ -1709,3 +1710,6 @@ mod version_tests;
mod view_behaviour_tests;
#[cfg(test)]
mod widgets_tests;
#[cfg(test)]
mod selection_spacing_tests;
+132
View File
@@ -0,0 +1,132 @@
//! Smart-selection spacing in page coordinates. Tracks contain aligned siblings (columns for
//! horizontal spacing, rows for vertical spacing); moving a track preserves its internal layout.
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::Rect;
const EPS: f64 = 0.01;
#[derive(Clone, Debug)]
pub struct Track {
pub ids: Vec<NodeId>,
pub bounds: Rect,
anchor: Rect,
}
#[derive(Clone, Debug)]
pub struct Axis {
pub tracks: Vec<Track>,
pub vertical: bool,
/// None means the existing distances are mixed; setting a value makes them uniform.
pub value: Option<f64>,
}
impl Axis {
pub fn start(&self, b: Rect) -> f64 {
if self.vertical { b.y0 } else { b.x0 }
}
pub fn end(&self, b: Rect) -> f64 {
if self.vertical { b.y1 } else { b.x1 }
}
pub fn gap(&self, a: &Track, b: &Track) -> f64 {
self.start(b.bounds) - self.end(a.bounds)
}
/// Desired page-space translations, keeping the first track fixed.
pub fn offsets(&self, value: f64) -> Vec<(NodeId, f64)> {
let mut pos = self.tracks.first().map_or(0.0, |t| self.start(t.bounds));
let mut out = Vec::new();
for t in &self.tracks {
let delta = pos - self.start(t.bounds);
out.extend(t.ids.iter().map(|id| (*id, delta)));
pos += self.end(t.bounds) - self.start(t.bounds) + value;
}
out
}
}
#[derive(Clone, Debug, Default)]
pub struct SelectionSpacing {
pub horizontal: Option<Axis>,
pub vertical: Option<Axis>,
}
/// Available only for movable, visible siblings outside normal auto-layout flow. Parent/child
/// selections, instance internals and locked layers must not be moved indirectly by a gap edit.
pub fn selection_spacing(doc: &Document, ids: &[NodeId]) -> SelectionSpacing {
let Some(first) = ids.first().and_then(|id| doc.node(*id)) else { return SelectionSpacing::default() };
if ids.len() < 2 || ids.len() > 2000 {
return SelectionSpacing::default();
}
let mut items = Vec::new();
for id in ids {
let Some(n) = doc.node(*id) else { return SelectionSpacing::default() };
if n.parent != first.parent
|| !n.visible
|| doc.ancestors(*id).into_iter().any(|a| doc.node(a).is_some_and(|n| !n.visible))
|| crate::tools::locked_in(doc, *id)
|| crate::components::inside_instance(doc, *id)
|| matches!(n.kind, NodeKind::Page { .. })
|| n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some() && !n.child_layout.absolute)
{
return SelectionSpacing::default();
}
let determinant = n.parent.map(|p| doc.world_transform(p).determinant()).unwrap_or(1.0);
if !determinant.is_finite() || determinant.abs() < 1e-12 {
return SelectionSpacing::default();
}
let Some(b) = doc.world_bounds(*id) else { return SelectionSpacing::default() };
if ![b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) || b.width() <= EPS || b.height() <= EPS {
return SelectionSpacing::default();
}
if !items.iter().any(|(i, _)| i == id) {
items.push((*id, b));
}
}
SelectionSpacing { horizontal: axis(&items, false), vertical: axis(&items, true) }
}
fn axis(items: &[(NodeId, Rect)], vertical: bool) -> Option<Axis> {
let edges = |b: Rect| if vertical { (b.y0, b.y1) } else { (b.x0, b.x1) };
let mut items = items.to_vec();
items.sort_by(|a, b| edges(a.1).0.total_cmp(&edges(b.1).0).then(a.0.cmp(&b.0)));
let mut tracks: Vec<Track> = Vec::new();
for (id, b) in items {
let (lo, hi) = edges(b);
let aligned = tracks.iter_mut().find(|t| {
let (a, z) = edges(t.anchor);
(lo - a).abs() <= EPS || (hi - z).abs() <= EPS || ((lo + hi - a - z) * 0.5).abs() <= EPS
});
if let Some(t) = aligned {
t.ids.push(id);
t.bounds = t.bounds.union(b);
} else {
tracks.push(Track { ids: vec![id], bounds: b, anchor: b });
}
}
if tracks.len() < 2 {
return None;
}
tracks.sort_by(|a, b| edges(a.bounds).0.total_cmp(&edges(b.bounds).0));
// Diagonally scattered layers aren't a row/column/grid selection.
let cross = |b: Rect| if vertical { (b.x0, b.x1) } else { (b.y0, b.y1) };
let low = tracks.iter().map(|t| cross(t.bounds).0).fold(f64::NEG_INFINITY, f64::max);
let high = tracks.iter().map(|t| cross(t.bounds).1).fold(f64::INFINITY, f64::min);
if high - low <= EPS {
return None;
}
let mut result = Axis { tracks, vertical, value: None };
let gaps: Vec<f64> = result.tracks.windows(2).filter_map(|p| p.first().zip(p.get(1)).map(|(a, b)| result.gap(a, b))).collect();
result.value = gaps.first().copied().filter(|v| gaps.iter().all(|g| (g - v).abs() <= EPS));
Some(result)
}
/// A spacing gesture never changes selection. Roll it back in its owning document, preserving
/// a subsequent tab/page/selection change and leaving another document's gesture alone.
pub fn cancel_gesture(session: &mut crate::Session, uid: u64) {
if let Some(ds) = session.docs.iter_mut().find(|d| d.uid == uid)
&& let Some(it) = ds.interaction.take()
{
ds.doc = it.doc;
ds.revision += 1;
ds.clean_selection();
}
}
@@ -0,0 +1,123 @@
use crate::{Session, selection_spacing::selection_spacing};
use figcraft_doc::NodeId;
use serde_json::json;
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x":x,"y":y,"width":w,"height":h,"parent":parent})).unwrap()["id"].as_u64().unwrap())
}
fn xy(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0)
}
#[test]
fn rows_columns_and_grids_keep_track_contents_and_sizes() {
let mut s = Session::new();
// Unequal widths, left-aligned columns. The column widths are 50 and 80; gaps 30 and 20.
let a = rect(&mut s, 100., 100., 50., 40., None);
let b = rect(&mut s, 180., 100., 80., 40., None);
let c = rect(&mut s, 100., 160., 30., 60., None);
let d = rect(&mut s, 180., 160., 40., 60., None);
let ids = [a, b, c, d];
let layout = selection_spacing(s.doc().unwrap(), &ids);
assert_eq!(layout.horizontal.unwrap().value, Some(30.));
assert_eq!(layout.vertical.unwrap().value, Some(20.));
s.execute("arrange.spacing", &json!({"ids":ids,"horizontal":12,"vertical":25})).unwrap();
assert_eq!(xy(&s, a), (100., 100.));
assert_eq!(xy(&s, b), (162., 100.));
assert_eq!(xy(&s, c), (100., 165.));
assert_eq!(xy(&s, d), (162., 165.));
assert_eq!(s.doc().unwrap().node(d).unwrap().width, 40.);
s.undo().unwrap();
assert_eq!(xy(&s, d), (180., 160.));
s.redo().unwrap();
assert_eq!(xy(&s, d), (162., 165.));
s.begin_interaction("Spacing").unwrap();
s.execute("arrange.spacing", &json!({"ids":ids,"horizontal":22,"fromGesture":true})).unwrap();
s.execute("arrange.spacing", &json!({"ids":ids,"vertical":35,"fromGesture":true})).unwrap();
assert_eq!(xy(&s, d), (172., 175.), "the unrequested axis keeps the earlier edit");
s.commit_interaction().unwrap();
}
#[test]
fn mixed_gaps_normalize_and_fractional_negative_gaps_are_supported() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 50., 40., None);
let b = rect(&mut s, 70., 0., 50., 40., None);
let c = rect(&mut s, 160., 0., 50., 40., None);
assert_eq!(selection_spacing(s.doc().unwrap(), &[a, b, c]).horizontal.unwrap().value, None);
s.execute("arrange.spacing", &json!({"ids":[a,b,c],"horizontal":-5.5})).unwrap();
assert_eq!(xy(&s, b), (44.5, 0.));
assert_eq!(xy(&s, c), (89., 0.));
assert_eq!(selection_spacing(s.doc().unwrap(), &[a, b, c]).horizontal.unwrap().value, Some(-5.5));
}
#[test]
fn a_scrub_uses_the_original_tracks_after_crossing_and_makes_one_undo_step() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 50., 40., None);
let b = rect(&mut s, 70., 0., 50., 40., None);
let c = rect(&mut s, 140., 0., 50., 40., None);
let before = s.undo_depth().0;
s.begin_interaction("Spacing").unwrap();
for v in [-100., 0., 30.] {
s.execute("arrange.spacing", &json!({"ids":[a,b,c],"horizontal":v,"fromGesture":true})).unwrap();
}
assert_eq!(xy(&s, b), (80., 0.));
assert_eq!(xy(&s, c), (160., 0.));
s.commit_interaction().unwrap();
assert_eq!(s.undo_depth().0, before + 1);
s.undo().unwrap();
assert_eq!(xy(&s, c), (140., 0.));
}
#[test]
fn unsafe_selections_and_invalid_axes_are_rejected_without_changes() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 50., 40., None);
let b = rect(&mut s, 70., 0., 50., 40., None);
for props in [json!({"locked":true}), json!({"visible":false})] {
s.execute("node.set", &json!({"id":b,"props":props})).unwrap();
assert!(s.execute("arrange.spacing", &json!({"ids":[a,b],"horizontal":5})).is_err());
s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap();
}
for args in [
json!({"ids":[a,b],"vertical":5}),
json!({"ids":[a,b],"horizontal":5,"vertical":5}),
json!({"ids":[a,b],"horizontal":"bad"}),
json!({"ids":[a,b],"horizontal":1e300}),
] {
assert!(s.execute("arrange.spacing", &args).is_err());
assert_eq!(xy(&s, b), (70., 0.));
}
let f = NodeId(
s.execute("frame.create", &json!({"x":0,"y":0,"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap(),
);
let x = rect(&mut s, 0., 0., 50., 40., Some(f));
let y = rect(&mut s, 70., 0., 50., 40., Some(f));
assert!(selection_spacing(s.doc().unwrap(), &[x, y]).horizontal.is_none());
assert!(selection_spacing(s.doc().unwrap(), &[a, x]).horizontal.is_none());
}
#[test]
fn rotated_parents_use_page_space_and_cancellation_stays_with_its_document() {
let mut s = Session::new();
let f = NodeId(
s.execute("frame.create", &json!({"x":100,"y":100,"width":300,"height":200,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(),
);
let a = rect(&mut s, 0., 0., 50., 40., Some(f));
let b = rect(&mut s, 70., 0., 50., 40., Some(f));
let before = xy(&s, b);
let uid = s.ds().unwrap().uid;
s.begin_interaction("Spacing").unwrap();
s.execute("arrange.spacing", &json!({"ids":[a,b],"vertical":40,"fromGesture":true})).unwrap();
assert!(((xy(&s, b).1 - xy(&s, a).1).abs() - 90.0).abs() < 1e-7, "50-high rotated rectangles must have a 40-point gap");
s.execute("file.new", &json!({})).unwrap();
s.begin_interaction("Unrelated").unwrap();
crate::selection_spacing::cancel_gesture(&mut s, uid);
assert!(s.ds().unwrap().interaction.is_some());
s.active = 0;
assert_eq!(xy(&s, b), before);
assert!(s.ds().unwrap().interaction.is_none());
}
+1
View File
@@ -1608,6 +1608,7 @@ status = "draft — needs native review"
"arrange.packH" = "Rozmístit těsně vodorovně"
"arrange.packV" = "Rozmístit těsně svisle"
"arrange.roundToPixels" = "Zaokrouhlit na pixely"
"arrange.spacing" = "Rozestupy"
"arrange.tidy" = "Uspořádat"
"bool.exclude" = "Vyloučit výběr"
"bool.flatten" = "Sloučit booleovskou skupinu do vektoru"
+1
View File
@@ -1546,6 +1546,7 @@ status = "draft — needs native review"
"arrange.packH" = "Horizontal zusammenschieben"
"arrange.packV" = "Vertikal zusammenschieben"
"arrange.roundToPixels" = "Auf Pixel runden"
"arrange.spacing" = "Abstand"
"arrange.tidy" = "Aufräumen"
"bool.exclude" = "Auswahl ausschließen"
"bool.flatten" = "Boolesche Gruppe zusammenfügen"
+1
View File
@@ -1621,6 +1621,7 @@ status = "draft — needs native review"
"arrange.packH" = "Agrupar en horizontal"
"arrange.packV" = "Agrupar en vertical"
"arrange.roundToPixels" = "Redondear a píxeles"
"arrange.spacing" = "Espaciado"
"arrange.tidy" = "Ordenar"
"bool.exclude" = "Excluir selección"
"bool.flatten" = "Acoplar booleana"
+1
View File
@@ -1546,6 +1546,7 @@ status = "draft — needs native review"
"arrange.packH" = "Tasser horizontalement"
"arrange.packV" = "Tasser verticalement"
"arrange.roundToPixels" = "Arrondir aux pixels"
"arrange.spacing" = "Espacement"
"arrange.tidy" = "Ranger"
"bool.exclude" = "Exclure la sélection"
"bool.flatten" = "Aplatir le booléen"
+1
View File
@@ -1612,6 +1612,7 @@ status = "draft — needs native review"
"arrange.packH" = "Rapatkan horizontal"
"arrange.packV" = "Rapatkan vertikal"
"arrange.roundToPixels" = "Bulatkan ke piksel"
"arrange.spacing" = "Spasi"
"arrange.tidy" = "Rapikan"
"bool.exclude" = "Kecualikan pilihan"
"bool.flatten" = "Satukan boolean"
+1
View File
@@ -1622,6 +1622,7 @@ status = "draft — needs native review"
"arrange.packH" = "水平方向に詰める"
"arrange.packV" = "垂直方向に詰める"
"arrange.roundToPixels" = "ピクセルに丸める"
"arrange.spacing" = "間隔"
"arrange.tidy" = "整列"
"bool.exclude" = "選択範囲を除外"
"bool.flatten" = "ブーリアンを平坦化"
+1
View File
@@ -1546,6 +1546,7 @@ status = "draft — needs native review"
"arrange.packH" = "가로로 붙이기"
"arrange.packV" = "세로로 붙이기"
"arrange.roundToPixels" = "픽셀에 맞춰 반올림"
"arrange.spacing" = "간격"
"arrange.tidy" = "정돈"
"bool.exclude" = "선택 항목 제외"
"bool.flatten" = "불리언 평면화"
+1
View File
@@ -1545,6 +1545,7 @@ status = "draft — needs native review"
"arrange.packH" = "Compactar na horizontal"
"arrange.packV" = "Compactar na vertical"
"arrange.roundToPixels" = "Arredondar para pixels"
"arrange.spacing" = "Espaçamento"
"arrange.tidy" = "Organizar"
"bool.exclude" = "Excluir sobreposição da seleção"
"bool.flatten" = "Nivelar booleano"
+1
View File
@@ -1609,6 +1609,7 @@ name = "Русский"
"arrange.packH" = "Уплотнить по горизонтали"
"arrange.packV" = "Уплотнить по вертикали"
"arrange.roundToPixels" = "Округлить до пикселей"
"arrange.spacing" = "Интервал"
"arrange.tidy" = "Упорядочить"
"bool.exclude" = "Исключить выделение"
"bool.flatten" = "Свести булеву группу"
+1
View File
@@ -1612,6 +1612,7 @@ name = "Türkçe"
"arrange.packH" = "Yatay sıkıştır"
"arrange.packV" = "Dikey sıkıştır"
"arrange.roundToPixels" = "Piksellere yuvarla"
"arrange.spacing" = "Aralık"
"arrange.tidy" = "Toparla"
"bool.exclude" = "Seçimi hariç tut"
"bool.flatten" = "Boolean'ı düzleştir"
+1
View File
@@ -1547,6 +1547,7 @@ status = "draft — needs native review"
"arrange.packH" = "水平紧凑排列"
"arrange.packV" = "垂直紧凑排列"
"arrange.roundToPixels" = "取整到像素"
"arrange.spacing" = "间距"
"arrange.tidy" = "整理"
"bool.exclude" = "排除所选项"
"bool.flatten" = "拼合布尔组"
+4
View File
@@ -248,6 +248,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
}
crate::dev_mode::draw(app, &painter);
crate::handles::draw(app, &painter);
crate::selection_spacing::draw(app, &painter);
crate::comments::draw(app, &painter);
crate::live_text::draw(app, &painter);
crate::live::draw(app, &painter, &ctx);
@@ -413,6 +414,9 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
let typing_elsewhere = crate::a11y::field_focused(&ctx);
app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
return false;
}
// Dropped files import onto the canvas.
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
for f in dropped {
+1
View File
@@ -62,6 +62,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
.response
.on_disabled_hover_text(from_main);
}
crate::selection_spacing::panel(app, ui, doc, sel);
let grid_parent = sel.iter().all(|i| parent_of(doc, *i).and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Grid));
if grid_parent && common(doc, sel, |n| n.child_layout.absolute) == Some(false) {
ui.add_enabled_ui(!nested, |ui| grid_cell(app, ui, doc, sel)).response.on_disabled_hover_text(from_main);
+5
View File
@@ -49,6 +49,7 @@ pub mod render_worker;
pub mod right;
pub mod scale_panel;
pub mod section_panel;
mod selection_spacing;
pub mod shortcuts;
pub mod shortcuts_panel;
pub mod style_picker;
@@ -1240,6 +1241,7 @@ impl FigApp {
// Control requests run first so their results reflect this frame.
control::drain(self, &ctx);
windows::requests(self, &ctx);
selection_spacing::sync(self, &ctx);
// Before any widget: the navigation keys (F6, Tab, arrows, Escape) move the keyboard.
a11y::begin(self, &ctx);
if self.ui.title_bar == TitleBar::Custom {
@@ -1466,3 +1468,6 @@ impl FigApp {
})
}
}
#[cfg(test)]
mod selection_spacing_tests;
+228
View File
@@ -0,0 +1,228 @@
//! Smart-selection fields and handles. Geometry and document edits belong to the engine.
use egui::{Color32, CornerRadius, CursorIcon, Id, Pos2, Rect, Stroke, Ui, vec2};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::selection_spacing::{Axis, selection_spacing};
use serde_json::json;
use crate::FigApp;
use crate::i18n::tr;
use crate::icons::Icon;
use crate::theme::semibold;
use crate::widgets::{self, Lead};
const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C);
fn drag_id() -> Id {
Id::new("selection_spacing_drag")
}
#[derive(Clone)]
struct Drag {
ids: Vec<NodeId>,
axis: Axis,
start: Pos2,
value: f64,
uid: u64,
page: NodeId,
moved: bool,
}
pub fn panel(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]) {
let layout = selection_spacing(doc, ids);
if layout.horizontal.is_none() && layout.vertical.is_none() {
return;
}
widgets::sublabels(ui, tr("Spacing"), None);
let col = widgets::col_width(ui.available_width());
crate::right::row(ui, 32.0, |ui| {
for (axis, key, icon, tip) in [
(&layout.horizontal, "horizontal", Icon::DistributeH, tr("Horizontal spacing")),
(&layout.vertical, "vertical", Icon::DistributeV, tr("Vertical spacing")),
] {
let Some(axis) = axis else { continue };
let edit = widgets::number_field(ui, Id::new(("selection_spacing", key)), Lead::Icon(icon, tip), axis.value, "", col, 1.0);
app.field_run(edit, "arrange.spacing", |v| json!({"ids":ids,key:v.clamp(-1e6,1e6),"fromGesture":true}));
}
});
}
#[derive(Clone)]
struct Handle {
value: f64,
center: Pos2,
hit: Rect,
}
fn bounds(doc: &Document, ids: &[NodeId]) -> Option<figcraft_geom::Rect> {
ids.iter().filter_map(|id| doc.world_bounds(*id)).reduce(|a, b| a.union(b))
}
fn handles(app: &FigApp, axis: &Axis) -> Vec<Handle> {
let Ok(doc) = app.session.doc() else { return vec![] };
let cross = |b: figcraft_geom::Rect| if axis.vertical { (b.x0, b.x1) } else { (b.y0, b.y1) };
let mut out = Vec::new();
for pair in axis.tracks.windows(2) {
let Some((a, b)) = pair.first().zip(pair.get(1)) else { continue };
let (Some(ab), Some(bb)) = (bounds(doc, &a.ids), bounds(doc, &b.ids)) else { continue };
let gap = axis.start(bb) - axis.end(ab);
let middle = (axis.start(bb) + axis.end(ab)) * 0.5;
// Intersect neighbouring tracks' cross-axis spans in a sweep: one handle per row/cell,
// without comparing every object in a large column to every object in the next one.
let mut aa: Vec<_> = a.ids.iter().filter_map(|id| doc.world_bounds(*id)).map(cross).collect();
let mut bs: Vec<_> = b.ids.iter().filter_map(|id| doc.world_bounds(*id)).map(cross).collect();
aa.sort_by(|a, b| a.0.total_cmp(&b.0));
bs.sort_by(|a, b| a.0.total_cmp(&b.0));
let (mut i, mut j) = (0, 0);
while let (Some(a), Some(b)) = (aa.get(i), bs.get(j)) {
let (lo, hi) = (a.0.max(b.0), a.1.min(b.1));
if hi > lo {
let c = (lo + hi) * 0.5;
let center = if axis.vertical { app.to_screen(c, middle) } else { app.to_screen(middle, c) };
let size = if axis.vertical { vec2(24.0, 10.0) } else { vec2(10.0, 24.0) };
out.push(Handle { value: gap, center, hit: Rect::from_center_size(center, size) });
}
if a.1 <= b.1 {
i += 1;
} else {
j += 1;
}
}
}
out
}
fn available(app: &FigApp) -> bool {
app.ui.right_tab == crate::RightTab::Design
&& app.ui.picker.is_none()
&& app.session.tool.tool == figcraft_engine::ToolId::Move
&& !app.session.tool.busy()
&& app.session.ds().is_ok_and(|d| d.text_edit.is_none() && d.vector_edit.is_none())
}
pub fn cancel(app: &mut FigApp, ctx: &egui::Context) -> bool {
let drag = ctx.data(|d| d.get_temp::<Drag>(drag_id()));
if let Some(drag) = &drag {
figcraft_engine::selection_spacing::cancel_gesture(&mut app.session, drag.uid);
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
}
drag.is_some()
}
/// Retire handles when another document, page, tool or editing context takes over.
pub fn sync(app: &mut FigApp, ctx: &egui::Context) {
if let Some(drag) = ctx.data(|d| d.get_temp::<Drag>(drag_id())) {
let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids);
if !same || !available(app) {
cancel(app, ctx);
}
}
}
/// Called before canvas pointer dispatch, also when the pointer leaves the canvas/window.
pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option<Pos2>, hovered: bool, space: bool) -> bool {
let dragging = ctx.data(|d| d.get_temp::<Drag>(drag_id()));
if let Some(mut drag) = dragging {
let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids);
if !same {
cancel(app, ctx);
return true;
}
let down = ctx.input(|i| i.pointer.primary_down());
if let Some(p) = pos {
let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6);
let distance = if drag.axis.vertical { p.y - drag.start.y } else { p.x - drag.start.x };
drag.moved |= distance.abs() >= 3.0;
if drag.moved {
let value = ctx
.input(|i| {
let quantum = if i.modifiers.alt {
0.1
} else if i.modifiers.shift {
10.0
} else {
1.0
};
((drag.value + f64::from(distance) / zoom) / quantum).round() * quantum
})
.clamp(-1e6, 1e6);
let key = if drag.axis.vertical { "vertical" } else { "horizontal" };
if let Err(e) = app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) {
app.toast(e.to_string());
}
}
}
ctx.set_cursor_icon(if drag.axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal });
if !down {
let _ = app.session.commit_interaction();
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
} else {
ctx.data_mut(|d| d.insert_temp(drag_id(), drag));
}
return true;
}
if !hovered || space || !available(app) || ctx.input(|i| i.modifiers.command) {
return false;
}
let Some(p) = pos else { return false };
let ids = app.session.selection();
let layout = app.session.doc().map(|d| selection_spacing(d, &ids)).unwrap_or_default();
for axis in [layout.horizontal, layout.vertical].into_iter().flatten() {
for h in handles(app, &axis) {
if !h.hit.contains(p) {
continue;
}
ctx.set_cursor_icon(if axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal });
if ctx.input(|i| i.pointer.primary_pressed())
&& app.session.begin_interaction(tr("Spacing")).is_ok()
&& let Ok(ds) = app.session.ds()
{
let drag = Drag { ids, axis, start: p, value: h.value, uid: ds.uid, page: ds.page, moved: false };
app.ui.layers_focus = false;
app.ui.key_box = None;
ctx.memory_mut(|m| {
if let Some(f) = m.focused() {
m.surrender_focus(f);
}
});
ctx.data_mut(|d| d.insert_temp(drag_id(), drag));
}
return true;
}
}
false
}
pub fn draw(app: &FigApp, painter: &egui::Painter) {
let ctx = painter.ctx();
let drag = ctx.data(|d| d.get_temp::<Drag>(drag_id()));
if !available(app) && drag.is_none() {
return;
}
let Ok(ds) = app.session.ds() else { return };
let hover = ctx.input(|i| i.pointer.hover_pos());
let Some(b) = bounds(&ds.doc, &ds.selection) else { return };
let rect = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand(12.0);
if drag.is_none() && !hover.is_some_and(|p| rect.contains(p)) {
return;
}
let axes = if let Some(d) = &drag {
vec![d.axis.clone()]
} else {
let layout = selection_spacing(&ds.doc, &ds.selection);
[layout.horizontal, layout.vertical].into_iter().flatten().collect()
};
for axis in axes {
for h in handles(app, &axis) {
let hot = hover.is_some_and(|p| h.hit.contains(p)) || drag.is_some();
let by = if axis.vertical { vec2(10.0, 0.0) } else { vec2(0.0, 10.0) };
painter.line_segment([h.center - by, h.center + by], Stroke::new(if hot { 3.0 } else { 2.0 }, PINK));
if hot {
let text = widgets::fmt_num(h.value);
let galley = painter.layout_no_wrap(text, semibold(10.0), Color32::WHITE);
let at = h.center + if axis.vertical { vec2(18.0, 0.0) } else { vec2(0.0, -22.0) };
let r = Rect::from_center_size(at, galley.size() + vec2(8.0, 4.0));
painter.rect_filled(r, CornerRadius::same(3), PINK);
painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE);
}
}
}
}
@@ -0,0 +1,188 @@
//! Real field and canvas gestures for #402; distances are checked in the engine document.
use crate::layers_drag_tests::Ui;
use egui::{Event, Id, Key, Modifiers, Pos2, vec2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
fn scene(grid: bool) -> (Ui, Vec<NodeId>) {
let mut s = Session::new();
let mut ids = vec![];
for row in 0..if grid { 2 } else { 1 } {
for col in 0..3 {
let id = NodeId(
s.execute("shape.rectangle", &json!({"x":100+col*80,"y":100+row*100,"width":50,"height":60})).unwrap()["id"].as_u64().unwrap(),
);
ids.push(id);
}
}
s.execute("select.set", &json!({"ids":ids})).unwrap();
let u = Ui::new(s);
(u, ids)
}
fn xy(u: &Ui, id: NodeId) -> (f64, f64) {
let b = u.app.session.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0)
}
fn key(u: &mut Ui, k: Key) {
u.frame(vec![
Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE },
Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
]);
u.frame(vec![]);
}
fn drag(u: &mut Ui, p: Pos2, by: egui::Vec2, release: bool) {
u.frame(vec![Event::PointerMoved(p)]);
u.button(p, true);
for i in 1..=4 {
u.frame(vec![Event::PointerMoved(p + by * i as f32 / 4.)]);
}
if release {
u.button(p + by, false);
u.frame(vec![]);
}
}
#[test]
fn spacing_fields_type_scrub_and_keep_one_undo_step() {
let (mut u, ids) = scene(true);
let field = Id::new(("selection_spacing", "horizontal"));
let p = u.ctx.read_response(field.with("field")).unwrap().rect.center();
let history = u.app.session.undo_depth().0;
u.button(p, true);
u.button(p, false);
u.frame(vec![]);
u.frame(vec![Event::Text("45.5".into())]);
key(&mut u, Key::Enter);
assert_eq!(xy(&u, ids[1]), (195.5, 100.));
assert_eq!(xy(&u, ids[4]), (195.5, 200.));
assert_eq!(u.app.session.undo_depth().0, history + 1);
u.app.run("edit.undo", json!({}));
u.frame(vec![]);
let lead = u.ctx.read_response(field.with("lead")).unwrap().rect.center();
drag(&mut u, lead, vec2(16., 0.), true);
assert_eq!(xy(&u, ids[1]), (196., 100.));
assert_eq!(u.app.session.undo_depth().0, history + 1);
u.app.run("edit.undo", json!({}));
assert_eq!(xy(&u, ids[1]), (180., 100.));
}
#[test]
fn canvas_gap_drag_keeps_selection_and_undo_redo_for_both_grid_axes() {
for (vertical, point) in [(false, (165., 130.)), (true, (125., 180.))] {
let (mut u, ids) = scene(true);
let before = u.app.session.undo_depth().0;
let p = u.app.to_screen(point.0, point.1);
let output = u.frame_output(vec![Event::PointerMoved(p)]);
assert_eq!(output.platform_output.cursor_icon, if vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
let z = u.app.session.ds().unwrap().view.zoom as f32;
drag(&mut u, p, if vertical { vec2(0., 20. * z) } else { vec2(20. * z, 0.) }, true);
assert_eq!(u.app.session.selection(), ids);
assert_eq!(xy(&u, ids[1]), if vertical { (180., 100.) } else { (200., 100.) });
assert_eq!(xy(&u, ids[3]), if vertical { (100., 220.) } else { (100., 200.) });
assert_eq!(u.app.session.undo_depth().0, before + 1);
u.app.run("edit.undo", json!({}));
assert_eq!(xy(&u, ids[4]), (180., 200.));
u.app.run("edit.redo", json!({}));
assert_eq!(xy(&u, ids[4]), if vertical { (180., 220.) } else { (200., 200.) });
}
}
#[test]
fn escape_rolls_back_and_an_outside_release_finishes_the_gesture() {
let (mut u, ids) = scene(false);
let before = u.app.session.undo_depth().0;
// A canvas handle takes the keyboard from a previously focused panel field.
let field = u.ctx.read_response(Id::new(("selection_spacing", "horizontal")).with("field")).unwrap().rect.center();
u.button(field, true);
u.button(field, false);
u.frame(vec![]);
let p = u.app.to_screen(165., 130.);
let z = u.app.session.ds().unwrap().view.zoom as f32;
drag(&mut u, p, vec2(20. * z, 0.), false);
assert_eq!(xy(&u, ids[1]), (200., 100.));
key(&mut u, Key::Escape);
u.button(p + vec2(20. * z, 0.), false);
u.frame(vec![]);
assert_eq!(xy(&u, ids[1]), (180., 100.));
assert_eq!(u.app.session.undo_depth().0, before);
assert_eq!(u.app.session.selection(), ids);
drag(&mut u, p, vec2(20. * z, 0.), false);
// Leave the window while held; releasing with no pointer position still commits once.
u.frame(vec![Event::PointerGone]);
u.frame(vec![Event::PointerButton {
pos: Pos2::new(-1., -1.),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Modifiers::NONE,
}]);
u.frame(vec![]);
assert_eq!(u.app.session.undo_depth().0, before + 1);
assert!(u.app.session.ds().unwrap().interaction.is_none());
}
#[test]
fn switching_documents_cancels_only_the_spacing_documents_gesture() {
let (mut u, ids) = scene(false);
let p = u.app.to_screen(165., 130.);
let z = u.app.session.ds().unwrap().view.zoom as f32;
drag(&mut u, p, vec2(20. * z, 0.), false);
u.app.run("file.new", json!({}));
u.app.session.begin_interaction("Unrelated").unwrap();
u.frame(vec![]);
assert!(u.app.session.ds().unwrap().interaction.is_some());
u.app.session.active = 0;
assert_eq!(xy(&u, ids[1]), (180., 100.));
assert!(u.app.session.ds().unwrap().interaction.is_none());
}
#[test]
fn handle_click_is_a_noop_and_modifiers_can_be_held_before_pressing() {
let (mut u, ids) = scene(false);
let p = u.app.to_screen(165., 130.);
let before = u.app.session.undo_depth().0;
u.frame(vec![Event::PointerMoved(p)]);
u.button(p, true);
u.button(p, false);
u.frame(vec![]);
assert_eq!(u.app.session.undo_depth().0, before);
for (modifiers, delta, gap) in [(Modifiers::SHIFT, 17., 50.), (Modifiers::ALT, 17.25, 47.3)] {
let (mut u, ids) = scene(false);
let p = u.app.to_screen(165., 130.);
let z = u.app.session.ds().unwrap().view.zoom as f32;
u.frame_with(vec![Event::PointerMoved(p)], modifiers);
u.frame_with(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }], modifiers);
for k in 1..=4 {
u.frame_with(vec![Event::PointerMoved(p + vec2(delta * z * k as f32 / 4., 0.))], modifiers);
}
u.frame_with(
vec![Event::PointerButton { pos: p + vec2(delta * z, 0.), button: egui::PointerButton::Primary, pressed: false, modifiers }],
modifiers,
);
u.frame(vec![]);
assert!((xy(&u, ids[1]).0 - 150. - gap).abs() < 1e-6);
}
assert_eq!(xy(&u, ids[1]), (180., 100.));
}
#[test]
fn undo_while_dragging_cancels_the_gesture_without_undoing_an_earlier_edit() {
let (mut u, ids) = scene(false);
let before = u.app.session.undo_depth().0;
let p = u.app.to_screen(165., 130.);
let z = u.app.session.ds().unwrap().view.zoom as f32;
drag(&mut u, p, vec2(20. * z, 0.), false);
let m = Modifiers::COMMAND;
u.frame_with(
vec![
Event::Key { key: Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: m },
Event::Key { key: Key::Z, physical_key: None, pressed: false, repeat: false, modifiers: m },
],
m,
);
u.button(p + vec2(20. * z, 0.), false);
u.frame(vec![]);
assert_eq!(u.app.session.undo_depth().0, before);
assert_eq!(xy(&u, ids[1]), (180., 100.));
assert_eq!(u.app.session.selection(), ids);
}
+6
View File
@@ -154,6 +154,9 @@ pub fn run_binding(app: &mut FigApp, id: &str) {
}
fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool {
if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) {
return true;
}
if key_box(app, key, m) {
return true;
}
@@ -177,6 +180,9 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
return true;
}
if !repeat || matches!(b.id, "edit.undo" | "edit.redo" | "view.zoomIn" | "view.zoomOut") {
if matches!(b.id, "edit.undo" | "edit.redo") && crate::selection_spacing::cancel(app, ctx) {
return true;
}
run_binding(app, b.id);
}
return true;
+12
View File
@@ -1514,6 +1514,18 @@ the instance's text layer id with `get_node`):
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"type_text","arguments":{"id":7,"text":"Grace Hopper"}}}
```
Smart-selection spacing (movable siblings in rows, columns or aligned grids):
```json
{"jsonrpc":"2.0","id":"spacing","method":"tools/call","params":{"name":"run_command","arguments":{"command":"arrange.spacing","params":{"ids":[3,4,5],"horizontal":24}}}}
```
`horizontal` and `vertical` are optional page-unit gaps (at least one is required, magnitude at
most 1,000,000). The first column/row stays fixed; subsequent tracks move without changing sizes,
parents or offsets inside each track. Nonuniform gaps become uniform. Normal auto-layout flow
children, locked/hidden layers and instance internals are excluded. Interactive callers may pass
`fromGesture: true` inside an interaction to keep using its original tracks during a scrub.
Align and distribute as one undo step:
```json
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Smart-selection spacing fields and on-canvas gap handles (#402); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Smart-selection spacing (#402): Layout fields and pink on-canvas handles adjust horizontal/vertical gaps for movable sibling rows, columns and aligned grids through `arrange.spacing`; mixed gaps normalize, track contents and sizes stay intact, one Undo step per scrub/drag, Escape cancels. Auto-layout flow children and locked/instance-internal layers are excluded. Numbers unchanged. |
| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. |
| 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) |
| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. |
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Smart-selection spacing fields and on-canvas gap handles (#402); the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -63,6 +63,7 @@ interaction feel not yet compared.
| Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing |
| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap |
| Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) |
| Smart-selection spacing: numeric fields and canvas handles | done | Layout shows Horizontal/Vertical spacing for movable sibling rows, columns and aligned grids (up to 2,000 layers). Gaps are between track bounds: moving a column/row preserves its internal offsets and sizes. Mixed gaps can be normalized by typing or dragging a handle. Pink gap handles show on hover, report live distances, respect zoom and commit one Undo step; Shift rounds to 10, Alt to 0.1, Escape cancels, release outside the canvas finishes. Parent/child or different-parent selections, auto-layout flow children, locked/hidden layers and instance internals have no spacing controls. This adds gap editing only, not the smart-selection reorder rings (`selection_spacing` engine/UI tests). |
| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | one field widget for every panel (`widgets::number_field`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's |
| Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation |
| Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection |
@@ -164,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Smart-selection spacing (#402): Layout fields and pink on-canvas handles adjust horizontal/vertical gaps for movable sibling rows, columns and aligned grids through `arrange.spacing`; mixed gaps normalize, track contents and sizes stay intact, one Undo step per scrub/drag, Escape cancels. Auto-layout flow children and locked/instance-internal layers are excluded. Numbers unchanged. |
| 2026-10-14 | minor | Type settings: the Small caps button draws its sample as a capital and a smaller capital, no longer as a box where the UI font lacks the small-capital letter (egui's default fonts) |
| 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu |
| 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) |