mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge pull request #854 from campbellcody/feat/smart-selection-drag-reordering
M1: smart-selection drag reordering
This commit is contained in:
@@ -27,6 +27,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spacing
|
||||
),
|
||||
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
|
||||
spec!(
|
||||
"arrange.reorder",
|
||||
"Reorder selection",
|
||||
"{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)",
|
||||
true,
|
||||
reorder
|
||||
),
|
||||
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)),
|
||||
spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px),
|
||||
@@ -556,6 +563,48 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Reorder visual slots without rebuilding or reparenting layers.
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "arrange.reorder";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let source = req_id(cmd, p, "source")?;
|
||||
let target = req_id(cmd, p, "target")?;
|
||||
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_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?;
|
||||
let positions = layout
|
||||
.positions(source, target, boolean(p, "swap") == Some(true))
|
||||
.ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?;
|
||||
// Validate every delta before editing; absolute targets always come from the gesture's
|
||||
// snapshot so crossing several slots and returning to the start does not accumulate edits.
|
||||
let doc = s.doc()?;
|
||||
let mut moves = Vec::new();
|
||||
for (id, at) in positions {
|
||||
let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?;
|
||||
let (dx, dy) = (at.x - now.x0, at.y - now.y0);
|
||||
if !dx.is_finite() || !dy.is_finite() {
|
||||
return Err(bad(cmd, "non-finite reorder result"));
|
||||
}
|
||||
if dx.abs() > 1e-9 || dy.abs() > 1e-9 {
|
||||
let node = doc.get(id)?;
|
||||
let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
||||
let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform;
|
||||
if !transform.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
return Err(bad(cmd, "non-finite local transform"));
|
||||
}
|
||||
moves.push((id, dx, dy));
|
||||
}
|
||||
}
|
||||
if !moves.is_empty() {
|
||||
let doc = s.doc_mut()?;
|
||||
for (id, dx, dy) in moves {
|
||||
move_world(doc, id, dx, dy)?;
|
||||
}
|
||||
}
|
||||
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)?;
|
||||
|
||||
@@ -49,6 +49,7 @@ pub mod proto_run;
|
||||
pub mod rename;
|
||||
pub mod rotation_origin;
|
||||
pub mod selection_colors;
|
||||
pub mod selection_reorder;
|
||||
pub mod selection_spacing;
|
||||
pub mod shape_handles;
|
||||
pub mod shapebuilder;
|
||||
@@ -1841,5 +1842,7 @@ mod view_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod widgets_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod selection_spacing_tests;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Geometry for reordering a smart selection. Only regular, non-overlapping rows, columns
|
||||
//! and complete top-left-aligned grids have unambiguous insertion slots.
|
||||
//! Marking several items and irregular grids are out of scope.
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::selection_spacing::selection_spacing;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReorderLayout {
|
||||
/// Visual order, independent of selection order and layer stacking.
|
||||
pub slots: Vec<(NodeId, Rect)>,
|
||||
columns: usize,
|
||||
rows: usize,
|
||||
gap_x: f64,
|
||||
gap_y: f64,
|
||||
}
|
||||
|
||||
impl ReorderLayout {
|
||||
pub fn new(doc: &Document, ids: &[NodeId]) -> Option<Self> {
|
||||
let layout = selection_spacing(doc, ids);
|
||||
let h = layout.horizontal.as_ref();
|
||||
let v = layout.vertical.as_ref();
|
||||
let gap = |axis: Option<&crate::selection_spacing::Axis>| match axis {
|
||||
Some(a) => a.value.filter(|v| *v >= 0.0),
|
||||
None => Some(0.0),
|
||||
};
|
||||
let (gap_x, gap_y) = (gap(h)?, gap(v)?);
|
||||
let columns = h.map_or(1, |a| a.tracks.len());
|
||||
let rows = v.map_or(1, |a| a.tracks.len());
|
||||
if ids.len() < 2 || columns.checked_mul(rows)? != ids.len() {
|
||||
return None;
|
||||
}
|
||||
let mut cells = std::collections::BTreeMap::new();
|
||||
for id in ids {
|
||||
let col = match h {
|
||||
Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?,
|
||||
None => 0,
|
||||
};
|
||||
let row = match v {
|
||||
Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?,
|
||||
None => 0,
|
||||
};
|
||||
let b = doc.world_bounds(*id)?;
|
||||
if columns > 1 && rows > 1 {
|
||||
let x = h?.tracks.get(col)?.bounds.x0;
|
||||
let y = v?.tracks.get(row)?.bounds.y0;
|
||||
if (b.x0 - x).abs() > 0.01 || (b.y0 - y).abs() > 0.01 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if cells.insert((row, col), (*id, b)).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self { slots: cells.into_values().collect(), columns, rows, gap_x, gap_y })
|
||||
}
|
||||
|
||||
/// Insertion shifts intervening items; swapping exchanges just two entries. Sizes and
|
||||
/// original gaps are retained, so unequal-sized layers reflow instead of overlapping.
|
||||
pub fn positions(&self, source: NodeId, target: NodeId, swap: bool) -> Option<Vec<(NodeId, Point)>> {
|
||||
let from = self.slots.iter().position(|(id, _)| *id == source)?;
|
||||
let to = self.slots.iter().position(|(id, _)| *id == target)?;
|
||||
if from == to {
|
||||
return Some(self.slots.iter().map(|(id, b)| (*id, Point::new(b.x0, b.y0))).collect());
|
||||
}
|
||||
let mut order = self.slots.clone();
|
||||
if swap {
|
||||
order.swap(from, to);
|
||||
} else {
|
||||
let item = order.remove(from);
|
||||
order.insert(to, item);
|
||||
}
|
||||
let mut widths = vec![0.0_f64; self.columns];
|
||||
let mut heights = vec![0.0_f64; self.rows];
|
||||
for (i, (_, b)) in order.iter().enumerate() {
|
||||
let w = widths.get_mut(i % self.columns)?;
|
||||
*w = w.max(b.width());
|
||||
let h = heights.get_mut(i / self.columns)?;
|
||||
*h = h.max(b.height());
|
||||
}
|
||||
let x0 = self.slots.iter().map(|(_, b)| b.x0).fold(f64::INFINITY, f64::min);
|
||||
let y0 = self.slots.iter().map(|(_, b)| b.y0).fold(f64::INFINITY, f64::min);
|
||||
let starts = |sizes: &[f64], mut at: f64, gap: f64| {
|
||||
sizes
|
||||
.iter()
|
||||
.map(|size| {
|
||||
let start = at;
|
||||
at += size + gap;
|
||||
start
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let xs = starts(&widths, x0, self.gap_x);
|
||||
let ys = starts(&heights, y0, self.gap_y);
|
||||
order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, b))| {
|
||||
let x = if self.columns == 1 { b.x0 } else { *xs.get(i % self.columns)? };
|
||||
let y = if self.rows == 1 { b.y0 } else { *ys.get(i / self.columns)? };
|
||||
Some((*id, Point::new(x, y)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use crate::{Session, selection_reorder::ReorderLayout};
|
||||
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 insertion_reflows_unequal_sizes_with_original_gaps_and_hierarchy() {
|
||||
for vertical in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let mut ids = vec![];
|
||||
for (at, size) in [(0., 40.), (50., 60.), (120., 20.)] {
|
||||
ids.push(rect(
|
||||
&mut s,
|
||||
if vertical { 0. } else { at },
|
||||
if vertical { at } else { 0. },
|
||||
if vertical { 30. } else { size },
|
||||
if vertical { size } else { 30. },
|
||||
None,
|
||||
));
|
||||
}
|
||||
let before = s.doc().unwrap().clone();
|
||||
// Explicit selection order must not determine visual order.
|
||||
s.execute("arrange.reorder", &json!({"ids":[ids[2],ids[0],ids[1]],"source":ids[0],"target":ids[2]})).unwrap();
|
||||
for (id, at) in [(ids[1], 0.), (ids[2], 70.), (ids[0], 100.)] {
|
||||
assert_eq!(xy(&s, id), if vertical { (0., at) } else { (at, 0.) });
|
||||
let old = before.node(id).unwrap();
|
||||
let new = s.doc().unwrap().node(id).unwrap();
|
||||
assert_eq!((new.width, new.height, new.parent), (old.width, old.height, old.parent));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().node(s.ds().unwrap().page).unwrap().children, before.node(s.ds().unwrap().page).unwrap().children);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
s.redo().unwrap();
|
||||
assert_eq!(xy(&s, ids[0]), if vertical { (0., 100.) } else { (100., 0.) });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grids_insert_across_rows_and_swap_only_the_two_entries() {
|
||||
let mut s = Session::new();
|
||||
let mut ids = vec![];
|
||||
for row in 0..2 {
|
||||
for col in 0..3 {
|
||||
ids.push(rect(&mut s, col as f64 * 70., row as f64 * 60., 50., 40., None));
|
||||
}
|
||||
}
|
||||
let before = s.doc().unwrap().clone();
|
||||
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4]})).unwrap();
|
||||
assert_eq!(xy(&s, ids[0]), (70., 60.));
|
||||
assert_eq!(xy(&s, ids[3]), (140., 0.));
|
||||
assert_eq!(xy(&s, ids[5]), (140., 60.));
|
||||
s.undo().unwrap();
|
||||
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4],"swap":true})).unwrap();
|
||||
assert_eq!(xy(&s, ids[0]), (70., 60.));
|
||||
assert_eq!(xy(&s, ids[4]), (0., 0.));
|
||||
for id in [ids[1], ids[2], ids[3], ids[5]] {
|
||||
assert_eq!(s.doc().unwrap().node(id), before.node(id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_reflow_keeps_gaps_with_different_sizes() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0., 0., 40., 30., None);
|
||||
let b = rect(&mut s, 50., 0., 60., 30., None);
|
||||
let c = rect(&mut s, 0., 50., 40., 50., None);
|
||||
let d = rect(&mut s, 50., 50., 60., 50., None);
|
||||
s.execute("arrange.reorder", &json!({"ids":[a,b,c,d],"source":a,"target":d})).unwrap();
|
||||
assert_eq!(xy(&s, b), (0., 0.));
|
||||
assert_eq!(xy(&s, c), (70., 0.));
|
||||
assert_eq!(xy(&s, d), (0., 70.));
|
||||
assert_eq!(xy(&s, a), (70., 70.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_are_absolute_and_returning_to_start_is_a_noop() {
|
||||
let mut s = Session::new();
|
||||
let ids: Vec<_> = (0..3).map(|i| rect(&mut s, i as f64 * 70., 0., 50., 40., None)).collect();
|
||||
s.execute("select.set", &json!({"ids":ids})).unwrap();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let history = s.undo_depth().0;
|
||||
s.begin_interaction("Reorder selection").unwrap();
|
||||
for target in [ids[2], ids[1], ids[0]] {
|
||||
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap();
|
||||
}
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.undo_depth().0, history);
|
||||
s.begin_interaction("Reorder selection").unwrap();
|
||||
for target in [ids[1], ids[2]] {
|
||||
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap();
|
||||
}
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(s.undo_depth().0, history + 1);
|
||||
assert_eq!(s.selection(), ids);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_selections_and_targets_do_not_edit_the_document() {
|
||||
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);
|
||||
for args in [
|
||||
json!({"ids":[a,b,c],"source":a,"target":b}), // Mixed gaps.
|
||||
json!({"ids":[a,b],"source":c,"target":b}),
|
||||
json!({"ids":[a,b],"source":a,"target":999999}),
|
||||
json!({"ids":[a,a,b],"source":a,"target":b}),
|
||||
json!({"ids":[a,b],"source":[],"target":b}),
|
||||
] {
|
||||
let before = s.doc().unwrap().clone();
|
||||
assert!(s.execute("arrange.reorder", &args).is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
for props in [json!({"locked":true}), json!({"visible":false})] {
|
||||
s.execute("node.set", &json!({"id":b,"props":props})).unwrap();
|
||||
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b]).is_none());
|
||||
s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap();
|
||||
}
|
||||
let d = rect(&mut s, 0., 70., 50., 40., None);
|
||||
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b, d]).is_none(), "an incomplete grid has no slots");
|
||||
let frame =
|
||||
NodeId(s.execute("frame.create", &json!({"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap());
|
||||
let x = rect(&mut s, 0., 0., 50., 40., Some(frame));
|
||||
let y = rect(&mut s, 70., 0., 50., 40., Some(frame));
|
||||
assert!(ReorderLayout::new(s.doc().unwrap(), &[x, y]).is_none());
|
||||
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, x]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reordering_children_of_a_rotated_parent_uses_page_coordinates() {
|
||||
let mut s = Session::new();
|
||||
let frame = NodeId(
|
||||
s.execute("frame.create", &json!({"x":200,"y":200,"width":300,"height":100,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
let a = rect(&mut s, 0., 0., 50., 40., Some(frame));
|
||||
let b = rect(&mut s, 70., 0., 50., 40., Some(frame));
|
||||
let (pa, pb) = (xy(&s, a), xy(&s, b));
|
||||
s.execute("arrange.reorder", &json!({"ids":[a,b],"source":a,"target":b})).unwrap();
|
||||
let (qa, qb) = (xy(&s, a), xy(&s, b));
|
||||
assert!((qa.0 - pb.0).abs() < 1e-7 && (qa.1 - pb.1).abs() < 1e-7);
|
||||
assert!((qb.0 - pa.0).abs() < 1e-7 && (qb.1 - pa.1).abs() < 1e-7);
|
||||
}
|
||||
@@ -418,6 +418,9 @@ fn every_command_survives_junk_params() {
|
||||
// Selection color slots: malformed indexes, ranges and paints.
|
||||
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
|
||||
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
|
||||
// Smart-selection reorder targets and modes as junk.
|
||||
json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}),
|
||||
json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}),
|
||||
// Rotation origins and placing with a tool as junk.
|
||||
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
|
||||
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
|
||||
|
||||
@@ -1101,6 +1101,7 @@ name = "English"
|
||||
"Rename on the server…" = "Rename on the server…"
|
||||
"Rename page" = "Rename page"
|
||||
"Rename to" = "Rename to"
|
||||
"Reorder selection" = "Reorder selection"
|
||||
"Replace all" = "Replace all"
|
||||
"Replace mixed export settings" = "Replace mixed export settings"
|
||||
"Replace with" = "Replace with"
|
||||
|
||||
@@ -1102,6 +1102,7 @@ status = "draft — needs native review"
|
||||
"Rename on the server…" = "Cambiar el nombre en el servidor…"
|
||||
"Rename page" = "Cambiar nombre de página"
|
||||
"Rename to" = "Nuevo nombre"
|
||||
"Reorder selection" = "Reordenar selección"
|
||||
"Replace all" = "Reemplazar todo"
|
||||
"Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos"
|
||||
"Replace with" = "Reemplazar con"
|
||||
@@ -1767,6 +1768,7 @@ status = "draft — needs native review"
|
||||
"arrange.front" = "Traer al frente"
|
||||
"arrange.packH" = "Agrupar en horizontal"
|
||||
"arrange.packV" = "Agrupar en vertical"
|
||||
"arrange.reorder" = "Reordenar selección"
|
||||
"arrange.roundToPixels" = "Redondear a píxeles"
|
||||
"arrange.spacing" = "Espaciado"
|
||||
"arrange.tidy" = "Ordenar"
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use egui::{CursorIcon, Id, Pos2, Rect, Stroke, Ui, vec2};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::selection_reorder::ReorderLayout;
|
||||
use figcraft_engine::selection_spacing::{Axis, SelectionSpacing, selection_spacing};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -20,6 +21,7 @@ struct LayoutCache {
|
||||
ids: Vec<NodeId>,
|
||||
frame: u64,
|
||||
layout: Arc<SelectionSpacing>,
|
||||
reorder: Option<Arc<ReorderLayout>>,
|
||||
}
|
||||
|
||||
fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc<SelectionSpacing> {
|
||||
@@ -41,25 +43,68 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc<SelectionSpa
|
||||
// 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));
|
||||
let reorder = ReorderLayout::new(&ds.doc, ids).map(Arc::new);
|
||||
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() });
|
||||
d.insert_temp(
|
||||
key,
|
||||
LayoutCache { document: ds.uid, page: ds.page, doc: ds.doc.clone(), ids: ids.to_vec(), frame, layout: layout.clone(), reorder },
|
||||
);
|
||||
});
|
||||
layout
|
||||
}
|
||||
fn reorder_layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Option<Arc<ReorderLayout>> {
|
||||
layout(app, ctx, ids);
|
||||
ctx.data(|d| d.get_temp::<LayoutCache>(Id::new("selection_spacing_layout"))).and_then(|c| c.reorder)
|
||||
}
|
||||
|
||||
fn drag_id() -> Id {
|
||||
Id::new("selection_spacing_drag")
|
||||
}
|
||||
|
||||
/// The reorder ring of a layer with world bounds `b`: its centre on screen and hit radius. The
|
||||
/// radius is 8 px, at most a quarter of the layer's smaller side on screen, so most of a small
|
||||
/// layer still drags the selection; a layer under 16 px on screen has no ring.
|
||||
fn ring(app: &FigApp, b: figcraft_geom::Rect) -> Option<(Pos2, f32)> {
|
||||
let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom);
|
||||
let side = ((b.x1 - b.x0).min(b.y1 - b.y0) * zoom) as f32;
|
||||
(side >= 16.0).then(|| (app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5), (side / 4.0).min(8.0)))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Action {
|
||||
Spacing { axis: Axis, value: f64, last_value: Option<f64> },
|
||||
Reorder { layout: Arc<ReorderLayout>, source: NodeId, target: NodeId, swap: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Drag {
|
||||
ids: Vec<NodeId>,
|
||||
axis: Axis,
|
||||
action: Action,
|
||||
start: Pos2,
|
||||
value: f64,
|
||||
uid: u64,
|
||||
page: NodeId,
|
||||
moved: bool,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec<NodeId>, p: Pos2, action: Action) {
|
||||
let label = match action {
|
||||
Action::Spacing { .. } => tr("Spacing"),
|
||||
Action::Reorder { .. } => tr("Reorder selection"),
|
||||
};
|
||||
if app.session.begin_interaction(label).is_ok()
|
||||
&& let Ok(ds) = app.session.ds()
|
||||
{
|
||||
let drag = Drag { ids, action, start: p, uid: ds.uid, page: ds.page, moved: false };
|
||||
app.ui.layers_focus = false;
|
||||
app.ui.pages_focus = None;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) {
|
||||
@@ -146,7 +191,7 @@ pub fn cancel(app: &mut FigApp, ctx: &egui::Context) -> bool {
|
||||
/// 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);
|
||||
let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some());
|
||||
if !same || !available(app) {
|
||||
cancel(app, ctx);
|
||||
}
|
||||
@@ -157,39 +202,77 @@ pub fn sync(app: &mut FigApp, ctx: &egui::Context) {
|
||||
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);
|
||||
let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some());
|
||||
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()),
|
||||
match &mut drag.action {
|
||||
Action::Spacing { axis, value: original, last_value } => {
|
||||
let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6);
|
||||
let distance = if 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
|
||||
};
|
||||
((*original + f64::from(distance) / zoom) / quantum).round() * quantum
|
||||
})
|
||||
.clamp(-1e6, 1e6);
|
||||
let key = if axis.vertical { "vertical" } else { "horizontal" };
|
||||
if *last_value != Some(value) {
|
||||
match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) {
|
||||
Ok(_) => *last_value = Some(value),
|
||||
Err(e) => app.toast(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::Reorder { layout, source, target, swap } if app.canvas.rect.contains(p) => {
|
||||
drag.moved |= p.distance(drag.start) >= 3.0;
|
||||
// Hit original slots, never the moving preview: otherwise a stationary
|
||||
// pointer could alternate between two permutations on successive frames.
|
||||
let nearest = layout
|
||||
.slots
|
||||
.iter()
|
||||
.min_by(|a, b| {
|
||||
let center = |b: figcraft_geom::Rect| app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5);
|
||||
center(a.1).distance_sq(p).total_cmp(¢er(b.1).distance_sq(p))
|
||||
})
|
||||
.map(|(id, _)| *id)
|
||||
.unwrap_or(*source);
|
||||
let swapping = ctx.input(|i| i.modifiers.command);
|
||||
if drag.moved && (nearest != *target || swapping != *swap) {
|
||||
match app
|
||||
.session
|
||||
.execute("arrange.reorder", &json!({"ids":drag.ids,"source":source,"target":nearest,"swap":swapping,"fromGesture":true}))
|
||||
{
|
||||
Ok(_) => {
|
||||
*target = nearest;
|
||||
*swap = swapping;
|
||||
}
|
||||
Err(e) => app.toast(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
// A release over a panel or outside the window commits the last canvas
|
||||
// preview; the release's sentinel coordinates must not select a new slot.
|
||||
Action::Reorder { .. } => {}
|
||||
}
|
||||
}
|
||||
ctx.set_cursor_icon(if drag.axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal });
|
||||
ctx.set_cursor_icon(match &drag.action {
|
||||
Action::Spacing { axis, .. } if axis.vertical => CursorIcon::ResizeVertical,
|
||||
Action::Spacing { .. } => CursorIcon::ResizeHorizontal,
|
||||
Action::Reorder { .. } => CursorIcon::Grabbing,
|
||||
});
|
||||
if !down {
|
||||
let _ = app.session.commit_interaction();
|
||||
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
|
||||
@@ -198,11 +281,26 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option<Pos2>, hovered:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if !hovered || space || !available(app) || ctx.input(|i| i.modifiers.command) {
|
||||
if !hovered || space || !available(app) {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = pos else { return false };
|
||||
let ids = app.session.selection();
|
||||
if let Some(reorder) = reorder_layout(app, ctx, &ids) {
|
||||
for (id, b) in &reorder.slots {
|
||||
if ring(app, *b).is_some_and(|(center, r)| center.distance(p) <= r) {
|
||||
ctx.set_cursor_icon(CursorIcon::Grab);
|
||||
if ctx.input(|i| i.pointer.primary_pressed()) {
|
||||
let source = *id;
|
||||
start_drag(app, ctx, ids, p, Action::Reorder { layout: reorder, source, target: source, swap: false });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ctx.input(|i| i.modifiers.command) {
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
@@ -210,21 +308,8 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option<Pos2>, hovered:
|
||||
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.pages_focus = None;
|
||||
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));
|
||||
if ctx.input(|i| i.pointer.primary_pressed()) {
|
||||
start_drag(app, ctx, ids, p, Action::Spacing { axis: axis.clone(), value: h.value, last_value: axis.value });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -245,8 +330,32 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
|
||||
if drag.is_none() && !hover.is_some_and(|p| rect.contains(p)) {
|
||||
return;
|
||||
}
|
||||
let axes = if let Some(d) = &drag {
|
||||
vec![d.axis.clone()]
|
||||
let reorder = match drag.as_ref().map(|d| &d.action) {
|
||||
Some(Action::Reorder { layout, .. }) => Some(layout.clone()),
|
||||
Some(Action::Spacing { .. }) => None,
|
||||
None => reorder_layout(app, ctx, &ds.selection),
|
||||
};
|
||||
if let Some(layout) = reorder {
|
||||
for (id, _) in &layout.slots {
|
||||
let Some((center, r)) = ds.doc.world_bounds(*id).and_then(|b| ring(app, b)) else { continue };
|
||||
let hot = hover.is_some_and(|p| center.distance(p) <= r);
|
||||
let active = matches!(drag.as_ref().map(|d| &d.action), Some(Action::Reorder { source, .. }) if source == id);
|
||||
if active {
|
||||
painter.circle_filled(center, 4.5, PINK);
|
||||
} else {
|
||||
painter.circle_stroke(center, 4.5, Stroke::new(if hot { 2.5 } else { 1.5 }, PINK));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Drag { action: Action::Reorder { layout, target, .. }, moved: true, .. }) = &drag {
|
||||
if let Some((_, b)) = layout.slots.iter().find(|(id, _)| id == target) {
|
||||
let rect = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand(3.0);
|
||||
painter.rect_stroke(rect, 0, Stroke::new(2.0, crate::theme::ACCENT), egui::StrokeKind::Outside);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let axes = if let Some(Drag { action: Action::Spacing { axis, .. }, .. }) = &drag {
|
||||
vec![axis.clone()]
|
||||
} else {
|
||||
let layout = layout(app, ctx, &ds.selection);
|
||||
[layout.horizontal.clone(), layout.vertical.clone()].into_iter().flatten().collect()
|
||||
|
||||
@@ -205,3 +205,157 @@ fn stationary_gap_drag_does_not_change_the_document_again() {
|
||||
u.button(p + vec2(20.2 * z, 0.), false);
|
||||
assert_eq!(xy(&u, ids[1]), (200., 100.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_ring_drag_inserts_across_grid_rows_as_one_undo_step() {
|
||||
let (mut u, ids) = scene(true);
|
||||
let before = u.app.session.undo_depth().0;
|
||||
let p = u.app.to_screen(125., 130.);
|
||||
let target = u.app.to_screen(205., 230.);
|
||||
let output = u.frame_output(vec![Event::PointerMoved(p)]);
|
||||
assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::Grab);
|
||||
drag(&mut u, p, target - p, true);
|
||||
assert_eq!(xy(&u, ids[0]), (180., 200.));
|
||||
assert_eq!(xy(&u, ids[3]), (260., 100.));
|
||||
assert_eq!(u.app.session.selection(), ids);
|
||||
assert_eq!(u.app.session.undo_depth().0, before + 1);
|
||||
u.app.run("edit.undo", json!({}));
|
||||
assert_eq!(xy(&u, ids[0]), (100., 100.));
|
||||
u.app.run("edit.redo", json!({}));
|
||||
assert_eq!(xy(&u, ids[0]), (180., 200.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() {
|
||||
for action in [0, 1, 2] {
|
||||
let (mut u, ids) = scene(false);
|
||||
let history = u.app.session.undo_depth().0;
|
||||
let before = u.app.session.doc().unwrap().clone();
|
||||
let p = u.app.to_screen(125., 130.);
|
||||
let target = u.app.to_screen(285., 130.);
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
if action == 0 {
|
||||
u.button(p, true);
|
||||
} else {
|
||||
drag(&mut u, p, target - p, false);
|
||||
assert_eq!(xy(&u, ids[0]), (260., 100.));
|
||||
if action == 1 {
|
||||
key(&mut u, Key::Escape);
|
||||
} else {
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
}
|
||||
}
|
||||
u.button(p, false);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.doc().unwrap(), &before);
|
||||
assert_eq!(u.app.session.undo_depth().0, history);
|
||||
assert_eq!(u.app.session.selection(), ids);
|
||||
assert!(u.app.session.ds().unwrap().interaction.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_drag_swaps_and_a_stationary_pointer_keeps_the_preview() {
|
||||
let (mut u, ids) = scene(true);
|
||||
let p = u.app.to_screen(125., 130.);
|
||||
let target = u.app.to_screen(205., 230.);
|
||||
// The modifier can already be down when the handle is pressed.
|
||||
let m = Modifiers::COMMAND;
|
||||
u.frame_with(vec![Event::PointerMoved(p)], m);
|
||||
u.frame_with(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m);
|
||||
u.frame_with(vec![Event::PointerMoved(target)], m);
|
||||
assert_eq!(xy(&u, ids[0]), (180., 200.));
|
||||
assert_eq!(xy(&u, ids[4]), (100., 100.));
|
||||
assert_eq!(xy(&u, ids[1]), (180., 100.));
|
||||
let snapshot = u.app.session.ds().unwrap().doc.clone();
|
||||
for _ in 0..5 {
|
||||
u.frame_with(vec![], m);
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&snapshot, &u.app.session.ds().unwrap().doc));
|
||||
// Changing modifiers without moving the pointer switches to insertion.
|
||||
u.frame(vec![]);
|
||||
assert_eq!(xy(&u, ids[1]), (100., 100.));
|
||||
assert_eq!(xy(&u, ids[4]), (100., 200.));
|
||||
u.button(target, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_documents_during_reordering_rolls_back_its_owner() {
|
||||
let (mut u, ids) = scene(false);
|
||||
let before = u.app.session.doc().unwrap().clone();
|
||||
let p = u.app.to_screen(125., 130.);
|
||||
let target = u.app.to_screen(285., 130.);
|
||||
drag(&mut u, p, target - p, 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!(u.app.session.doc().unwrap(), &before);
|
||||
assert_eq!(u.app.session.selection(), ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_release_outside_window_commits_and_undo_while_dragging_cancels() {
|
||||
for undo in [false, true] {
|
||||
let (mut u, ids) = scene(false);
|
||||
let history = u.app.session.undo_depth().0;
|
||||
let p = u.app.to_screen(125., 130.);
|
||||
let target = u.app.to_screen(285., 130.);
|
||||
drag(&mut u, p, target - p, false);
|
||||
if undo {
|
||||
let m = Modifiers::COMMAND;
|
||||
u.frame_with(vec![Event::Key { key: Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: m }], m);
|
||||
}
|
||||
u.frame(vec![Event::PointerGone]);
|
||||
u.button(Pos2::new(-1., -1.), false);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.undo_depth().0, history + usize::from(!undo));
|
||||
assert_eq!(xy(&u, ids[0]), if undo { (100., 100.) } else { (260., 100.) });
|
||||
assert!(u.app.session.ds().unwrap().interaction.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_externally_finished_reorder_cannot_resume_on_later_pointer_motion() {
|
||||
let (mut u, ids) = scene(false);
|
||||
let p = u.app.to_screen(125., 130.);
|
||||
let target = u.app.to_screen(285., 130.);
|
||||
drag(&mut u, p, target - p, false);
|
||||
u.app.session.commit_interaction().unwrap();
|
||||
let history = u.app.session.undo_depth().0;
|
||||
let committed = u.app.session.doc().unwrap().clone();
|
||||
let middle = u.app.to_screen(205., 130.);
|
||||
u.frame(vec![Event::PointerMoved(middle)]);
|
||||
u.button(middle, false);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.doc().unwrap(), &committed);
|
||||
assert_eq!(u.app.session.undo_depth().0, history);
|
||||
assert_eq!(xy(&u, ids[0]), (260., 100.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_layers_on_screen_have_no_reorder_ring_and_drag_the_selection_from_their_middle() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
let z = u.app.session.ds().unwrap().view.zoom;
|
||||
// 12 px squares 36 px apart on screen: a regular row, too small for rings.
|
||||
let ids: Vec<NodeId> = (0..3)
|
||||
.map(|col| {
|
||||
let p = json!({"x": (100. + f64::from(col) * 36.) / z, "y": 100. / z, "width": 12. / z, "height": 12. / z});
|
||||
NodeId(u.app.session.execute("shape.rectangle", &p).unwrap()["id"].as_u64().unwrap())
|
||||
})
|
||||
.collect();
|
||||
u.app.session.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
let before: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect();
|
||||
let p = u.app.to_screen(before[0].0 + 6. / z, before[0].1 + 6. / z);
|
||||
let output = u.frame_output(vec![Event::PointerMoved(p)]);
|
||||
assert_ne!(output.platform_output.cursor_icon, egui::CursorIcon::Grab);
|
||||
drag(&mut u, p, vec2(0., 60.), true);
|
||||
let after: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect();
|
||||
assert!(after[0].1 > before[0].1, "the selection moved: {before:?} -> {after:?}");
|
||||
assert!(
|
||||
before.iter().zip(&after).all(|(b, a)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1 - (after[0].1 - before[0].1)).abs() < 1e-6),
|
||||
"all together, in order"
|
||||
);
|
||||
assert_eq!(u.app.session.selection(), ids);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user