Merge main into #1548

This commit is contained in:
bflatastic
2026-10-30 00:19:46 +07:00
33 changed files with 907 additions and 71 deletions
+2 -1
View File
@@ -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-29 · **Change:** minor (Thai, Lao, Khmer and Burmese lines break between words (#1219); 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 calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Thai, Lao, Khmer and Burmese lines break between words (#1219); 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 calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
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
@@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). |
| 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills (1,000 rectangles filled with one pattern: 1,000 tile drawings before, 1 now, same image fill), and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one, so patterns nested in patterns stop at the budget. Tests: `tile_tests` (svg). Numbers unchanged |
| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged |
| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged |
| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged |
+9
View File
@@ -85,6 +85,15 @@ impl eframe::App for WebShell {
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// Pointer and key events sent over the control channel (`ui.input`), for the frame about
// to draw: the batch is applied by this frame and acknowledged once it is presented, as on
// the desktop (`apps/figcraft/src/main.rs`). Without this hook the batch sat in its queue
// and its promise never settled.
let events = figcraft_ui_egui::windows::take_input(&mut self.fig, raw_input.viewport_id);
raw_input.events.extend(events);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.fig.control_queue.extend(crate::api::take_requests());
self.fig.ui(ui);
+3
View File
@@ -95,3 +95,6 @@ sync_github_name = true
[people.StrajnarFilip]
display_name = "Filip Strajnar"
[people.Carbocode]
sync_github_name = true
+7 -1
View File
@@ -268,6 +268,12 @@ impl Variables {
self.variables.iter().find(|v| v.id == id || v.name == id)
}
/// Look up an already resolved variable id (a stored binding or alias): exact ids only, so a
/// variable named like an id never stands in for it.
pub fn get_by_id(&self, id: &str) -> Option<&Variable> {
self.variables.iter().find(|v| v.id == id)
}
pub fn collection(&self, id: &str) -> Option<&Collection> {
self.collections.iter().find(|c| c.id == id || c.name == id)
}
@@ -277,7 +283,7 @@ impl Variables {
pub fn resolve(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<VariableValue> {
let mut cur = id.to_string();
for _ in 0..32 {
let var = self.get(&cur)?;
let var = self.get_by_id(&cur)?;
let coll = self.collection(&var.collection)?;
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
match var.values.get(mode).or_else(|| var.values.values().next())? {
+36
View File
@@ -310,6 +310,42 @@ fn variables_resolve_modes_and_aliases() {
assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None);
}
#[test]
fn variables_resolve_stored_ids_never_by_name() {
let mut v = Variables::default();
v.collections.push(Collection {
id: "c1".into(),
name: "Theme".into(),
modes: vec![Mode { id: "m".into(), name: "Mode 1".into() }],
extends: None,
});
let var = |id: &str, name: &str, value: VariableValue| Variable {
id: id.into(),
name: name.into(),
collection: "c1".into(),
kind: VariableType::Color,
values: std::collections::BTreeMap::from([("m".to_string(), value)]),
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
};
let red = Color::from_hex("#FF0000").unwrap();
let blue = Color::from_hex("#0D99FF").unwrap();
// Named like the next variable's id, and like an id that no longer exists.
v.variables.push(var("VariableID:1", "VariableID:2", VariableValue::Color(red)));
v.variables.push(var("VariableID:2", "surface", VariableValue::Color(blue)));
v.variables.push(var("VariableID:3", "VariableID:9", VariableValue::Color(red)));
v.variables.push(var("VariableID:4", "alias", VariableValue::Alias("VariableID:9".into())));
let modes = std::collections::BTreeMap::new();
assert_eq!(v.resolve("VariableID:2", &modes), Some(VariableValue::Color(blue)));
// A deleted id stays unresolved instead of picking up the variable named like it.
assert_eq!(v.resolve("VariableID:9", &modes), None);
assert_eq!(v.resolve("VariableID:4", &modes), None);
}
#[test]
fn serde_roundtrip_and_repair() {
let mut d = Document::new("x");
+73
View File
@@ -9,6 +9,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox};
use serde::Serialize;
use crate::guides;
use crate::tools::SNAP_PX;
/// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label
/// (a distance) at its middle; `dashed` marks a measurement's extension line.
@@ -127,6 +128,56 @@ impl Targets {
t
}
/// Snap lines for a guide on `owner` (a page or a top-level frame): a page's top-level layers,
/// or a frame's own box, layout grids and descendants, in the owner's own space so a rotated
/// frame's guide snaps along its axes.
pub fn gather_for_guide(doc: &Document, page: NodeId, owner: NodeId) -> Targets {
let space = if owner == page { Affine::IDENTITY } else { doc.world_transform(owner) };
let to = space.inverse();
let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite());
let mut t = Targets { space, ..Default::default() };
if owner == page {
if let Some(p) = doc.node(page) {
for c in &p.children {
if doc.node(*c).is_some_and(|n| n.visible)
&& let Some(b) = bbox(*c)
{
t.add_box(b);
}
}
}
return t;
}
// The frame's own box and its layout grids.
if let Some(b) = bbox(owner) {
t.add_box(b);
let (gx, gy) = figcraft_layout::grid_snap_lines_in(doc, owner, to * doc.world_transform(owner));
for x in gx {
t.xs.push((x, b.y0, b.y1));
}
for y in gy {
t.ys.push((y, b.x0, b.x1));
}
}
// Everything in the frame. Nested layers count, so a guide over a group reaches its parts; a
// hidden layer hides its whole branch, so a visible child of a hidden group isn't a target.
let mut stack: Vec<(NodeId, bool)> = doc.node(owner).map(|n| n.children.iter().rev().map(|c| (*c, true)).collect()).unwrap_or_default();
let mut seen = 0;
while let Some((id, visible)) = stack.pop() {
seen += 1;
if seen > 5_000_000 {
break;
}
let Some(n) = doc.node(id) else { continue };
let visible = visible && n.visible;
if visible && let Some(b) = bbox(id) {
t.add_box(b);
}
stack.extend(n.children.iter().rev().map(|c| (*c, visible)));
}
t
}
/// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it.
pub fn add_box(&mut self, r: Rect) {
for x in [r.x0, r.center().x, r.x1] {
@@ -335,6 +386,16 @@ impl Targets {
(dx, dy, self.guides_spans(&xs, &ys))
}
/// The correction along one axis (x when `vertical`, else y) to the nearest target line within
/// `threshold`, in snap space. A guide moves on its own axis only, so the other is left alone.
pub fn snap_axis(&self, vertical: bool, offset: f64, threshold: f64) -> Option<f64> {
if !offset.is_finite() || !threshold.is_finite() || threshold < 0.0 {
return None;
}
let lines = if vertical { &self.xs } else { &self.ys };
lines.iter().map(|(v, _, _)| v - offset).filter(|d| d.is_finite() && d.abs() <= threshold).min_by(|a, b| a.abs().total_cmp(&b.abs()))
}
/// Guide lines for edges of `r` that coincide with targets.
pub fn guides_for(&self, r: Rect) -> Vec<Guide> {
let (xs, ys) = rect_spans(r);
@@ -363,6 +424,18 @@ impl Targets {
}
}
/// Snap a guide on `owner` (a page or a top-level frame) to the nearest layer edge or centre along
/// its own axis, within `SNAP_PX` screen pixels at `zoom`. Returns the new offset in `owner`'s
/// coordinates, or `None` when nothing is in reach.
pub fn snap_guide(doc: &Document, page: NodeId, owner: NodeId, vertical: bool, offset: f64, zoom: f64) -> Option<f64> {
// The guide moves along the owner's own axis (x for a vertical guide, y for a horizontal one),
// which the owner's transform may scale: convert the screen-pixel reach into owner units.
let [a, b, c, d, _, _] = doc.world_transform(owner).as_coeffs();
let axis = if vertical { a.hypot(b) } else { c.hypot(d) };
let threshold = SNAP_PX / (zoom.max(1e-6) * axis.max(1e-6));
Targets::gather_for_guide(doc, page, owner).snap_axis(vertical, offset, threshold).map(|d| offset + d).filter(|v| v.is_finite())
}
/// The parent of the moving layers (the first one's), or the page.
fn parent_of(doc: &Document, page: NodeId, moving: &[NodeId]) -> NodeId {
moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page)
+8 -6
View File
@@ -611,7 +611,7 @@ fn apply_corners(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value)
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?,
"devStatus" => {
let st = if v.is_null() {
Some(DevStatus::None)
@@ -705,7 +705,7 @@ fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -
"contentAlign" | "counterAxisAlignContent" => {
layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
}
"wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false),
"wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?,
"gridColumns" | "gridRows" => {
let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
@@ -714,8 +714,10 @@ fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -
*n = count;
tracks.truncate(count as usize);
}
"strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false),
"reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false),
"strokesIncluded" => {
layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?
}
"reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?,
_ => return Ok(false),
}
Ok(true)
@@ -823,7 +825,7 @@ fn apply_meta(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
"interactions" => doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?,
"overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?,
"scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?,
"constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false),
"constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?,
_ => return Ok(false),
}
Ok(true)
@@ -1004,7 +1006,7 @@ fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
"operandOperation" => set_operand_op(doc, id, v, cmd)?,
"sectionContentsHidden" => {
if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind {
*contents_hidden = v.as_bool().unwrap_or(false);
*contents_hidden = v.as_bool().ok_or_else(|| bad(cmd, "`sectionContentsHidden` must be a boolean"))?;
}
}
"background" => {
+46 -14
View File
@@ -587,11 +587,14 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"ids": out}))
}
/// Infer direction, gap and padding from where children are (Figma's "Add auto layout").
/// Infer direction, gap and padding from visible, non-absolute children, matching the layout flow.
fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout {
let Some(f) = doc.node(frame) else { return AutoLayout::default() };
let boxes: Vec<Rect> =
f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect();
let boxes: Vec<Rect> = figcraft_layout::flow_children(doc, frame)
.iter()
.filter_map(|c| doc.node(*c))
.map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect()))
.collect();
if boxes.is_empty() {
return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() };
}
@@ -608,12 +611,43 @@ fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout {
AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() }
}
/// Sort unmasked spans by position along the layout axis. Masks stay at their original indices;
/// each visible mask's following content keeps its paint order until the next mask, hidden or not.
fn sort_layout_children(doc: &Document, kids: &mut [NodeId], vertical: bool) {
let mut start = 0;
let mut masked = false;
for end in 0..=kids.len() {
let mask = kids.get(end).and_then(|id| doc.node(*id)).filter(|n| n.is_mask);
if end != kids.len() && mask.is_none() {
continue;
}
if !masked && let Some(span) = kids.get_mut(start..end) {
span.sort_by(|a, b| {
let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
});
}
start = end + 1;
masked = mask.is_some_and(|n| n.visible);
}
}
/// Add inferred auto layout to existing frames or one group converted in place; wrap other
/// selections. Preserve mask boundaries and a converted group's Fill sizing in its parent.
fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("layout.add", s, p)?;
let dir = string(p, "direction");
let doc = s.doc()?;
let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()));
let targets = if all_frames {
let single_group = ids.len() == 1 && ids.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Group));
let in_place = all_frames || single_group;
let targets = if in_place {
if single_group {
// Layout must arrange the group's children directly. Wrapping the group would
// leave them in a single fixed-size layout item and add an unnecessary layer.
convert(s, &json!({"ids": ids}), "layout.add", true)?;
}
ids
} else {
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
@@ -634,23 +668,21 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
_ => LayoutMode::Horizontal,
};
}
// Children in reading order along the axis.
let mut kids = doc.get(*f)?.children.clone();
let vertical = al.mode == LayoutMode::Vertical;
kids.sort_by(|a, b| {
let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
});
sort_layout_children(doc, &mut kids, al.mode == LayoutMode::Vertical);
let empty = doc.node(*f).is_some_and(|n| n.children.iter().filter_map(|c| doc.node(*c)).all(|c| !c.visible || c.child_layout.absolute));
let n = doc.get_mut(*f)?;
n.children = kids;
n.layout = Some(al);
if !all_frames || !empty {
if !in_place || !empty {
// A frame wrapped around the selection hugs it; an existing frame with no flow
// children keeps its size instead of collapsing to its padding.
n.child_layout.sizing_h = Sizing::Hug;
n.child_layout.sizing_v = Sizing::Hug;
if !single_group || n.child_layout.sizing_h != Sizing::Fill {
n.child_layout.sizing_h = Sizing::Hug;
}
if !single_group || n.child_layout.sizing_v != Sizing::Fill {
n.child_layout.sizing_v = Sizing::Hug;
}
}
}
s.set_selection(targets.clone());
+9 -2
View File
@@ -784,7 +784,7 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn bind_variable(cmd: &str, doc: &mut Document, ids: &[NodeId], field: &str, vid: Option<&str>) -> Result<()> {
// An instance's component property takes a variable of its type; other fields the types their
// scopes belong to.
if let Some(v) = vid.and_then(|v| doc.variables.get(v)) {
if let Some(v) = vid.and_then(|v| doc.variables.get_by_id(v)) {
for i in ids {
match field.strip_prefix(crate::components::PROP_BINDING) {
Some(name) => super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?,
@@ -1253,7 +1253,14 @@ pub(crate) fn set_paint_color(list: &mut Vec<Paint>, i: usize, stop: Option<usiz
}
}
(Some(p), None) => p.kind = PaintKind::Solid { color: c },
(None, None) if i == 0 => list.push(Paint::solid(c)),
(None, None) if i == 0 => {
// A bound colour owns its alpha, so the new paint keeps it out of the opacity.
let mut p = Paint::solid(c);
p.kind = PaintKind::Solid { color: c };
p.opacity = 1.0;
p.color_alpha = 1.0;
list.push(p);
}
(None, _) => {}
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
/// `text` as a CSS comment or quoted string body.
fn css_safe(text: &str) -> String {
text.replace("*/", "* /").replace(['"', '\\', '\n'], " ")
text.replace("*/", "* /").replace(['"', '\\', '\n', '\r', '\u{c}'], " ")
}
/// Each collection's variables as custom properties (named as generated code names them): the
@@ -29,6 +29,155 @@ fn kind(s: &Session, n: NodeId) -> NodeKind {
s.doc().unwrap().node(n).unwrap().kind.clone()
}
#[test]
fn adding_auto_layout_converts_a_single_group_and_arranges_its_children_directly() {
let mut s = Session::new();
let page = s.page().unwrap();
let (a, b) = (rect(&mut s, page, 10.0, 20.0), rect(&mut s, page, 90.0, 20.0));
let group = id(ex(&mut s, "object.group", json!({"ids": [a, b], "name": "Actions"})));
let siblings = s.doc().unwrap().node(page).unwrap().children.clone();
let before = [page_box(&s, group), page_box(&s, a), page_box(&s, b)];
let result = ex(&mut s, "layout.add", json!({"ids": [group], "direction": "horizontal"}));
assert_eq!(result["ids"], json!([group]), "the group's identity is retained");
let doc = s.doc().unwrap();
let frame = doc.node(group).unwrap();
assert!(matches!(frame.kind, NodeKind::Frame));
assert_eq!(frame.name, "Actions");
assert_eq!(frame.parent, Some(page));
assert_eq!(frame.children, vec![a, b], "no intermediate group or wrapper frame");
assert!(frame.layout.is_some() && frame.fills.is_empty() && !frame.clip);
assert_eq!(doc.node(page).unwrap().children, siblings);
assert_eq!([page_box(&s, group), page_box(&s, a), page_box(&s, b)], before);
// The second child now follows changes to the first through auto layout.
ex(&mut s, "node.set", json!({"id": a, "props": {"width": 60}}));
assert_eq!(page_box(&s, b), [110.0, 20.0, 150.0, 40.0]);
s.undo().unwrap();
s.undo().unwrap();
let restored = s.doc().unwrap().node(group).unwrap();
assert!(matches!(restored.kind, NodeKind::Group) && restored.layout.is_none());
assert_eq!(restored.children, vec![a, b]);
assert_eq!([page_box(&s, group), page_box(&s, a), page_box(&s, b)], before);
s.redo().unwrap();
assert!(matches!(kind(&s, group), NodeKind::Frame));
assert!(s.doc().unwrap().node(group).unwrap().layout.is_some());
}
#[test]
fn adding_auto_layout_to_multiple_groups_still_wraps_the_selection() {
let mut s = Session::new();
let page = s.page().unwrap();
let (a, b) = (rect(&mut s, page, 10.0, 20.0), rect(&mut s, page, 90.0, 20.0));
let g1 = id(ex(&mut s, "object.group", json!({"ids": [a]})));
let g2 = id(ex(&mut s, "object.group", json!({"ids": [b]})));
let result = ex(&mut s, "layout.add", json!({"ids": [g1, g2]}));
let frame = crate::params::id_of(&result["ids"][0]).unwrap();
let doc = s.doc().unwrap();
assert!(matches!(doc.node(frame).unwrap().kind, NodeKind::Frame));
assert_eq!(doc.node(frame).unwrap().children, vec![g1, g2]);
assert!(matches!(kind(&s, g1), NodeKind::Group) && matches!(kind(&s, g2), NodeKind::Group));
}
#[test]
fn adding_auto_layout_preserves_mask_runs_and_their_rendering() {
let mut s = Session::new();
let page = s.page().unwrap();
let (right, left) = (rect(&mut s, page, 300.0, 0.0), rect(&mut s, page, 0.0, 0.0));
let (mask, content) = (rect(&mut s, page, 100.0, 0.0), rect(&mut s, page, 80.0, 0.0));
let (mask2, content2) = (rect(&mut s, page, 200.0, 0.0), rect(&mut s, page, 180.0, 0.0));
let group = id(ex(&mut s, "object.group", json!({"ids": [right, left, mask, content, mask2, content2]})));
ex(&mut s, "node.set", json!({"ids": [mask, mask2], "props": {"isMask": true}}));
// Absolute layers keep the overlaps, isolating paint order from normal flow placement.
ex(&mut s, "node.set", json!({"ids": [mask, content, mask2, content2], "props": {"absolute": true}}));
let before = figcraft_render::render_node(s.doc().unwrap(), group, 1.0).unwrap();
ex(&mut s, "layout.add", json!({"ids": [group]}));
let doc = s.doc().unwrap();
let frame = doc.node(group).unwrap();
assert_eq!(frame.children, vec![left, right, mask, content, mask2, content2], "sort the unmasked prefix, retain mask runs");
let runs: Vec<Vec<NodeId>> = doc.paint_runs(frame).iter().map(|r| frame.children[r.clone()].to_vec()).collect();
assert_eq!(runs, vec![vec![left], vec![right], vec![mask, content], vec![mask2, content2]]);
let after = figcraft_render::render_node(doc, group, 1.0).unwrap();
assert_eq!((after.width(), after.height(), after.data()), (before.width(), before.height(), before.data()));
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(group).unwrap().children, vec![right, left, mask, content, mask2, content2]);
}
#[test]
fn a_hidden_mask_keeps_its_boundary_but_unmasked_children_still_sort() {
let mut s = Session::new();
let page = s.page().unwrap();
let mask = rect(&mut s, page, 100.0, 0.0);
let content = rect(&mut s, page, 80.0, 0.0);
let hidden_mask = rect(&mut s, page, 200.0, 0.0);
let (right, left) = (rect(&mut s, page, 300.0, 0.0), rect(&mut s, page, 250.0, 0.0));
let group = id(ex(&mut s, "object.group", json!({"ids": [mask, content, hidden_mask, right, left]})));
ex(&mut s, "node.set", json!({"ids": [mask, hidden_mask], "props": {"isMask": true}}));
ex(&mut s, "node.set", json!({"id": hidden_mask, "props": {"visible": false}}));
ex(&mut s, "layout.add", json!({"ids": [group], "direction": "horizontal"}));
let doc = s.doc().unwrap();
let frame = doc.node(group).unwrap();
assert_eq!(frame.children, vec![mask, content, hidden_mask, left, right]);
let runs: Vec<Vec<NodeId>> = doc.paint_runs(frame).iter().map(|r| frame.children[r.clone()].to_vec()).collect();
assert_eq!(runs, vec![vec![mask, content], vec![hidden_mask], vec![left], vec![right]]);
}
#[test]
fn adding_auto_layout_infers_gaps_from_flow_children_only() {
for props in [json!({"visible": false}), json!({"absolute": true})] {
let mut s = Session::new();
let page = s.page().unwrap();
let a = rect(&mut s, page, 0.0, 0.0);
let excluded = rect(&mut s, page, 80.0, 0.0);
let b = rect(&mut s, page, 160.0, 0.0);
let group = id(ex(&mut s, "object.group", json!({"ids": [a, excluded, b]})));
ex(&mut s, "node.set", json!({"id": excluded, "props": props}));
let before = [page_box(&s, a), page_box(&s, b)];
ex(&mut s, "layout.add", json!({"ids": [group]}));
let layout = s.doc().unwrap().node(group).unwrap().layout.as_ref().unwrap();
assert_eq!(layout.gap, 120.0);
assert_eq!([page_box(&s, a), page_box(&s, b)], before);
}
}
#[test]
fn hidden_children_do_not_influence_inferred_direction_or_padding() {
let mut s = Session::new();
let frame = id(ex(&mut s, "frame.create", json!({"width": 300, "height": 200})));
let (a, b) = (rect(&mut s, frame, 10.0, 20.0), rect(&mut s, frame, 90.0, 20.0));
let hidden = rect(&mut s, frame, 0.0, 1000.0);
ex(&mut s, "node.set", json!({"id": hidden, "props": {"visible": false}}));
let before = [page_box(&s, a), page_box(&s, b)];
ex(&mut s, "layout.add", json!({"ids": [frame]}));
let layout = s.doc().unwrap().node(frame).unwrap().layout.as_ref().unwrap();
assert_eq!(layout.mode, figcraft_doc::LayoutMode::Horizontal);
assert_eq!(layout.padding, [20.0, 170.0, 160.0, 10.0]);
assert_eq!(layout.gap, 40.0);
assert_eq!([page_box(&s, a), page_box(&s, b)], before);
}
#[test]
fn converting_a_group_preserves_fill_on_each_axis_in_its_parent() {
for (h, v) in [("fill", "fixed"), ("fixed", "fill"), ("fill", "fill")] {
let mut s = Session::new();
let parent = id(ex(&mut s, "frame.create", json!({"width": 500, "height": 300})));
let (a, b) = (rect(&mut s, parent, 0.0, 0.0), rect(&mut s, parent, 80.0, 0.0));
let group = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
let sibling = rect(&mut s, parent, 200.0, 0.0);
ex(&mut s, "node.set", json!({"id": parent, "props": {"layout": "horizontal", "padding": 0, "gap": 10}}));
ex(&mut s, "node.set", json!({"id": group, "props": {"sizingH": h, "sizingV": v}}));
let before = [page_box(&s, group), page_box(&s, sibling)];
ex(&mut s, "layout.add", json!({"ids": [group]}));
let cl = &s.doc().unwrap().node(group).unwrap().child_layout;
assert_eq!(cl.sizing_h, if h == "fill" { Sizing::Fill } else { Sizing::Hug });
assert_eq!(cl.sizing_v, if v == "fill" { Sizing::Fill } else { Sizing::Hug });
assert_eq!([page_box(&s, group), page_box(&s, sibling)], before);
s.undo().unwrap();
assert!(matches!(kind(&s, group), NodeKind::Group));
assert_eq!([page_box(&s, group), page_box(&s, sibling)], before);
}
}
#[test]
fn a_frame_becomes_a_group_around_its_layers() {
let mut s = Session::new();
+135
View File
@@ -0,0 +1,135 @@
//! Guides snap to layer edges and centres when dragged out of a ruler or moved, on the guide's own
//! axis only, within five screen pixels at any zoom (page guides see the page's top-level layers;
//! frame guides see the frame's box, layout grids and every layer in it).
use figcraft_doc::NodeId;
use figcraft_geom::Affine;
use serde_json::json;
use crate::Session;
use crate::params::id_of;
use crate::snap::{Targets, snap_guide};
fn id(v: serde_json::Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
fn page_snap(s: &Session, vertical: bool, offset: f64, zoom: f64) -> Option<f64> {
let (doc, page) = (s.doc().unwrap(), s.page().unwrap());
snap_guide(doc, page, page, vertical, offset, zoom)
}
#[test]
fn a_page_guide_snaps_to_a_top_level_layers_edges_and_centre() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 300, "width": 50, "height": 40})).unwrap();
// Edges at 100 and 150, centre at 125; within five pixels at zoom 1.
assert_eq!(page_snap(&s, true, 102.0, 1.0), Some(100.0));
assert_eq!(page_snap(&s, true, 124.0, 1.0), Some(125.0));
assert_eq!(page_snap(&s, true, 148.0, 1.0), Some(150.0));
// Out of reach.
assert_eq!(page_snap(&s, true, 106.0, 1.0), None);
// The guide's own axis only: x 302 is far from the x edges, y 302 is a pixel above 300.
assert_eq!(page_snap(&s, true, 302.0, 1.0), None);
assert_eq!(page_snap(&s, false, 302.0, 1.0), Some(300.0));
}
#[test]
fn the_reach_is_five_screen_pixels_at_any_zoom() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 300, "width": 50, "height": 40})).unwrap();
// Six page pixels away: out of reach at zoom 2 (2.5), in reach at zoom 0.5 (10).
assert_eq!(page_snap(&s, true, 106.0, 2.0), None);
assert_eq!(page_snap(&s, true, 106.0, 0.5), Some(100.0));
// A zoom of zero is clamped rather than dividing by zero.
assert_eq!(page_snap(&s, true, 100.0, 0.0), Some(100.0));
}
#[test]
fn a_frame_guide_snaps_to_its_box_its_children_and_nested_layers() {
let mut s = Session::new();
let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap());
let inner = id(s.execute("node.create", &json!({"type": "frame", "parent": frame, "x": 10, "y": 10, "width": 60, "height": 60})).unwrap());
s.execute("node.create", &json!({"type": "rectangle", "parent": inner, "x": 20, "y": 20, "width": 20, "height": 20})).unwrap();
let outside = id(s.execute("node.create", &json!({"type": "rectangle", "x": 1000, "y": 1000, "width": 20, "height": 20})).unwrap());
let page = s.page().unwrap();
let snap = |s: &Session, vertical: bool, offset: f64| snap_guide(s.doc().unwrap(), page, frame, vertical, offset, 1.0);
// The frame's own right edge is 300.
assert_eq!(snap(&s, true, 299.0), Some(300.0));
// The nested rectangle sits at 30 in the frame; its left edge and centre (40) are targets.
assert_eq!(snap(&s, true, 31.0), Some(30.0));
assert_eq!(snap(&s, true, 41.0), Some(40.0));
// The layer outside the frame is not.
assert_eq!(snap(&s, true, 999.0), None);
// Hiding the nested rectangle takes its lines away.
let inner_rect = s.doc().unwrap().node(inner).unwrap().children[0];
s.execute("node.set", &json!({"ids": [inner_rect], "props": {"visible": false}})).unwrap();
assert_eq!(snap(&s, true, 31.0), None);
// A layer outside the frame still doesn't matter, and `outside` exists to prove the scope.
assert!(s.doc().unwrap().node(outside).is_some());
}
#[test]
fn a_frame_guide_snaps_along_the_frames_own_axes_when_it_is_turned() {
let mut s = Session::new();
let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap());
s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 40, "y": 20, "width": 20, "height": 20})).unwrap();
s.execute("node.set", &json!({"ids": [frame], "props": {"rotation": 30.0}})).unwrap();
let (doc, page) = (s.doc().unwrap(), s.page().unwrap());
let t = Targets::gather_for_guide(doc, page, frame);
assert_ne!(t.space, Affine::IDENTITY, "guides snap in the frame's space");
assert!(t.xs.iter().any(|(x, _, _)| near(*x, 40.0)), "{:?}", t.xs);
// The rotation rounds the offsets in the last bits, so compare with a tolerance.
assert!(snap_guide(doc, page, frame, true, 41.0, 1.0).is_some_and(|v| near(v, 40.0)));
assert!(snap_guide(doc, page, frame, false, 21.0, 1.0).is_some_and(|v| near(v, 20.0)));
}
#[test]
fn a_hidden_container_hides_its_children_from_snapping() {
let mut s = Session::new();
let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap());
let inner = id(s.execute("node.create", &json!({"type": "frame", "parent": frame, "x": 10, "y": 10, "width": 60, "height": 60})).unwrap());
let child = id(s.execute("node.create", &json!({"type": "rectangle", "parent": inner, "x": 20, "y": 20, "width": 20, "height": 20})).unwrap());
let page = s.page().unwrap();
let snap = |s: &Session, offset: f64| snap_guide(s.doc().unwrap(), page, frame, true, offset, 1.0);
// The inner frame's left edge is 10 in the frame; the nested rectangle's is 30.
assert_eq!(snap(&s, 11.0), Some(10.0));
assert_eq!(snap(&s, 31.0), Some(30.0));
// Hiding the inner frame hides its branch: the child's own flag stays true, but its edge is
// not a target any more (nor is the hidden frame's).
s.execute("node.set", &json!({"ids": [inner], "props": {"visible": false}})).unwrap();
assert!(s.doc().unwrap().node(child).unwrap().visible);
assert_eq!(snap(&s, 11.0), None);
assert_eq!(snap(&s, 31.0), None);
}
#[test]
fn the_reach_is_five_screen_pixels_across_a_scaled_frame() {
let mut s = Session::new();
let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 50, "width": 300, "height": 200})).unwrap());
s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
// The frame is drawn at twice its own size, so an owner unit is two screen pixels at zoom 1.
s.doc_mut().unwrap().node_mut(frame).unwrap().transform = Affine::scale(2.0);
let page = s.page().unwrap();
let snap = |s: &Session, offset: f64, zoom: f64| snap_guide(s.doc().unwrap(), page, frame, true, offset, zoom);
// Five screen pixels are 2.5 frame units: two snaps to the edge at 100, three doesn't.
assert_eq!(snap(&s, 102.0, 1.0), Some(100.0));
assert_eq!(snap(&s, 103.0, 1.0), None);
// Zooming in halves the owner-space reach again (1.25 frame units at 2x zoom).
assert_eq!(snap(&s, 102.0, 2.0), None);
}
#[test]
fn a_guide_with_nothing_near_stays_where_it_is() {
let s = Session::new();
let page = s.page().unwrap();
let doc = s.doc().unwrap();
assert_eq!(snap_guide(doc, page, page, true, 12.5, 1.0), None);
assert_eq!(snap_guide(doc, page, page, false, 12.5, 1.0), None);
// Non-finite input is refused rather than spread.
assert_eq!(snap_guide(doc, page, page, true, f64::NAN, 1.0), None);
}
+2
View File
@@ -309,6 +309,8 @@ mod gesture_preview_tests;
#[cfg(test)]
mod gradient_shorthand_tests;
#[cfg(test)]
mod guide_snap_tests;
#[cfg(test)]
mod hide_publishing_tests;
#[cfg(test)]
mod history_behaviour_tests;
+6 -2
View File
@@ -171,7 +171,9 @@ fn library_update_follows(s: &mut Session, lib: &mut Session, local: &str, [inne
// Same copies (same ids), new values: the bound layers and the alias follow.
assert_eq!(copy_of(s, "primary").id, local);
assert_eq!((fill(s, inner), fill(s, outer)), ("222222".into(), "FF0000".into()));
assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF"));
assert!(
matches!(s.doc().unwrap().variables.resolve(&s.doc().unwrap().variables.get("brand").unwrap().id, &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF")
);
assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night");
assert_eq!(ex(s, "library.updates", json!({}))["count"], 0);
// Undo takes the update back.
@@ -234,7 +236,9 @@ fn bind_alias_mode_read_only_and_updates() {
ex(&mut s, "variable.create", json!({"collection": "App", "name": "gap", "type": "number", "value": 2}));
ex(&mut s, "library.aliasVariable", json!({"library": lid, "alias": "accent", "variable": "brand"}));
let modes = Default::default();
assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FF00"));
assert!(
matches!(s.doc().unwrap().variables.resolve(&s.doc().unwrap().variables.get("brand").unwrap().id, &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FF00")
);
assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": "gap"})).is_err(), "types differ");
assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": local})).is_err(), "copies are read-only");
+24
View File
@@ -194,3 +194,27 @@ fn node_find_leaves_document_selection_page_and_history_untouched() {
assert_ne!(s.doc().unwrap().get(layer).unwrap().name, "Previous edit");
assert_eq!(s.ds().unwrap().history.undo.len(), undo - 1);
}
#[test]
fn node_set_rejects_non_boolean_flags() {
let mut s = Session::new();
let page = s.page().unwrap();
let rect = add(&mut s, page, NodeKind::Rectangle, "R");
let frame = add(&mut s, page, NodeKind::Frame, "F");
ex(&mut s, "node.set", json!({"ids": [rect], "props": {"isMask": true, "constrainProportions": true}}));
ex(&mut s, "node.set", json!({"ids": [frame], "props": {"layoutMode": "HORIZONTAL", "wrap": true, "strokesIncluded": true, "reverseZ": true}}));
for (id, key) in [(rect, "isMask"), (rect, "constrainProportions"), (frame, "wrap"), (frame, "strokesIncluded"), (frame, "reverseZ")] {
let r = s.execute("node.set", &json!({"ids": [id], "props": {key: "yes"}}));
assert!(r.is_err(), "{key} accepted a non-boolean");
}
let sec = add(&mut s, page, NodeKind::Section { contents_hidden: false }, "S");
ex(&mut s, "node.set", json!({"ids": [sec], "props": {"sectionContentsHidden": true}}));
let r = s.execute("node.set", &json!({"ids": [sec], "props": {"sectionContentsHidden": "yes"}}));
assert!(r.is_err(), "sectionContentsHidden accepted a non-boolean");
let doc = s.doc().unwrap();
assert!(matches!(doc.get(sec).unwrap().kind, figcraft_doc::NodeKind::Section { contents_hidden: true }));
assert!(doc.get(rect).unwrap().is_mask && doc.get(rect).unwrap().constrain_proportions);
let l = doc.get(frame).unwrap();
let l = l.layout.as_ref().unwrap();
assert!(l.wrap && l.strokes_included && l.reverse_z);
}
+4 -7
View File
@@ -373,17 +373,14 @@ fn variable_keys_inside_typed_paint_lists_stay_literal_but_bare_wrappers_error()
}
#[test]
fn variable_bind_keeps_its_legacy_type_lookup_when_a_name_collides_with_an_id() {
fn variable_bind_type_checks_the_found_variable_when_a_name_collides_with_an_id() {
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let number = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "size", "type": "number", "value": 20}))["id"].clone();
let paint = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FF0000"}))["id"].clone();
run(&mut s, "variable.update", json!({"variable": number, "name": paint}));
let id = create(&mut s, json!({}));
let before = s.doc().unwrap().clone();
let depth = s.undo_depth();
let e = s.execute("variable.bind", &json!({"id": id, "field": "fill", "variable": "surface"})).unwrap_err().to_string();
assert!(e.contains("`fill` doesn't take a number variable"), "{e}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth(), depth);
run(&mut s, "variable.bind", json!({"id": id, "field": "fill", "variable": "surface"}));
assert_eq!(s.doc().unwrap().node(id).unwrap().bound.get("fill").map(String::as_str), paint.as_str());
assert_eq!(color(&s, id, "fill").to_hex(), "FF0000");
}
@@ -480,3 +480,21 @@ fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() {
assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye");
assert!(node(&s, t).bound.is_empty());
}
#[test]
fn a_translucent_variable_bound_to_an_empty_fill_applies_its_alpha_once() {
// #1514: the first paint added by a binding must not take the colour's alpha as opacity too.
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
run(
&mut s,
"variable.create",
json!({"collection": "Theme", "name": "tint", "type": "color", "values": {"Light": "#FF000080", "Dark": "#00FF0040"}}),
);
let r = id(run(&mut s, "node.create", json!({"type": "rectangle", "props": {"fills": []}})));
run(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "tint"}));
assert_eq!(node(&s, r).fills[0].opacity, 1.0);
run(&mut s, "variable.setMode", json!({"collection": "Theme", "mode": "Dark", "ids": [r]}));
assert_eq!(node(&s, r).fills[0].solid_color().unwrap().to_css_hex(), "#00FF0040");
assert_eq!(node(&s, r).fills[0].opacity, 1.0);
}
+12
View File
@@ -513,3 +513,15 @@ fn review_a_long_alias_chain_keeps_its_role_to_the_end() {
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
assert!(css.contains("--o0: 0.5;"), "{css}");
}
#[test]
fn css_export_neutralises_every_css_newline_in_strings() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "t", "type": "string", "value": "a\rb\u{c}c"}));
for format in ["css", "tailwind"] {
let css = ex(&mut s, "variable.export", json!({"format": format}))["text"].as_str().unwrap_or_default().to_string();
assert!(!css.contains('\r') && !css.contains('\u{c}'), "{css:?}");
assert!(css.contains("\"a b c\""), "{css:?}");
}
}
+2
View File
@@ -14,3 +14,5 @@ mod scope_reset;
mod selection_colors;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod shape_builder_response;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod variable_binding_ids;
@@ -0,0 +1,32 @@
use figcraft_engine::Session;
use serde_json::{Value, json};
/// Variable A is renamed to variable B's id; returns (session, B's id).
fn renamed_to_other_id(a_type: &str, a_value: Value) -> (Session, String) {
let mut s = Session::new();
s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
let a = s.execute("variable.create", &json!({"collection": "Theme", "name": "shadow", "type": a_type, "value": a_value})).unwrap();
let a = a["id"].as_str().unwrap().to_string();
let b = s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})).unwrap();
let b = b["id"].as_str().unwrap().to_string();
s.execute("variable.update", &json!({"variable": a, "name": b})).unwrap();
(s, b)
}
#[test]
fn a_bound_id_wins_over_a_variable_named_like_it() {
let (mut s, b) = renamed_to_other_id("color", json!("#FF0000"));
let r = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": b}}})).unwrap();
let node = s.execute("node.get", &json!({"id": r["id"]})).unwrap();
let text = node.to_string();
assert!(text.contains(&format!("\"boundVariables\":{{\"fill\":\"{b}\"}}")), "{text}");
assert!(text.contains("#0D99FF"), "{text}");
assert!(!text.contains("#FF0000"), "{text}");
}
#[test]
fn the_type_check_uses_the_bound_id() {
let (mut s, b) = renamed_to_other_id("number", json!(4));
let r = s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": b}}}));
assert!(r.is_ok(), "{r:?}");
}
+10 -8
View File
@@ -5955,12 +5955,14 @@ mod tests {
assert_eq!(doc.variables.collections.len(), 1);
assert_eq!(doc.variables.variables.len(), 5);
let values = &doc.variables;
assert!(matches!(values.resolve("Core/color.primary", &BTreeMap::new()), Some(VariableValue::Color(_))));
assert_eq!(values.resolve("Core/spacing.sm", &BTreeMap::new()), Some(VariableValue::Number(8.0)));
assert!(matches!(values.resolve("Core/copy.cta", &BTreeMap::new()), Some(VariableValue::String(ref s)) if s == "Buy"));
assert_eq!(values.resolve("Core/flags.enabled", &BTreeMap::new()), Some(VariableValue::Boolean(true)));
assert!(matches!(values.resolve(&values.get("Core/color.primary").unwrap().id, &BTreeMap::new()), Some(VariableValue::Color(_))));
assert_eq!(values.resolve(&values.get("Core/spacing.sm").unwrap().id, &BTreeMap::new()), Some(VariableValue::Number(8.0)));
assert!(
matches!(values.resolve(&values.get("Core/copy.cta").unwrap().id, &BTreeMap::new()), Some(VariableValue::String(ref s)) if s == "Buy")
);
assert_eq!(values.resolve(&values.get("Core/flags.enabled").unwrap().id, &BTreeMap::new()), Some(VariableValue::Boolean(true)));
assert!(warnings.iter().any(|s| s.contains("themes")));
assert!(matches!(values.resolve("Core/aliases.secondary", &BTreeMap::new()), Some(VariableValue::Color(_))));
assert!(matches!(values.resolve(&values.get("Core/aliases.secondary").unwrap().id, &BTreeMap::new()), Some(VariableValue::Color(_))));
assert!(!warnings.iter().any(|s| s.contains("unsupported or unresolved")));
let page = doc.node(doc.first_page().unwrap()).unwrap();
assert_eq!(
@@ -6118,9 +6120,9 @@ mod tests {
assert_eq!(doc.variables.variables.len(), 7);
let v = &doc.variables;
assert!(matches!(v.get("Core/palette.middle").unwrap().values.values().next(), Some(VariableValue::Alias(_))));
assert!(matches!(v.resolve("Core/palette.accent", &BTreeMap::new()), Some(VariableValue::Color(_))));
assert_eq!(v.resolve("Core/space.copy", &BTreeMap::new()), Some(VariableValue::Number(12.0)));
assert_eq!(v.resolve("Core/labelAlias", &BTreeMap::new()), Some(VariableValue::String("Button".into())));
assert!(matches!(v.resolve(&v.get("Core/palette.accent").unwrap().id, &BTreeMap::new()), Some(VariableValue::Color(_))));
assert_eq!(v.resolve(&v.get("Core/space.copy").unwrap().id, &BTreeMap::new()), Some(VariableValue::Number(12.0)));
assert_eq!(v.resolve(&v.get("Core/labelAlias").unwrap().id, &BTreeMap::new()), Some(VariableValue::String("Button".into())));
assert!(!warnings.iter().any(|w| w.contains("unsupported or unresolved")), "{warnings:?}");
let saved = crate::save_bytes(&doc, None).unwrap();
let (reloaded, issues) = crate::load_bytes(&saved).unwrap();
+6 -2
View File
@@ -34,8 +34,10 @@ mod paint_tests;
mod shape_tests;
#[cfg(test)]
mod structure_tests;
#[cfg(test)]
mod tile_tests;
use std::collections::{BTreeSet, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use figcraft_color::{BlendMode, ImageScaleMode, Paint};
use figcraft_doc::{Document, Effect, MaskType, Node, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, WindingRule};
@@ -96,11 +98,13 @@ pub(crate) struct Ctx<'a> {
fonts: Option<text::Fonts>,
/// Patterns being drawn into tile images (inside one another).
pattern_depth: usize,
/// Tiles drawn so far, by pattern and scale (see [`paint`]'s `render_tile`).
tiles: HashMap<(*const usvg::Pattern, u64), Option<paint::Tile>>,
}
impl<'a> Ctx<'a> {
pub(crate) fn new(doc: &'a mut Document) -> Self {
Ctx { doc, count: 0, anonymous: HashSet::new(), notes: BTreeSet::new(), fonts: None, pattern_depth: 0 }
Ctx { doc, count: 0, anonymous: HashSet::new(), notes: BTreeSet::new(), fonts: None, pattern_depth: 0, tiles: HashMap::new() }
}
pub(crate) fn full(&self) -> bool {
+26 -5
View File
@@ -228,17 +228,36 @@ fn unstale(root: &usvg::Group) -> (&usvg::Group, Affine) {
(g, base)
}
/// A pattern tile drawn into an image: the image's hash, its size and its pixels → pattern space
/// transform.
pub(crate) type Tile = (String, f64, f64, Affine);
/// Draws pattern `pat`'s tile into an image (at twice its size on the page, at most [`MAX_TILE`]
/// a side): the image's hash, its size and its pixels → pattern space transform.
fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option<(String, f64, f64, Affine)> {
/// a side), once per pattern and scale in an import: other uses get the same tile.
fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option<Tile> {
if cx.pattern_depth >= 2 {
return None;
}
let r = pat.rect();
let (rw, rh) = (f64::from(r.width()), f64::from(r.height()));
let k = (length_scale(tile) * 2.0).max(0.01).min(MAX_TILE / rw.max(rh));
// The tile depends only on the pattern and the scale; the tree outlives the import, so its
// address names the pattern (ids repeat across embedded documents).
let key = (std::ptr::from_ref(pat), k.to_bits());
if let Some(t) = cx.tiles.get(&key) {
return t.clone();
}
let t = draw_tile(cx, pat, k);
cx.tiles.insert(key, t.clone());
t
}
/// [`render_tile`]'s drawing at scale `k`, its layers counted in the import's layer budget.
fn draw_tile(cx: &mut Ctx, pat: &usvg::Pattern, k: f64) -> Option<Tile> {
let r = pat.rect();
let (rw, rh) = (f64::from(r.width()), f64::from(r.height()));
let (pw, ph) = ((rw * k).ceil().max(1.0), (rh * k).ceil().max(1.0));
if !(pw.is_finite() && ph.is_finite()) || pw > MAX_TILE || ph > MAX_TILE {
if !(pw.is_finite() && ph.is_finite()) || pw > MAX_TILE || ph > MAX_TILE || cx.full() {
return None;
}
let mut sub = Document::new("Pattern");
@@ -247,13 +266,15 @@ fn render_tile(cx: &mut Ctx, pat: &usvg::Pattern, tile: Affine) -> Option<(Strin
if let Some(f) = sub.node_mut(frame) {
f.fills.clear();
}
let notes = {
let (notes, count) = {
let mut scx = Ctx::new(&mut sub);
scx.count = cx.count;
scx.pattern_depth = cx.pattern_depth + 1;
let (content, base) = unstale(pat.root());
children(&mut scx, content, frame, base, 0);
std::mem::take(&mut scx.notes)
(std::mem::take(&mut scx.notes), scx.count)
};
cx.count = count;
cx.notes.extend(notes);
let view = figcraft_render::View { origin: (0.0, 0.0), scale: k, width: pw as u32, height: ph as u32 };
let mut pix = figcraft_render::Pixmap::new(pw as u32, ph as u32)?;
+83
View File
@@ -0,0 +1,83 @@
//! Pattern tiles: drawn once per pattern and scale in an import, their layers counted in the
//! import's layer budget.
use figcraft_color::{Paint, PaintKind};
use figcraft_doc::{Document, NodeKind};
use figcraft_geom::Affine;
use super::{Ctx, MAX_NODES, children, import_svg_with_notes};
fn svg(defs: &str, body: &str) -> String {
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><defs>{defs}</defs>{body}</svg>"##)
}
/// What importing `data` with `spent` layers of the budget already used leaves behind: the tiles
/// drawn, the layers counted and the notes.
fn walk(data: &str, spent: usize) -> (usize, usize, Vec<String>) {
let tree = usvg::Tree::from_data(data.as_bytes(), &usvg::Options::default()).unwrap();
let mut d = Document::new("tiles");
let page = d.first_page().unwrap();
let frame = d.create(page, NodeKind::Frame, "f", 0.0, 0.0, 100.0, 100.0).unwrap();
let mut cx = Ctx::new(&mut d);
cx.count = spent;
children(&mut cx, tree.root(), frame, Affine::IDENTITY, 0);
(cx.tiles.len(), cx.count, cx.notes.into_iter().collect())
}
const DOTS: &str = r##"<pattern id="p" patternUnits="userSpaceOnUse" width="10" height="10"><circle cx="5" cy="5" r="3"/><rect width="2" height="2"/><rect x="8" width="2" height="2"/></pattern>"##;
#[test]
fn a_pattern_used_a_thousand_times_is_drawn_once() {
let uses: String = (0..1000).map(|i| format!(r#"<rect x="{}" y="{}" width="10" height="10" fill="url(#p)"/>"#, i % 10 * 10, i / 10)).collect();
let (tiles, count, _) = walk(&svg(DOTS, &uses), 0);
assert_eq!(tiles, 1);
// A thousand rectangles and the tile's three layers, once.
assert_eq!(count, 1003);
// Another scale is another tile.
let scaled = format!(r#"{uses}<rect transform="scale(3)" width="10" height="10" fill="url(#p)"/>"#);
assert_eq!(walk(&svg(DOTS, &scaled), 0).0, 2);
}
/// The image fills of `data`'s layers, and the images they show.
fn fills(data: &str) -> Vec<(Paint, Vec<u8>)> {
let mut d = Document::new("fills");
let page = d.first_page().unwrap();
let f = import_svg_with_notes(&mut d, data.as_bytes(), page, 0.0, 0.0, "x").unwrap().frame;
let layers: Vec<_> = d.subtree(f).into_iter().filter_map(|id| d.node(id)).filter_map(|n| n.fills.first().cloned()).collect();
layers
.into_iter()
.map(|p| {
let PaintKind::Image(ip) = &p.kind else { panic!("not an image fill: {p:?}") };
let bytes = d.images.get(&ip.image).unwrap().bytes.to_vec();
(p, bytes)
})
.collect()
}
#[test]
fn a_reused_tile_is_the_tile_a_single_use_draws() {
let once = fills(&svg(DOTS, r#"<rect width="40" height="40" fill="url(#p)"/>"#));
let twice = fills(&svg(DOTS, r#"<rect width="40" height="40" fill="url(#p)"/><rect x="50" width="40" height="40" fill="url(#p)"/>"#));
assert_eq!(once.len(), 1);
assert_eq!(twice.len(), 2);
assert!(twice.iter().all(|f| *f == once[0]), "both uses show the single use's tile");
}
#[test]
fn a_nested_pattern_bomb_is_refused_within_the_budget() {
// Pattern `a` holds 50 layers, each filled with pattern `b` at its own scale (a tile each),
// and `b` holds 50 layers: 2,601 layers from one rectangle (each of `a`'s layers comes with
// the group carrying its transform).
let b: String = (0..50).map(|_| r#"<rect width="1" height="1"/>"#).collect();
let a: String = (1..=50).map(|i| format!(r#"<rect transform="scale(1.{i:02})" width="1" height="1" fill="url(#b)"/>"#)).collect();
let defs = format!(
r#"<pattern id="b" patternUnits="userSpaceOnUse" width="4" height="4">{b}</pattern><pattern id="a" patternUnits="userSpaceOnUse" width="4" height="4">{a}</pattern>"#
);
let data = svg(&defs, r#"<rect width="40" height="40" fill="url(#a)"/>"#);
// Every tile's layers count.
assert_eq!(walk(&data, 0).1, 2601);
// With 500 layers left the tiles stop there.
let (_, count, notes) = walk(&data, MAX_NODES - 500);
assert!(count <= MAX_NODES, "{count}");
assert!(notes.iter().any(|n| n.contains("the rest was left out")), "{notes:?}");
}
+30 -14
View File
@@ -23,7 +23,8 @@ enum GuideDrag {
/// Dragging a new guide out of a ruler.
New { vertical: bool, frame: Option<NodeId> },
/// Moving guide `index` of `owner` (a page or frame); `copy` (Alt at the press) drops a new one.
Move { owner: NodeId, index: usize, vertical: bool, copy: bool, frame: Option<NodeId> },
/// `from` is where it was pressed, so a click without movement only selects it.
Move { owner: NodeId, index: usize, vertical: bool, copy: bool, frame: Option<NodeId>, from: Pos2 },
}
impl GuideDrag {
@@ -97,23 +98,28 @@ fn place(doc: &Document, owner: NodeId, index: usize, vertical: bool, offset: f6
Some(Placed { owner, index, vertical, offset, line: Some(guides::segment(doc, owner, vertical, offset)?) })
}
/// Guide `index` of `owner` (a page or frame) through page point `p`, its offset rounded to whole
/// pixels with `snap` (else to hundredths).
fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, snap: bool) -> Option<Placed> {
/// Guide `index` of `owner` (a page or frame) through page point `p`. Its offset snaps to a nearby
/// layer edge or centre when `object_zoom` is set (`None`: object snapping off), then to whole
/// pixels with `pixel` (else to hundredths); a snapped offset keeps the edge exactly.
fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, pixel: bool, object_zoom: Option<f64>) -> Option<Placed> {
let ds = app.session.ds().ok()?;
let v = guides::offset_at(&ds.doc, owner, vertical, p)?;
let offset = if snap { v.round() } else { (v * 100.0).round() / 100.0 };
let offset = match object_zoom.and_then(|zoom| figcraft_engine::snap::snap_guide(&ds.doc, ds.page, owner, vertical, v, zoom)) {
Some(snapped) => snapped,
None if pixel => v.round(),
None => (v * 100.0).round() / 100.0,
};
if owner == ds.page { Some(Placed { owner, index, vertical, offset, line: None }) } else { place(&ds.doc, owner, index, vertical, offset) }
}
/// A guide dragged out of a ruler (`vertical`: the left one) and dropped at page point `p`: on the
/// top-level frame under it, in the frame's coordinates and along its axis nearer the ruler's, or
/// on the page.
fn drop_target(app: &FigApp, p: Point, vertical: bool, snap: bool) -> Option<Placed> {
fn drop_target(app: &FigApp, p: Point, vertical: bool, pixel: bool, object_zoom: Option<f64>) -> Option<Placed> {
let ds = app.session.ds().ok()?;
match guides::drop_owner(&ds.doc, ds.page, p) {
f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, snap),
page => through(app, page, usize::MAX, vertical, p, snap),
f if f != ds.page => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, pixel, object_zoom),
page => through(app, page, usize::MAX, vertical, p, pixel, object_zoom),
}
}
@@ -182,18 +188,28 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
return false;
}
let r = app.canvas.rect;
let (pressed, released, alt) = ctx.input(|i| {
(i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.modifiers.alt)
let (pressed, released, alt, suspend) = ctx.input(|i| {
(
i.pointer.button_pressed(egui::PointerButton::Primary),
i.pointer.button_released(egui::PointerButton::Primary),
i.modifiers.alt,
i.modifiers.command || i.modifiers.ctrl,
)
});
if let Some(drag) = ctx.data(|d| d.get_temp::<GuideDrag>(drag_id())) {
let vertical = match drag {
GuideDrag::New { vertical, .. } | GuideDrag::Move { vertical, .. } => vertical,
};
let p = app.to_page(pos);
let snap = app.session.ds().map(|d| d.view.snap_pixel).unwrap_or(true);
let (pixel, object_zoom) =
app.session.ds().map(|d| (d.view.snap_pixel, (d.view.snap_objects && !suspend).then_some(d.view.zoom))).unwrap_or((true, None));
let at = match drag {
GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, snap),
GuideDrag::Move { owner, index, vertical, .. } => through(app, owner, index, vertical, p, snap),
GuideDrag::New { vertical, .. } => drop_target(app, p, vertical, pixel, object_zoom),
// A click only selects the guide: object snapping starts once the pointer moves.
GuideDrag::Move { owner, index, vertical, from, .. } => {
let moved = (pos - from).length() > 0.5;
through(app, owner, index, vertical, p, pixel, if moved { object_zoom } else { None })
}
};
// Alt: distances from the guide to the layers in the frame it crosses.
let frame = drag.frame().or_else(|| at.map(|g| g.owner).filter(|o| app.session.ds().is_ok_and(|d| d.page != *o)));
@@ -272,7 +288,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
app.ui.context_menu_shown = false;
} else {
ctx.data_mut(|d| {
d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical, copy: alt, frame })
d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical, copy: alt, frame, from: pos })
});
}
}
+27
View File
@@ -148,6 +148,33 @@ fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
#[test]
fn layout_add_button_requires_every_selected_container_to_have_no_layout() {
let mut s = Session::new();
let rect = id(s.execute("shape.rectangle", &json!({"width": 40, "height": 20})).unwrap());
let group = id(s.execute("object.group", &json!({"ids": [rect]})).unwrap());
let frame = id(s.execute("frame.create", &json!({"x": 200, "width": 100, "height": 60})).unwrap());
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
for (selection, layout, expected) in [
(vec![group], false, true),
(vec![frame, group], false, true),
(vec![frame, group], true, false),
(vec![frame], true, false),
(vec![], true, false),
] {
if layout {
u.app.session.execute("node.set", &json!({"id": frame, "props": {"layout": "horizontal"}})).unwrap();
}
u.app.session.execute("select.set", &json!({"ids": selection})).unwrap();
u.frame(vec![]);
let output = u.frame_output(vec![]);
let nodes = output.platform_output.accesskit_update.unwrap().nodes;
let has_add = nodes.iter().any(|(_, n)| n.role() == Role::Button && n.label() == Some("Add auto layout"));
assert_eq!(has_add, expected, "selection: {selection:?}, frame has layout: {layout}");
}
}
/// Rectangles A (10, 20, 40×30) and B (100, 50, 20×20); A is selected.
fn setup() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
+110
View File
@@ -0,0 +1,110 @@
//! Guides dragged out of a ruler snap to nearby layer edges and centres (Snap to Objects), on the
//! guide's own axis, and Cmd/Ctrl suspends that while the guides keep their pixel snapping.
use egui::{Event, Modifiers, Pos2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rulers(mut s: Session) -> Ui {
s.execute("view.rulers", &json!({"on": true})).unwrap();
Ui::new(s)
}
fn guides(u: &Ui, owner: NodeId) -> Vec<(bool, f64)> {
let ds = u.app.session.ds().unwrap();
ds.doc.node(owner).unwrap().guides().iter().map(|g| (g.vertical, g.offset)).collect()
}
fn page(u: &Ui) -> NodeId {
u.app.session.ds().unwrap().page
}
fn from_top_ruler(u: &Ui) -> Pos2 {
let r = u.app.canvas.rect;
Pos2::new(r.min.x + r.width() * 0.5, r.min.y + 5.0)
}
fn from_left_ruler(u: &Ui) -> Pos2 {
let r = u.app.canvas.rect;
Pos2::new(r.min.x + 5.0, r.min.y + r.height() * 0.5)
}
fn drag(u: &mut Ui, from: Pos2, to: Pos2) {
u.frame(vec![Event::PointerMoved(from)]);
u.button(from, true);
u.frame(vec![Event::PointerMoved(to)]);
u.button(to, false);
}
fn drag_with(u: &mut Ui, from: Pos2, to: Pos2, m: Modifiers) {
u.frame_with(vec![Event::PointerMoved(from)], m);
u.frame_with(vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m);
u.frame_with(vec![Event::PointerMoved(to)], m);
u.frame_with(vec![Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: m }], m);
}
#[test]
fn a_guide_dragged_from_a_ruler_snaps_to_a_layers_edge() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap();
let mut u = rulers(s);
let start = from_top_ruler(&u);
let to = u.app.to_screen(200.0, 102.0);
drag(&mut u, start, to);
assert_eq!(guides(&u, page(&u)), vec![(false, 100.0)], "two pixels from the top edge");
}
#[test]
fn a_guide_dragged_over_a_frame_snaps_to_a_layer_inside_it() {
let mut s = Session::new();
let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap());
s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
let mut u = rulers(s);
let start = from_left_ruler(&u);
let to = u.app.to_screen(112.0, 200.0);
drag(&mut u, start, to);
assert_eq!(guides(&u, frame), vec![(true, 10.0)], "two pixels from the child's left edge");
}
#[test]
fn with_snap_to_objects_off_the_guide_keeps_its_pixel_rounding() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap();
s.execute("view.snapObjects", &json!({"on": false})).unwrap();
let mut u = rulers(s);
let start = from_top_ruler(&u);
let to = u.app.to_screen(200.0, 102.0);
drag(&mut u, start, to);
assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)], "rounded to the pixel, not the edge");
}
#[test]
fn a_click_on_a_guide_only_selects_it() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "offset": 102})).unwrap();
let mut u = rulers(s);
// Two pixels above the rectangle's top edge, but a click without movement leaves it there.
let at = u.app.to_screen(500.0, 102.0);
u.button(at, true);
u.button(at, false);
assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)], "a click must not snap it to the edge at 100");
}
#[test]
fn cmd_or_ctrl_suspends_guide_snapping() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 100, "width": 120, "height": 80})).unwrap();
let mut u = rulers(s);
let start = from_top_ruler(&u);
let to = u.app.to_screen(200.0, 102.0);
drag_with(&mut u, start, to, Modifiers::CTRL);
assert_eq!(guides(&u, page(&u)), vec![(false, 102.0)]);
}
+2
View File
@@ -95,6 +95,8 @@ mod gradient_shorthand_tests;
#[cfg(test)]
mod guide_release_tests;
#[cfg(test)]
mod guide_snap_tests;
#[cfg(test)]
mod home_recents_tests;
#[cfg(test)]
mod home_tests;
+5 -1
View File
@@ -23,9 +23,13 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> {
doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p))
}
/// 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]) {
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)));
let no_al = !sel.is_empty() && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.layout.is_none()));
let al = common(doc, sel, |n| n.layout.clone());
let has_al = matches!(al, Some(Some(_)));
let title = if has_al { tr("Auto layout") } else { tr("Layout") };
@@ -35,7 +39,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i));
let btns: Vec<(Icon, &str)> = if in_instance {
vec![]
} else if frames && !has_al {
} else if layout_containers && no_al {
vec![(Icon::Plus, crate::shortcuts::tip(tr("Add auto layout"), "layout.add"))]
} else if has_al {
vec![(Icon::Minus, crate::shortcuts::tip(tr("Remove auto layout"), "layout.remove"))]
+22 -2
View File
@@ -1,6 +1,6 @@
# Auto layout and constraints parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
Auto layout is the most used layout feature in Figma and the one where small differences (a
hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%,
@@ -9,6 +9,24 @@ positions it implies (`crates/layout/src/behaviour_tests.rs`, 64 cases, and
`crates/engine/src/al_behaviour_tests.rs` through commands), and all pass; nothing yet against real
Figma files. Remaining: 3–6 h.
## Adding auto layout to a group
With one group selected, Add auto layout converts that group into a frame in place, retaining its
id, name, parent and direct children. The Layout sidebar’s +, context menu, Shift+A and API calls
all dispatch `layout.add`, so they share the conversion and a single undo step restores the group.
The layout settings are inferred from visible, non-absolute children, matching the layout engine’s
flow. Hidden and absolute children therefore do not distort direction, gap or padding. A converted
group keeps Fill sizing on either axis in its auto layout parent. Sorting by position preserves
mask boundaries and the paint order within masked runs. The sidebar offers Add only when every
selected container has no auto layout, including mixed selections.
This follows the Figma behavior reported by the contributor and the sidebar workflow users know from
similar design tools: start with a group and make its children participate in auto layout.
Wrapping the group would instead make the entire group one layout item, leaving its children
outside the new frame’s layout flow and adding an unnecessary level to the layer tree. Converting
the selected container makes later changes to a child’s size reflow its siblings directly.
Selecting several groups still wraps that selection in a shared auto layout frame.
## Rules from the documentation
Status: **pass** (a case in the suite holds), **fixed** (the suite found a divergence, now fixed),
@@ -69,7 +87,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` |
| Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | |
| Layout grids: columns, rows, grid; stretch/left/centre/right; variables bound | done | |
| Suggest auto layout / "Add auto layout" on a selection | done | adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) |
| Suggest auto layout / "Add auto layout" on a selection | done | the Layout sidebar exposes Add auto layout (+) for groups and frames using the same `layout.add` command as the context menu; a single group becomes a frame in place, keeping its id, name and direct children; adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) |
| Real Figma files laid out identically | **not checked** | needs `.fig` or REST import of real files ([gaps](gaps.md) #1, #3) |
| Performance: re-flow only where sizes or places changed | done | measured in `cargo xtask bench` (components at scale) |
@@ -77,6 +95,8 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | M2 review fixes: conversion preserves mask runs and Fill sizing per axis; layout inference uses visible, non-absolute flow children; the sidebar hides Add for mixed selections containing existing auto layout. Regression coverage includes rendered mask pixels, visible-child positions, parent layout stability and the sidebar accessibility tree. Numbers unchanged |
| 2026-10-29 | minor | M2: the Layout sidebar exposes Add auto layout (+) for groups. `layout.add` converts one selected group into a frame in place rather than wrapping it: same id, name and direct children; the layout arranges those children, and one undo restores the group. Sidebar, menu, shortcut and API calls all use the corrected command. This follows the contributor’s observed Figma behavior and the workflow familiar to users of similar design tools. Tests cover direct-child reflow, stable hierarchy, undo/redo and wrapping a multi-group selection. Numbers unchanged |
| 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged |
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. |
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -30,7 +30,7 @@ and prototype interactions are still unchecked there.
| Format | Figma | FigCraft | Notes | Hours |
|---|---|---|---|---|
| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); fuzzed | 2–4 (filters) |
| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on one path as text on a path (on several paths as outlines); a pattern's tile is drawn once per scale however many layers use it, and its layers count in the import's 100,000-layer budget; fuzzed | 2–4 (filters) |
| PNG, JPG | yes | yes | embedded ICC profiles (PNG `iCCP`, JPG APP2) and PNG `sRGB` / `gAMA` / `cHRM` chunks convert the image into the file's colour space; no profile (or CMYK, damaged): taken as sRGB | — |
| GIF | yes (animated) | **yes**, animated | the file is kept as is; the canvas and PNG, JPG and PDF exports show the first frame (SVG exports embed the file, so viewers that animate GIFs animate it), the presenter and the preview play it (frames decoded on first play: at most 500 frames and 128 MB, past that the rest don't play; always loops) | — |
| WebP | yes | yes | embedded ICC profile honoured | — |
@@ -184,6 +184,7 @@ source paint coordinates.
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills (1,000 rectangles filled with one pattern: 1,000 tile drawings before, 1 now, same image fill), and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one, so patterns nested in patterns stop at the budget. Tests: `tile_tests` (svg). Numbers unchanged |
| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged |
| 2026-10-29 | minor | Fonts: the missing-font row adds downloading a missing family the font store has (Google Fonts, Noto) from the Missing fonts window, after the user allows it (#754). Replacing a missing font with another is still missing. Numbers unchanged |
| 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) or is shaped like a content hash (64 hex digits, which the placeholder's bytes don't hash to) now shares that placeholder instead of being keyed by its raw name. The importers' largest image (256 MiB) is one constant, `figcraft_doc::ImageData::MAX_BYTES`, which live sessions use too. Numbers unchanged |
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Thai, Lao, Khmer and Burmese lines break between words (#1219); 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 calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Thai, Lao, Khmer and Burmese lines break between words (#1219); 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 calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
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
@@ -76,6 +76,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). |
| 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills, and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one. Tests: `tile_tests` (svg) |
| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged |
| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged |
| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps cover only where its mask or content can paint within the view (a 40 × 40 mask on a 3000 × 2000 view: 96 MB → 61.5 kB, same pixels), and isolated layers share a 2 GiB budget held at once per render thread; a layer past it is left out with a warning. Tests: `mask_bounds_tests` |
+3 -2
View File
File diff suppressed because one or more lines are too long