mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 03:40:28 +00:00
Merge main into #1421
apply_order takes #1458's numbering; a $var wrapper ranks 7, after boundVariables.
This commit is contained in:
@@ -208,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Prototype and hand off</h3>
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), 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 <code>count + 1</code>), 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.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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<Widget>?`) 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. |
|
||||
|
||||
@@ -366,6 +366,7 @@ pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, 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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -300,6 +300,11 @@ pub fn paint_images<'a>(paints: impl IntoIterator<Item = &'a Paint>, out: &mut B
|
||||
/// Image hashes a node paints (fills, strokes, text and text run fills).
|
||||
pub fn node_images(n: &Node, out: &mut BTreeSet<String>) {
|
||||
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 {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
+31
-3
@@ -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");
|
||||
|
||||
@@ -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<Vec<usize>>,
|
||||
#[serde(default)]
|
||||
pub winding: WindingRule,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fills: Option<Vec<Paint>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub paint_space: Option<RegionPaintSpace>,
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<bool>) -> Option<bool> {
|
||||
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<bool>, inherited: Option<&[figcraft_color::Paint]>) -> Option<bool> {
|
||||
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<usize>]| -> Vec<Vec<usize>> {
|
||||
let mut k: Vec<Vec<usize>> = loops
|
||||
.iter()
|
||||
@@ -131,18 +142,52 @@ impl VectorNetwork {
|
||||
let faces = self.faces();
|
||||
let face_keys: Vec<Vec<Vec<usize>>> = faces.iter().map(|f| key(&f.loops)).collect();
|
||||
if !self.regions.iter().all(|r| face_keys.contains(&key(&r.loops))) {
|
||||
let filled: Vec<Region> = 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)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,32 @@ fn nested() -> VectorNetwork {
|
||||
}
|
||||
|
||||
fn region(loops: Vec<Vec<usize>>, 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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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)?;
|
||||
|
||||
@@ -39,6 +39,18 @@ pub fn step_name(step: usize, json: &Value) -> Result<Option<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String> = 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)]
|
||||
|
||||
@@ -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<usize>,
|
||||
}
|
||||
|
||||
/// 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<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement>
|
||||
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<Part> = 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<VectorNetwork> {
|
||||
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<NodeId> {
|
||||
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<Node
|
||||
let mut net = network_from(area, pw.inverse()).ok_or_else(|| EngineError::Other("invalid geometry".into()))?;
|
||||
let bb = net.bounds().ok_or_else(|| EngineError::Other("empty region".into()))?;
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let to_world = pw * Affine::translate((bb.x0, bb.y0));
|
||||
let paint_transform = to_world.inverse() * doc.world_transform(style) * source_paint_frame;
|
||||
for region in &mut net.regions {
|
||||
region.fills = region_fills.clone();
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let mut v = Node::new(id, NodeKind::Vector { network: net }, src.name.clone(), bb.width(), bb.height());
|
||||
v.transform = Affine::translate((bb.x0, bb.y0));
|
||||
@@ -321,7 +415,9 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
// 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<Value> {
|
||||
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<Part> = 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<Value> {
|
||||
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<Part> = 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<CommandSpec> {
|
||||
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<bool> {
|
||||
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
|
||||
|
||||
@@ -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<usize> = 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));
|
||||
}
|
||||
@@ -1219,17 +1219,31 @@ pub(crate) fn cut_across(s: &mut Session, a: Point, b: Point) -> Result<Vec<Node
|
||||
/// under it, the others get the same (points outside closed areas are skipped). A vector without
|
||||
/// fills gets the default fill so the painted area shows.
|
||||
pub fn paint_all(doc: &mut Document, id: NodeId, pts: &[Point]) -> Result<bool> {
|
||||
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<usize> = 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)
|
||||
}
|
||||
|
||||
@@ -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<usize> = 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<Vec<usize>> =
|
||||
r.loops.iter().map(|l| l.iter().filter_map(|i| smap.get(*i).copied().flatten()).collect::<Vec<_>>()).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])] {
|
||||
|
||||
@@ -52,9 +52,8 @@ pub fn hatch_spacing(zoom: f64) -> Option<f64> {
|
||||
(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.
|
||||
|
||||
@@ -31,7 +31,7 @@ fn nested(regions: Vec<Region>) -> VectorNetwork {
|
||||
}
|
||||
|
||||
fn region(loops: Vec<Vec<usize>>, 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.
|
||||
|
||||
@@ -244,7 +244,7 @@ pub(crate) fn specs() -> Vec<CommandSpec> {
|
||||
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
|
||||
)]
|
||||
|
||||
+111
-1
@@ -63,6 +63,8 @@ macro_rules! spec {
|
||||
struct Registry {
|
||||
list: Vec<CommandSpec>,
|
||||
index: HashMap<&'static str, usize>,
|
||||
/// The parameter names each command's doc lists (`list` order; see [`doc_param_names`]).
|
||||
names: Vec<Option<Vec<&'static str>>>,
|
||||
}
|
||||
|
||||
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<String> = 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<Vec<&'static str>> {
|
||||
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<Vec<&'static str>> {
|
||||
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<Value> {
|
||||
Ok(serde_json::json!({"ok": true}))
|
||||
|
||||
@@ -576,7 +576,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
}
|
||||
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)}))
|
||||
}),
|
||||
|
||||
@@ -53,37 +53,61 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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
|
||||
),
|
||||
|
||||
@@ -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<Value> {
|
||||
let cmd = "node.find";
|
||||
let filter = |key| -> Result<Option<String>> {
|
||||
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<CommandSpec> {
|
||||
vec![
|
||||
spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))),
|
||||
@@ -235,6 +309,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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) });
|
||||
}
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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 `<name>.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 `<name>.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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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!(
|
||||
|
||||
@@ -62,11 +62,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
|
||||
@@ -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<String, Value>) -> 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<String> = 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<String, Value>) -> 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));
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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
|
||||
),
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
}
|
||||
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<Value> {
|
||||
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<usize> = (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<Value> {
|
||||
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<usize> = (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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let ids = req_targets("vector.join", s, p)?;
|
||||
@@ -695,7 +905,7 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let last = network.vertices.len() - 1;
|
||||
network.segments.push(Segment::line(last, 0));
|
||||
let all: Vec<usize> = (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();
|
||||
|
||||
@@ -164,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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
|
||||
),
|
||||
|
||||
@@ -279,13 +279,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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)?;
|
||||
|
||||
@@ -373,7 +373,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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") {
|
||||
|
||||
@@ -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"], "<Card show_dot={false} title=\"Buy\" />\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"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\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"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\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("<Card title=\"Inbox\" />"), "{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"], "<Button state=ButtonState::Hover />\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"], "<Chip icon={<StarIcon />} />\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"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Chip :icon=\"StarIcon\" />\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<Widget>? 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"], "<Panel />\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("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}");
|
||||
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("<Panel content={|| view! {\n") && usage.contains("<Badge />") && 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());
|
||||
}
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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";
|
||||
|
||||
@@ -37,8 +37,9 @@ const MAX_WARNINGS: usize = 50;
|
||||
|
||||
/// The languages a component can be connected in.
|
||||
pub fn language(s: &str) -> Option<CodeLang> {
|
||||
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::<String>())
|
||||
}
|
||||
CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{p}}}")).collect::<String>()),
|
||||
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
CodeLang::SwiftUi | CodeLang::Flutter => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().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<Option<String>> {
|
||||
/// The `language` parameter (default React).
|
||||
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
|
||||
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<CommandSpec> {
|
||||
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<Value> {
|
||||
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 {
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<CodeLang> {
|
||||
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();
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
let mut parts: Vec<String> = 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::<String>()))
|
||||
}
|
||||
|
||||
/// 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<String> = 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<String> = 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::<String>()));
|
||||
}
|
||||
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<Placed> = 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<Placed>, Vec<Placed>) = 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
)]
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
.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<String> {
|
||||
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::<String>())
|
||||
@@ -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::<Vec<_>>().join(", ")),
|
||||
CodeLang::SwiftUi | CodeLang::Flutter => {
|
||||
format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", "))
|
||||
}
|
||||
CodeLang::ArkUi => {
|
||||
let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().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<Widget>?".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<String> {
|
||||
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 {
|
||||
|
||||
@@ -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"),
|
||||
(
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ impl Session {
|
||||
/// as it was.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
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() {
|
||||
|
||||
@@ -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::<Vec<_>>()}));
|
||||
assert!(out["id"].is_null(), "{out}");
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_regions_survive_junk() {
|
||||
for j in [
|
||||
|
||||
@@ -168,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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
|
||||
),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = 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}");
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -248,6 +248,9 @@ struct Mapper<'a> {
|
||||
networks: Vec<Option<VectorNetwork>>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Value> = 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<Value> =
|
||||
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<Value> = 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<Value> = 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<Value> = 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<Value> = 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<Value> =
|
||||
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
|
||||
|
||||
@@ -217,6 +217,14 @@ pub(crate) fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option<Value> {
|
||||
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<Value> {
|
||||
|
||||
@@ -264,6 +264,256 @@ fn tail_canvas() -> Vec<u8> {
|
||||
canvas(&schema(), &tail_message())
|
||||
}
|
||||
|
||||
fn region_canvas(fill_paints: Option<Vec<Kv<'static>>>, style_id: u32) -> Vec<u8> {
|
||||
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<Kv<'static>>, link_target: Option<u64>, linked_fill: Kv<'static>) -> Vec<u8> {
|
||||
region_canvas_with_typed_link(own, link_target, "FILL", linked_fill)
|
||||
}
|
||||
|
||||
fn region_canvas_with_typed_link(own: Option<Kv<'static>>, link_target: Option<u64>, style_type: &'static str, linked_fill: Kv<'static>) -> Vec<u8> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<VectorNetwork> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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)}});
|
||||
|
||||
@@ -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<VectorN
|
||||
return None;
|
||||
}
|
||||
if !loops.is_empty() {
|
||||
out.regions.push(Region { loops, winding });
|
||||
out.regions.push(Region { loops, winding, ..Default::default() });
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
+42
-1
@@ -155,6 +155,9 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"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<Value> {
|
||||
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: `<name>.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<Value> {
|
||||
"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<Value> {
|
||||
"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<String> = 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<Vec<Value>> = 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<String, Value>) -> Result<ToolResult,
|
||||
Some(v @ (Value::String(_) | Value::Object(_))) => 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})))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -95,6 +95,13 @@ pub fn simplify(path: &BezPath) -> Result<BezPath, PathOpsError> {
|
||||
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<BezPath, PathOpsError> {
|
||||
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<BezPath, PathOpsError> {
|
||||
let op = match op {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+27
-2
@@ -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);
|
||||
|
||||
+32
-1
@@ -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<NodeId>) {
|
||||
let mut d = Document::new("sample");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
+172
-1
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<FontFamily> {
|
||||
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");
|
||||
}
|
||||
+26
-5
@@ -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!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", 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!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", 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!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(path));
|
||||
self.body.push_str(&clipped(&paint_clip, el));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
-1
@@ -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("<path ").count(), 2, "empty region emits no path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_garbage() {
|
||||
let mut d = Document::new("t");
|
||||
@@ -30,6 +62,21 @@ fn import_rejects_garbage() {
|
||||
assert!(import_svg(&mut d, b"not svg", p, 0.0, 0.0, "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_gradient_export_keeps_the_original_paint_box() {
|
||||
use figcraft_color::{Gradient, GradientKind};
|
||||
use figcraft_doc::RegionPaintSpace;
|
||||
use figcraft_geom::{Affine, Rect, Shape};
|
||||
let mut d = Document::new("region gradient");
|
||||
let page = d.first_page().unwrap();
|
||||
let mut network = VectorNetwork::from_path(&Rect::new(20.0, 10.0, 80.0, 40.0).to_path(0.1));
|
||||
network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK))]);
|
||||
network.regions[0].paint_space = Some(RegionPaintSpace { width: 60.0, height: 30.0, transform: Affine::translate((20.0, 10.0)) });
|
||||
let id = d.create(page, NodeKind::Vector { network }, "V", 0.0, 0.0, 120.0, 100.0).unwrap();
|
||||
let svg = export_svg(&d, &[id], &SvgOptions::default()).unwrap();
|
||||
assert!(svg.contains("x1=\"30\" y1=\"0\" x2=\"30\" y2=\"30\""), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_gradient_and_groups() {
|
||||
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50">
|
||||
|
||||
@@ -1069,12 +1069,12 @@ fn public_family(family: &str) -> Option<String> {
|
||||
|
||||
/// 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,
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" = "نقل الطبقة إلى داخل"
|
||||
|
||||
@@ -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" = "গ্রিড ট্র্যাক তালিকা ১০০০ এন্ট্রি অতিক্রম করেছে; ট্র্যাকগুলো ডিফল্ট সমান সাইজিং ব্যবহার করে"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "レイヤーを中に移動"
|
||||
|
||||
@@ -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" = "შრის შიგნით გადატანა"
|
||||
|
||||
@@ -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" = "레이어를 안으로 이동"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "Переместить слой в"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "ย้ายเลเยอร์เข้าไปใน"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user