mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 00:39:44 -06:00
* E21: require release font coverage and stabilize capture deadline tests * E21: separate capture, IME and field-save regression phases * M0: correct the migration qualification reference * M-egui037: document input lifecycle and UI regression contracts * M0: correct egui 0.37 status docs and stabilize join-code regression --------- Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com> Co-authored-by: Codebuff <noreply@codebuff.com>
441 lines
19 KiB
Rust
441 lines
19 KiB
Rust
//! 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_reorder::ReorderLayout;
|
|
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>,
|
|
reorder: Option<Arc<ReorderLayout>>,
|
|
}
|
|
|
|
/// Return one cached spacing/reorder calculation per document, page, selection and frame,
|
|
/// refreshing next frame after an edit and reusing an unchanged document snapshot.
|
|
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::unique("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));
|
|
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(), reorder },
|
|
);
|
|
});
|
|
layout
|
|
}
|
|
/// Return the cached reorder layout, first ensuring the shared layout cache is populated.
|
|
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::unique("selection_spacing_layout"))).and_then(|c| c.reorder)
|
|
}
|
|
|
|
/// Stable egui id for spacing/reorder drag state.
|
|
fn drag_id() -> Id {
|
|
Id::unique("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>,
|
|
action: Action,
|
|
start: Pos2,
|
|
uid: u64,
|
|
page: NodeId,
|
|
moved: bool,
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// Draw horizontal and vertical spacing fields, running `arrange.spacing` with
|
|
/// `fromGesture`.
|
|
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::unique(("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 && d.interaction.is_some());
|
|
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 && 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 {
|
|
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(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()));
|
|
} else {
|
|
ctx.data_mut(|d| d.insert_temp(drag_id(), drag));
|
|
}
|
|
return true;
|
|
}
|
|
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) {
|
|
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()) {
|
|
start_drag(app, ctx, ids, p, Action::Spacing { axis: axis.clone(), value: h.value, last_value: axis.value });
|
|
}
|
|
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 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()
|
|
};
|
|
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();
|
|
}
|
|
}
|