diff --git a/README.md b/README.md
index e17cd74c..75328de1 100644
--- a/README.md
+++ b/README.md
@@ -208,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
Prototype and hand off
- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
+ Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose, ArkUI, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
diff --git a/ROADMAP.md b/ROADMAP.md
index 9604db66..5677e22f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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-28 · **Change:** minor (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 (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
@@ -352,6 +352,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
@@ -433,6 +434,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
+| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged |
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs
index 1ba9d60a..8e689ec0 100644
--- a/apps/figcraft-cli/src/files.rs
+++ b/apps/figcraft-cli/src/files.rs
@@ -366,6 +366,7 @@ pub fn parse_script(text: &str) -> Result, String> {
.enumerate()
.map(|(i, s)| {
let cmd = s.get("command").and_then(Value::as_str).ok_or_else(|| format!("step {i}: `command` (a string) is required"))?;
+ figcraft_engine::batch::refuse_unknown_step_keys(i, &s).map_err(|e| e.to_string())?;
let params = match s.get("params") {
None | Some(Value::Null) => json!({}),
Some(p @ Value::Object(_)) => p.clone(),
@@ -443,6 +444,9 @@ mod tests {
assert!(parse_script("[{\"command\":\"a\",\"params\":3}]").is_err());
assert!(parse_script("not json\n").is_err());
assert!(parse_script("42").is_err());
+ // `args` for `params` would run the step without them (#1413).
+ let e = parse_script(r#"[{"command":"a"},{"command":"b","args":{"x":1}}]"#).unwrap_err().to_string();
+ assert!(e.contains("step 1: unknown key `args`"), "{e}");
}
#[test]
diff --git a/crates/doc/src/boolean_hit_tests.rs b/crates/doc/src/boolean_hit_tests.rs
index b826f202..5a8a0112 100644
--- a/crates/doc/src/boolean_hit_tests.rs
+++ b/crates/doc/src/boolean_hit_tests.rs
@@ -2,7 +2,7 @@
//! picks the group, and a double click goes one level deeper than the selection.
use crate::geometry::{enter_target, select_target};
-use crate::{BoolOp, Document, NodeId, NodeKind};
+use crate::{BoolOp, Document, NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
use figcraft_geom::Point;
fn page(d: &Document) -> NodeId {
@@ -40,6 +40,61 @@ fn hits_reach_operands_but_clicks_pick_the_group() {
assert_eq!(listed, vec![f, g, b]);
}
+#[test]
+fn ordinary_hit_uses_region_paints_without_layer_fill() {
+ let mut d = Document::new("region hit");
+ let p = page(&d);
+ let network = VectorNetwork {
+ vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(10.0, 0.0), Vertex::new(10.0, 10.0), Vertex::new(0.0, 10.0)],
+ segments: (0..4).map(|i| Segment::line(i, (i + 1) % 4)).collect(),
+ regions: vec![
+ Region {
+ loops: vec![vec![0, 1, 2, 3]],
+ winding: WindingRule::NonZero,
+ fills: Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]),
+ ..Default::default()
+ },
+ Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
+ ],
+ };
+ let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
+ d.get_mut(id).unwrap().fills.clear();
+ assert!(d.node(id).unwrap().fills.is_empty());
+ assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id));
+ assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
+}
+
+#[test]
+fn inherited_paint_frames_use_per_region_hit_testing() {
+ let mut d = Document::new("inherited region hit");
+ let p = page(&d);
+ let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true);
+ network.regions[0].winding = WindingRule::EvenOdd;
+ network.regions.push(network.regions[0].clone());
+ let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
+ assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), None, "legacy even-odd loops cancel");
+ let NodeKind::Vector { network } = &mut d.get_mut(id).unwrap().kind else { panic!("expected vector") };
+ network.regions[0].paint_space = Some(crate::RegionPaintSpace { width: 10.0, height: 10.0, transform: figcraft_geom::Affine::IDENTITY });
+ assert!(network.regions.iter().all(|region| region.fills.is_none()));
+ assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id), "paint-framed regions inherit the node fill independently");
+ assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
+}
+
+#[test]
+fn explicitly_unfilled_regions_keep_the_stroke_only_selection_band() {
+ let mut d = Document::new("stroke selection");
+ let p = page(&d);
+ let mut network =
+ VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
+ network.regions[0].fills = Some(Vec::new());
+ let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
+ let n = d.get_mut(id).unwrap();
+ n.fills.clear();
+ n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
+ n.stroke.weight = 1.0;
+ assert_eq!(d.hit_test(p, Point::new(50.0, -4.0), 2.0, None), Some(id));
+}
+
#[test]
fn double_clicks_go_one_level_deeper() {
let (d, f, g, b, r1, _) = setup();
diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs
index 93d32f0b..a3168f8d 100644
--- a/crates/doc/src/colors.rs
+++ b/crates/doc/src/colors.rs
@@ -60,6 +60,9 @@ impl Node {
f(&mut prototype.background);
}
NodeKind::Text(t) => t.for_each_color_mut(f),
+ NodeKind::Vector { network } => {
+ network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten().for_each(|p| p.for_each_color_mut(f))
+ }
_ => {}
}
for it in &mut self.interactions {
diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs
index 1ee885f5..0e06d294 100644
--- a/crates/doc/src/geometry.rs
+++ b/crates/doc/src/geometry.rs
@@ -74,6 +74,23 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
n.local_rect().inflate(tol_l, tol_l).contains(lp)
}
+ NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
+ let rounded = network.rounded();
+ let net = rounded.as_ref().unwrap_or(network);
+ if !outline {
+ for (i, region) in net.regions.iter().enumerate().rev() {
+ let paints = region.fills.as_deref().unwrap_or(&n.fills);
+ if paints.iter().any(|p| p.visible) && net.region_contains(i, lp) {
+ return true;
+ }
+ }
+ }
+ let path = net.stroke_path();
+ let has_fill = net.regions.iter().any(|region| region.fills.as_deref().unwrap_or(&n.fills).iter().any(|paint| paint.visible));
+ let stroke_only = n.has_visible_stroke() && !has_fill;
+ let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol_l * STROKE_REACH } else { tol_l });
+ path.bounding_box().inflate(reach, reach).contains(lp) && near_path(&path, lp, reach)
+ }
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))),
}
}
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index d19ef0c7..88851ff5 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -915,6 +915,15 @@ impl Document {
pt.source = m.0;
}
}
+ if let NodeKind::Vector { network } = &mut c.kind {
+ for p in network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten() {
+ if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind
+ && let Some(m) = map.get(&NodeId(pt.source))
+ {
+ pt.source = m.0;
+ }
+ }
+ }
self.reserve_ids(*new_id);
self.nodes.insert(*new_id, Arc::new(c));
self.dirty.touch(*new_id);
diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs
index fe53ec29..348c4544 100644
--- a/crates/doc/src/library.rs
+++ b/crates/doc/src/library.rs
@@ -300,6 +300,11 @@ pub fn paint_images<'a>(paints: impl IntoIterator- , out: &mut B
/// Image hashes a node paints (fills, strokes, text and text run fills).
pub fn node_images(n: &Node, out: &mut BTreeSet
) {
paint_images(n.fills.iter().chain(&n.strokes), out);
+ if let NodeKind::Vector { network } = &n.kind {
+ for r in &network.regions {
+ paint_images(r.fills.iter().flatten(), out);
+ }
+ }
if let NodeKind::Text(t) = &n.kind {
paint_images(t.style.fills.iter().flatten(), out);
for r in &t.runs {
diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs
index bcf8f09d..0942e3f8 100644
--- a/crates/doc/src/node.rs
+++ b/crates/doc/src/node.rs
@@ -222,7 +222,7 @@ pub struct Annotation {
/// (`figcraft_engine::code_connect`).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeLink {
- /// `react`, `swiftui` or `compose`.
+ /// `react`, `leptos`, `vue`, `swiftui`, `compose`, `arkui` or `flutter`.
pub language: String,
/// What the snippet imports from (`@acme/ui`, a Swift module, a Kotlin name).
#[serde(default, skip_serializing_if = "String::is_empty")]
diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs
index ebec2383..70dcd25b 100644
--- a/crates/doc/src/tests.rs
+++ b/crates/doc/src/tests.rs
@@ -125,6 +125,34 @@ fn hit_test_respects_z_and_clip() {
assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0, None), Some(f));
}
+#[test]
+fn region_paints_participate_in_colors_images_and_pattern_remapping() {
+ let mut d = Document::new("region resources");
+ let p = page(&d);
+ let group = d.create(p, NodeKind::Group, "Group", 0.0, 0.0, 40.0, 20.0).unwrap();
+ let source = d.create(group, NodeKind::Rectangle, "Source", 0.0, 0.0, 4.0, 4.0).unwrap();
+ let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true);
+ network.regions[0].fills = Some(vec![
+ figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FF0000").unwrap()),
+ figcraft_color::Paint::image("region-image", figcraft_color::ImageScaleMode::Fill),
+ figcraft_color::Paint::pattern(source.0),
+ ]);
+ let vector = d.create(group, NodeKind::Vector { network }, "Vector", 0.0, 0.0, 20.0, 20.0).unwrap();
+ assert!(used_images(&d).contains("region-image"));
+ let dup = d.duplicate_into(group, p, None).unwrap();
+ let copied_children = d.node(dup).unwrap().children.clone();
+ let copied_source = copied_children[0];
+ let copied_vector = d.node(copied_children[1]).unwrap();
+ let NodeKind::Vector { network } = &copied_vector.kind else { panic!() };
+ let figcraft_color::PaintKind::Pattern(pattern) = &network.regions[0].fills.as_ref().unwrap()[2].kind else { panic!() };
+ assert_eq!(pattern.source, copied_source.0);
+
+ let red = figcraft_color::Color::from_hex("FF0000").unwrap();
+ d.map_colors(|c| if c == red { figcraft_color::Color::BLACK } else { c });
+ let NodeKind::Vector { network } = &d.node(vector).unwrap().kind else { panic!() };
+ assert_eq!(network.regions[0].fills.as_ref().unwrap()[0].solid_color(), Some(figcraft_color::Color::BLACK));
+}
+
#[test]
fn click_target_rules() {
let mut d = Document::new("x");
@@ -212,7 +240,7 @@ fn vector_network_paths() {
let mut bad = VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0)],
segments: vec![Segment::line(0, 5)],
- regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd }],
+ regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd, fills: None, ..Default::default() }],
};
bad.sanitize();
assert!(bad.segments.is_empty());
@@ -534,7 +562,7 @@ fn hit_test_even_odd_vector_hole_misses() {
VectorNetwork {
vertices: v1.into_iter().chain(v2).collect(),
segments: s1.into_iter().chain(s2).collect(),
- regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }],
+ regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule, ..Default::default() }],
}
};
let mut d = Document::new("x");
@@ -637,7 +665,7 @@ fn two_segment_closed_loop_rounded_corners() {
let net = VectorNetwork {
vertices: vec![v0, v1],
segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }],
- regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }],
+ regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() }],
};
let r = net.rounded().unwrap();
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs
index b3040b1e..5ae01a08 100644
--- a/crates/doc/src/vector.rs
+++ b/crates/doc/src/vector.rs
@@ -1,8 +1,9 @@
//! Vector networks: vertices joined by curved segments, any number of segments per vertex, plus
//! filled regions made of closed loops.
+use figcraft_color::Paint;
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv};
-use figcraft_geom::{BezPath, Point, Vec2};
+use figcraft_geom::{Affine, BezPath, Point, Vec2};
use serde::{Deserialize, Serialize};
use crate::{StrokeCap, StrokeJoin};
@@ -88,12 +89,44 @@ pub enum WindingRule {
EvenOdd,
}
+/// The paint coordinate frame retained when a region moves or its vector is refit.
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+pub struct RegionPaintSpace {
+ /// Width of the box that defines paint coordinates.
+ pub width: f64,
+ /// Height of the box that defines paint coordinates.
+ pub height: f64,
+ /// Maps paint-local coordinates into this network's local coordinates.
+ pub transform: Affine,
+}
+
/// A filled region: loops of segment indices.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct Region {
pub loops: Vec>,
#[serde(default)]
pub winding: WindingRule,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fills: Option>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub paint_space: Option,
+}
+
+impl Region {
+ /// Paint box and transform, falling back to the current node box for old or invalid metadata.
+ pub fn paint_frame(&self, width: f64, height: f64) -> (f64, f64, Affine) {
+ let Some(frame) = self.paint_space else { return (width, height, Affine::IDENTITY) };
+ let coefficients = frame.transform.as_coeffs();
+ let valid = frame.width.is_finite()
+ && frame.height.is_finite()
+ && frame.width > 0.0
+ && frame.height > 0.0
+ && coefficients.iter().all(|value| value.is_finite())
+ && frame.transform.determinant().is_finite()
+ && frame.transform.determinant() != 0.0
+ && frame.transform.inverse().as_coeffs().iter().all(|value| value.is_finite());
+ if valid { (frame.width, frame.height, frame.transform) } else { (width, height, Affine::IDENTITY) }
+ }
}
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
@@ -153,7 +186,7 @@ impl VectorNetwork {
let mut regions = Vec::new();
if closed && n > 2 {
segments.push(Segment::line(n - 1, 0));
- regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero });
+ regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero, fills: None, paint_space: None });
}
Self { vertices, segments, regions }
}
@@ -242,7 +275,7 @@ impl VectorNetwork {
}
close_open(&mut net, &mut loop_segs, &mut all_loops, first_v, cur_v);
if !all_loops.is_empty() {
- net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero });
+ net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero, fills: None, paint_space: None });
}
net
}
@@ -637,6 +670,12 @@ impl VectorNetwork {
s.ta = (s.ta.0 * sx, s.ta.1 * sy);
s.tb = (s.tb.0 * sx, s.tb.1 * sy);
}
+ let scale = Affine::scale_non_uniform(sx, sy);
+ for region in &mut self.regions {
+ if let Some(frame) = &mut region.paint_space {
+ frame.transform = scale * frame.transform;
+ }
+ }
}
pub fn translate(&mut self, dx: f64, dy: f64) {
@@ -644,6 +683,12 @@ impl VectorNetwork {
v.x += dx;
v.y += dy;
}
+ let translate = Affine::translate((dx, dy));
+ for region in &mut self.regions {
+ if let Some(frame) = &mut region.paint_space {
+ frame.transform = translate * frame.transform;
+ }
+ }
}
/// Bounds of the curves.
diff --git a/crates/doc/src/vector_faces.rs b/crates/doc/src/vector_faces.rs
index 050ab821..634039d0 100644
--- a/crates/doc/src/vector_faces.rs
+++ b/crates/doc/src/vector_faces.rs
@@ -30,7 +30,12 @@ impl Face {
/// The fill region for this face.
pub fn region(&self) -> Region {
- Region { loops: self.loops.clone(), winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero } }
+ Region {
+ loops: self.loops.clone(),
+ winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero },
+ fills: None,
+ paint_space: None,
+ }
}
/// Some point strictly inside the face (not in a hole).
@@ -115,7 +120,13 @@ impl VectorNetwork {
/// Like [`Self::paint`], but with `want` the face ends up filled (`Some(true)`) or unfilled
/// (`Some(false)`) whatever it was: a paint bucket drag keeps doing what its first click did.
pub fn paint_to(&mut self, p: Point, want: Option) -> Option {
+ self.paint_to_with(p, want, None)
+ }
+
+ /// Like [`Self::paint_to`], with the layer fill stack used for inherited regions.
+ pub fn paint_to_with(&mut self, p: Point, want: Option, inherited: Option<&[figcraft_color::Paint]>) -> Option {
let face = self.face_at(p)?;
+ let legacy = self.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
let key = |loops: &[Vec]| -> Vec> {
let mut k: Vec> = loops
.iter()
@@ -131,18 +142,52 @@ impl VectorNetwork {
let faces = self.faces();
let face_keys: Vec>> = faces.iter().map(|f| key(&f.loops)).collect();
if !self.regions.iter().all(|r| face_keys.contains(&key(&r.loops))) {
- let filled: Vec = faces.iter().filter(|f| f.interior_point().is_some_and(|q| self.fill_contains(q))).map(Face::region).collect();
- self.regions = filled;
+ if legacy {
+ // Legacy vectors use one combined fill rule across all regions.
+ self.regions =
+ faces.iter().filter(|face| face.interior_point().is_some_and(|point| self.fill_contains(point))).map(Face::region).collect();
+ } else {
+ // Split each styled source region independently to retain its stack and frame.
+ let source_regions = self.regions.clone();
+ let mut filled = Vec::new();
+ for (source_index, source) in source_regions.into_iter().enumerate() {
+ let source_path = self.region_path(source_index);
+ for face in &faces {
+ if face.interior_point().is_some_and(|q| {
+ source_path.as_ref().is_some_and(|path| match source.winding {
+ WindingRule::NonZero => path.winding(q) != 0,
+ WindingRule::EvenOdd => path.winding(q) % 2 != 0,
+ })
+ }) {
+ let mut region = face.region();
+ region.fills = source.fills.clone();
+ region.paint_space = source.paint_space;
+ filled.push(region);
+ }
+ }
+ }
+ self.regions = filled;
+ }
}
let k = key(&face.loops);
- match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
- (Some(_), None | Some(false)) => {
+ let filled = if legacy {
+ self.fill_contains(p) && inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible))
+ } else {
+ self.regions.iter().filter(|r| key(&r.loops) == k).any(|region| match ®ion.fills {
+ Some(fills) => fills.iter().any(|paint| paint.visible),
+ None => inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible)),
+ })
+ };
+ let want = want.unwrap_or(!filled);
+ match (filled, want) {
+ (true, false) => {
self.regions.retain(|r| key(&r.loops) != k);
Some(false)
}
- (Some(_), Some(true)) => Some(true),
- (None, Some(false)) => Some(false),
- (None, _) => {
+ (true, true) => Some(true),
+ (false, false) => Some(false),
+ (false, true) => {
+ self.regions.retain(|r| key(&r.loops) != k);
self.regions.push(face.region());
Some(true)
}
diff --git a/crates/doc/src/vector_regions_tests.rs b/crates/doc/src/vector_regions_tests.rs
index c09664b4..75814f9f 100644
--- a/crates/doc/src/vector_regions_tests.rs
+++ b/crates/doc/src/vector_regions_tests.rs
@@ -30,7 +30,32 @@ fn nested() -> VectorNetwork {
}
fn region(loops: Vec>, winding: WindingRule) -> Region {
- Region { loops, winding }
+ Region { loops, winding, fills: None, ..Default::default() }
+}
+
+#[test]
+fn region_fills_default_to_inheritance_and_keep_explicit_empty() {
+ let legacy: Region = serde_json::from_value(serde_json::json!({"loops": []})).unwrap();
+ assert_eq!(legacy.fills, None);
+ let empty = Region { fills: Some(Vec::new()), ..legacy };
+ let value = serde_json::to_value(empty).unwrap();
+ assert!(value["fills"].as_array().is_some_and(Vec::is_empty));
+ let inherited = serde_json::to_value(region(Vec::new(), WindingRule::NonZero)).unwrap();
+ assert!(inherited.get("fills").is_none());
+}
+
+#[test]
+fn region_paint_space_roundtrips_and_rejects_nonfinite_inverse() {
+ use crate::RegionPaintSpace;
+ use figcraft_geom::Affine;
+ let mut r = region(Vec::new(), WindingRule::EvenOdd);
+ assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
+ r.paint_space = Some(RegionPaintSpace { width: 30.0, height: 20.0, transform: Affine::translate((4.0, 8.0)) });
+ let decoded: Region = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
+ assert_eq!(decoded, r);
+ assert_eq!(decoded.paint_frame(100.0, 40.0), (30.0, 20.0, Affine::translate((4.0, 8.0))));
+ r.paint_space.as_mut().unwrap().transform = Affine::scale_non_uniform(1e-320, 1.0);
+ assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
}
fn p(x: f64, y: f64) -> Point {
diff --git a/crates/engine/src/al_behaviour_tests.rs b/crates/engine/src/al_behaviour_tests.rs
index a7a4d1d7..d36b35b8 100644
--- a/crates/engine/src/al_behaviour_tests.rs
+++ b/crates/engine/src/al_behaviour_tests.rs
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
-use figcraft_doc::{CellAlign, MainAlign, NodeId, Sizing};
+use figcraft_doc::{CellAlign, CrossAlign, LayoutMode, MainAlign, NodeId, Sizing};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
@@ -233,3 +233,46 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
}
+
+/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
+/// an object that replaces the whole auto layout) applies first and the other keys tune it.
+#[test]
+fn auto_layout_keys_in_one_call_tune_the_mode_given_beside_them() {
+ for (key, mode) in ["layout", "layoutMode"].into_iter().flat_map(|k| [(k, json!("vertical")), (k, json!({"mode": "vertical"}))]) {
+ let mut props = json!({"gap": 8, "padding": 4, "crossAlign": "center"});
+ props[key] = mode;
+ let mut s = Session::new();
+ let created = id(s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap());
+ let set_on = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
+ set(&mut s, set_on, props.clone());
+ for f in [created, set_on] {
+ let l = s.doc().unwrap().node(f).unwrap().layout.clone().unwrap();
+ assert_eq!((l.mode, l.gap, l.padding, l.cross_align), (LayoutMode::Vertical, 8.0, [4.0; 4], CrossAlign::Center), "{props}");
+ }
+ }
+}
+
+/// Auto layout keys beside a `layout` that turns auto layout off would turn it back on: the call
+/// is refused and the layer stays as it was.
+#[test]
+fn auto_layout_keys_beside_no_auto_layout_are_refused() {
+ let mut s = Session::new();
+ let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
+ for props in
+ [json!({"layout": null, "padding": 4}), json!({"layout": "none", "gap": 8, "padding": 4}), json!({"layoutMode": "NONE", "wrap": true})]
+ {
+ let e = s.execute("node.set", &json!({"id": f, "props": props})).unwrap_err().to_string();
+ assert!(e.contains("turns auto layout off"), "{props}: {e}");
+ assert!(s.doc().unwrap().node(f).unwrap().layout.is_none(), "{props}");
+ let e = s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap_err().to_string();
+ assert!(e.contains("turns auto layout off"), "{props}: {e}");
+ }
+ let e = s.execute("node.set", &json!({"id": f, "props": {"layout": null, "gap": 8, "padding": 4}})).unwrap_err().to_string();
+ assert!(e.contains("`gap`, `padding` are auto layout properties, and `layout` in the same call"), "{e}");
+ let e = s.execute("node.set", &json!({"id": f, "props": {"layoutMode": "NONE", "wrap": true}})).unwrap_err().to_string();
+ assert!(e.contains("`wrap` is an auto layout property, and `layoutMode` in the same call"), "{e}");
+ // Without other auto layout keys, `layout: null` still removes it.
+ set(&mut s, f, json!({"layout": "vertical", "gap": 8}));
+ set(&mut s, f, json!({"layout": null, "name": "Plain"}));
+ assert!(s.doc().unwrap().node(f).unwrap().layout.is_none());
+}
diff --git a/crates/engine/src/assets/playground.rs b/crates/engine/src/assets/playground.rs
index 5c05c3b9..ddb969c2 100644
--- a/crates/engine/src/assets/playground.rs
+++ b/crates/engine/src/assets/playground.rs
@@ -52,7 +52,7 @@ pub fn specs() -> Vec {
vec![spec!(
"component.playground",
"Component playground",
- "{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
+ "{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui|flutter (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
false,
|s, p| {
let cmd = "component.playground";
@@ -63,7 +63,7 @@ pub fn specs() -> Vec {
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
};
let lang = match string(p, "language") {
- Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
+ Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui|flutter"))?,
None => CodeLang::React,
};
let (copy, inst) = build(s.doc()?, target, &values)?;
diff --git a/crates/engine/src/automation/batch.rs b/crates/engine/src/automation/batch.rs
index 5c4550ec..aa11a2bb 100644
--- a/crates/engine/src/automation/batch.rs
+++ b/crates/engine/src/automation/batch.rs
@@ -39,6 +39,18 @@ pub fn step_name(step: usize, json: &Value) -> Result> {
}
}
+/// Refuse a step key other than `command`, `params` and `as` (`step` is its index, for the
+/// error): `args` in place of `params` would run the command without them.
+pub fn refuse_unknown_step_keys(step: usize, json: &Value) -> Result<()> {
+ let Some(o) = json.as_object() else { return Ok(()) };
+ let unknown: Vec = o.keys().filter(|k| !matches!(k.as_str(), "command" | "params" | "as")).map(|k| format!("`{k}`")).collect();
+ if unknown.is_empty() {
+ return Ok(());
+ }
+ let s = if unknown.len() == 1 { "" } else { "s" };
+ Err(bad("command.batch", format!("step {step}: unknown key{s} {} (a step takes command, params and as)", unknown.join(", "))))
+}
+
/// The steps of one batch run so far: their results, and which step each `as` name belongs to.
/// A nested `command.batch` step gets its own scope, so its references are to its own steps.
#[derive(Clone, Debug, Default)]
diff --git a/crates/engine/src/canvas/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs
index 74a4a9d1..b68f1e71 100644
--- a/crates/engine/src/canvas/shapebuilder.rs
+++ b/crates/engine/src/canvas/shapebuilder.rs
@@ -2,9 +2,10 @@
//!
//! The selected shapes' outlines split the plane into **regions** (the faces of their arrangement):
//! connected areas covered by the same shapes. Merging regions makes one new vector from them,
-//! styled like the top-most shape covering the first region; removing regions deletes them. Either
-//! way every shape covering a picked region loses that area (it becomes a vector, or is deleted
-//! when nothing is left), so the rest of the drawing stays exactly where it was.
+//! styled like the top-most fill or stroke covering the first selected region (using that vector
+//! region's paints when applicable); removing regions deletes them. Either way every shape covering
+//! a picked region loses that area (it becomes a vector, or is deleted when nothing is left), so
+//! the rest of the drawing stays exactly where it was.
//!
//! **Strokes and open paths.** A visible stroke takes part as its outlined area (a [`Part`] of its
//! own, above the shape's fill), so stroked lines and open paths split the regions they cross and
@@ -37,6 +38,9 @@ use crate::{EngineError, Result, Session, bad, spec};
pub struct Part {
pub id: NodeId,
pub stroke: bool,
+ /// The source vector region for a fill (`None` for ordinary fills and strokes).
+ #[serde(skip)]
+ pub region: Option,
}
/// One region of the arrangement, in page coordinates.
@@ -126,7 +130,16 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
if !(figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_))) {
return None;
}
- figcraft_render::node_outline(doc, n)?
+ match (&n.kind, part.region) {
+ (NodeKind::Vector { network }, Some(i)) => {
+ let rounded = network.rounded();
+ let net = rounded.as_ref().unwrap_or(network);
+ let region = net.regions.get(i)?;
+ let path = net.region_path(i)?;
+ (path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
+ }
+ _ => figcraft_render::node_outline(doc, n)?,
+ }
};
p.apply_affine(doc.world_transform(part.id));
(!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd))
@@ -155,10 +168,17 @@ fn compute(doc: Arc, uid: u64, ids: &[NodeId]) -> Result
let mut inputs = Vec::new();
for id in eligible(&doc, ids) {
let before = parts.len();
- for part in [Part { id, stroke: false }, Part { id, stroke: true }] {
- if inputs.len() < figcraft_pathops::MAX_ARRANGEMENT_INPUTS
- && let Some(o) = world_area(&doc, part)
- {
+ let fill_parts: Vec = match &doc.get(id)?.kind {
+ NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
+ network.regions.iter().enumerate().map(|(region, _)| Part { id, stroke: false, region: Some(region) }).collect()
+ }
+ _ => vec![Part { id, stroke: false, region: None }],
+ };
+ for part in fill_parts.into_iter().chain([Part { id, stroke: true, region: None }]) {
+ if let Some(o) = world_area(&doc, part) {
+ if inputs.len() >= figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
+ return Err(EngineError::Other("shape builder: too many fill regions and strokes to process safely".into()));
+ }
parts.push(part);
inputs.push(o);
}
@@ -253,6 +273,68 @@ fn network_from(path: &BezPath, to_local: Affine) -> Option {
Some(net)
}
+fn append_network(into: &mut VectorNetwork, mut part: VectorNetwork) {
+ let (vertices, segments) = (into.vertices.len(), into.segments.len());
+ for segment in &mut part.segments {
+ segment.a += vertices;
+ segment.b += vertices;
+ }
+ for region in &mut part.regions {
+ for loop_segments in &mut region.loops {
+ for segment in loop_segments {
+ *segment += segments;
+ }
+ }
+ }
+ into.vertices.extend(part.vertices);
+ into.segments.extend(part.segments);
+ into.regions.extend(part.regions);
+}
+
+fn paint_frame(region: Option<&figcraft_doc::Region>, node: &Node) -> (f64, f64, Affine) {
+ region.map_or((node.width, node.height, Affine::IDENTITY), |r| r.paint_frame(node.width, node.height))
+}
+
+/// Rebuild `id` from each remaining source region, retaining region order, paint stacks and frames.
+fn reshape_vector_regions(doc: &mut Document, id: NodeId, areas: &[(usize, BezPath)]) -> Result<()> {
+ let old = doc.get(id)?.clone();
+ let (world, network) = match &old.kind {
+ NodeKind::Vector { network } => (doc.world_transform(id), network),
+ _ => return Err(EngineError::Other("expected a vector with regions".into())),
+ };
+ if !(world.determinant().is_finite() && world.determinant().abs() > 1e-12) {
+ return Err(EngineError::Other("can't reshape a layer scaled to nothing".into()));
+ }
+ let mut rebuilt = VectorNetwork::default();
+ for (index, area) in areas {
+ let Some(source) = network.regions.get(*index) else { continue };
+ let Some(mut part) = network_from(area, world.inverse()) else { return Err(EngineError::Other("invalid geometry".into())) };
+ if part.regions.is_empty() {
+ continue;
+ }
+ let (width, height, frame) = paint_frame(Some(source), &old);
+ for region in &mut part.regions {
+ region.fills = source.fills.clone();
+ region.paint_space = Some(figcraft_doc::RegionPaintSpace { width, height, transform: frame });
+ }
+ append_network(&mut rebuilt, part);
+ }
+ let Some(bounds) = rebuilt.bounds() else {
+ let n = doc.get_mut(id)?;
+ n.kind = NodeKind::Vector { network: rebuilt };
+ return Ok(());
+ };
+ rebuilt.translate(-bounds.x0, -bounds.y0);
+ let n = doc.get_mut(id)?;
+ n.kind = NodeKind::Vector { network: rebuilt };
+ n.children.clear();
+ n.corners = Corners::default();
+ n.transform *= Affine::translate((bounds.x0, bounds.y0));
+ n.width = bounds.width();
+ n.height = bounds.height();
+ Ok(())
+}
+
/// Replace shape `id`'s geometry with the page-space area `area`, keeping its transform and style.
fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
let w = doc.world_transform(id);
@@ -273,10 +355,16 @@ fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
crate::vectoredit::refit(doc, id)
}
-/// A new vector for the page-space area `area`, styled like part `style` (a stroke part: filled
-/// with the stroke's paints), just above its shape.
+/// A new vector for page-space `area`, styled like `style` (a stroke part: filled with stroke
+/// paints), just above its shape. Vector region paints and their original frame stay attached.
fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result {
let mut src = doc.get(style.id)?.clone();
+ let source_region = match (&src.kind, style.region) {
+ (NodeKind::Vector { network }, Some(i)) => network.regions.get(i),
+ _ => None,
+ };
+ let (paint_width, paint_height, source_paint_frame) = paint_frame(source_region, &src);
+ let region_fills = source_region.and_then(|r| r.fills.clone());
if style.stroke {
src.fills = std::mem::take(&mut src.strokes);
src.stroke = StrokeProps::default();
@@ -291,6 +379,12 @@ fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result Result {
// Merging every region of a single shape would only replace it with a copy of itself.
let whole_shape =
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
- if merge && whole_shape {
+ // A vector's distinct region paints are separate fill parts that merging does unify.
+ let fill_parts = touched_parts.iter().filter(|part| !part.stroke).count();
+ if merge && whole_shape && fill_parts <= 1 {
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
}
let doc = s.doc_mut()?;
@@ -347,7 +443,8 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result {
let mut changed = Vec::new();
let mut outlined: Vec<(NodeId, NodeId)> = Vec::new();
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
- let (fill, stroke) = (Part { id: src, stroke: false }, Part { id: src, stroke: true });
+ let stroke = Part { id: src, stroke: true, region: None };
+ let fill_parts: Vec = a.parts.iter().copied().filter(|part| part.id == src && !part.stroke).collect();
// The stroke becomes an outline of its own (what's left of it), the shape loses it.
if a.parts.contains(&stroke) {
if let Some(area) = kept(stroke)? {
@@ -357,16 +454,35 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result {
n.strokes.clear();
n.styles.stroke = None;
}
- let left = if !a.parts.contains(&fill) {
- None
- } else if touched_parts.contains(&fill) {
- kept(fill)?
- } else {
+ if fill_parts.is_empty() {
+ doc.remove(src)?;
+ removed.push(src);
+ continue;
+ }
+ let touched_fills: Vec = fill_parts.iter().copied().filter(|part| touched_parts.contains(part)).collect();
+ if touched_fills.is_empty() {
// The fill wasn't picked: the shape stays as it is.
changed.push(src);
continue;
- };
- match left {
+ }
+ if fill_parts.iter().any(|part| part.region.is_some()) {
+ let mut areas = Vec::new();
+ for part in &fill_parts {
+ let Some(region) = part.region else { continue };
+ if let Some(area) = kept(*part)? {
+ areas.push((region, area));
+ }
+ }
+ if areas.is_empty() {
+ doc.remove(src)?;
+ removed.push(src);
+ } else {
+ reshape_vector_regions(doc, src, &areas)?;
+ changed.push(src);
+ }
+ continue;
+ }
+ match kept(fill_parts[0])? {
Some(area) => {
reshape(doc, src, &area)?;
changed.push(src);
@@ -427,7 +543,7 @@ pub fn specs() -> Vec {
spec!(
"shapeBuilder.merge",
"Merge regions",
- "{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first; the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
+ "{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first selected region (using its vector-region paints); the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
true,
|s, p| apply(s, "shapeBuilder.merge", p, true)
),
@@ -464,6 +580,10 @@ pub fn key(s: &mut Session, name: &str) -> Result {
Ok(true)
}
+#[cfg(test)]
+#[path = "shapebuilder_region_paints_tests.rs"]
+mod region_paints_tests;
+
impl Session {
/// The Shape builder tool: hover highlights a region, click/drag collects regions, release
/// merges (or removes, with Alt or in Remove mode) them as one undo step. Clicking outside
diff --git a/crates/engine/src/canvas/shapebuilder_region_paints_tests.rs b/crates/engine/src/canvas/shapebuilder_region_paints_tests.rs
new file mode 100644
index 00000000..74e45093
--- /dev/null
+++ b/crates/engine/src/canvas/shapebuilder_region_paints_tests.rs
@@ -0,0 +1,307 @@
+use figcraft_color::{Color, Gradient, GradientKind, Paint};
+use figcraft_doc::{NodeId, NodeKind, Region, VectorNetwork, WindingRule};
+use figcraft_geom::{Affine, Point, Shape};
+use serde_json::{Value, json};
+
+use crate::Session;
+use crate::params::id_of;
+use crate::shapebuilder::arrangement;
+
+fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
+ s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
+}
+
+fn id(v: &Value) -> NodeId {
+ id_of(&v["id"]).unwrap()
+}
+
+fn rect(s: &mut Session, x: f64, y: f64, width: f64, height: f64, fill: &str) -> NodeId {
+ id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": width, "height": height, "props": {"fill": fill}})))
+}
+
+fn append(into: &mut VectorNetwork, mut part: VectorNetwork) {
+ let (vertices, segments) = (into.vertices.len(), into.segments.len());
+ for segment in &mut part.segments {
+ segment.a += vertices;
+ segment.b += vertices;
+ }
+ for region in &mut part.regions {
+ for lp in &mut region.loops {
+ for segment in lp {
+ *segment += segments;
+ }
+ }
+ }
+ into.vertices.extend(part.vertices);
+ into.segments.extend(part.segments);
+ into.regions.extend(part.regions);
+}
+
+fn box_path(x0: f64, y0: f64, x1: f64, y1: f64) -> VectorNetwork {
+ VectorNetwork::from_points(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
+}
+
+fn split_vector(s: &mut Session) -> NodeId {
+ let id = rect(s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
+ let mut network = VectorNetwork::default();
+ append(&mut network, box_path(0.0, 0.0, 50.0, 100.0));
+ append(&mut network, box_path(50.0, 0.0, 100.0, 100.0));
+ network.regions[0].fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]);
+ network.regions[1].fills = Some(vec![Paint::solid(Color::from_hex("#0000ff").unwrap())]);
+ s.doc_mut().unwrap().get_mut(id).unwrap().kind = NodeKind::Vector { network };
+ id
+}
+
+fn network(s: &Session, id: NodeId) -> &VectorNetwork {
+ let NodeKind::Vector { network } = &s.doc().unwrap().node(id).unwrap().kind else { panic!("expected vector") };
+ // This helper's borrow is tied to the session document.
+ network
+}
+
+fn world_region_area(s: &Session, id: NodeId, index: usize) -> f64 {
+ let doc = s.doc().unwrap();
+ let mut path = network(s, id).region_path(index).unwrap();
+ path.apply_affine(doc.world_transform(id));
+ path.area().abs()
+}
+
+fn normalized_paint_world(s: &Session, id: NodeId, index: usize) -> Affine {
+ let doc = s.doc().unwrap();
+ let node = doc.node(id).unwrap();
+ let (width, height, transform) = network(s, id).regions[index].paint_frame(node.width, node.height);
+ doc.world_transform(id) * transform * Affine::scale_non_uniform(width, height)
+}
+
+fn assert_same_transform(a: Affine, b: Affine) {
+ for (x, y) in a.as_coeffs().into_iter().zip(b.as_coeffs()) {
+ assert!((x - y).abs() < 1e-8, "paint frame changed: {a:?} vs {b:?}");
+ }
+}
+
+#[test]
+fn arrangement_keeps_internal_paint_boundaries_when_another_shape_crosses_them() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
+ s.set_selection(vec![vector, crossing]);
+ let a = arrangement(&mut s, &[vector, crossing]).unwrap();
+ let left = a.region_at(Point::new(35.0, 50.0)).unwrap();
+ let right = a.region_at(Point::new(65.0, 50.0)).unwrap();
+ assert_ne!(left, right, "the old paint boundary must split the crossing shape's overlap");
+ assert!(a.regions[left].parts.iter().any(|p| p.id == vector && p.region == Some(0)));
+ assert!(a.regions[right].parts.iter().any(|p| p.id == vector && p.region == Some(1)));
+}
+
+#[test]
+fn legacy_unstyled_nested_evenodd_regions_keep_the_hole_unpainted() {
+ let mut s = Session::new();
+ let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
+ let mut net = box_path(0.0, 0.0, 100.0, 100.0);
+ let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
+ append(&mut net, inner);
+ for region in &mut net.regions {
+ region.winding = WindingRule::EvenOdd;
+ region.fills = None;
+ region.paint_space = None;
+ }
+ s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net };
+ let crossing = rect(&mut s, -5.0, 45.0, 15.0, 10.0, "#ffffff");
+ s.set_selection(vec![vector, crossing]);
+ let a = arrangement(&mut s, &[vector, crossing]).unwrap();
+ assert!(a.region_at(Point::new(50.0, 50.0)).is_none(), "the legacy combined even-odd fill leaves its nested center empty");
+
+ ex(&mut s, "shapeBuilder.remove", json!({"points": [[5, 50]]}));
+ let NodeKind::Vector { network } = &s.doc().unwrap().node(vector).unwrap().kind else { panic!("expected vector") };
+ assert!(!network.fill_contains(Point::new(50.0, 50.0)), "the nested hole survives removal");
+ assert!(network.fill_contains(Point::new(85.0, 50.0)), "unselected outer area remains filled");
+}
+
+#[test]
+fn merge_all_painted_regions_uses_the_first_picked_region_style() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ s.set_selection(vec![vector]);
+ let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
+ let merged = id(&result);
+ assert!(!s.doc().unwrap().contains(vector));
+ assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
+ assert!((world_region_area(&s, merged, 0) - 10_000.0).abs() < 0.1);
+ s.undo().unwrap();
+ assert_eq!(network(&s, vector).regions.len(), 2);
+}
+
+#[test]
+fn merge_all_inherited_gradient_regions_with_distinct_frames_is_not_a_noop() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ {
+ let doc = s.doc_mut().unwrap();
+ let node = doc.get_mut(vector).unwrap();
+ node.fills = vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))];
+ let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
+ for (region, offset) in network.regions.iter_mut().zip([0.0, 12.0]) {
+ region.fills = None;
+ region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::translate((offset, 0.0)) });
+ }
+ }
+ let before_a = normalized_paint_world(&s, vector, 0);
+ let before_b = normalized_paint_world(&s, vector, 1);
+ assert_ne!(before_a, before_b, "source regions use distinct inherited-gradient frames");
+
+ s.set_selection(vec![vector]);
+ let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
+ let merged = id(&result);
+ assert!(!s.doc().unwrap().contains(vector), "the selected regions are merged into a new vector");
+ assert_eq!(network(&s, merged).regions.len(), 1);
+ assert_eq!(network(&s, merged).regions[0].fills, None, "node gradient remains inherited");
+ assert!(matches!(s.doc().unwrap().node(merged).unwrap().fills[0].kind, figcraft_color::PaintKind::Gradient(_)));
+ assert_same_transform(normalized_paint_world(&s, merged, 0), before_a);
+}
+
+#[test]
+fn exceeding_arrangement_capacity_never_drops_unprocessed_regions() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ let mut net = VectorNetwork::default();
+ for i in 0..=figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
+ let mut part = box_path(i as f64 * 12.0, 0.0, i as f64 * 12.0 + 10.0, 10.0);
+ part.regions[0].fills = Some(vec![Paint::solid(Color::BLACK)]);
+ append(&mut net, part);
+ }
+ s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net.clone() };
+ s.set_selection(vec![vector]);
+ let error = s.execute("shapeBuilder.remove", &json!({"points": [[5, 5]]})).unwrap_err();
+ assert!(error.to_string().contains("too many fill regions"));
+ assert_eq!(network(&s, vector), &net);
+}
+
+#[test]
+fn remove_changes_only_the_picked_painted_face() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
+ s.set_selection(vec![vector, crossing]);
+ ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
+ assert!((world_region_area(&s, vector, 0) - 2500.0).abs() < 0.1);
+ assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "the untouched blue region keeps its area");
+ let net = network(&s, vector);
+ assert_eq!(net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
+ assert_eq!(net.regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
+}
+
+#[test]
+fn reshape_keeps_inherited_and_explicitly_empty_region_fills_distinct() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ {
+ let doc = s.doc_mut().unwrap();
+ let NodeKind::Vector { network } = &mut doc.get_mut(vector).unwrap().kind else { panic!("expected vector") };
+ network.regions[0].fills = Some(Vec::new());
+ network.regions[1].fills = None;
+ }
+ let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
+ s.set_selection(vec![vector, crossing]);
+ ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
+ assert_eq!(network(&s, vector).regions[0].fills, Some(Vec::new()));
+ assert_eq!(network(&s, vector).regions[1].fills, None);
+ assert_eq!(s.doc().unwrap().node(vector).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
+}
+
+#[test]
+fn merge_uses_selected_region_paints_and_retains_other_region_stack() {
+ let mut s = Session::new();
+ // Create crossing first, then vector above it so vector region supplies merge appearance.
+ let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
+ let vector = split_vector(&mut s);
+ s.set_selection(vec![crossing, vector]);
+ let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[35, 50]]}));
+ let merged = id(&result);
+ let merged_net = network(&s, merged);
+ assert_eq!(merged_net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
+ assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "unselected blue region survives");
+ assert_eq!(network(&s, vector).regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
+ assert!((world_region_area(&s, merged, 0) - 2500.0).abs() < 0.1);
+ assert!(s.undo().unwrap());
+ assert_eq!(network(&s, vector).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
+ assert!(s.redo().unwrap());
+ assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
+}
+
+#[test]
+fn merged_region_keeps_a_transformed_source_gradient_frame() {
+ let mut s = Session::new();
+ let crossing = rect(&mut s, 0.0, 0.0, 300.0, 200.0, "#ffffff");
+ let vector = split_vector(&mut s);
+ let mut transform = Affine::translate((100.0, 20.0)) * Affine::rotate(0.2);
+ transform *= Affine::scale_non_uniform(1.2, 0.8);
+ {
+ let doc = s.doc_mut().unwrap();
+ let node = doc.get_mut(vector).unwrap();
+ node.transform = transform;
+ let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
+ network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]);
+ }
+ let before = normalized_paint_world(&s, vector, 0);
+ let point = s.doc().unwrap().world_transform(vector) * Point::new(35.0, 50.0);
+ s.set_selection(vec![crossing, vector]);
+ let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[point.x, point.y]]}));
+ let merged = id(&result);
+ assert!(matches!(&network(&s, merged).regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
+ assert_same_transform(before, normalized_paint_world(&s, merged, 0));
+}
+
+#[test]
+fn removal_preserves_holes_and_transformed_gradient_frames() {
+ let mut s = Session::new();
+ let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
+ let mut outer = box_path(0.0, 0.0, 100.0, 100.0);
+ let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
+ let outer_loop = outer.regions[0].loops[0].clone();
+ let inner_loop: Vec = inner.regions[0].loops[0].iter().map(|i| i + outer.segments.len()).collect();
+ append(&mut outer, inner);
+ outer.regions = vec![Region {
+ loops: vec![outer_loop, inner_loop],
+ winding: WindingRule::EvenOdd,
+ fills: Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]),
+ paint_space: None,
+ }];
+ let transform = Affine::translate((120.0, 20.0)) * Affine::rotate(0.2);
+ {
+ let doc = s.doc_mut().unwrap();
+ let node = doc.get_mut(vector).unwrap();
+ node.kind = NodeKind::Vector { network: outer };
+ node.transform = transform;
+ }
+ let before = normalized_paint_world(&s, vector, 0);
+ let world = s.doc().unwrap().world_transform(vector) * Point::new(10.0, 50.0);
+ let overlay = rect(&mut s, world.x - 5.0, world.y - 5.0, 10.0, 10.0, "#ffffff");
+ s.set_selection(vec![overlay, vector]);
+ ex(&mut s, "shapeBuilder.remove", json!({"points": [[world.x, world.y]]}));
+ let net = network(&s, vector);
+ assert_eq!(net.regions.len(), 1);
+ assert_eq!(net.regions[0].loops.len(), 3, "the original hole and removed face stay separate loops");
+ assert!(!net.region_contains(0, Point::new(50.0, 50.0)), "the hole remains empty");
+ assert!(!net.region_contains(0, Point::new(10.0, 50.0)), "the removed face remains empty");
+ assert!(net.region_contains(0, Point::new(90.0, 50.0)), "the unselected ring remains filled");
+ assert_same_transform(before, normalized_paint_world(&s, vector, 0));
+}
+
+#[test]
+fn removing_the_left_region_keeps_the_right_region_paint_frame() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ {
+ let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(vector).unwrap().kind else { panic!("expected vector") };
+ network.regions[1].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#0000ff").unwrap()))]);
+ }
+ let before = normalized_paint_world(&s, vector, 1);
+ s.set_selection(vec![vector]);
+ ex(&mut s, "shapeBuilder.remove", json!({"points": [[25, 50]]}));
+ let node = s.doc().unwrap().node(vector).unwrap();
+ assert!((node.width - 50.0).abs() < 1e-6, "the layer is reboxed to the surviving region: {}", node.width);
+ let net = network(&s, vector);
+ assert_eq!(net.regions.len(), 1);
+ assert!(matches!(&net.regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
+ assert!((world_region_area(&s, vector, 0) - 5000.0).abs() < 1e-6);
+ assert_same_transform(before, normalized_paint_world(&s, vector, 0));
+}
diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs
index fc263b4f..963e9dd9 100644
--- a/crates/engine/src/canvas/vectoredit.rs
+++ b/crates/engine/src/canvas/vectoredit.rs
@@ -1219,17 +1219,31 @@ pub(crate) fn cut_across(s: &mut Session, a: Point, b: Point) -> Result Result {
+ let inherited = doc.get(id)?.fills.clone();
let filled = edit(doc, id, |net| {
let mut want = None;
for p in pts {
- want = net.paint_to(*p, want).or(want);
+ want = net.paint_to_with(*p, want, Some(&inherited)).or(want);
}
want
})?
.ok_or_else(|| bad("vector.edit.paint", "click inside a closed area"))?;
let n = doc.get_mut(id)?;
if filled && n.fills.iter().all(|f| !f.visible) {
- n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)];
+ let default = figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE);
+ let targets: std::collections::BTreeSet = match &n.kind {
+ NodeKind::Vector { network } => pts.iter().filter_map(|point| network.region_at(*point)).collect(),
+ _ => std::collections::BTreeSet::new(),
+ };
+ if let NodeKind::Vector { network } = &mut n.kind {
+ for (index, region) in network.regions.iter_mut().enumerate() {
+ if region.fills.is_none() && !targets.contains(&index) {
+ // Freeze an untouched inherited region before adding a node-wide default.
+ region.fills = Some(Vec::new());
+ }
+ }
+ }
+ n.fills = vec![default];
}
Ok(filled)
}
diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs
index 8832e05d..b44ee941 100644
--- a/crates/engine/src/canvas/vectoredit/ops.rs
+++ b/crates/engine/src/canvas/vectoredit/ops.rs
@@ -19,15 +19,21 @@ pub fn move_vertices(net: &mut VectorNetwork, idx: &[usize], d: Vec2) {
if !finite(d) {
return;
}
- let mut seen = std::collections::BTreeSet::new();
- for i in idx {
- if seen.insert(*i)
- && let Some(v) = net.vertices.get_mut(*i)
- {
+ let seen: std::collections::BTreeSet = idx.iter().copied().filter(|i| *i < net.vertices.len()).collect();
+ for i in &seen {
+ if let Some(v) = net.vertices.get_mut(*i) {
v.x += d.x;
v.y += d.y;
}
}
+ if seen.len() == net.vertices.len() {
+ let translate = Affine::translate(d);
+ for region in &mut net.regions {
+ if let Some(frame) = &mut region.paint_space {
+ frame.transform = translate * frame.transform;
+ }
+ }
+ }
}
/// Map vertices `idx` through `m` (scale, rotate, flip…); the handles at them follow its linear
@@ -65,6 +71,13 @@ pub fn transform_vertices(net: &mut VectorNetwork, idx: &[usize], m: Affine) {
}
}
}
+ if set.len() == net.vertices.len() {
+ for region in &mut net.regions {
+ if let Some(frame) = &mut region.paint_space {
+ frame.transform = m * frame.transform;
+ }
+ }
+ }
}
/// Set a handle to `rel` (relative to its vertex). The vertex's other handle follows its mirroring
@@ -586,7 +599,7 @@ pub fn part(net: &VectorNetwork, verts: &[usize]) -> VectorNetwork {
let loops: Vec> =
r.loops.iter().map(|l| l.iter().filter_map(|i| smap.get(*i).copied().flatten()).collect::>()).filter(|l| !l.is_empty()).collect();
if !loops.is_empty() {
- out.regions.push(Region { loops, winding: r.winding });
+ out.regions.push(Region { loops, ..r.clone() });
}
}
reopen_loops(&mut out);
@@ -648,7 +661,7 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if unfilled {
lp.insert(0, si);
- net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero });
+ net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
}
Some(si)
@@ -862,6 +875,14 @@ mod tests {
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0)], false)
}
+ #[test]
+ fn split_part_keeps_region_paint_metadata() {
+ let mut n = square();
+ n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
+ let part = part(&n, &[0, 1, 2, 3]);
+ assert_eq!(part.regions[0].fills, n.regions[0].fills);
+ }
+
#[test]
fn handles_follow_mirroring() {
let mut n = open_line();
@@ -899,7 +920,7 @@ mod tests {
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
n.segments.push(Segment::line(0, 2));
- n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
+ n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let p = Point::new(25.0, 10.0);
assert!(n.fill_contains(p));
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
diff --git a/crates/engine/src/canvas/vectoredit/regions.rs b/crates/engine/src/canvas/vectoredit/regions.rs
index 111c8e11..91763d3a 100644
--- a/crates/engine/src/canvas/vectoredit/regions.rs
+++ b/crates/engine/src/canvas/vectoredit/regions.rs
@@ -52,9 +52,8 @@ pub fn hatch_spacing(zoom: f64) -> Option {
(s.is_finite() && s > 0.0).then_some(s)
}
-/// Whether vector `n` shows a fill (only filled areas are regions).
-fn filled(n: &figcraft_doc::Node) -> bool {
- n.fills.iter().any(|f| f.visible)
+fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool {
+ net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible))
}
/// The network as its fill draws it: rounded points drawn in (region indexes are kept).
@@ -73,7 +72,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
return None;
}
let net = network(doc, ve.id)?;
- if net.regions.is_empty() || !doc.node(ve.id).is_some_and(filled) {
+ let node = doc.node(ve.id)?;
+ if net.regions.is_empty() || !net.regions.iter().enumerate().any(|(i, _)| region_filled(node, net, i)) {
return None;
}
let w = doc.world_transform(ve.id);
@@ -90,7 +90,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) {
return None;
}
- drawn(net).region_at(lp)
+ let drawn = drawn(net);
+ (0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp))
}
/// The regions to hatch: the selected ones and the hovered one (not while a drag is on), each once.
diff --git a/crates/engine/src/canvas/vectoredit/regions_tests.rs b/crates/engine/src/canvas/vectoredit/regions_tests.rs
index f0e5768e..6787a59f 100644
--- a/crates/engine/src/canvas/vectoredit/regions_tests.rs
+++ b/crates/engine/src/canvas/vectoredit/regions_tests.rs
@@ -31,7 +31,7 @@ fn nested(regions: Vec) -> VectorNetwork {
}
fn region(loops: Vec>, winding: WindingRule) -> Region {
- Region { loops, winding }
+ Region { loops, winding, fills: None, ..Default::default() }
}
/// The 7.1 region: both squares as one region.
@@ -118,6 +118,17 @@ fn only_filled_areas_are_regions() {
assert!(ve(&s).regions.is_empty());
}
+#[test]
+fn styled_region_hit_ignores_empty_override_and_needs_no_layer_fill() {
+ let mut n = overlapping();
+ n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
+ n.regions[1].fills = Some(Vec::new());
+ let mut s = Session::new();
+ let id = editing_vector(&mut s, n);
+ s.doc_mut().unwrap().get_mut(id).unwrap().fills.clear();
+ assert_eq!(hover(&mut s, 100.0, 100.0, 1.0), Some(0));
+}
+
#[test]
fn hover_and_press_select_a_region_with_all_its_points() {
// 20.
diff --git a/crates/engine/src/canvas/vectoredit/width.rs b/crates/engine/src/canvas/vectoredit/width.rs
index 13c7be0c..0e565cc9 100644
--- a/crates/engine/src/canvas/vectoredit/width.rs
+++ b/crates/engine/src/canvas/vectoredit/width.rs
@@ -244,7 +244,7 @@ pub(crate) fn specs() -> Vec {
vec![spec!(
"vector.edit.widthPoint",
"Width point",
- "{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)",
+ "{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight), side? (left|right)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)",
true,
width_point
)]
diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs
index a247ed52..c046cd19 100644
--- a/crates/engine/src/cmd.rs
+++ b/crates/engine/src/cmd.rs
@@ -63,6 +63,8 @@ macro_rules! spec {
struct Registry {
list: Vec,
index: HashMap<&'static str, usize>,
+ /// The parameter names each command's doc lists (`list` order; see [`doc_param_names`]).
+ names: Vec>>,
}
fn registry() -> &'static Registry {
@@ -123,7 +125,8 @@ fn registry() -> &'static Registry {
log::warn!("duplicate command id {}", s.id);
}
}
- Registry { list, index }
+ let names = list.iter().map(|s| doc_param_names(s.params)).collect();
+ Registry { list, index, names }
})
}
@@ -136,6 +139,113 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
r.index.get(id).and_then(|i| r.list.get(*i))
}
+/// The parameter names command `id` takes, from its doc; `None` when the doc isn't a `{…}` list
+/// (`node.set` documents its props, and refuses the ones it doesn't know itself).
+pub fn param_names(id: &str) -> Option<&'static [&'static str]> {
+ let r = registry();
+ r.index.get(id).and_then(|i| r.names.get(*i)).and_then(|n| n.as_deref())
+}
+
+/// Refuse params naming something the command doesn't take: a misspelt name would otherwise be
+/// ignored and the command answer as if it hadn't been given. `ids` and `id` are always taken:
+/// the layers a command acts on (`params::targets` reads either).
+pub fn refuse_unknown_params(id: &str, params: &Value) -> Result<()> {
+ let (Some(names), Some(given)) = (param_names(id), params.as_object()) else { return Ok(()) };
+ let taken = |k: &str| matches!(k, "ids" | "id") || names.contains(&k);
+ let unknown: Vec = given.keys().filter(|k| !taken(k)).map(|k| format!("`{k}`")).collect();
+ if unknown.is_empty() {
+ return Ok(());
+ }
+ let s = if unknown.len() == 1 { "" } else { "s" };
+ let takes = if names.is_empty() { "none".to_string() } else { names.join(", ") };
+ Err(crate::bad(id, format!("unknown parameter{s} {} (it takes {takes})", unknown.join(", "))))
+}
+
+/// The top-level items of a `{…}` params doc (`{ids?, direction? (horizontal|vertical)}` →
+/// `ids?`, ` direction? (horizontal|vertical)`), with those of the alternatives after it
+/// (`{x, y} | {points}`); `None` when the doc doesn't start with one.
+pub(crate) fn doc_items(doc: &'static str) -> Option> {
+ let mut body = doc.trim_start().strip_prefix('{')?;
+ let (mut items, mut depth, mut start) = (Vec::new(), 0usize, 0);
+ loop {
+ let mut rest = None;
+ for (i, c) in body.char_indices() {
+ match c {
+ '{' | '[' | '(' => depth += 1,
+ '}' | ']' | ')' if depth > 0 => depth -= 1,
+ '}' | ',' if depth == 0 => {
+ items.push(body.get(start..i).unwrap_or_default());
+ start = i + c.len_utf8();
+ if c == '}' {
+ rest = body.get(start..);
+ break;
+ }
+ }
+ _ => {}
+ }
+ }
+ let rest = rest?;
+ match rest.trim_start().strip_prefix('|').and_then(|r| r.trim_start().strip_prefix('{')) {
+ Some(next) => (body, start) = (next, 0),
+ None => return Some(items),
+ }
+ }
+}
+
+/// The names one doc item gives: its first word and the words joined to it by `|`, `/` or `+`
+/// (`id?/ids?`, `path | data (base64) + name`, `value? + newValue?`), without the `?` of optional
+/// ones. Bracketed text describes a name, and what follows a `:`, `=` or `→` its value.
+pub(crate) fn item_names(item: &'static str) -> Vec<&'static str> {
+ let mut outside = Vec::new();
+ let (mut depth, mut start) = (0usize, None);
+ for (i, c) in item.char_indices() {
+ let value_follows = depth == 0 && matches!(c, ':' | '=' | '→');
+ let in_word = depth == 0 && !value_follows && !c.is_whitespace() && !matches!(c, '(' | '[' | '{' | ')' | ']' | '}');
+ match c {
+ '(' | '[' | '{' => depth += 1,
+ ')' | ']' | '}' => depth = depth.saturating_sub(1),
+ _ => {}
+ }
+ match (in_word, start) {
+ (true, None) => start = Some(i),
+ (false, Some(s)) => {
+ outside.push(item.get(s..i).unwrap_or_default());
+ start = None;
+ }
+ _ => {}
+ }
+ if value_follows {
+ break;
+ }
+ }
+ if let Some(s) = start {
+ outside.push(item.get(s..).unwrap_or_default());
+ }
+ let is_name =
+ |n: &&str| n.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
+ let mut words = outside.into_iter();
+ let (mut names, mut word) = (Vec::new(), words.next());
+ while let Some(w) = word {
+ names.extend(w.split(['/', '|']).map(|n| n.trim_end_matches('?')).filter(is_name));
+ word = match words.next() {
+ Some("|" | "/" | "+") => words.next(),
+ _ => None,
+ };
+ }
+ names
+}
+
+/// Every name a `{…}` params doc lists, once each (see [`item_names`]).
+fn doc_param_names(doc: &'static str) -> Option> {
+ let mut names = Vec::new();
+ for n in doc_items(doc)?.into_iter().flat_map(item_names) {
+ if !names.contains(&n) {
+ names.push(n);
+ }
+ }
+ Some(names)
+}
+
/// `{"ok": true}` shorthand.
pub fn ok() -> Result {
Ok(serde_json::json!({"ok": true}))
diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs
index 3fde2b09..baac9ab3 100644
--- a/crates/engine/src/cmd/comment.rs
+++ b/crates/engine/src/cmd/comment.rs
@@ -576,7 +576,7 @@ pub fn specs() -> Vec {
Ok(json!({"id": id, "link": link(id)}))
}
),
- spec!("comment.reply", "Reply to comment", "{id, text, author?}", true, |s, p| {
+ spec!("comment.reply", "Reply to comment", "{id | link, text, author?}", true, |s, p| {
let id = req_comment("comment.reply", s, p)?;
let who = author(s, p);
let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs());
@@ -586,7 +586,7 @@ pub fn specs() -> Vec {
c.touched_by(&who);
Ok(json!({"reply": c.replies.len() - 1}))
}),
- spec!("comment.edit", "Edit comment", "{id, text, reply? (index; omit for the first message), author?}", true, |s, p| {
+ spec!("comment.edit", "Edit comment", "{id | link, text, reply? (index; omit for the first message), author?}", true, |s, p| {
let id = req_comment("comment.edit", s, p)?;
let t = text("comment.edit", p)?;
let who = author(s, p);
@@ -607,7 +607,7 @@ pub fn specs() -> Vec {
spec!(
"comment.react",
"React to comment",
- "{id, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}",
+ "{id | link, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}",
true,
|s, p| {
let id = req_comment("comment.react", s, p)?;
@@ -629,7 +629,7 @@ pub fn specs() -> Vec {
Ok(json!({"on": on}))
}
),
- spec!("comment.resolve", "Resolve comment", "{id, resolved? (default true)}", true, |s, p| {
+ spec!("comment.resolve", "Resolve comment", "{id | link, resolved? (default true)}", true, |s, p| {
let id = req_comment("comment.resolve", s, p)?;
let v = boolean(p, "resolved").unwrap_or(true);
if let Some(c) = s.doc_mut()?.comment_mut(id) {
@@ -640,7 +640,7 @@ pub fn specs() -> Vec {
spec!(
"comment.move",
"Move comment",
- "{id, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}",
+ "{id | link, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}",
true,
|s, p| {
let id = req_comment("comment.move", s, p)?;
@@ -655,24 +655,30 @@ pub fn specs() -> Vec {
ok()
}
),
- spec!("comment.delete", "Delete comment", "{id, reply? (index: delete one reply; omit to delete the thread), author?}", true, |s, p| {
- let id = req_comment("comment.delete", s, p)?;
- let who = author(s, p);
- let doc = s.doc_mut()?;
- let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?;
- match message_index("comment.delete", c, p)? {
- 0 => {
- own("comment.delete", &c.first, &who, "delete this thread")?;
- doc.comments.retain(|c| c.id != id);
- }
- i => {
- let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?;
- own("comment.delete", m, &who, "delete this reply")?;
- c.replies.remove(i - 1);
+ spec!(
+ "comment.delete",
+ "Delete comment",
+ "{id | link, reply? (index: delete one reply; omit to delete the thread), author?}",
+ true,
+ |s, p| {
+ let id = req_comment("comment.delete", s, p)?;
+ let who = author(s, p);
+ let doc = s.doc_mut()?;
+ let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?;
+ match message_index("comment.delete", c, p)? {
+ 0 => {
+ own("comment.delete", &c.first, &who, "delete this thread")?;
+ doc.comments.retain(|c| c.id != id);
+ }
+ i => {
+ let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?;
+ own("comment.delete", m, &who, "delete this reply")?;
+ c.replies.remove(i - 1);
+ }
}
+ ok()
}
- ok()
- }),
+ ),
spec!(
"comment.markRead",
"Mark comment as read",
@@ -711,7 +717,7 @@ pub fn specs() -> Vec {
spec!(
"comment.next",
"Next comment",
- "{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query? (the list, as comment.list), author?}",
+ "{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query?, frame?, resolved? (the list, as comment.list), author?}",
false,
|s, p| {
let dir = num(p, "dir").unwrap_or(1.0);
@@ -748,7 +754,7 @@ pub fn specs() -> Vec {
}
Ok(json!({"zoom": v.zoom}))
}),
- spec!("comment.link", "Copy link to comment", "{id}", false, |s, p| {
+ spec!("comment.link", "Copy link to comment", "{id | link}", false, |s, p| {
let id = req_comment("comment.link", s, p)?;
Ok(json!({"link": link(id)}))
}),
diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs
index 2e64ce04..c4c97252 100644
--- a/crates/engine/src/cmd/create.rs
+++ b/crates/engine/src/cmd/create.rs
@@ -53,37 +53,61 @@ pub fn specs() -> Vec {
spec!(
"node.create",
"Create layer",
- "{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? (for text), path? (SVG path data for vector), props? {node.set keys}, select? (default true)}",
+ "{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]), closed? (vector), props? {node.set keys}, select? (default true)}",
true,
create
),
spec!(
"frame.create",
"Create frame",
- "{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?} (a preset without x/y lands to the right of the page's frames)",
+ "{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, index?, name?, props?, select? (default true)} (a preset without x/y lands to the right of the page's frames)",
true,
frame_create
),
- spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "rectangle")),
- spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "ellipse")),
- spec!("shape.polygon", "Polygon", "{x, y, width, height, parent?, props? {pointCount}}", true, |s, p| create_kind(s, p, "polygon")),
- spec!("shape.star", "Star", "{x, y, width, height, parent?, props? {pointCount, innerRadius}}", true, |s, p| create_kind(s, p, "star")),
- spec!("shape.line", "Line", "{x, y, width (length), parent?, props? {rotation}}", true, |s, p| create_kind(s, p, "line")),
- spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, props?}", true, |s, p| create_kind(s, p, "arrow")),
- spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
- create_kind(s, p, "text")
+ spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| {
+ create_kind(s, p, "rectangle")
}),
+ spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(
+ s, p, "ellipse"
+ )),
+ spec!(
+ "shape.polygon",
+ "Polygon",
+ "{x, y, width, height, parent?, index?, name?, props? {pointCount}, select? (default true)}",
+ true,
+ |s, p| create_kind(s, p, "polygon")
+ ),
+ spec!(
+ "shape.star",
+ "Star",
+ "{x, y, width, height, parent?, index?, name?, props? {pointCount, innerRadius}, select? (default true)}",
+ true,
+ |s, p| create_kind(s, p, "star")
+ ),
+ spec!("shape.line", "Line", "{x, y, width (length), parent?, index?, name?, props? {rotation}, select? (default true)}", true, |s, p| {
+ create_kind(s, p, "line")
+ }),
+ spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(
+ s, p, "arrow"
+ )),
+ spec!(
+ "text.create",
+ "Text",
+ "{text | characters, x, y, parent?, index?, name? (default: the first line), width? (fixed width wraps), props? (fontSize, fontFamily, …), select? (default true)}",
+ true,
+ |s, p| { create_kind(s, p, "text") }
+ ),
spec!(
"vector.create",
"Vector",
- "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
+ "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}",
true,
|s, p| create_kind(s, p, "vector")
),
spec!(
"image.place",
"Place image",
- "{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
+ "{data (base64) | path, x?, y?, width?, height?, parent?, name? (default Image), target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
true,
place_image
),
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index d24600c9..9679e19b 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -8,8 +8,8 @@ use serde_json::{Map, Value, json};
use super::{CommandSpec, ok};
use crate::params::{
- blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets,
- string, targets,
+ blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_id, opt_index, paint_of, parse, req_id, req_index, req_num, req_string,
+ req_targets, string, targets,
};
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
@@ -29,6 +29,80 @@ use select::{
select_parent, select_same, select_sibling, toggle,
};
+/// The layer types `node.find` takes, as `NodeKind::type_name` spells them.
+const LAYER_TYPES: [&str; 16] = [
+ "PAGE",
+ "FRAME",
+ "GROUP",
+ "SECTION",
+ "RECTANGLE",
+ "ELLIPSE",
+ "POLYGON",
+ "STAR",
+ "LINE",
+ "VECTOR",
+ "TEXT",
+ "BOOLEAN_OPERATION",
+ "COMPONENT",
+ "COMPONENT_SET",
+ "INSTANCE",
+ "SLICE",
+];
+
+/// Lowercase for `node.find`'s substring filters. Greek final sigma becomes σ on both sides, so a
+/// query lowercased on its own (`Σ` to `σ`) still matches a name ending in it (`ΟΣ` to `ος`).
+fn fold(s: &str) -> String {
+ s.to_lowercase().replace('ς', "σ")
+}
+
+fn node_find(s: &mut Session, p: &Value) -> Result {
+ let cmd = "node.find";
+ let filter = |key| -> Result> {
+ get(p, key).map(|v| v.as_str().map(fold).ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose()
+ };
+ let name = filter("name")?;
+ let text = filter("text")?;
+ let component = filter("component")?;
+ let kind = filter("type")?;
+ if let Some(kind) = &kind
+ && !LAYER_TYPES.iter().any(|t| t.eq_ignore_ascii_case(kind))
+ {
+ return Err(bad(cmd, format!("unknown type; use one of {}", LAYER_TYPES.join(", "))));
+ }
+ let limit = match get(p, "limit") {
+ None => 100,
+ Some(v) => v.as_u64().filter(|n| *n <= 10000).ok_or_else(|| bad(cmd, "`limit` must be an integer from 0 to 10000"))? as usize,
+ };
+ let root = opt_id(cmd, p, "root")?.map_or_else(|| s.page(), Ok)?;
+ let doc = s.doc()?;
+ // Keep one child iterator per ancestor, not a materialised subtree or all pending siblings.
+ let mut stack = vec![doc.get(root)?.children.iter()];
+ let mut ids = Vec::new();
+ let mut total = 0usize;
+ while let Some(children) = stack.last_mut() {
+ let Some(&id) = children.next() else {
+ stack.pop();
+ continue;
+ };
+ let n = doc.get(id)?;
+ stack.push(n.children.iter());
+ if name.as_ref().is_some_and(|q| !fold(&n.name).contains(q))
+ || kind.as_ref().is_some_and(|q| !n.kind.type_name().eq_ignore_ascii_case(q))
+ || text.as_ref().is_some_and(|q| !matches!(&n.kind, NodeKind::Text(t) if fold(&t.characters).contains(q)))
+ || component.as_ref().is_some_and(
+ |q| !matches!(&n.kind, NodeKind::Instance { component, .. } if doc.node(*component).is_some_and(|c| fold(&c.name).contains(q))),
+ )
+ {
+ continue;
+ }
+ total += 1;
+ if ids.len() < limit {
+ ids.push(id);
+ }
+ }
+ Ok(json!({"ids": ids, "total": total}))
+}
+
pub fn specs() -> Vec {
vec![
spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))),
@@ -235,6 +309,13 @@ pub fn specs() -> Vec {
let i = req_id("node.get", p, "id")?;
Ok(crate::inspect::node_json(s.doc()?, i, 1))
}),
+ spec!(
+ "node.find",
+ "Find layers",
+ "{root? (strict descendants; default current page), name?, type? (canonical layer type), text?, component? (main component name), limit? (0-10000; default 100)}: every filter must match; name, text and component are case-insensitive substrings, type is exact (any case); returns {ids, total} and does not select",
+ false,
+ node_find
+ ),
spec!(
"node.reparent",
"Move layer into",
@@ -248,8 +329,8 @@ pub fn specs() -> Vec {
s.doc_mut()?.reorder(i, idx)?;
ok()
}),
- spec!("fill.add", "Add fill", "{ids?, paint? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
- spec!("stroke.add", "Add stroke", "{ids?, paint? (default black)}", true, |s, p| paint_list(s, p, false, true)),
+ spec!("fill.add", "Add fill", "{ids?, paint? | color? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
+ spec!("stroke.add", "Add stroke", "{ids?, paint? | color? (default black)}", true, |s, p| paint_list(s, p, false, true)),
spec!("fill.remove", "Remove fill", "{ids?, index}", true, |s, p| paint_list(s, p, true, false)),
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
@@ -273,7 +354,7 @@ pub fn specs() -> Vec {
spec!(
"fill.pattern",
"Pattern fill",
- "{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?} the source layer tiled; it updates as the source changes",
+ "{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?, visible?, blend?} the source layer tiled; it updates as the source changes",
true,
pattern_fill
),
@@ -354,7 +435,7 @@ pub fn specs() -> Vec {
spec!(
"export.update",
"Edit export setting",
- "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}",
+ "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
true,
|s, p| {
let cmd = "export.update";
@@ -387,6 +468,7 @@ pub fn specs() -> Vec {
for (i, c) in a.iter().enumerate() {
let cmd =
c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
+ crate::batch::refuse_unknown_step_keys(i, c)?;
let params = c.get("params").cloned().unwrap_or(json!({}));
out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) });
}
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 576452c5..173507a6 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -36,7 +36,7 @@ pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write};
pub use open::MAX_RECENT;
#[cfg(test)]
use open::append_pages;
-use open::{import, open, open_recent};
+use open::{import, open, open_recent, revert};
pub(crate) use save::save_ends_preview;
pub use save::{FileSnapshot, file_bytes, thumbnail};
use save::{color_profile, mark_saved, save, save_as};
@@ -75,7 +75,7 @@ pub fn specs() -> Vec {
spec!(
"file.open",
"Open",
- "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
+ "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file), reload? (a file already open: read it again into its tab, which must have no unsaved changes)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open returns its `index`, with `stale: true` when the file changed on disk since the tab read or saved it, or `reloaded: true` after `reload`)",
false,
open
),
@@ -110,7 +110,7 @@ pub fn specs() -> Vec {
spec!(
"file.export",
"Export",
- "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
+ "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
false,
export
),
@@ -121,13 +121,19 @@ pub fn specs() -> Vec {
false,
export_layers
),
- spec!("file.exportSvg", "Export selection as SVG", "{ids?, path?}", false, |s, p| {
- let mut q = p.clone();
- if let Some(o) = q.as_object_mut() {
- o.insert("format".into(), json!("svg"));
+ spec!(
+ "file.exportSvg",
+ "Export selection as SVG",
+ "{ids?, path?, scale? | size?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke? (file.export's SVG options)}",
+ false,
+ |s, p| {
+ let mut q = p.clone();
+ if let Some(o) = q.as_object_mut() {
+ o.insert("format".into(), json!("svg"));
+ }
+ export(s, &q)
}
- export(s, &q)
- }),
+ ),
spec!(
"file.exportFramesPdf",
"Export frames to PDF",
@@ -142,7 +148,13 @@ pub fn specs() -> Vec {
true,
import
),
- spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
+ spec!(
+ "file.placeImage",
+ "Place image",
+ "{path | data, x?, y?, width?, height?, parent?, name?, target?, mode? (as image.place)}",
+ true,
+ super::create::place_image
+ ),
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
let from = req_index("file.moveTab", p, "from")?;
@@ -191,21 +203,7 @@ pub fn specs() -> Vec {
Ok(json!({"index": s.active}))
}),
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
- let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
- // Stamped before reading: a change made during the read makes the next save refuse.
- let on_disk = crate::library::stat(&path).ok();
- let (loaded, _) = load_path(&path, None)?;
- let i = s.active;
- let uid = s.ds()?.uid;
- if let Some(slot) = s.docs.get_mut(i) {
- *slot = DocState::new(loaded.doc, Some(path.clone()))
- .with_versions(loaded.versions)
- .with_branches(loaded.branches)
- .with_images_left_in_file(&path, loaded.images_past_budget);
- slot.on_disk = on_disk;
- }
- // Edits on its branch tabs are reverted too.
- crate::branches::close_tabs(s, uid, None);
+ revert(s)?;
ok()
}),
spec!(
diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs
index 6b724ced..169d71fd 100644
--- a/crates/engine/src/cmd/file/open.rs
+++ b/crates/engine/src/cmd/file/open.rs
@@ -62,11 +62,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result {
return open_data(s, &d, p, variables.as_ref());
}
let path = req_string("file.open", p, "path")?;
- // Already open: switch to it.
+ // Already open: switch to it, and say when the file changed on disk since this tab read or
+ // saved it (`stale`), or read it again (`reload`).
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
s.active = i;
remember(s, &path);
- return Ok(json!({"index": i}));
+ let stale = s.ds()?.on_disk.as_ref().is_some_and(|read| crate::library::stat(&path).ok().as_ref() != Some(read));
+ if !boolean(p, "reload").unwrap_or(false) {
+ return Ok(if stale { json!({"index": i, "stale": true}) } else { json!({"index": i}) });
+ }
+ if s.ds()?.is_dirty() {
+ return Err(EngineError::Disabled(
+ "file.open".into(),
+ "the open copy has unsaved changes: save them, or Revert to saved to drop them".into(),
+ ));
+ }
+ if s.live().doc_uid.is_some() {
+ return Err(EngineError::Disabled("file.open".into(), "leave the live session before reading the file again".into()));
+ }
+ revert(s)?;
+ return Ok(json!({"index": i, "reloaded": true}));
}
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
@@ -95,6 +110,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result {
Ok(out)
}
+/// Replace the active tab's document with its file as saved (`file.revert`, `file.open {reload}`).
+pub(super) fn revert(s: &mut Session) -> Result<()> {
+ let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
+ // Stamped before reading: a change made during the read makes the next save refuse.
+ let on_disk = crate::library::stat(&path).ok();
+ let (loaded, _) = load_path(&path, None)?;
+ let i = s.active;
+ let uid = s.ds()?.uid;
+ if let Some(slot) = s.docs.get_mut(i) {
+ *slot = DocState::new(loaded.doc, Some(path.clone()))
+ .with_versions(loaded.versions)
+ .with_branches(loaded.branches)
+ .with_images_left_in_file(&path, loaded.images_past_budget);
+ slot.on_disk = on_disk;
+ }
+ // Edits on its branch tabs are reverted too.
+ crate::branches::close_tabs(s, uid, None);
+ Ok(())
+}
+
/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells
/// the format. It opens as a new document with no path, so the first save asks where to put it.
fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result {
diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs
index 2474ec23..ee5ba4f6 100644
--- a/crates/engine/src/cmd/props.rs
+++ b/crates/engine/src/cmd/props.rs
@@ -405,23 +405,37 @@ pub(crate) fn variable_prop<'a>(cmd: &str, key: &str, value: &'a Value) -> Resul
Ok(Some(name))
}
-/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
-/// links after the values so a link wins over values its style sets, variable bindings last so
-/// paints set in the same call don't detach them. `$var` wrappers also win over style links.
+/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, the
+/// auto layout mode before the keys that tune it (an object `layout` replaces the whole auto
+/// layout), style links after the values so a link wins over values its style sets, variable
+/// bindings last so paints set in the same call don't detach them. `$var` wrappers also win over style links.
fn apply_order(k: &str, v: &Value) -> u8 {
if v.get("$var").is_some() {
- return 6;
+ return 7;
}
match k {
"path" | "width" | "height" | "w" | "h" => 0,
- "rotation" => 2,
- "x" | "y" => 3,
- "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
- "boundVariables" => 5,
- _ => 1,
+ "layout" | "layoutMode" => 1,
+ "rotation" => 3,
+ "x" | "y" => 4,
+ "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 5,
+ "boundVariables" => 6,
+ _ => 2,
}
}
+/// Auto layout keys given beside a `layout` (or `layoutMode`) that turns auto layout off: they
+/// would turn it back on (`layout_mut`), so the call is refused rather than one of them losing.
+fn layout_keys_beside_none(cmd: &str, props: &Map) -> Result<()> {
+ let off = ["layout", "layoutMode"]
+ .into_iter()
+ .find(|k| props.get(*k).is_some_and(|v| v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))));
+ let tuning: Vec = props.keys().filter(|k| LAYOUT_KEYS.contains(&k.as_str())).map(|k| format!("`{k}`")).collect();
+ let Some(off) = off.filter(|_| !tuning.is_empty()) else { return Ok(()) };
+ let (what, are) = if tuning.len() == 1 { ("an auto layout property", "is") } else { ("auto layout properties", "are") };
+ Err(bad(cmd, format!("{} {are} {what}, and `{off}` in the same call turns auto layout off", tuning.join(", "))))
+}
+
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
/// into W or H.
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
@@ -1034,6 +1048,7 @@ fn apply_variable(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value)
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> {
+ layout_keys_beside_none(cmd, props)?;
let mut unknown = Vec::new();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, v)| apply_order(k, v));
diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs
index d0822db9..3f51bb5e 100644
--- a/crates/engine/src/cmd/proto.rs
+++ b/crates/engine/src/cmd/proto.rs
@@ -16,7 +16,7 @@ pub fn specs() -> Vec {
spec!(
"proto.interaction.add",
"Add interaction",
- "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
+ "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], dest? (without action: a click-navigate to it), transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs? | duration_ms?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}, delayMs? | delay_ms? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
true,
add
),
@@ -51,7 +51,7 @@ pub fn specs() -> Vec {
spec!(
"proto.interaction.set",
"Edit interaction",
- "{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?, delayMs?}",
+ "{id?, index, interaction? {..} (replaces it whole), trigger?, action? (first action), actions?, transition?, durationMs? | duration_ms?, easing?, delayMs? | delay_ms?} patch fields on top of the current interaction (or of interaction)",
true,
set
),
diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs
index 05823cab..6b203c59 100644
--- a/crates/engine/src/cmd/structure.rs
+++ b/crates/engine/src/cmd/structure.rs
@@ -2,8 +2,8 @@
use figcraft_color::{Color, Paint};
use figcraft_doc::{
- AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps,
- VectorNetwork, WindingRule, proto,
+ AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, RegionPaintSpace, Segment, Sizing, StrokeAlign, StrokeJoin,
+ StrokeProps, VectorNetwork, WindingRule, proto,
};
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use serde_json::{Value, json};
@@ -83,7 +83,7 @@ pub fn specs() -> Vec {
}
ok()
}),
- spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add),
+ spec!("layout.suggest", "Suggest auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add),
spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page),
spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?;
@@ -385,56 +385,35 @@ fn flatten(s: &mut Session, p: &Value) -> Result {
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
let index = doc.index_in_parent(top).map(|i| i + 1);
let pw = doc.world_transform(parent);
- let mut net = VectorNetwork::default();
- for i in &ids {
- let Some(n) = doc.node(*i) else { continue };
- // Vectors keep their own network (open paths and unfilled loops have no fill outline);
- // booleans flatten to their result; strokes don't get outlined.
- let mut part = match &n.kind {
- NodeKind::Vector { network } => network.clone(),
- _ => {
- let Some((op, rule)) = (match &n.kind {
- NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
- figcraft_render::subtree_outline(doc, n, 0)
- }
- _ => figcraft_render::node_outline(doc, n),
- }) else {
- continue;
- };
- let mut part = VectorNetwork::from_path(&op);
- if rule == figcraft_render::FillRule::EvenOdd {
- for r in &mut part.regions {
- r.winding = WindingRule::EvenOdd;
- }
- }
- part
+ let first = ids.first().and_then(|i| doc.node(*i)).cloned();
+ let fallback_fills = first
+ .as_ref()
+ .filter(|node| matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty())
+ .map(|_| vec![Paint::solid(Color::DEFAULT_SHAPE)]);
+ let output_fills = first
+ .as_ref()
+ .map(|node| {
+ if matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty() {
+ fallback_fills.clone().unwrap_or_default()
+ } else {
+ node.fills.clone()
}
- };
- // Into the common parent's space.
- let all: Vec = (0..part.vertices.len()).collect();
- crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i));
- let (nv, ns) = (net.vertices.len(), net.segments.len());
- net.vertices.extend(part.vertices);
- net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
- net.regions.extend(
- part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
- );
+ })
+ .unwrap_or_default();
+ let mut net = VectorNetwork::default();
+ for id in &ids {
+ collect_flatten_parts(doc, *id, pw, fallback_fills.as_deref(), &output_fills, &mut net, 0)?;
}
let Some(bb) = net.bounds() else {
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
};
- let first = ids.first().and_then(|i| doc.node(*i)).cloned();
net.translate(-bb.x0, -bb.y0);
let vid = doc.alloc_id();
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
v.transform = Affine::translate((bb.x0, bb.y0));
if let Some(f) = first {
- v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() {
- vec![Paint::solid(Color::DEFAULT_SHAPE)]
- } else {
- f.fills.clone()
- };
+ v.fills = output_fills;
v.strokes = f.strokes.clone();
v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() };
v.effects = f.effects.clone();
@@ -449,6 +428,99 @@ fn flatten(s: &mut Session, p: &Value) -> Result {
Ok(json!({"id": vid}))
}
+fn collect_flatten_parts(
+ doc: &Document,
+ id: NodeId,
+ parent_world: Affine,
+ fallback_fills: Option<&[Paint]>,
+ output_fills: &[Paint],
+ net: &mut VectorNetwork,
+ depth: usize,
+) -> Result<()> {
+ if depth > 64 {
+ return Err(bad("vector.flatten", "group nesting exceeds 64"));
+ }
+ let Some(node) = doc.node(id) else { return Ok(()) };
+ if matches!(node.kind, NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) && !node.children.is_empty() {
+ // Match subtree_outline: a nonempty frame contributes its own visible fill, then
+ // visible descendants in stacking order. Child clipping remains the container's outline rule.
+ if node.kind.is_frame_like()
+ && node.fills.iter().any(|paint| paint.visible)
+ && let Some(path) = figcraft_doc::geometry::shape_path(node)
+ {
+ let frame = VectorNetwork::from_path(&path);
+ let into_parent = parent_world.inverse() * doc.world_transform(id);
+ append_flatten_part(node, fallback_fills, output_fills, into_parent, frame, net);
+ }
+ for child in &node.children {
+ if !doc.node(*child).is_some_and(|child| child.visible) {
+ continue;
+ }
+ collect_flatten_parts(doc, *child, parent_world, fallback_fills, output_fills, net, depth + 1)?;
+ }
+ return Ok(());
+ }
+ let part = match &node.kind {
+ NodeKind::Vector { network } => {
+ let mut part = network.clone();
+ if !part.regions.is_empty() && part.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()) {
+ let winding = if part.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
+ WindingRule::EvenOdd
+ } else {
+ WindingRule::NonZero
+ };
+ let loops = std::mem::take(&mut part.regions).into_iter().flat_map(|region| region.loops).collect();
+ part.regions = vec![Region { loops, winding, ..Default::default() }];
+ }
+ part
+ }
+ _ => {
+ let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) };
+ let mut part = VectorNetwork::from_path(&path);
+ for region in &mut part.regions {
+ if rule == figcraft_render::FillRule::EvenOdd {
+ region.winding = WindingRule::EvenOdd;
+ }
+ }
+ part
+ }
+ };
+ let into_parent = parent_world.inverse() * doc.world_transform(id);
+ append_flatten_part(node, fallback_fills, output_fills, into_parent, part, net);
+ Ok(())
+}
+
+fn append_flatten_part(
+ node: &Node,
+ fallback_fills: Option<&[Paint]>,
+ output_fills: &[Paint],
+ into_parent: Affine,
+ mut part: VectorNetwork,
+ net: &mut VectorNetwork,
+) {
+ for region in &mut part.regions {
+ let (width, height, paint_transform) = region.paint_frame(node.width, node.height);
+ if region.fills.is_none() {
+ let effective = if node.fills.is_empty() { fallback_fills.unwrap_or(&node.fills) } else { &node.fills };
+ if effective != output_fills {
+ region.fills = Some(effective.to_vec());
+ }
+ }
+ region.paint_space = Some(RegionPaintSpace { width, height, transform: paint_transform });
+ }
+ let vertices: Vec = (0..part.vertices.len()).collect();
+ crate::vectoredit::ops::transform_vertices(&mut part, &vertices, into_parent);
+ let (nv, ns) = (net.vertices.len(), net.segments.len());
+ net.vertices.extend(part.vertices);
+ net.segments.extend(part.segments.into_iter().map(|segment| Segment { a: segment.a + nv, b: segment.b + nv, ..segment }));
+ net.regions.extend(
+ part.regions.into_iter().map(|region| Region {
+ loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + ns).collect()).collect(),
+ ..region
+ }),
+ );
+}
+
fn outline_stroke(s: &mut Session, p: &Value) -> Result {
let ids = req_targets("vector.outlineStroke", s, p)?;
let mut out = Vec::new();
@@ -489,7 +561,8 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result {
v.fills = n.strokes.clone();
v.strokes.clear();
v.styles.fill = n.styles.stroke;
- if n.fills.iter().any(|f| f.visible) && closed {
+ let region_fill = matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.as_ref().is_some_and(|fills| fills.iter().any(|f| f.visible))));
+ if (n.fills.iter().any(|f| f.visible) || region_fill) && closed {
// Keep the fill as its own layer under the outline, grouped.
doc.insert(v, parent, idx.map(|x| x + 1))?;
let fill_id = i;
@@ -634,12 +707,53 @@ fn simplify(s: &mut Session, p: &Value) -> Result {
let doc = s.doc_mut()?;
let n = doc.get_mut(i)?;
if let NodeKind::Vector { network } = &mut n.kind {
- let fill = network.fill_path();
- let src = if fill.elements().is_empty() { network.stroke_path() } else { fill };
- let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default());
- let regions = !network.regions.is_empty();
- let mut net = VectorNetwork::from_path(&simplified);
- if !regions {
+ let source = network.clone();
+ let mut net = VectorNetwork::default();
+ let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
+ if legacy && !source.regions.is_empty() {
+ let winding = if source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
+ WindingRule::EvenOdd
+ } else {
+ WindingRule::NonZero
+ };
+ let path = source.fill_path();
+ let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
+ Ok(path) => (path, WindingRule::EvenOdd),
+ Err(_) => (path, winding),
+ };
+ let combined = Region { winding: output_winding, ..Default::default() };
+ append_region_path(&mut net, &path, &combined, output_winding);
+ } else {
+ for (index, region) in source.regions.iter().enumerate() {
+ let Some(path) = source.region_path(index) else { continue };
+ let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
+ append_region_path(&mut net, &path, region, region.winding);
+ }
+ }
+ if !source.regions.is_empty() {
+ let mut used = vec![false; source.segments.len()];
+ for segment in source.regions.iter().flat_map(|region| region.loops.iter().flatten()) {
+ if let Some(used) = used.get_mut(*segment) {
+ *used = true;
+ }
+ }
+ let mut open = source.clone();
+ open.segments = source.segments.iter().enumerate().filter_map(|(index, segment)| (!used[index]).then_some(*segment)).collect();
+ open.regions.clear();
+ if !open.segments.is_empty() {
+ let path = open.stroke_path();
+ let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let mut loose = VectorNetwork::from_path(&simplified);
+ loose.regions.clear();
+ append_network(&mut net, loose);
+ }
+ }
+ if source.regions.is_empty() {
+ let path = source.stroke_path();
+ let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ net = VectorNetwork::from_path(&simplified);
net.regions.clear();
}
*network = net;
@@ -657,22 +771,99 @@ fn offset_path(s: &mut Session, p: &Value) -> Result {
if !n.children.is_empty() {
continue;
}
- let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
- let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
- let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
- let res = if d >= 0.0 {
- figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
- } else {
- figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
- };
- let Ok(mut res) = res else { continue };
- let bb = res.bounding_box();
- res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
- let nm = doc.get_mut(i)?;
- let mut net = VectorNetwork::from_path(&res);
- for r in &mut net.regions {
- r.winding = WindingRule::EvenOdd;
+ if d == 0.0 {
+ continue;
}
+ let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
+ let NodeKind::Vector { network: source } = &n.kind else {
+ let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
+ let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
+ let result = if d >= 0.0 {
+ figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
+ } else {
+ figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
+ }
+ .map_err(|error| bad("vector.offset", error.to_string()))?;
+ let mut result = result;
+ let bounds = result.bounding_box();
+ result.apply_affine(Affine::translate((-bounds.x0, -bounds.y0)));
+ let node = doc.get_mut(i)?;
+ node.kind = NodeKind::Vector { network: VectorNetwork::from_path(&result) };
+ node.transform *= Affine::translate((bounds.x0, bounds.y0));
+ node.width = bounds.width();
+ node.height = bounds.height();
+ continue;
+ };
+ let mut net = VectorNetwork::default();
+ let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
+ if source.regions.is_empty() {
+ let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
+ let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
+ let mut res = if d >= 0.0 {
+ figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
+ } else {
+ figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
+ }
+ .map_err(|error| bad("vector.offset", error.to_string()))?;
+ let bb = res.bounding_box();
+ res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
+ let nm = doc.get_mut(i)?;
+ nm.kind = NodeKind::Vector { network: VectorNetwork::from_path(&res) };
+ nm.transform *= Affine::translate((bb.x0, bb.y0));
+ nm.width = bb.width();
+ nm.height = bb.height();
+ continue;
+ }
+ if legacy {
+ let path = source.fill_path();
+ let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
+ let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
+ let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
+ let result = if d >= 0.0 {
+ figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
+ } else {
+ figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
+ }
+ .map_err(|error| bad("vector.offset", error.to_string()))?;
+ let mut part = VectorNetwork::from_path(&result);
+ let (paint_width, paint_height, paint_transform) = source.regions[0].paint_frame(n.width, n.height);
+ for region in &mut part.regions {
+ region.winding = WindingRule::EvenOdd;
+ region.fills = source.regions[0].fills.clone();
+ region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
+ }
+ append_network(&mut net, part);
+ } else {
+ for (index, region) in source.regions.iter().enumerate() {
+ let Some(path) = source.region_path(index) else { continue };
+ let mut paint_region = region.clone();
+ let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height);
+ paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
+ let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd)
+ .map_err(|error| bad("vector.offset", error.to_string()))?;
+ let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
+ let res = if d >= 0.0 {
+ figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
+ } else {
+ figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
+ }
+ .map_err(|error| bad("vector.offset", error.to_string()))?;
+ // A collapsed negative offset removes this region, while other source regions stay.
+ if res.elements().is_empty() {
+ continue;
+ }
+ append_region_path(&mut net, &res, &paint_region, WindingRule::EvenOdd);
+ }
+ }
+ let Some(bb) = net.bounds() else {
+ let nm = doc.get_mut(i)?;
+ nm.kind = NodeKind::Vector { network: net };
+ nm.width = 0.0;
+ nm.height = 0.0;
+ continue;
+ };
+ net.translate(-bb.x0, -bb.y0);
+ let nm = doc.get_mut(i)?;
nm.kind = NodeKind::Vector { network: net };
nm.transform *= Affine::translate((bb.x0, bb.y0));
nm.width = bb.width();
@@ -681,6 +872,25 @@ fn offset_path(s: &mut Session, p: &Value) -> Result {
ok()
}
+fn append_region_path(net: &mut VectorNetwork, path: &BezPath, source: &Region, winding: WindingRule) {
+ let mut part = VectorNetwork::from_path(path);
+ for region in &mut part.regions {
+ region.winding = winding;
+ region.fills = source.fills.clone();
+ region.paint_space = source.paint_space;
+ }
+ append_network(net, part);
+}
+
+fn append_network(net: &mut VectorNetwork, part: VectorNetwork) {
+ let (nv, ns) = (net.vertices.len(), net.segments.len());
+ net.vertices.extend(part.vertices);
+ net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
+ net.regions.extend(
+ part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
+ );
+}
+
/// Join open vectors whose end points touch into one vector.
fn join(s: &mut Session, p: &Value) -> Result {
let ids = req_targets("vector.join", s, p)?;
@@ -695,7 +905,7 @@ fn join(s: &mut Session, p: &Value) -> Result {
let last = network.vertices.len() - 1;
network.segments.push(Segment::line(last, 0));
let all: Vec = (0..network.segments.len()).collect();
- network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero });
+ network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero, fills: None, paint_space: None });
}
}
return ok();
diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs
index 4672583f..0f3fdef6 100644
--- a/crates/engine/src/cmd/styles.rs
+++ b/crates/engine/src/cmd/styles.rs
@@ -164,7 +164,7 @@ pub fn specs() -> Vec {
spec!(
"variable.create",
"Create variable",
- "{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}",
+ "{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?}",
true,
var_create
),
diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs
index 5f00fbe7..ec219db3 100644
--- a/crates/engine/src/cmd/text.rs
+++ b/crates/engine/src/cmd/text.rs
@@ -279,13 +279,19 @@ pub fn specs() -> Vec {
.ok_or_else(|| bad("text.decoration", "`decoration`: none|underline|strikethrough"))?;
style(s, p, |st| st.decoration = d)
}),
- spec!("text.listStyle", "List style", "{style: none|bulleted|numbered, ids?} while editing: the selected paragraphs", true, |s, p| {
- let l: ListStyle = get(p, "style")
- .or_else(|| get(p, "list"))
- .and_then(super::props::enum_of)
- .ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
- set_list(s, p, l, false)
- }),
+ spec!(
+ "text.listStyle",
+ "List style",
+ "{style | list: none|bulleted|numbered, ids?} while editing: the selected paragraphs",
+ true,
+ |s, p| {
+ let l: ListStyle = get(p, "style")
+ .or_else(|| get(p, "list"))
+ .and_then(super::props::enum_of)
+ .ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
+ set_list(s, p, l, false)
+ }
+ ),
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_indent = v)
@@ -374,7 +380,7 @@ pub fn specs() -> Vec {
spec!("text.endPreview", "End text preview", "{} puts the document back as it was before text.preview → {ended}", false, |s, _| {
Ok(json!({"ended": crate::textpreview::end(s)}))
}),
- spec!("text.set", "Set text", "{id?, characters}", true, |s, p| {
+ spec!("text.set", "Set text", "{id?, characters | text}", true, |s, p| {
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
let ids = req_targets("text.set", s, p)?;
let doc = s.doc_mut()?;
@@ -390,7 +396,7 @@ pub fn specs() -> Vec {
spec!(
"text.style",
"Style text range",
- "{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textCase?, hyperlink?, textFills?}",
+ "{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontVariations? | variationSettings?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textDecorationStyle?, textDecorationThickness?, textDecorationOffset?, textDecorationSkipInk?, textDecorationColor?, textCase?, hyperlink?, textFills?}",
true,
|s, p| {
let ids = req_targets("text.style", s, p)?;
diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs
index 97b947a5..f8cde2e3 100644
--- a/crates/engine/src/cmd/view.rs
+++ b/crates/engine/src/cmd/view.rs
@@ -373,7 +373,7 @@ pub fn specs() -> Vec {
spec!(
"pref.uiScale",
"Interface size",
- "{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
+ "{scale? (a size from 0.75 to 3 relative to the system's display scale: 1 is the system's own size, so 1.25 is 125 %, not 125; outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
false,
|s, p| {
match get(p, "scale") {
diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs
index 22bb53c4..2c3706f8 100644
--- a/crates/engine/src/component_codegen_tests.rs
+++ b/crates/engine/src/component_codegen_tests.rs
@@ -106,6 +106,17 @@ fn components_take_their_properties_as_parameters() {
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
+ let dart = code(&mut s, c, "flutter");
+ for want in [
+ "import 'package:flutter/material.dart';\n\nclass Card extends StatelessWidget {\n",
+ " const Card({\n super.key,\n this.showDot = true,\n this.title = \"Hello\",\n });\n",
+ " final bool showDot;\n final String title;\n",
+ " Widget build(BuildContext context) {\n return Container(",
+ "if (showDot)\n",
+ "Text(\n title,",
+ ] {
+ assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
+ }
// The command describes the properties.
let v = component_code(&mut s, c, "react");
assert_eq!(v["name"], "Card");
@@ -140,6 +151,7 @@ fn instances_are_calls_passing_the_values_they_set() {
assert!(react.contains("export function Card({"));
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
+ assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], " \n");
// Text that needs escaping is an expression in JSX and escaped per language.
@@ -147,6 +159,7 @@ fn instances_are_calls_passing_the_values_they_set() {
assert_eq!(component_code(&mut s, i, "react")["usage"], " \n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
+ assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" \\$5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], " \n");
// A quoted title becomes a binding whose attribute value is escaped: the expression itself
@@ -175,6 +188,8 @@ fn nested_instances_in_a_layer_tree_are_calls() {
assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}");
assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")"));
assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")"));
+ let dart = code(&mut s, screen, "flutter");
+ assert!(dart.contains("Card(title: \"Inbox\")") && !dart.contains("Hello"), "{dart}");
let leptos = code(&mut s, screen, "leptos");
assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains(" "), "{leptos}");
assert!(!leptos.contains("Hello"), "{leptos}");
@@ -232,6 +247,19 @@ fn variant_sets_share_one_definition() {
assert_eq!(component_code(&mut s, i, "leptos")["usage"], " \n");
}
+#[test]
+fn flutter_variant_sets_share_one_enum() {
+ let mut s = Session::new();
+ let (set, _, _) = button_set(&mut s);
+ let dart = code(&mut s, set, "flutter");
+ assert!(dart.contains("enum ButtonState { defaultValue, hover }\n"), "{dart}");
+ assert!(dart.contains(" this.state = ButtonState.defaultValue,\n"), "{dart}");
+ assert!(dart.contains(" final ButtonState state;\n"), "{dart}");
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
+ assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Button(state: ButtonState.hover)\n");
+}
+
#[test]
fn instance_swap_properties_take_views() {
let mut s = Session::new();
@@ -252,6 +280,10 @@ fn instance_swap_properties_take_views() {
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
let kt = code(&mut s, chip, "compose");
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
+ let dart = code(&mut s, chip, "flutter");
+ assert!(dart.contains(" final Widget icon;\n") && dart.contains(" this.icon = const HeartIcon(),\n"), "{dart}");
+ assert!(dart.contains("child: icon\n"), "the nested instance is the parameter: {dart}");
+ assert!(!dart.contains("child: Container("), "the swap's widget tree isn't spelled out: {dart}");
let ark = code(&mut s, chip, "arkui");
for want in [
" @BuilderParam icon: () => void = this.defaultIconBuilder",
@@ -272,6 +304,7 @@ fn instance_swap_properties_take_views() {
assert_eq!(component_code(&mut s, i, "react")["usage"], " } />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
+ assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Chip(icon: StarIcon())\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], " }} />\n");
assert_eq!(component_code(&mut s, i, "vue")["usage"], " \n");
@@ -289,6 +322,9 @@ fn slots_take_optional_content() {
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
+ let dart = code(&mut s, panel, "flutter");
+ assert!(dart.contains(" final List? content;\n") && dart.contains(" this.content,\n"), "{dart}");
+ assert!(dart.contains("...?content,\n") && dart.contains("if (content == null) ...[\n"), "{dart}");
let ark = code(&mut s, panel, "arkui");
for want in [
" @BuilderParam content: () => void = this.defaultContentBuilder",
@@ -305,11 +341,14 @@ fn slots_take_optional_content() {
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 400, "y": 0})));
assert_eq!(component_code(&mut s, i, "react")["usage"], " \n", "the main's content isn't passed");
+ assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Panel()\n", "the main's content isn't passed");
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true}));
let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with(" "), "{usage}");
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with(" ") && usage.ends_with("}} />\n"), "{usage}");
+ let usage = component_code(&mut s, i, "flutter")["usage"].as_str().unwrap_or_default().to_string();
+ assert!(usage.starts_with("Panel(content: [\n") && usage.contains("Badge()"), "{usage}");
let usage = component_code(&mut s, i, "arkui")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("Panel({ content: () => {\n") && usage.contains("Badge()") && usage.ends_with("} })\n"), "{usage}");
}
@@ -359,10 +398,22 @@ fn identifiers_are_escaped_and_unique() {
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
+ // Dart: lowerCamel members, keywords suffixed, never a type the widget tree names.
+ let dart = code_for(doc, c, CodeLang::Flutter);
+ for want in [
+ "class TextComponent extends StatelessWidget {",
+ "enum TextComponentColorVariant { red }\n",
+ " this.defaultValue = false,",
+ " this.p2Columns = false,",
+ " final String type;",
+ ] {
+ assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
+ }
// Errors.
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "html"})).is_err());
+ assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "flutter"})).is_ok());
assert!(s.execute("inspect.componentCode", &json!({"id": 999_999})).is_err());
}
diff --git a/crates/engine/src/dev/annotations.rs b/crates/engine/src/dev/annotations.rs
index 0b27d03e..45b867ab 100644
--- a/crates/engine/src/dev/annotations.rs
+++ b/crates/engine/src/dev/annotations.rs
@@ -247,7 +247,7 @@ pub fn specs() -> Vec {
spec!(
"annotation.add",
"Add annotation",
- "{id? (default the first selected layer), label? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects], category? (id or name)} → {id, index} (a note, pinned properties or both)",
+ "{id? (default the first selected layer), label? | text? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects], category? (id or name)} → {id, index} (a note, pinned properties or both)",
true,
|s, p| {
let cmd = "annotation.add";
@@ -270,7 +270,7 @@ pub fn specs() -> Vec {
spec!(
"annotation.edit",
"Edit annotation",
- "{id?, index, label?, properties?: [..] (replaces the pinned properties), category? (id or name; null: none)}",
+ "{id?, index, label? | text?, properties?: [..] (replaces the pinned properties), category? (id or name; null: none)}",
true,
|s, p| {
let cmd = "annotation.edit";
diff --git a/crates/engine/src/dev/code_connect.rs b/crates/engine/src/dev/code_connect.rs
index 8524c25a..33bb594b 100644
--- a/crates/engine/src/dev/code_connect.rs
+++ b/crates/engine/src/dev/code_connect.rs
@@ -37,8 +37,9 @@ const MAX_WARNINGS: usize = 50;
/// The languages a component can be connected in.
pub fn language(s: &str) -> Option {
- CodeLang::from_id(s)
- .filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))
+ CodeLang::from_id(s).filter(|l| {
+ matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi | CodeLang::Flutter)
+ })
}
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
@@ -69,6 +70,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
// A single-file component is a default export.
CodeLang::Vue => format!("import {} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
+ CodeLang::Flutter => format!("import '{}';", path.replace(['\'', '\\'], "")),
_ => format!("import {path}"),
}
}
@@ -273,6 +275,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
(Some(l), CodeLang::Vue) => l.name.clone(),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
(Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name),
+ (Some(l), CodeLang::Flutter) => format!("{}()", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name),
(None, _) => value_expr(doc, c, p, v, lang),
}
@@ -456,7 +459,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::())
}
CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{p}}}")).collect::()),
- CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")),
+ CodeLang::SwiftUi | CodeLang::Flutter => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")),
CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")),
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().join(", ")),
}
@@ -478,6 +481,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
CodeLang::Vue => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./@-".contains(ch)),
+ CodeLang::Flutter => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./:-".contains(ch)),
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
};
if path.chars().count() > MAX_IMPORT || !path_ok {
@@ -486,6 +490,9 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
CodeLang::Vue => format!("importPath: a module path (letters, digits, _ . / @ and -), up to {MAX_IMPORT} characters"),
+ CodeLang::Flutter => {
+ format!("importPath: a Dart import (package:acme/card.dart; letters, digits, _ . / : and -), up to {MAX_IMPORT} characters")
+ }
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
});
}
@@ -521,7 +528,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result> {
/// The `language` parameter (default React).
fn lang_param(cmd: &str, p: &Value) -> Result {
match text(cmd, p, "language")? {
- Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui")),
+ Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui|flutter")),
None => Ok(CodeLang::React),
}
}
@@ -551,7 +558,7 @@ pub fn specs() -> Vec {
spec!(
"codeConnect.set",
"Connect to code",
- "{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
+ "{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui|flutter (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
true,
|s, p| {
let cmd = "codeConnect.set";
@@ -666,7 +673,10 @@ fn import(s: &mut Session, p: &Value) -> Result {
let (Some(component), Some(lang), Some(name), Some(template)) =
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
else {
- warnings.push(format!("link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui), name and template strings", i + 1));
+ warnings.push(format!(
+ "link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui|flutter), name and template strings",
+ i + 1
+ ));
continue;
};
let link = CodeLink {
diff --git a/crates/engine/src/dev/codegen.rs b/crates/engine/src/dev/codegen.rs
index 964387e1..8a72c201 100644
--- a/crates/engine/src/dev/codegen.rs
+++ b/crates/engine/src/dev/codegen.rs
@@ -1,7 +1,7 @@
//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the
-//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS;
-//! stacks for auto layout, absolute offsets otherwise). Components become components whose
-//! properties are parameters and instances become calls passing their values (see
+//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose, ArkUI (HarmonyOS) or
+//! Flutter (stacks for auto layout, absolute offsets otherwise). Components become components
+//! whose properties are parameters and instances become calls passing their values (see
//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The
//! output is a starting point, not a pixel-exact translation.
@@ -20,6 +20,7 @@ use crate::{bad, spec};
mod arkui;
mod compose;
mod css;
+mod flutter;
mod html;
mod swift;
@@ -30,6 +31,7 @@ pub(crate) use css::Tokens;
pub(crate) use css::css_decls;
use css::{css, tok};
pub use css::{css_var_name, css_var_of};
+pub(crate) use flutter::{FLUTTER_IMPORT, Place, flutter, flutter_indent};
use html::html;
pub(crate) use html::{Markup, class_name};
pub(crate) use swift::swift;
@@ -38,13 +40,15 @@ pub fn specs() -> Vec {
vec![spec!(
"inspect.code",
"Code for layer",
- "{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
+ "{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui|flutter (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
false,
|s, p| {
let cmd = "inspect.code";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
let lang = match string(p, "language") {
- Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui"))?,
+ Some(l) => {
+ CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui|flutter"))?
+ }
None => CodeLang::Css,
};
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
@@ -96,10 +100,12 @@ pub enum CodeLang {
Compose,
/// HarmonyOS ArkUI: an ArkTS struct (`@Component`, `build`).
ArkUi,
+ /// A Flutter `StatelessWidget` (Dart) with its widget tree.
+ Flutter,
}
impl CodeLang {
- pub const ALL: [CodeLang; 9] = [
+ pub const ALL: [CodeLang; 10] = [
CodeLang::Css,
CodeLang::Html,
CodeLang::React,
@@ -109,6 +115,7 @@ impl CodeLang {
CodeLang::SwiftUi,
CodeLang::Compose,
CodeLang::ArkUi,
+ CodeLang::Flutter,
];
pub fn id(self) -> &'static str {
match self {
@@ -121,6 +128,7 @@ impl CodeLang {
CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose",
CodeLang::ArkUi => "arkui",
+ CodeLang::Flutter => "flutter",
}
}
pub fn label(self) -> &'static str {
@@ -134,6 +142,7 @@ impl CodeLang {
CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose",
CodeLang::ArkUi => "ArkUI",
+ CodeLang::Flutter => "Flutter",
}
}
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
@@ -141,6 +150,11 @@ impl CodeLang {
pub fn is_web(self) -> bool {
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Vue)
}
+ /// Languages whose strings interpolate `$` (Kotlin's `$name`, Dart's `$name`), where a literal
+ /// `$` has to be escaped.
+ pub fn escapes_dollar(self) -> bool {
+ matches!(self, CodeLang::Compose | CodeLang::Flutter)
+ }
pub fn from_id(s: &str) -> Option {
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
return Some(CodeLang::React);
@@ -151,6 +165,9 @@ impl CodeLang {
if s.eq_ignore_ascii_case("arkts") {
return Some(CodeLang::ArkUi);
}
+ if s.eq_ignore_ascii_case("dart") {
+ return Some(CodeLang::Flutter);
+ }
CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s))
}
}
@@ -262,7 +279,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
CodeLang::Css => css(doc, id),
CodeLang::Html => html(doc, id),
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
- CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => {
+ CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi | CodeLang::Flutter => {
crate::component_codegen::code(doc, id, lang)
}
}
@@ -549,6 +566,111 @@ mod tests {
assert!(ark.contains(&format!(".position({{ x: {}, y: {} }})", num(x), num(y))), "the absolute child keeps its place: {ark}");
}
+ #[test]
+ fn flutter_builds_a_widget_tree() {
+ let mut s = Session::new();
+ let f = card(&mut s);
+ let doc = s.doc().unwrap();
+ let dart = code_for(doc, f, CodeLang::Flutter);
+ // The card hugs its content, so its width is the layout's, not the 200 it was created with.
+ let w = doc.node(f).map(|n| n.width).unwrap_or_default();
+ assert!(dart.starts_with(&format!("Container(\n width: {},\n height: 100,\n", num(w))), "{dart}");
+ assert!(dart.contains("borderRadius: BorderRadius.circular(12)"), "{dart}");
+ assert!(dart.contains("child: Row(\n"), "{dart}");
+ assert!(dart.contains(" Text(\n \"Say \\\"hi\\\" \\$5\\nnow\",\n"), "escaped: {dart}");
+ assert!(dart.contains("fontWeight: FontWeight.w400,"), "{dart}");
+ assert!(dart.contains("shape: BoxShape.circle"), "{dart}");
+ assert!(dart.ends_with(')'), "a bare expression, like the other native targets: {dart}");
+ // Through the command: `dart` names the same language, and rem is for the web only.
+ let v = s.execute("inspect.code", &json!({"id": f, "language": "flutter"})).unwrap();
+ assert_eq!(v["code"].as_str().unwrap(), dart);
+ assert_eq!(v["language"], "flutter");
+ assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "dart"})).unwrap()["language"], "flutter");
+ let rem = s.execute("inspect.code", &json!({"id": f, "language": "flutter", "unit": "rem"})).unwrap();
+ assert_eq!(rem["code"].as_str().unwrap(), dart, "rem doesn't apply to Dart");
+ }
+
+ #[test]
+ fn flutter_positions_free_children_and_bounds_the_tree() {
+ let mut s = Session::new();
+ let root = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
+ s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 5, "height": 5, "parent": root})).unwrap();
+ let mut parent = root;
+ for _ in 0..20 {
+ parent = id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent})).unwrap()["id"]).unwrap();
+ }
+ let dart = code_for(s.doc().unwrap(), root, CodeLang::Flutter);
+ assert!(dart.contains("Stack(\n alignment: Alignment.topLeft,\n"), "{dart}");
+ assert!(dart.contains("Positioned(\n left: 7,\n top: 9,\n"), "{dart}");
+ // A wrapper's closing parenthesis sits on its own line, not glued to the widget's.
+ assert!(dart.contains(" )\n ),\n"), "{dart}");
+ assert!(dart.contains("…"), "the depth is capped: {dart}");
+ assert!(dart.contains("const SizedBox()"), "a capped layer is still a widget in the list: {dart}");
+ }
+
+ #[test]
+ fn flutter_keeps_a_capped_layers_name_on_its_comment_line() {
+ let mut s = Session::new();
+ let root = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
+ let mut parent = root;
+ for _ in 0..20 {
+ let p = json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": "deep\nvoid main() {}"});
+ parent = id_of(&s.execute("frame.create", &p).unwrap()["id"]).unwrap();
+ }
+ let dart = code_for(s.doc().unwrap(), root, CodeLang::Flutter);
+ assert!(dart.contains("…"), "the depth is capped: {dart}");
+ assert!(!dart.lines().any(|l| l.trim_start().starts_with("void main()")), "a name never leaves its comment: {dart}");
+ }
+
+ #[test]
+ fn flutter_expands_and_stretches_fill_children() {
+ let mut s = Session::new();
+ let row = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 40})).unwrap()["id"]).unwrap();
+ let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap()["id"]).unwrap();
+ s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap();
+ s.execute("layout.add", &json!({"ids": [row], "direction": "horizontal"})).unwrap();
+ s.execute("node.set", &json!({"id": a, "props": {"sizingH": "fill", "sizingV": "fill"}})).unwrap();
+ let dart = code_for(s.doc().unwrap(), row, CodeLang::Flutter);
+ assert!(dart.contains("child: Row(\n"), "{dart}");
+ assert!(dart.contains("Expanded(\n"), "a child filling the main axis takes the room left: {dart}");
+ assert!(dart.contains("crossAxisAlignment: CrossAxisAlignment.stretch,"), "a child filling the cross axis stretches the line: {dart}");
+ }
+
+ #[test]
+ fn flutter_lifts_a_child_that_ignores_its_auto_layout_parent_out_of_the_line() {
+ let mut s = Session::new();
+ let row = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 40})).unwrap()["id"]).unwrap();
+ let flow = id_of(&s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 50, "height": 20, "parent": row})).unwrap()["id"]).unwrap();
+ let over = id_of(&s.execute("shape.ellipse", &json!({"x": 150, "y": 6, "width": 30, "height": 30, "parent": row})).unwrap()["id"]).unwrap();
+ s.execute("layout.add", &json!({"ids": [row], "direction": "horizontal"})).unwrap();
+ s.execute("node.set", &json!({"id": row, "props": {"padding": [4, 4, 4, 4]}})).unwrap();
+ // It asks for both axes, which the line would read as "stretch me and take the room left".
+ s.execute("node.set", &json!({"id": over, "props": {"absolute": true, "sizingH": "fill", "sizingV": "fill"}})).unwrap();
+ let doc = s.doc().unwrap();
+ let dart = code_for(doc, row, CodeLang::Flutter);
+ let at = |needle: &str| dart.find(needle).unwrap_or_else(|| panic!("no {needle:?}: {dart}"));
+ // Over the line, in a Stack at the parent's own box, at its own offsets.
+ let [_, _, _, _, x, y] = doc.node(over).unwrap().transform.as_coeffs();
+ assert!(
+ dart.contains(&format!("Positioned(\n left: {},\n top: {},\n", num(x), num(y))),
+ "the absolute child keeps its place: {dart}"
+ );
+ let stack = at("child: Stack(\n");
+ let fill = at("Positioned.fill(\n");
+ let padding = at("padding: EdgeInsets.all(4),");
+ let line = at("child: Row(\n");
+ assert!(stack < fill && fill < padding && padding < line, "the line sits in the Stack, padded: {dart}");
+ assert!(!dart[..padding].contains("padding: "), "the parent's padding would offset the absolute child: {dart}");
+ // Only the flow child is in the line, so neither `Expanded` nor a stretch comes from it.
+ let over_at = at("\n Positioned(\n");
+ let line_part = &dart[line..over_at];
+ assert!(line_part.contains("width: 50,"), "the flow child is in the line: {dart}");
+ assert!(!line_part.contains("width: 30,"), "the absolute child left the line: {dart}");
+ assert!(!dart.contains("Expanded(\n"), "it takes no room in the line either: {dart}");
+ assert!(dart.contains("crossAxisAlignment: CrossAxisAlignment.start,"), "{dart}");
+ assert!(doc.node(flow).is_some());
+ }
+
#[test]
fn arkui_wraps_a_positioned_instance_swap_so_the_position_lands_on_a_container() {
let mut s = Session::new();
diff --git a/crates/engine/src/dev/codegen/flutter.rs b/crates/engine/src/dev/codegen/flutter.rs
new file mode 100644
index 00000000..98f280ea
--- /dev/null
+++ b/crates/engine/src/dev/codegen/flutter.rs
@@ -0,0 +1,364 @@
+use figcraft_doc::AutoLayout;
+
+use super::swift::one_line;
+use super::{
+ CodeLang, CrossAlign, Ctx, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Param, Sizing, TextAlignH, TextDecoration,
+ drop_shadow, first_fill, kotlin_color, literal, num, radius, stroke_color,
+};
+
+// ------------------------------------------------------------------ Flutter (Dart)
+
+/// The import a generated Flutter unit starts with (a widget tree on its own needs it too).
+pub(crate) const FLUTTER_IMPORT: &str = "import 'package:flutter/material.dart';\n\n";
+
+/// Dart indents by two spaces.
+pub(crate) fn flutter_indent(depth: usize) -> String {
+ " ".repeat(depth)
+}
+
+/// Where a child sits in its parent: in a Stack at its position in the frame, taking the room left
+/// in a Row or Column, or wherever the parent puts it.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) enum Place {
+ Flow,
+ At(f64, f64),
+ Fill,
+}
+
+/// A child and where its parent puts it.
+type Placed = (NodeId, Place);
+
+/// Dart's `FontWeight.w600` (a weight is hundreds).
+fn flutter_weight(w: u16) -> String {
+ format!("FontWeight.w{}", (w.clamp(100, 900) / 100) * 100)
+}
+
+/// A Row or Column's main-axis alignment (`None` for the default, the start).
+fn flutter_main_align(a: MainAlign) -> Option<&'static str> {
+ match a {
+ MainAlign::Min => None,
+ MainAlign::Center => Some("MainAxisAlignment.center"),
+ MainAlign::Max => Some("MainAxisAlignment.end"),
+ MainAlign::SpaceBetween => Some("MainAxisAlignment.spaceBetween"),
+ MainAlign::SpaceAround => Some("MainAxisAlignment.spaceAround"),
+ MainAlign::SpaceEvenly => Some("MainAxisAlignment.spaceEvenly"),
+ }
+}
+
+/// A Row or Column's cross-axis alignment; a flow child that fills it stretches the line instead.
+fn flutter_cross_align(a: CrossAlign, stretch: bool) -> &'static str {
+ if stretch {
+ return "CrossAxisAlignment.stretch";
+ }
+ match a {
+ CrossAlign::Min => "CrossAxisAlignment.start",
+ CrossAlign::Center => "CrossAxisAlignment.center",
+ CrossAlign::Max => "CrossAxisAlignment.end",
+ CrossAlign::Baseline => "CrossAxisAlignment.baseline",
+ }
+}
+
+/// A frame's auto layout padding as `EdgeInsets` (`None` when it has none).
+fn flutter_padding(n: &Node) -> Option {
+ let l = n.layout.as_ref()?;
+ let [t, r, b, left] = l.padding;
+ if ![t, r, b, left].iter().any(|v| *v > 0.0) {
+ return None;
+ }
+ Some(if t == r && r == b && b == left {
+ format!("EdgeInsets.all({})", num(t))
+ } else if t == b && left == r {
+ format!("EdgeInsets.symmetric(vertical: {}, horizontal: {})", num(t), num(left))
+ } else {
+ format!("EdgeInsets.fromLTRB({}, {}, {}, {})", num(left), num(t), num(r), num(b))
+ })
+}
+
+/// A layer's fill, shape, corner radius, stroke and shadow as a `BoxDecoration`; `None` when it has
+/// none of them. `pad` is the indentation of the constructor itself.
+fn box_decoration(n: &Node, pad: &str) -> Option {
+ let mut parts: Vec = Vec::new();
+ if let Some(c) = first_fill(n) {
+ parts.push(format!("color: {}", kotlin_color(c)));
+ }
+ if matches!(n.kind, NodeKind::Ellipse { .. }) {
+ parts.push("shape: BoxShape.circle".into());
+ } else if radius(n) > 0.0 {
+ parts.push(format!("borderRadius: BorderRadius.circular({})", num(radius(n))));
+ }
+ if let Some(c) = stroke_color(n) {
+ parts.push(format!("border: Border.all(color: {}, width: {})", kotlin_color(c), num(n.stroke.weight)));
+ }
+ if let Some(s) = drop_shadow(n) {
+ parts.push(format!(
+ "boxShadow: [BoxShadow(color: {}, blurRadius: {}, offset: Offset({}, {}))]",
+ kotlin_color(s.color),
+ num(s.radius / 2.0),
+ num(s.offset.0),
+ num(s.offset.1)
+ ));
+ }
+ if parts.is_empty() {
+ return None;
+ }
+ let inner = format!("{pad} ");
+ Some(format!("BoxDecoration(\n{}{pad})", parts.iter().map(|p| format!("{inner}{p},\n")).collect::()))
+}
+
+/// Flutter for a layer and its subtree at `depth`. A layer a boolean parameter shows sits behind an
+/// `if (…)`: Dart's lists take conditions, so the element itself carries it.
+pub(crate) fn flutter(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, place: Place) {
+ let Some(n) = ctx.doc.node(id) else { return };
+ if id != ctx.root
+ && let Some(p) = ctx.param(n, "visible")
+ {
+ out.push_str(&format!("{}if ({})\n", flutter_indent(depth), p.ident));
+ flutter_placed(ctx, n, depth + 1, out, place);
+ return;
+ }
+ flutter_placed(ctx, n, depth, out, place);
+}
+
+/// A layer at `depth` and what wraps it: where its parent puts it, then its opacity. Every closing
+/// parenthesis sits at its own level, so the caller can end the element with a comma in a list.
+fn flutter_placed(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, place: Place) {
+ if ctx.too_deep(depth) {
+ let pad = flutter_indent(depth);
+ out.push_str(&format!("{pad}// {} …\n{pad}const SizedBox()", one_line(&n.name)));
+ return;
+ }
+ let mut depth = depth;
+ let mut close: Vec = Vec::new();
+ match place {
+ Place::Flow => {}
+ Place::At(x, y) => {
+ let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
+ out.push_str(&format!("{pad}Positioned(\n{inner}left: {},\n{inner}top: {},\n{inner}child: ", num(x), num(y)));
+ close.push(format!("\n{pad})"));
+ depth += 1;
+ }
+ Place::Fill => {
+ let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
+ out.push_str(&format!("{pad}Expanded(\n{inner}child: "));
+ close.push(format!("\n{pad})"));
+ depth += 1;
+ }
+ }
+ if n.opacity < 1.0 {
+ let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
+ out.push_str(&format!("{pad}Opacity(\n{inner}opacity: {},\n{inner}child: ", num(f64::from(n.opacity))));
+ close.push(format!("\n{pad})"));
+ depth += 1;
+ }
+ // A wrapper leaves its line open ("child: "), so the widget carries no indentation of its own.
+ let mut body = String::new();
+ flutter_widget(ctx, n, depth, &mut body);
+ out.push_str(if close.is_empty() { body.as_str() } else { body.trim_start() });
+ for c in close.iter().rev() {
+ out.push_str(c);
+ }
+}
+
+/// A layer's own look and its subtree as a Flutter widget at `depth`: a complete expression whose
+/// closing parenthesis sits at `depth`.
+fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
+ let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
+ // Nested instances are calls; an instance swap parameter is a widget.
+ let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
+ Some(p) => Some(p.ident.clone()),
+ None => ctx.call(n, CodeLang::Flutter, depth).map(|(code, _)| code),
+ };
+ if let Some(call) = call {
+ out.push_str(call.trim_end_matches('\n'));
+ return;
+ }
+ match &n.kind {
+ NodeKind::Text(t) => {
+ let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone());
+ out.push_str(&format!("{pad}Text(\n{inner}{text},\n"));
+ match t.align_h {
+ TextAlignH::Left => {}
+ TextAlignH::Center => out.push_str(&format!("{inner}textAlign: TextAlign.center,\n")),
+ TextAlignH::Right => out.push_str(&format!("{inner}textAlign: TextAlign.right,\n")),
+ TextAlignH::Justified => out.push_str(&format!("{inner}textAlign: TextAlign.justify,\n")),
+ }
+ let s = &t.style;
+ let mut style: Vec = Vec::new();
+ if !s.font_family.is_empty() {
+ style.push(format!("fontFamily: {}", literal(&s.font_family, true)));
+ }
+ style.push(format!("fontSize: {}", num(s.font_size)));
+ style.push(format!("fontWeight: {}", flutter_weight(s.weight())));
+ if let Some(c) = first_fill(n) {
+ style.push(format!("color: {}", kotlin_color(c)));
+ }
+ if s.italic() {
+ style.push("fontStyle: FontStyle.italic".into());
+ }
+ match s.line_height {
+ // Dart's height is a multiple of the font size, not a length.
+ LineHeight::Pixels(v) if v > 0.0 && s.font_size > 0.0 => style.push(format!("height: {}", num(v / s.font_size))),
+ LineHeight::Percent(p) if p > 0.0 => style.push(format!("height: {}", num(p / 100.0))),
+ _ => {}
+ }
+ match s.letter_spacing {
+ LetterSpacing::Pixels(v) if v != 0.0 => style.push(format!("letterSpacing: {}", num(v))),
+ LetterSpacing::Percent(p) if p != 0.0 && s.font_size > 0.0 => style.push(format!("letterSpacing: {}", num(p / 100.0 * s.font_size))),
+ _ => {}
+ }
+ match s.decoration {
+ TextDecoration::None => {}
+ TextDecoration::Underline => style.push("decoration: TextDecoration.underline".into()),
+ TextDecoration::Strikethrough => style.push("decoration: TextDecoration.lineThrough".into()),
+ }
+ let sp = flutter_indent(depth + 2);
+ out.push_str(&format!("{inner}style: TextStyle(\n{}{inner}),\n{pad})", style.iter().map(|x| format!("{sp}{x},\n")).collect::()));
+ }
+ NodeKind::Ellipse { .. } | NodeKind::Rectangle => {
+ out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
+ if let Some(d) = box_decoration(n, &inner) {
+ out.push_str(&format!("{inner}decoration: {d},\n"));
+ }
+ out.push_str(&format!("{pad})"));
+ }
+ k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
+ let children = ctx.kids(n);
+ let slot = ctx.param(n, "slot");
+ // The children block says whether it kept the padding for itself (an absolute child
+ // puts the line in a Stack at the parent's own box).
+ let mut kids = String::new();
+ let padded_inside = !(children.is_empty() && slot.is_none()) && flutter_children(ctx, n, &children, slot, depth + 1, &mut kids);
+ out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
+ if !padded_inside && let Some(p) = flutter_padding(n) {
+ out.push_str(&format!("{inner}padding: {p},\n"));
+ }
+ if let Some(d) = box_decoration(n, &inner) {
+ out.push_str(&format!("{inner}decoration: {d},\n"));
+ }
+ if children.is_empty() && slot.is_none() {
+ out.push_str(&format!("{pad})"));
+ return;
+ }
+ out.push_str(&format!("{inner}alignment: Alignment.topLeft,\n{inner}child: "));
+ out.push_str(kids.trim_start());
+ out.push_str(&format!(",\n{pad})"));
+ }
+ _ => out.push_str(&format!(
+ "{pad}// {}: export as an image asset\n{pad}Image.asset({}, width: {}, height: {})",
+ n.kind.label(),
+ literal(&format!("{}.png", n.name), true),
+ num(n.width),
+ num(n.height)
+ )),
+ }
+}
+
+/// The Row, Column or Stack holding a frame-like layer's children, at `depth`. Returns whether it
+/// took the parent's padding over (see [`flutter_widget`]).
+fn flutter_children(ctx: &Ctx, n: &Node, children: &[NodeId], slot: Option<&Param>, depth: usize, out: &mut String) -> bool {
+ let doc = ctx.doc;
+ let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
+ // Where each child sits, and whether one of the children in the line asks it to stretch.
+ let mut placed: Vec = Vec::new();
+ let mut stretch = false;
+ for c in children {
+ let Some(cn) = doc.node(*c) else { continue };
+ let place = match n.layout.as_ref() {
+ // A child that ignores its auto layout parent keeps its own offsets: it leaves the
+ // line, so it neither stretches it nor takes the room left in it.
+ Some(_) if cn.child_layout.absolute => {
+ let [_, _, _, _, x, y] = cn.transform.as_coeffs();
+ Place::At(x, y)
+ }
+ Some(l) => {
+ let vertical = l.mode == LayoutMode::Vertical;
+ let (main, cross) = if vertical {
+ (cn.child_layout.sizing_v, cn.child_layout.sizing_h)
+ } else {
+ (cn.child_layout.sizing_h, cn.child_layout.sizing_v)
+ };
+ stretch |= cross == Sizing::Fill;
+ if main == Sizing::Fill { Place::Fill } else { Place::Flow }
+ }
+ // Free children sit at their position in the frame.
+ None => {
+ let [_, _, _, _, x, y] = cn.transform.as_coeffs();
+ Place::At(x, y)
+ }
+ };
+ placed.push((*c, place));
+ }
+ let Some(l) = n.layout.as_ref() else {
+ out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n{inner}children: [\n"));
+ flutter_items(ctx, &placed, slot, depth + 2, out);
+ out.push_str(&format!("{inner}],\n{pad})"));
+ return false;
+ };
+ let (over, flow): (Vec, Vec) = placed.into_iter().partition(|(_, p)| matches!(p, Place::At(..)));
+ if over.is_empty() {
+ out.push_str(&flutter_line(ctx, l, &flow, slot, stretch, depth));
+ return false;
+ }
+ // A child at its own offsets goes over the line, in a Stack at the parent's own box, so its
+ // offsets stay the parent's and the line keeps the parent's padding.
+ let pad_edges = flutter_padding(n);
+ let list = flutter_indent(depth + 2);
+ let line_depth = depth + 3 + usize::from(pad_edges.is_some());
+ out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n{inner}children: [\n"));
+ if !flow.is_empty() || slot.is_some() {
+ // Filling the Stack gives the line the parent's size, which `Expanded` children need.
+ out.push_str(&format!("{list}Positioned.fill(\n{list} child: "));
+ match pad_edges {
+ Some(p) => {
+ out.push_str(&format!("Padding(\n{list} padding: {p},\n{list} child: "));
+ out.push_str(flutter_line(ctx, l, &flow, slot, stretch, line_depth).trim_start());
+ out.push_str(&format!(",\n{list} )"));
+ }
+ None => out.push_str(flutter_line(ctx, l, &flow, slot, stretch, line_depth).trim_start()),
+ }
+ out.push_str(&format!(",\n{list}),\n"));
+ }
+ flutter_items(ctx, &over, None, depth + 2, out);
+ out.push_str(&format!("{inner}],\n{pad})"));
+ true
+}
+
+/// The Row or Column holding a frame's flow children, at `depth`: a complete widget expression.
+fn flutter_line(ctx: &Ctx, l: &AutoLayout, flow: &[Placed], slot: Option<&Param>, stretch: bool, depth: usize) -> String {
+ let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
+ let mut out = format!("{pad}{}(\n", if l.mode == LayoutMode::Vertical { "Column" } else { "Row" });
+ if let Some(m) = flutter_main_align(l.main_align) {
+ out.push_str(&format!("{inner}mainAxisAlignment: {m},\n"));
+ }
+ out.push_str(&format!("{inner}crossAxisAlignment: {},\n", flutter_cross_align(l.cross_align, stretch)));
+ if !stretch && matches!(l.cross_align, CrossAlign::Baseline) {
+ out.push_str(&format!("{inner}textBaseline: TextBaseline.alphabetic,\n"));
+ }
+ // The gap between the children; the space distributions already spread them.
+ if l.gap > 0.0 && flutter_main_align(l.main_align).is_none_or(|m| !m.contains("Space")) {
+ out.push_str(&format!("{inner}spacing: {},\n", num(l.gap)));
+ }
+ out.push_str(&format!("{inner}children: [\n"));
+ flutter_items(ctx, flow, slot, depth + 2, &mut out);
+ out.push_str(&format!("{inner}],\n{pad})"));
+ out
+}
+
+/// A children list (`[` already open) at `depth`; a slot shows the instance's content, or else the
+/// main's.
+fn flutter_items(ctx: &Ctx, kids: &[Placed], slot: Option<&Param>, depth: usize, out: &mut String) {
+ let list = flutter_indent(depth);
+ if let Some(p) = slot {
+ let name = &p.ident;
+ out.push_str(&format!("{list}...?{name},\n"));
+ out.push_str(&format!("{list}if ({name} == null) ...[\n"));
+ }
+ let kid = depth + usize::from(slot.is_some());
+ for (id, place) in kids {
+ flutter(ctx, *id, kid, out, *place);
+ out.push_str(",\n");
+ }
+ if slot.is_some() {
+ out.push_str(&format!("{list}],\n"));
+ }
+}
diff --git a/crates/engine/src/dev/component_codegen.rs b/crates/engine/src/dev/component_codegen.rs
index 2e513cf6..bf7f442d 100644
--- a/crates/engine/src/dev/component_codegen.rs
+++ b/crates/engine/src/dev/component_codegen.rs
@@ -1,17 +1,17 @@
//! Component code: a component (or component set) becomes a React, Leptos, Vue, SwiftUI, Jetpack
-//! Compose or ArkUI component whose properties are its parameters (a variant property → a union or
-//! enum, boolean → a flag showing the layers bound to it, text → a string, instance swap → a view
-//! parameter, slot → optional content), and an instance becomes a call passing the values it sets.
-//! Variants of a set share one definition (the set's default variant); in React and Leptos their
-//! differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode` and
-//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its
-//! real snippet instead.
+//! Compose, ArkUI or Flutter component whose properties are its parameters (a variant property → a
+//! union or enum, boolean → a flag showing the layers bound to it, text → a string, instance swap →
+//! a view parameter, slot → optional content), and an instance becomes a call passing the values it
+//! sets. Variants of a set share one definition (the set's default variant); in React and Leptos
+//! their differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode`
+//! and used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows
+//! its real snippet instead.
use figcraft_doc::{Document, NodeId, NodeKind, PropDef};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
-use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift};
+use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift};
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
use crate::params::{id_of, string, targets};
use crate::{bad, spec};
@@ -68,14 +68,14 @@ pub fn specs() -> Vec {
vec![spec!(
"inspect.componentCode",
"Component code",
- "{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
+ "{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui|flutter (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
false,
|s, p| {
let cmd = "inspect.componentCode";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
let lang = match string(p, "language") {
// One list of code languages, shared with codeConnect (see `code_connect::language`).
- Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui"))?,
+ Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui|flutter"))?,
None => CodeLang::React,
};
let doc = s.doc()?;
@@ -108,10 +108,10 @@ pub fn specs() -> Vec {
)]
}
-/// Code for any layer in React, Leptos, Vue, SwiftUI, Compose or ArkUI: an instance's call and its
-/// component's definition, a component's (or set's) definition, or the layer tree (React and
-/// Leptos wrap it in a function component), with the imports of the connected components it calls
-/// at the top.
+/// Code for any layer in React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter: an instance's call
+/// and its component's definition, a component's (or set's) definition, or the layer tree (React
+/// and Leptos wrap it in a function component), with the imports of the connected components it
+/// calls at the top.
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
let Some(n) = doc.node(id) else { return String::new() };
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
@@ -119,7 +119,15 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
}
let comp = main_of(doc, id).and_then(|m| describe(doc, m));
let code = match (&n.kind, comp) {
- (NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
+ (NodeKind::Instance { .. }, Some(c)) => {
+ let call = usage(doc, id, lang, 0).unwrap_or_default();
+ let def = definition(doc, &c, lang);
+ match def.strip_prefix(FLUTTER_IMPORT) {
+ // Dart's imports come before the code that uses them.
+ Some(rest) if lang == CodeLang::Flutter => format!("{FLUTTER_IMPORT}{call}\n{rest}"),
+ _ => format!("{call}\n{def}"),
+ }
+ }
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
_ => match lang {
CodeLang::React | CodeLang::Leptos | CodeLang::Vue => {
@@ -140,6 +148,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
arkui(&Ctx::new(doc, id), id, 0, &mut out, &[]);
out
}
+ CodeLang::Flutter => {
+ let mut out = String::new();
+ flutter(&Ctx::new(doc, id), id, 0, &mut out, Place::Flow);
+ out
+ }
_ => {
let mut out = String::new();
compose(&Ctx::new(doc, id), id, 0, &mut out);
@@ -328,24 +341,33 @@ const RESERVED: &[&str] = &[
"yield",
];
-/// Types the generated SwiftUI, Compose and ArkUI code names: a variant enum may not shadow them.
+/// Types the generated SwiftUI, Compose, ArkUI and Flutter code names: a variant enum may not shadow them.
const TYPES: &[&str] = &[
"Alignment",
"AnyView",
"Arrangement",
"Bool",
"Boolean",
+ "Border",
+ "BorderRadius",
+ "BorderSide",
"Box",
+ "BoxDecoration",
+ "BoxShadow",
+ "BoxShape",
+ "BuildContext",
"CaseIterable",
"Circle",
"CircleShape",
"Color",
"Column",
"Composable",
+ "CrossAxisAlignment",
"Edge",
"EdgeInsets",
"Ellipse",
"EmptyView",
+ "Expanded",
"FlexAlign",
"Font",
"FontStyle",
@@ -354,19 +376,28 @@ const TYPES: &[&str] = &[
"HStack",
"HorizontalAlign",
"Image",
+ "MainAxisAlignment",
"Modifier",
+ "Offset",
+ "Opacity",
+ "Positioned",
"Rect",
"RectangleShape",
"RoundedCornerShape",
"RoundedRectangle",
"Row",
"Shape",
+ "SizedBox",
"Stack",
+ "StatelessWidget",
"String",
"Text",
"TextAlign",
+ "TextBaseline",
"TextCase",
+ "TextDecoration",
"TextDecorationType",
+ "TextStyle",
"Unit",
"VStack",
"VerticalAlign",
@@ -481,6 +512,13 @@ fn cases(values: &[String], swift: bool) -> Vec {
.collect()
}
+/// Enum member names for variant values in Dart: lowerCamel, unique, never a keyword (a `default`
+/// value becomes `defaultValue`).
+fn dart_cases(values: &[String]) -> Vec {
+ let mut used = Vec::new();
+ values.iter().map(|v| ident(v, &mut used)).collect()
+}
+
/// A Rust string literal.
pub(crate) fn rust_string(s: &str) -> String {
format!("{:?}", s.chars().take(10_000).collect::())
@@ -533,7 +571,7 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
let i = opts.iter().position(|o| Some(o.as_str()) == value.as_str()).unwrap_or(0);
format!("{}::{}", leptos_enum(c, p), rust_cases(opts).get(i).cloned().unwrap_or_else(|| "Unknown".into()))
}
- (ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
+ (ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l.escapes_dollar()),
(ParamKind::Variant(opts), l) => {
let v = value.as_str().unwrap_or("");
if l == CodeLang::Vue {
@@ -542,10 +580,12 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
if l == CodeLang::React {
return js_string(v);
}
- let swift = l == CodeLang::SwiftUi;
let i = opts.iter().position(|o| o == v).unwrap_or(0);
- let case = cases(opts, swift).get(i).cloned().unwrap_or_else(|| "unknown".into());
- if swift { format!(".{case}") } else { format!("{}.{case}", enum_name(c, p, false)) }
+ match l {
+ CodeLang::SwiftUi => format!(".{}", cases(opts, true).get(i).cloned().unwrap_or_else(|| "unknown".into())),
+ CodeLang::Flutter => format!("{}.{}", enum_name(c, p, false), dart_cases(opts).get(i).cloned().unwrap_or_else(|| "unknown".into())),
+ _ => format!("{}.{}", enum_name(c, p, false), cases(opts, false).get(i).cloned().unwrap_or_else(|| "Unknown".into())),
+ }
}
(ParamKind::InstanceSwap, l) => {
let t = id_of(value).and_then(|i| component_type(doc, i));
@@ -556,12 +596,14 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
(None, CodeLang::Leptos) => "|| ()".into(),
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
+ (Some(t), CodeLang::Flutter) => format!("{t}()"),
// ArkUI takes a builder: `@BuilderParam` is a `() => void`.
(Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"),
(None, CodeLang::ArkUi) => "() => {}".into(),
(Some(t), _) => format!("{{ {t}() }}"),
(None, _) if js => "null".into(),
(None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(),
+ (None, CodeLang::Flutter) => "const SizedBox.shrink()".into(),
(None, _) => "{}".into(),
}
}
@@ -616,7 +658,7 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option<(String, String)> {
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
let values = instance_values(doc, id, &c)?;
- let pad = if matches!(lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) };
+ let pad = if matches!(lang, CodeLang::React | CodeLang::Vue | CodeLang::Flutter) { " ".repeat(depth) } else { indent(depth) };
// A connected component: its real snippet (its import is at the top of the code: `code`).
if let Some(link) = crate::code_connect::link(doc, id, lang) {
return Some((crate::code_connect::render(doc, &c, &values, link, lang, &pad), String::new()));
@@ -684,7 +726,9 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept
.collect();
if slots.is_empty() { format!("{pad}<{t}{attrs} />\n") } else { format!("{pad}<{t}{attrs}>\n{slots}{pad}{t}>\n") }
}
- CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ")),
+ CodeLang::SwiftUi | CodeLang::Flutter => {
+ format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", "))
+ }
CodeLang::ArkUi => {
let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ");
// ArkTS calls take an object literal, or nothing at all.
@@ -743,6 +787,15 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize, pref
}
(format!("() => {{\n{out}{}}}", indent(depth + 1)), String::new())
}
+ CodeLang::Flutter => {
+ // A slot holds a list of widgets, so it takes however many layers the slot has.
+ let mut out = String::new();
+ for k in kids {
+ flutter(&ctx, k, depth + 1, &mut out, Place::Flow);
+ out.push_str(",\n");
+ }
+ (format!("[\n{out}{}]", flutter_indent(depth)), String::new())
+ }
_ => {
let mut out = String::new();
for k in kids {
@@ -760,6 +813,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
match lang {
CodeLang::SwiftUi => swift_definition(doc, c),
CodeLang::Compose => compose_definition(doc, c),
+ CodeLang::Flutter => flutter_definition(doc, c),
CodeLang::Leptos => leptos(doc, c),
CodeLang::Vue => vue(doc, c),
CodeLang::ArkUi => arkui_definition(doc, c),
@@ -994,6 +1048,66 @@ fn compose_definition(doc: &Document, c: &Component) -> String {
out
}
+/// A Flutter `StatelessWidget`: variant enums, a const constructor with defaults, and the widget
+/// tree in `build`.
+fn flutter_definition(doc: &Document, c: &Component) -> String {
+ let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
+ let mut out = String::from(FLUTTER_IMPORT);
+ for p in &c.params {
+ if let ParamKind::Variant(opts) = &p.kind {
+ out.push_str(&format!("enum {} {{ {} }}\n\n", enum_name(c, p, false), dart_cases(opts).join(", ")));
+ }
+ }
+ let t = &c.type_name;
+ out.push_str(&format!("class {t} extends StatelessWidget {{\n"));
+ if c.params.is_empty() {
+ out.push_str(&format!(" const {t}({{super.key}});\n"));
+ } else {
+ out.push_str(&format!(" const {t}({{\n super.key,\n"));
+ for p in &c.params {
+ match flutter_default(doc, c, p) {
+ Some(d) => out.push_str(&format!(" this.{} = {d},\n", p.ident)),
+ None => out.push_str(&format!(" this.{},\n", p.ident)),
+ }
+ }
+ out.push_str(" });\n\n");
+ for p in &c.params {
+ out.push_str(&format!(" final {} {};\n", flutter_type(c, p), p.ident));
+ }
+ }
+ out.push_str("\n @override\n Widget build(BuildContext context) {\n return ");
+ let mut body = String::new();
+ flutter(&ctx, c.comp, 2, &mut body, Place::Flow);
+ out.push_str(body.trim_start());
+ out.push_str(";\n }\n}\n");
+ out
+}
+
+/// A parameter's Dart type.
+fn flutter_type(c: &Component, p: &Param) -> String {
+ match &p.kind {
+ ParamKind::Variant(_) => enum_name(c, p, false),
+ ParamKind::Boolean => "bool".into(),
+ ParamKind::Text => "String".into(),
+ ParamKind::InstanceSwap => "Widget".into(),
+ // A slot holds however many layers it was filled with.
+ ParamKind::Slot => "List?".into(),
+ }
+}
+
+/// A parameter's default as a Dart constant (`None` for a slot, which has none): Dart wants its
+/// defaults to be constants, so an instance swap is a `const` widget.
+fn flutter_default(doc: &Document, c: &Component, p: &Param) -> Option {
+ match &p.kind {
+ ParamKind::Slot => None,
+ ParamKind::InstanceSwap => Some(match id_of(&p.default).and_then(|i| component_type(doc, i)) {
+ Some(t) => format!("const {t}()"),
+ None => "const SizedBox.shrink()".into(),
+ }),
+ _ => Some(value_expr(doc, c, p, &p.default, CodeLang::Flutter)),
+ }
+}
+
/// An ArkUI (`@Component`) struct: variant enums, `@Prop` parameters, a default builder for each
/// instance swap and slot, and the `build` body.
fn arkui_definition(doc: &Document, c: &Component) -> String {
diff --git a/crates/engine/src/history_behaviour_tests.rs b/crates/engine/src/history_behaviour_tests.rs
index 45e0c7bf..8cd5612e 100644
--- a/crates/engine/src/history_behaviour_tests.rs
+++ b/crates/engine/src/history_behaviour_tests.rs
@@ -188,7 +188,7 @@ fn threads_replies_and_resolving() {
|c| c.replies.iter().map(|m| m.text.as_str()).eq(["Sure", "Done"]),
"replies stay in the order they were written",
),
- ("comment.resolve", json!({"id": c, "author": "Sam"}), |c| c.resolved, "anyone in the file can resolve a thread, not just its author"),
+ ("comment.resolve", json!({"id": c}), |c| c.resolved, "anyone in the file can resolve a thread, not just its author (it takes no author)"),
("comment.resolve", json!({"id": c, "resolved": false}), |c| !c.resolved, "a resolved thread can be reopened"),
("comment.resolve", json!({"id": c}), |c| c.resolved, "resolving again marks it resolved"),
(
diff --git a/crates/engine/src/inspect_behaviour_tests.rs b/crates/engine/src/inspect_behaviour_tests.rs
index 780aa19c..ec6a89bc 100644
--- a/crates/engine/src/inspect_behaviour_tests.rs
+++ b/crates/engine/src/inspect_behaviour_tests.rs
@@ -1,8 +1,9 @@
//! Inspect behaviour suite, written from the public documentation of design tools' inspect and
//! developer handoff modes: the reported layout (size, position, rotation, radius, opacity, blend),
-//! colour formats, units, typography, the generated CSS, SwiftUI and Compose for common layers,
-//! the asset list and redline measurements. Each rule is a table row or an assert with a one-line
-//! derivation; where the documentation is silent the row says so and pins today's behaviour.
+//! colour formats, units, typography, the generated CSS, SwiftUI, Compose and Flutter for common
+//! layers, the asset list and redline measurements. Each rule is a table row or an assert with a
+//! one-line derivation; where the documentation is silent the row says so and pins today's
+//! behaviour.
// The tables read best as tuples.
#![allow(clippy::type_complexity)]
@@ -241,12 +242,16 @@ fn native_code_for_a_simple_layer() {
set(&mut s, &[r], json!({"cornerRadius": 8}));
let swift = code(&mut s, r, json!({"language": "swiftui"}));
let compose = code(&mut s, r, json!({"language": "compose"}));
+ let flutter = code(&mut s, r, json!({"language": "flutter"}));
// (code, expected fragment, derivation)
- let rows: [(&str, &str, &str); 4] = [
+ let rows: [(&str, &str, &str); 7] = [
(&swift, "frame(width: 100, height: 50)", "SwiftUI sizes a view with frame, in points"),
(&swift, "Color(red: 1, green: 0, blue: 0, opacity: 1)", "docs silent: the fill as a SwiftUI colour"),
(&compose, "Modifier.size(100.dp, 50.dp)", "Compose sizes in dp"),
(&compose, "RoundedCornerShape(8.dp)", "docs silent: corner radius as a rounded shape"),
+ (&flutter, "width: 100,\n height: 50,", "Flutter sizes a Container in logical pixels"),
+ (&flutter, "Color(0xFFFF0000)", "docs silent: the fill as a Flutter colour"),
+ (&flutter, "borderRadius: BorderRadius.circular(8)", "docs silent: corner radius as a rounded border"),
];
for (c, want, why) in rows {
assert!(c.contains(want), "{want}: {why}\n{c}");
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index e9a4a637..d13e1af6 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -351,6 +351,8 @@ mod menu_gaps_tests;
#[cfg(test)]
mod nested_override_tests;
#[cfg(test)]
+mod node_find_tests;
+#[cfg(test)]
mod node_set_refusals_tests;
#[cfg(test)]
mod normalize_guard_tests;
@@ -495,6 +497,8 @@ mod tiff_tests;
#[cfg(test)]
mod transform_behaviour_tests;
#[cfg(test)]
+mod unknown_params_tests;
+#[cfg(test)]
mod variable_props_tests;
#[cfg(test)]
mod variable_tests;
diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs
index 5cdccf69..1bae1b10 100644
--- a/crates/engine/src/live_tests.rs
+++ b/crates/engine/src/live_tests.rs
@@ -158,8 +158,8 @@ fn viewport(page: NodeId, x: f64, y: f64, w: f64, h: f64) -> Presence {
#[test]
fn without_a_link_live_sessions_are_unavailable() {
let mut s = Session::new();
- for cmd in ["collab.host", "collab.join"] {
- let e = s.execute(cmd, &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap_err();
+ for (cmd, p) in [("collab.host", json!({})), ("collab.join", json!({"link": "figcraft://join?host=a:1&file=f&token=t"}))] {
+ let e = s.execute(cmd, &p).unwrap_err();
assert!(matches!(e, EngineError::Disabled(..)), "{cmd}: {e}");
}
assert_eq!(s.execute("collab.leave", &json!({})).unwrap()["ended"], false);
diff --git a/crates/engine/src/node_find_tests.rs b/crates/engine/src/node_find_tests.rs
new file mode 100644
index 00000000..d8729357
--- /dev/null
+++ b/crates/engine/src/node_find_tests.rs
@@ -0,0 +1,196 @@
+//! Finding layers without changing the selection, page or edit history (#996).
+
+use figcraft_doc::*;
+use serde_json::{Value, json};
+use std::sync::Arc;
+
+use crate::Session;
+use crate::components::tests::{ex, idv};
+
+fn add(s: &mut Session, parent: NodeId, kind: NodeKind, name: &str) -> NodeId {
+ s.doc_mut().unwrap().create(parent, kind, name, 0.0, 0.0, 100.0, 100.0).unwrap()
+}
+
+fn found(s: &mut Session, params: Value, ids: &[NodeId]) {
+ assert_eq!(ex(s, "node.find", params), json!({"ids": ids, "total": ids.len()}));
+}
+
+#[test]
+fn node_find_filters_are_literal_case_insensitive_and_anded() {
+ let mut s = Session::new();
+ let page = s.page().unwrap();
+ let frame = add(&mut s, page, NodeKind::Frame, "CAFÉ [Card]");
+ let title = add(&mut s, frame, NodeKind::Text(TextData::new("HÉLLO 世界")), "Café Title");
+ let other = add(&mut s, frame, NodeKind::Text(TextData::new("Other")), "Title");
+ let rect = add(&mut s, page, NodeKind::Rectangle, "HÉLLO 世界");
+ found(&mut s, json!({"name": "CAFÉ"}), &[frame, title]);
+ found(&mut s, json!({"name": "[cArD]"}), &[frame]);
+ found(&mut s, json!({"type": "tExT"}), &[title, other]);
+ found(&mut s, json!({"text": "héLLo 世"}), &[title]);
+ found(&mut s, json!({"name": "title", "type": "TEXT", "text": "世界"}), &[title]);
+ found(&mut s, json!({"name": "café", "type": "RECTANGLE"}), &[]);
+ found(&mut s, json!({"text": "héllo", "name": "HÉLLO"}), &[]);
+ found(&mut s, json!({"text": ""}), &[title, other]);
+ found(&mut s, json!({"name": ""}), &[frame, title, other, rect]);
+ found(&mut s, json!({"name": ".*"}), &[]);
+}
+
+#[test]
+fn node_find_matches_a_greek_final_sigma_both_ways() {
+ let mut s = Session::new();
+ let page = s.page().unwrap();
+ let os = add(&mut s, page, NodeKind::Text(TextData::new("ΛΟΓΟΣ")), "ΟΣ");
+ found(&mut s, json!({"name": "Σ"}), &[os]);
+ found(&mut s, json!({"name": "ς"}), &[os]);
+ found(&mut s, json!({"text": "λογοσ"}), &[os]);
+}
+
+#[test]
+fn node_find_scope_is_preorder_strict_descendants_in_stored_order() {
+ let mut s = Session::new();
+ let page = s.page().unwrap();
+ let root = add(&mut s, page, NodeKind::Frame, "Root");
+ let first = add(&mut s, root, NodeKind::Frame, "First");
+ let nested = add(&mut s, first, NodeKind::Rectangle, "Nested");
+ let second = add(&mut s, root, NodeKind::Rectangle, "Second");
+ let outside = add(&mut s, page, NodeKind::Rectangle, "Outside");
+ s.doc_mut().unwrap().get_mut(first).unwrap().visible = false;
+ s.doc_mut().unwrap().get_mut(second).unwrap().locked = true;
+ found(&mut s, json!({}), &[root, first, nested, second, outside]);
+ found(&mut s, json!({"root": root}), &[first, nested, second]);
+ found(&mut s, json!({"root": root, "name": "root"}), &[]);
+ found(&mut s, json!({"root": nested}), &[]);
+ found(&mut s, json!({"root": page}), &[root, first, nested, second, outside]);
+ s.doc_mut().unwrap().reorder(second, 0).unwrap();
+ found(&mut s, json!({"root": root}), &[second, first, nested]);
+}
+
+#[test]
+fn node_find_other_page_requires_root_and_does_not_switch_pages() {
+ let mut s = Session::new();
+ let page = s.page().unwrap();
+ let here = add(&mut s, page, NodeKind::Frame, "Here");
+ let other_page = s.doc_mut().unwrap().add_page("Other");
+ let root = add(&mut s, other_page, NodeKind::Frame, "There");
+ let child = add(&mut s, root, NodeKind::Rectangle, "Child");
+ found(&mut s, json!({}), &[here]);
+ found(&mut s, json!({"root": other_page}), &[root, child]);
+ found(&mut s, json!({"root": root}), &[child]);
+ assert_eq!(s.page().unwrap(), page);
+}
+
+#[test]
+fn node_find_instances_match_main_name_and_visit_actual_children_only() {
+ let mut s = Session::new();
+ let page = s.page().unwrap();
+ let main = add(&mut s, page, NodeKind::Component { description: String::new(), props: Default::default() }, "CAFÉ [Button]");
+ let original = add(&mut s, main, NodeKind::Text(TextData::new("Original")), "Original");
+ let inst = idv(&ex(&mut s, "instance.create", json!({"component": main})));
+ let child = s.doc().unwrap().get(inst).unwrap().children[0];
+ ex(&mut s, "node.set", json!({"ids": [inst], "props": {"name": "Copy"}}));
+ ex(&mut s, "text.set", json!({"id": child, "text": "Changed"}));
+ let broken = add(&mut s, page, NodeKind::Instance { component: NodeId(u64::MAX), props: Default::default() }, "Missing");
+ found(&mut s, json!({"component": "café [bUt"}), &[inst]);
+ found(&mut s, json!({"component": "", "type": "instance", "name": "copy"}), &[inst]);
+ found(&mut s, json!({"component": "copy"}), &[]);
+ found(&mut s, json!({"component": "button", "text": "Changed"}), &[]);
+ found(&mut s, json!({"root": inst}), &[child]);
+ found(&mut s, json!({"root": inst, "text": "original"}), &[]);
+ found(&mut s, json!({"root": inst, "text": "changed"}), &[child]);
+ found(&mut s, json!({}), &[main, original, inst, child, broken]);
+}
+
+#[test]
+fn node_find_accepts_every_canonical_type() {
+ let mut s = Session::new();
+ let page = s.page().unwrap();
+ let kinds = [
+ NodeKind::Frame,
+ NodeKind::Group,
+ NodeKind::Section { contents_hidden: false },
+ NodeKind::Rectangle,
+ NodeKind::Ellipse { start: 0.0, sweep: 1.0, inner: 0.0 },
+ NodeKind::Polygon { count: 3 },
+ NodeKind::Star { count: 5, inner_ratio: 0.5 },
+ NodeKind::Line,
+ NodeKind::Vector { network: Default::default() },
+ NodeKind::Text(TextData::new("Text")),
+ NodeKind::BooleanOp { op: BoolOp::Union },
+ NodeKind::Component { description: String::new(), props: Default::default() },
+ NodeKind::ComponentSet { description: String::new() },
+ NodeKind::Instance { component: NodeId(u64::MAX), props: Default::default() },
+ NodeKind::Slice,
+ ];
+ for kind in kinds {
+ let name = kind.type_name();
+ let id = add(&mut s, page, kind, name);
+ assert_eq!(ex(&mut s, "node.get", json!({"id": id}))["type"], name);
+ found(&mut s, json!({"type": name.to_ascii_lowercase()}), &[id]);
+ }
+ found(&mut s, json!({"type": "PAGE"}), &[]);
+}
+
+#[test]
+fn node_find_limit_caps_ids_not_total_and_zero_counts_only() {
+ let mut s = Session::new();
+ let page = s.page().unwrap();
+ let ids: Vec<_> = (0..105).map(|_| add(&mut s, page, NodeKind::Rectangle, "Match")).collect();
+ assert_eq!(ex(&mut s, "node.find", json!({})), json!({"ids": &ids[..100], "total": 105}));
+ assert_eq!(ex(&mut s, "node.find", json!({"limit": 2})), json!({"ids": &ids[..2], "total": 105}));
+ assert_eq!(ex(&mut s, "node.find", json!({"limit": 0})), json!({"ids": [], "total": 105}));
+ found(&mut s, json!({"limit": 10000}), &ids);
+ found(&mut s, json!({"name": "missing", "limit": 0}), &[]);
+}
+
+#[test]
+fn node_find_rejects_bad_root_type_filters_and_limits() {
+ let mut s = Session::new();
+ for params in [
+ json!({"root": 999999}),
+ json!({"root": "nope"}),
+ json!({"root": {"id": 1}}),
+ json!({"type": "BOOLEANOP"}),
+ json!({"type": "FRAME "}),
+ json!({"type": ""}),
+ json!({"type": 3}),
+ json!({"name": []}),
+ json!({"text": true}),
+ json!({"component": {}}),
+ json!({"limit": -1}),
+ json!({"limit": 10001}),
+ json!({"limit": 1.5}),
+ json!({"limit": 1.0}),
+ json!({"limit": "2"}),
+ json!({"limit": true}),
+ json!({"limit": []}),
+ json!({"limit": 1e308}),
+ ] {
+ assert!(s.execute("node.find", ¶ms).is_err(), "{params}");
+ }
+ let error = s.execute("node.find", &json!({"type": "x".repeat(10000)})).unwrap_err().to_string();
+ assert!(error.contains("unknown type"), "{error}");
+ assert!(error.len() < 300, "unknown types are not echoed whole");
+}
+
+#[test]
+fn node_find_leaves_document_selection_page_and_history_untouched() {
+ let mut s = Session::new();
+ let layer = idv(&ex(&mut s, "shape.rectangle", json!({"width": 10, "height": 10})));
+ ex(&mut s, "node.set", json!({"ids": [layer], "props": {"name": "Previous edit"}}));
+ ex(&mut s, "node.set", json!({"ids": [layer], "props": {"name": "Undone edit"}}));
+ ex(&mut s, "edit.undo", json!({}));
+ ex(&mut s, "select.set", json!({"ids": [layer]}));
+ let ds = s.ds().unwrap();
+ let (doc, selection, page, undo, redo) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.redo.len());
+ assert!(undo > 0 && redo > 0);
+ found(&mut s, json!({"name": "previous"}), &[layer]);
+ found(&mut s, json!({"name": "missing"}), &[]);
+ ex(&mut s, "node.find", json!({"limit": 0}));
+ assert!(s.execute("node.find", &json!({"root": 999999})).is_err());
+ let ds = s.ds().unwrap();
+ assert!(Arc::ptr_eq(&ds.doc, &doc));
+ assert_eq!((&ds.selection, ds.page, ds.history.undo.len(), ds.history.redo.len()), (&selection, page, undo, redo));
+ assert_eq!(ex(&mut s, "edit.undo", json!({}))["undone"], true);
+ assert_ne!(s.doc().unwrap().get(layer).unwrap().name, "Previous edit");
+ assert_eq!(s.ds().unwrap().history.undo.len(), undo - 1);
+}
diff --git a/crates/engine/src/save_disk_changes_tests.rs b/crates/engine/src/save_disk_changes_tests.rs
index 807daa9e..7a0505c9 100644
--- a/crates/engine/src/save_disk_changes_tests.rs
+++ b/crates/engine/src/save_disk_changes_tests.rs
@@ -74,3 +74,37 @@ fn auto_save_leaves_changes_made_on_disk_alone() {
assert_eq!(page_on_disk(&path), "CLI VENCEU");
let _ = std::fs::remove_file(&path);
}
+
+/// The name of the page the active tab shows.
+fn page_in_tab(s: &Session) -> String {
+ let ds = s.ds().unwrap();
+ ds.doc.node(ds.page).unwrap().name.clone()
+}
+
+/// Opening a file that is already open says when it changed on disk since the tab read or saved
+/// it, and `reload` reads it again into the tab, unless the tab has unsaved changes (#997).
+#[test]
+fn opening_an_open_file_says_it_is_stale_and_reloads_on_request() {
+ let path = temp_file("reopen");
+ let mut setup = Session::new();
+ setup.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
+ setup.execute("file.save", &json!({"path": path})).unwrap();
+ let mut app = Session::new();
+ let index = app.execute("file.open", &json!({"path": path})).unwrap()["index"].clone();
+ let before = page_in_tab(&app);
+ assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index}), "unchanged: not stale");
+ let mut cli = Session::new();
+ cli.execute("file.open", &json!({"path": path})).unwrap();
+ cli.execute("page.rename", &json!({"name": "Renamed on disk by the CLI"})).unwrap();
+ cli.execute("file.save", &json!({})).unwrap();
+ assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index, "stale": true}));
+ assert_eq!(page_in_tab(&app), before, "without reload the tab keeps what it read");
+ assert_eq!(app.execute("file.open", &json!({"path": path, "reload": true})).unwrap(), json!({"index": index, "reloaded": true}));
+ assert_eq!(page_in_tab(&app), "Renamed on disk by the CLI");
+ assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index}), "read again: no longer stale");
+ app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
+ let e = app.execute("file.open", &json!({"path": path, "reload": true})).unwrap_err().to_string();
+ assert!(e.contains("unsaved changes"), "{e}");
+ assert!(app.ds().unwrap().is_dirty(), "the unsaved edit is still there");
+ let _ = std::fs::remove_file(&path);
+}
diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs
index 44af2a05..a9d491e2 100644
--- a/crates/engine/src/session/exec.rs
+++ b/crates/engine/src/session/exec.rs
@@ -49,6 +49,7 @@ impl Session {
/// as it was.
pub fn execute(&mut self, id: &str, params: &Value) -> Result {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
+ crate::cmd::refuse_unknown_params(id, params)?;
board::refuse_on_board(self, id)?;
// Revert replaces the document's uid: refuse before ending previews or syncing branches.
if id == "file.revert" && self.live().doc_uid.is_some() {
diff --git a/crates/engine/src/shapebuilder_strokes_tests.rs b/crates/engine/src/shapebuilder_strokes_tests.rs
index 19aa2c9c..8bebfb0f 100644
--- a/crates/engine/src/shapebuilder_strokes_tests.rs
+++ b/crates/engine/src/shapebuilder_strokes_tests.rs
@@ -166,6 +166,20 @@ fn a_centred_stroke_on_a_sharp_star_makes_three_regions() {
assert_eq!(covers, vec![(false, true), (true, false), (true, true)], "fill only, stroke only, both");
}
+#[test]
+fn merging_every_region_of_one_stroked_shape_is_a_no_op() {
+ let mut s = Session::new();
+ let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}})));
+ ex(&mut s, "node.set", json!({"id": r, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}}));
+ s.set_selection(vec![r]);
+ let count = ex(&mut s, "shapeBuilder.regions", json!({}))["regions"].as_array().unwrap().len();
+ assert!(count > 1, "fill and stroke make several regions");
+ let before = s.doc().unwrap().clone();
+ let out = ex(&mut s, "shapeBuilder.merge", json!({"regions": (0..count).collect::>()}));
+ assert!(out["id"].is_null(), "{out}");
+ assert_eq!(*s.doc().unwrap(), before);
+}
+
#[test]
fn stroke_regions_survive_junk() {
for j in [
diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs
index e08afcc6..1742382a 100644
--- a/crates/engine/src/sharing/live/join.rs
+++ b/crates/engine/src/sharing/live/join.rs
@@ -168,7 +168,7 @@ pub fn specs() -> Vec {
spec!(
"collab.share",
"Share with a join code",
- "{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
+ "{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
false,
share
),
@@ -182,7 +182,7 @@ pub fn specs() -> Vec {
spec!(
"collab.join",
"Join live session",
- "{code: a join code (K7M-2QX-9TD from a server, asked at the server in Preferences; a longer one for the local network; spaces, dashes and case don't matter, O reads as 0 and I or L as 1) | link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=… (a session an app hosts, or a server's share link; a code here works too)} → the new session: {session, status: \"connecting\", …}; its file opens in a tab of its own (other files' sessions go on) and `collab.peers` tells when it's live (`sessions` lists it until then, and if it fails)",
+ "{code | link (a join code: K7M-2QX-9TD from a server, asked at the server in Preferences; a longer one for the local network; spaces, dashes and case don't matter, O reads as 0 and I or L as 1; or an invite link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=…, from a session an app hosts or a server's share link; either key takes either)} → the new session: {session, status: \"connecting\", …}; its file opens in a tab of its own (other files' sessions go on) and `collab.peers` tells when it's live (`sessions` lists it until then, and if it fails)",
false,
join
),
@@ -210,7 +210,7 @@ pub fn specs() -> Vec {
spec!(
"collab.manageFile",
"Manage published file",
- "{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink {access?: view (default) | edit, label?} | revoke {link (a link's id)} | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename {name} | remove (the file is deleted from the server and leaves your online files)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}",
+ "{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink | revoke | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename | remove (the file is deleted from the server and leaves your online files), access? (createLink: view (default) | edit), label? (createLink), link? (revoke: a link's id), name? (rename)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}",
false,
manage_file
),
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index bbbe0fb7..fd288f81 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -459,6 +459,9 @@ fn every_command_survives_junk_params() {
// would write to it.)
json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}),
json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}),
+ // Layer search roots, filters and limits as junk.
+ json!({"root": {"id": 2}, "name": [1], "type": "nope", "text": true, "component": [], "limit": -1}),
+ json!({"root": 999999, "limit": 1e308}),
// Batch step names and reference paths as junk.
json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}),
json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [
@@ -483,11 +486,19 @@ fn every_command_survives_junk_params() {
continue;
}
for j in &junk {
- let mut s = sess();
- let _ = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
- let _ = s.execute("text.create", &json!({"text": "t"}));
- if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
- panic!("{cmd} panicked on {j}: {msg}");
+ // Each junk object as given (names the command doesn't take are refused) and cut down
+ // to the names it takes, so the junk values still reach its parsers.
+ let mut taken = j.clone();
+ if let (Some(names), Some(o)) = (crate::cmd::param_names(spec.id), taken.as_object_mut()) {
+ o.retain(|k, _| matches!(k.as_str(), "ids" | "id") || names.contains(&k.as_str()));
+ }
+ for params in if taken == *j { vec![j] } else { vec![j, &taken] } {
+ let mut s = sess();
+ let _ = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
+ let _ = s.execute("text.create", &json!({"text": "t"}));
+ if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, params) {
+ panic!("{cmd} panicked on {params}: {msg}");
+ }
}
}
}
diff --git a/crates/engine/src/unknown_params_tests.rs b/crates/engine/src/unknown_params_tests.rs
new file mode 100644
index 00000000..f0773f25
--- /dev/null
+++ b/crates/engine/src/unknown_params_tests.rs
@@ -0,0 +1,91 @@
+//! A parameter name a command doesn't take is refused, not ignored: a misspelt name would make the
+//! command answer as if it hadn't been given (#1413).
+
+use serde_json::{Value, json};
+
+use crate::Session;
+use crate::cmd::{all_commands, param_names};
+
+fn err(s: &mut Session, cmd: &str, params: Value) -> String {
+ match s.execute(cmd, ¶ms) {
+ Err(e) => e.to_string(),
+ Ok(v) => panic!("{cmd} {params} ran: {v}"),
+ }
+}
+
+/// The issue's repro: a misspelt `filter` listed the resolved thread as if no filter were given.
+#[test]
+fn a_misspelt_parameter_is_refused_and_named() {
+ let mut s = Session::new();
+ s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "a"})).unwrap();
+ s.execute("comment.resolve", &json!({"id": 1})).unwrap();
+ let e = err(&mut s, "comment.list", json!({"filterr": "open"}));
+ assert!(e.contains("unknown parameter `filterr`") && e.contains("it takes page, filter, sort"), "{e}");
+ assert_eq!(s.execute("comment.list", &json!({"filter": "open"})).unwrap()["comments"].as_array().map(Vec::len), Some(0));
+ let e = err(&mut s, "edit.undo", json!({"steps": 2, "stepz": 1}));
+ assert!(e.contains("unknown parameters `steps`, `stepz` (it takes none)"), "{e}");
+}
+
+/// `ids` and `id` name the layers a command acts on, documented or not (`params::targets` reads
+/// either), so `{id}` keeps working where a doc only says `ids?`.
+#[test]
+fn ids_and_id_are_always_taken() {
+ let mut s = Session::new();
+ let r = crate::params::id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap();
+ assert_eq!(param_names("align.left"), Some(&["ids"][..]));
+ s.execute("align.left", &json!({"id": r})).unwrap();
+ s.execute("edit.undo", &json!({"ids": []})).unwrap();
+}
+
+/// Names a doc gives as alternatives (`id? | ids?`, `id?/ids?`, `path | data`) are all taken, and
+/// `null` params or no params at all stay fine.
+#[test]
+fn documented_alternatives_and_empty_params_are_taken() {
+ assert_eq!(param_names("vector.fillRule").map(|n| n.contains(&"id") && n.contains(&"ids")), Some(true));
+ assert_eq!(param_names("text.setFeatures").map(|n| n.contains(&"id") && n.contains(&"ids")), Some(true));
+ assert_eq!(param_names("file.import").map(|n| n.contains(&"path") && n.contains(&"data") && n.contains(&"name")), Some(true));
+ assert_eq!(param_names("file.export").map(|n| n.contains(&"scale") && n.contains(&"size")), Some(true));
+ assert_eq!(param_names("collab.share").map(|n| n.contains(&"via") && !n.contains(&"local")), Some(true));
+ assert_eq!(param_names("component.editProperty").map(|n| n.contains(&"value") && n.contains(&"newValue")), Some(true));
+ // Docs whose shape used to hide names: a stray brace, a comma in an example, a second object.
+ assert_eq!(param_names("proto.interaction.add").map(|n| n.contains(&"delayMs") && n.contains(&"dest")), Some(true));
+ assert_eq!(param_names("collab.join"), Some(&["code", "link"][..]));
+ assert_eq!(param_names("proto.interaction.set").map(|n| n.contains(&"trigger") && n.contains(&"interaction")), Some(true));
+ // Commands that run another command's code take its names too.
+ assert_eq!(param_names("file.placeImage").map(|n| ["width", "height", "target", "mode"].iter().all(|k| n.contains(k))), Some(true));
+ assert_eq!(param_names("file.exportSvg").map(|n| ["scale", "size", "contentsOnly", "svgOutlineText"].iter().all(|k| n.contains(k))), Some(true));
+ // Alternative groups: `{x, y} | {points: …}`.
+ assert_eq!(param_names("vector.edit.paint"), Some(&["x", "y", "points"][..]));
+ assert_eq!(param_names("vector.edit.cut"), Some(&["segment", "t", "vertex", "from", "to"][..]));
+ // `node.set` documents its props and checks them itself.
+ assert_eq!(param_names("node.set"), None);
+ let mut s = Session::new();
+ for p in [json!(null), json!({})] {
+ s.execute("select.all", &p).unwrap();
+ }
+}
+
+/// Every top-level item of every `{…}` params doc gives a name, so no parameter a doc describes is
+/// refused.
+#[test]
+fn every_doc_item_names_a_parameter() {
+ let nameless: Vec = all_commands()
+ .iter()
+ .flat_map(|c| crate::cmd::doc_items(c.params).unwrap_or_default().into_iter().map(move |i| (c.id, i)))
+ .filter(|(_, i)| !i.trim().is_empty() && crate::cmd::item_names(i).is_empty())
+ .map(|(id, i)| format!("{id}: `{i}`"))
+ .collect();
+ assert!(nameless.is_empty(), "doc items without a name: {nameless:#?}");
+}
+
+/// A batch step key other than `command`, `params` and `as` (`args` for `params`) is refused, and
+/// so is a step's unknown parameter, naming the step.
+#[test]
+fn batch_steps_refuse_unknown_keys_and_parameters() {
+ let mut s = Session::new();
+ let e = err(&mut s, "command.batch", json!({"commands": [{"command": "select.all"}, {"command": "inspect.code", "args": {"id": 2}}]}));
+ assert!(e.contains("step 1: unknown key `args`"), "{e}");
+ let e =
+ err(&mut s, "command.batch", json!({"commands": [{"command": "select.all"}, {"command": "comment.list", "params": {"filterr": "open"}}]}));
+ assert!(e.contains("comment.list") && e.contains("step 1: unknown parameter `filterr`"), "{e}");
+}
diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs
index 60cf960e..9d24bc39 100644
--- a/crates/engine/src/vector_behaviour_tests.rs
+++ b/crates/engine/src/vector_behaviour_tests.rs
@@ -7,9 +7,9 @@
// The tables read best as tuples.
#![allow(clippy::type_complexity)]
-use figcraft_doc::{HandleMirroring, NodeId, NodeKind, VectorNetwork};
+use figcraft_doc::{HandleMirroring, NodeId, NodeKind, Region, Segment, VectorNetwork, WindingRule};
use figcraft_geom::kurbo::ParamCurve;
-use figcraft_geom::{Point, Shape};
+use figcraft_geom::{BezPath, Point, Shape};
use serde_json::{Value, json};
use crate::Session;
@@ -17,6 +17,276 @@ use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
use crate::vectoredit::{VectorEdit, network};
+#[test]
+fn region_paints_survive_simplify_offset_and_bucket_normalization() {
+ let mut s = Session::new();
+ let id = square(&mut s, false);
+ ex_to_vector(s.doc_mut().unwrap(), id);
+ let mut gradient = figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK);
+ gradient.from = (0.2, 0.1);
+ gradient.to = (0.8, 0.9);
+ let fills = vec![
+ figcraft_color::Paint::gradient(gradient),
+ figcraft_color::Paint::image("test-image", figcraft_color::ImageScaleMode::Crop),
+ figcraft_color::Paint::pattern(123),
+ ];
+ {
+ let doc = s.doc_mut().unwrap();
+ let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
+ network.regions[0].fills = Some(fills.clone());
+ }
+ ex(&mut s, "vector.simplify", json!({"ids": [id]}));
+ assert_eq!(net(&s, id).regions[0].fills, Some(fills.clone()), "simplify keeps the full stack");
+ ex(&mut s, "vector.offset", json!({"ids": [id], "offset": 2}));
+ assert_eq!(net(&s, id).regions[0].fills, Some(fills.clone()), "offset keeps the full stack");
+
+ // Add a divider so the legacy single region must split into independently painted faces.
+ ex(&mut s, "vector.edit.begin", json!({"id": id}));
+ let network = net(&s, id);
+ let (topseg, botseg) = (
+ network.segments.iter().position(|sg| network.vertices[sg.a].y == 0.0 && network.vertices[sg.b].y == 0.0).unwrap(),
+ network.segments.iter().position(|sg| network.vertices[sg.a].y == 104.0 && network.vertices[sg.b].y == 104.0).unwrap(),
+ );
+ let a = ex(&mut s, "vector.edit.addVertex", json!({"segment": topseg, "t": 0.5}))["vertex"].as_u64().unwrap();
+ let b = ex(&mut s, "vector.edit.addVertex", json!({"segment": botseg, "t": 0.5}))["vertex"].as_u64().unwrap();
+ ex(&mut s, "vector.edit.connect", json!({"a": a, "b": b}));
+ ex(&mut s, "vector.edit.paint", json!({"x": 25, "y": 52}));
+ let normalized = net(&s, id);
+ assert_eq!(normalized.regions.len(), 1, "unfilled face is removed while the other stays");
+ assert_eq!(normalized.regions[0].fills, Some(fills.clone()), "untouched face keeps the original stack");
+ assert!(!normalized.fill_contains(Point::new(27.0, 54.0)));
+ assert!(normalized.fill_contains(Point::new(77.0, 54.0)));
+ s.undo().unwrap();
+ assert!(net(&s, id).fill_contains(Point::new(25.0, 52.0)), "bucket change is undoable");
+ ex(&mut s, "vector.edit.end", json!({}));
+
+ let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap();
+ let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
+ let flattened_net = net(&s, flattened);
+ assert!(flattened_net.regions.iter().all(|region| region.fills == Some(fills.clone())), "flatten keeps each source region stack");
+ assert!(flattened_net.regions.iter().all(|region| region.paint_space.is_some()), "flatten keeps source paint frames");
+ assert!(flattened_net.fill_contains(Point::new(25.0, 52.0)));
+ let prior_frame =
+ flattened_net.regions[0].paint_frame(s.doc().unwrap().get(flattened).unwrap().width, s.doc().unwrap().get(flattened).unwrap().height);
+ let prior_world_frame = s.doc().unwrap().world_transform(flattened) * prior_frame.2;
+ let regrouped = id_of(&ex(&mut s, "object.group", json!({"ids": [flattened]}))["id"]).unwrap();
+ let flattened_again = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [regrouped]}))["id"]).unwrap();
+ let after = net(&s, flattened_again);
+ let after_frame =
+ after.regions[0].paint_frame(s.doc().unwrap().get(flattened_again).unwrap().width, s.doc().unwrap().get(flattened_again).unwrap().height);
+ let after_world_frame = s.doc().unwrap().world_transform(flattened_again) * after_frame.2;
+ assert!(
+ prior_world_frame.as_coeffs().iter().zip(after_world_frame.as_coeffs()).all(|(before, after)| (before - after).abs() < 1e-8),
+ "flattening an already framed gradient preserves its source coordinates"
+ );
+}
+
+#[test]
+fn explicit_and_inherited_region_paints_survive_refit() {
+ let mut s = Session::new();
+ let empty_id = square(&mut s, false);
+ ex_to_vector(s.doc_mut().unwrap(), empty_id);
+ {
+ let doc = s.doc_mut().unwrap();
+ if let NodeKind::Vector { network } = &mut doc.get_mut(empty_id).unwrap().kind {
+ network.regions[0].fills = Some(Vec::new());
+ }
+ }
+ ex(&mut s, "vector.simplify", json!({"ids": [empty_id]}));
+ ex(&mut s, "vector.offset", json!({"ids": [empty_id], "offset": 2}));
+ assert_eq!(net(&s, empty_id).regions[0].fills, Some(Vec::new()), "explicit empty paint stack survives refit");
+
+ let inherited_id = square(&mut s, false);
+ ex_to_vector(s.doc_mut().unwrap(), inherited_id);
+ {
+ let doc = s.doc_mut().unwrap();
+ if let NodeKind::Vector { network } = &mut doc.get_mut(inherited_id).unwrap().kind {
+ network.regions[0].fills = None;
+ }
+ }
+ ex(&mut s, "vector.offset", json!({"ids": [inherited_id], "offset": 2}));
+ let inherited_net = net(&s, inherited_id);
+ let inherited = &inherited_net.regions[0];
+ assert_eq!(inherited.fills, None, "inherited paints remain inherited after offset");
+ let inherited_space = inherited.paint_space.expect("offset binds inherited paints to the original frame");
+ assert_eq!((inherited_space.width, inherited_space.height), (100.0, 100.0));
+}
+
+#[test]
+fn flatten_keeps_inherited_region_paints_inherited() {
+ let mut s = Session::new();
+ let inherited_flatten = square(&mut s, false);
+ let inherited_fills = s.doc().unwrap().get(inherited_flatten).unwrap().fills.clone();
+ ex_to_vector(s.doc_mut().unwrap(), inherited_flatten);
+ if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(inherited_flatten).unwrap().kind {
+ network.regions[0].fills = None;
+ }
+ let group = id_of(&ex(&mut s, "object.group", json!({"ids": [inherited_flatten]}))["id"]).unwrap();
+ let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
+ assert!(
+ net(&s, flattened).regions.iter().all(|region| region.fills.is_none()),
+ "flatten keeps inherited paints inherited when output node has same fills"
+ );
+ assert_eq!(s.doc().unwrap().get(flattened).unwrap().fills, inherited_fills);
+
+ let plain = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 60}))["id"]).unwrap();
+ let plain_fills = s.doc().unwrap().get(plain).unwrap().fills.clone();
+ let group = id_of(&ex(&mut s, "object.group", json!({"ids": [plain]}))["id"]).unwrap();
+ let flattened_plain = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
+ assert!(net(&s, flattened_plain).regions.iter().all(|region| region.fills.is_none()), "plain shapes inherit the flattened node's fill");
+ assert_eq!(s.doc().unwrap().get(flattened_plain).unwrap().fills, plain_fills);
+ let edited_fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
+ s.doc_mut().unwrap().get_mut(flattened_plain).unwrap().fills = edited_fills.clone();
+ assert_eq!(net(&s, flattened_plain).regions[0].fills, None, "changing the flattened node fill updates inherited geometry");
+
+ let different_source = square(&mut s, false);
+ s.doc_mut().unwrap().get_mut(different_source).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
+ ex_to_vector(s.doc_mut().unwrap(), different_source);
+ let source_fills = s.doc().unwrap().get(different_source).unwrap().fills.clone();
+ if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(different_source).unwrap().kind {
+ network.regions[0].fills = None;
+ }
+ let group = id_of(&ex(&mut s, "object.group", json!({"ids": [different_source]}))["id"]).unwrap();
+ s.doc_mut().unwrap().get_mut(group).unwrap().fills.clear();
+ let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
+ assert_eq!(net(&s, flattened).regions[0].fills, Some(source_fills), "flatten retains paints that differ from output node fills");
+}
+
+#[test]
+fn legacy_offset_preserves_the_gradients_world_paint_frame() {
+ let mut s = Session::new();
+ let legacy_gradient = square(&mut s, false);
+ let mut gradient = figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK);
+ gradient.from = (0.2, 0.1);
+ gradient.to = (0.8, 0.9);
+ s.doc_mut().unwrap().get_mut(legacy_gradient).unwrap().fills = vec![figcraft_color::Paint::gradient(gradient)];
+ ex_to_vector(s.doc_mut().unwrap(), legacy_gradient);
+ if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(legacy_gradient).unwrap().kind {
+ network.regions[0].fills = None;
+ }
+ let before = net(&s, legacy_gradient).regions[0].paint_frame(100.0, 100.0).2;
+ let before_world = s.doc().unwrap().world_transform(legacy_gradient) * before;
+ ex(&mut s, "vector.offset", json!({"ids": [legacy_gradient], "offset": 2}));
+ let after_net = net(&s, legacy_gradient);
+ let after = after_net.regions[0]
+ .paint_frame(s.doc().unwrap().get(legacy_gradient).unwrap().width, s.doc().unwrap().get(legacy_gradient).unwrap().height)
+ .2;
+ let after_world = s.doc().unwrap().world_transform(legacy_gradient) * after;
+ assert!(
+ before_world.as_coeffs().iter().zip(after_world.as_coeffs()).all(|(before, after)| (before - after).abs() < 1e-8),
+ "legacy offset preserves the gradient's world paint frame"
+ );
+}
+
+#[test]
+fn bucket_fills_an_explicitly_empty_face_and_leaves_the_other_empty() {
+ let mut s = Session::new();
+ let empty_faces =
+ id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L100 0 L100 100 L0 100 Z M200 0 L300 0 L300 100 L200 100 Z"}))["id"]).unwrap();
+ {
+ let doc = s.doc_mut().unwrap();
+ let node = doc.get_mut(empty_faces).unwrap();
+ node.fills.clear();
+ let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
+ assert!(network.regions.iter().all(|region| region.fills.is_none()));
+ }
+ ex(&mut s, "vector.edit.begin", json!({"id": empty_faces}));
+ ex(&mut s, "vector.edit.paint", json!({"x": 50, "y": 50}));
+ let split = net(&s, empty_faces);
+ let left =
+ split.regions.iter().enumerate().find(|(index, _)| split.region_contains(*index, Point::new(50.0, 50.0))).map(|(_, region)| region).unwrap();
+ let right =
+ split.regions.iter().enumerate().find(|(index, _)| split.region_contains(*index, Point::new(250.0, 50.0))).map(|(_, region)| region).unwrap();
+ assert_eq!(left.fills, None, "clicking an explicitly empty face fills it");
+ assert_eq!(right.fills, Some(Vec::new()), "untouched inherited-empty face stays empty");
+ assert!(!s.doc().unwrap().get(empty_faces).unwrap().fills.is_empty(), "the painted target gets the layer default fill");
+}
+
+#[test]
+fn legacy_simplify_combines_mixed_winding_regions() {
+ let mut s = Session::new();
+ let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L100 0 L100 100 L0 100 Z M25 25 L75 25 L75 75 L25 75 Z"}))["id"]).unwrap();
+ let mut second_path = BezPath::new();
+ second_path.move_to((50.0, 0.0));
+ second_path.line_to((150.0, 0.0));
+ second_path.line_to((150.0, 100.0));
+ second_path.line_to((50.0, 100.0));
+ second_path.close_path();
+ let second = VectorNetwork::from_path(&second_path);
+ {
+ let doc = s.doc_mut().unwrap();
+ let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
+ network.regions[0].winding = WindingRule::EvenOdd;
+ let vertex_offset = network.vertices.len();
+ let segment_offset = network.segments.len();
+ network.vertices.extend(second.vertices);
+ network.segments.extend(second.segments.into_iter().map(|segment| Segment {
+ a: segment.a + vertex_offset,
+ b: segment.b + vertex_offset,
+ ..segment
+ }));
+ network.regions.extend(second.regions.into_iter().map(|region| Region {
+ loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + segment_offset).collect()).collect(),
+ ..region
+ }));
+ network.regions[1].winding = WindingRule::NonZero;
+ }
+ ex(&mut s, "vector.simplify", json!({"ids": [id]}));
+ let simplified = net(&s, id);
+ assert_eq!(simplified.regions.len(), 1, "legacy regions simplify as one combined fill");
+ assert!(!simplified.fill_contains(Point::new(60.0, 10.0)), "overlap cancels under combined even-odd winding");
+ assert!(!simplified.fill_contains(Point::new(40.0, 40.0)), "nested contour remains a hole");
+ assert!(simplified.fill_contains(Point::new(120.0, 50.0)), "non-overlapping area remains filled");
+ assert!(simplified.regions[0].fills.is_none());
+ assert!(simplified.regions[0].paint_space.is_none());
+}
+
+#[test]
+fn legacy_evenodd_holes_survive_bucket_offset_and_flatten() {
+ let mut s = Session::new();
+ let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L200 0 L200 200 L0 200 Z M50 50 L150 50 L150 150 L50 150 Z"}))["id"]).unwrap();
+ {
+ let doc = s.doc_mut().unwrap();
+ let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
+ network.regions[0].winding = figcraft_doc::WindingRule::EvenOdd;
+ }
+ assert!(!net(&s, id).fill_contains(Point::new(100.0, 100.0)), "center starts as an even-odd hole");
+ ex(&mut s, "vector.edit.begin", json!({"id": id}));
+ ex(&mut s, "vector.edit.paint", json!({"x": 100, "y": 100}));
+ assert!(net(&s, id).fill_contains(Point::new(100.0, 100.0)), "bucket fills a legacy even-odd hole");
+ s.undo().unwrap();
+ ex(&mut s, "vector.edit.end", json!({}));
+
+ let flatten_source =
+ id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L200 0 L200 200 L0 200 Z M50 50 L150 50 L150 150 L50 150 Z"}))["id"]).unwrap();
+ {
+ let doc = s.doc_mut().unwrap();
+ if let NodeKind::Vector { network } = &mut doc.get_mut(flatten_source).unwrap().kind {
+ network.regions[0].winding = figcraft_doc::WindingRule::EvenOdd;
+ }
+ }
+ let group = id_of(&ex(&mut s, "object.group", json!({"ids": [flatten_source]}))["id"]).unwrap();
+ let flattened_legacy = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
+ assert_eq!(net(&s, flattened_legacy).regions.len(), 1, "flatten combines unstyled legacy loops");
+ assert!(!net(&s, flattened_legacy).fill_contains(Point::new(100.0, 100.0)), "flatten preserves the legacy even-odd hole");
+
+ ex(&mut s, "vector.offset", json!({"ids": [id], "offset": 2}));
+ assert!(!net(&s, id).fill_contains(Point::new(100.0, 100.0)), "offset retains the even-odd hole");
+ let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap();
+ let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
+ assert_eq!(net(&s, flattened).regions.len(), 1, "legacy region loops stay grouped");
+ assert!(!net(&s, flattened).fill_contains(Point::new(100.0, 100.0)), "flatten retains the even-odd hole");
+}
+
+/// Give vector `id` a painted region, converting a plain shape to a vector first.
+fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
+ let n = doc.get(id).unwrap();
+ let (path, _) = figcraft_render::node_outline(doc, n).unwrap();
+ let mut network = VectorNetwork::from_path(&path);
+ network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
+ doc.get_mut(id).unwrap().kind = NodeKind::Vector { network };
+}
+
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
diff --git a/crates/engine/src/vector_fill_rule_tests.rs b/crates/engine/src/vector_fill_rule_tests.rs
index 8a8fe850..eb462ab2 100644
--- a/crates/engine/src/vector_fill_rule_tests.rs
+++ b/crates/engine/src/vector_fill_rule_tests.rs
@@ -88,8 +88,8 @@ fn two_squares() -> VectorNetwork {
Segment::line(3, 4),
],
regions: vec![
- Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero },
- Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero },
+ Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, ..Default::default() },
+ Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero, ..Default::default() },
],
}
}
@@ -114,8 +114,8 @@ fn two_lenses() -> VectorNetwork {
Segment { a: 0, b: 1, ta: (0.0, 50.0), tb: (0.0, 50.0) },
],
regions: vec![
- Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero },
- Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero },
+ Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() },
+ Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero, ..Default::default() },
],
}
}
@@ -131,7 +131,10 @@ fn one_shared_segment() -> VectorNetwork {
VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)],
segments: vec![Segment { a: 0, b: 1, ta: (0.0, -60.0), tb: (0.0, -60.0) }],
- regions: vec![Region { loops: vec![vec![0]], winding: WindingRule::NonZero }, Region { loops: vec![vec![0]], winding: WindingRule::NonZero }],
+ regions: vec![
+ Region { loops: vec![vec![0]], winding: WindingRule::NonZero, ..Default::default() },
+ Region { loops: vec![vec![0]], winding: WindingRule::NonZero, ..Default::default() },
+ ],
}
}
diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs
index ba420527..03c82cfb 100644
--- a/crates/format/src/fig/export/shape.rs
+++ b/crates/format/src/fig/export/shape.rs
@@ -157,6 +157,9 @@ impl<'d> Exporter<'d> {
/// The editable network (with per-point corner radii, handle mirroring and end caps) and,
/// when it has filled areas, their outline.
pub(super) fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) {
+ if net.regions.iter().any(|region| region.fills.is_some() || region.paint_space.is_some()) {
+ self.warn("vector region paints were left out of the .fig export");
+ }
let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum();
if net.vertices.len() + net.segments.len() + entries > MAX_NETWORK {
self.warn("vector networks too large for the format were written as outlines");
diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs
index 4198b1cc..9e891bdb 100644
--- a/crates/format/src/fig/export_tests.rs
+++ b/crates/format/src/fig/export_tests.rs
@@ -827,6 +827,51 @@ fn round_trip_keeps_the_details() {
assert_eq!(label.characters, "Hi");
}
+#[test]
+fn fig_export_warns_when_region_paints_are_left_out() {
+ let mut doc = generate(3);
+ let Some(id) = doc.nodes().find_map(|node| match &node.kind {
+ NodeKind::Vector { network } if !network.regions.is_empty() => Some(node.id),
+ _ => None,
+ }) else {
+ panic!("generated document has no filled vector");
+ };
+ let node = doc.node_mut(id).unwrap();
+ let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
+ let region = network.regions.first_mut().unwrap();
+ region.fills = Some(Vec::new());
+
+ let (_, warnings) = export(&doc, None).unwrap();
+ assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
+}
+
+#[test]
+fn fig_export_warns_when_inherited_region_paints_have_a_paint_frame() {
+ let mut doc = generate(3);
+ let id = doc
+ .nodes()
+ .find_map(|node| match &node.kind {
+ NodeKind::Vector { network } if !network.regions.is_empty() => Some(node.id),
+ _ => None,
+ })
+ .unwrap();
+ let node = doc.node_mut(id).unwrap();
+ let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
+ let region = network.regions.first_mut().unwrap();
+ region.fills = None;
+ region.paint_space = Some(RegionPaintSpace { width: 20.0, height: 10.0, transform: Affine::translate((4.0, 8.0)) });
+
+ let (_, warnings) = export(&doc, None).unwrap();
+ assert!(warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
+
+ let mut plain = doc;
+ let node = plain.node_mut(id).unwrap();
+ let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
+ network.regions[0].paint_space = None;
+ let (_, warnings) = export(&plain, None).unwrap();
+ assert!(!warnings.iter().any(|warning| warning == "vector region paints were left out of the .fig export"), "{warnings:?}");
+}
+
#[test]
fn written_files_decode_and_list_parents_first() {
for seed in 0..20 {
diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs
index 06cfdb8b..546f6eb3 100644
--- a/crates/format/src/fig/map.rs
+++ b/crates/format/src/fig/map.rs
@@ -248,6 +248,9 @@ struct Mapper<'a> {
networks: Vec>,
/// Index in `networks` by what a network is made from: its blob, its scale and its style table.
network_ids: HashMap<(usize, u64, u64, usize), usize>,
+ /// Region paint elements (paints and gradient stops) read so far: one budget for the whole
+ /// file, since layers with region paints don't share their network.
+ region_paint_elements: usize,
}
impl<'a> Mapper<'a> {
@@ -322,6 +325,7 @@ impl<'a> Mapper<'a> {
reserved: 0,
networks: Vec::new(),
network_ids: HashMap::new(),
+ region_paint_elements: 0,
}
}
diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs
index e750a5e4..38708f92 100644
--- a/crates/format/src/fig/map/layer.rs
+++ b/crates/format/src/fig/map/layer.rs
@@ -1,7 +1,7 @@
use super::component::{Entry, Inst, OverrideRow, prop_ref};
use super::connector::connector_has_saved_label;
use super::layout::{layout_grid, tracks};
-use super::paint::{effect, export_setting, paint, paint_bound, stroke};
+use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke, style_link};
use super::{
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows,
size,
@@ -566,6 +566,84 @@ impl<'a> Mapper<'a> {
size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n)
};
let (sx, sy) = (scale("x"), scale("y"));
+ let vertices: Vec = net
+ .vertices
+ .iter()
+ .map(|p| {
+ let mut out = json!({"x": p.x * sx, "y": p.y * sy});
+ let st = style(p.style);
+ if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
+ out["radius"] = json!(r);
+ }
+ match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
+ Some("ANGLE") => out["mirror"] = json!("angle"),
+ Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
+ Some("NONE") => out["mirror"] = json!("none"),
+ _ => {}
+ }
+ if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
+ out["join"] = json!(join.to_ascii_lowercase());
+ }
+ out
+ })
+ .collect();
+ let segments: Vec =
+ net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect();
+ let regions: Vec = net
+ .regions
+ .iter()
+ .map(|r| {
+ let mut region = json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }});
+ if r.style != 0 {
+ match style(r.style) {
+ Some(s) => {
+ let fills = s.get("fillPaints").and_then(|own_fills| {
+ if own_fills.list().iter().any(paint_bound) {
+ return Some(own_fills);
+ }
+ let linked_style = style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID")
+ .and_then(|id| self.nodes.get(&id).copied())
+ .filter(|style| style.get("styleType").and_then(Kv::str) == Some("FILL"));
+ match linked_style {
+ Some(style) => style.get("fillPaints"),
+ None => Some(own_fills),
+ }
+ });
+ if let Some(fills) = fills {
+ if let Kv::Array(source_paints) = fills {
+ let paint_cost = source_paints.iter().fold(source_paints.len(), |cost, paint| {
+ cost.saturating_add(paint.get("stops").map(Kv::list).unwrap_or_default().len())
+ });
+ let oversized_stops =
+ source_paints.iter().any(|paint| paint.get("stops").map(Kv::list).unwrap_or_default().len() > 256);
+ if source_paints.len() > 256 || oversized_stops || self.region_paint_elements.saturating_add(paint_cost) > 200_000
+ {
+ self.warn("vector region paint data exceeded import limits and was skipped");
+ return region;
+ }
+ self.region_paint_elements += paint_cost;
+ let paints: Vec = source_paints.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect();
+ if paints.len() != source_paints.len() {
+ self.warn("invalid vector region paints were skipped");
+ }
+ if paints.iter().any(has_bound_variables) {
+ self.warn("variable-bound vector region paints keep resolved appearance; live bindings were left out");
+ }
+ if s.get("styleIdForFill").is_some() {
+ self.warn("linked vector region fill styles keep resolved appearance; live style links were left out");
+ }
+ region["_figRegionFills"] = Value::Array(paints);
+ } else {
+ self.warn("invalid vector region fill paint list; layer fill used");
+ }
+ }
+ }
+ None => self.warn("vector region references a missing fill style; layer fill used"),
+ }
+ }
+ region
+ })
+ .collect();
// An absent layer join is the source default.
let layer_join = v.str("strokeJoin").unwrap_or("MITER");
let point_join = |p: &network::Vertex| style(p.style).and_then(|s| s.get("strokeJoin"));
@@ -573,9 +651,6 @@ impl<'a> Mapper<'a> {
let unresolved_style = net.vertices.iter().any(|p| p.style != 0 && style(p.style).is_none());
let unknown_join = net.vertices.iter().any(|p| point_join(p).is_some_and(|j| !j.str().is_some_and(known)));
let unsupported_join = net.vertices.iter().any(|p| point_join(p).and_then(Kv::str).is_some_and(|j| known(j) && j != layer_join));
- if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) {
- self.warn("filled areas of a vector with their own fill use the layer's fill");
- }
if unresolved_style {
self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners");
}
@@ -604,42 +679,18 @@ impl<'a> Mapper<'a> {
o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node))));
}
// Every instance of a main shows the same network: it is written once and shared.
+ let mut entry = json!({});
+ entry["vertices"] = Value::Array(vertices);
+ entry["segments"] = Value::Array(segments);
+ entry["regions"] = Value::Array(regions.clone());
+ if regions.iter().any(|r| r.get("_figRegionFills").is_some()) {
+ o.insert("vectorNetwork".into(), entry);
+ return true;
+ }
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize);
let id = match self.network_ids.get(&key) {
Some(id) => *id,
None => {
- let vertices: Vec = net
- .vertices
- .iter()
- .map(|p| {
- let mut out = json!({"x": p.x * sx, "y": p.y * sy});
- let st = style(p.style);
- if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
- out["radius"] = json!(r);
- }
- match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
- Some("ANGLE") => out["mirror"] = json!("angle"),
- Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
- Some("NONE") => out["mirror"] = json!("none"),
- _ => {}
- }
- if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) {
- out["join"] = json!(join.to_ascii_lowercase());
- }
- out
- })
- .collect();
- let segments: Vec = net
- .segments
- .iter()
- .map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]}))
- .collect();
- let regions: Vec =
- net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect();
- let mut entry = json!({});
- entry["vertices"] = Value::Array(vertices);
- entry["segments"] = Value::Array(segments);
- entry["regions"] = Value::Array(regions);
self.networks.push(crate::rest::network(&entry));
self.network_ids.insert(key, self.networks.len() - 1);
self.networks.len() - 1
diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs
index 808e6fc8..e38ddf26 100644
--- a/crates/format/src/fig/map/paint.rs
+++ b/crates/format/src/fig/map/paint.rs
@@ -217,6 +217,14 @@ pub(crate) fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option {
Some(Value::Object(o))
}
+pub(super) fn has_bound_variables(value: &Value) -> bool {
+ match value {
+ Value::Object(object) => object.contains_key("boundVariables") || object.values().any(has_bound_variables),
+ Value::Array(values) => values.iter().any(has_bound_variables),
+ _ => false,
+ }
+}
+
/// The variables bound to the fields of a paint stop, an effect or a layout grid, as its REST
/// `boundVariables`: `fields` pairs the format's field with the REST name.
pub(super) fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option {
diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs
index abc90d73..0e6d32bc 100644
--- a/crates/format/src/fig/tail_tests.rs
+++ b/crates/format/src/fig/tail_tests.rs
@@ -264,6 +264,256 @@ fn tail_canvas() -> Vec {
canvas(&schema(), &tail_message())
}
+fn region_canvas(fill_paints: Option>>, style_id: u32) -> Vec {
+ let mut message = tail_message();
+ let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
+ let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
+ let vector_data = field_mut(wave, "vectorData").unwrap();
+ let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
+ let Kv::Object(fields) = styles.first_mut().unwrap() else { panic!() };
+ if let Some(fill_paints) = fill_paints {
+ fields.push(("fillPaints", arr(fill_paints)));
+ }
+ let mut network = network::decode(&square_blob()).unwrap();
+ network.regions[0].style = style_id;
+ let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
+ let bytes = field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap();
+ *bytes = Kv::Bytes(network::encode(&network));
+ canvas(&schema(), &message)
+}
+
+fn region_canvas_with_link(own: Option>, link_target: Option, linked_fill: Kv<'static>) -> Vec {
+ region_canvas_with_typed_link(own, link_target, "FILL", linked_fill)
+}
+
+fn region_canvas_with_typed_link(own: Option>, link_target: Option, style_type: &'static str, linked_fill: Kv<'static>) -> Vec {
+ let mut message = tail_message();
+ let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
+ let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
+ let vector_data = field_mut(wave, "vectorData").unwrap();
+ let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
+ let Kv::Object(fields) = styles.first_mut().unwrap() else { panic!() };
+ if let Some(own) = own {
+ fields.push(("fillPaints", arr(vec![own])));
+ }
+ if let Some(target) = link_target {
+ fields.push(("styleIdForFill", o(vec![("guid", g(target))])));
+ }
+ nodes.push(node(50, None, "RECTANGLE", "Linked Fill", vec![("styleType", e(style_type)), ("fillPaints", arr(vec![linked_fill]))]));
+ let mut network = network::decode(&square_blob()).unwrap();
+ network.regions[0].style = 1;
+ let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
+ *field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap() = Kv::Bytes(network::encode(&network));
+ canvas(&schema(), &message)
+}
+
+fn field_mut<'a, 's>(value: &'a mut Kv<'s>, name: &str) -> Option<&'a mut Kv<'s>> {
+ if let Kv::Object(fields) = value {
+ return fields.iter_mut().find(|(key, _)| *key == name).map(|(_, field)| field);
+ }
+ None
+}
+
+#[test]
+fn vector_region_fills_import_from_style_table() {
+ let solid = o(vec![
+ ("type", e("SOLID")),
+ ("color", o(vec![("r", f(0.2)), ("g", f(0.4)), ("b", f(0.8)), ("a", f(0.5))])),
+ ("opacity", f(0.5)),
+ ("visible", Kv::Bool(false)),
+ ]);
+ let gradient = o(vec![
+ ("type", e("GRADIENT_LINEAR")),
+ (
+ "stops",
+ arr(vec![
+ o(vec![("position", f(0.0)), ("color", o(vec![("r", f(1.0)), ("g", f(0.0)), ("b", f(0.0)), ("a", f(1.0))]))]),
+ o(vec![("position", f(1.0)), ("color", o(vec![("r", f(0.0)), ("g", f(0.0)), ("b", f(1.0)), ("a", f(0.25))]))]),
+ ]),
+ ),
+ ]);
+ let image_hash = vec![0x12, 0x34, 0x56];
+ let image = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(image_hash.clone()))])), ("imageScaleMode", e("FILL"))]);
+ let wire = region_canvas(Some(vec![solid, gradient, image]), 1);
+ let (doc, warnings) = import(&wire, "Region fills").unwrap();
+ let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
+ let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
+ assert_eq!((color.r, color.g, color.b, fills[0].opacity, fills[0].visible), (0.2, 0.4, 0.8, 0.25, false));
+ assert!(matches!(&fills[1].kind, figcraft_doc::color::PaintKind::Gradient(g) if g.stops.len() == 2 && g.stops[1].color.a == 0.25));
+ let figcraft_doc::color::PaintKind::Image(imported_image) = &fills[2].kind else { panic!("{fills:?}") };
+ let image_key = imported_image.image.clone();
+ assert!(doc.images.contains_key(&image_key), "image reference must retain its image placeholder");
+ assert!(!warnings.iter().any(|warning| warning.contains("their own fill use the layer's fill")), "{warnings:?}");
+
+ let saved = crate::save_bytes(&doc, None).unwrap();
+ let (reopened, _) = crate::load_bytes(&saved).unwrap();
+ assert_eq!(network_of(&reopened, "Wave").regions[0].fills, Some(fills.clone()));
+ assert!(reopened.images.contains_key(&image_key), "image reference must survive native save/reopen");
+}
+
+#[test]
+fn vector_region_missing_and_empty_paints_remain_distinct() {
+ let (inherited, _) = import(®ion_canvas(None, 1), "Inherited region").unwrap();
+ assert_eq!(network_of(&inherited, "Wave").regions[0].fills, None);
+ let (empty, _) = import(®ion_canvas(Some(Vec::new()), 1), "Empty region").unwrap();
+ assert_eq!(network_of(&empty, "Wave").regions[0].fills, Some(Vec::new()));
+}
+
+#[test]
+fn vector_region_linked_fill_uses_style_paints_without_recording_definition() {
+ let linked = solid(0.9, 0.1, 0.2);
+ let own = solid(0.1, 0.2, 0.9);
+ let wire = region_canvas_with_link(Some(own), Some(50), linked);
+ let (doc, _) = import(&wire, "Linked region style").unwrap();
+ let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
+ let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
+ assert_eq!((color.r, color.g, color.b), (0.9, 0.1, 0.2));
+ assert!(doc.nodes().all(|node| node.name != "Linked Fill"), "style lookup must not add a style as a layer");
+ let archive = Archive::open(&wire).unwrap();
+ let (schema, message) = decode_canvas(&archive.canvas).unwrap();
+ let message = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
+ let (rest, _, _) = map::to_rest(&message, "Linked region style", crate::rest::MAX_NODES);
+ assert!(rest.get("styles").is_none(), "regional link must not populate the layer styles map");
+}
+
+#[test]
+fn vector_region_variable_bound_own_fill_wins_over_linked_style() {
+ let own =
+ o(vec![("type", e("SOLID")), ("color", o(vec![("r", f(0.1)), ("g", f(0.2)), ("b", f(0.9)), ("a", f(1.0))])), ("colorVar", alias_data(41))]);
+ let (doc, _) = import(®ion_canvas_with_link(Some(own), Some(50), solid(0.9, 0.1, 0.2)), "Bound region style").unwrap();
+ let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
+ let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
+ assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
+}
+
+#[test]
+fn vector_region_missing_fills_inherit_and_unresolved_link_keeps_own_fill() {
+ let (inherited, _) = import(®ion_canvas_with_link(None, Some(50), solid(0.9, 0.1, 0.2)), "Inherited linked region").unwrap();
+ assert_eq!(network_of(&inherited, "Wave").regions[0].fills, None);
+
+ let own = solid(0.1, 0.2, 0.9);
+ let (fallback, _) = import(®ion_canvas_with_link(Some(own), Some(999), solid(0.9, 0.1, 0.2)), "Unresolved linked region").unwrap();
+ let fills = network_of(&fallback, "Wave").regions[0].fills.as_ref().unwrap();
+ let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
+ assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
+}
+
+#[test]
+fn vector_region_link_to_non_fill_style_keeps_own_fill() {
+ let own = solid(0.1, 0.2, 0.9);
+ let (doc, _) = import(®ion_canvas_with_typed_link(Some(own), Some(50), "TEXT", solid(0.9, 0.1, 0.2)), "Text-linked region").unwrap();
+ let fills = network_of(&doc, "Wave").regions[0].fills.as_ref().unwrap();
+ let figcraft_doc::color::PaintKind::Solid { color } = &fills[0].kind else { panic!("{fills:?}") };
+ assert_eq!((color.r, color.g, color.b), (0.1, 0.2, 0.9));
+}
+
+#[test]
+fn vector_region_paint_budget_covers_the_whole_file() {
+ // 190 gradients of 255 stops: 48,640 elements per layer, so four layers fit and a fifth doesn't.
+ let stop = o(vec![("position", f(0.0)), ("color", o(vec![("r", f(1.0)), ("g", f(0.0)), ("b", f(0.0)), ("a", f(1.0))]))]);
+ let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("stops", arr(vec![stop; 255]))]);
+ let mut message = tail_message();
+ let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
+ let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
+ let vector_data = field_mut(wave, "vectorData").unwrap();
+ let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
+ let Kv::Object(style_fields) = styles.first_mut().unwrap() else { panic!() };
+ style_fields.push(("fillPaints", arr(vec![gradient; 190])));
+ let wave = wave.clone();
+ for (index, position) in ["w", "x", "y", "z"].into_iter().enumerate() {
+ let mut copy = wave.clone();
+ let Kv::Object(fields) = &mut copy else { panic!() };
+ for (key, value) in fields.iter_mut() {
+ match *key {
+ "guid" => *value = g(60 + index as u64),
+ "name" => *value = Kv::Str(format!("Wave {index}")),
+ "parentIndex" => *value = o(vec![("guid", g(1)), ("position", s(position))]),
+ _ => {}
+ }
+ }
+ nodes.push(copy);
+ }
+ let mut network = network::decode(&square_blob()).unwrap();
+ network.regions[0].style = 1;
+ let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
+ *field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap() = Kv::Bytes(network::encode(&network));
+
+ let (doc, warnings) = import(&canvas(&schema(), &message), "Region paint budget").unwrap();
+ let painted = ["Wave", "Wave 0", "Wave 1", "Wave 2", "Wave 3"].iter().filter(|name| network_of(&doc, name).regions[0].fills.is_some()).count();
+ assert_eq!(painted, 4);
+ assert!(warnings.iter().any(|warning| warning.contains("region paint data exceeded import limits")), "{warnings:?}");
+}
+
+#[test]
+fn vector_regions_keep_distinct_solid_paints() {
+ let mut message = tail_message();
+ let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
+ let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
+ let vector_data = field_mut(wave, "vectorData").unwrap();
+ let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
+ let Kv::Object(first_style) = styles.first_mut().unwrap() else { panic!() };
+ first_style.push(("fillPaints", arr(vec![solid(0.1, 0.2, 0.9)])));
+ let Kv::Object(second_style) = o(vec![("styleID", u(2)), ("fillPaints", arr(vec![solid(0.9, 0.1, 0.2)]))]) else { panic!() };
+ styles.push(Kv::Object(second_style));
+ let mut network = network::decode(&square_blob()).unwrap();
+ network.regions[0].style = 1;
+ network.regions.push(network::Region { style: 2, non_zero: network.regions[0].non_zero, loops: network.regions[0].loops.clone() });
+ let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() };
+ let bytes = field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap();
+ *bytes = Kv::Bytes(network::encode(&network));
+
+ let (doc, _) = import(&canvas(&schema(), &message), "Two region fills").unwrap();
+ let regions = &network_of(&doc, "Wave").regions;
+ assert_eq!(regions.len(), 2);
+ let color = |index: usize| match ®ions[index].fills.as_ref().unwrap()[0].kind {
+ figcraft_doc::color::PaintKind::Solid { color } => (color.r, color.g, color.b),
+ paint => panic!("{paint:?}"),
+ };
+ assert_eq!(color(0), (0.1, 0.2, 0.9));
+ assert_eq!(color(1), (0.9, 0.1, 0.2));
+}
+
+#[test]
+fn vector_region_gradient_limit_warns_instead_of_silently_truncating() {
+ for count in [256, 257] {
+ let stops = (0..count)
+ .map(|i| {
+ let value = if i + 1 == count { 1.0 } else { 0.0 };
+ o(vec![
+ ("position", f(i as f64 / (count - 1) as f64)),
+ ("color", o(vec![("r", f(value)), ("g", f(value)), ("b", f(value)), ("a", f(1.0))])),
+ ])
+ })
+ .collect();
+ let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("stops", arr(stops))]);
+ let (doc, warnings) = import(®ion_canvas(Some(vec![gradient]), 1), "Bounded region gradient").unwrap();
+ let fills = &network_of(&doc, "Wave").regions[0].fills;
+ if count == 256 {
+ let figcraft_doc::color::PaintKind::Gradient(g) = &fills.as_ref().unwrap()[0].kind else { panic!() };
+ assert_eq!(g.stops.len(), 256);
+ assert_eq!(g.stops.last().unwrap().color.r, 1.0);
+ assert!(!warnings.iter().any(|w| w.contains("vector region paint data exceeded")));
+ } else {
+ assert!(fills.is_none());
+ assert!(warnings.iter().any(|w| w.contains("vector region paint data exceeded import limits")), "{warnings:?}");
+ }
+ }
+}
+
+#[test]
+fn vector_region_invalid_paints_and_style_refs_warn() {
+ let malformed = o(vec![("opacity", f(0.5))]);
+ let unsupported = o(vec![("type", e("EMOJI"))]);
+ let (doc, warnings) = import(®ion_canvas(Some(vec![malformed, unsupported]), 1), "Invalid region paints").unwrap();
+ assert_eq!(network_of(&doc, "Wave").regions[0].fills, Some(Vec::new()));
+ assert!(warnings.iter().any(|warning| warning.contains("invalid vector region paints were skipped")), "{warnings:?}");
+ assert!(warnings.iter().any(|warning| warning.contains("EMOJI paints are not supported")), "{warnings:?}");
+
+ let (doc, warnings) = import(®ion_canvas(None, 9), "Missing region style").unwrap();
+ assert_eq!(network_of(&doc, "Wave").regions[0].fills, None);
+ assert!(warnings.iter().any(|warning| warning.contains("vector region references a missing fill style")), "{warnings:?}");
+}
+
fn network_of<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::VectorNetwork {
match &named(doc, name).kind {
NodeKind::Vector { network } => network,
diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs
index c1253048..5bac704a 100644
--- a/crates/format/src/fig/tests.rs
+++ b/crates/format/src/fig/tests.rs
@@ -13,7 +13,8 @@ use super::*;
/// Message field ids count from 1; enum values from 0.
const SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET SHAPE_WITH_TEXT CONNECTOR TABLE
-enum PaintType SOLID GRADIENT_LINEAR IMAGE
+enum PaintType SOLID GRADIENT_LINEAR IMAGE EMOJI
+enum ImageScaleMode STRETCH FIT FILL TILE
enum WindingRule NONZERO ODD
enum StyleType NONE FILL TEXT
enum StackMode NONE HORIZONTAL VERTICAL
@@ -51,7 +52,7 @@ message ConditionalActions Action[] actions, VariableData condition
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
message VariableData AnyValue value, DataType dataType
-message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar
+message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode
message ParentIndex GUID guid, string position
message FontName string family, string style, string postscript
message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable
diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs
index f9b83691..d394d185 100644
--- a/crates/format/src/rest.rs
+++ b/crates/format/src/rest.rs
@@ -738,9 +738,30 @@ impl<'a> Ctx<'a> {
/// (exact look, editable as a shape): returns whether it does, and the caller moves the paints
/// with [`outline_stroke`] once the styles and variables of the stroke are read.
fn vector(&mut self, n: &mut Node, j: &Value) -> bool {
- let net = match j.get("vectorNetworkRef") {
- Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
- None => j.get("vectorNetwork").map(network),
+ let net = match j.get("vectorNetwork") {
+ Some(net) => Some(network(net).map(|mut network| {
+ // Private `.fig` mapper handoff only. Public REST `vectorNetwork` remains
+ // native network JSON; it never consumes the private mapper paint fields.
+ if self.fig_sources.is_some()
+ && let Some(regions) = net.get("regions").and_then(Value::as_array)
+ {
+ for (region, source) in network.regions.iter_mut().zip(regions) {
+ let Some(fills) = source.get("_figRegionFills") else { continue };
+ let Some(list) = fills.as_array() else {
+ self.warn("invalid vector region paints were skipped");
+ continue;
+ };
+ let before = list.len();
+ let wrapper = serde_json::json!({"fills": fills});
+ region.fills = props::paints(self, &wrapper, "fills");
+ if region.fills.as_ref().is_some_and(|parsed| parsed.len() < before) {
+ self.warn("invalid vector region paints were skipped");
+ }
+ }
+ }
+ network
+ })),
+ None => j.get("vectorNetworkRef").map(|r| r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())),
};
if let Some(net) = net {
match net {
@@ -1391,6 +1412,11 @@ pub(crate) fn network(v: &Value) -> Option {
if !fits {
return None;
}
+ // Region paints come only from the validated `.fig` mapper handoff, never from public JSON.
+ for r in &mut net.regions {
+ r.fills = None;
+ r.paint_space = None;
+ }
for p in &mut net.vertices {
p.radius = p.radius.clamp(0.0, MAX_SIZE);
}
diff --git a/crates/format/src/rest/import_regression_tests.rs b/crates/format/src/rest/import_regression_tests.rs
index 2d6364be..71d9e0af 100644
--- a/crates/format/src/rest/import_regression_tests.rs
+++ b/crates/format/src/rest/import_regression_tests.rs
@@ -42,6 +42,24 @@ fn stroke_only_vector_shared_stroke_style_keeps_paints() {
}
}
+#[test]
+fn public_rest_ignores_region_fills_and_private_fig_handoff() {
+ let network = json!({
+ "vertices": [{"x": 0, "y": 0}, {"x": 10, "y": 0}, {"x": 10, "y": 10}],
+ "segments": [{"a": 0, "b": 1}, {"a": 1, "b": 2}, {"a": 2, "b": 0}],
+ "regions": [{"loops": [[0, 1, 2]], "winding": "nonZero", "fills": [{"type": "solid", "color": {"r": 0, "g": 1, "b": 0}}],
+ "paint_space": {"width": 10, "height": 10, "transform": [1, 0, 0, 1, 5, 5]},
+ "_figRegionFills": [{"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0}}]}]
+ });
+ let node = json!({"id": "1:1", "name": "Vector", "type": "VECTOR", "relativeTransform": at(0.0, 0.0),
+ "size": {"x": 10, "y": 10}, "vectorNetwork": network});
+ let (doc, _) = import(&file(json!([node])), &no_images).unwrap();
+ let vector = named(&doc, "Vector");
+ let NodeKind::Vector { network } = &vector.kind else { panic!("{:#?}", vector.kind) };
+ assert!(network.regions[0].fills.is_none());
+ assert!(network.regions[0].paint_space.is_none());
+}
+
#[test]
fn stroke_colour_variable_on_outlined_vector_is_kept() {
let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": alias(BRAND)}});
diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs
index 24ceca15..59220f1b 100644
--- a/crates/format/src/sketch/path.rs
+++ b/crates/format/src/sketch/path.rs
@@ -96,7 +96,7 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) -
})
.collect();
let winding = winding(l);
- let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() };
+ let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding, ..Default::default() }] } else { Vec::new() };
Some(VectorNetwork { vertices, segments, regions })
}
@@ -354,7 +354,7 @@ fn compound(cx: &mut Cx, run: &[&Value], winding: WindingRule) -> Option Vec {
"name": string("Layer name"),
"text": string("Characters, for type text"),
"path": string("SVG path data, for type vector (e.g. \"M0 0 L100 0 L50 80 Z\")"),
+ "points": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "For type vector, instead of path: the points [[x, y], ...]"},
+ "closed": boolean("For type vector: close the path"),
+ "select": boolean("Select the new layer (default true)"),
"props": {"type": "object", "description": "Any set_properties keys applied at creation, e.g. {\"fill\":\"#3366FF\",\"cornerRadius\":12,\"fontSize\":24,\"layout\":\"vertical\",\"gap\":8,\"padding\":16}"},
}),
&["type"],
@@ -260,6 +263,7 @@ pub fn tool_definitions() -> Vec {
json!({
"path": string("File path"),
"variables": {"description": "REST API file JSON only: the local variables JSON (`GET /v1/files/:key/variables/local`) as a path or the JSON object. Default: `.variables.json` or `variables.json` next to the file."},
+ "reload": boolean("A file that is already open: read it again from disk into its tab (refused while the tab has unsaved changes). Without it, the result says `stale: true` when the file changed on disk since the tab read or saved it"),
}),
&["path"],
),
@@ -289,6 +293,7 @@ pub fn tool_definitions() -> Vec {
"shift": boolean("Shift held"),
"alt": boolean("Alt/Option held"),
"cmd": boolean("Cmd (macOS) / Ctrl held"),
+ "ctrl": boolean("Ctrl held (the Control key on macOS)"),
"pressure": num("Pen pressure 0-1 (the Pencil records it as the stroke's width profile; a drag starts with it)"),
"pressureTo": num("Pen pressure 0-1 at a drag's end (interpolated along it)"),
}),
@@ -301,7 +306,7 @@ pub fn tool_definitions() -> Vec {
"Press key",
"Press a key or shortcut as if typed on the canvas: a menu shortcut (e.g. {\"key\":\"G\",\"cmd\":true} groups, {\"key\":\"A\",\"alt\":true,\"shift\":false} …), a tool key (R, O, T, F, V…), Escape, Delete, Enter, arrows (nudge; shift = big nudge). While editing text, keys go to the text editor (Backspace, Delete, Left, Right, Home, End, Enter, Escape, SelectAll).",
obj(
- json!({"key": string("Key name: a letter/digit, Escape, Delete, Backspace, Enter, ArrowLeft…"), "shift": boolean("Shift"), "alt": boolean("Alt/Option"), "cmd": boolean("Cmd/Ctrl")}),
+ json!({"key": string("Key name: a letter/digit, Escape, Delete, Backspace, Enter, ArrowLeft…"), "shift": boolean("Shift"), "alt": boolean("Alt/Option"), "cmd": boolean("Cmd/Ctrl"), "ctrl": boolean("Ctrl (the Control key on macOS)")}),
&["key"],
),
false,
@@ -434,12 +439,45 @@ fn friendly_params(p: &mut Value) {
// ---------- dispatch ----------
+/// Refuse argument names `schema` doesn't list, at the top and in the objects it describes inside
+/// (a batch's steps); objects it leaves open (`params`) take any. `path` leads the names in the
+/// error (`commands[0].`).
+fn refuse_unknown_args(schema: &Value, args: &Value, path: &str) -> Result<(), String> {
+ if let (Some(props), Some(given)) = (schema.get("properties").and_then(Value::as_object), args.as_object()) {
+ let unknown: Vec = given.keys().filter(|k| !props.contains_key(*k)).map(|k| format!("`{path}{k}`")).collect();
+ if !unknown.is_empty() {
+ let s = if unknown.len() == 1 { "" } else { "s" };
+ let takes: Vec<&str> = props.keys().map(String::as_str).collect();
+ let takes = if takes.is_empty() { "none".to_string() } else { takes.join(", ") };
+ return Err(format!("unknown argument{s} {} (it takes {takes})", unknown.join(", ")));
+ }
+ for (k, v) in given {
+ if let Some(inner) = props.get(k) {
+ refuse_unknown_args(inner, v, &format!("{path}{k}."))?;
+ }
+ }
+ }
+ if let (Some(items), Some(list)) = (schema.get("items"), args.as_array()) {
+ for (i, v) in list.iter().enumerate() {
+ refuse_unknown_args(items, v, &format!("{}[{i}].", path.trim_end_matches('.')))?;
+ }
+ }
+ Ok(())
+}
+
/// Run one tool. Every failure becomes an `isError` result (never a panic).
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
let a = match args_obj(args) {
Ok(a) => a,
Err(e) => return ToolResult::error(e),
};
+ // Built once: the catalogue is the same on every call.
+ static TOOLS: std::sync::LazyLock> = std::sync::LazyLock::new(tool_definitions);
+ if let Some(schema) = TOOLS.iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name)).and_then(|t| t.get("inputSchema"))
+ && let Err(e) = refuse_unknown_args(schema, &Value::Object(a.clone()), "")
+ {
+ return ToolResult::error(e);
+ }
let r = match name {
"list_commands" => list_commands(b, &a),
"run_command" => run_command(b, &a),
@@ -1013,6 +1051,9 @@ fn open_file(b: &mut dyn Backend, a: &Map) -> Result params["variables"] = v.clone(),
Some(_) => return Err("`variables` must be a path or the local variables JSON object".into()),
}
+ if let Some(reload) = a.get("reload").filter(|v| !v.is_null()) {
+ params["reload"] = reload.clone();
+ }
let r = exec(b, "file.open", params)?;
let doc = b.call("document.inspect", json!({"depth": 1}))?;
Ok(ToolResult::json(&json!({"opened": r, "document": doc})))
diff --git a/crates/mcp/tests/it/agent_tasks.rs b/crates/mcp/tests/it/agent_tasks.rs
index ae576e12..4bb2f8af 100644
--- a/crates/mcp/tests/it/agent_tasks.rs
+++ b/crates/mcp/tests/it/agent_tasks.rs
@@ -236,6 +236,10 @@ fn task_export_png_and_svg() {
c.ok("save_file", json!({"path": file.to_string_lossy()}));
let opened = c.ok("open_file", json!({"path": file.to_string_lossy()}));
assert!(opened["document"]["pages"][0]["children"].to_string().contains("Banner"));
+ // The tab is that file: `reload` reads it again from disk (#997).
+ let reread = c.ok("open_file", json!({"path": file.to_string_lossy(), "reload": true}));
+ assert_eq!(reread["opened"]["reloaded"], true, "{reread}");
+ assert!(reread["document"]["pages"][0]["children"].to_string().contains("Banner"));
std::fs::remove_dir_all(&dir).ok();
}
@@ -307,6 +311,32 @@ fn task_vector_edit_keys() {
assert!(c.call("run_command", json!({"command": "vector.edit.state", "params": {}}))["isError"] == true, "Enter left edit mode");
}
+#[test]
+fn task_node_find_without_selecting_then_use_ids_in_batch() {
+ let mut c = Client::new();
+ let card = c.create(json!({"type": "frame", "name": "Card"}));
+ let title = c.create(json!({"type": "text", "parent": card, "name": "Title", "text": "Hello"}));
+ c.ok("select", json!({"ids": [card]}));
+ let before = c.doc();
+ assert_eq!(c.run("node.find", json!({"root": card, "type": "text", "text": "HELLO"})), json!({"ids": [title], "total": 1}));
+ let after = c.doc();
+ for key in ["selection", "currentPage", "history"] {
+ assert_eq!(after[key], before[key], "{key}");
+ }
+ let out = c.ok(
+ "batch",
+ json!({"commands": [
+ {"command": "node.find", "params": {"root": card, "name": "title"}},
+ {"command": "node.set", "params": {"ids": [{"$step": 0, "field": "ids/0"}], "props": {"name": "Found title"}}}
+ ]}),
+ );
+ assert_eq!(out[0], json!({"ids": [title], "total": 1}));
+ assert_eq!(c.node(&title)["name"], "Found title");
+ assert_eq!(c.doc()["selection"], before["selection"]);
+ c.run("edit.undo", json!({}));
+ assert_eq!(c.node(&title)["name"], "Title");
+}
+
/// A whole card in one `batch` call: later steps reach the frames earlier steps created through
/// `{"$step": n}`, so the tree is built in one round-trip and one undo step.
#[test]
diff --git a/crates/mcp/tests/it/protocol.rs b/crates/mcp/tests/it/protocol.rs
index b4396c36..4dc2672e 100644
--- a/crates/mcp/tests/it/protocol.rs
+++ b/crates/mcp/tests/it/protocol.rs
@@ -83,6 +83,13 @@ fn tools_call_happy_and_error_paths() {
assert!(c.err("pointer", json!({"x": 1})).contains("`y`"));
assert!(c.err("type_text", json!({"text": "hi"})).contains("not editing text"));
assert!(c.err("open_file", json!({"path": "/definitely/not/here.figcraft"})).contains("here.figcraft"));
+ // Names a tool or a command doesn't take are refused, not ignored (#1413).
+ let e = c.err("run_command", json!({"command": "inspect.code", "args": {"id": id, "language": "html"}}));
+ assert!(e.contains("unknown argument `args` (it takes command, params)"), "{e}");
+ let e = c.err("batch", json!({"commands": [{"command": "comment.list", "args": {"filter": "open"}}]}));
+ assert!(e.contains("unknown argument `commands[0].args`"), "{e}");
+ let e = c.err("run_command", json!({"command": "comment.list", "params": {"filterr": "open"}}));
+ assert!(e.contains("unknown parameter `filterr`"), "{e}");
// Protocol-level errors.
let r = c.rpc("tools/call", json!({"name": "no_such_tool", "arguments": {}}));
assert_eq!(r["error"]["code"], -32602);
@@ -92,6 +99,10 @@ fn tools_call_happy_and_error_paths() {
assert_eq!(r["error"]["code"], -32602);
// The document is unharmed by all of that.
assert_eq!(c.page_children().len(), 1);
+ // Arguments the tools pass on are in their schemas, so they still go through.
+ c.ok("create_node", json!({"type": "vector", "points": [[0, 0], [10, 0], [5, 8]], "closed": true, "select": false}));
+ c.ok("key", json!({"key": "Escape", "ctrl": false}));
+ c.ok("pointer", json!({"kind": "hover", "x": 1, "y": 1, "ctrl": false}));
}
#[test]
diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs
index 23528e25..5f86f02b 100644
--- a/crates/pathops/src/lib.rs
+++ b/crates/pathops/src/lib.rs
@@ -95,6 +95,13 @@ pub fn simplify(path: &BezPath) -> Result {
binary(path, &BezPath::new(), linesweeper::BinaryOp::Union)
}
+/// Normalize one path using its fill rule. Unlike [`faces`], this emits every contour without a
+/// face-count cap, so render silhouettes preserve large imported networks.
+pub fn simplify_with_rule(path: &BezPath, even_odd: bool) -> Result {
+ let rule = if even_odd { linesweeper::FillRule::EvenOdd } else { linesweeper::FillRule::NonZero };
+ binary_by(path, &BezPath::new(), rule, linesweeper::BinaryOp::Union)
+}
+
/// Combine `acc` (the operands so far) with the next operand `p` by `op`.
pub fn boolean_step(acc: &BezPath, p: &BezPath, op: BoolOp) -> Result {
let op = match op {
diff --git a/crates/pathops/src/ops_tests.rs b/crates/pathops/src/ops_tests.rs
index f3b474f2..c982fbf3 100644
--- a/crates/pathops/src/ops_tests.rs
+++ b/crates/pathops/src/ops_tests.rs
@@ -2,10 +2,10 @@
//! agreement with a uniform operation, and single steps.
use figcraft_doc::BoolOp;
-use figcraft_geom::Rect;
use figcraft_geom::kurbo::{BezPath, Shape};
+use figcraft_geom::{Point, Rect};
-use crate::{boolean, boolean_ops, boolean_step};
+use crate::{boolean, boolean_ops, boolean_step, simplify_with_rule};
fn sq(x: f64, y: f64, s: f64) -> BezPath {
Rect::new(x, y, x + s, y + s).to_path(0.1)
@@ -81,3 +81,20 @@ fn single_steps_and_edge_cases() {
bad.line_to((1.0, 1.0));
assert!(boolean_ops([(&a, BoolOp::Union), (&bad, BoolOp::Intersect)]).is_err());
}
+
+#[test]
+fn fill_rule_normalization_keeps_even_odd_holes_and_more_than_4096_contours() {
+ let mut ring = sq(0.0, 0.0, 20.0);
+ ring.extend(sq(5.0, 5.0, 10.0).elements().iter().copied());
+ let ring = simplify_with_rule(&ring, true).unwrap();
+ assert!(ring.winding(Point::new(2.0, 2.0)) != 0);
+ assert_eq!(ring.winding(Point::new(10.0, 10.0)), 0, "same-direction inner contour remains a hole");
+
+ let mut many = BezPath::new();
+ for i in 0..4097 {
+ many.extend(sq(i as f64 * 2.0, 0.0, 1.0).elements().iter().copied());
+ }
+ let all = simplify_with_rule(&many, false).unwrap();
+ assert!(all.winding(Point::new(8192.5, 0.5)) != 0, "last contour survives beyond the old 4096-face cap");
+ assert!((area(&all) - 4097.0).abs() < 1e-6);
+}
diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs
index 95305390..957b30d3 100644
--- a/crates/pdf/src/draw.rs
+++ b/crates/pdf/src/draw.rs
@@ -329,8 +329,33 @@ impl<'a> Exporter<'a> {
if let Some((path, r)) = &outline
&& closed
{
- for p in &n.fills {
- self.fill(s, path, rule(*r), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
+ if let NodeKind::Vector { network } = &n.kind
+ && network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())
+ {
+ let rounded = network.rounded();
+ let net = rounded.as_ref().unwrap_or(network);
+ for (i, region) in net.regions.iter().enumerate() {
+ let paints = region.fills.as_deref().unwrap_or(&n.fills);
+ if let Some(mut region_path) = net.region_path(i) {
+ let (pw, ph, frame) = region.paint_frame(w, h);
+ region_path.apply_affine(frame.inverse());
+ let fill_rule =
+ if region.winding == figcraft_doc::WindingRule::EvenOdd { FillRule::EvenOdd } else { FillRule::NonZero };
+ for p in paints {
+ self.fill(
+ s,
+ ®ion_path,
+ fill_rule,
+ p,
+ PaintFrame { size: PaintSize { width: pw, height: ph }, transform: xf * frame },
+ );
+ }
+ }
+ }
+ } else {
+ for p in &n.fills {
+ self.fill(s, path, rule(*r), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
+ }
}
}
self.place_inner(s, inner);
diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs
index a696e279..cdd85d6b 100644
--- a/crates/pdf/src/tests.rs
+++ b/crates/pdf/src/tests.rs
@@ -3,7 +3,7 @@ use std::sync::Arc;
use super::*;
use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImageScaleMode, Paint};
-use figcraft_doc::{BoolOp, Effect, EffectKind, ImageData, NodeKind, StrokeAlign, TextData};
+use figcraft_doc::{BoolOp, Effect, EffectKind, ImageData, NodeKind, Region, Segment, StrokeAlign, TextData, VectorNetwork, Vertex, WindingRule};
pub(crate) fn page_count(pdf: &[u8]) -> usize {
let s = String::from_utf8_lossy(pdf);
@@ -51,6 +51,37 @@ pub(crate) fn has_inter() -> bool {
figcraft_text::find_face("Inter", "Regular").is_some()
}
+#[test]
+fn pdf_vector_region_paints_emit_independent_colors() {
+ let mut d = Document::new("regions");
+ let p = d.first_page().unwrap();
+ let network = VectorNetwork {
+ vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)]
+ .into_iter()
+ .map(|(x, y)| Vertex::new(x, y))
+ .collect(),
+ segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(),
+ regions: vec![
+ Region {
+ loops: vec![vec![0, 1, 2, 3]],
+ winding: WindingRule::NonZero,
+ fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
+ ..Default::default()
+ },
+ Region {
+ loops: vec![vec![4, 5, 6, 7]],
+ winding: WindingRule::NonZero,
+ fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
+ ..Default::default()
+ },
+ Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
+ ],
+ };
+ let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 20.0, 10.0).unwrap();
+ let content = inflated(&export_pdf(&d, &[id], &PdfOptions::default()).unwrap());
+ assert!(content.contains("1 0 0 rg") && content.contains("0 0 1 rg"), "{content}");
+}
+
/// A page with two frames: shapes, gradients, an image, strokes, a mask, a boolean, effects, text.
pub(crate) fn sample_doc() -> (Document, Vec) {
let mut d = Document::new("sample");
diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs
index fa0f98ba..3ff68026 100644
--- a/crates/render/src/draw.rs
+++ b/crates/render/src/draw.rs
@@ -326,6 +326,27 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
Some((p, FillRule::Winding))
}
NodeKind::Vector { network } => {
+ if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) {
+ let rounded = network.rounded();
+ let net = rounded.as_ref().unwrap_or(network);
+ let mut areas = BezPath::new();
+ for (i, region) in net.regions.iter().enumerate() {
+ let paints = region.fills.as_deref().unwrap_or(&n.fills);
+ if !paints.iter().any(|p| p.visible) {
+ continue;
+ }
+ let Some(path) = net.region_path(i) else { continue };
+ let even_odd = region.winding == figcraft_doc::WindingRule::EvenOdd;
+ if let Ok(normalized) = figcraft_pathops::simplify_with_rule(&path, even_odd) {
+ areas.extend(normalized.elements().iter().copied());
+ } else {
+ // Invalid geometry still gets the renderer's best-effort path.
+ areas.extend(path.elements().iter().copied());
+ }
+ }
+ let silhouette = figcraft_pathops::simplify(&areas).unwrap_or(areas);
+ return Some((silhouette, FillRule::Winding));
+ }
let rule =
if network.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd) { FillRule::EvenOdd } else { FillRule::Winding };
Some((network.fill_path(), rule))
@@ -702,6 +723,31 @@ fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
/// What paints over a node's fills, under its children and strokes (its inner shadows).
type OverFills<'a> = Option<&'a dyn Fn(&mut Pixmap)>;
+/// A vector's network when any of its regions has paints or a paint frame of its own.
+fn region_painted(n: &Node) -> Option<&figcraft_doc::VectorNetwork> {
+ match &n.kind {
+ NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => Some(network),
+ _ => None,
+ }
+}
+
+/// Each region of a vector with region paints, filled with its own paints (or the layer's) in its
+/// own paint frame.
+fn fill_vector_regions(ctx: &Ctx, pix: &mut Pixmap, n: &Node, network: &figcraft_doc::VectorNetwork, xf: Affine) {
+ let rounded = network.rounded();
+ let net = rounded.as_ref().unwrap_or(network);
+ for (i, region) in net.regions.iter().enumerate() {
+ let paints = region.fills.as_deref().unwrap_or(&n.fills);
+ if let Some(mut path) = net.region_path(i) {
+ let (pw, ph, frame) = region.paint_frame(n.width, n.height);
+ path.apply_affine(frame.inverse());
+ let Some(tp) = ts_path(&path) else { continue };
+ let rule = if region.winding == figcraft_doc::WindingRule::EvenOdd { FillRule::EvenOdd } else { FillRule::Winding };
+ fill_with(pix, &tp, paints, pw, ph, xf * frame, rule, ctx, None);
+ }
+ }
+}
+
/// Fills, `over_fills`, children (clipped), strokes and text of a node, without opacity/effects.
fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, over_fills: OverFills) {
let doc = ctx.doc;
@@ -783,14 +829,18 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
let splatted = ctx.lod && closed && lod::splat_fills(pix, n, xf);
let clips = n.clip && n.kind.is_frame_like() && !n.children.is_empty();
+ let styled_network = region_painted(n);
// The outline fills, clips children to rounded corners and strokes booleans.
- let outline = if splatted && !(clips && n.corners.uniform() != Some(0.0)) { None } else { node_outline(doc, n) };
- if !splatted
- && let Some((path, rule)) = &outline
- && closed
- && let Some(tp) = ts_path(path)
- {
- fill_with(pix, &tp, &n.fills, w, h, xf, *rule, ctx, None);
+ let outline =
+ if styled_network.is_some() || (splatted && !(clips && n.corners.uniform() != Some(0.0))) { None } else { node_outline(doc, n) };
+ if !splatted && closed {
+ if let Some(network) = styled_network {
+ fill_vector_regions(ctx, pix, n, network, xf);
+ } else if let Some((path, rule)) = &outline
+ && let Some(tp) = ts_path(path)
+ {
+ fill_with(pix, &tp, &n.fills, w, h, xf, *rule, ctx, None);
+ }
}
if let Some(f) = over_fills {
f(pix);
diff --git a/crates/render/src/lod.rs b/crates/render/src/lod.rs
index a5b3ddc1..e330568f 100644
--- a/crates/render/src/lod.rs
+++ b/crates/render/src/lod.rs
@@ -77,6 +77,9 @@ pub(crate) fn splattable_within<'a>(doc: &'a Document, n: &Node, depth: usize, m
{
return (ok, Some(levels));
}
+ if matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())) {
+ return (false, Some(0));
+ }
let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_));
let own = match &n.kind {
NodeKind::Text(t) => text_paints(n, t).any(image),
diff --git a/crates/render/src/lod_tests.rs b/crates/render/src/lod_tests.rs
index 3c35c3c3..f2c83dba 100644
--- a/crates/render/src/lod_tests.rs
+++ b/crates/render/src/lod_tests.rs
@@ -5,7 +5,7 @@
use std::sync::{Arc, Once};
use figcraft_color::{Color, ImageScaleMode, Paint};
-use figcraft_doc::{Document, ImageData, NodeId, NodeKind, TextData};
+use figcraft_doc::{Document, ImageData, NodeId, NodeKind, Region, TextData, VectorNetwork};
use figcraft_geom::{Affine, Rect};
use crate::retained_tests::assert_same;
@@ -110,6 +110,19 @@ fn lod_is_off_unless_asked_for() {
assert!(!Options::default().lod);
}
+#[test]
+fn region_paints_disable_vector_and_ancestor_splatting() {
+ let mut d = Document::new("region LOD");
+ let page = d.first_page().unwrap();
+ let parent = d.create(page, NodeKind::Frame, "Parent", 0.0, 0.0, 4.0, 4.0).unwrap();
+ let mut network = VectorNetwork::default();
+ network.regions.push(Region { loops: Vec::new(), winding: Default::default(), fills: Some(Vec::new()), ..Default::default() });
+ let vector = d.create(parent, NodeKind::Vector { network }, "Styled", 0.0, 0.0, 2.0, 2.0).unwrap();
+ let node = d.node(vector).unwrap();
+ assert!(!lod::splattable(&d, node, 0));
+ assert!(!lod::splattable(&d, d.node(parent).unwrap(), 0));
+}
+
#[test]
fn zoomed_out_previews_are_close_to_exact() {
let (d, p) = cards();
diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs
index a5a59831..f53f132e 100644
--- a/crates/render/src/tests.rs
+++ b/crates/render/src/tests.rs
@@ -1,6 +1,6 @@
use super::*;
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint};
-use figcraft_doc::{Effect, EffectKind, NodeKind, Shadow, StrokeAlign, TextData};
+use figcraft_doc::{Effect, EffectKind, NodeKind, Region, Segment, Shadow, StrokeAlign, TextData, VectorNetwork, Vertex, WindingRule};
fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] {
let i = ((y * p.width() + x) * 4) as usize;
@@ -28,6 +28,177 @@ fn solid_rect_and_page_background() {
assert_eq!(px(&pix, 5, 5), [0xF5, 0xF5, 0xF5, 255]);
}
+#[test]
+fn vector_regions_use_their_own_paints_and_empty_regions_do_not_erase() {
+ let mut d = figcraft_doc::Document::new("regions");
+ let page = d.first_page().unwrap();
+ let network = VectorNetwork {
+ vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)]
+ .into_iter()
+ .map(|(x, y)| Vertex::new(x, y))
+ .collect(),
+ segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(),
+ regions: vec![
+ Region {
+ loops: vec![vec![0, 1, 2, 3]],
+ winding: WindingRule::NonZero,
+ fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
+ ..Default::default()
+ },
+ Region {
+ loops: vec![vec![4, 5, 6, 7]],
+ winding: WindingRule::NonZero,
+ fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
+ ..Default::default()
+ },
+ Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(vec![]), ..Default::default() },
+ ],
+ };
+ let id = d.create(page, NodeKind::Vector { network }, "V", 10.0, 10.0, 20.0, 10.0).unwrap();
+ d.get_mut(id).unwrap().fills.clear();
+ let pix = render_view(&d, page, &view(40, 30), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
+ assert_eq!(px(&pix, 15, 15), [255, 0, 0, 255]);
+ assert_eq!(px(&pix, 25, 15), [0, 0, 255, 255]);
+ assert_eq!(d.get(id).unwrap().fills.len(), 0);
+}
+
+#[test]
+fn region_paint_space_preserves_gradient_image_and_pattern_placement() {
+ use figcraft_color::{ImageScaleMode, PaintKind, PatternPaint};
+ use figcraft_doc::RegionPaintSpace;
+ use figcraft_geom::{Affine, Rect, Shape};
+
+ let mut d = Document::new("paint frames");
+ let page = d.first_page().unwrap();
+ let tile = d.create(page, NodeKind::Rectangle, "Tile", 500.0, 500.0, 8.0, 6.0).unwrap();
+ d.get_mut(tile).unwrap().fills = vec![Paint::solid(Color::from_hex("F05020").unwrap())];
+ let mut image = Pixmap::new(8, 4).unwrap();
+ for (i, pixel) in image.pixels_mut().iter_mut().enumerate() {
+ *pixel = tiny_skia::PremultipliedColorU8::from_rgba((i % 8 * 30) as u8, (i / 8 * 60) as u8, 30, 255).unwrap();
+ }
+ d.images.insert(
+ "region-frame-image".into(),
+ figcraft_doc::ImageData { bytes: std::sync::Arc::new(encode_png(&image).unwrap()), width: 8, height: 4 },
+ );
+ let mut pattern = PatternPaint::new(tile.0);
+ pattern.spacing = (3.0, 4.0);
+ let paints = [
+ Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK)),
+ Paint::image("region-frame-image", ImageScaleMode::Fill),
+ Paint::image("region-frame-image", ImageScaleMode::Tile),
+ Paint { kind: PaintKind::Pattern(pattern), ..Paint::solid(Color::WHITE) },
+ ];
+ let frame = Affine::translate((35.0, 25.0)) * Affine::rotate(0.3);
+ let id = d.create(page, NodeKind::Rectangle, "Source", 0.0, 0.0, 60.0, 30.0).unwrap();
+ for paint in paints {
+ let n = d.get_mut(id).unwrap();
+ n.kind = NodeKind::Rectangle;
+ n.transform = frame;
+ n.width = 60.0;
+ n.height = 30.0;
+ n.fills = vec![paint.clone()];
+ let before = render_view(&d, page, &view(120, 100), &Options::default()).unwrap();
+ let mut path = Rect::new(0.0, 0.0, 60.0, 30.0).to_path(0.1);
+ path.apply_affine(frame);
+ let mut network = VectorNetwork::from_path(&path);
+ for region in &mut network.regions {
+ region.fills = Some(vec![paint.clone()]);
+ region.paint_space = Some(RegionPaintSpace { width: 60.0, height: 30.0, transform: frame });
+ }
+ let n = d.get_mut(id).unwrap();
+ n.kind = NodeKind::Vector { network };
+ n.transform = Affine::IDENTITY;
+ n.width = 120.0;
+ n.height = 100.0;
+ n.fills.clear();
+ let after = render_view(&d, page, &view(120, 100), &Options::default()).unwrap();
+ for (a, b) in before.data().iter().zip(after.data()) {
+ assert!(a.abs_diff(*b) <= 1, "paint moved after reboxing: {:?}", paint.kind);
+ }
+ }
+}
+
+#[test]
+fn styled_vector_mask_unions_region_windings_and_excludes_unpainted_regions() {
+ let mut d = figcraft_doc::Document::new("region mask");
+ let page = d.first_page().unwrap();
+ let group = d.create(page, NodeKind::Group, "Mask group", 0.0, 0.0, 60.0, 20.0).unwrap();
+ let mut network = VectorNetwork::default();
+ let mut regions = Vec::new();
+ for x in [0.0, 10.0, 30.0, 40.0, 50.0] {
+ let v = network.vertices.len();
+ network.vertices.extend([(x, 0.0), (x + 20.0, 0.0), (x + 20.0, 20.0), (x, 20.0)].map(|(x, y)| Vertex::new(x, y)));
+ let s = network.segments.len();
+ network.segments.extend((0..4).map(|i| Segment::line(v + i, v + (i + 1) % 4)));
+ let fills = match regions.len() {
+ 0 => Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
+ 1 => Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
+ 2 => Some(vec![Paint { visible: false, ..Paint::solid(Color::from_hex("00FF00").unwrap()) }]),
+ 3 => None,
+ _ => Some(Vec::new()),
+ };
+ regions.push(Region {
+ loops: vec![(s..s + 4).collect()],
+ winding: if regions.len() == 1 { WindingRule::EvenOdd } else { WindingRule::NonZero },
+ fills,
+ ..Default::default()
+ });
+ }
+ network.regions = regions;
+ let mask = d.create(group, NodeKind::Vector { network }, "Mask", 0.0, 0.0, 60.0, 20.0).unwrap();
+ {
+ let n = d.get_mut(mask).unwrap();
+ n.fills.clear();
+ n.is_mask = true;
+ n.mask_type = figcraft_doc::MaskType::Vector;
+ }
+ let content = d.create(group, NodeKind::Rectangle, "Content", 0.0, 0.0, 60.0, 20.0).unwrap();
+ d.get_mut(content).unwrap().fills = vec![Paint::solid(Color::BLACK)];
+ let pix = render_view(&d, page, &view(60, 20), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
+ assert_eq!(px(&pix, 5, 10), [0, 0, 0, 255], "visible left region masks");
+ assert_eq!(px(&pix, 15, 10), [0, 0, 0, 255], "overlap remains in union despite mixed winding");
+ assert_eq!(px(&pix, 35, 10), [255, 255, 255, 255], "hidden region excluded from styled silhouette");
+ assert_eq!(px(&pix, 45, 10), [255, 255, 255, 255], "unpainted inherited region excluded");
+ assert_eq!(px(&pix, 55, 10), [255, 255, 255, 255], "explicit-empty region excluded");
+}
+
+#[test]
+fn inherited_paint_frames_keep_overlapping_regions_in_vector_masks() {
+ use figcraft_geom::{Affine, Point, Shape};
+
+ let mut d = Document::new("inherited region mask");
+ let page = d.first_page().unwrap();
+ let group = d.create(page, NodeKind::Group, "Mask group", 0.0, 0.0, 40.0, 20.0).unwrap();
+ let mut network = VectorNetwork::default();
+ for x in [0.0, 10.0] {
+ let v = network.vertices.len();
+ let s = network.segments.len();
+ network.vertices.extend([(x, 0.0), (x + 20.0, 0.0), (x + 20.0, 20.0), (x, 20.0)].map(|(x, y)| Vertex::new(x, y)));
+ network.segments.extend((0..4).map(|i| Segment::line(v + i, v + (i + 1) % 4)));
+ network.regions.push(Region {
+ loops: vec![(s..s + 4).collect()],
+ winding: WindingRule::EvenOdd,
+ fills: None,
+ paint_space: Some(figcraft_doc::RegionPaintSpace { width: 20.0, height: 20.0, transform: Affine::translate((x, 0.0)) }),
+ });
+ }
+ let mask = d.create(group, NodeKind::Vector { network }, "Mask", 0.0, 0.0, 30.0, 20.0).unwrap();
+ let n = d.get_mut(mask).unwrap();
+ n.fills = vec![Paint::solid(Color::BLACK)];
+ n.is_mask = true;
+ n.mask_type = figcraft_doc::MaskType::Vector;
+ let (outline, rule) = node_outline(&d, d.node(mask).unwrap()).unwrap();
+ assert_eq!(rule, FillRule::Winding);
+ assert_ne!(outline.winding(Point::new(15.0, 10.0)), 0, "independent inherited regions retain their overlap");
+ let content = d.create(group, NodeKind::Rectangle, "Content", 0.0, 0.0, 40.0, 20.0).unwrap();
+ d.get_mut(content).unwrap().fills = vec![Paint::solid(Color::BLACK)];
+ let pix = render_view(&d, page, &view(40, 20), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
+ for x in [5, 15, 25] {
+ assert_eq!(px(&pix, x, 10), [0, 0, 0, 255]);
+ }
+ assert_eq!(px(&pix, 35, 10), [255, 255, 255, 255]);
+}
+
#[test]
fn zoom_scales() {
let (d, p, _) = doc_with_rect(Color::BLACK);
diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs
index d2a6c46e..b651f859 100644
--- a/crates/svg/src/import/text.rs
+++ b/crates/svg/src/import/text.rs
@@ -28,8 +28,11 @@ impl Fonts {
fn load() -> Self {
Fonts(figcraft_text::fontdb::family_names().into_iter().map(|f| f.to_lowercase()).collect())
}
+ /// Installed under this name, or under the family it names (`system-ui` on a Mac is its
+ /// System Font, see `fontdb::apple_alias`).
fn has(&self, family: &str) -> bool {
- self.0.contains(&family.to_lowercase())
+ let key = family.to_lowercase();
+ self.0.contains(&key) || figcraft_text::fontdb::apple_alias(&key).is_some_and(|target| self.0.contains(target))
}
}
@@ -67,8 +70,9 @@ fn generic(name: &str) -> Option<&'static [&'static str]> {
})
}
-/// The family to use: the first the SVG lists that's installed, else the first it names, else the
-/// default.
+/// The family to use: the first the SVG lists that's installed (a generic family as the first of
+/// its installed stand-ins, unless a font answers to its name: `system-ui` on a Mac), else the
+/// first it names, else the default.
fn family(list: &[FontFamily], fonts: &Fonts) -> String {
let name = |f: &FontFamily| match f {
FontFamily::Serif => "serif".to_string(),
@@ -80,14 +84,11 @@ fn family(list: &[FontFamily], fonts: &Fonts) -> String {
};
for f in list {
let n = name(f);
- match generic(&n) {
- Some(cands) => {
- if let Some(c) = cands.iter().find(|c| fonts.has(c)) {
- return (*c).to_string();
- }
- }
- None if fonts.has(&n) => return n,
- None => {}
+ if fonts.has(&n) {
+ return n;
+ }
+ if let Some(c) = generic(&n).and_then(|cands| cands.iter().find(|c| fonts.has(c))) {
+ return (*c).to_string();
}
}
list.iter().map(name).find(|n| generic(n).is_none() && !n.is_empty()).unwrap_or_else(|| TextStyle::default().font_family)
@@ -343,3 +344,7 @@ fn add_layer(cx: &mut Ctx, layer: TextLayer<'_, '_, '_>) {
}
cx.add(n, parent);
}
+
+#[cfg(test)]
+#[path = "text_tests.rs"]
+mod tests;
diff --git a/crates/svg/src/import/text_tests.rs b/crates/svg/src/import/text_tests.rs
new file mode 100644
index 00000000..f267b294
--- /dev/null
+++ b/crates/svg/src/import/text_tests.rs
@@ -0,0 +1,32 @@
+//! Which family an SVG's `font-family` list gives a text layer.
+
+use usvg::FontFamily;
+
+use super::{Fonts, family};
+
+fn fonts(installed: &[&str]) -> Fonts {
+ Fonts(installed.iter().map(|f| f.to_lowercase()).collect())
+}
+
+fn named(list: &[&str]) -> Vec {
+ list.iter()
+ .map(|n| match *n {
+ "sans-serif" => FontFamily::SansSerif,
+ n => FontFamily::Named(n.to_string()),
+ })
+ .collect()
+}
+
+/// The names CSS gives the system font keep their name on a Mac, where they draw in its System
+/// Font, and fall back as before without it.
+#[test]
+fn css_system_font_names_keep_their_name_where_the_system_font_is() {
+ let mac = fonts(&["System Font", "Inter", "Helvetica"]);
+ assert_eq!(family(&named(&["-apple-system", "BlinkMacSystemFont", "system-ui", "sans-serif"]), &mac), "-apple-system");
+ assert_eq!(family(&named(&["BlinkMacSystemFont", "sans-serif"]), &mac), "BlinkMacSystemFont");
+ assert_eq!(family(&named(&["system-ui", "sans-serif"]), &mac), "system-ui");
+ assert_eq!(family(&named(&["Helvetica", "system-ui"]), &mac), "Helvetica", "an installed family the SVG lists first still wins");
+ let elsewhere = fonts(&["Inter", "DejaVu Sans"]);
+ assert_eq!(family(&named(&["-apple-system", "BlinkMacSystemFont", "system-ui", "sans-serif"]), &elsewhere), "Inter");
+ assert_eq!(family(&named(&["-apple-system"]), &elsewhere), "-apple-system", "nothing installed: the first name it gives");
+}
diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs
index 25175d28..e85bf3bf 100644
--- a/crates/svg/src/lib.rs
+++ b/crates/svg/src/lib.rs
@@ -494,11 +494,32 @@ impl W<'_> {
} else if let Some((path, rule)) = &outline
&& closed
{
- let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
- for p in &n.fills {
- if let Some(a) = self.paint_attr(p, w, h) {
- let el = format!(" ", matrix(xf), path_d(path));
- self.body.push_str(&clipped(&paint_clip, el));
+ if let NodeKind::Vector { network } = &n.kind
+ && network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())
+ {
+ let rounded = network.rounded();
+ let net = rounded.as_ref().unwrap_or(network);
+ for (i, region) in net.regions.iter().enumerate() {
+ let paints = region.fills.as_deref().unwrap_or(&n.fills);
+ let fr = if region.winding == figcraft_doc::WindingRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
+ if let Some(mut region_path) = net.region_path(i) {
+ let (pw, ph, frame) = region.paint_frame(w, h);
+ region_path.apply_affine(frame.inverse());
+ for p in paints {
+ if let Some(a) = self.paint_attr(p, pw, ph) {
+ let el = format!(" ", matrix(xf * frame), path_d(®ion_path));
+ self.body.push_str(&clipped(&paint_clip, el));
+ }
+ }
+ }
+ }
+ } else {
+ let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
+ for p in &n.fills {
+ if let Some(a) = self.paint_attr(p, w, h) {
+ let el = format!(" ", matrix(xf), path_d(path));
+ self.body.push_str(&clipped(&paint_clip, el));
+ }
}
}
}
diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs
index bfce1cf4..523e1de0 100644
--- a/crates/svg/src/tests.rs
+++ b/crates/svg/src/tests.rs
@@ -1,6 +1,6 @@
use super::*;
use figcraft_color::{Color, Paint};
-use figcraft_doc::{Document, Effect, NodeKind};
+use figcraft_doc::{Document, Effect, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
#[test]
fn export_basic_shapes() {
@@ -23,6 +23,38 @@ fn export_basic_shapes() {
assert!(d2.validate().is_empty());
}
+#[test]
+fn export_vector_region_paints_independently() {
+ let mut d = Document::new("regions");
+ let p = d.first_page().unwrap();
+ let network = VectorNetwork {
+ vertices: [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (10.0, 10.0)]
+ .into_iter()
+ .map(|(x, y)| Vertex::new(x, y))
+ .collect(),
+ segments: (0..8).map(|i| Segment::line(i, (i + 1) % 4 + (i / 4) * 4)).collect(),
+ regions: vec![
+ Region {
+ loops: vec![vec![0, 1, 2, 3]],
+ winding: WindingRule::NonZero,
+ fills: Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]),
+ ..Default::default()
+ },
+ Region {
+ loops: vec![vec![4, 5, 6, 7]],
+ winding: WindingRule::NonZero,
+ fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]),
+ ..Default::default()
+ },
+ Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
+ ],
+ };
+ let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 20.0, 10.0).unwrap();
+ let svg = export_svg(&d, &[id], &SvgOptions::default()).unwrap();
+ assert!(svg.contains("#FF0000") && svg.contains("#0000FF"), "{svg}");
+ assert_eq!(svg.matches("
diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs
index ced18c81..13987171 100644
--- a/crates/text/src/fontdb.rs
+++ b/crates/text/src/fontdb.rs
@@ -1069,12 +1069,12 @@ fn public_family(family: &str) -> Option {
/// The family Apple's system fonts are installed under, for the names designs use for them
/// (lowercase): SF Pro and SF UI are the variable `SFNS.ttf` ("System Font"), its text and display
-/// cuts its optical sizes.
-fn apple_alias(key: &str) -> Option<&'static str> {
+/// cuts its optical sizes, as are the names CSS gives the system font (`system-ui`,
+/// `-apple-system`, `BlinkMacSystemFont`, `.AppleSystemUIFont`).
+pub fn apple_alias(key: &str) -> Option<&'static str> {
Some(match key.trim_start_matches('.') {
- "sf pro" | "sf pro text" | "sf pro display" | "sf ui text" | "sf ui display" | "sf ns" | "sf ns text" | "sf ns display" | "san francisco" => {
- "system font"
- }
+ "sf pro" | "sf pro text" | "sf pro display" | "sf ui text" | "sf ui display" | "sf ns" | "sf ns text" | "sf ns display" | "san francisco"
+ | "system-ui" | "-apple-system" | "blinkmacsystemfont" | "applesystemuifont" => "system font",
"sf compact text" | "sf compact display" => "sf compact",
_ => return None,
})
diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs
index 85d1f3a7..35b9435b 100644
--- a/crates/text/src/fontdb_tests.rs
+++ b/crates/text/src/fontdb_tests.rs
@@ -340,8 +340,8 @@ fn font_bytes_load_into_the_db() {
}
}
-/// Apple's system font answers to the names designs use for it (SF Pro, SF UI Text, …), unless a
-/// font of that name is installed.
+/// Apple's system font answers to the names designs and CSS use for it (SF Pro, SF UI Text,
+/// system-ui, …), unless a font of that name is installed.
#[test]
fn apple_system_font_names_find_the_system_font() {
let mut db = FontDb::default();
@@ -352,7 +352,11 @@ fn apple_system_font_names_find_the_system_font() {
for name in ["SF Pro", "SF Pro Text", "SF Pro Display", "SF UI Text", "SF UI Display", ".SF NS Text", "San Francisco"] {
assert!(db.has_family(name), "{name}");
}
+ for name in ["system-ui", "-apple-system", "BlinkMacSystemFont", ".AppleSystemUIFont"] {
+ assert_eq!(db.key(name), "system font", "{name}");
+ }
assert!(!db.has_family("SF Mono"));
+ assert!(!db.has_family("ui-monospace"));
db.pending.insert("sf pro".into(), ("SF Pro".into(), Vec::new()));
assert_eq!(db.key("SF Pro"), "sf pro", "an installed SF Pro is itself");
assert_eq!(db.key("SF UI Text"), "system font");
diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml
index f252e114..a00004dc 100644
--- a/crates/ui-egui/locales/ar.toml
+++ b/crates/ui-egui/locales/ar.toml
@@ -258,7 +258,7 @@ status = "draft — needs native review"
"Changes on main" = "التغييرات في الملف الرئيسي"
"Changes on the branch" = "التغييرات في الفرع"
"Changes unknown" = "التغييرات غير معروفة"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "اختر React أو Leptos أو Vue أو SwiftUI أو Compose أو ArkUI أعلاه لربط هذا المكوّن بكوده."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "اختر React أو Leptos أو Vue أو SwiftUI أو Compose أو ArkUI أو Flutter أعلاه لربط هذا المكوّن بكوده."
"Choose another profile to change it." = "اختر ملف تعريف آخر لتغييره."
"Choose collection" = "اختيار مجموعة"
"Choose image" = "اختيار صورة"
@@ -1204,7 +1204,6 @@ status = "draft — needs native review"
"Resolve" = "حل"
"Resolve change" = "حل التغيير"
"Resolved" = "محلول"
-"Resources" = "الموارد"
"Responsive" = "متجاوب"
"Restart" = "إعادة البدء"
"Restore" = "استعادة"
@@ -1694,7 +1693,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "تُستورد الموصلات الزاوية والمنحنية التي ليس لها مسار محفوظ كخطوط مستقيمة"
"entries without a node type were skipped" = "تم تخطي الإدخالات التي ليس لها نوع عقدة"
"figcraft:// links from a browser, chat or email open this app and join the session." = "تفتح روابط figcraft:// من المتصفح أو الدردشة أو البريد هذا التطبيق وتنضم إلى الجلسة."
-"filled areas of a vector with their own fill use the layer's fill" = "تستخدم مناطق المتجه المعبأة ذات التعبئة الخاصة تعبئة الطبقة"
"from {server}" = "من {server}"
"gamepad triggers without exactly one known controller button were skipped" = "تم تخطي محفزات ذراع الألعاب التي لا تحدد زرًا معروفًا واحدًا بالضبط"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "تتجاوز قائمة مسارات الشبكة 1000 إدخال؛ تستخدم المسارات الأحجام الافتراضية المتساوية"
@@ -2130,6 +2128,7 @@ status = "draft — needs native review"
"measure.distances" = "قياس المسافات"
"measure.padding" = "قياس حشو التخطيط التلقائي"
"node.create" = "إنشاء طبقة"
+"node.find" = "بحث عن الطبقات"
"node.get" = "جلب طبقة"
"node.reorder" = "إعادة ترتيب الطبقة"
"node.reparent" = "نقل الطبقة إلى داخل"
diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml
index f9bdc180..b7d74e60 100644
--- a/crates/ui-egui/locales/bn.toml
+++ b/crates/ui-egui/locales/bn.toml
@@ -245,7 +245,7 @@ status = "draft — needs native review"
"Changes on main" = "প্রধান (main)-এ পরিবর্তন"
"Changes on the branch" = "ব্রাঞ্চে পরিবর্তন"
"Changes unknown" = "পরিবর্তন অজ্ঞাত"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, Vue, SwiftUI, Compose বা ArkUI বেছে নিন।"
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, Vue, SwiftUI, Compose, ArkUI বা Flutter বেছে নিন।"
"Choose another profile to change it." = "পরিবর্তন করতে অন্য একটি প্রোফাইল বেছে নিন।"
"Choose collection" = "সংগ্রহ (collection) বেছে নিন"
"Choose image" = "ছবি বেছে নিন"
@@ -1164,7 +1164,6 @@ status = "draft — needs native review"
"Resolve" = "সমাধান করুন"
"Resolve change" = "পরিবর্তন সমাধান করুন"
"Resolved" = "সমাধান করা হয়েছে"
-"Resources" = "রিসোর্সসমূহ"
"Responsive" = "রেসপনসিভ"
"Restart" = "পুনরায় শুরু করুন"
"Restore" = "পুনরুদ্ধার করুন"
@@ -1638,7 +1637,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "সংরক্ষিত পাথ ছাড়া এলবো এবং কার্ভড কানেক্টর সরল রেখা হিসেবে ইম্পোর্ট হয়"
"entries without a node type were skipped" = "নোটের ধরন ছাড়া এন্ট্রিগুলো বাদ দেওয়া হয়েছে"
"figcraft:// links from a browser, chat or email open this app and join the session." = "ব্রাউজার, চ্যাট বা ইমেল থেকে figcraft:// লিংকগুলো এই অ্যাপ খুলে সেশনে যুক্ত হয়।"
-"filled areas of a vector with their own fill use the layer's fill" = "ভেক্টরের নিজস্ব ফিলযুক্ত পূর্ণ এলাকাগুলো লেয়ারের ফিল ব্যবহার করে"
"from {server}" = "{server} থেকে"
"gamepad triggers without exactly one known controller button were skipped" = "সঠিকভাবে একটি পরিচিত কন্ট্রোলার বোতাম ছাড়া গেমপ্যাড ট্রিগার বাদ দেওয়া হয়েছে"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "গ্রিড ট্র্যাক তালিকা ১০০০ এন্ট্রি অতিক্রম করেছে; ট্র্যাকগুলো ডিফল্ট সমান সাইজিং ব্যবহার করে"
diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml
index f8f86df9..04b3001d 100644
--- a/crates/ui-egui/locales/cs.toml
+++ b/crates/ui-egui/locales/cs.toml
@@ -235,7 +235,7 @@ status = "draft — needs native review"
"Changes on main" = "Změny v hlavním souboru"
"Changes on the branch" = "Změny ve větvi"
"Changes unknown" = "Změny nejsou známy"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, Vue, SwiftUI, Compose nebo ArkUI."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, Vue, SwiftUI, Compose, ArkUI nebo Flutter."
"Choose another profile to change it." = "Pro změnu vyberte jiný profil."
"Choose collection" = "Vybrat kolekci"
"Choose image" = "Vybrat obrázek"
@@ -1142,7 +1142,6 @@ status = "draft — needs native review"
"Resolve" = "Vyřešit"
"Resolve change" = "Vyřešit změnu"
"Resolved" = "Vyřešené"
-"Resources" = "Zdroje"
"Responsive" = "Responzivní"
"Restart" = "Znovu spustit"
"Restore" = "Obnovit"
@@ -1604,7 +1603,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "lomené a zakřivené spojnice bez uložené cesty se importují jako přímky"
"entries without a node type were skipped" = "položky bez typu uzlu byly přeskočeny"
"figcraft:// links from a browser, chat or email open this app and join the session." = "Odkazy figcraft:// z prohlížeče, chatu nebo e-mailu otevřou tuto aplikaci a připojí k relaci."
-"filled areas of a vector with their own fill use the layer's fill" = "vyplněné oblasti vektoru s vlastní výplní používají výplň vrstvy"
"from {server}" = "ze serveru {server}"
"gamepad triggers without exactly one known controller button were skipped" = "spouštěče herního ovladače bez právě jednoho známého tlačítka ovladače byly přeskočeny"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "seznam stop mřížky přesahuje 1000 položek; stopy používají výchozí stejnou velikost"
@@ -2026,6 +2024,7 @@ status = "draft — needs native review"
"measure.distances" = "Měřit vzdálenosti"
"measure.padding" = "Měřit vnitřní odsazení automatického rozvržení"
"node.create" = "Vytvořit vrstvu"
+"node.find" = "Najít vrstvy"
"node.get" = "Získat vrstvu"
"node.reorder" = "Změnit pořadí vrstvy"
"node.reparent" = "Přesunout vrstvu do"
diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml
index 127900c9..fa5a02d8 100644
--- a/crates/ui-egui/locales/de.toml
+++ b/crates/ui-egui/locales/de.toml
@@ -230,7 +230,7 @@ status = "draft — needs native review"
"Changes on main" = "Änderungen in der Hauptdatei"
"Changes on the branch" = "Änderungen im Branch"
"Changes unknown" = "Änderungen unbekannt"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Wählen Sie oben React, Leptos, Vue, SwiftUI, Compose oder ArkUI, um diese Komponente mit ihrem Code zu verbinden."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Wählen Sie oben React, Leptos, Vue, SwiftUI, Compose, ArkUI oder Flutter, um diese Komponente mit ihrem Code zu verbinden."
"Choose another profile to change it." = "Wählen Sie ein anderes Profil, um es zu ändern."
"Choose collection" = "Sammlung wählen"
"Choose image" = "Bild wählen"
@@ -1112,7 +1112,6 @@ status = "draft — needs native review"
"Resolve" = "Erledigen"
"Resolve change" = "Änderung erledigen"
"Resolved" = "Erledigt"
-"Resources" = "Ressourcen"
"Responsive" = "Responsiv"
"Restart" = "Neu starten"
"Restore" = "Wiederherstellen"
@@ -1564,7 +1563,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "gewinkelte und gebogene Verbinder ohne gespeicherten Pfad werden als gerade Linien importiert"
"entries without a node type were skipped" = "Einträge ohne Knotentyp wurden übersprungen"
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-Links aus Browser, Chat oder E-Mail öffnen diese App und treten der Sitzung bei."
-"filled areas of a vector with their own fill use the layer's fill" = "gefüllte Bereiche eines Vektors mit eigener Füllung verwenden die Füllung der Ebene"
"from {server}" = "von {server}"
"gamepad triggers without exactly one known controller button were skipped" = "Gamepad-Auslöser ohne genau eine bekannte Controllertaste wurden übersprungen"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "die Liste der Rasterspuren hat mehr als 1000 Einträge; die Spuren verwenden die standardmäßige gleiche Größe"
@@ -1980,6 +1978,7 @@ status = "draft — needs native review"
"measure.distances" = "Abstände messen"
"measure.padding" = "Innenabstand des Auto-Layouts messen"
"node.create" = "Ebene erstellen"
+"node.find" = "Ebenen suchen"
"node.get" = "Ebene abrufen"
"node.reorder" = "Ebene neu anordnen"
"node.reparent" = "Ebene verschieben in"
diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml
index 8c1442a4..df4a738b 100644
--- a/crates/ui-egui/locales/en.toml
+++ b/crates/ui-egui/locales/en.toml
@@ -252,7 +252,7 @@ name = "English"
"Changes on main" = "Changes on main"
"Changes on the branch" = "Changes on the branch"
"Changes unknown" = "Changes unknown"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code."
"Choose another profile to change it." = "Choose another profile to change it."
"Choose collection" = "Choose collection"
"Choose image" = "Choose image"
@@ -1202,7 +1202,6 @@ name = "English"
"Resolve" = "Resolve"
"Resolve change" = "Resolve change"
"Resolved" = "Resolved"
-"Resources" = "Resources"
"Responsive" = "Responsive"
"Restart" = "Restart"
"Restore" = "Restore"
@@ -1693,7 +1692,6 @@ name = "English"
"elbowed and curved connectors without a saved path import as straight lines" = "elbowed and curved connectors without a saved path import as straight lines"
"entries without a node type were skipped" = "entries without a node type were skipped"
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft:// links from a browser, chat or email open this app and join the session."
-"filled areas of a vector with their own fill use the layer's fill" = "filled areas of a vector with their own fill use the layer's fill"
"from {server}" = "from {server}"
"gamepad triggers without exactly one known controller button were skipped" = "gamepad triggers without exactly one known controller button were skipped"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "grid track list exceeds 1000 entries; the tracks use the default equal sizing"
@@ -1714,12 +1712,15 @@ name = "English"
"invalid internal .fig text path data was ignored" = "invalid internal .fig text path data was ignored"
"invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines"
"invalid or oversized vector path data was skipped" = "invalid or oversized vector path data was skipped"
+"invalid vector region fill paint list; layer fill used" = "invalid vector region fill paint list; layer fill used"
+"invalid vector region paints were skipped" = "invalid vector region paints were skipped"
"just now" = "just now"
"key triggers without a supported key were skipped" = "key triggers without a supported key were skipped"
"key up triggers import as key presses" = "key up triggers import as key presses"
"layers without a position were placed at their parent's origin" = "layers without a position were placed at their parent's origin"
"layers without a type were skipped" = "layers without a type were skipped"
"legacy image filter container is unsupported; filter conversion was skipped" = "legacy image filter container is unsupported; filter conversion was skipped"
+"linked vector region fill styles keep resolved appearance; live style links were left out" = "linked vector region fill styles keep resolved appearance; live style links were left out"
"locked" = "locked"
"malformed image filters container was skipped" = "malformed image filters container was skipped"
"malformed image paintFilter container was skipped" = "malformed image paintFilter container was skipped"
@@ -1775,10 +1776,13 @@ name = "English"
"variable modes of collections that aren't in the file were skipped" = "variable modes of collections that aren't in the file were skipped"
"variable modes of collections that aren't in the variables JSON were skipped" = "variable modes of collections that aren't in the variables JSON were skipped"
"variable values that don't match their type (or composed colours) were skipped" = "variable values that don't match their type (or composed colours) were skipped"
+"variable-bound vector region paints keep resolved appearance; live bindings were left out" = "variable-bound vector region paints keep resolved appearance; live bindings were left out"
"variables of unknown types were skipped" = "variables of unknown types were skipped"
"variables whose collection isn't in the variables JSON were skipped" = "variables whose collection isn't in the variables JSON were skipped"
"variables without readable values were skipped" = "variables without readable values were skipped"
"vector points with unknown stroke joins use the layer join" = "vector points with unknown stroke joins use the layer join"
+"vector region paint data exceeded import limits and was skipped" = "vector region paint data exceeded import limits and was skipped"
+"vector region references a missing fill style; layer fill used" = "vector region references a missing fill style; layer fill used"
"vectors without geometry are empty" = "vectors without geometry are empty"
"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vectors without geometry are empty (export the file JSON with geometry=paths)"
"very long text was cut to 1,048,576 characters" = "very long text was cut to 1,048,576 characters"
diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml
index dd6b4ed1..639031af 100644
--- a/crates/ui-egui/locales/es.toml
+++ b/crates/ui-egui/locales/es.toml
@@ -254,7 +254,7 @@ status = "draft — needs native review"
"Changes on main" = "Cambios en la principal"
"Changes on the branch" = "Cambios en la rama"
"Changes unknown" = "Cambios desconocidos"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Elige React, Leptos, Vue, SwiftUI, Compose o ArkUI arriba para conectar este componente con su código."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Elige React, Leptos, Vue, SwiftUI, Compose, ArkUI o Flutter arriba para conectar este componente con su código."
"Choose another profile to change it." = "Elige otro perfil para cambiarlo."
"Choose collection" = "Elegir colección"
"Choose image" = "Elegir imagen"
@@ -1198,7 +1198,6 @@ status = "draft — needs native review"
"Resolve" = "Resolver"
"Resolve change" = "Resolver cambio"
"Resolved" = "Resuelto"
-"Resources" = "Recursos"
"Responsive" = "Adaptable"
"Restart" = "Reiniciar"
"Restore" = "Restaurar"
@@ -1682,7 +1681,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "los conectores angulares y curvos sin trazado guardado se importan como líneas rectas"
"entries without a node type were skipped" = "se omitieron las entradas sin tipo de nodo"
"figcraft:// links from a browser, chat or email open this app and join the session." = "Los enlaces figcraft:// de un navegador, chat o correo abren esta app y se unen a la sesión."
-"filled areas of a vector with their own fill use the layer's fill" = "las áreas rellenas de un vector con relleno propio usan el relleno de la capa"
"from {server}" = "de {server}"
"gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de mando sin exactamente un botón de mando conocido"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "la lista de pistas de cuadrícula supera las 1000 entradas; las pistas usan el tamaño igual predeterminado"
@@ -2118,6 +2116,7 @@ status = "draft — needs native review"
"measure.distances" = "Medir distancias"
"measure.padding" = "Medir margen interno del diseño automático"
"node.create" = "Crear capa"
+"node.find" = "Buscar capas"
"node.get" = "Obtener capa"
"node.reorder" = "Reordenar capa"
"node.reparent" = "Mover capa dentro de"
diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml
index f1a3bbc0..55b094d0 100644
--- a/crates/ui-egui/locales/fi.toml
+++ b/crates/ui-egui/locales/fi.toml
@@ -236,7 +236,7 @@ status = "draft — needs native review"
"Check" = "Valintamerkki"
"Checking the fonts installed on this computer…" = "Tarkistetaan tämän tietokoneen asennettuja fontteja…"
"Checking the server…" = "Tarkistetaan palvelinta…"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Valitse yllä React, Leptos, Vue, SwiftUI, Compose tai ArkUI yhdistääksesi tämän komponentin sen koodiin."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Valitse yllä React, Leptos, Vue, SwiftUI, Compose, ArkUI tai Flutter yhdistääksesi tämän komponentin sen koodiin."
"Chevron" = "Kulmanuoli"
"Choose another profile to change it." = "Vaihda valitsemalla toinen profiili."
"Choose collection" = "Valitse kokoelma"
@@ -1168,7 +1168,6 @@ status = "draft — needs native review"
"Resolve" = "Ratkaise"
"Resolve change" = "Ratkaise muutos"
"Resolved" = "Ratkaistu"
-"Resources" = "Resurssit"
"Responsive" = "Mukautuva"
"Restart" = "Aloita uudelleen"
"Restore" = "Palauta"
@@ -1643,7 +1642,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "mutkaiset ja kaarevat yhdistimet, joilla ei ole tallennettua polkua, tuodaan suorina viivoina"
"entries without a node type were skipped" = "kohteet, joilta puuttuu solmutyyppi, ohitettiin"
"figcraft:// links from a browser, chat or email open this app and join the session." = "selaimen, keskustelun tai sähköpostin figcraft://-linkit avaavat tämän sovelluksen ja liittyvät istuntoon."
-"filled areas of a vector with their own fill use the layer's fill" = "vektorin omalla täytöllä määritetyt alueet käyttävät tason täyttöä"
"from {server}" = "palvelimelta {server}"
"gamepad triggers without exactly one known controller button were skipped" = "peliohjainlaukaisimet, joilla ei ole täsmälleen yhtä tunnettua ohjainpainiketta, ohitettiin"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ruudukon osien luettelo ylittää 1000 kohdetta; osat käyttävät oletuksen mukaista tasakokoa"
@@ -2069,6 +2067,7 @@ status = "draft — needs native review"
"measure.distances" = "Mittaa etäisyyksiä"
"measure.padding" = "Mittaa automaattisen asettelun sisämarginaali"
"node.create" = "Luo taso"
+"node.find" = "Etsi tasoja"
"node.get" = "Hae taso"
"node.reorder" = "Muuta tason järjestystä"
"node.reparent" = "Siirrä taso kohteeseen"
diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml
index 7c80a3e3..2d9b9b76 100644
--- a/crates/ui-egui/locales/fr.toml
+++ b/crates/ui-egui/locales/fr.toml
@@ -239,7 +239,7 @@ status = "draft — needs native review"
"Changes on main" = "Modifications dans le fichier principal"
"Changes on the branch" = "Modifications dans la branche"
"Changes unknown" = "Modifications inconnues"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Choisissez React, Leptos, Vue, SwiftUI, Compose ou ArkUI ci-dessus pour relier ce composant à son code."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Choisissez React, Leptos, Vue, SwiftUI, Compose, ArkUI ou Flutter ci-dessus pour relier ce composant à son code."
"Choose another profile to change it." = "Choisissez un autre profil pour le changer."
"Choose collection" = "Choisir une collection"
"Choose image" = "Choisir une image"
@@ -1169,7 +1169,6 @@ status = "draft — needs native review"
"Resolve" = "Résoudre"
"Resolve change" = "Résoudre la modification"
"Resolved" = "Résolu"
-"Resources" = "Ressources"
"Responsive" = "Adaptatif"
"Restart" = "Recommencer"
"Restore" = "Restaurer"
@@ -1646,7 +1645,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "les connecteurs coudés et courbes sans tracé enregistré sont importés comme des lignes droites"
"entries without a node type were skipped" = "les entrées sans type de nœud ont été ignorées"
"figcraft:// links from a browser, chat or email open this app and join the session." = "Les liens figcraft:// d’un navigateur, d’une messagerie ou d’un e-mail ouvrent cette application et rejoignent la session."
-"filled areas of a vector with their own fill use the layer's fill" = "les zones remplies d’un vecteur avec leur propre remplissage utilisent le remplissage du calque"
"from {server}" = "depuis {server}"
"gamepad triggers without exactly one known controller button were skipped" = "les déclencheurs de manette sans exactement un bouton de manette connu ont été ignorés"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "la liste des pistes de grille dépasse 1 000 entrées ; les pistes utilisent le dimensionnement égal par défaut"
@@ -2070,6 +2068,7 @@ status = "draft — needs native review"
"measure.distances" = "Mesurer les distances"
"measure.padding" = "Mesurer la marge intérieure de la mise en page auto"
"node.create" = "Créer un calque"
+"node.find" = "Rechercher des calques"
"node.get" = "Obtenir le calque"
"node.reorder" = "Réordonner le calque"
"node.reparent" = "Déplacer le calque dans"
diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml
index b1755027..172043f5 100644
--- a/crates/ui-egui/locales/id.toml
+++ b/crates/ui-egui/locales/id.toml
@@ -242,7 +242,7 @@ status = "draft — needs native review"
"Changes on main" = "Perubahan di main"
"Changes on the branch" = "Perubahan di branch"
"Changes unknown" = "Perubahan tidak diketahui"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Pilih React, Leptos, Vue, SwiftUI, Compose, atau ArkUI di atas untuk menghubungkan component ini ke kodenya."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Pilih React, Leptos, Vue, SwiftUI, Compose, ArkUI, atau Flutter di atas untuk menghubungkan component ini ke kodenya."
"Choose another profile to change it." = "Pilih profil lain untuk mengubahnya."
"Choose collection" = "Pilih koleksi"
"Choose image" = "Pilih gambar"
@@ -1148,7 +1148,6 @@ status = "draft — needs native review"
"Resolve" = "Selesaikan"
"Resolve change" = "Selesaikan perubahan"
"Resolved" = "Terselesaikan"
-"Resources" = "Sumber daya"
"Responsive" = "Responsif"
"Restart" = "Mulai ulang"
"Restore" = "Pulihkan"
@@ -1608,7 +1607,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "konektor siku dan lengkung tanpa jalur tersimpan diimpor sebagai garis lurus"
"entries without a node type were skipped" = "entri tanpa tipe node dilewati"
"figcraft:// links from a browser, chat or email open this app and join the session." = "Link figcraft:// dari browser, chat, atau email membuka aplikasi ini dan bergabung ke sesi."
-"filled areas of a vector with their own fill use the layer's fill" = "area terisi pada vector dengan isian sendiri memakai isian layer"
"from {server}" = "dari {server}"
"gamepad triggers without exactly one known controller button were skipped" = "pemicu gamepad yang tidak memiliki tepat satu tombol kontroler yang dikenal dilewati"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "daftar track grid melebihi 1000 entri; track memakai ukuran sama besar bawaan"
@@ -2030,6 +2028,7 @@ status = "draft — needs native review"
"measure.distances" = "Ukur jarak"
"measure.padding" = "Ukur padding auto layout"
"node.create" = "Buat layer"
+"node.find" = "Cari layer"
"node.get" = "Ambil layer"
"node.reorder" = "Ubah urutan layer"
"node.reparent" = "Pindahkan layer ke dalam"
diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml
index d31ab7b6..0a9c6156 100644
--- a/crates/ui-egui/locales/it.toml
+++ b/crates/ui-egui/locales/it.toml
@@ -228,7 +228,7 @@ status = "draft — needs native review"
"Changes on main" = "Modifiche su main"
"Changes on the branch" = "Modifiche sul ramo"
"Changes unknown" = "Modifiche sconosciute"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Scegli React, Leptos, Vue, SwiftUI, Compose o ArkUI qui sopra per collegare questo componente al suo codice."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Scegli React, Leptos, Vue, SwiftUI, Compose, ArkUI o Flutter qui sopra per collegare questo componente al suo codice."
"Choose another profile to change it." = "Scegli un altro profilo per cambiarlo."
"Choose collection" = "Scegli collezione"
"Choose image" = "Scegli immagine"
@@ -1128,7 +1128,6 @@ status = "draft — needs native review"
"Resolve" = "Risolvi"
"Resolve change" = "Risolvi la modifica"
"Resolved" = "Risolto"
-"Resources" = "Risorse"
"Responsive" = "Responsive"
"Restart" = "Riavvia"
"Restore" = "Ripristina"
@@ -1581,7 +1580,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "i connettori ad angolo e curvi senza un tracciato salvato vengono importati come linee rette"
"entries without a node type were skipped" = "le voci senza tipo di nodo sono state saltate"
"figcraft:// links from a browser, chat or email open this app and join the session." = "i link figcraft:// da un browser, una chat o un'email aprono questa app e partecipano alla sessione."
-"filled areas of a vector with their own fill use the layer's fill" = "le aree riempite di un vettore con riempimento proprio usano il riempimento del livello"
"from {server}" = "da {server}"
"gamepad triggers without exactly one known controller button were skipped" = "i trigger del gamepad senza esattamente un pulsante noto del controller sono stati saltati"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "l'elenco delle tracce della griglia supera 1000 voci; le tracce usano il dimensionamento uniforme predefinito"
@@ -1983,6 +1981,7 @@ status = "draft — needs native review"
"measure.distances" = "Misura distanze"
"measure.padding" = "Misura margini interni dell'auto layout"
"node.create" = "Crea livello"
+"node.find" = "Trova livelli"
"node.get" = "Ottieni livello"
"node.reorder" = "Riordina livello"
"node.reparent" = "Sposta livello dentro"
diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml
index ec117721..417311b2 100644
--- a/crates/ui-egui/locales/ja.toml
+++ b/crates/ui-egui/locales/ja.toml
@@ -240,7 +240,7 @@ status = "draft — needs native review"
"Changes on main" = "メインの変更"
"Changes on the branch" = "ブランチの変更"
"Changes unknown" = "変更内容は不明"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "上で React、Leptos、Vue、SwiftUI、Compose、ArkUI のいずれかを選び、このコンポーネントをコードに接続。"
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "上で React、Leptos、Vue、SwiftUI、Compose、ArkUI、Flutter のいずれかを選び、このコンポーネントをコードに接続。"
"Choose another profile to change it." = "変更するには別のプロファイルを選択。"
"Choose collection" = "コレクションを選択"
"Choose image" = "画像を選択"
@@ -1156,7 +1156,6 @@ status = "draft — needs native review"
"Resolve" = "解決"
"Resolve change" = "変更を解決"
"Resolved" = "解決済み"
-"Resources" = "リソース"
"Responsive" = "レスポンシブ"
"Restart" = "再起動"
"Restore" = "復元"
@@ -1627,7 +1626,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "保存されたパスのない折れ線・曲線コネクタは直線としてインポート"
"entries without a node type were skipped" = "ノードタイプのない項目をスキップ"
"figcraft:// links from a browser, chat or email open this app and join the session." = "ブラウザー、チャット、メールからの figcraft:// リンクでこのアプリが開き、セッションに参加する。"
-"filled areas of a vector with their own fill use the layer's fill" = "独自の塗りを持つベクターの塗りつぶし領域にはレイヤーの塗りを使用"
"from {server}" = "{server} から"
"gamepad triggers without exactly one known controller button were skipped" = "既知のコントローラーボタンがちょうど 1 つではないゲームパッドトリガーをスキップ"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "グリッドトラックのリストが 1000 項目を超えるため、トラックはデフォルトの均等サイズを使用"
@@ -2050,6 +2048,7 @@ status = "draft — needs native review"
"measure.distances" = "距離を測定"
"measure.padding" = "オートレイアウトのパディングを測定"
"node.create" = "レイヤーを作成"
+"node.find" = "レイヤーを検索"
"node.get" = "レイヤーを取得"
"node.reorder" = "レイヤーの順序を変更"
"node.reparent" = "レイヤーを中に移動"
diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml
index b5d19efb..84597419 100644
--- a/crates/ui-egui/locales/ka.toml
+++ b/crates/ui-egui/locales/ka.toml
@@ -249,7 +249,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Changes on main" = "ცვლილებები მთავარში"
"Changes on the branch" = "ცვლილებები განშტოებაში"
"Changes unknown" = "ცვლილებები უცნობია"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "ამ კომპონენტის კოდთან დასაკავშირებლად ზემოთ აირჩიეთ React, Leptos, Vue, SwiftUI, Compose ან ArkUI."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "ამ კომპონენტის კოდთან დასაკავშირებლად ზემოთ აირჩიეთ React, Leptos, Vue, SwiftUI, Compose, ArkUI ან Flutter."
"Choose another profile to change it." = "მის შესაცვლელად აირჩიეთ სხვა პროფილი."
"Choose collection" = "კოლექციის არჩევა"
"Choose image" = "გამოსახულების არჩევა"
@@ -1179,7 +1179,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Resolve" = "გადაწყვეტა"
"Resolve change" = "ცვლილების გადაწყვეტა"
"Resolved" = "გადაწყვეტილია"
-"Resources" = "რესურსები"
"Responsive" = "ადაპტური"
"Restart" = "გადატვირთვა"
"Restore" = "აღდგენა"
@@ -1664,7 +1663,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"elbowed and curved connectors without a saved path import as straight lines" = "შენახული ტრაექტორიის გარეშე კუთხოვანი და მრუდი შემაერთებლები სწორ ხაზებად იმპორტირდება"
"entries without a node type were skipped" = "კვანძის ტიპის გარეშე ჩანაწერები გამოტოვებულია"
"figcraft:// links from a browser, chat or email open this app and join the session." = "ბრაუზერიდან, ჩატიდან ან ელფოსტიდან figcraft:// ბმულები ხსნის ამ აპს და უერთდება სესიას."
-"filled areas of a vector with their own fill use the layer's fill" = "ვექტორის შევსებული არეები საკუთარი შევსებით შრის შევსებას იყენებს"
"from {server}" = "წყარო: {server}"
"gamepad triggers without exactly one known controller button were skipped" = "გეიმპადის გამშვებები, რომლებსაც ზუსტად ერთი ცნობილი კონტროლერის ღილაკი არ აქვს, გამოტოვებულია"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ბადის ტრეკების სია 1000 ჩანაწერს აღემატება; ტრეკები ნაგულისხმევ თანაბარ ზომებს იყენებს"
@@ -2122,6 +2120,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"measure.distances" = "მანძილების გაზომვა"
"measure.padding" = "ავტოგანლაგების შიდა დაშორების გაზომვა"
"node.create" = "შრის შექმნა"
+"node.find" = "შრეების ძებნა"
"node.get" = "შრის მიღება"
"node.reorder" = "შრის გადალაგება"
"node.reparent" = "შრის შიგნით გადატანა"
diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml
index 397d9507..693cf5b6 100644
--- a/crates/ui-egui/locales/ko.toml
+++ b/crates/ui-egui/locales/ko.toml
@@ -230,7 +230,7 @@ status = "draft — needs native review"
"Changes on main" = "메인의 변경 사항"
"Changes on the branch" = "브랜치의 변경 사항"
"Changes unknown" = "변경 내용 알 수 없음"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "위에서 React, Leptos, Vue, SwiftUI, Compose 또는 ArkUI를 선택해 이 컴포넌트를 코드에 연결하세요."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "위에서 React, Leptos, Vue, SwiftUI, Compose, ArkUI 또는 Flutter를 선택해 이 컴포넌트를 코드에 연결하세요."
"Choose another profile to change it." = "변경하려면 다른 프로필을 선택하세요."
"Choose collection" = "컬렉션 선택"
"Choose image" = "이미지 선택"
@@ -1106,7 +1106,6 @@ status = "draft — needs native review"
"Resolve" = "해결"
"Resolve change" = "변경 사항 해결"
"Resolved" = "해결됨"
-"Resources" = "리소스"
"Responsive" = "반응형"
"Restart" = "다시 시작"
"Restore" = "복원"
@@ -1555,7 +1554,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "저장된 경로가 없는 꺾인 커넥터와 곡선 커넥터는 직선으로 가져옴"
"entries without a node type were skipped" = "노드 유형이 없는 항목을 건너뜀"
"figcraft:// links from a browser, chat or email open this app and join the session." = "브라우저, 채팅, 이메일의 figcraft:// 링크로 이 앱을 열고 세션에 참여함."
-"filled areas of a vector with their own fill use the layer's fill" = "자체 채우기가 있는 벡터의 채워진 영역은 레이어의 채우기를 사용함"
"from {server}" = "{server}에서"
"gamepad triggers without exactly one known controller button were skipped" = "알려진 컨트롤러 버튼이 정확히 하나가 아닌 게임패드 트리거를 건너뜀"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "그리드 트랙 목록이 1000개 항목을 넘어 트랙은 기본 균등 크기를 사용함"
@@ -2086,6 +2084,7 @@ status = "draft — needs native review"
"measure.distances" = "거리 측정"
"measure.padding" = "자동 레이아웃 패딩 측정"
"node.create" = "레이어 만들기"
+"node.find" = "레이어 찾기"
"node.get" = "레이어 가져오기"
"node.reorder" = "레이어 순서 변경"
"node.reparent" = "레이어를 안으로 이동"
diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml
index d9905f2c..63708f82 100644
--- a/crates/ui-egui/locales/nl.toml
+++ b/crates/ui-egui/locales/nl.toml
@@ -236,7 +236,7 @@ status = "draft — needs native review"
"Check" = "Vinkje"
"Checking the fonts installed on this computer…" = "De lettertypen op deze computer worden gecontroleerd…"
"Checking the server…" = "Server controleren…"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Kies hierboven React, Leptos, Vue, SwiftUI, Compose of ArkUI om dit component aan zijn code te koppelen."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Kies hierboven React, Leptos, Vue, SwiftUI, Compose, ArkUI of Flutter om dit component aan zijn code te koppelen."
"Chevron" = "Punthaak"
"Choose another profile to change it." = "Kies een ander profiel om dit te wijzigen."
"Choose collection" = "Kies een verzameling"
@@ -1168,7 +1168,6 @@ status = "draft — needs native review"
"Resolve" = "Oplossen"
"Resolve change" = "Wijziging oplossen"
"Resolved" = "Opgelost"
-"Resources" = "Bronnen"
"Responsive" = "Responsive"
"Restart" = "Opnieuw starten"
"Restore" = "Terugzetten"
@@ -1643,7 +1642,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "haakse en gebogen connectors zonder opgeslagen pad worden geïmporteerd als rechte lijnen"
"entries without a node type were skipped" = "items zonder node-type zijn overgeslagen"
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-links uit een browser, chat of e-mail openen deze app en laten je deelnemen aan de sessie."
-"filled areas of a vector with their own fill use the layer's fill" = "gevulde gebieden van een vector met een eigen vulling gebruiken de vulling van de laag"
"from {server}" = "van {server}"
"gamepad triggers without exactly one known controller button were skipped" = "gamepad-triggers zonder precies één bekende controllerknop zijn overgeslagen"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "de lijst met rastertracks overschrijdt 1000 items; de tracks gebruiken de standaard gelijke grootte"
@@ -2068,6 +2066,7 @@ status = "draft — needs native review"
"measure.distances" = "Afstanden meten"
"measure.padding" = "Padding van auto layout meten"
"node.create" = "Laag maken"
+"node.find" = "Lagen zoeken"
"node.get" = "Laag ophalen"
"node.reorder" = "Laag herordenen"
"node.reparent" = "Laag verplaatsen naar"
diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml
index c935fca1..d5402774 100644
--- a/crates/ui-egui/locales/pl.toml
+++ b/crates/ui-egui/locales/pl.toml
@@ -245,7 +245,7 @@ name = "Polski"
"Changes on main" = "Zmiany w gałęzi głównej"
"Changes on the branch" = "Zmiany w gałęzi"
"Changes unknown" = "Nieznane zmiany"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Wybierz React, Leptos, Vue, SwiftUI, Compose lub ArkUI powyżej, aby połączyć ten komponent z jego kodem."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Wybierz React, Leptos, Vue, SwiftUI, Compose, ArkUI lub Flutter powyżej, aby połączyć ten komponent z jego kodem."
"Choose another profile to change it." = "Wybierz inny profil, aby go zmienić."
"Choose collection" = "Wybierz kolekcję"
"Choose image" = "Wybierz obraz"
@@ -1172,7 +1172,6 @@ name = "Polski"
"Resolve" = "Rozwiąż"
"Resolve change" = "Rozwiąż zmianę"
"Resolved" = "Rozwiązany"
-"Resources" = "Zasoby"
"Responsive" = "Responsywny"
"Restart" = "Uruchom ponownie"
"Restore" = "Przywróć"
@@ -1653,7 +1652,6 @@ name = "Polski"
"elbowed and curved connectors without a saved path import as straight lines" = "złamane i zakrzywione łączniki bez zapisanej ścieżki są importowane jako linie proste"
"entries without a node type were skipped" = "wpisy bez typu węzła zostały pominięte"
"figcraft:// links from a browser, chat or email open this app and join the session." = "linki figcraft:// z przeglądarki, czatu lub poczty e-mail otwierają tę aplikację i dołączają do sesji."
-"filled areas of a vector with their own fill use the layer's fill" = "wypełnione obszary wektora z własnym wypełnieniem używają wypełnienia warstwy"
"from {server}" = "z serwera {server}"
"gamepad triggers without exactly one known controller button were skipped" = "wyzwalacze kontrolera bez dokładnie jednego znanego przycisku zostały pominięte"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "lista ścieżek siatki przekracza 1000 pozycji; ścieżki używają domyślnego równego rozmiaru"
@@ -2081,6 +2079,7 @@ name = "Polski"
"measure.distances" = "Zmierz odległości"
"measure.padding" = "Zmierz marginesy automatycznego układu"
"node.create" = "Utwórz warstwę"
+"node.find" = "Znajdź warstwy"
"node.get" = "Pobierz warstwę"
"node.reorder" = "Zmień kolejność warstwy"
"node.reparent" = "Przenieś warstwę do"
diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml
index 16d6eab7..0f631556 100644
--- a/crates/ui-egui/locales/pt-BR.toml
+++ b/crates/ui-egui/locales/pt-BR.toml
@@ -228,7 +228,7 @@ status = "draft — needs native review"
"Changes on main" = "Alterações no principal"
"Changes on the branch" = "Alterações na branch"
"Changes unknown" = "Alterações desconhecidas"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Escolha React, Leptos, Vue, SwiftUI, Compose ou ArkUI acima para conectar este componente ao código dele."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Escolha React, Leptos, Vue, SwiftUI, Compose, ArkUI ou Flutter acima para conectar este componente ao código dele."
"Choose another profile to change it." = "Escolha outro perfil para alterá-lo."
"Choose collection" = "Escolher coleção"
"Choose image" = "Escolher imagem"
@@ -1105,7 +1105,6 @@ status = "draft — needs native review"
"Resolve" = "Resolver"
"Resolve change" = "Resolver alteração"
"Resolved" = "Resolvido"
-"Resources" = "Recursos"
"Responsive" = "Responsivo"
"Restart" = "Reiniciar"
"Restore" = "Restaurar"
@@ -1553,7 +1552,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "conectores angulares e curvos sem um caminho salvo são importados como linhas retas"
"entries without a node type were skipped" = "entradas sem tipo de nó foram ignoradas"
"figcraft:// links from a browser, chat or email open this app and join the session." = "Links figcraft:// de um navegador, chat ou e-mail abrem este app e entram na sessão."
-"filled areas of a vector with their own fill use the layer's fill" = "áreas preenchidas de um vetor com preenchimento próprio usam o preenchimento da camada"
"from {server}" = "de {server}"
"gamepad triggers without exactly one known controller button were skipped" = "gatilhos de gamepad sem exatamente um botão de controle conhecido foram ignorados"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "a lista de faixas da grade passa de 1000 entradas; as faixas usam o dimensionamento igual padrão"
@@ -2083,6 +2081,7 @@ status = "draft — needs native review"
"measure.distances" = "Medir distâncias"
"measure.padding" = "Medir espaçamento interno do layout automático"
"node.create" = "Criar camada"
+"node.find" = "Localizar camadas"
"node.get" = "Obter camada"
"node.reorder" = "Reordenar camada"
"node.reparent" = "Mover camada para dentro de"
diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml
index d25f7d14..6e6932d5 100644
--- a/crates/ui-egui/locales/ru.toml
+++ b/crates/ui-egui/locales/ru.toml
@@ -242,7 +242,7 @@ name = "Русский"
"Changes on main" = "Изменения в основной ветке"
"Changes on the branch" = "Изменения в ветке"
"Changes unknown" = "Изменения неизвестны"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Выберите выше React, Leptos, Vue, SwiftUI, Compose или ArkUI, чтобы связать компонент с его кодом."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Выберите выше React, Leptos, Vue, SwiftUI, Compose, ArkUI или Flutter, чтобы связать компонент с его кодом."
"Choose another profile to change it." = "Для смены выберите другой профиль."
"Choose collection" = "Выбрать коллекцию"
"Choose image" = "Выбрать изображение"
@@ -1172,7 +1172,6 @@ name = "Русский"
"Resolve" = "Решить"
"Resolve change" = "Разрешить изменение"
"Resolved" = "Решённые"
-"Resources" = "Ресурсы"
"Responsive" = "Адаптивно"
"Restart" = "Начать заново"
"Restore" = "Восстановить"
@@ -1653,7 +1652,6 @@ name = "Русский"
"elbowed and curved connectors without a saved path import as straight lines" = "ломаные и изогнутые соединительные линии без сохранённого пути импортируются как прямые"
"entries without a node type were skipped" = "записи без типа узла пропущены"
"figcraft:// links from a browser, chat or email open this app and join the session." = "Ссылки figcraft:// из браузера, чата или почты открывают это приложение и подключают к сессии."
-"filled areas of a vector with their own fill use the layer's fill" = "залитые области вектора с собственной заливкой используют заливку слоя"
"from {server}" = "с {server}"
"gamepad triggers without exactly one known controller button were skipped" = "триггеры геймпада без ровно одной известной кнопки контроллера пропущены"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "список треков сетки превышает 1000 записей; треки получают стандартный равный размер"
@@ -2081,6 +2079,7 @@ name = "Русский"
"measure.distances" = "Измерить расстояния"
"measure.padding" = "Измерить внутренние отступы автолейаута"
"node.create" = "Создать слой"
+"node.find" = "Найти слои"
"node.get" = "Получить слой"
"node.reorder" = "Изменить порядок слоя"
"node.reparent" = "Переместить слой в"
diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml
index 8718b060..01b3d7b9 100644
--- a/crates/ui-egui/locales/sv.toml
+++ b/crates/ui-egui/locales/sv.toml
@@ -252,7 +252,7 @@ name = "Svenska"
"Changes on main" = "Ändringar i main"
"Changes on the branch" = "Ändringar i grenen"
"Changes unknown" = "Okända ändringar"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Välj React, Leptos, Vue, SwiftUI, Compose eller ArkUI ovan för att koppla den här komponenten till dess kod."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Välj React, Leptos, Vue, SwiftUI, Compose, ArkUI eller Flutter ovan för att koppla den här komponenten till dess kod."
"Choose another profile to change it." = "Välj en annan profil för att ändra den."
"Choose collection" = "Välj samling"
"Choose image" = "Välj bild"
@@ -1199,7 +1199,6 @@ name = "Svenska"
"Resolve" = "Lös"
"Resolve change" = "Lös ändring"
"Resolved" = "Löst"
-"Resources" = "Resurser"
"Responsive" = "Responsiv"
"Restart" = "Starta om"
"Restore" = "Återställ"
@@ -1689,7 +1688,6 @@ name = "Svenska"
"elbowed and curved connectors without a saved path import as straight lines" = "vinklade och böjda kopplingslinjer utan sparad bana importeras som raka linjer"
"entries without a node type were skipped" = "poster utan nodtyp hoppades över"
"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-länkar från en webbläsare, chatt eller e-post öppnar den här appen och ansluter till sessionen."
-"filled areas of a vector with their own fill use the layer's fill" = "fyllda områden i en vektor med egen fyllning använder lagrets fyllning"
"from {server}" = "från {server}"
"gamepad triggers without exactly one known controller button were skipped" = "spelkontrollsutlösare utan exakt en känd kontrollknapp hoppades över"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "listan över rutnätsspår har fler än 1000 poster; spåren får standardstorleken med lika stora spår"
@@ -2126,6 +2124,7 @@ name = "Svenska"
"measure.distances" = "Mät avstånd"
"measure.padding" = "Mät padding"
"node.create" = "Skapa nod"
+"node.find" = "Sök lager"
"node.get" = "Hämta nod"
"node.reorder" = "Ändra ordning"
"node.reparent" = "Byt överordnad nod"
diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml
index 560975a1..dc4ea922 100644
--- a/crates/ui-egui/locales/th.toml
+++ b/crates/ui-egui/locales/th.toml
@@ -243,7 +243,7 @@ status = "draft — needs native review"
"Changes on main" = "การเปลี่ยนแปลงบนเมน"
"Changes on the branch" = "การเปลี่ยนแปลงบนแบรนช์"
"Changes unknown" = "ไม่ทราบการเปลี่ยนแปลง"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "เลือก React, Leptos, Vue, SwiftUI, Compose หรือ ArkUI ด้านบนเพื่อเชื่อมคอมโพเนนต์นี้กับโค้ด"
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "เลือก React, Leptos, Vue, SwiftUI, Compose, ArkUI หรือ Flutter ด้านบนเพื่อเชื่อมคอมโพเนนต์นี้กับโค้ด"
"Choose another profile to change it." = "เลือกโปรไฟล์อื่นเพื่อเปลี่ยน"
"Choose collection" = "เลือกคอลเลกชัน"
"Choose image" = "เลือกรูปภาพ"
@@ -1161,7 +1161,6 @@ status = "draft — needs native review"
"Resolve" = "แก้ไขแล้ว"
"Resolve change" = "แก้ไขการเปลี่ยนแปลง"
"Resolved" = "แก้ไขแล้ว"
-"Resources" = "แหล่งข้อมูล"
"Responsive" = "รีสปอนซีฟ"
"Restart" = "เริ่มใหม่"
"Restore" = "กู้คืน"
@@ -1632,7 +1631,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "ตัวเชื่อมแบบหักมุมและโค้งที่ไม่มีเส้นทางที่บันทึกไว้นำเข้าเป็นเส้นตรง"
"entries without a node type were skipped" = "ข้ามรายการที่ไม่มีชนิดโหนด"
"figcraft:// links from a browser, chat or email open this app and join the session." = "ลิงก์ figcraft:// จากเบราว์เซอร์ แชต หรืออีเมลจะเปิดแอปนี้และเข้าร่วมเซสชัน"
-"filled areas of a vector with their own fill use the layer's fill" = "พื้นที่เติมสีของเวกเตอร์ที่มีสีเติมของตัวเองใช้สีเติมของเลเยอร์"
"from {server}" = "จาก {server}"
"gamepad triggers without exactly one known controller button were skipped" = "ข้ามทริกเกอร์จอยเกมที่ไม่ได้ระบุปุ่มควบคุมที่รู้จักเพียงปุ่มเดียว"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "รายการแทร็กกริดเกิน 1000 รายการ: แทร็กใช้ขนาดเท่ากันตามค่าเริ่มต้น"
@@ -2046,6 +2044,7 @@ status = "draft — needs native review"
"measure.distances" = "วัดระยะห่าง"
"measure.padding" = "วัดระยะขอบในของออโต้เลย์เอาต์"
"node.create" = "สร้างเลเยอร์"
+"node.find" = "ค้นหาเลเยอร์"
"node.get" = "ดึงเลเยอร์"
"node.reorder" = "เรียงลำดับเลเยอร์ใหม่"
"node.reparent" = "ย้ายเลเยอร์เข้าไปใน"
diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml
index a36e21d2..58a0f4e3 100644
--- a/crates/ui-egui/locales/tr.toml
+++ b/crates/ui-egui/locales/tr.toml
@@ -239,7 +239,7 @@ name = "Türkçe"
"Changes on main" = "Ana dosyadaki değişiklikler"
"Changes on the branch" = "Daldaki değişiklikler"
"Changes unknown" = "Değişiklikler bilinmiyor"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Bu bileşeni koduna bağlamak için yukarıdan React, Leptos, Vue, SwiftUI, Compose veya ArkUI seçin."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Bu bileşeni koduna bağlamak için yukarıdan React, Leptos, Vue, SwiftUI, Compose, ArkUI veya Flutter seçin."
"Choose another profile to change it." = "Değiştirmek için başka bir profil seçin."
"Choose collection" = "Koleksiyon seç"
"Choose image" = "Görüntü seç"
@@ -1146,7 +1146,6 @@ name = "Türkçe"
"Resolve" = "Çöz"
"Resolve change" = "Değişikliği çöz"
"Resolved" = "Çözüldü"
-"Resources" = "Kaynaklar"
"Responsive" = "Duyarlı"
"Restart" = "Yeniden başlat"
"Restore" = "Geri yükle"
@@ -1608,7 +1607,6 @@ name = "Türkçe"
"elbowed and curved connectors without a saved path import as straight lines" = "kaydedilmiş yolu olmayan dirsekli ve eğri bağlayıcılar düz çizgi olarak içe aktarılır"
"entries without a node type were skipped" = "düğüm türü olmayan girdiler atlandı"
"figcraft:// links from a browser, chat or email open this app and join the session." = "Bir tarayıcıdan, sohbetten veya e-postadan gelen figcraft:// bağlantıları bu uygulamayı açar ve oturuma katılır."
-"filled areas of a vector with their own fill use the layer's fill" = "kendi dolgusu olan vektör dolgu alanları katmanın dolgusunu kullanır"
"from {server}" = "kaynak: {server}"
"gamepad triggers without exactly one known controller button were skipped" = "tam olarak bir bilinen kumanda düğmesi olmayan oyun kumandası tetikleyicileri atlandı"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ızgara izi listesi 1000 girdiyi aşıyor; izler varsayılan eşit boyutlandırmayı kullanır"
@@ -2030,6 +2028,7 @@ name = "Türkçe"
"measure.distances" = "Mesafeleri ölç"
"measure.padding" = "Otomatik yerleşim iç boşluğunu ölç"
"node.create" = "Katman oluştur"
+"node.find" = "Katman bul"
"node.get" = "Katmanı getir"
"node.reorder" = "Katmanı yeniden sırala"
"node.reparent" = "Katmanı içine taşı"
diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml
index e130afbf..19253912 100644
--- a/crates/ui-egui/locales/vi.toml
+++ b/crates/ui-egui/locales/vi.toml
@@ -223,7 +223,7 @@ status = "draft — needs native review"
"Changes unknown" = "Không rõ thay đổi"
"Checking the fonts installed on this computer…" = "Đang kiểm tra các phông chữ cài đặt trên máy tính này…"
"Checking the server…" = "Đang kiểm tra máy chủ…"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Hãy chọn React, Leptos, Vue, SwiftUI, Compose hoặc ArkUI ở trên để kết nối thành phần này với mã nguồn."
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "Hãy chọn React, Leptos, Vue, SwiftUI, Compose, ArkUI hoặc Flutter ở trên để kết nối thành phần này với mã nguồn."
"Choose another profile to change it." = "Hãy chọn hồ sơ khác để thay đổi."
"Choose collection" = "Chọn bộ sưu tập"
"Choose image" = "Chọn hình ảnh"
@@ -1129,7 +1129,6 @@ status = "draft — needs native review"
"Resolve" = "Giải quyết"
"Resolve change" = "Giải quyết thay đổi"
"Resolved" = "Đã giải quyết"
-"Resources" = "Tài nguyên"
"Responsive" = "Đáp ứng"
"Restart" = "Bắt đầu lại"
"Restore" = "Khôi phục"
@@ -1582,7 +1581,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "các đường nối gấp khúc và cong không có đường dẫn đã lưu được nhập thành đường thẳng"
"entries without a node type were skipped" = "đã bỏ qua các mục không có loại nút"
"figcraft:// links from a browser, chat or email open this app and join the session." = "Liên kết figcraft:// từ trình duyệt, ứng dụng trò chuyện hoặc email sẽ mở ứng dụng này và tham gia phiên."
-"filled areas of a vector with their own fill use the layer's fill" = "các vùng tô riêng trong một vector sẽ dùng màu tô của lớp"
"from {server}" = "từ {server}"
"gamepad triggers without exactly one known controller button were skipped" = "đã bỏ qua các kích hoạt tay cầm không có đúng một nút điều khiển đã biết"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "danh sách dải lưới vượt quá 1000 mục; các dải sẽ dùng kích thước đều nhau mặc định"
@@ -1984,6 +1982,7 @@ status = "draft — needs native review"
"measure.distances" = "Đo khoảng cách"
"measure.padding" = "Đo khoảng đệm bố cục tự động"
"node.create" = "Tạo lớp"
+"node.find" = "Tìm lớp"
"node.get" = "Lấy lớp"
"node.reorder" = "Sắp xếp lại lớp"
"node.reparent" = "Di chuyển lớp vào trong"
diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml
index 1081afad..ce5056b9 100644
--- a/crates/ui-egui/locales/zh-Hans.toml
+++ b/crates/ui-egui/locales/zh-Hans.toml
@@ -231,7 +231,7 @@ status = "draft — needs native review"
"Changes on main" = "主文件上的更改"
"Changes on the branch" = "分支上的更改"
"Changes unknown" = "更改未知"
-"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "在上方选择 React、Leptos、Vue、SwiftUI、Compose 或 ArkUI,将此组件关联到其代码。"
+"Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code." = "在上方选择 React、Leptos、Vue、SwiftUI、Compose、ArkUI 或 Flutter,将此组件关联到其代码。"
"Choose another profile to change it." = "选择其他配置文件以更改。"
"Choose collection" = "选择集合"
"Choose image" = "选择图片"
@@ -1114,7 +1114,6 @@ status = "draft — needs native review"
"Resolve" = "解决"
"Resolve change" = "解决更改"
"Resolved" = "已解决"
-"Resources" = "资源"
"Responsive" = "响应式"
"Restart" = "重新开始"
"Restore" = "恢复"
@@ -1563,7 +1562,6 @@ status = "draft — needs native review"
"elbowed and curved connectors without a saved path import as straight lines" = "没有已保存路径的折线和曲线连接线导入为直线"
"entries without a node type were skipped" = "已跳过没有节点类型的条目"
"figcraft:// links from a browser, chat or email open this app and join the session." = "来自浏览器、聊天或邮件的 figcraft:// 链接会打开此应用并加入会话。"
-"filled areas of a vector with their own fill use the layer's fill" = "具有自身填充的矢量填充区域使用图层的填充"
"from {server}" = "来自 {server}"
"gamepad triggers without exactly one known controller button were skipped" = "已跳过没有恰好一个已知手柄按钮的游戏手柄触发器"
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "网格轨道列表超过 1000 个条目;轨道使用默认的等分尺寸"
@@ -1983,6 +1981,7 @@ status = "draft — needs native review"
"measure.distances" = "测量距离"
"measure.padding" = "测量自动布局内边距"
"node.create" = "创建图层"
+"node.find" = "查找图层"
"node.get" = "获取图层"
"node.reorder" = "调整图层顺序"
"node.reparent" = "将图层移入"
diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs
index c44d457d..7a27aff3 100644
--- a/crates/ui-egui/src/app/frame.rs
+++ b/crates/ui-egui/src/app/frame.rs
@@ -32,6 +32,16 @@ impl FigApp {
}
}
+ /// Clicking the control that opened the colour picker closes it again; clicking another opens
+ /// the picker for that one.
+ pub fn toggle_picker(&mut self, target: crate::colorpicker::PickerTarget, _top: Option) {
+ if self.ui.picker.as_ref() == Some(&target) {
+ crate::colorpicker::close(self);
+ } else {
+ self.ui.picker = Some(target);
+ }
+ }
+
/// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect
/// don't draw (#307). Checked as the frame starts (the control channel) and after the panels
/// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more.
diff --git a/crates/ui-egui/src/board_picker_tests.rs b/crates/ui-egui/src/board_picker_tests.rs
index c5860525..e5c1666b 100644
--- a/crates/ui-egui/src/board_picker_tests.rs
+++ b/crates/ui-egui/src/board_picker_tests.rs
@@ -27,7 +27,7 @@ fn board() -> Ui {
/// The templates button: left of the Design panel's button at the board toolbar's right end.
fn templates_button(u: &Ui) -> Pos2 {
let p = crate::toolbar::pill_rect(u.app.canvas.rect, crate::toolbar::bar_width(&u.app));
- pos2(p.max.x - 8.0 - 32.0 - 4.0 - 16.0, p.center().y)
+ pos2(p.max.x - 8.0 - 32.0 - 8.0 - 16.0, p.center().y)
}
fn count(u: &Ui, f: impl Fn(&BoardItem) -> bool) -> usize {
diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs
index e8592467..e4758ca7 100644
--- a/crates/ui-egui/src/board_ui_tests.rs
+++ b/crates/ui-egui/src/board_ui_tests.rs
@@ -39,10 +39,10 @@ fn pill(u: &Ui) -> Rect {
}
/// The left edge of board toolbar group `i` (button 32 wide, a 16-wide caret 2 after it on the
-/// groups with flyouts, 4 between): move, ink, sticky, shapes, connector, text, section, stamp,
+/// groups with flyouts, 8 between): move, ink, sticky, shapes, connector, text, section, stamp,
/// comment.
fn group_x(u: &Ui, i: usize) -> f32 {
- const WIDTHS: [f32; 9] = [54.0, 54.0, 54.0, 54.0, 36.0, 36.0, 36.0, 54.0, 36.0];
+ const WIDTHS: [f32; 9] = [58.0, 58.0, 58.0, 58.0, 40.0, 40.0, 40.0, 58.0, 40.0];
pill(u).min.x + 8.0 + WIDTHS.iter().take(i).sum::()
}
diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs
index fff5536a..85c8d291 100644
--- a/crates/ui-egui/src/colorpicker_selection_tests.rs
+++ b/crates/ui-egui/src/colorpicker_selection_tests.rs
@@ -53,6 +53,25 @@ fn picker_closes_when_another_layer_is_selected() {
assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker);
}
+#[test]
+fn clicking_the_swatch_again_closes_the_picker() {
+ let (mut u, a, _) = two();
+ u.app.run("fill.add", json!({"ids": [a], "color": "#00FF00"}));
+ let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] };
+ u.app.toggle_picker(target.clone(), Some(200.0));
+ u.frame(vec![]);
+ u.frame(vec![]);
+ assert_eq!(u.app.ui.picker, Some(target.clone()), "the swatch opens the picker");
+ u.app.toggle_picker(target.clone(), Some(200.0));
+ u.frame(vec![]);
+ assert!(u.app.ui.picker.is_none(), "the same swatch toggles it shut");
+ // Another paint of the same layer opens the picker for that one.
+ let second = PickerTarget::Paint { fills: true, index: 1, ids: vec![a] };
+ u.app.toggle_picker(second.clone(), Some(200.0));
+ u.frame(vec![]);
+ assert_eq!(u.app.ui.picker, Some(second), "another paint opens the picker for it");
+}
+
// Regression test for #237: the "On this page" swatches wrapped onto rows that started at the picker's
// left edge (only the first row was indented) and ran past its right edge.
#[test]
@@ -77,11 +96,9 @@ fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() {
u.frame(vec![]);
}
let out = u.frame_output(vec![]);
- // The picker is the only floating window open.
- let picker = u
- .ctx
- .memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Middle).find_map(|l| m.area_rect(l.id)))
- .expect("the picker is open");
+ // The picker is the only floating window open: its area is the picker's own id, which the style
+ // picker also looks up to tell a press in here from one outside.
+ let picker = u.ctx.memory(|m| m.area_rect(crate::colorpicker::window_id())).expect("the picker is open");
// A swatch is a 14 pt square with a 1 pt outline.
let swatches: Vec = out
.shapes
diff --git a/crates/ui-egui/src/gradient_shorthand_tests.rs b/crates/ui-egui/src/gradient_shorthand_tests.rs
index db368f8e..0e5a9f0f 100644
--- a/crates/ui-egui/src/gradient_shorthand_tests.rs
+++ b/crates/ui-egui/src/gradient_shorthand_tests.rs
@@ -14,7 +14,7 @@ fn engine_execute_takes_the_gradient_shorthand() {
let r = exec(&mut u, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone();
let short = json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]});
exec(&mut u, "node.set", json!({"ids": [r], "fills": [short]})).unwrap();
- let n = exec(&mut u, "node.get", json!({"id": r, "depth": 0})).unwrap();
+ let n = exec(&mut u, "node.get", json!({"id": r})).unwrap();
let f = &n["fills"][0];
assert_eq!((f["type"].clone(), f["kind"].clone(), f["width"].clone()), (json!("gradient"), json!("linear"), json!([0.0, 1.5])), "{n}");
assert_eq!(f["stops"].as_array().map(Vec::len), Some(2), "{n}");
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 8d5d3e1b..ce957f28 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -17,6 +17,7 @@ pub mod icons;
pub mod live;
pub mod menus;
pub mod pending_open;
+pub mod popover;
pub mod present;
pub mod shortcuts;
pub mod tabs;
@@ -161,6 +162,8 @@ mod plugin_save_ui_tests;
#[cfg(test)]
mod plugins_browse_tests;
#[cfg(test)]
+mod popover_tests;
+#[cfg(test)]
mod popup_width_tests;
#[cfg(test)]
mod present_links_tests;
@@ -199,6 +202,8 @@ mod text_enter_tests;
#[cfg(test)]
mod titlebar_tests;
#[cfg(test)]
+mod ui_params_tests;
+#[cfg(test)]
mod ui_scale_tests;
#[cfg(test)]
mod variables_export_ui_tests;
@@ -402,6 +407,11 @@ pub struct UiState {
/// The prototype preview pane floating over the canvas, when open.
pub preview: Option,
pub picker: Option,
+ /// The colour picker's popover while it is open (its place survives a drag).
+ pub picker_popover: Option,
+ /// The control that opened the picker, refreshed every frame it is drawn (the panel scrolls):
+ /// a press in it is held out of the popover's closing, so its own click toggles the picker.
+ pub picker_button: Option,
pub context_menu: Option<(egui::Pos2, Vec)>,
/// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the
/// focused layer row or the canvas selection and takes the keyboard.
@@ -449,6 +459,8 @@ pub struct UiState {
pub color_profile: Option,
/// The open style picker.
pub style_picker: Option,
+ /// The style picker's popover while it is open (its place survives a drag).
+ pub style_picker_popover: Option,
/// The style picker shows colour styles as a grid of swatches.
pub style_grid: bool,
/// Style folders closed in the style picker.
@@ -578,6 +590,8 @@ impl Default for UiState {
present_view: present::PresentView::default(),
preview: None,
picker: None,
+ picker_popover: None,
+ picker_button: None,
context_menu: None,
key_menu: false,
key_box: None,
@@ -605,6 +619,7 @@ impl Default for UiState {
pdf_print: figcraft_doc::PrintSettings::default(),
color_profile: None,
style_picker: None,
+ style_picker_popover: None,
style_grid: false,
style_folds: BTreeSet::new(),
text_preview: None,
diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs
index bc48207c..50de5bf9 100644
--- a/crates/ui-egui/src/panel_remove_tests.rs
+++ b/crates/ui-egui/src/panel_remove_tests.rs
@@ -192,3 +192,36 @@ fn adding_a_fill_to_mixed_fills_opens_no_picker() {
assert_eq!((doc.node(a).unwrap().fills.len(), doc.node(b).unwrap().fills.len()), (2, 3));
assert_eq!(u.app.ui.picker, None);
}
+
+/// The shared popover's closing reaches the colour picker: a press on the canvas closes it.
+#[test]
+fn a_press_on_the_canvas_closes_the_colour_picker() {
+ let mut s = Session::new();
+ let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
+ s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
+ s.execute("select.set", &json!({"ids": [r]})).unwrap();
+ let mut u = Ui::new(s);
+ u.ctx.enable_accesskit();
+ let sw = u.ctx.read_response(Id::unique(("swatch", true, 0usize))).unwrap().rect.center();
+ click(&mut u, sw);
+ assert!(u.app.ui.picker.is_some(), "the swatch opens the picker");
+ click(&mut u, Pos2::new(320.0, 300.0));
+ assert_eq!(u.app.ui.picker, None, "a press on the canvas closes the picker");
+}
+
+/// Pressing the swatch that opened the picker again closes it: its press is held out of the
+/// popover's outside-press closing, so the swatch's own toggle gets the click.
+#[test]
+fn the_swatch_that_opened_the_colour_picker_closes_it_again() {
+ let mut s = Session::new();
+ let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
+ s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
+ s.execute("select.set", &json!({"ids": [r]})).unwrap();
+ let mut u = Ui::new(s);
+ u.ctx.enable_accesskit();
+ let sw = u.ctx.read_response(Id::unique(("swatch", true, 0usize))).unwrap().rect.center();
+ click(&mut u, sw);
+ assert!(u.app.ui.picker.is_some(), "the swatch opens the picker");
+ click(&mut u, sw);
+ assert_eq!(u.app.ui.picker, None, "the same swatch closes it again");
+}
diff --git a/crates/ui-egui/src/panels/dev_mode.rs b/crates/ui-egui/src/panels/dev_mode.rs
index cac69330..bcb97edb 100644
--- a/crates/ui-egui/src/panels/dev_mode.rs
+++ b/crates/ui-egui/src/panels/dev_mode.rs
@@ -445,7 +445,7 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId
let text = if connectable {
tr("Link this component to its code component: instances then show your real snippet with their values.")
} else {
- tr("Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code.")
+ tr("Choose React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter above to connect this component to its code.")
};
widgets::note(ui, text, t.text_secondary);
return;
diff --git a/crates/ui-egui/src/panels/export_panel.rs b/crates/ui-egui/src/panels/export_panel.rs
index cec8ebc0..06b34f2c 100644
--- a/crates/ui-egui/src/panels/export_panel.rs
+++ b/crates/ui-egui/src/panels/export_panel.rs
@@ -247,12 +247,12 @@ pub(crate) fn print_options(ui: &mut Ui, id: Id, p: &PrintSettings) -> Option Value {
+pub(crate) fn print_params(p: &PrintSettings) -> Value {
json!({"bleed": p.bleed(), "cropMarks": p.crop_marks, "bleedMarks": p.bleed_marks, "cmyk": p.cmyk})
}
/// `file.export` params for `setting`: its size, format and options (PDF: its print options).
-fn setting_params(setting: &ExportSetting) -> Value {
+pub(crate) fn setting_params(setting: &ExportSetting) -> Value {
let mut params = serde_json::to_value(setting).unwrap_or_else(|_| json!({}));
if let Value::Object(m) = &mut params {
m.remove("print");
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index 80b0c3e0..be691a8a 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -306,11 +306,14 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
};
widgets::section_header(ui, tr("Page"), &[], false);
let mut open_picker = false;
+ let mut swatch_top = None;
let mut new_hex = None;
row(ui, 32.0, |ui| {
let p = Paint::solid(bg);
- if widgets::swatch(ui, &p, 16.0).clicked() {
+ let sw = widgets::swatch(ui, &p, 16.0);
+ if sw.clicked() {
open_picker = true;
+ swatch_top = Some(sw.rect.min.y);
}
if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") {
new_hex = Some(h);
@@ -318,7 +321,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
widgets::label(ui, "100 %", t.text);
});
if open_picker {
- app.ui.picker = Some(crate::colorpicker::PickerTarget::PageBackground);
+ app.toggle_picker(crate::colorpicker::PickerTarget::PageBackground, swatch_top);
}
if let Some(h) = new_hex
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs
index f2d48eba..688d22e1 100644
--- a/crates/ui-egui/src/panels/right/effects.rs
+++ b/crates/ui-egui/src/panels/right/effects.rs
@@ -329,8 +329,9 @@ fn tweaked(e: &Effect, f: impl FnOnce(&mut EffectKind)) -> Effect {
/// duo noise's second colour), in the current row; `edit` applies a new one.
fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, secondary: bool, edit: &ColorEdit<'_>) {
let Some(c) = e.color(secondary) else { return };
- if widgets::swatch(ui, &Paint::solid(c), 16.0).clicked() {
- app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary });
+ let sw = widgets::swatch(ui, &Paint::solid(c), 16.0);
+ if sw.clicked() {
+ app.toggle_picker(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, Some(sw.rect.min.y));
}
if let Some(h) = widgets::text_field(ui, Id::unique(("fx_c", i, secondary)), &c.to_hex(), 80.0, "")
&& let Some(nc) = figcraft_color::Color::from_hex_or_name(&h)
diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs
index 5de0dfff..f73f8beb 100644
--- a/crates/ui-egui/src/panels/right/paints.rs
+++ b/crates/ui-egui/src/panels/right/paints.rs
@@ -253,8 +253,9 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start:
match fills.as_ref().and_then(|f| f.first()) {
Some(p) => {
widgets::paint_swatch(ui.painter(), sw, p);
- if ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()).clicked() {
- app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end });
+ let range_swatch = ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click());
+ if range_swatch.clicked() {
+ app.toggle_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(range_swatch.rect.min.y));
}
if let Some(c) = p.solid_color() {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
@@ -445,8 +446,17 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
widgets::paint_swatch(ui.painter(), sw, p);
let sresp = ui.interact(sw.expand(2.0), Id::unique(("swatch", fills, i)), Sense::click());
+ // While this paint is the one the picker edits, its swatch is held out of the popover's
+ // closing, so a press on it again reaches the toggle below.
+ let editing = matches!(
+ &app.ui.picker,
+ Some(crate::colorpicker::PickerTarget::Paint { fills: f, index: k, ids: held }) if *f == fills && *k == i && *held == ids
+ );
+ if editing {
+ app.ui.picker_button = Some(sresp.rect);
+ }
if sresp.clicked() {
- app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() });
+ app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect.min.y));
}
let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs
index 682b37fe..3fdada05 100644
--- a/crates/ui-egui/src/panels/selection_colors.rs
+++ b/crates/ui-egui/src/panels/selection_colors.rs
@@ -47,9 +47,9 @@ fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacti
colors
}
-pub(crate) fn open_picker(app: &mut FigApp, targets: Vec) {
+pub(crate) fn toggle(app: &mut FigApp, targets: Vec, top: Option) {
let Ok(ds) = app.session.ds() else { return };
- app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid });
+ app.toggle_picker(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }, top);
}
/// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity
@@ -72,7 +72,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId
crate::right::row(ui, 32.0, |ui| {
let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour"));
if swatch.clicked() {
- open_picker(app, group.targets.clone());
+ toggle(app, group.targets.clone(), Some(swatch.rect.min.y));
}
let width = (ui.available_width() - 116.0).max(40.0);
let linked_name = group
@@ -82,7 +82,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId
.or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str())));
if let Some(name) = linked_name {
if widgets::dropdown(ui, name, width, None).clicked() {
- open_picker(app, group.targets.clone());
+ toggle(app, group.targets.clone(), None);
}
} else if let Some(color) = group.paint.solid_color() {
if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) {
@@ -100,7 +100,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId
} else {
let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None);
if response.clicked() {
- open_picker(app, group.targets.clone());
+ toggle(app, group.targets.clone(), Some(response.rect.min.y));
}
}
let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0));
diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs
index a95af934..299bb57b 100644
--- a/crates/ui-egui/src/panels/typography.rs
+++ b/crates/ui-egui/src/panels/typography.rs
@@ -158,10 +158,11 @@ fn path_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], ids:
}
/// Set character `props` on the selected chars of the text being edited, else on whole layers
-/// (through `node.set`, which records overrides inside instances).
-fn commit_text(app: &mut FigApp, ids: &[NodeId], range: Option<(NodeId, usize, usize)>, props: Value) {
+/// (through `node.set`, which records overrides inside instances). Paragraph spacing is always
+/// the whole layer's: `text.style` doesn't take it.
+pub(crate) fn commit_text(app: &mut FigApp, ids: &[NodeId], range: Option<(NodeId, usize, usize)>, props: Value) {
match range {
- Some((_, a, b)) if a != b => {
+ Some((_, a, b)) if a != b && props.get("paragraphSpacing").is_none() => {
app.run("text.style", text_style_params(ids, range, props));
}
_ => app.set_props(ids, props),
@@ -170,7 +171,7 @@ fn commit_text(app: &mut FigApp, ids: &[NodeId], range: Option<(NodeId, usize, u
/// `text.style` params for `props` on the selected chars of the text being edited, else on the
/// whole of every layer in `ids` (previews; commits go through [`commit_text`]).
-fn text_style_params(ids: &[NodeId], range: Option<(NodeId, usize, usize)>, mut props: Value) -> Value {
+pub(crate) fn text_style_params(ids: &[NodeId], range: Option<(NodeId, usize, usize)>, mut props: Value) -> Value {
match range {
Some((id, a, b)) if a != b => {
props["id"] = json!(id);
diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs
new file mode 100644
index 00000000..dbcf3bdb
--- /dev/null
+++ b/crates/ui-egui/src/popover.rs
@@ -0,0 +1,283 @@
+//! The chrome every detached panel shares: a card with a header — the title 16 points in from its
+//! left edge, a 24-point X 8 in from its right — a hairline under the header and the theme's popup
+//! shadow, around one slot.
+//!
+//! The measurements are our own: they are the header the colour picker has always drawn, and the
+//! card is as wide as it. The shadow comes from the theme, so the dark theme gets its own. Nothing
+//! here is taken from another product's interface.
+//!
+//! It opens against an anchor (its right edge on it, its top on it, lifted so its bottom keeps off
+//! the screen's bottom) or at a point, the header drags it, and it closes itself on the X, on
+//! Escape and on a press outside it — except in the rects the caller holds out (the button that
+//! opened it, a popover it opened in turn), so that button's own click can toggle it shut instead
+//! of the press closing it and the release opening it again.
+
+use egui::epaint::RectShape;
+use egui::{Align2, Color32, Context, CornerRadius, Id, Pos2, Rect, Sense, Ui, Vec2, pos2, vec2};
+
+use crate::a11y::Tip;
+use crate::i18n::tr;
+use crate::icons::{self, Icon};
+use crate::theme::{Tokens, semibold};
+use crate::widgets;
+
+/// The default width: the colour picker's, and the one every detached panel shares.
+pub const WIDTH: f32 = 240.0;
+/// The header's height: the title's line and the X sit in it.
+pub const HEADER_H: f32 = 40.0;
+/// The title starts this far from the panel's left edge, the X ends this far from its right one.
+const TITLE_LEFT: f32 = 16.0;
+const CLOSE_RIGHT: f32 = 8.0;
+/// The X's button (the icon is inset in it).
+const CLOSE: f32 = 24.0;
+/// The slot's default margin inside the panel.
+const SLOT_PAD: f32 = 16.0;
+/// A lifted popover keeps this much screen under it; it stays [`EDGE`] inside the others.
+const SCREEN_GAP: f32 = 16.0;
+const EDGE: f32 = 8.0;
+/// The panel's corners.
+const RADIUS: u8 = 13;
+
+/// The surface a popover draws on: the panels' own, or the dark one the style picker uses.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Surface {
+ Panel,
+ Menu,
+}
+
+/// Where a popover opens when the caller has not put it down itself.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Open {
+ /// Its right edge on `x` (the right panel's left edge), its top on `y` (the button's top),
+ /// lifted when that would run its bottom past the screen's bottom.
+ Anchor(Pos2),
+ /// Its top-left exactly here.
+ At(Pos2),
+}
+
+/// What keeps a popover open while a press or Escape happens somewhere else.
+#[derive(Clone, Copy, Default)]
+pub struct Hold<'a> {
+ /// Presses in these rects do not close it: the button that opened it, the popovers it opened.
+ pub rects: &'a [Rect],
+ /// Escape belongs to something above it (a field being typed in, a picker of its own).
+ pub escape: bool,
+}
+
+/// Where the popover shows.
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Place {
+ /// From [`Open`], until the header drags it.
+ Auto,
+ /// Here: put down by the caller or by the drag.
+ At(Pos2),
+}
+
+/// An open popover.
+pub struct Popover {
+ id: Id,
+ /// The header's line.
+ pub title: String,
+ width: f32,
+ pad: f32,
+ surface: Surface,
+ /// Whether a press outside it closes it.
+ outside: bool,
+ place: Place,
+ /// While the header is held down: where in the panel the pointer grabbed it.
+ grab: Option,
+ /// The panel, its header, its slot and the X, as drawn last frame.
+ pub rect: Rect,
+ pub header: Rect,
+ pub slot: Rect,
+ pub close: Rect,
+ /// The panel's size as laid out last frame.
+ size: Vec2,
+}
+
+impl Popover {
+ /// A popover titled `title`; `id` names its egui area, so it must be stable and unique.
+ pub fn new(id: impl std::hash::Hash + std::fmt::Debug, title: impl Into) -> Self {
+ Self::with_id(Id::unique(id), title)
+ }
+
+ /// A popover whose egui area is `id` itself — not a hash of it — so a caller that publishes the
+ /// id can find the area again (the colour picker's [`crate::colorpicker::window_id`]).
+ pub fn with_id(id: Id, title: impl Into) -> Self {
+ Self {
+ id,
+ title: title.into(),
+ width: WIDTH,
+ pad: SLOT_PAD,
+ surface: Surface::Panel,
+ outside: true,
+ place: Place::Auto,
+ grab: None,
+ rect: Rect::NOTHING,
+ header: Rect::NOTHING,
+ slot: Rect::NOTHING,
+ close: Rect::NOTHING,
+ size: vec2(WIDTH, 0.0),
+ }
+ }
+
+ /// `width` points wide instead of [`WIDTH`].
+ pub fn width(mut self, width: f32) -> Self {
+ self.width = width.max(1.0);
+ self.size.x = self.width;
+ self
+ }
+
+ /// `pad` points of margin around the slot instead of the default 16.
+ pub fn pad(mut self, pad: f32) -> Self {
+ self.pad = pad.max(0.0);
+ self
+ }
+
+ /// Drawn on the dark surface the style browser uses.
+ pub fn surface(mut self, surface: Surface) -> Self {
+ self.surface = surface;
+ self
+ }
+
+ /// Whether a press outside it closes it (the default). The colour picker stays open, so a
+ /// press on a row beside it keeps editing the paint it is on.
+ pub fn close_on_outside(mut self, yes: bool) -> Self {
+ self.outside = yes;
+ self
+ }
+
+ /// Its top-left at `pos`, instead of where [`Open`] would put it.
+ pub fn at(mut self, pos: Pos2) -> Self {
+ self.place = Place::At(pos);
+ self
+ }
+
+ /// Draws the panel and fills its slot with `body`; true when it asked to close — the X,
+ /// Escape or a press outside it (outside [`Hold`]'s rects) on this frame.
+ pub fn show(&mut self, ctx: &Context, open: Open, hold: Hold<'_>, body: impl FnOnce(&mut Ui)) -> bool {
+ let t = Tokens::get(ctx);
+ let dark = self.surface == Surface::Menu;
+ let screen = ctx.content_rect();
+ // The anchor rule needs the panel's height, which only a layout gives: the first frame
+ // lays it out invisibly and the next one places it — a jump would show.
+ let first = self.size.y <= 0.0;
+ let pos = match self.place {
+ Place::At(p) => clamp(p, self.size, screen),
+ Place::Auto => {
+ let p = match open {
+ Open::At(p) => p,
+ Open::Anchor(a) => open_pos(a, self.size, screen),
+ };
+ clamp(p, self.size, screen)
+ }
+ };
+ let mut close = false;
+ egui::Area::new(self.id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
+ if first {
+ ui.set_invisible();
+ ctx.request_repaint();
+ }
+ // The panel is painted into a place kept before the content: the shadow and the fill
+ // go under it, though its size is only known once the slot is laid out.
+ let bg = ui.painter().add(egui::Shape::Noop);
+ let spacing = ui.spacing().item_spacing;
+ ui.spacing_mut().item_spacing = Vec2::ZERO;
+ // 1. The header: the title, then the X at the right.
+ let (header, _) = ui.allocate_exact_size(vec2(self.width, HEADER_H), Sense::hover());
+ let close_rect = Rect::from_min_size(pos2(header.max.x - CLOSE_RIGHT - CLOSE, header.center().y - CLOSE / 2.0), Vec2::splat(CLOSE));
+ let handle = Rect::from_min_max(header.min, pos2(close_rect.min.x - 6.0, header.max.y));
+ let drag = ui.interact(handle, self.id.with("drag"), Sense::drag());
+ let font = semibold(11.0);
+ let title = widgets::elide_font(ui, &self.title, (handle.width() - TITLE_LEFT - 8.0).max(0.0), font.clone());
+ ui.painter().text(pos2(header.min.x + TITLE_LEFT, header.center().y), Align2::LEFT_CENTER, title, font, header_text(t, dark));
+ close = close_button(ui, self.id.with("close"), close_rect, dark, t);
+ // 2. The header drags the panel (grabbed where the press landed, so the first frame of
+ // a fast drag counts too); the X keeps its own clicks.
+ if drag.hovered() && ctx.input(|i| i.pointer.primary_pressed()) {
+ self.grab = ctx.input(|i| i.pointer.interact_pos()).map(|at| at - pos);
+ }
+ if drag.dragged()
+ && let (Some(grab), Some(at)) = (self.grab, drag.interact_pointer_pos())
+ {
+ self.place = Place::At(clamp(at - grab, self.size, screen));
+ }
+ if ctx.input(|i| i.pointer.any_released()) {
+ self.grab = None;
+ }
+ if drag.dragged() || drag.hovered() {
+ ui.ctx().set_cursor_icon(if drag.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
+ }
+ // 3. The slot, under a hairline.
+ let frame = egui::Frame::NONE.inner_margin(egui::Margin::same(self.pad as i8)).show(ui, |ui| {
+ ui.spacing_mut().item_spacing = spacing;
+ ui.set_width(self.width - 2.0 * self.pad);
+ body(ui);
+ ui.min_rect()
+ });
+ let slot = frame.inner;
+ let panel = Rect::from_min_size(header.min, vec2(self.width, frame.response.rect.max.y - header.min.y));
+ let shadow = ctx.global_style().visuals.popup_shadow;
+ let fill = if dark { t.menu } else { t.panel };
+ let shapes = vec![
+ egui::Shape::from(shadow.as_shape(panel, RADIUS)),
+ RectShape::filled(panel, CornerRadius::same(RADIUS), fill).into(),
+ // A point of the theme's border colour under the header.
+ RectShape::filled(
+ Rect::from_min_max(pos2(panel.min.x, header.max.y - 1.0), pos2(panel.max.x, header.max.y)),
+ CornerRadius::ZERO,
+ if dark { Color32::from_white_alpha(24) } else { t.border },
+ )
+ .into(),
+ ];
+ ui.painter().set(bg, egui::Shape::Vec(shapes));
+ self.rect = panel;
+ self.header = header;
+ self.slot = slot;
+ self.close = close_rect;
+ self.size = panel.size();
+ });
+ // Closing is the widget's: the X above, Escape and a press outside (a child popup or a
+ // held-out rect keeps it open, so the button that opened it can click it shut).
+ let popup_open = egui::Popup::is_any_open(ctx);
+ let escape = !hold.escape && !popup_open && ctx.input(|i| i.key_pressed(egui::Key::Escape));
+ let outside = self.outside
+ && !popup_open
+ && ctx.input(|i| {
+ i.pointer.any_pressed()
+ && i.pointer.interact_pos().is_some_and(|at| !self.rect.contains(at) && !hold.rects.iter().any(|r| r.contains(at)))
+ });
+ close || escape || outside
+ }
+}
+
+fn header_text(t: Tokens, dark: bool) -> Color32 {
+ if dark { t.menu_text } else { t.text }
+}
+
+/// The X: a 24-point button, its icon inset 6 (the colour picker's header).
+fn close_button(ui: &mut Ui, id: Id, rect: Rect, dark: bool, t: Tokens) -> bool {
+ let resp = ui.interact(rect, id, Sense::click()).tip(tr("Close"));
+ if resp.hovered() {
+ ui.painter().rect_filled(rect, CornerRadius::same(5), if dark { Color32::from_white_alpha(20) } else { t.hover });
+ }
+ icons::paint(ui.painter(), rect.shrink(6.0), Icon::Close, if dark { t.menu_text } else { t.icon });
+ resp.clicked()
+}
+
+/// The opening position: the right edge on `anchor.x` (the right panel's left edge), the top on
+/// `anchor.y` (the button's top) — lifted, when that runs its bottom past the screen's bottom, to
+/// keep [`SCREEN_GAP`] under it.
+fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 {
+ let bottom = screen.max.y - SCREEN_GAP;
+ let y = if anchor.y + size.y > bottom { bottom - size.y } else { anchor.y };
+ pos2(anchor.x - size.x, y)
+}
+
+/// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't.
+fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 {
+ let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE);
+ let x = pos.x.clamp(x0, (screen.max.x - size.x - EDGE).max(x0));
+ let y = pos.y.clamp(y0, (screen.max.y - size.y - EDGE).max(y0));
+ pos2(x, y)
+}
diff --git a/crates/ui-egui/src/popover_tests.rs b/crates/ui-egui/src/popover_tests.rs
new file mode 100644
index 00000000..713e8d39
--- /dev/null
+++ b/crates/ui-egui/src/popover_tests.rs
@@ -0,0 +1,175 @@
+//! The shared popover drawn on its own: 240 points wide, a 40-high header with the title 16 in
+//! from the left and the X 8 in from the right, the slot under a hairline, the opening rule
+//! against an anchor (right edge on it, top aligned, lifted when that would run off the screen),
+//! the header dragging it, and closing on the X, Escape and a press outside — but not on a press
+//! in a held-out rect, so the button that opened it can toggle it shut.
+
+use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
+
+use crate::popover::{HEADER_H, Hold, Open, Popover, WIDTH};
+
+const SCREEN: egui::Vec2 = vec2(1440.0, 900.0);
+
+/// A context with the UI's fonts: the header's title is drawn in the semibold family.
+fn context() -> egui::Context {
+ let ctx = egui::Context::default();
+ crate::theme::install_fonts(&ctx, false, 0);
+ ctx
+}
+
+fn input(t: &mut f64, events: Vec) -> RawInput {
+ *t += 1.0 / 60.0;
+ RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), time: Some(*t), events, ..Default::default() }
+}
+
+fn press(p: Pos2, pressed: bool) -> Vec {
+ vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }]
+}
+
+fn escape() -> Vec {
+ vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]
+}
+
+/// One frame of `p` opened at `open`, its slot holding a row: the shapes it painted and whether it
+/// asked to close.
+fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open, hold: Hold<'_>, events: Vec) -> (Vec, bool) {
+ let mut closed = false;
+ let mut out = ctx.run_ui(input(t, events), |ui| {
+ closed = p.show(ui.ctx(), open, hold, |ui| {
+ ui.label("slot");
+ });
+ });
+ out.textures_delta.clear();
+ (crate::live_tests::flat(&out.shapes), closed)
+}
+
+/// Two frames: the first lays the panel out to learn its height, the second places and paints it.
+fn settle(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open) -> Vec {
+ draw(ctx, t, p, open, Hold::default(), vec![]);
+ draw(ctx, t, p, open, Hold::default(), vec![]).0
+}
+
+fn near(a: Pos2, b: Pos2) -> bool {
+ (a - b).length() < 0.05
+}
+
+#[test]
+fn the_header_is_40_high_with_the_title_16_in_and_the_x_8_in() {
+ let ctx = context();
+ let mut t = 0.0;
+ let mut p = Popover::new("popover_header", "Custom");
+ let shapes = settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
+ assert_eq!(p.rect.width(), WIDTH, "240 points wide");
+ assert!(near(p.rect.min, pos2(160.0, 100.0)), "where it was put: {:?}", p.rect);
+ assert_eq!(p.header.height(), HEADER_H, "the header is 40 high");
+ // The X: 24 square, its right edge 8 in from the panel's, centred on the header's line.
+ assert!((p.close.max.x - (p.rect.max.x - 8.0)).abs() < 0.01, "the X is 8 in from the right: {:?}", p.close);
+ assert!((p.close.center().y - p.header.center().y).abs() < 0.01, "the X is on the header's line");
+ // The title: one line, 16 in from the left, on the header's middle.
+ let (_, at, size) = crate::live_tests::texts(&shapes).into_iter().find(|(s, _, _)| s == "Custom").expect("the title shows");
+ assert!((at.x - (p.rect.min.x + 16.0)).abs() < 0.5, "the title starts 16 in: {at:?}");
+ assert!((at.y + size.y / 2.0 - p.header.center().y).abs() < 1.0, "the title is on the header's line: {at:?}");
+ // The slot: 16 in from the panel's sides, under the header.
+ assert!((p.slot.min.x - (p.rect.min.x + 16.0)).abs() < 0.01, "the slot starts 16 in: {:?}", p.slot);
+ assert!((p.slot.min.y - (p.header.max.y + 16.0)).abs() < 0.01, "the slot starts under the header: {:?}", p.slot);
+}
+
+#[test]
+fn it_opens_with_its_right_edge_on_the_anchor_and_its_top_aligned() {
+ let ctx = context();
+ let mut t = 0.0;
+ let mut p = Popover::new("popover_anchor", "Popup");
+ settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 200.0)));
+ assert!((p.rect.max.x - 900.0).abs() < 0.01, "its right edge on the anchor: {:?}", p.rect);
+ assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect);
+}
+
+#[test]
+fn it_lifts_so_its_bottom_keeps_16_from_the_screens_bottom() {
+ let ctx = context();
+ let mut t = 0.0;
+ let mut p = Popover::new("popover_lift", "Popup");
+ settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 860.0)));
+ // Aligning the top would run it off: it rises so its bottom sits 16 above the screen's.
+ assert!((p.rect.max.y - (SCREEN.y - 16.0)).abs() < 0.01, "its bottom is 16 above the screen's: {:?}", p.rect);
+ assert!(p.rect.min.y < 860.0, "it was lifted: {:?}", p.rect);
+}
+
+#[test]
+fn the_header_drags_it_and_the_slot_does_not() {
+ let ctx = context();
+ let mut t = 0.0;
+ let mut p = Popover::new("popover_drag", "Popup");
+ settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
+ let at = Open::At(pos2(160.0, 100.0));
+ let (from, to) = (pos2(200.0, 120.0), pos2(250.0, 180.0));
+ let before = p.rect.min;
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
+ assert!(near(p.rect.min, before + (to - from)), "the header dragged it by the pointer's move: {:?} from {before:?}", p.rect.min);
+ // The slot does not drag it.
+ let (from, to) = (p.slot.center(), p.slot.center() + vec2(-70.0, -40.0));
+ let held = p.rect.min;
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
+ assert!(near(p.rect.min, held), "a press in the slot leaves it: {:?} from {held:?}", p.rect.min);
+}
+
+#[test]
+fn the_x_and_escape_close_it() {
+ let ctx = context();
+ let mut t = 0.0;
+ let mut p = Popover::new("popover_close", "Popup");
+ settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
+ let at = Open::At(pos2(160.0, 100.0));
+ let close = p.close.center();
+ assert!(!draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, true)).1, "a press is not a click yet");
+ assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, false)).1, "the X asks for the close");
+ assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), escape()).1, "Escape asks for the close");
+ // Escape that belongs to something above it does not.
+ let mut p = Popover::new("popover_escape_held", "Popup");
+ settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
+ let hold = Hold { rects: &[], escape: true };
+ assert!(!draw(&ctx, &mut t, &mut p, at, hold, escape()).1, "a held Escape leaves it open");
+}
+
+#[test]
+fn a_press_outside_closes_it_but_not_on_the_button_that_opened_it() {
+ let ctx = context();
+ let mut t = 0.0;
+ let mut p = Popover::new("popover_outside", "Popup");
+ settle(&ctx, &mut t, &mut p, Open::At(pos2(300.0, 200.0)));
+ let at = Open::At(pos2(300.0, 200.0));
+ // The button that opened it: a press there leaves it open, so its release can toggle it shut.
+ let opener = Rect::from_min_size(pos2(60.0, 60.0), vec2(24.0, 24.0));
+ let hold = Hold { rects: &[opener], escape: false };
+ let on_opener = opener.center();
+ assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, true)).1, "a press on the opener leaves it open");
+ assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, false)).1, "and so does its release");
+ // A press anywhere else closes it, before the release.
+ let elsewhere = pos2(900.0, 400.0);
+ assert!(draw(&ctx, &mut t, &mut p, at, hold, press(elsewhere, true)).1, "a press outside closes it");
+}
+
+#[test]
+fn a_caller_can_put_it_where_it_likes() {
+ let ctx = context();
+ let mut t = 0.0;
+ // Placed outright, the opening rule no longer matters.
+ let mut p = Popover::new("popover_at", "Popup").at(pos2(300.0, 250.0));
+ settle(&ctx, &mut t, &mut p, Open::At(pos2(900.0, 100.0)));
+ assert!(near(p.rect.min, pos2(300.0, 250.0)), "where the caller put it: {:?}", p.rect);
+ // Nor after a drag: it stays where it was dropped.
+ let at = Open::At(pos2(900.0, 100.0));
+ let (from, to) = (pos2(340.0, 270.0), pos2(400.0, 320.0));
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));
+ draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
+ assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect);
+}
diff --git a/crates/ui-egui/src/resources_tests.rs b/crates/ui-egui/src/resources_tests.rs
index 42869098..d105f24d 100644
--- a/crates/ui-egui/src/resources_tests.rs
+++ b/crates/ui-egui/src/resources_tests.rs
@@ -1,19 +1,14 @@
-//! The toolbar's Resources button and Shift+I open the Assets panel (#710), from a collapsed
-//! sidebar and from the version history too.
+//! Shift+I opens the Assets panel (#710), from a collapsed sidebar and from the version history
+//! too — and the toolbar has no Resources button for it any more (the left rail's Assets tab,
+//! Shift+I, Alt+2 and View ▸ Panels ▸ Assets are the ways in).
use egui::accesskit::Role;
-use egui::{Event, Key, Modifiers, Pos2, pos2};
+use egui::{Event, Key, Modifiers};
use figcraft_engine::Session;
use crate::LeftTab;
use crate::layers_drag_tests::Ui;
-fn click(u: &mut Ui, p: Pos2) {
- u.button(p, true);
- u.button(p, false);
- u.frame(vec![]);
-}
-
fn shift_i(u: &mut Ui) {
let modifiers = Modifiers::SHIFT;
u.frame(vec![
@@ -23,22 +18,11 @@ fn shift_i(u: &mut Ui) {
u.frame(vec![]);
}
-/// The middle of the toolbar's Resources button, found in the AccessKit tree.
-fn resources_button(u: &mut Ui) -> Pos2 {
- u.ctx.enable_accesskit();
- u.frame(vec![]);
- let out = u.frame_output(vec![]);
- let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
- let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some("Resources")).expect("Resources");
- let b = n.bounds().expect("bounds");
- pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
-}
-
fn assets_shown(u: &Ui) -> bool {
u.app.ui.left_tab == LeftTab::Assets && !u.app.ui.left_collapsed && !u.app.ui.versions.open
}
-/// The three states the sidebar can be in when Resources is asked for.
+/// The three states the sidebar can be in when Shift+I is pressed.
fn starts() -> Vec<(&'static str, Ui)> {
let shown = Ui::new(Session::new());
let mut collapsed = Ui::new(Session::new());
@@ -48,15 +32,6 @@ fn starts() -> Vec<(&'static str, Ui)> {
vec![("layers", shown), ("collapsed", collapsed), ("version history", versions)]
}
-#[test]
-fn the_resources_button_opens_the_assets_panel() {
- for (name, mut u) in starts() {
- let at = resources_button(&mut u);
- click(&mut u, at);
- assert!(assets_shown(&u), "{name}: the Assets panel is open");
- }
-}
-
#[test]
fn shift_i_opens_the_assets_panel() {
for (name, mut u) in starts() {
@@ -65,3 +40,27 @@ fn shift_i_opens_the_assets_panel() {
assert!(assets_shown(&u), "{name}: the Assets panel is open");
}
}
+
+/// The toolbar's buttons, as screen readers get them.
+fn toolbar_labels(u: &mut Ui) -> Vec {
+ u.ctx.enable_accesskit();
+ u.frame(vec![]);
+ let out = u.frame_output(vec![]);
+ out.platform_output
+ .accesskit_update
+ .expect("an AccessKit update")
+ .nodes
+ .iter()
+ .filter(|(_, n)| n.role() == Role::Button)
+ .filter_map(|(_, n)| n.label().map(str::to_string))
+ .collect()
+}
+
+#[test]
+fn the_toolbar_has_no_resources_button() {
+ let mut u = Ui::new(Session::new());
+ let labels = toolbar_labels(&mut u);
+ // Actions stays (the palette's button); Resources went (#1210).
+ assert!(labels.iter().any(|l| l == "Actions"), "Actions is in the toolbar: {labels:?}");
+ assert!(!labels.iter().any(|l| l == "Resources"), "no Resources button: {labels:?}");
+}
diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
index e8832802..5a0933e4 100644
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -73,11 +73,6 @@ pub fn tip(label: &'static str, id: &str) -> &'static str {
cached_tip(label, shortcut(id))
}
-/// A tooltip naming a key ("Resources ⇧I").
-pub fn key_tip(label: &'static str, key: &'static str) -> &'static str {
- cached_tip(label, Some(key))
-}
-
/// Panels ask for their tooltips every frame: each (label, key, platform) is built once.
fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str {
type Entry = (Platform, &'static str, &'static str, &'static str);
@@ -459,6 +454,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re
if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) {
return true;
}
+ // The colour picker open: Escape closes it here, before the binding deselects — unless one of
+ // its own dropdowns is open, which takes this Escape.
+ if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) {
+ return true;
+ }
if key_box(app, key, m) {
return true;
}
diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs
index a5795a8c..dcb0ccf0 100644
--- a/crates/ui-egui/src/toolbar.rs
+++ b/crates/ui-egui/src/toolbar.rs
@@ -21,6 +21,19 @@ const CARET: f32 = 14.0;
const FLYOUT_W: f32 = 16.0;
const FLYOUT_GAP: f32 = 2.0;
+// The pill's grid: every state of it (the tools, the vector and shape builder ones) is laid out on
+// one 32 pt cell, PAD in from its ends and GAP between its top-level items, the divider included.
+/// A toolbar button's size: the cell everything in the pill is placed on.
+const TOOL_CELL: f32 = 32.0;
+/// The pill's left and right padding.
+const PAD: f32 = 8.0;
+/// Between the pill's top-level items — the divider included.
+const GAP: f32 = 8.0;
+/// The divider's width.
+const DIVIDER: f32 = 1.0;
+/// The mode switcher's marks: smaller than the tools' (13–14 pt).
+const MODE_GLYPH: f32 = 14.0;
+
/// `GLYPH`'s box centred in `r`.
fn glyph_rect(r: Rect) -> Rect {
Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH))
@@ -129,17 +142,18 @@ pub(crate) fn bar_width(app: &FigApp) -> f32 {
main_width(if board { BOARD_GROUPS } else { GROUPS }, board)
}
-/// The main toolbar pill's width: the tool groups with their flyout carets, Resources and
-/// Actions, and the mode switcher (boards: the Design panel's button instead).
+/// The main toolbar pill's width: the tool groups with their flyout carets, Actions, the divider
+/// and the mode switcher (boards: the templates and the Design panel's buttons instead).
fn main_width(groups: &[Group], board: bool) -> f32 {
- let (cell, gap) = (TOOL_CELL, 4.0);
- let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * (FLYOUT_GAP + FLYOUT_W);
- let end = if board { 2.0 * cell + gap + 16.0 } else { 4.0 * 34.0 + 24.0 };
- groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end
+ let tools: f32 = groups.iter().map(|g| TOOL_CELL + if g.has_flyout() { FLYOUT_GAP + FLYOUT_W } else { 0.0 }).sum();
+ // The end, and the gaps that aren't a group's own: after Actions and after the divider (and
+ // between a board's two buttons, which the end counts as one item).
+ let (end, gaps) = if board { (2.0 * TOOL_CELL, 3.0) } else { (4.0 * TOOL_CELL, 2.0) };
+ PAD * 2.0 + tools + (groups.len() as f32 + gaps) * GAP + TOOL_CELL + DIVIDER + end
}
-/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the
-/// Draw · Design · Prototype · Inspect mode switcher (on a board, the Design panel's button).
+/// The main toolbar pill: the tool groups with their flyouts, Actions, then the
+/// Draw · Design · Prototype · Inspect mode switcher (on a board, its two buttons).
fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: bool) {
let groups = if board { BOARD_GROUPS } else { GROUPS };
let width = main_width(groups, board);
@@ -148,16 +162,16 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board:
let scroll = pill_scroll(ui, rect, width);
let outer_clip = ui.clip_rect();
ui.set_clip_rect(rect.intersect(outer_clip));
- let mut x = rect.min.x + 8.0 - scroll;
+ let mut x = rect.min.x + PAD - scroll;
for g in groups {
x = group_cell(app, ui, g, pos2(x, cy), rect.min.y, t);
}
- x = side_buttons(app, ui, x, cy, t);
+ x = actions_button(app, ui, x, cy, t);
divider(ui, t, x, cy);
- x += 8.0;
+ x += DIVIDER + GAP;
if board {
// Boards have no prototypes or Dev Mode; the Design panel is hidden until asked for.
- x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t);
+ x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t) + GAP;
let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
let tip = crate::shortcuts::tip(tr("Design panel"), "view.design");
if tool_cell(ui, t, r, "board_panel", Icon::Panels, app.ui.board_panel, tip).clicked() {
@@ -185,9 +199,6 @@ fn pill_scroll(ui: &Ui, rect: Rect, width: f32) -> f32 {
scroll
}
-/// A toolbar button's size.
-const TOOL_CELL: f32 = 32.0;
-
/// Tool group `g` at `at` (its left edge, the pill's middle): the shown tool's button, its flyout
/// caret and, while open, its flyout above the pill (whose top is `top`). Where the next starts.
fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32, t: &Tokens) -> f32 {
@@ -223,7 +234,7 @@ fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32
options_flyout(app, ui, g, pos2(r.min.x, top - 8.0));
}
}
- x + 4.0
+ x + GAP
}
/// The caret after group `g`'s button (at `at`, its left edge and middle): opens or closes its
@@ -245,36 +256,23 @@ fn flyout_caret(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, t: &To
}
}
-/// Resources (components, plugins: the Assets panel) and the Actions palette's button, from `x`.
-/// Where the next part starts.
-fn side_buttons(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 {
- for (icon, tip, id) in [
- (Icon::Assets, crate::shortcuts::key_tip(tr("Resources"), "Shift+I"), "resources"),
- (Icon::Actions, crate::shortcuts::tip(tr("Actions"), "app.actions"), "actions"),
- ] {
- let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
- let resp = ui.interact(r, ui.scope_id().with(id), Sense::click()).tip(tip);
- if id == "actions" {
- app.ui.actions_rect = Some(r);
- }
- // The Actions button reads on while its panel is open; clicking it again closes it.
- let actions_open = id == "actions" && app.ui.palette.is_some();
- if actions_open {
- ui.painter().rect_filled(r, CornerRadius::same(5), t.accent);
- } else if resp.hovered() {
- ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
- }
- icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { t.accent_on } else { t.icon });
- if resp.clicked() {
- if id == "actions" {
- crate::menus::toggle_palette(&mut app.ui);
- } else {
- app.run("view.assets", json!({}));
- }
- }
- x += TOOL_CELL + 4.0;
+/// The Actions palette's button, from `x`. Where the next part starts.
+fn actions_button(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) -> f32 {
+ let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
+ let resp = ui.interact(r, ui.scope_id().with("actions"), Sense::click()).tip(crate::shortcuts::tip(tr("Actions"), "app.actions"));
+ app.ui.actions_rect = Some(r);
+ // The Actions button reads on while its panel is open; clicking it again closes it.
+ let open = app.ui.palette.is_some();
+ if open {
+ ui.painter().rect_filled(r, CornerRadius::same(5), t.accent);
+ } else if resp.hovered() {
+ ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
- x
+ icons::paint(ui.painter(), glyph_rect(r), Icon::Actions, if open { t.accent_on } else { t.icon });
+ if resp.clicked() {
+ crate::menus::toggle_palette(&mut app.ui);
+ }
+ x + TOOL_CELL + GAP
}
/// The mode switcher from `x`: Draw · Design · Prototype · Inspect (code).
@@ -286,19 +284,24 @@ fn mode_switcher(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) {
crate::RightTab::Prototype => 2,
crate::RightTab::Inspect => 3,
};
- let seg = Rect::from_min_size(pos2(x, cy - 18.0), vec2(4.0 * 34.0 + 4.0, 36.0));
- ui.painter().rect_filled(seg, CornerRadius::same(8), t.field);
+ let seg = Rect::from_min_size(pos2(x, cy - TOOL_CELL / 2.0), vec2(4.0 * TOOL_CELL, TOOL_CELL));
+ ui.painter().rect_filled(seg, CornerRadius::same(5), t.field);
for (i, (icon, tip)) in modes.iter().enumerate() {
- let r = Rect::from_min_size(pos2(seg.min.x + 2.0 + i as f32 * 34.0, seg.min.y + 2.0), vec2(34.0, 32.0));
+ let r = Rect::from_min_size(pos2(seg.min.x + i as f32 * TOOL_CELL, seg.min.y), vec2(TOOL_CELL, TOOL_CELL));
let resp = ui.interact(r, ui.scope_id().with(("mode", i)), Sense::click()).on_hover_text(*tip);
a11y::toggle(&resp, tip, i == active);
if i == active {
- ui.painter().rect_filled(r, CornerRadius::same(6), t.panel);
- ui.painter().rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, t.border), StrokeKind::Inside);
+ // The current mode lifts with a plain inset, no outline.
+ ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(3), t.panel);
} else if resp.hovered() {
- ui.painter().rect_filled(r, CornerRadius::same(6), t.hover);
+ ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
- icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary });
+ icons::paint(
+ ui.painter(),
+ Rect::from_center_size(r.center(), vec2(MODE_GLYPH, MODE_GLYPH)),
+ *icon,
+ if i == active { t.accent_text } else { t.icon_secondary },
+ );
if resp.clicked() {
pick_mode(app, i);
}
@@ -359,15 +362,14 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
app.run("tool.set", json!({"tool": "move"}));
}
let label = if why == figcraft_engine::live::PUBLISHING { tr("Publishing…") } else { tr("View only") };
- let cell = 32.0;
let text = ui.painter().layout_no_wrap(label.to_string(), crate::theme::semibold(11.5), t.text);
let badge_w = 12.0 + 14.0 + 6.0 + text.size().x + 12.0;
- let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + badge_w + 8.0;
+ let width = PAD * 2.0 + 2.0 * (TOOL_CELL + GAP) + DIVIDER + GAP + badge_w;
let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);
let cy = rect.center().y;
- let mut x = rect.min.x + 8.0;
+ let mut x = rect.min.x + PAD;
for (tool, icon) in [(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)] {
- let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
+ let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
let active = app.session.tool.tool == tool;
let resp = tool_cell(ui, &t, r, ("tool", tool.id()), icon, active, &format!("{} {}", tr(tool.label()), pretty_shortcut(tool.shortcut())));
if active {
@@ -376,10 +378,10 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
if resp.clicked() {
app.run("tool.set", json!({"tool": tool.id()}));
}
- x += cell + 4.0;
+ x += TOOL_CELL + GAP;
}
divider(ui, &t, x, cy);
- let badge = Rect::from_min_size(pos2(x + 12.0, cy - 13.0), vec2(badge_w, 26.0));
+ let badge = Rect::from_min_size(pos2(x + DIVIDER + GAP, cy - 13.0), vec2(badge_w, 26.0));
let resp = ui.interact(badge, ui.scope_id().with("view_only"), Sense::hover()).on_hover_text(why);
a11y::status(&resp, label, why);
ui.painter().rect_filled(badge, CornerRadius::same(13), t.field);
@@ -391,15 +393,14 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
let t = Tokens::get(ui.ctx());
let canvas = app.canvas.rect;
- let cell = 32.0;
- let width = VTool::ALL.len() as f32 * (cell + 4.0) + 8.0 + 12.0 + DONE_W + 8.0;
+ let width = VTool::ALL.len() as f32 * (TOOL_CELL + GAP) + PAD * 2.0 + DIVIDER + GAP + DONE_W;
let rect = pill(ui, &mut app.ui.nav, canvas, width, &t);
let cy = rect.center().y;
- let mut x = rect.min.x + 8.0;
+ let mut x = rect.min.x + PAD;
// Holding Cmd with the Move tool bends: show it.
let held = current == VTool::Move && ui.input(|i| i.modifiers.command);
for tool in VTool::ALL {
- let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
+ let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
let tip = if tool == VTool::Bend {
trf("{tool} hold {key}", &[("tool", &tr(tool.label())), ("key", &pretty_shortcut(tool.shortcut()))])
} else {
@@ -423,7 +424,7 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
if resp.clicked() {
app.run("vector.edit.tool", json!({"tool": tool.id()}));
}
- x += cell + 4.0;
+ x += TOOL_CELL + GAP;
}
if done_button(ui, &t, x, cy, &format!("{} Esc", tr("Done editing"))) {
app.run("vector.edit.end", json!({}));
@@ -433,7 +434,6 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
/// The pill shown while the shape builder is active: Merge and Remove modes, a hint and Done.
fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
- let cell = 32.0;
let remove = app.session.tool.builder_remove;
let has_regions = figcraft_engine::shapebuilder::current(&app.session).is_some_and(|a| !a.regions.is_empty());
let alt = pretty_shortcut("Alt");
@@ -445,12 +445,12 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
trf("Click or drag to merge · {alt} removes", &[("alt", &alt)])
};
let galley = ui.painter().layout_no_wrap(hint, regular(12.0), t.text_secondary);
- let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + galley.size().x + 12.0 + 12.0 + DONE_W + 8.0;
+ let width = PAD * 2.0 + 2.0 * (TOOL_CELL + GAP) + 2.0 * (DIVIDER + GAP) + galley.size().x + GAP + DONE_W;
let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);
let cy = rect.center().y;
- let mut x = rect.min.x + 8.0;
+ let mut x = rect.min.x + PAD;
for (mode, icon, tip) in [("merge", Icon::ShapeBuilder, tr("Merge regions")), ("remove", Icon::ShapeBuilderRemove, tr("Remove regions"))] {
- let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
+ let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));
let active = (mode == "remove") == remove;
let resp = tool_cell(ui, &t, r, ("sbmode", mode), icon, active, tip);
if active {
@@ -459,13 +459,13 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
if resp.clicked() {
app.run("shapeBuilder.mode", json!({"mode": mode}));
}
- x += cell + 4.0;
+ x += TOOL_CELL + GAP;
}
divider(ui, &t, x, cy);
- x += 12.0;
+ x += DIVIDER + GAP;
let w = galley.size().x;
ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, t.text_secondary);
- x += w + 12.0;
+ x += w + GAP;
if done_button(ui, &t, x, cy, &format!("{} {}", tr("Done"), pretty_shortcut("Enter"))) {
app.run("tool.set", json!({"tool": "move"}));
}
@@ -515,9 +515,10 @@ pub(crate) fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSal
resp
}
-/// A vertical separator just after `x`.
+/// A vertical separator just after `x`: DIVIDER wide, as tall as the pill's content.
fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) {
- ui.painter().line_segment([pos2(x + 2.0, cy - 14.0), pos2(x + 2.0, cy + 14.0)], Stroke::new(1.0, t.border));
+ let half = TOOL_CELL / 2.0;
+ ui.painter().line_segment([pos2(x + DIVIDER / 2.0, cy - half), pos2(x + DIVIDER / 2.0, cy + half)], Stroke::new(DIVIDER, t.border));
}
const DONE_W: f32 = 56.0;
@@ -525,7 +526,7 @@ const DONE_W: f32 = 56.0;
/// A divider at `x` and the accent Done button after it; true when clicked.
fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool {
divider(ui, t, x, cy);
- let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0));
+ let done = Rect::from_min_size(pos2(x + DIVIDER + GAP, cy - 14.0), vec2(DONE_W, 28.0));
let resp = ui.interact(done, ui.scope_id().with("tool_done"), Sense::click()).tip(tip);
ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { t.accent });
ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), t.accent_on);
@@ -642,12 +643,33 @@ mod tests {
let phone = Rect::from_min_size(pos2(0.0, 0.0), vec2(375.0, 700.0));
let pill = pill_rect(phone, full);
assert!(pill.width() <= phone.width() - 16.0 && pill.min.x >= 8.0 && pill.max.x <= phone.max.x - 8.0, "{pill:?}");
+ // Narrower than its items, so it scrolls under a drag or the wheel (#1209).
+ assert!(full > pill.width(), "{full} in {}", pill.width());
// A desktop canvas keeps the full width, and a degenerate one doesn't go negative.
let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0));
assert_eq!(pill_rect(wide, full).width(), full);
assert_eq!(pill_rect(Rect::from_min_size(pos2(0.0, 0.0), vec2(4.0, 4.0)), full).width(), 0.0);
}
+ /// The width the pill asks for is the width its items take — the groups, Actions, the divider
+ /// and the end (the mode switcher, or a board's two buttons), PAD in from either end and one
+ /// GAP apart — so its last item ends exactly PAD from its right edge.
+ #[test]
+ fn the_pill_is_as_wide_as_its_items() {
+ let canvas = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0));
+ for (groups, board) in [(GROUPS, false), (BOARD_GROUPS, true)] {
+ let pill = pill_rect(canvas, main_width(groups, board));
+ let mut x = pill.min.x + PAD;
+ for g in groups {
+ x += TOOL_CELL + if g.has_flyout() { FLYOUT_GAP + FLYOUT_W } else { 0.0 } + GAP;
+ }
+ x += TOOL_CELL + GAP; // Actions
+ x += DIVIDER + GAP; // the divider
+ let end = if board { 2.0 * TOOL_CELL + GAP } else { 4.0 * TOOL_CELL };
+ assert!((x + end - (pill.max.x - PAD)).abs() < 0.01, "board {board}: {} vs {}", x + end, pill.max.x - PAD);
+ }
+ }
+
/// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point.
fn widths(ppp: f32, icon: Icon, size: f32) -> Vec {
let ctx = egui::Context::default();
diff --git a/crates/ui-egui/src/ui_params_tests.rs b/crates/ui-egui/src/ui_params_tests.rs
new file mode 100644
index 00000000..6ee71dfc
--- /dev/null
+++ b/crates/ui-egui/src/ui_params_tests.rs
@@ -0,0 +1,77 @@
+//! Params the panels build at run time name only what their commands take: a name a command
+//! doesn't list is refused (#1413), which would break the control that sent it.
+
+use figcraft_doc::{ExportFormat, ExportSetting, NodeId, PrintSettings, VdCompat};
+use figcraft_engine::Session;
+use serde_json::{Value, json};
+
+use crate::{FigApp, NoServices};
+
+fn app_with(cmd: &str, params: Value) -> (FigApp, NodeId) {
+ let mut s = Session::new();
+ let id = NodeId(s.execute(cmd, ¶ms).unwrap()["id"].as_u64().unwrap());
+ (FigApp::new(s, Box::new(NoServices)), id)
+}
+
+/// The Export section: a setting with every option off its default exports in every format, and
+/// its edits (the setting's own keys, the PDF print options) apply.
+#[test]
+fn export_settings_with_every_option_are_taken() {
+ let (mut app, id) = app_with("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
+ app.session.execute("export.add", &json!({"ids": [id]})).unwrap();
+ let print = PrintSettings { bleed: 3.0, crop_marks: true, bleed_marks: true, cmyk: true };
+ for format in ExportFormat::ALL {
+ let setting = ExportSetting {
+ format,
+ print,
+ contents_only: false,
+ include_bounding_box: true,
+ svg_outline_text: false,
+ svg_id_attribute: true,
+ svg_simplify_stroke: false,
+ vd_compat: VdCompat::Exact,
+ ..ExportSetting::default()
+ };
+ let mut p = crate::export_panel::setting_params(&setting);
+ p["ids"] = json!([id]);
+ app.session.execute("file.export", &p).unwrap_or_else(|e| panic!("file.export {p}: {e}"));
+ p["index"] = json!(0);
+ app.session.execute("export.update", &p).unwrap_or_else(|e| panic!("export.update {p}: {e}"));
+ }
+ let mut p = crate::export_panel::print_params(&print);
+ p["ids"] = json!([id]);
+ p["index"] = json!(0);
+ app.session.execute("export.update", &p).unwrap_or_else(|e| panic!("export.update {p}: {e}"));
+}
+
+/// The Typography section on selected characters: every character property it sets goes through
+/// `text.style`, and paragraph spacing (the whole layer's) through `node.set`.
+#[test]
+fn typography_edits_on_selected_characters_are_taken() {
+ let (mut app, id) = app_with("text.create", json!({"text": "Typography", "x": 0, "y": 0}));
+ let range = Some((id, 0, 4));
+ for (key, v) in [
+ ("fontFamily", json!("Inter")),
+ ("fontStyle", json!("Bold")),
+ ("fontSize", json!(20)),
+ ("lineHeight", json!(24)),
+ ("letterSpacing", json!(1)),
+ ("textDecoration", json!("underline")),
+ ("textDecorationStyle", json!("wavy")),
+ ("textDecorationThickness", json!(2)),
+ ("textDecorationOffset", json!(3)),
+ ("textDecorationSkipInk", json!(false)),
+ ("textDecorationColor", json!("#FF0000")),
+ ("textCase", json!("upper")),
+ ] {
+ crate::typography::commit_text(&mut app, &[id], range, json!({ key: v }));
+ assert_eq!(app.ui.last_error, None, "{key}");
+ }
+ // A named instance of a variable font: its style, and the custom axis values cleared.
+ crate::typography::commit_text(&mut app, &[id], range, json!({"fontStyle": "Regular", "fontVariations": null}));
+ assert_eq!(app.ui.last_error, None, "fontVariations");
+ crate::typography::commit_text(&mut app, &[id], range, json!({"paragraphSpacing": 12}));
+ assert_eq!(app.ui.last_error, None, "paragraphSpacing");
+ let spacing = app.session.doc().unwrap().node(id).and_then(|n| n.kind.text()).map(|t| t.paragraph_spacing);
+ assert_eq!(spacing, Some(12.0));
+}
diff --git a/crates/ui-egui/src/widgets/board_picker.rs b/crates/ui-egui/src/widgets/board_picker.rs
index 6f7e509a..766bfece 100644
--- a/crates/ui-egui/src/widgets/board_picker.rs
+++ b/crates/ui-egui/src/widgets/board_picker.rs
@@ -25,7 +25,8 @@ const GAP: f32 = 8.0;
const TITLE_H: f32 = 26.0;
/// The templates button at `at` (its left edge, the toolbar's middle) and, while open, its picker
-/// above the toolbar (whose top is `top`). Where the next button starts.
+/// above the toolbar (whose top is `top`). The button's right edge: the toolbar puts the next
+/// button a GAP after it.
pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Tokens) -> f32 {
let r = Rect::from_min_size(pos2(at.x, at.y - 16.0), vec2(32.0, 32.0));
let open = app.ui.tool_flyout == Some(KEY);
@@ -39,7 +40,7 @@ pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Toke
if app.ui.tool_flyout == Some(KEY) {
picker(app, ui, r, top - 8.0, t);
}
- r.max.x + 4.0
+ r.max.x
}
fn area_id() -> egui::Id {
diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs
index 2f236a93..c3bd47c5 100644
--- a/crates/ui-egui/src/widgets/colorpicker.rs
+++ b/crates/ui-egui/src/widgets/colorpicker.rs
@@ -1,7 +1,7 @@
//! The colour picker popover: paint type, saturation/brightness square, hue and alpha sliders,
//! hex and RGB fields, gradient stops, image and pattern fills and document colours.
-use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
+use egui::{Align2, Color32, CornerRadius, Id, Mesh, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_color::{
BlendMode, Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint,
PatternTile,
@@ -13,7 +13,8 @@ use crate::FigApp;
use crate::a11y::Tip;
use crate::i18n::{tr, tr_noop, trf};
use crate::icons::{self, Icon};
-use crate::theme::{Tokens, doc_c32, regular, semibold};
+use crate::popover::{Hold, Open, Popover};
+use crate::theme::{Tokens, doc_c32, regular};
use crate::widgets;
#[derive(Clone, Debug, PartialEq)]
@@ -853,6 +854,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
finish(app);
}
app.ui.picker = None;
+ app.ui.picker_popover = None;
return;
};
let t = Tokens::get(ctx);
@@ -863,170 +865,184 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
_ => None,
};
let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0));
- let mut open = true;
- let mut close_now = false;
- egui::Window::new("color_picker")
- // A fixed area id so the style picker can tell a click in here from a click outside
- // (`Window::new` alone would key the area by `Id::unique(Some(title))`).
- .id(window_id())
- .title_bar(false)
- .fixed_pos(pos)
- .fixed_size(vec2(240.0, 0.0))
- .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(0)))
- .open(&mut open)
- .show(ctx, |ui| {
- ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
- // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...).
- if let Some(c) = app.session.tool.picked.take() {
- set_color(app, &target, &mut paint, c, stop_id(), ui);
+ // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared
+ // popover's chrome now; the picker keeps its body. Its area id is [`window_id`], the one the
+ // style picker looks up to tell a press in here from one outside.
+ let mut pop = app.ui.picker_popover.take().unwrap_or_else(|| Popover::with_id(window_id(), tr("Custom")).pad(0.0));
+ // Sampling a colour from the canvas — the eyedropper this picker's own button started, or the
+ // colour it just sampled — is the press, not a press outside: neither may close the picker.
+ let picking = app.session.tool.pick_only || app.session.tool.picked.is_some();
+ pop = pop.close_on_outside(!picking);
+ // Closing is the shared widget's: the X, Escape and a press outside it. Presses in the panel
+ // beside it keep it (swatches and fields there switch the colour it edits); the control that
+ // opened it is held too, so pressing it again reaches its own toggle.
+ let panel = Rect::from_min_max(pos2(screen.max.x - app.ui.right_width, screen.min.y), screen.max);
+ let mut held: Vec = vec![panel];
+ held.extend(app.ui.picker_button);
+ let closed = pop.show(ctx, Open::At(pos), Hold { rects: &held, escape: false }, |ui| {
+ ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
+ // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...).
+ if let Some(c) = app.session.tool.picked.take() {
+ set_color(app, &target, &mut paint, c, stop_id(), ui);
+ }
+ // Paint type.
+ if !matches!(
+ target,
+ PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
+ ) {
+ paint_type_row(ui, app, &target, &mut paint);
+ }
+ // The paint's own blend mode, with the paints and layers under it.
+ if !matches!(
+ target,
+ PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
+ ) {
+ blend_row(ui, app, &target, &mut paint);
+ }
+ // Gradient stops.
+ let mut edit_color: Option = match &paint.kind {
+ PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),
+ _ => None,
+ };
+ let sel_stop_id = stop_id();
+ if let PaintKind::Gradient(g) = paint.kind.clone() {
+ edit_color = gradient_controls(app, ui, &target, &mut paint, g, &t, sel_stop_id);
+ }
+ if let PaintKind::Image(img) = paint.kind.clone() {
+ image_controls(app, ui, &target, &mut paint, img, &t);
+ }
+ if let PaintKind::Pattern(pt) = paint.kind.clone() {
+ pattern_controls(app, ui, &target, &mut paint, pt);
+ }
+ if let Some(color) = edit_color {
+ let mut hsb = ui
+ .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb")))
+ .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8())
+ .map(|(h, s, b, _)| Hsb { h, s, b })
+ .unwrap_or_else(|| Hsb::from_color(color));
+ let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover());
+ let (c1, d1) = sv_square(ui, sq, &mut hsb);
+ let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());
+ let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0));
+ if icons::button(
+ &mut ui
+ .new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0)))),
+ Icon::Eyedropper,
+ 24.0,
+ false,
+ tr("Pick color (I)"),
+ )
+ .clicked()
+ {
+ app.run("edit.pickColor", json!({"apply": false}));
+ return;
}
- // Header.
- let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover());
- ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text);
- let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0));
- let cresp = ui.interact(cr, Id::unique("cp_close"), Sense::click());
- icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon);
- if cresp.clicked() {
- close_now = true;
+ let (c2, d2) = hue_slider(ui, hr, &mut hsb);
+ let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover());
+ let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0));
+ let mut new_alpha = None;
+ if let Some((a, d)) = alpha_slider(ui, ar, color) {
+ new_alpha = Some((a, d));
}
- // Paint type.
- if !matches!(
- target,
- PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
- ) {
- paint_type_row(ui, app, &target, &mut paint);
+ let mut nc = hsb.to_color().with_alpha(color.a);
+ let mut done = d1 || d2;
+ let mut changed = c1 || c2;
+ if let Some((a, d)) = new_alpha {
+ nc = nc.with_alpha(a);
+ changed = true;
+ done |= d;
}
- // The paint's own blend mode, with the paints and layers under it.
- if !matches!(
- target,
- PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
- ) {
- blend_row(ui, app, &target, &mut paint);
+ // Colour model fields (Hex, RGB, HSL, HSB, LCH, OKLCH or CSS) + alpha.
+ if let Some((c, d)) = model_fields(ui, nc) {
+ nc = c;
+ hsb = Hsb::from_color(nc);
+ changed = true;
+ done |= d;
}
- // Gradient stops.
- let mut edit_color: Option = match &paint.kind {
- PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),
- _ => None,
- };
- let sel_stop_id = stop_id();
- if let PaintKind::Gradient(g) = paint.kind.clone() {
- edit_color = gradient_controls(app, ui, &target, &mut paint, g, &t, sel_stop_id);
+ ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));
+ // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.
+ if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) {
+ gamut_warning(ui, &t);
}
- if let PaintKind::Image(img) = paint.kind.clone() {
- image_controls(app, ui, &target, &mut paint, img, &t);
- }
- if let PaintKind::Pattern(pt) = paint.kind.clone() {
- pattern_controls(app, ui, &target, &mut paint, pt);
- }
- if let Some(color) = edit_color {
- let mut hsb = ui
- .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb")))
- .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8())
- .map(|(h, s, b, _)| Hsb { h, s, b })
- .unwrap_or_else(|| Hsb::from_color(color));
- let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover());
- let (c1, d1) = sv_square(ui, sq, &mut hsb);
- let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());
- let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0));
- if icons::button(
- &mut ui.new_child(
- egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0))),
- ),
- Icon::Eyedropper,
- 24.0,
- false,
- tr("Pick color (I)"),
- )
- .clicked()
- {
- app.run("edit.pickColor", json!({"apply": false}));
- return;
- }
- let (c2, d2) = hue_slider(ui, hr, &mut hsb);
- let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover());
- let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0));
- let mut new_alpha = None;
- if let Some((a, d)) = alpha_slider(ui, ar, color) {
- new_alpha = Some((a, d));
- }
- let mut nc = hsb.to_color().with_alpha(color.a);
- let mut done = d1 || d2;
- let mut changed = c1 || c2;
- if let Some((a, d)) = new_alpha {
- nc = nc.with_alpha(a);
- changed = true;
- done |= d;
- }
- // Colour model fields (Hex, RGB, HSL, HSB, LCH, OKLCH or CSS) + alpha.
- if let Some((c, d)) = model_fields(ui, nc) {
- nc = c;
- hsb = Hsb::from_color(nc);
- changed = true;
- done |= d;
- }
- ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));
- // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.
- if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) {
- gamut_warning(ui, &t);
- }
- if changed {
- match &mut paint.kind {
- PaintKind::Solid { color } => {
- *color = nc.with_alpha(1.0);
- paint.opacity = nc.a;
- }
- PaintKind::Gradient(g) => {
- let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0);
- if let Some(s) = g.stops.get_mut(sel) {
- s.color = nc;
- }
- }
- PaintKind::Image(_) | PaintKind::Pattern(_) => {}
+ if changed {
+ match &mut paint.kind {
+ PaintKind::Solid { color } => {
+ *color = nc.with_alpha(1.0);
+ paint.opacity = nc.a;
}
- write(app, &target, &paint, !done);
- }
- if done {
- finish(app);
- }
- }
- // Colours used on this page.
- let doc_colors = page_colors(app, ui);
- if !doc_colors.is_empty() {
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- widgets::label(ui, tr("On this page"), t.text_secondary);
- });
- // Inset on both sides: `add_space` would only indent the first row of a wrap.
- inset(ui, |ui| {
- for c in &doc_colors {
- if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {
- set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
+ PaintKind::Gradient(g) => {
+ let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0);
+ if let Some(s) = g.stops.get_mut(sel) {
+ s.color = nc;
}
}
- });
+ PaintKind::Image(_) | PaintKind::Pattern(_) => {}
+ }
+ write(app, &target, &paint, !done);
}
- // Recent colours.
- let recent = app.session.recent_colors.clone();
- if !recent.is_empty() {
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- widgets::label(ui, tr("Recent"), t.text_secondary);
- });
- inset(ui, |ui| {
- for c in recent.iter().take(14) {
- if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {
- set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
- }
+ if done {
+ finish(app);
+ }
+ }
+ // Colours used on this page.
+ let doc_colors = page_colors(app, ui);
+ if !doc_colors.is_empty() {
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ widgets::label(ui, tr("On this page"), t.text_secondary);
+ });
+ // Inset on both sides: `add_space` would only indent the first row of a wrap.
+ inset(ui, |ui| {
+ for c in &doc_colors {
+ if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {
+ set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
}
- });
- }
- ui.add_space(12.0);
- });
- if !open || close_now || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
- finish(app);
- app.ui.picker = None;
+ }
+ });
+ }
+ // Recent colours.
+ let recent = app.session.recent_colors.clone();
+ if !recent.is_empty() {
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ widgets::label(ui, tr("Recent"), t.text_secondary);
+ });
+ inset(ui, |ui| {
+ for c in recent.iter().take(14) {
+ if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {
+ set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
+ }
+ }
+ });
+ }
+ ui.add_space(12.0);
+ });
+ if closed {
+ close(app);
+ } else if app.ui.picker.is_some() {
+ // Kept only while the picker is open: a body that closed it (the eyedropper) doesn't leave
+ // a stale place for the next picker to inherit.
+ app.ui.picker_popover = Some(pop);
}
- let _ = Pos2::ZERO;
+}
+
+/// Escape with the picker open closes it (the selection stays); false when it was not open, so the
+/// key goes on to the binding that deselects.
+pub fn close_if_open(app: &mut FigApp) -> bool {
+ if app.ui.picker.is_none() {
+ return false;
+ }
+ close(app);
+ true
+}
+
+/// The colour picker closes (its X, a press outside it, Escape, or another popover took over):
+/// pending edits commit and the picker with its popover go.
+pub fn close(app: &mut FigApp) {
+ finish(app);
+ app.ui.picker = None;
+ app.ui.picker_popover = None;
+ app.ui.picker_button = None;
}
/// Swatches wrapped in rows inset 16 points from both sides of the picker.
@@ -1324,7 +1340,7 @@ mod selection_color_tests {
s.execute("select.set", &json!({"ids":[a,b]})).unwrap();
let mut app = FigApp::new(s, Box::new(NoServices));
let targets = collect(app.session.doc().unwrap(), &[a, b])[0].targets.clone();
- crate::selection_colors::open_picker(&mut app, targets);
+ crate::selection_colors::toggle(&mut app, targets, None);
(app, a, b)
}
diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs
index aaf807ca..b1001a0e 100644
--- a/crates/ui-egui/src/widgets/style_picker.rs
+++ b/crates/ui-egui/src/widgets/style_picker.rs
@@ -19,11 +19,13 @@ use crate::a11y::Tip;
use crate::colorpicker::PickerTarget;
use crate::i18n::{tr, trf};
use crate::icons::{self, Icon};
+use crate::popover::{Hold, Open, Popover, Surface};
use crate::theme::{Tokens, regular, semibold};
use crate::widgets;
-/// Content width of the popover.
-const W: f32 = 240.0;
+/// The slot's margin inside the shared popover, and the content width left of it.
+const PAD: f32 = 8.0;
+const W: f32 = 224.0;
/// Height of a list row.
const ROW_H: f32 = 26.0;
/// Height of a text style's list row (its name in its own font).
@@ -167,6 +169,9 @@ impl Picker {
/// Open the picker for `slot` of `ids`, level with the pointer.
pub fn open(app: &mut FigApp, ui: &Ui, ids: &[NodeId], slot: StyleSlot) {
let y = ui.input(|i| i.pointer.interact_pos()).map_or(140.0, |p| p.y - 12.0);
+ // Opening the style picker closes the colour picker (it would linger beside it).
+ crate::colorpicker::close(app);
+ app.ui.style_picker_popover = None;
app.ui.style_picker = Some(Picker { slot, ids: ids.to_vec(), y, query: String::new(), edit: None });
}
@@ -174,6 +179,8 @@ pub fn open(app: &mut FigApp, ui: &Ui, ids: &[NodeId], slot: StyleSlot) {
pub fn open_for_selection(app: &mut FigApp, slot: StyleSlot, style: Option<&str>) {
let ids = app.session.selection();
let edit = style.and_then(|s| app.session.doc().ok()?.style(s).map(editing));
+ crate::colorpicker::close(app);
+ app.ui.style_picker_popover = None;
app.ui.style_picker = Some(Picker { slot, ids, y: 140.0, query: String::new(), edit });
}
@@ -516,9 +523,8 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut
let link = link_of(app, doc, &p.ids, slot);
let applied = link.clone().flatten().map(|id| Key::of_local(doc, &id));
let grid = slot.has_grid() && app.ui.style_grid;
- // Header: title, then view toggle, detach and create.
+ // The title is the popover's; this row keeps the view toggle, detach and create.
let (hr, _) = ui.allocate_exact_size(vec2(W, 24.0), Sense::hover());
- ui.painter().text(pos2(hr.min.x + 2.0, hr.center().y), Align2::LEFT_CENTER, slot.title(), semibold(11.0), t.menu_text);
let mut x = hr.max.x - 24.0;
let mut slot_rect = || {
let r = Rect::from_min_size(pos2(x, hr.min.y), vec2(24.0, 24.0));
@@ -973,7 +979,6 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle,
/// Editing one style: name, description, its values (paints open the colour picker; text styles
/// have their typography), update from the selection, delete.
fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {
- let t = Tokens::get(ui.ctx());
let slot = p.slot;
let has_selection = !p.ids.is_empty();
let Some(e) = p.edit.as_mut() else { return };
@@ -985,7 +990,6 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {
if menu_icon(ui, Rect::from_min_size(hr.min, vec2(24.0, 24.0)), Icon::ChevronLeft, tr("Back to styles"), false).clicked() {
out.act = Some(Act::Back);
}
- ui.painter().text(pos2(hr.min.x + 30.0, hr.center().y), Align2::LEFT_CENTER, tr("Edit style"), semibold(11.0), t.menu_text);
ui.add_space(6.0);
caption(ui, tr("Name"));
let r = ui.add(egui::TextEdit::singleline(&mut e.name).font(regular(11.0)).desired_width(W));
@@ -1074,9 +1078,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
if let Some(e) = &p.edit {
commit_edit(app, &doc, e);
}
+ app.ui.style_picker_popover = None;
return;
}
- let t = Tokens::get(ctx);
let screen = ctx.content_rect();
// Left of the Design panel; further left while the colour picker (which sits there) is open.
let mut x = screen.max.x - app.ui.right_width - W - 32.0;
@@ -1085,34 +1089,24 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
}
let y = p.y.clamp(screen.min.y + 48.0, (screen.max.y - 460.0).max(screen.min.y + 48.0));
let mut out = Frame { act: None, hover: None, budget: 4 };
- let area = egui::Area::new(Id::unique("style_picker_area")).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| {
- egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
- ui.set_width(W);
- if p.edit.is_some() {
- editor(ui, &doc, &mut p, &mut out);
- } else {
- listing(app, ui, &doc, &mut p, &mut out);
- }
- });
- });
- let rect = area.response.rect;
- ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("style_picker_border"))).rect_stroke(
- rect,
- CornerRadius::same(10),
- Stroke::new(1.0, Color32::from_black_alpha(40)),
- egui::StrokeKind::Inside,
- );
- // Clicks in the colour picker editing this picker's style, or in a menu the editor opened,
- // keep it open.
+ // The title and the X are the shared popover's now; the list keeps its tools row.
+ let title = if p.edit.is_some() { tr("Edit style") } else { p.slot.title() };
+ let mut pop = app.ui.style_picker_popover.take().unwrap_or_else(|| Popover::new("style_picker_area", title).pad(PAD).surface(Surface::Menu));
+ pop.title = title.to_string();
+ // Clicks in the colour picker editing this picker's style, in the variable picker binding one
+ // of its fields, or in a menu the editor opened, keep it open.
let picker_rect =
app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(crate::colorpicker::window_id())));
- // So do clicks in the variable picker binding one of its fields (Escape closes that first).
let variables = crate::variable_ui::picker_rect(ctx);
- let in_popup = egui::Popup::is_any_open(ctx) || variables.is_some();
- let outside = ctx.input(|i| {
- i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|pos| !rect.contains(pos) && !picker_rect.is_some_and(|r| r.contains(pos)))
- }) && !in_popup;
- let mut close = outside || (ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !in_popup);
+ let holds: Vec = picker_rect.into_iter().chain(variables).collect();
+ let closed = pop.show(ctx, Open::At(pos2(x, y)), Hold { rects: &holds, escape: variables.is_some() || picker_rect.is_some() }, |ui| {
+ if p.edit.is_some() {
+ editor(ui, &doc, &mut p, &mut out);
+ } else {
+ listing(app, ui, &doc, &mut p, &mut out);
+ }
+ });
+ let mut close = closed;
let ids = p.ids.clone();
let slot = p.slot;
// The canvas shows the text style under the pointer on the selected text.
@@ -1185,8 +1179,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
if matches!(app.ui.picker, Some(PickerTarget::Style { .. })) {
app.ui.picker = None;
}
+ app.ui.style_picker_popover = None;
} else {
app.ui.style_picker = Some(p);
+ app.ui.style_picker_popover = Some(pop);
}
}
@@ -1194,6 +1190,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
fn edit_local(app: &mut FigApp, ui: &Ui, slot: StyleSlot, id: &str) {
let y = ui.input(|i| i.pointer.interact_pos()).map_or(140.0, |p| p.y - 12.0);
let edit = app.session.doc().ok().and_then(|d| d.style(id).map(editing));
+ crate::colorpicker::close(app);
+ app.ui.style_picker_popover = None;
app.ui.style_picker = Some(Picker { slot, ids: app.session.selection(), y, query: String::new(), edit });
}
diff --git a/crates/ui-egui/src/widgets/style_picker/tests.rs b/crates/ui-egui/src/widgets/style_picker/tests.rs
index b732c11d..fa996374 100644
--- a/crates/ui-egui/src/widgets/style_picker/tests.rs
+++ b/crates/ui-egui/src/widgets/style_picker/tests.rs
@@ -323,17 +323,10 @@ fn text_variables_session(bind: bool) -> crate::layers_drag_tests::Ui {
u
}
-/// The left edge of the style editor's content (its "Edit style" title starts 30 px in).
+/// The left edge of the style editor's content: the shared popover's slot, inside its margin.
fn editor_left(u: &mut crate::layers_drag_tests::Ui) -> f32 {
- let mut left = None;
- for c in u.frame_output(vec![]).shapes {
- if let egui::Shape::Text(t) = &c.shape
- && t.galley.text() == "Edit style"
- {
- left = Some(t.pos.x - 30.0);
- }
- }
- left.expect("the editor's title")
+ u.frame_output(vec![]);
+ u.app.ui.style_picker_popover.as_ref().expect("the editor's popover").slot.min.x
}
/// The texts drawn in one frame, with their centers.
diff --git a/docs/boards-parity.md b/docs/boards-parity.md
index 69526a18..116295b4 100644
--- a/docs/boards-parity.md
+++ b/docs/boards-parity.md
@@ -27,7 +27,7 @@ all end with the same geometry.
| Feature | Status | Notes |
|---|---|---|
| Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) |
-| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Resources, Actions, the Design panel's button |
+| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Actions, then the templates and the Design panel's buttons — on the same 32 pt grid as a design file's (no Resources button, as there) |
| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow/curved and its arrow; the Design panel opens with its button or View ▸ Design panel |
| Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why |
| Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it |
diff --git a/docs/cli.md b/docs/cli.md
index 39863bea..80bab2a4 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -133,7 +133,9 @@ Runs commands in a headless session (a new document, or `--in`), printing one JS
```
The whole script is **one undo step**, and it is **all-or-nothing**, exactly like running the same
-commands through `command.batch` (the MCP tool `batch`, the control channel, plugins). A step named
+commands through `command.batch` (the MCP tool `batch`, the control channel, plugins). A step key
+other than `command`, `params` and `as`, or a parameter the command's doc (`figcraft-cli commands`)
+doesn't list, fails the script with an error naming it (`ids` and `id` are always taken). A step named
with `"as"` can be used by later steps: `{"$ref": "name"}` in a step's params is the `id` in that
step's result (`{"$step": n}` does the same by zero-based position), and `"field"` takes another key
or a slash path such as `ids/0`. So a step can target a layer an earlier step of the same script
diff --git a/docs/components-parity.md b/docs/components-parity.md
index 18083632..df48b9c4 100644
--- a/docs/components-parity.md
+++ b/docs/components-parity.md
@@ -65,7 +65,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); scopes filter pickers only |
| Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay |
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values |
-| Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose code doesn't name variables yet |
+| Code syntax per platform (Web, iOS, Android, Flutter) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose/ArkUI/Flutter code doesn't name variables yet |
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
| Explicit modes per frame, library collections' modes | done | |
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index 64534074..02388ace 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -99,7 +99,8 @@ Run any command (the same ids as the menus, shortcuts, Actions palette, CLI and
calls never open dialogs: dialog requests are dropped. Returns the command's result (creation
commands return `{"id": N}`). Paints (`fills`, `strokes`, `paint`) take a colour, the full paint
JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"],"from"?,"to"?}`
-(as in `docs/mcp.md`).
+(as in `docs/mcp.md`). A parameter the command's doc (`engine.commands`) doesn't list is an error
+naming it and the ones it takes, never ignored; `ids` and `id` are always taken.
```json
{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}
diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md
index 7d21c5fa..80d351d2 100644
--- a/docs/dev-mode-parity.md
+++ b/docs/dev-mode-parity.md
@@ -12,13 +12,13 @@ repository, third-party integrations. Remaining: 1–2 h.
|---|---|---|
| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why), and the Pages list only opens pages (no adding, renaming, reordering, duplicating or deleting). Files dropped on the canvas aren't imported (a dropped document still opens as a file of its own), and the Assets panel places, swaps and applies nothing, doesn't change Hide when publishing, and doesn't accept library updates or add, remove or swap libraries; each says why |
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
-| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
+| Code: CSS, iOS (SwiftUI), Android (Compose), Flutter | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup; Flutter is ours too: a `StatelessWidget` for a component, a widget tree for a layer); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) |
| Focus view (one frame, previous / next) | done | |
| Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions |
-| Code connect (map components to repository code) | done | per language (React, Leptos, Vue, SwiftUI, Compose, ArkUI): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository |
+| Code connect (map components to repository code) | done | per language (React, Leptos, Vue, SwiftUI, Compose, ArkUI, Flutter): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository |
| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab, with their panels (callbacks can only read); no third-party integrations |
| Measurements (redlines on hover, Alt in the design) | done | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas (`devMode.addMeasurement` between two layers, along a side or to the layer around it; stored on the layer, saved, synced, undoable) drawn in Dev Mode as labelled redlines that follow the layers, listed, placed and removed in the Inspect tab; a measurement whose other layer was deleted isn't drawn until an undo brings it back |
| VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors |
@@ -31,6 +31,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| 2026-10-26 | minor | Class names from layer names transliterate non-ASCII scripts (#1235): `class_name` runs names through `deunicode` (new dependency: pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`; before, every one of them came out `layer-`); symbols-only names take the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. ASCII-only names are unchanged. `html_codegen_tests` updated (its diacritics expectation improves from `layer-1-n-code-name` to `layer-1-unicode-name`) and extended. Percentages and hours unchanged |
| 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged |
| 2026-10-24 | minor | Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Breadth 97% → 98%, ready 76% → 78%, 2–4 h → 1–2 h |
+| 2026-10-16 | minor | Flutter code generation (ours: the incumbent generates CSS, iOS and Android only): `inspect.code {language: flutter}`, with `dart` as an alias, writes a Dart widget tree for a layer — `Container` for a frame's box (size, padding, `BoxDecoration` with colour, radius, border and shadows), `Row` / `Column` for auto layout (`spacing`, `crossAxisAlignment`, `textBaseline`; `stretch` where a child fills), `Expanded` for a child that fills the main axis, `Stack` + `Positioned` for free children, `Text` with `TextStyle` (family, size, weight, style, height, letter spacing, decoration, alignment), `Image.asset` for images, `Opacity` for layer opacity, `SizedBox` for a capped subtree — and a `StatelessWidget` for a component (properties become constructor parameters with their defaults, instances are calls passing only what they change, variants become a Dart enum, swaps a `Widget` and slots a `List?`). Code connect takes `flutter` too, with a Dart `import '…';` line. The Inspect panel's language picker (which iterates `CodeLang::ALL`) and the Connect to code note list it, and the 13 UI catalogues follow. Dev Mode breadth (~96%) and ready for real work (~72%) unchanged at this rounding; hours unchanged |
| 2026-10-15 | minor | Dev Mode tail (gap 16): generated layer trees (React, Leptos, SwiftUI, Compose) start with the import lines of the connected components they call, once each, also those in a component's definition, in slots an instance fills and placed by a connected template's instance swaps; Compare changes takes any saved version as the before (`dev.compare {version}`, the view's Compare with menu; frames that aren't ready for dev compare with versions). Behaviour suite (5 tests; 4 failed before the fixes): the Dev Mode canvas only inspects (no moving, resizing, drawing, text or vector editing; menus, shortcuts, nudges, opacity keys and paste refuse design edits with a note; toolbar drawing tools go back to the design), the ready list filters and badges Changed items, statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels (the runtime's panels, reused). Breadth 96% → 97% (measurements kept on the canvas and the read-only layers panel counted as missing), ready 72% → 76%, 0–1 h → 2–4 h |
| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h |
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Dev Mode breadth 90% → 93%, ready 64% → 70%, hours unchanged (1–3, dev resources); feature breadth and ready for real work unchanged at this rounding |
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 54c05a5a..b0a6b48e 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -17,7 +17,7 @@ and prototype interactions are still unchecked there.
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|---|---|---|---|---|---|
-| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
+| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) |
| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
@@ -57,7 +57,7 @@ export limit, not a native document nesting limit.
| WebP | — | yes (ours) | | — |
| Android Vector Drawable (XML) | — | yes (ours) | an `.xml` drawable resource (`file.export {format: "xml"}`, export settings, File ▸ Export…, the CLI and MCP), pretty-printed, sized in dp with the viewport in layer units, for AndroidX (VectorDrawableCompat: gradients and even-odd need API 24 without it). Shapes, booleans and text as path data with transforms baked in; solid fills and linear, radial and sweep gradients (`aapt:attr`, exact for any handles: radial and sweep in a `` that maps the unit circle); centred strokes as stroke attributes where Android draws them the same (uniform scale, one cap, no dashes or variable width), other strokes as filled outlines; clipping frames and masks as `` with ``; `android:name` from layer names with the id attribute option. What the format can't hold goes by the compatibility mode (`vdCompat`): best effort (default) applies layer opacity to each shape, rewrites even-odd fills and clip outlines as non-zero, draws diamond gradients as radial and alpha masks as outline clips, each with a warning; exact leaves out what it can't draw exactly, with a warning; ignore writes what maps without warnings: layer opacity over overlapping shapes is dropped (drawn opaque), even-odd fills keep `fillType="evenOdd"`, even-odd clip outlines clip as non-zero, diamond gradients are left out and alpha masks clip to their outline. Effects, image and pattern fills and blend modes are always left out (warned except in ignore); an export that draws nothing writes one empty path (Android refuses a drawable without a path), with a warning. Checked by parsing the XML, against FigCraft's renderer (gradients pixel by pixel), with `aapt2 compile` and `link`, and by rendering samples (linear, skewed radial and sweep gradients, a sweep with mirrored handles, even-odd booleans, centred and inside strokes, rotated text, a clipping frame, an alpha mask, a translucent group, each compatibility mode) with Android 13's (API 33) VectorDrawable on an emulator, next to FigCraft's PNG export: they match, including where the sweep starts and which way it turns, except what the compatibility mode and its warnings say (best effort: overlapping shapes in a translucent group look denser; exact and ignore leave out or draw opaque what they list). Not checked on API 21–23 (VectorDrawableCompat) | — |
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
-| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML, React, Leptos and Vue | see [dev-mode-parity](dev-mode-parity.md) | — |
+| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML, React, Vue, Leptos, ArkUI and Flutter | see [dev-mode-parity](dev-mode-parity.md) | — |
| `.fig` ("Save local copy") | yes | **experimental** | the whole file, also as a copy from File ▸ Save as… (the document keeps its `.figcraft` path and unsaved state); see [Export to `.fig`](#export-to-fig-experimental). Round trips through our own importer; written files checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width | 5–15 (non-colour bindings as bindings, checks of effects, images and prototypes, and against real files) |
### Export to `.fig` (experimental)
@@ -77,7 +77,8 @@ visible and opaque (a page also its background opacity): a reader takes what is
draws nothing below a hidden page. The message
lists one node change per layer, parents first, each with its GUID, its parent and a position
string that orders it among its siblings; vector networks and outlines go in blobs, written once
-each.
+each. `.fig` export warns and drops vector region paint stacks; import and editing preserve their
+source paint coordinates.
- **Written:** pages (name, background); frames, groups, sections, rectangles (as rounded
rectangles, with their four radii), ellipses (arcs), polygons, stars, lines, vectors (editable
diff --git a/docs/gaps.md b/docs/gaps.md
index 273e5d4a..d3d6b1b4 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -79,9 +79,12 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
`.figcraft` path. Layers copied from another design app paste (`edit.pasteDesignLayers`, and
Cmd/Ctrl+V when the clipboard's HTML holds a `.fig` canvas): read like a `.fig` file, with its
warnings, size-checked (256 MB) before decoding.
+- **Landed (2026-10-26):** `.fig` import reads vector-region fill stacks; vector editing and path
+ operations retain them with their source paint coordinates. `.fig` export still warns and drops
+ region fills. Evidence: `crates/format/src/fig/tail_tests.rs`, `crates/engine/src/shapebuilder_region_paints_tests.rs`.
- **Missing:** elbowed or curved connectors without a saved path (drawn straight), labels of
- connectors without a saved snapshot, table cells beyond their own layers, filled areas of a
- vector with their own fills (#634, held for an owner decision on the document model), variables
+ connectors without a saved snapshot, table cells beyond their own layers, live region
+ style/variable links, variables
bound to fields the editor can't bind and video fills aren't read; text lays out again with
local fonts. Nothing has been checked against real-world files yet (gap 3): the 2026-10-17
changes were tested on files built in code, and paste from another design app on clipboard HTML
@@ -379,7 +382,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
### 16. Dev Mode: the tail after dev resources
- **Landed (2026-10-11):** code connect: a component or set links to its code component per
- language (React, Leptos, Vue, SwiftUI, Compose, ArkUI): an import path, the name and a template whose `{property}`
+ language (React, Leptos, Vue, SwiftUI, Compose, ArkUI, Flutter): an import path, the name and a template whose `{property}`
and `{property ? "a" : "b"}` placeholders take the instance's values, escaped per language and
never executed. `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet
instead of generated code (inside generated layer trees too); the panel's Connect to code edits
diff --git a/docs/mcp.md b/docs/mcp.md
index 65eb9956..1d3495cc 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -161,7 +161,9 @@ Or in a project's `.mcp.json`:
ignored); a layer a mask hides can't be clicked there, and a mask group's box and exports are
the mask's (plus any layers under the mask).
- Children of an auto-layout frame are positioned by the layout: set `layout`, `gap`, `padding`,
- `mainAlign`, `crossAlign`, `sizingH`/`sizingV` instead of x/y. More auto layout props:
+ `mainAlign`, `crossAlign`, `sizingH`/`sizingV` instead of x/y. In one call, `layout` applies
+ before the keys that tune it, so `{"layout": {"mode": "vertical"}, "gap": 8}` keeps the gap;
+ auto layout keys beside a `layout` of `null` or `"none"` are refused. More auto layout props:
- `sizingH` / `sizingV`: `fixed`, `hug` (auto layout frames and text) or `fill` (children in an
auto layout flow). A child can't fill an axis its parent hugs: setting `fill` turns the parent
`fixed` on that axis, and a parent set to `hug` turns the children that filled it `fixed`. A
@@ -313,12 +315,23 @@ Or in a project's `.mcp.json`:
- Programmatic calls never open dialogs.
- Verify your work: `inspect_document` / `get_node` for numbers, `screenshot` for pixels.
+To find layers without changing the selection, use `run_command` with `node.find`, for example
+`{"command": "node.find", "params": {"root": 12, "type": "TEXT", "text": "hello", "limit": 20}}`.
+It searches strict descendants of `root`, or the current page without one, in stored child order
+(depth-first, parent before children), including hidden and locked layers and instance children.
+`name`, `text` (text layers only) and `component` (instances by their main component's name) are
+case-insensitive literal substrings; `type` is a canonical layer type, case-insensitive. All
+filters must match. The result is `{"ids": [...], "total": N}`: at most `limit` ids (default 100,
+0 to 10000), with every match counted in `total`; `limit: 0` counts only. A root on another page
+works without switching pages. Unknown roots or types and invalid limits are errors. In a batch,
+a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
+
## Tools
| Tool | What it does |
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
-| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose or ArkUI for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
+| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose, ArkUI or Flutter for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
@@ -327,7 +340,7 @@ Or in a project's `.mcp.json`:
| `select {ids}` | Replace the selection (`[]` deselects). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
-| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. |
+| `open_file {path, variables?, reload?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open gives its `index`, with `stale: true` when the file changed on disk since that tab read or saved it; `reload: true` reads it again into the tab, refused while the tab has unsaved changes) and the document's `document` outline. |
| `undo` / `redo` | Step through history. |
| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). |
| `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity, arrows that pan the view when nothing is selected; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. |
@@ -1533,16 +1546,16 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the
desktop frame's that differ behind `lg:`, and layers only on desktop `hidden` plus `lg:flex`
(auto layout frames) or `lg:block` (other layers).
-- **Component code:** `inspect.code {id, language: react|leptos|vue|swiftui|compose|arkui}` on a component,
+- **Component code:** `inspect.code {id, language: react|leptos|vue|swiftui|compose|arkui|flutter}` on a component,
a component set or a variant writes the component as a reusable unit: React (TSX) with a props
interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), Vue as a
single-file component (``, a `