mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 01:00:39 +00:00
M8: a large selection's overlay and Design panel values are kept for frames where nothing changed (#1611)
* M8: a large selection's overlay and Design panel values are kept for frames where nothing changed (#1571) The selection's outlines, box, size and slots on the canvas, and the Design panel's instance checks and position values, are kept with the document snapshot (held weakly), the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any changes. Dragging 648 selected layers: overlays 1.99 to 0.86 ms a frame, Design panel 2.54 to 1.40 ms. * M8: split the position fields into one function per row (Sonar S3776)
This commit is contained in:
1 parent
a5f88f146c
commit
5247f73f59
10 files changed
+441
-108
No files matched your search
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `ediLine truncated
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #1571 (part): a large selection redid the same work on every frame. A pointer move draws two to five egui frames, and each one worked out every selected layer's outline, the selection box and size, the slots shown, and the Design panel's instance checks and position values (X, Y, rotation, constraints, alignment) again, though only the first frame after a move or an edit can differ. Now they are kept with the document snapshot, the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any of those changes (`selection_cache`); the hover and drop-target highlights are drawn every frame as before. Nothing looks different. Dragging 648 layers of a synthetic file of about 1,900 (release build, control-driven, two frames per move): the canvas overlays 1.99 to 0.86 ms a frame, the Design panel 2.54 to 1.40 ms. Not yet: the frame of each move still works everything out (the selection spacing, the export section, the outlines), the canvas texture is uploaded whole after each render. Tests: `canvas/selection_cache_tests.rs`. |
|
||||
| 2026-10-30 | minor | M8, #1089: libraries and plugins larger than one address's download burst (16 MiB) could never be downloaded: the per-address token bucket never held more than its burst, so a private server's 40 or 64 MiB team library was refused for good. Now a download larger than the burst is served once the address's budget is full, leaving it in debt that refills at 1 MiB a second before its next download (`Bucket::take_spread`, also for everyone's library budget); within the burst nothing changes. Over any stretch of time an address still gets at most the largest object plus the rate times that time. `server.md` (rates, threat model). Tests: `download_burst_tests.rs` (the bucket's loan and payback, ten minutes of 40 MiB requests kept to the rate, a 40 MiB library served and then the address waits). |
|
||||
| 2026-10-30 | minor | M8, #1099: `command.batch` (and every grouped run: plugins, scripts, MCP `batch`) kept a full undo snapshot per step until the batch ended, so a long batch held one document copy per step. Inside a group, edits now add to the one undo step the group pushed, so a batch holds one snapshot however long it is; it is still one undo step that restores exactly the state before it, and a failure still rolls everything back. An `edit.undo` step inside a batch now undoes the batch's edits so far together. Measured on a 2,000-layer file with 160 KB of document colours (debug build): a 1,000-step batch went from +165 MiB peak and 80 to 100 ms to +10 MiB and about 50 ms (4,000 steps: +35 MiB, the step list and command journal). Numbers unchanged. Tests: `batch_undo_snapshot_tests`. |
|
||||
| 2026-10-30 | minor | M8, #1101: the importers' 256-level depth cap could overflow the browser's 1 MB stack before it was reached. Measured per level: the REST import's layer walk (also the second stage of every `.fig` import) took about 4 KB, a whole megabyte at 256, and overflowed a 1 MB thread; it now runs on a work list in the same order (groups fitted once their children are in), a few KB in all. The `.fig` mapping, which builds each layer from its children's results, stays recursive at about 1.8 KB a level, 455 KB at the cap. Desktop and browser keep the 256-level cap, so nothing that loads today is cut off. Numbers unchanged. Tests: `deep_import_tests` (256 and 400 levels on a 1 MB thread). |
|
||||
|
||||
@@ -374,6 +374,9 @@ mod builder_poly_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_cache_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod wheel_zoom_tests;
|
||||
|
||||
|
||||
@@ -71,7 +71,30 @@ pub(super) fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) {
|
||||
/// The slots of the selected components and instances (and a slot being dropped onto): a dashed
|
||||
/// outline and the slot's name above its top-left corner, like a frame label (never over the
|
||||
/// content).
|
||||
pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[NodeId], drop: Option<NodeId>) {
|
||||
pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Arc<Document>, sel: &[NodeId], drop: Option<NodeId>) {
|
||||
let key = [drop.map_or(0, |d| d.0.wrapping_add(1))];
|
||||
let slots = crate::selection_cache::cached(painter.ctx(), "slots", doc, sel, &key, || slots_shown(doc, sel, drop));
|
||||
for (id, name) in slots {
|
||||
let Some(q) = node_quad(app, doc, id) else { continue };
|
||||
if !Rect::from_points(&q).intersects(app.canvas.rect) {
|
||||
continue;
|
||||
}
|
||||
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0));
|
||||
// The label, when the slot is wide enough on screen to hold it.
|
||||
let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT);
|
||||
let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0));
|
||||
if (q[1] - q[0]).length() < w {
|
||||
continue;
|
||||
}
|
||||
let at = q[0] - vec2(0.0, h + 2.0);
|
||||
crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT);
|
||||
painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT);
|
||||
}
|
||||
}
|
||||
|
||||
/// The slots [`slot_badges`] shows, with their names: those of the selected components and
|
||||
/// instances (and of the ones the selected layers are in), and the slot being dropped onto.
|
||||
fn slots_shown(doc: &Document, sel: &[NodeId], drop: Option<NodeId>) -> Vec<(NodeId, String)> {
|
||||
use figcraft_engine::components::{owning_component, owning_instance, slot_name, slots_of};
|
||||
let mut roots: Vec<NodeId> = Vec::new();
|
||||
for id in sel {
|
||||
@@ -90,22 +113,7 @@ pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document,
|
||||
{
|
||||
slots.push((d, name));
|
||||
}
|
||||
for (id, name) in slots {
|
||||
let Some(q) = node_quad(app, doc, id) else { continue };
|
||||
if !Rect::from_points(&q).intersects(app.canvas.rect) {
|
||||
continue;
|
||||
}
|
||||
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0));
|
||||
// The label, when the slot is wide enough on screen to hold it.
|
||||
let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT);
|
||||
let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0));
|
||||
if (q[1] - q[0]).length() < w {
|
||||
continue;
|
||||
}
|
||||
let at = q[0] - vec2(0.0, h + 2.0);
|
||||
crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT);
|
||||
painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT);
|
||||
}
|
||||
slots
|
||||
}
|
||||
|
||||
/// The instance an Alt-drop of a component from the Assets panel swaps: tinted and outlined in
|
||||
@@ -197,16 +205,13 @@ fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option<Vec<Vec<Pos
|
||||
|
||||
/// A layer's outline on the canvas: its shape where [`outline_path`] has one, its box otherwise.
|
||||
pub(super) fn draw_outline(app: &FigApp, painter: &egui::Painter, doc: &Document, id: NodeId, s: Stroke) {
|
||||
painter.extend(outline_shapes(app, doc, id, s));
|
||||
}
|
||||
|
||||
/// The shapes [`draw_outline`] draws.
|
||||
pub(super) fn outline_shapes(app: &FigApp, doc: &Document, id: NodeId, s: Stroke) -> Vec<Shape> {
|
||||
match outline_shape(app, doc, id) {
|
||||
Some(lines) => {
|
||||
for l in lines {
|
||||
painter.add(Shape::line(l, s));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(q) = node_quad(app, doc, id) {
|
||||
quad(painter, q, s);
|
||||
}
|
||||
}
|
||||
Some(lines) => lines.into_iter().map(|l| Shape::line(l, s)).collect(),
|
||||
None => node_quad(app, doc, id).map(|q| Shape::closed_line(q.to_vec(), s)).into_iter().collect(),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::builder::{builder_overlay, hatched, region_hatch};
|
||||
use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, quad, slot_badges, stroke, swap_highlight};
|
||||
use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, outline_shapes, quad, slot_badges, stroke, swap_highlight};
|
||||
use super::rulers::{boards, fmt, rulers};
|
||||
use super::{
|
||||
Align2, COMPONENT, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Point, Pos2, Rect, Shape, Stroke, StrokeKind, Tokens, ToolId,
|
||||
@@ -234,20 +236,51 @@ fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Do
|
||||
painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
|
||||
/// What the selection's overlay draws that only the document, the selection, the view and the
|
||||
/// overlay colour decide: kept for the frames that show the same ([`crate::selection_cache`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct SelectionGeometry {
|
||||
/// The outline colour: the component colour when any selected layer is in one.
|
||||
pub(super) col: Color32,
|
||||
/// Every selected layer's outline on screen.
|
||||
pub(super) outlines: Arc<Vec<Shape>>,
|
||||
/// The transform box (page space) and its width and height.
|
||||
bbox: Option<[Point; 4]>,
|
||||
size: Option<(f64, f64)>,
|
||||
/// A selected layer is locked (no handles).
|
||||
locked: bool,
|
||||
}
|
||||
|
||||
pub(super) fn selection_geometry(app: &FigApp, painter: &egui::Painter, doc: &Arc<Document>, sel: &[NodeId]) -> SelectionGeometry {
|
||||
let green = accent(painter);
|
||||
let mapping = [app.to_screen(0.0, 0.0), app.to_screen(1.0, 1.0)].map(crate::selection_cache::bits);
|
||||
let extra = [mapping[0][0], mapping[0][1], mapping[1][0], mapping[1][1], u64::from(u32::from_le_bytes(green.to_array()))];
|
||||
crate::selection_cache::cached(painter.ctx(), "selection overlay", doc, sel, &extra, || {
|
||||
let col = if sel.iter().any(|i| outline_colour(doc, *i, green) == COMPONENT) { COMPONENT } else { green };
|
||||
let outlines = sel.iter().flat_map(|id| outline_shapes(app, doc, *id, stroke(col, 1.0))).collect();
|
||||
SelectionGeometry {
|
||||
col,
|
||||
outlines: Arc::new(outlines),
|
||||
bbox: selection_box(doc, sel),
|
||||
size: selection_frame(doc, sel).map(|(_, w, h)| (w, h)),
|
||||
locked: sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The selection's outlines, transform box and handles; returns the size label's box.
|
||||
fn selection_overlay(
|
||||
app: &FigApp,
|
||||
painter: &egui::Painter,
|
||||
ds: &figcraft_engine::DocState,
|
||||
doc: &Document,
|
||||
doc: &Arc<Document>,
|
||||
sel: &[NodeId],
|
||||
zoom: f64,
|
||||
) -> Option<Rect> {
|
||||
let mut size_label = None;
|
||||
let col = if sel.iter().any(|i| outline_colour(doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
|
||||
for id in sel {
|
||||
draw_outline(app, painter, doc, *id, stroke(col, 1.0));
|
||||
}
|
||||
let geo = selection_geometry(app, painter, doc, sel);
|
||||
let col = geo.col;
|
||||
painter.extend(geo.outlines.iter().cloned());
|
||||
// Constraint guides belong to the normal selection overlay: not while the Shape Builder
|
||||
// reshapes by regions, nor while measuring to another layer (Alt), whose lines take over.
|
||||
if app.session.tool.tool != ToolId::ShapeBuilder && app.session.tool.overlay.measure_box.is_none() {
|
||||
@@ -255,8 +288,8 @@ fn selection_overlay(
|
||||
}
|
||||
// The shape builder reshapes by regions: no transform box.
|
||||
if app.session.tool.tool != ToolId::ShapeBuilder
|
||||
&& let Some(b) = selection_box(doc, sel)
|
||||
&& let Some((_, w, h)) = selection_frame(doc, sel)
|
||||
&& let Some(b) = geo.bbox
|
||||
&& let Some((w, h)) = geo.size
|
||||
{
|
||||
let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line));
|
||||
// A selected connector's handles are its two ends (drag one onto another object).
|
||||
@@ -275,12 +308,7 @@ fn selection_overlay(
|
||||
// Mode: no handles either.
|
||||
// Crop mode draws its own marks on the box instead (`handles::crop_marks`).
|
||||
let cropping = app.ui.crop.is_some();
|
||||
let handles = if cropping
|
||||
|| measuring
|
||||
|| app.session.tool.dev_mode
|
||||
|| connector.is_some()
|
||||
|| sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i))
|
||||
{
|
||||
let handles = if cropping || measuring || app.session.tool.dev_mode || connector.is_some() || geo.locked {
|
||||
0
|
||||
} else if is_line {
|
||||
2
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The selection overlay's geometry and the Design panel's values are kept for the frames that
|
||||
//! show the same (#1571), made again on every input they show (an edit, the selection, zoom, pan,
|
||||
//! the canvas's place, the theme's overlay green), and the same as when made afresh; what they
|
||||
//! don't keep (the hover and drop-target highlight) shows on the next frame.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, Context, LayerId, Painter, Rect, pos2, vec2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::overlays::{SelectionGeometry, overlays, selection_geometry};
|
||||
use crate::theme::{Tokens, set_canvas_page};
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// Three rectangles (the middle one rounded, outlined along its corners) selected, and a fourth
|
||||
/// that isn't, on a light page.
|
||||
fn setup() -> (FigApp, Context, Vec<NodeId>) {
|
||||
let mut s = Session::new();
|
||||
let mut ids = Vec::new();
|
||||
for x in [0, 80, 160, 240] {
|
||||
ids.push(id(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 50, "height": 50})).unwrap()));
|
||||
}
|
||||
let radii = json!({"topLeftRadius": 12, "topRightRadius": 12, "bottomRightRadius": 12, "bottomLeftRadius": 12});
|
||||
s.execute("node.set", &json!({"id": ids[1], "props": radii})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": ids[..3]})).unwrap();
|
||||
let mut app = FigApp::new(s, Box::new(NoServices));
|
||||
app.canvas.rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
|
||||
let ctx = Context::default();
|
||||
set_canvas_page(&ctx, Color32::WHITE);
|
||||
(app, ctx, ids)
|
||||
}
|
||||
|
||||
fn geometry(app: &FigApp, ctx: &Context) -> SelectionGeometry {
|
||||
let ds = app.session.ds().unwrap();
|
||||
selection_geometry(app, &Painter::new(ctx.clone(), LayerId::background(), Rect::EVERYTHING), &ds.doc.clone(), &ds.selection.clone())
|
||||
}
|
||||
|
||||
/// The geometry made with nothing kept, on a page of colour `page`.
|
||||
fn fresh(app: &FigApp, page: Color32) -> SelectionGeometry {
|
||||
let ctx = Context::default();
|
||||
set_canvas_page(&ctx, page);
|
||||
geometry(app, &ctx)
|
||||
}
|
||||
|
||||
fn kept(a: &SelectionGeometry, b: &SelectionGeometry) -> bool {
|
||||
Arc::ptr_eq(&a.outlines, &b.outlines)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_frame_reuses_the_selection_geometry() {
|
||||
let (app, ctx, _) = setup();
|
||||
let first = geometry(&app, &ctx);
|
||||
assert_eq!(first.outlines.len(), 3);
|
||||
assert!(kept(&first, &geometry(&app, &ctx)));
|
||||
assert_eq!(first, fresh(&app, Color32::WHITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_value_does_not_keep_its_document_alive() {
|
||||
let (app, ctx, _) = setup();
|
||||
let doc = app.session.ds().unwrap().doc.clone();
|
||||
let holders = Arc::strong_count(&doc);
|
||||
geometry(&app, &ctx);
|
||||
assert_eq!(Arc::strong_count(&doc), holders);
|
||||
}
|
||||
|
||||
type Change = Box<dyn Fn(&mut FigApp, &Context)>;
|
||||
|
||||
#[test]
|
||||
fn every_input_the_geometry_shows_makes_it_again() {
|
||||
let (mut app, ctx, ids) = setup();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
let changes: Vec<(&str, Change)> = vec![
|
||||
(
|
||||
"an edit",
|
||||
Box::new(move |app: &mut FigApp, _: &Context| {
|
||||
app.set_props(&[b], json!({"x": 90}));
|
||||
}),
|
||||
),
|
||||
(
|
||||
"the selection",
|
||||
Box::new(move |app: &mut FigApp, _: &Context| {
|
||||
app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
}),
|
||||
),
|
||||
("zoom", Box::new(|app: &mut FigApp, _: &Context| app.session.ds_mut().unwrap().view.zoom *= 2.0)),
|
||||
("pan", Box::new(|app: &mut FigApp, _: &Context| app.session.ds_mut().unwrap().view.cx += 30.0)),
|
||||
("the canvas moving", Box::new(|app: &mut FigApp, _: &Context| app.canvas.rect = app.canvas.rect.translate(vec2(40.0, 0.0)))),
|
||||
("the theme", Box::new(|_: &mut FigApp, ctx: &Context| set_canvas_page(ctx, Color32::BLACK))),
|
||||
];
|
||||
let mut page = Color32::WHITE;
|
||||
for (what, change) in changes {
|
||||
let before = geometry(&app, &ctx);
|
||||
change(&mut app, &ctx);
|
||||
if what == "the theme" {
|
||||
page = Color32::BLACK;
|
||||
assert_ne!(crate::theme::canvas_accent(&ctx), before.col, "a dark page takes the other green");
|
||||
}
|
||||
let after = geometry(&app, &ctx);
|
||||
assert!(!kept(&before, &after), "{what}");
|
||||
assert_ne!(before, after, "{what}");
|
||||
assert_eq!(after, fresh(&app, page), "{what}: as if nothing were kept");
|
||||
assert!(kept(&after, &geometry(&app, &ctx)), "{what}: kept again");
|
||||
}
|
||||
}
|
||||
|
||||
/// How many shapes one frame of the canvas overlays draws.
|
||||
fn drawn(app: &FigApp, ctx: &Context) -> usize {
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
overlays(app, &ui.ctx().layer_painter(LayerId::background()), &Tokens::light());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.shapes.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hover_and_drop_highlights_show_over_kept_geometry() {
|
||||
let (mut app, ctx, ids) = setup();
|
||||
let other = ids[3];
|
||||
let plain = drawn(&app, &ctx);
|
||||
let first = geometry(&app, &ctx);
|
||||
app.session.ds_mut().unwrap().hover = Some(other);
|
||||
assert_eq!(drawn(&app, &ctx), plain + 1, "the hovered layer's outline");
|
||||
assert!(kept(&first, &geometry(&app, &ctx)), "the hover isn't part of the selection's geometry");
|
||||
app.session.ds_mut().unwrap().hover = None;
|
||||
app.session.tool.overlay.drop_target = Some(other);
|
||||
assert_eq!(drawn(&app, &ctx), plain + 1, "the drop target's outline");
|
||||
app.session.tool.overlay.drop_target = None;
|
||||
assert_eq!(drawn(&app, &ctx), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_design_panels_instance_facts_follow_edits_and_the_selection() {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
id(s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 20, "height": 20, "parent": f})).unwrap());
|
||||
let main = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
|
||||
let inst = id(s.execute("instance.create", &json!({"component": main, "x": 300, "y": 0})).unwrap());
|
||||
let child = s.doc().unwrap().node(inst).unwrap().children[0];
|
||||
let ctx = Context::default();
|
||||
let facts = |s: &Session| {
|
||||
let ds = s.ds().unwrap();
|
||||
crate::right::in_instance(&ctx, &ds.doc.clone(), &ds.selection.clone())
|
||||
};
|
||||
let facts_of = |s: &mut Session, ids: Value| {
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
let f = facts(s);
|
||||
(f.any, f.nested)
|
||||
};
|
||||
assert_eq!(facts_of(&mut s, json!([main])), (false, false));
|
||||
assert_eq!(facts_of(&mut s, json!([inst])), (true, false));
|
||||
assert_eq!(facts_of(&mut s, json!([inst, child])), (true, true));
|
||||
assert_eq!(facts_of(&mut s, json!([inst])), (true, false));
|
||||
s.execute("component.detach", &json!({"ids": [inst]})).unwrap();
|
||||
assert!(s.doc().unwrap().node(inst).is_some(), "detaching keeps the layer");
|
||||
assert_eq!(s.ds().unwrap().selection, vec![inst], "and the selection");
|
||||
assert_eq!(facts(&s), crate::right::InInstance { any: false, nested: false }, "an edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_position_fields_follow_edits_and_the_selection() {
|
||||
let (mut app, ctx, ids) = setup();
|
||||
let x = |app: &FigApp| {
|
||||
let ds = app.session.ds().unwrap();
|
||||
crate::right::position_values(&ctx, &ds.doc.clone(), &ds.selection.clone()).x
|
||||
};
|
||||
assert_eq!(x(&app), None, "mixed");
|
||||
app.session.execute("select.set", &json!({"ids": [ids[1]]})).unwrap();
|
||||
assert_eq!(x(&app), Some(80.0), "the selection");
|
||||
app.set_props(&[ids[1]], json!({"x": 90}));
|
||||
assert_eq!(x(&app), Some(90.0), "an edit");
|
||||
}
|
||||
@@ -307,6 +307,7 @@ pub use control::{ControlReply, ControlRequest, RequestGate, RequestState};
|
||||
mod app;
|
||||
mod clock;
|
||||
mod open;
|
||||
mod selection_cache;
|
||||
mod services;
|
||||
mod state;
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! alignment in its cell, and clip content. Everything goes through `node.set` props or `layout.*`
|
||||
//! commands.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use figcraft_doc::{AutoLayout, CellAlign, ContentAlign, CrossAlign, Document, LayoutMode, MainAlign, Node, NodeId, NodeKind, Sizing};
|
||||
use serde_json::json;
|
||||
@@ -25,7 +27,7 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> {
|
||||
|
||||
/// Show selection sizing and layout controls; offer Add only when every selected container has
|
||||
/// no auto layout and the selection is editable outside an instance.
|
||||
pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId]) {
|
||||
let ids = sel.to_vec();
|
||||
let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
|
||||
let layout_containers = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group)));
|
||||
@@ -35,8 +37,8 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
|
||||
let title = if has_al { tr("Auto layout") } else { tr("Layout") };
|
||||
// An instance's auto layout is its main's (only padding, gap and alignment are its own); the
|
||||
// layers inside it override their size and resizing, not their place in the layout.
|
||||
let in_instance = sel.iter().any(|i| figcraft_engine::components::owning_instance(doc, *i).is_some());
|
||||
let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i));
|
||||
let facts = crate::right::in_instance(ui.ctx(), doc, sel);
|
||||
let (in_instance, nested) = (facts.any, facts.nested);
|
||||
let btns: Vec<(Icon, &str)> = if in_instance {
|
||||
vec![]
|
||||
} else if layout_containers && no_al {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! The right panel: Design, Prototype and Inspect tabs.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_color::{BlendMode, Paint, PaintKind};
|
||||
use figcraft_doc::{
|
||||
@@ -274,7 +276,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
|
||||
crate::layout_panel::layout_section(app, ui, &doc, &sel);
|
||||
widgets::separator(ui);
|
||||
}
|
||||
if vector_edit_none && (arc_section(app, ui, &doc, &sel) || points_section(app, ui, &doc, &sel)) {
|
||||
if vector_edit_none && arc_section(app, ui, &doc, &sel) || points_section(app, ui, &doc, &sel) {
|
||||
widgets::separator(ui);
|
||||
}
|
||||
appearance(app, ui, &doc, &sel);
|
||||
@@ -500,21 +502,95 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
crate::component_panel::instance_section(app, ui, doc, sel);
|
||||
}
|
||||
|
||||
fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
/// Whether a selected layer is an instance or in one, and whether one is inside an instance (not
|
||||
/// the instance itself): the Design panel asks every frame, kept while the document and the
|
||||
/// selection stay the same ([`crate::selection_cache`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct InInstance {
|
||||
pub any: bool,
|
||||
pub nested: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn in_instance(ctx: &egui::Context, doc: &Arc<Document>, sel: &[NodeId]) -> InInstance {
|
||||
use figcraft_engine::components::{inside_instance, owning_instance};
|
||||
crate::selection_cache::cached(ctx, "in instance", doc, sel, &[], || InInstance {
|
||||
any: sel.iter().any(|i| owning_instance(doc, *i).is_some()),
|
||||
nested: sel.iter().any(|i| inside_instance(doc, *i)),
|
||||
})
|
||||
}
|
||||
|
||||
fn position(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId]) {
|
||||
widgets::section_header(ui, tr("Position"), &[], false);
|
||||
// Layers inside an instance take their place and constraints from the main component.
|
||||
let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i));
|
||||
let nested = in_instance(ui.ctx(), doc, sel).nested;
|
||||
ui.add_enabled_ui(!nested, |ui| position_fields(app, ui, doc, sel))
|
||||
.response
|
||||
.on_disabled_hover_text(tr("Layers in an instance take their position from the main component"));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
/// What the position fields show of the selection (`None`: mixed), kept like [`in_instance`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct PositionValues {
|
||||
pub can_align: bool,
|
||||
pub x: Option<f64>,
|
||||
pub y: Option<f64>,
|
||||
pub rot: Option<f64>,
|
||||
/// Every selected layer is in an auto layout frame, and whether they ignore its layout.
|
||||
pub parent_al: bool,
|
||||
pub absolute: Option<bool>,
|
||||
/// Every selected layer is in a frame that places it by constraints, and those constraints.
|
||||
pub in_frame: bool,
|
||||
pub constraints: Option<figcraft_doc::Constraints>,
|
||||
}
|
||||
|
||||
/// What the X field shows of a layer (in its frame's space: groups have none of their own); a
|
||||
/// relative edit of mixed values starts from it.
|
||||
fn x_of(doc: &Document, n: &Node) -> f64 {
|
||||
(doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
fn y_of(doc: &Document, n: &Node) -> f64 {
|
||||
(doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
fn rot_of(n: &Node) -> f64 {
|
||||
(figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
pub(crate) fn position_values(ctx: &egui::Context, doc: &Arc<Document>, sel: &[NodeId]) -> PositionValues {
|
||||
let parent = |n: &Node| n.parent.and_then(|p| doc.node(p));
|
||||
crate::selection_cache::cached(ctx, "position", doc, sel, &[], || PositionValues {
|
||||
can_align: figcraft_engine::cmd::arrange::can_align(doc, sel),
|
||||
x: common(doc, sel, |n| x_of(doc, n)),
|
||||
y: common(doc, sel, |n| y_of(doc, n)),
|
||||
rot: common(doc, sel, rot_of),
|
||||
parent_al: sel.iter().all(|i| doc.node(*i).and_then(parent).is_some_and(|p| p.layout.is_some())),
|
||||
absolute: common(doc, sel, |n| n.child_layout.absolute),
|
||||
// Constraints: children of frames without auto layout, or ignoring it.
|
||||
in_frame: sel.iter().all(|i| {
|
||||
doc.node(*i).is_some_and(|n| parent(n).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute)))
|
||||
}),
|
||||
constraints: common(doc, sel, |n| n.constraints),
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the
|
||||
/// absolute-position toggle and frame constraints.
|
||||
fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
let ids = sel.to_vec();
|
||||
fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId]) {
|
||||
let pv = position_values(ui.ctx(), doc, sel);
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
align_row(app, ui, sel, pv.can_align, col);
|
||||
position_row(app, ui, doc, sel, &pv, col);
|
||||
rotation_row(app, ui, sel, pv.rot, col);
|
||||
if pv.in_frame {
|
||||
constraints_row(app, ui, sel, pv.constraints, col);
|
||||
}
|
||||
}
|
||||
|
||||
/// Alignment, and distribution and tidy up for three or more layers.
|
||||
fn align_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], can_align: bool, col: f32) {
|
||||
let ids = sel.to_vec();
|
||||
widgets::sublabels(ui, tr("Alignment"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let cmds = ["align.left", "align.hcenter", "align.right", "align.top", "align.vcenter", "align.bottom"];
|
||||
@@ -528,7 +604,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
|
||||
(Icon::AlignBottom, tip(tr("Align bottom"), cmds[5])),
|
||||
];
|
||||
let w = col / 3.0;
|
||||
let picked = ui.add_enabled_ui(figcraft_engine::cmd::arrange::can_align(doc, sel), |ui| {
|
||||
let picked = ui.add_enabled_ui(can_align, |ui| {
|
||||
let h = widgets::segmented(ui, &items[..3], None, w);
|
||||
let v = widgets::segmented(ui, &items[3..], None, w).map(|i| i + 3);
|
||||
h.or(v)
|
||||
@@ -553,23 +629,20 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
|
||||
});
|
||||
}
|
||||
});
|
||||
// In the frame's space: groups have none of their own.
|
||||
// What each field shows of a layer (a relative edit of mixed values starts from it).
|
||||
let x_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0;
|
||||
let y_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0;
|
||||
let rot_of = |n: &Node| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0;
|
||||
let (x, y, rot) = (common(doc, sel, x_of), common(doc, sel, y_of), common(doc, sel, rot_of));
|
||||
let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()));
|
||||
let absolute = common(doc, sel, |n| n.child_layout.absolute);
|
||||
}
|
||||
|
||||
/// X and Y, and the absolute-position toggle for children of auto layout frames.
|
||||
fn position_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], pv: &PositionValues, col: f32) {
|
||||
let ids = sel.to_vec();
|
||||
widgets::sublabels(ui, tr("Position"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), x, "", col, 1.0);
|
||||
app.field_of(&ids, "x", e, x_of, |v| json!(v));
|
||||
let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), y, "", col, 1.0);
|
||||
app.field_of(&ids, "y", e, y_of, |v| json!(v));
|
||||
let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), pv.x, "", col, 1.0);
|
||||
app.field_of(&ids, "x", e, |n| x_of(doc, n), |v| json!(v));
|
||||
let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), pv.y, "", col, 1.0);
|
||||
app.field_of(&ids, "y", e, |n| y_of(doc, n), |v| json!(v));
|
||||
// Children of auto layout frames can ignore the layout (absolute position).
|
||||
if parent_al {
|
||||
let on = absolute.unwrap_or(false);
|
||||
if pv.parent_al {
|
||||
let on = pv.absolute.unwrap_or(false);
|
||||
if icons::button(
|
||||
ui,
|
||||
Icon::AbsolutePosition,
|
||||
@@ -583,6 +656,11 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rotation, with the rotate and flip buttons.
|
||||
fn rotation_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], rot: Option<f64>, col: f32) {
|
||||
let ids = sel.to_vec();
|
||||
widgets::sublabels(ui, tr("Rotation"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field_mixed(ui, Id::unique("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0);
|
||||
@@ -596,50 +674,38 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
|
||||
app.run(["object.rotateRight", "object.flipH", "object.flipV"][i], json!({"ids": ids}));
|
||||
}
|
||||
});
|
||||
// Constraints (children of frames without auto layout, or ignoring it).
|
||||
let in_frame = sel.iter().all(|i| {
|
||||
doc.node(*i).is_some_and(|n| {
|
||||
n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute))
|
||||
})
|
||||
});
|
||||
if in_frame {
|
||||
let c = common(doc, sel, |n| n.constraints);
|
||||
widgets::sublabels(ui, tr("Constraints"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into());
|
||||
let resp = widgets::dropdown(ui, tr(&pretty(&h)), col, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for (l, v) in [
|
||||
(tr("Left"), "left"),
|
||||
(tr("Right"), "right"),
|
||||
(tr("Left & right"), "leftRight"),
|
||||
(tr("Center"), "center"),
|
||||
(tr("Scale"), "scale"),
|
||||
] {
|
||||
if menu_item(ui, l, "") {
|
||||
app.set_props(&ids, json!({"constraints": {"horizontal": v}}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The horizontal and vertical constraints of children of frames without auto layout.
|
||||
fn constraints_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], c: Option<figcraft_doc::Constraints>, col: f32) {
|
||||
let ids = sel.to_vec();
|
||||
widgets::sublabels(ui, tr("Constraints"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into());
|
||||
let resp = widgets::dropdown(ui, tr(&pretty(&h)), col, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for (l, v) in
|
||||
[(tr("Left"), "left"), (tr("Right"), "right"), (tr("Left & right"), "leftRight"), (tr("Center"), "center"), (tr("Scale"), "scale")]
|
||||
{
|
||||
if menu_item(ui, l, "") {
|
||||
app.set_props(&ids, json!({"constraints": {"horizontal": v}}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into());
|
||||
let resp = widgets::dropdown(ui, tr(&pretty(&v)), col, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for (l, val) in [
|
||||
(tr("Top"), "top"),
|
||||
(tr("Bottom"), "bottom"),
|
||||
(tr("Top & bottom"), "topBottom"),
|
||||
(tr("Center"), "center"),
|
||||
(tr("Scale"), "scale"),
|
||||
] {
|
||||
if menu_item(ui, l, "") {
|
||||
app.set_props(&ids, json!({"constraints": {"vertical": val}}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into());
|
||||
let resp = widgets::dropdown(ui, tr(&pretty(&v)), col, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for (l, val) in
|
||||
[(tr("Top"), "top"), (tr("Bottom"), "bottom"), (tr("Top & bottom"), "topBottom"), (tr("Center"), "center"), (tr("Scale"), "scale")]
|
||||
{
|
||||
if menu_item(ui, l, "") {
|
||||
app.set_props(&ids, json!({"constraints": {"vertical": val}}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The selected ellipses' arc: Start (degrees), Sweep (a share of a full turn, negative running
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Values worked out from the document and the selection that several frames show unchanged
|
||||
//! (#1571): a pointer move draws about three frames, and a large selection's outlines, slots and
|
||||
//! mixed values cost milliseconds each time. A value is kept with the document snapshot it was
|
||||
//! made from (compared by pointer: an edit is a new snapshot), the selection and whatever else it
|
||||
//! depends on (`extra`: the view's mapping to the screen, a colour…), and made again when any
|
||||
//! differs.
|
||||
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
|
||||
/// One kept value and what it was made from. The document is held weakly, so a kept value never
|
||||
/// keeps an old snapshot alive; the weak reference keeps its address from being reused.
|
||||
#[derive(Clone)]
|
||||
struct Kept<T> {
|
||||
doc: Weak<Document>,
|
||||
sel: Vec<NodeId>,
|
||||
extra: Vec<u64>,
|
||||
value: T,
|
||||
}
|
||||
|
||||
/// The value `name` for `doc`, `sel` and `extra`: the kept one when they are what it was made
|
||||
/// from, else `make()`, kept for next time.
|
||||
pub(crate) fn cached<T: Clone + Send + Sync + 'static>(
|
||||
ctx: &egui::Context,
|
||||
name: &'static str,
|
||||
doc: &Arc<Document>,
|
||||
sel: &[NodeId],
|
||||
extra: &[u64],
|
||||
make: impl FnOnce() -> T,
|
||||
) -> T {
|
||||
let id = egui::Id::unique(("selection_cache", name));
|
||||
if let Some(k) = ctx.data(|d| d.get_temp::<Kept<T>>(id))
|
||||
&& std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(doc))
|
||||
&& k.sel == sel
|
||||
&& k.extra == extra
|
||||
{
|
||||
return k.value;
|
||||
}
|
||||
let value = make();
|
||||
ctx.data_mut(|d| d.insert_temp(id, Kept { doc: Arc::downgrade(doc), sel: sel.to_vec(), extra: extra.to_vec(), value: value.clone() }));
|
||||
value
|
||||
}
|
||||
|
||||
/// A screen point as key material.
|
||||
pub(crate) fn bits(p: egui::Pos2) -> [u64; 2] {
|
||||
[u64::from(p.x.to_bits()), u64::from(p.y.to_bits())]
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control caLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.Line truncated
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -75,6 +75,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #1571 (part): a large selection redid the same work on every frame. A pointer move draws two to five egui frames, and each one worked out every selected layer's outline, the selection box and size, the slots shown, and the Design panel's instance checks and position values (X, Y, rotation, constraints, alignment) again, though only the first frame after a move or an edit can differ. Now they are kept with the document snapshot, the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any of those changes (`selection_cache`); the hover and drop-target highlights are drawn every frame as before. Nothing looks different. Dragging 648 layers of a synthetic file of about 1,900 (release build, control-driven, two frames per move): the canvas overlays 1.99 to 0.86 ms a frame, the Design panel 2.54 to 1.40 ms. Not yet: the frame of each move still works everything out (the selection spacing, the export section, the outlines), the canvas texture is uploaded whole after each render. Tests: `canvas/selection_cache_tests.rs`. |
|
||||
| 2026-10-30 | minor | M8, #1089: libraries and plugins larger than one address's download burst (16 MiB) could never be downloaded: the per-address token bucket never held more than its burst, so a private server's 40 or 64 MiB team library was refused for good. Now a download larger than the burst is served once the address's budget is full, leaving it in debt that refills at 1 MiB a second before its next download (`Bucket::take_spread`, also for everyone's library budget); within the burst nothing changes. Over any stretch of time an address still gets at most the largest object plus the rate times that time. `server.md` (rates, threat model). Tests: `download_burst_tests.rs` (the bucket's loan and payback, ten minutes of 40 MiB requests kept to the rate, a 40 MiB library served and then the address waits). |
|
||||
| 2026-10-30 | minor | M8, #1099: a batch's edits add to the one undo step it pushed, so it keeps one snapshot instead of one per step (1,000 steps: +165 MiB to +10 MiB peak); still one undo step, rolled back as a unit. Tests: `batch_undo_snapshot_tests` |
|
||||
| 2026-10-30 | minor | M8, #1101: the REST import's layer walk runs on a work list (it took about 4 KB of stack a level, a megabyte at the 256-level cap); `.fig` and REST files 256 levels deep import on a 1 MB stack, the browser's. Tests: `deep_import_tests` |
|
||||
|
||||
Reference in new issue
Block a user