diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 43cfcf3c..ad6c21d1 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -19,6 +19,13 @@ pub fn specs() -> Vec { 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 { } ok() } + +/// Set the distances between smart-selection tracks without rebuilding/reparenting the layers. +fn spacing(s: &mut Session, p: &Value) -> Result { + 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::, Option)>::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() +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 245e08f6..93da1907 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/selection_spacing.rs b/crates/engine/src/selection_spacing.rs new file mode 100644 index 00000000..8592a453 --- /dev/null +++ b/crates/engine/src/selection_spacing.rs @@ -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, + pub bounds: Rect, + anchor: Rect, +} + +#[derive(Clone, Debug)] +pub struct Axis { + pub tracks: Vec, + pub vertical: bool, + /// None means the existing distances are mixed; setting a value makes them uniform. + pub value: Option, +} + +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, + pub vertical: Option, +} + +/// 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 { + 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 = 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 = 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(); + } +} diff --git a/crates/engine/src/selection_spacing_tests.rs b/crates/engine/src/selection_spacing_tests.rs new file mode 100644 index 00000000..ac36f3c3 --- /dev/null +++ b/crates/engine/src/selection_spacing_tests.rs @@ -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(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()); +} diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index cc0e66fd..12e403c6 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -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" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index ba9c90bd..e05690e3 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -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" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index e4d7595e..fa7b9006 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -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" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 6edfbd03..d07ad1da 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -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" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 99d6cff4..4262eae2 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -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" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 788ed68b..59190fd7 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1703,6 +1703,7 @@ status = "draft — needs native review" "arrange.packH" = "水平方向に詰める" "arrange.packV" = "垂直方向に詰める" "arrange.roundToPixels" = "ピクセルに丸める" +"arrange.spacing" = "間隔" "arrange.tidy" = "整列" "bool.exclude" = "選択範囲を除外" "bool.flatten" = "ブーリアンを平坦化" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index a6bfbf6e..37ad8548 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1627,6 +1627,7 @@ status = "draft — needs native review" "arrange.packH" = "가로로 붙이기" "arrange.packV" = "세로로 붙이기" "arrange.roundToPixels" = "픽셀에 맞춰 반올림" +"arrange.spacing" = "간격" "arrange.tidy" = "정돈" "bool.exclude" = "선택 항목 제외" "bool.flatten" = "불리언 평면화" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 75bbb301..18d947c2 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -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" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 782aa225..ac349add 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1691,6 +1691,7 @@ name = "Русский" "arrange.packH" = "Уплотнить по горизонтали" "arrange.packV" = "Уплотнить по вертикали" "arrange.roundToPixels" = "Округлить до пикселей" +"arrange.spacing" = "Интервал" "arrange.tidy" = "Упорядочить" "bool.exclude" = "Исключить выделение" "bool.flatten" = "Свести булеву группу" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f751ecb5..0c4792af 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -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" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 788d7142..80d85d70 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -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" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 3434832d..9ea4cc96 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1628,6 +1628,7 @@ status = "draft — needs native review" "arrange.packH" = "水平紧凑排列" "arrange.packV" = "垂直紧凑排列" "arrange.roundToPixels" = "取整到像素" +"arrange.spacing" = "间距" "arrange.tidy" = "整理" "bool.exclude" = "排除所选项" "bool.flatten" = "拼合布尔组" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8e399fa8..bc184270 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 { diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index 282e351f..ca38bee7 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -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; diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 9d3c0dcc..11d30369 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 80065b11..b2f27760 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs new file mode 100644 index 00000000..5110be1c --- /dev/null +++ b/crates/ui-egui/src/selection_spacing.rs @@ -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, + ids: Vec, + frame: u64, + layout: Arc, +} + +fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc { + 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::(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, + axis: Axis, + start: Pos2, + value: f64, + uid: u64, + page: NodeId, + moved: bool, + last_value: Option, +} + +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 { + ids.iter().filter_map(|id| doc.world_bounds(*id)).reduce(|a, b| a.union(b)) +} + +fn handles(app: &FigApp, axis: &Axis) -> Vec { + 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_id())); + if let Some(drag) = &drag { + figcraft_engine::selection_spacing::cancel_gesture(&mut app.session, drag.uid); + ctx.data_mut(|d| d.remove::(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_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, hovered: bool, space: bool) -> bool { + let dragging = ctx.data(|d| d.get_temp::(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_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_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) { + 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(); + } +} diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs new file mode 100644 index 00000000..453bd60d --- /dev/null +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -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) { + 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.)); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 0a291a7c..09b2c57b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -228,6 +228,9 @@ pub fn run_binding(app: &mut FigApp, id: &str) { } fn key_event(app: &mut FigApp, key: Key, physical: Option, 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, 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; diff --git a/docs/mcp.md b/docs/mcp.md index f685ba10..284e262a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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