Merge fix/595

# Conflicts:
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/lib.rs
This commit is contained in:
bflatastic
2026-10-17 20:23:42 +07:00
24 changed files with 897 additions and 10 deletions
+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
@@ -44,6 +44,7 @@ pub mod proto_run;
pub mod rename;
pub mod rotation_origin;
pub mod selection_colors;
pub mod selection_spacing;
pub mod shape_handles;
pub mod shapebuilder;
pub mod snap;
@@ -1801,3 +1802,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
@@ -1689,6 +1689,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
@@ -1636,6 +1636,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
@@ -1718,6 +1718,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
@@ -1627,6 +1627,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
@@ -1693,6 +1693,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
@@ -1703,6 +1703,7 @@ status = "draft — needs native review"
"arrange.packH" = "水平方向に詰める"
"arrange.packV" = "垂直方向に詰める"
"arrange.roundToPixels" = "ピクセルに丸める"
"arrange.spacing" = "間隔"
"arrange.tidy" = "整列"
"bool.exclude" = "選択範囲を除外"
"bool.flatten" = "ブーリアンを平坦化"
+1
View File
@@ -1627,6 +1627,7 @@ status = "draft — needs native review"
"arrange.packH" = "가로로 붙이기"
"arrange.packV" = "세로로 붙이기"
"arrange.roundToPixels" = "픽셀에 맞춰 반올림"
"arrange.spacing" = "간격"
"arrange.tidy" = "정돈"
"bool.exclude" = "선택 항목 제외"
"bool.flatten" = "불리언 평면화"
+1
View File
@@ -1626,6 +1626,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
@@ -1691,6 +1691,7 @@ name = "Русский"
"arrange.packH" = "Уплотнить по горизонтали"
"arrange.packV" = "Уплотнить по вертикали"
"arrange.roundToPixels" = "Округлить до пикселей"
"arrange.spacing" = "Интервал"
"arrange.tidy" = "Упорядочить"
"bool.exclude" = "Исключить выделение"
"bool.flatten" = "Свести булеву группу"
+1
View File
@@ -1711,6 +1711,7 @@ name = "Svenska"
"arrange.packH" = "Packa horisontellt"
"arrange.packV" = "Packa vertikalt"
"arrange.roundToPixels" = "Avrunda till pixlar"
"arrange.spacing" = "Avstånd"
"arrange.tidy" = "Snygga till"
"bool.exclude" = "Uteslut markering"
"bool.flatten" = "Platta till boolesk form"
+1
View File
@@ -1693,6 +1693,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
@@ -1628,6 +1628,7 @@ status = "draft — needs native review"
"arrange.packH" = "水平紧凑排列"
"arrange.packV" = "垂直紧凑排列"
"arrange.roundToPixels" = "取整到像素"
"arrange.spacing" = "间距"
"arrange.tidy" = "整理"
"bool.exclude" = "排除所选项"
"bool.flatten" = "拼合布尔组"
+4
View File
@@ -252,6 +252,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);
@@ -496,6 +497,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, behind a card while they do ([`crate::pending_open`]).
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
for f in dropped {
+10 -10
View File
@@ -14,7 +14,14 @@ use crate::colorpicker::PickerTarget;
use crate::theme::{ACCENT, semibold};
use crate::{FigApp, RightTab};
const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C);
pub(crate) const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C);
pub(crate) fn pink_label(painter: &egui::Painter, label: String, center: Pos2) {
let galley = painter.layout_no_wrap(label, semibold(10.0), Color32::WHITE);
let rect = Rect::from_center_size(center, galley.size() + vec2(8.0, 4.0));
painter.rect_filled(rect, CornerRadius::same(3), PINK);
painter.galley(rect.min + vec2(4.0, 2.0), galley, Color32::WHITE);
}
#[derive(Clone, Debug)]
enum Drag {
@@ -338,11 +345,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
let hot = hover.is_some_and(|h| r.contains(h)) || active_side == Some(*side);
if hot {
painter.rect_filled(*r, CornerRadius::ZERO, PINK.gamma_multiply(0.25));
let label = crate::canvas::fmt(*v);
let galley = painter.layout_no_wrap(label, semibold(10.0), Color32::WHITE);
let lr = Rect::from_center_size(r.center(), galley.size() + vec2(8.0, 4.0));
painter.rect_filled(lr, CornerRadius::same(3), PINK);
painter.galley(lr.min + vec2(4.0, 2.0), galley, Color32::WHITE);
pink_label(painter, crate::canvas::fmt(*v), r.center());
}
}
let drag_key = match &dragging {
@@ -356,10 +359,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
painter.rect_filled(r, CornerRadius::ZERO, PINK.gamma_multiply(0.3));
let c = r.center();
painter.line_segment([pos2(c.x - 3.0, c.y), pos2(c.x + 3.0, c.y)], Stroke::new(1.0, PINK));
let galley = painter.layout_no_wrap(crate::canvas::fmt(g.value), semibold(10.0), Color32::WHITE);
let lr = Rect::from_center_size(c, galley.size() + vec2(8.0, 4.0));
painter.rect_filled(lr, CornerRadius::same(3), PINK);
painter.galley(lr.min + vec2(4.0, 2.0), galley, Color32::WHITE);
pink_label(painter, crate::canvas::fmt(g.value), c);
}
}
let _ = Align2::CENTER_CENTER;
+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, 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
@@ -52,6 +52,7 @@ pub mod right;
pub mod scale_panel;
pub mod section_panel;
pub mod selection_colors;
mod selection_spacing;
pub mod shortcuts;
pub mod shortcuts_panel;
pub mod style_picker;
@@ -1393,6 +1394,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 {
@@ -1680,5 +1682,8 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) {
ctx.request_repaint();
}
#[cfg(test)]
mod selection_spacing_tests;
#[cfg(test)]
mod typography_scrub_tests;
+324
View File
@@ -0,0 +1,324 @@
//! Smart-selection fields and handles. Geometry and document edits belong to the engine.
use std::sync::Arc;
use egui::{CursorIcon, Id, Pos2, Rect, Stroke, Ui, vec2};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::selection_spacing::{Axis, SelectionSpacing, selection_spacing};
use serde_json::json;
use crate::FigApp;
use crate::handles::{PINK, pink_label};
use crate::i18n::tr;
use crate::icons::Icon;
use crate::widgets::{self, Lead};
#[derive(Clone)]
struct LayoutCache {
document: u64,
page: NodeId,
doc: Arc<Document>,
ids: Vec<NodeId>,
frame: u64,
layout: Arc<SelectionSpacing>,
}
fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc<SelectionSpacing> {
let Ok(ds) = app.session.ds() else { return Arc::new(SelectionSpacing::default()) };
let key = Id::new("selection_spacing_layout");
let frame = ctx.cumulative_frame_nr();
if let Some(mut cache) = ctx.data(|d| d.get_temp::<LayoutCache>(key))
&& cache.document == ds.uid
&& cache.page == ds.page
&& cache.ids == ids
&& (cache.frame == frame || Arc::ptr_eq(&cache.doc, &ds.doc))
{
if cache.frame != frame {
cache.frame = frame;
ctx.data_mut(|d| d.insert_temp(key, cache.clone()));
}
return cache.layout;
}
// Panel, input and drawing share one calculation per frame. If a command changes the
// document during this frame, refresh next frame; unchanged snapshots also reuse it then.
let layout = Arc::new(selection_spacing(&ds.doc, ids));
ctx.data_mut(|d| {
d.insert_temp(key, LayoutCache { document: ds.uid, page: ds.page, doc: ds.doc.clone(), ids: ids.to_vec(), frame, layout: layout.clone() });
});
layout
}
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,
last_value: Option<f64>,
}
pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) {
let layout = layout(app, ui.ctx(), 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 drag.last_value != Some(value) {
match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) {
Ok(_) => drag.last_value = Some(value),
Err(e) => 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 = layout(app, ctx, &ids);
for axis in [layout.horizontal.as_ref(), layout.vertical.as_ref()].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: axis.clone(), start: p, value: h.value, uid: ds.uid, page: ds.page, moved: false, last_value: axis.value };
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 = layout(app, ctx, &ds.selection);
[layout.horizontal.clone(), layout.vertical.clone()].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 at = h.center + if axis.vertical { vec2(18.0, 0.0) } else { vec2(0.0, -22.0) };
pink_label(painter, widgets::fmt_num(h.value), at);
}
}
}
}
#[cfg(test)]
mod cache_tests {
use super::*;
use crate::NoServices;
use figcraft_engine::Session;
fn setup() -> (FigApp, egui::Context, Vec<NodeId>) {
let mut session = Session::new();
let mut ids = Vec::new();
for x in [0, 80, 160] {
ids.push(NodeId(session.execute("shape.rectangle", &json!({"x":x,"width":50,"height":50})).unwrap()["id"].as_u64().unwrap()));
}
session.execute("select.set", &json!({"ids":ids})).unwrap();
(FigApp::new(session, Box::new(NoServices)), egui::Context::default(), ids)
}
#[test]
fn layout_is_shared_within_a_frame_and_refreshed_after_a_document_edit() {
let (mut app, ctx, ids) = setup();
let mut cached = None;
let mut output = ctx.run_ui(Default::default(), |_| {
let first = layout(&app, &ctx, &ids);
assert_eq!(first.horizontal.as_ref().unwrap().value, Some(30.0));
assert!(Arc::ptr_eq(&first, &layout(&app, &ctx, &ids)));
cached = Some(first);
});
output.textures_delta.clear();
let mut output = ctx.run_ui(Default::default(), |_| {
let first = layout(&app, &ctx, &ids);
assert!(Arc::ptr_eq(cached.as_ref().unwrap(), &first), "unchanged frames reuse the calculation");
app.set_props(&[ids[1]], json!({"x":90}));
assert!(Arc::ptr_eq(&first, &layout(&app, &ctx, &ids)), "a mid-frame edit waits until the next frame");
});
output.textures_delta.clear();
let mut output = ctx.run_ui(Default::default(), |_| {
let next = layout(&app, &ctx, &ids);
assert!(!Arc::ptr_eq(cached.as_ref().unwrap(), &next));
assert_eq!(next.horizontal.as_ref().unwrap().value, None);
assert!(Arc::ptr_eq(&next, &layout(&app, &ctx, &ids)));
});
output.textures_delta.clear();
}
#[test]
fn a_selection_or_document_switch_invalidates_the_layout() {
let (mut app, ctx, ids) = setup();
let mut output = ctx.run_ui(Default::default(), |_| {
let all = layout(&app, &ctx, &ids);
let subset = layout(&app, &ctx, &ids[..2]);
assert!(!Arc::ptr_eq(&all, &subset));
assert_eq!(subset.horizontal.as_ref().unwrap().tracks.len(), 2);
app.session.execute("file.new", &json!({})).unwrap();
let other = layout(&app, &ctx, &ids[..2]);
assert!(!Arc::ptr_eq(&subset, &other));
assert!(other.horizontal.is_none() && other.vertical.is_none());
});
output.textures_delta.clear();
}
}
@@ -0,0 +1,207 @@
//! 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);
}
#[test]
fn stationary_gap_drag_does_not_change_the_document_again() {
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);
assert_eq!(xy(&u, ids[1]), (200., 100.));
let revision = u.app.session.ds().unwrap().revision;
let doc = u.app.session.ds().unwrap().doc.clone();
for _ in 0..5 {
u.frame(vec![]);
u.frame(vec![Event::PointerMoved(p + vec2(20.2 * z, 0.))]);
}
assert_eq!(u.app.session.ds().unwrap().revision, revision);
assert!(std::sync::Arc::ptr_eq(&doc, &u.app.session.ds().unwrap().doc));
u.button(p + vec2(20.2 * z, 0.), false);
assert_eq!(xy(&u, ids[1]), (200., 100.));
}
+6
View File
@@ -228,6 +228,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;
}
@@ -251,6 +254,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
@@ -1543,6 +1543,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