mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 10:56:48 -06:00
Merge fix/595
# Conflicts: # crates/engine/src/lib.rs # crates/ui-egui/src/lib.rs
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1703,6 +1703,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "水平方向に詰める"
|
||||
"arrange.packV" = "垂直方向に詰める"
|
||||
"arrange.roundToPixels" = "ピクセルに丸める"
|
||||
"arrange.spacing" = "間隔"
|
||||
"arrange.tidy" = "整列"
|
||||
"bool.exclude" = "選択範囲を除外"
|
||||
"bool.flatten" = "ブーリアンを平坦化"
|
||||
|
||||
@@ -1627,6 +1627,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "가로로 붙이기"
|
||||
"arrange.packV" = "세로로 붙이기"
|
||||
"arrange.roundToPixels" = "픽셀에 맞춰 반올림"
|
||||
"arrange.spacing" = "간격"
|
||||
"arrange.tidy" = "정돈"
|
||||
"bool.exclude" = "선택 항목 제외"
|
||||
"bool.flatten" = "불리언 평면화"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1691,6 +1691,7 @@ name = "Русский"
|
||||
"arrange.packH" = "Уплотнить по горизонтали"
|
||||
"arrange.packV" = "Уплотнить по вертикали"
|
||||
"arrange.roundToPixels" = "Округлить до пикселей"
|
||||
"arrange.spacing" = "Интервал"
|
||||
"arrange.tidy" = "Упорядочить"
|
||||
"bool.exclude" = "Исключить выделение"
|
||||
"bool.flatten" = "Свести булеву группу"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1628,6 +1628,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "水平紧凑排列"
|
||||
"arrange.packV" = "垂直紧凑排列"
|
||||
"arrange.roundToPixels" = "取整到像素"
|
||||
"arrange.spacing" = "间距"
|
||||
"arrange.tidy" = "整理"
|
||||
"bool.exclude" = "排除所选项"
|
||||
"bool.flatten" = "拼合布尔组"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.));
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user