From 4ecf6e2f399071ec850957895a1b7b31783a94d8 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:37:30 +0200 Subject: [PATCH 01/73] M4: preserve vector region fills across .fig import and native rendering A filled area of a vector network can now carry its own paint stack. A region without paints of its own keeps drawing with the layer's fills, and a region with an explicitly empty list draws nothing, so the two cases stay distinguishable. The canvas, SVG and PDF output, boolean and hit-test geometry, vector editing and region operations all honour the per-region paints and keep them when regions are split, merged or reordered. The .fig importer resolves the individual paint stacks of a layer's filled areas through its normal paint mapping and hands them to the document builder as private mapper data. Public REST vectorNetwork JSON never consumes that data and its region paints are dropped, so only validated .fig input can set them. Invalid entries are skipped with an import note. Live style and variable links on individual regions and writing region paints in the experimental .fig exporter are not part of this change. --- crates/doc/src/boolean_hit_tests.rs | 25 +++- crates/doc/src/colors.rs | 3 + crates/doc/src/geometry.rs | 15 ++ crates/doc/src/lib.rs | 9 ++ crates/doc/src/library.rs | 5 + crates/doc/src/tests.rs | 30 +++- crates/doc/src/vector.rs | 7 +- crates/doc/src/vector_faces.rs | 2 +- crates/doc/src/vector_regions_tests.rs | 13 +- crates/engine/src/cmd/structure.rs | 22 ++- crates/engine/src/shapebuilder.rs | 4 + crates/engine/src/vector_behaviour_tests.rs | 63 ++++++++ crates/engine/src/vectoredit.rs | 3 + crates/engine/src/vectoredit/ops.rs | 14 +- crates/engine/src/vectoredit/regions.rs | 11 +- crates/engine/src/vectoredit/regions_tests.rs | 13 +- crates/format/src/fig/export.rs | 3 + crates/format/src/fig/export_tests.rs | 18 +++ crates/format/src/fig/map.rs | 57 ++++++- crates/format/src/fig/tail_tests.rs | 140 ++++++++++++++++++ crates/format/src/fig/tests.rs | 5 +- .../src/rest/import_regression_tests.rs | 16 ++ crates/format/src/rest/mod.rs | 25 +++- crates/pathops/src/lib.rs | 15 +- crates/pathops/src/ops_tests.rs | 21 ++- crates/pdf/src/draw.rs | 21 ++- crates/pdf/src/tests.rs | 31 +++- crates/render/src/draw.rs | 52 ++++++- crates/render/src/lod.rs | 3 + crates/render/src/lod_tests.rs | 15 +- crates/render/src/tests.rs | 77 +++++++++- crates/svg/src/lib.rs | 29 +++- crates/svg/src/tests.rs | 32 +++- docs/gaps.md | 2 +- 34 files changed, 753 insertions(+), 48 deletions(-) diff --git a/crates/doc/src/boolean_hit_tests.rs b/crates/doc/src/boolean_hit_tests.rs index b826f202..9107a149 100644 --- a/crates/doc/src/boolean_hit_tests.rs +++ b/crates/doc/src/boolean_hit_tests.rs @@ -2,7 +2,7 @@ //! picks the group, and a double click goes one level deeper than the selection. use crate::geometry::{enter_target, select_target}; -use crate::{BoolOp, Document, NodeId, NodeKind}; +use crate::{BoolOp, Document, NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule}; use figcraft_geom::Point; fn page(d: &Document) -> NodeId { @@ -40,6 +40,29 @@ 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)]), + }, + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()) }, + ], + }; + 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 double_clicks_go_one_level_deeper() { let (d, f, g, b, r1, _) = setup(); diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs index d480f4ea..411470ef 100644 --- a/crates/doc/src/colors.rs +++ b/crates/doc/src/colors.rs @@ -57,6 +57,9 @@ impl Node { f(&mut prototype.background); } NodeKind::Text(t) => t.for_each_color_mut(f), + NodeKind::Vector { network } => { + network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten().for_each(|p| p.for_each_color_mut(f)) + } _ => {} } for it in &mut self.interactions { diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 06596ddc..85924e4e 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -68,6 +68,21 @@ 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()) => { + 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 reach = (n.stroke.max_weight() / 2.0).max(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) && path.winding(lp) != 0)), } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 71c6da39..3b90cf29 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -836,6 +836,15 @@ impl Document { pt.source = m.0; } } + if let NodeKind::Vector { network } = &mut c.kind { + for p in network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten() { + if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind + && let Some(m) = map.get(&NodeId(pt.source)) + { + pt.source = m.0; + } + } + } self.reserve_ids(*new_id); self.nodes.insert(*new_id, Arc::new(c)); self.dirty.touch(*new_id); diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 35994bb0..842ee0c8 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -300,6 +300,11 @@ pub fn paint_images<'a>(paints: impl IntoIterator, out: &mut B /// Image hashes a node paints (fills, strokes, text and text run fills). pub fn node_images(n: &Node, out: &mut BTreeSet) { paint_images(n.fills.iter().chain(&n.strokes), out); + if let NodeKind::Vector { network } = &n.kind { + for r in &network.regions { + paint_images(r.fills.iter().flatten(), out); + } + } if let NodeKind::Text(t) = &n.kind { paint_images(t.style.fills.iter().flatten(), out); for r in &t.runs { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a5ead3b7..056e4dc9 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -125,6 +125,34 @@ fn hit_test_respects_z_and_clip() { assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0, None), Some(f)); } +#[test] +fn region_paints_participate_in_colors_images_and_pattern_remapping() { + let mut d = Document::new("region resources"); + let p = page(&d); + let group = d.create(p, NodeKind::Group, "Group", 0.0, 0.0, 40.0, 20.0).unwrap(); + let source = d.create(group, NodeKind::Rectangle, "Source", 0.0, 0.0, 4.0, 4.0).unwrap(); + let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true); + network.regions[0].fills = Some(vec![ + figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FF0000").unwrap()), + figcraft_color::Paint::image("region-image", figcraft_color::ImageScaleMode::Fill), + figcraft_color::Paint::pattern(source.0), + ]); + let vector = d.create(group, NodeKind::Vector { network }, "Vector", 0.0, 0.0, 20.0, 20.0).unwrap(); + assert!(used_images(&d).contains("region-image")); + let dup = d.duplicate_into(group, p, None).unwrap(); + let copied_children = d.node(dup).unwrap().children.clone(); + let copied_source = copied_children[0]; + let copied_vector = d.node(copied_children[1]).unwrap(); + let NodeKind::Vector { network } = &copied_vector.kind else { panic!() }; + let figcraft_color::PaintKind::Pattern(pattern) = &network.regions[0].fills.as_ref().unwrap()[2].kind else { panic!() }; + assert_eq!(pattern.source, copied_source.0); + + let red = figcraft_color::Color::from_hex("FF0000").unwrap(); + d.map_colors(|c| if c == red { figcraft_color::Color::BLACK } else { c }); + let NodeKind::Vector { network } = &d.node(vector).unwrap().kind else { panic!() }; + assert_eq!(network.regions[0].fills.as_ref().unwrap()[0].solid_color(), Some(figcraft_color::Color::BLACK)); +} + #[test] fn click_target_rules() { let mut d = Document::new("x"); @@ -212,7 +240,7 @@ fn vector_network_paths() { let mut bad = VectorNetwork { vertices: vec![Vertex::new(0.0, 0.0)], segments: vec![Segment::line(0, 5)], - regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd }], + regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd, fills: None }], }; bad.sanitize(); assert!(bad.segments.is_empty()); diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index fd0a0f7e..88c296f5 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -1,6 +1,7 @@ //! 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 serde::{Deserialize, Serialize}; @@ -94,6 +95,8 @@ pub struct Region { pub loops: Vec>, #[serde(default)] pub winding: WindingRule, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fills: Option>, } /// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again @@ -143,7 +146,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 }); } Self { vertices, segments, regions } } @@ -240,7 +243,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 }); } net } diff --git a/crates/doc/src/vector_faces.rs b/crates/doc/src/vector_faces.rs index 050ab821..f79049fc 100644 --- a/crates/doc/src/vector_faces.rs +++ b/crates/doc/src/vector_faces.rs @@ -30,7 +30,7 @@ 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 } } /// Some point strictly inside the face (not in a hole). diff --git a/crates/doc/src/vector_regions_tests.rs b/crates/doc/src/vector_regions_tests.rs index c09664b4..cbea9e2d 100644 --- a/crates/doc/src/vector_regions_tests.rs +++ b/crates/doc/src/vector_regions_tests.rs @@ -30,7 +30,18 @@ fn nested() -> VectorNetwork { } fn region(loops: Vec>, winding: WindingRule) -> Region { - Region { loops, winding } + Region { loops, winding, fills: None } +} + +#[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()); } fn p(x: f64, y: f64) -> Point { diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index bed70d3d..548aa4bb 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -305,6 +305,9 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let ids = req_targets("vector.flatten", s, p)?; let doc = s.doc_mut()?; let ids = z_sorted(doc, &roots(doc, &ids)); + if ids.iter().any(|i| subtree_has_region_paints(doc, *i, 0)) { + return Err(EngineError::Disabled("vector.flatten".into(), "cannot flatten a vector with region paints".into())); + } let top = *ids.last().ok_or_else(|| bad("vector.flatten", "nothing selected"))?; 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); @@ -360,6 +363,14 @@ fn flatten(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": vid})) } +pub(crate) fn subtree_has_region_paints(doc: &Document, id: NodeId, depth: usize) -> bool { + let Some(n) = doc.node(id) else { return false }; + if matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some())) { + return true; + } + depth < 128 && n.children.iter().any(|child| subtree_has_region_paints(doc, *child, depth + 1)) +} + fn outline_stroke(s: &mut Session, p: &Value) -> Result { let ids = req_targets("vector.outlineStroke", s, p)?; let mut out = Vec::new(); @@ -397,7 +408,8 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result { v.fills = n.strokes.clone(); v.strokes.clear(); v.styles.fill = n.styles.stroke; - if n.fills.iter().any(|f| f.visible) && closed { + let region_fill = matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.as_ref().is_some_and(|fills| fills.iter().any(|f| f.visible)))); + if (n.fills.iter().any(|f| f.visible) || region_fill) && closed { // Keep the fill as its own layer under the outline, grouped. doc.insert(v, parent, idx.map(|x| x + 1))?; let fill_id = i; @@ -534,6 +546,9 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { fn simplify(s: &mut Session, p: &Value) -> Result { let tol = num_or(p, "tolerance", 0.5).max(0.01); for i in req_targets("vector.simplify", s, p)? { + if s.doc()?.node(i).is_some_and(|n| matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some()))) { + return Err(EngineError::Disabled("vector.simplify".into(), "cannot simplify a vector with region paints".into())); + } let doc = s.doc_mut()?; let n = doc.get_mut(i)?; if let NodeKind::Vector { network } = &mut n.kind { @@ -554,6 +569,9 @@ fn simplify(s: &mut Session, p: &Value) -> Result { fn offset_path(s: &mut Session, p: &Value) -> Result { let d = req_num("vector.offset", p, "offset")?; for i in req_targets("vector.offset", s, p)? { + if s.doc()?.node(i).is_some_and(|n| matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some()))) { + return Err(EngineError::Disabled("vector.offset".into(), "cannot offset a vector with region paints".into())); + } let doc = s.doc_mut()?; let n = doc.get(i)?.clone(); let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; @@ -594,7 +612,7 @@ fn join(s: &mut Session, p: &Value) -> Result { let last = network.vertices.len() - 1; network.segments.push(Segment::line(last, 0)); let all: Vec = (0..network.segments.len()).collect(); - network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero }); + network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero, fills: None }); } } return ok(); diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/shapebuilder.rs index 99d59b45..7738d8d6 100644 --- a/crates/engine/src/shapebuilder.rs +++ b/crates/engine/src/shapebuilder.rs @@ -324,6 +324,10 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result { if merge && whole_shape { return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick})); } + // Rebuilding a shape from an area rebuilds its network, which would drop individual region paints. + if touched.iter().any(|i| s.doc().is_ok_and(|doc| crate::cmd::structure::subtree_has_region_paints(doc, *i, 0))) { + return Err(EngineError::Disabled(cmd.into(), "the shape builder cannot rebuild a vector with region paints".into())); + } let doc = s.doc_mut()?; let created = if merge { let area = figcraft_pathops::union_faces(regions.iter().map(|r| &r.path)).map_err(|e| EngineError::Other(e.to_string()))?; diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 0c472ecc..33c01a5a 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -17,6 +17,69 @@ use crate::params::id_of; use crate::tools::{PointerEvent, PointerKind}; use crate::vectoredit::{VectorEdit, network}; +#[test] +fn region_paints_block_destructive_rebuilds_before_mutation() { + let mut s = Session::new(); + let id = square(&mut s, true); + { + 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(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]); + } + let before = net(&s, id); + for (cmd, params) in + [("vector.simplify", json!({"ids": [id]})), ("vector.offset", json!({"ids": [id], "offset": 2})), ("vector.flatten", json!({"ids": [id]}))] + { + assert!( + matches!(s.execute(cmd, ¶ms), Err(crate::EngineError::Disabled(actual, reason)) if actual == cmd && reason.contains("region paints")), + "{cmd} must report the region-paint-loss guard" + ); + assert_eq!(net(&s, id), before, "{cmd} must leave network untouched"); + } + let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap(); + assert!( + matches!(s.execute("vector.flatten", &json!({"ids": [group]})), Err(crate::EngineError::Disabled(actual, reason)) if actual == "vector.flatten" && reason.contains("region paints")), + "flatten must report its descendant region-paint-loss guard" + ); + assert_eq!(net(&s, id), before, "descendant guard must leave network untouched"); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + assert!( + matches!(s.execute("vector.edit.paint", &json!({"x": 50, "y": 50})), Err(crate::EngineError::Disabled(actual, reason)) if actual == "vector.edit.paint" && reason.contains("region paints")), + "bucket must report its region-paint-loss guard" + ); + assert_eq!(net(&s, id), before, "bucket normalization must leave network untouched"); +} + +#[test] +fn region_paints_block_shape_builder_before_mutation() { + let mut s = Session::new(); + let a = square(&mut s, false); + let b = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"]).unwrap(); + { + let doc = s.doc_mut().unwrap(); + ex_to_vector(doc, a); + } + s.set_selection(vec![a, b]); + let before = net(&s, a); + for (cmd, params) in [("shapeBuilder.merge", json!({"points": [[75, 75]]})), ("shapeBuilder.remove", json!({"points": [[75, 75]]}))] { + assert!( + matches!(s.execute(cmd, ¶ms), Err(crate::EngineError::Disabled(actual, reason)) if actual == cmd && reason.contains("region paints")), + "{cmd} must report the region-paint-loss guard" + ); + assert_eq!(net(&s, a), before, "{cmd} must leave network untouched"); + assert!(s.doc().unwrap().node(b).is_some(), "{cmd} must not remove the other shape"); + } +} + +/// Give vector `id` a painted region, converting a plain shape to a vector first. +fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) { + let n = doc.get(id).unwrap(); + let (path, _) = figcraft_render::node_outline(doc, n).unwrap(); + let mut network = VectorNetwork::from_path(&path); + network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]); + doc.get_mut(id).unwrap().kind = NodeKind::Vector { network }; +} + fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index f923de80..852ad57f 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -1201,6 +1201,9 @@ pub(crate) fn cut_across(s: &mut Session, a: Point, b: Point) -> Result Result { + if doc.node(id).is_some_and(|n| matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some()))) { + return Err(EngineError::Disabled("vector.edit.paint".into(), "paint bucket cannot normalize a vector with region paints".into())); + } let filled = edit(doc, id, |net| { let mut want = None; for p in pts { diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/vectoredit/ops.rs index 935f033a..4793b9f4 100644 --- a/crates/engine/src/vectoredit/ops.rs +++ b/crates/engine/src/vectoredit/ops.rs @@ -586,7 +586,7 @@ pub fn part(net: &VectorNetwork, verts: &[usize]) -> VectorNetwork { let loops: Vec> = r.loops.iter().map(|l| l.iter().filter_map(|i| smap.get(*i).copied().flatten()).collect::>()).filter(|l| !l.is_empty()).collect(); if !loops.is_empty() { - out.regions.push(Region { loops, winding: r.winding }); + out.regions.push(Region { loops, ..r.clone() }); } } reopen_loops(&mut out); @@ -648,7 +648,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 }); } } Some(si) @@ -862,6 +862,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 +907,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 }); let p = Point::new(25.0, 10.0); assert!(n.fill_contains(p)); for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] { diff --git a/crates/engine/src/vectoredit/regions.rs b/crates/engine/src/vectoredit/regions.rs index 111c8e11..91763d3a 100644 --- a/crates/engine/src/vectoredit/regions.rs +++ b/crates/engine/src/vectoredit/regions.rs @@ -52,9 +52,8 @@ pub fn hatch_spacing(zoom: f64) -> Option { (s.is_finite() && s > 0.0).then_some(s) } -/// Whether vector `n` shows a fill (only filled areas are regions). -fn filled(n: &figcraft_doc::Node) -> bool { - n.fills.iter().any(|f| f.visible) +fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool { + net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible)) } /// The network as its fill draws it: rounded points drawn in (region indexes are kept). @@ -73,7 +72,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio return None; } let net = network(doc, ve.id)?; - if net.regions.is_empty() || !doc.node(ve.id).is_some_and(filled) { + let node = doc.node(ve.id)?; + if net.regions.is_empty() || !net.regions.iter().enumerate().any(|(i, _)| region_filled(node, net, i)) { return None; } let w = doc.world_transform(ve.id); @@ -90,7 +90,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) { return None; } - drawn(net).region_at(lp) + let drawn = drawn(net); + (0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp)) } /// The regions to hatch: the selected ones and the hovered one (not while a drag is on), each once. diff --git a/crates/engine/src/vectoredit/regions_tests.rs b/crates/engine/src/vectoredit/regions_tests.rs index f0e5768e..9ca57bd9 100644 --- a/crates/engine/src/vectoredit/regions_tests.rs +++ b/crates/engine/src/vectoredit/regions_tests.rs @@ -31,7 +31,7 @@ fn nested(regions: Vec) -> VectorNetwork { } fn region(loops: Vec>, winding: WindingRule) -> Region { - Region { loops, winding } + Region { loops, winding, fills: None } } /// The 7.1 region: both squares as one region. @@ -118,6 +118,17 @@ fn only_filled_areas_are_regions() { assert!(ve(&s).regions.is_empty()); } +#[test] +fn styled_region_hit_ignores_empty_override_and_needs_no_layer_fill() { + let mut n = overlapping(); + n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]); + n.regions[1].fills = Some(Vec::new()); + let mut s = Session::new(); + let id = editing_vector(&mut s, n); + s.doc_mut().unwrap().get_mut(id).unwrap().fills.clear(); + assert_eq!(hover(&mut s, 100.0, 100.0, 1.0), Some(0)); +} + #[test] fn hover_and_press_select_a_region_with_all_its_points() { // 20. diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..fc431229 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1603,6 +1603,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. fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) { + if net.regions.iter().any(|region| region.fills.is_some()) { + self.warn("vector region paints were left out of the .fig export"); + } let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum(); if net.vertices.len() + net.segments.len() + entries > MAX_NETWORK { self.warn("vector networks too large for the format were written as outlines"); diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 67215b0c..7d721594 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -810,6 +810,24 @@ 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 written_files_decode_and_list_parents_first() { for seed in 0..20 { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..065c933c 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1234,11 +1234,50 @@ impl<'a> Mapper<'a> { .collect(); let segments: Vec = net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect(); - let regions: Vec = - net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); - 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"); - } + let mut region_paint_elements = 0usize; + let regions: Vec = net + .regions + .iter() + .map(|r| { + let mut region = json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }}); + if r.style != 0 { + match style(r.style) { + Some(s) => { + if let Some(fills) = s.get("fillPaints") { + 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 || region_paint_elements.saturating_add(paint_cost) > 200_000 { + self.warn("vector region paint data exceeded import limits and was skipped"); + region_paint_elements = 200_000; + return region; + } + region_paint_elements += paint_cost; + let paints: Vec = source_paints.iter().filter_map(paint).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(); let join = v.str("strokeJoin"); if net.vertices.iter().any(|p| style(p.style).and_then(|s| s.get("strokeJoin")).and_then(Kv::str).is_some_and(|j| Some(j) != join)) { self.warn("points with their own stroke join use the layer's"); @@ -1995,6 +2034,14 @@ fn paint(p: &Kv) -> Option { Some(Value::Object(o)) } +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. fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option { diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 5d34f23b..7f16ad1d 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -231,6 +231,146 @@ fn tail_canvas() -> Vec { canvas(&schema(), &tail_message()) } +fn region_canvas(fill_paints: Option>>, style_id: u32) -> Vec { + let mut message = tail_message(); + let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() }; + let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap(); + let vector_data = field_mut(wave, "vectorData").unwrap(); + let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() }; + let Kv::Object(fields) = styles.first_mut().unwrap() else { panic!() }; + if let Some(fill_paints) = fill_paints { + fields.push(("fillPaints", arr(fill_paints))); + } + let (mut network, _) = network::decode_styled(&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 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_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_styled(&square_blob()).unwrap(); + network.regions[0].style = 1; + network.regions.push(network::Region { style: 2, non_zero: network.regions[0].non_zero, loops: network.regions[0].loops.clone() }); + let Kv::Array(blobs) = field_mut(&mut message, "blobs").unwrap() else { panic!() }; + let bytes = field_mut(blobs.get_mut(1).unwrap(), "bytes").unwrap(); + *bytes = Kv::Bytes(network::encode(&network)); + + let (doc, _) = import(&canvas(&schema(), &message), "Two region fills").unwrap(); + let regions = &network_of(&doc, "Wave").regions; + assert_eq!(regions.len(), 2); + let color = |index: usize| match ®ions[index].fills.as_ref().unwrap()[0].kind { + figcraft_doc::color::PaintKind::Solid { color } => (color.r, color.g, color.b), + paint => panic!("{paint:?}"), + }; + assert_eq!(color(0), (0.1, 0.2, 0.9)); + assert_eq!(color(1), (0.9, 0.1, 0.2)); +} + +#[test] +fn vector_region_gradient_limit_warns_instead_of_silently_truncating() { + for count in [256, 257] { + let stops = (0..count) + .map(|i| { + let value = if i + 1 == count { 1.0 } else { 0.0 }; + o(vec![ + ("position", f(i as f64 / (count - 1) as f64)), + ("color", o(vec![("r", f(value)), ("g", f(value)), ("b", f(value)), ("a", f(1.0))])), + ]) + }) + .collect(); + let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("stops", arr(stops))]); + let (doc, warnings) = import(®ion_canvas(Some(vec![gradient]), 1), "Bounded region gradient").unwrap(); + let fills = &network_of(&doc, "Wave").regions[0].fills; + if count == 256 { + let figcraft_doc::color::PaintKind::Gradient(g) = &fills.as_ref().unwrap()[0].kind else { panic!() }; + assert_eq!(g.stops.len(), 256); + assert_eq!(g.stops.last().unwrap().color.r, 1.0); + assert!(!warnings.iter().any(|w| w.contains("vector region paint data exceeded"))); + } else { + assert!(fills.is_none()); + assert!(warnings.iter().any(|w| w.contains("vector region paint data exceeded import limits")), "{warnings:?}"); + } + } +} + +#[test] +fn vector_region_invalid_paints_and_style_refs_warn() { + let malformed = o(vec![("opacity", f(0.5))]); + let unsupported = o(vec![("type", e("EMOJI"))]); + let (doc, warnings) = import(®ion_canvas(Some(vec![malformed, unsupported]), 1), "Invalid region paints").unwrap(); + assert_eq!(network_of(&doc, "Wave").regions[0].fills, Some(Vec::new())); + assert!(warnings.iter().any(|warning| warning.contains("invalid vector region paints were skipped")), "{warnings:?}"); + assert!(warnings.iter().any(|warning| warning.contains("EMOJI paints are not supported")), "{warnings:?}"); + + let (doc, warnings) = import(®ion_canvas(None, 9), "Missing region style").unwrap(); + assert_eq!(network_of(&doc, "Wave").regions[0].fills, None); + assert!(warnings.iter().any(|warning| warning.contains("vector region references a missing fill style")), "{warnings:?}"); +} + fn network_of<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::VectorNetwork { match &named(doc, name).kind { NodeKind::Vector { network } => network, diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..015e6908 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -13,7 +13,8 @@ use super::*; /// Message field ids count from 1; enum values from 0. const SCHEMA: &str = " enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET SHAPE_WITH_TEXT CONNECTOR TABLE -enum PaintType SOLID GRADIENT_LINEAR IMAGE +enum PaintType SOLID GRADIENT_LINEAR IMAGE EMOJI +enum ImageScaleMode STRETCH FIT FILL TILE enum WindingRule NONZERO ODD enum StyleType NONE FILL TEXT enum StackMode NONE HORIZONTAL VERTICAL @@ -50,7 +51,7 @@ message ConditionalActions Action[] actions, VariableData condition message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition message VariableData AnyValue value, DataType dataType -message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar +message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode message ParentIndex GUID guid, string position message FontName string family, string style, string postscript message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable diff --git a/crates/format/src/rest/import_regression_tests.rs b/crates/format/src/rest/import_regression_tests.rs index 2d6364be..73b1b3bb 100644 --- a/crates/format/src/rest/import_regression_tests.rs +++ b/crates/format/src/rest/import_regression_tests.rs @@ -42,6 +42,22 @@ 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}}], + "_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()); +} + #[test] fn stroke_colour_variable_on_outlined_vector_is_kept() { let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": alias(BRAND)}}); diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 282c6a75..99409ceb 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -645,7 +645,26 @@ impl<'a> Ctx<'a> { fn vector(&mut self, n: &mut Node, j: &Value) -> bool { if let Some(net) = j.get("vectorNetwork") { match network(net) { - Some(network) => { + Some(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"); + } + } + } n.kind = NodeKind::Vector { network }; return false; } @@ -1178,6 +1197,10 @@ fn network(v: &Value) -> Option { if !fits { return None; } + // Region paints come only from the validated `.fig` mapper handoff, never from public JSON. + for r in &mut net.regions { + r.fills = None; + } for p in &mut net.vertices { p.radius = p.radius.clamp(0.0, MAX_SIZE); } diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 553fce04..02a523b1 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -59,14 +59,14 @@ pub fn closed(path: &BezPath) -> BezPath { /// Every coordinate is finite and of a sane magnitude (the sweep needs bounded input). pub use figcraft_geom::measure::finite_path as finite; -fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result { +fn binary_with_rule(a: &BezPath, b: &BezPath, rule: linesweeper::FillRule, op: linesweeper::BinaryOp) -> Result { if a.elements().is_empty() && b.elements().is_empty() { return Ok(BezPath::new()); } if !finite(a) || !finite(b) { return Err(PathOpsError::BadInput); } - let contours = linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput)?; + let contours = linesweeper::binary_op(&closed(a), &closed(b), rule, op).map_err(|_| PathOpsError::BadInput)?; let mut out = BezPath::new(); for c in contours.contours() { out.extend(c.path.elements().iter().copied()); @@ -74,11 +74,22 @@ fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result Result { + binary_with_rule(a, b, linesweeper::FillRule::NonZero, op) +} + /// Normalise a single path's winding (non-zero) into simple even-odd contours. pub fn simplify(path: &BezPath) -> Result { binary(path, &BezPath::new(), linesweeper::BinaryOp::Union) } +/// Normalize one path using its fill rule. Unlike [`faces`], this emits every contour without a +/// face-count cap, so render silhouettes preserve large imported networks. +pub fn simplify_with_rule(path: &BezPath, even_odd: bool) -> Result { + let rule = if even_odd { linesweeper::FillRule::EvenOdd } else { linesweeper::FillRule::NonZero }; + binary_with_rule(path, &BezPath::new(), rule, linesweeper::BinaryOp::Union) +} + /// Combine `acc` (the operands so far) with the next operand `p` by `op`. pub fn boolean_step(acc: &BezPath, p: &BezPath, op: BoolOp) -> Result { let op = match op { diff --git a/crates/pathops/src/ops_tests.rs b/crates/pathops/src/ops_tests.rs index f3b474f2..c982fbf3 100644 --- a/crates/pathops/src/ops_tests.rs +++ b/crates/pathops/src/ops_tests.rs @@ -2,10 +2,10 @@ //! agreement with a uniform operation, and single steps. use figcraft_doc::BoolOp; -use figcraft_geom::Rect; use figcraft_geom::kurbo::{BezPath, Shape}; +use figcraft_geom::{Point, Rect}; -use crate::{boolean, boolean_ops, boolean_step}; +use crate::{boolean, boolean_ops, boolean_step, simplify_with_rule}; fn sq(x: f64, y: f64, s: f64) -> BezPath { Rect::new(x, y, x + s, y + s).to_path(0.1) @@ -81,3 +81,20 @@ fn single_steps_and_edge_cases() { bad.line_to((1.0, 1.0)); assert!(boolean_ops([(&a, BoolOp::Union), (&bad, BoolOp::Intersect)]).is_err()); } + +#[test] +fn fill_rule_normalization_keeps_even_odd_holes_and_more_than_4096_contours() { + let mut ring = sq(0.0, 0.0, 20.0); + ring.extend(sq(5.0, 5.0, 10.0).elements().iter().copied()); + let ring = simplify_with_rule(&ring, true).unwrap(); + assert!(ring.winding(Point::new(2.0, 2.0)) != 0); + assert_eq!(ring.winding(Point::new(10.0, 10.0)), 0, "same-direction inner contour remains a hole"); + + let mut many = BezPath::new(); + for i in 0..4097 { + many.extend(sq(i as f64 * 2.0, 0.0, 1.0).elements().iter().copied()); + } + let all = simplify_with_rule(&many, false).unwrap(); + assert!(all.winding(Point::new(8192.5, 0.5)) != 0, "last contour survives beyond the old 4096-face cap"); + assert!((area(&all) - 4097.0).abs() < 1e-6); +} diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs index 99f4e390..c702aa25 100644 --- a/crates/pdf/src/draw.rs +++ b/crates/pdf/src/draw.rs @@ -317,8 +317,25 @@ impl<'a> Exporter<'a> { if let Some((path, r)) = &outline && closed { - for p in &n.fills { - self.fill(s, path, rule(*r), p, w, h, xf); + if let NodeKind::Vector { network } = &n.kind + && network.regions.iter().any(|r| r.fills.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(region_path) = net.region_path(i) { + 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, w, h, xf); + } + } + } + } else { + for p in &n.fills { + self.fill(s, path, rule(*r), p, w, h, xf); + } } } self.place_inner(s, inner); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 3656cf1e..6ab29b1e 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::*; use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImageScaleMode, Paint}; -use figcraft_doc::{BoolOp, Effect, EffectKind, ImageData, NodeKind, StrokeAlign, TextData}; +use figcraft_doc::{BoolOp, Effect, EffectKind, ImageData, NodeKind, Region, Segment, StrokeAlign, TextData, VectorNetwork, Vertex, WindingRule}; pub(crate) fn page_count(pdf: &[u8]) -> usize { let s = String::from_utf8_lossy(pdf); @@ -51,6 +51,35 @@ 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())]), + }, + Region { + loops: vec![vec![4, 5, 6, 7]], + winding: WindingRule::NonZero, + fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]), + }, + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()) }, + ], + }; + let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 20.0, 10.0).unwrap(); + let content = inflated(&export_pdf(&d, &[id], &PdfOptions::default()).unwrap()); + assert!(content.contains("1 0 0 rg") && content.contains("0 0 1 rg"), "{content}"); +} + /// A page with two frames: shapes, gradients, an image, strokes, a mask, a boolean, effects, text. pub(crate) fn sample_doc() -> (Document, Vec) { let mut d = Document::new("sample"); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 7bfafd33..f003a198 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -230,6 +230,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()) { + 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)) @@ -635,14 +656,31 @@ 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 = match &n.kind { + NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some()) => Some(network), + _ => None, + }; // 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 { + 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(path) = net.region_path(i) + && let Some(tp) = ts_path(&path) + { + let rule = if region.winding == figcraft_doc::WindingRule::EvenOdd { FillRule::EvenOdd } else { FillRule::Winding }; + fill_with(pix, &tp, paints, w, h, xf, rule, ctx, None); + } + } + } else if let Some((path, rule)) = &outline + && let Some(tp) = ts_path(path) + { + fill_with(pix, &tp, &n.fills, w, h, xf, *rule, ctx, None); + } } if let Some(f) = over_fills { f(pix); diff --git a/crates/render/src/lod.rs b/crates/render/src/lod.rs index 6d742585..6cbfea24 100644 --- a/crates/render/src/lod.rs +++ b/crates/render/src/lod.rs @@ -63,6 +63,9 @@ pub fn splattable(doc: &Document, n: &Node, depth: usize) -> bool { if depth > MAX_DEPTH { return false; } + if matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some())) { + return false; + } let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_)); let own = match &n.kind { NodeKind::Text(t) => text_paints(n, t).any(image), diff --git a/crates/render/src/lod_tests.rs b/crates/render/src/lod_tests.rs index de8b52b4..9ed6c65e 100644 --- a/crates/render/src/lod_tests.rs +++ b/crates/render/src/lod_tests.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Once}; use figcraft_color::{Color, ImageScaleMode, Paint}; -use figcraft_doc::{Document, ImageData, NodeId, NodeKind, TextData}; +use figcraft_doc::{Document, ImageData, NodeId, NodeKind, Region, TextData, VectorNetwork}; use figcraft_geom::{Affine, Rect}; use crate::retained_tests::assert_same; @@ -110,6 +110,19 @@ fn lod_is_off_unless_asked_for() { assert!(!Options::default().lod); } +#[test] +fn region_paints_disable_vector_and_ancestor_splatting() { + let mut d = Document::new("region LOD"); + let page = d.first_page().unwrap(); + let parent = d.create(page, NodeKind::Frame, "Parent", 0.0, 0.0, 4.0, 4.0).unwrap(); + let mut network = VectorNetwork::default(); + network.regions.push(Region { loops: Vec::new(), winding: Default::default(), fills: Some(Vec::new()) }); + let vector = d.create(parent, NodeKind::Vector { network }, "Styled", 0.0, 0.0, 2.0, 2.0).unwrap(); + let node = d.node(vector).unwrap(); + assert!(!lod::splattable(&d, node, 0)); + assert!(!lod::splattable(&d, d.node(parent).unwrap(), 0)); +} + #[test] fn zoomed_out_previews_are_close_to_exact() { let (d, p) = cards(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 55488153..ae0925df 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1,6 +1,6 @@ use super::*; use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint}; -use figcraft_doc::{Effect, EffectKind, NodeKind, Shadow, StrokeAlign, TextData}; +use figcraft_doc::{Effect, EffectKind, NodeKind, Region, Segment, Shadow, StrokeAlign, TextData, VectorNetwork, Vertex, WindingRule}; fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] { let i = ((y * p.width() + x) * 4) as usize; @@ -28,6 +28,81 @@ 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())]), + }, + Region { + loops: vec![vec![4, 5, 6, 7]], + winding: WindingRule::NonZero, + fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]), + }, + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(vec![]) }, + ], + }; + 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 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, + }); + } + 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 zoom_scales() { let (d, p, _) = doc_with_rect(Color::BLACK); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a6813017..8f853b92 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -447,11 +447,30 @@ impl W<'_> { } else if let Some((path, rule)) = &outline && closed { - let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; - for p in &n.fills { - if let Some(a) = self.paint_attr(p, w, h) { - let el = format!("", matrix(xf), path_d(path)); - self.body.push_str(&clipped(&paint_clip, el)); + if let NodeKind::Vector { network } = &n.kind + && network.regions.iter().any(|r| r.fills.is_some()) + { + 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(region_path) = net.region_path(i) { + for p in paints { + if let Some(a) = self.paint_attr(p, w, h) { + let el = format!("", matrix(xf), path_d(®ion_path)); + self.body.push_str(&clipped(&paint_clip, el)); + } + } + } + } + } else { + let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; + for p in &n.fills { + if let Some(a) = self.paint_attr(p, w, h) { + let el = format!("", matrix(xf), path_d(path)); + self.body.push_str(&clipped(&paint_clip, el)); + } } } } diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index b15cd999..866e4cb0 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -1,6 +1,6 @@ use super::*; use figcraft_color::{Color, Paint}; -use figcraft_doc::{Document, Effect, NodeKind}; +use figcraft_doc::{Document, Effect, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule}; #[test] fn export_basic_shapes() { @@ -23,6 +23,36 @@ 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())]), + }, + Region { + loops: vec![vec![4, 5, 6, 7]], + winding: WindingRule::NonZero, + fills: Some(vec![Paint::solid(Color::from_hex("0000FF").unwrap())]), + }, + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()) }, + ], + }; + 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(" Date: Fri, 16 Oct 2026 18:40:59 +0800 Subject: [PATCH 02/73] M7: Flutter code generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Code generation gains a Flutter target (ours; the incumbent generates CSS, iOS and Android only), which makes Dart the seventh language: `language: flutter` (with `dart` as an alias, so `CodeLang::from_id` maps both, `id()` is `flutter` and the label `Flutter`). - `inspect.code` writes a Dart widget tree for the layer: a `Container` for a box, with its size, padding and a `BoxDecoration` (colour, radius, border, shadows); `Row` / `Column` for auto layout, with `spacing`, `crossAxisAlignment` and `textBaseline`, and `stretch` wherever a child fills the cross axis; `Expanded` for a child that fills the main axis; `Stack` with `Positioned` for free children; `Text` with a `TextStyle` (family, size, weight, style, height, letter spacing, decoration and alignment); `Image.asset` for images; `Opacity` for layer opacity; `SizedBox` for a capped subtree. Colours are `Color(0xAARRGGBB)` and `$` is escaped in string literals, as in Compose. - `inspect.componentCode` writes a `StatelessWidget` for a component or set: properties become constructor parameters with their defaults, a variant property becomes a Dart enum (lowerCamel members, never a keyword), a boolean a `bool`, a text a `String`, a swap a `Widget` and a slot a `List?`. Instances are calls that pass only what they change, and an instance's snippet puts the `import 'package:flutter/material.dart';` line before the call, since Dart's imports come first. - Code connect takes Flutter too: `codeConnect.set {language: flutter}` stores an import path, a name and a template, and the snippet carries a Dart `import '…';` line. The default template passes named parameters (`Card(size: {size})`), as SwiftUI does, and import paths are checked as Dart imports (letters, digits, `_ . / : -`). - The Inspect panel's language picker iterates `CodeLang::ALL`, so Flutter appears there and in the Connect to code note; the 13 UI catalogs and the component playground's language list follow. - Docs: README, ROADMAP, docs/mcp.md, docs/gaps.md, docs/components-parity.md, docs/file-format-parity.md, docs/target-app-parity.md and docs/dev-mode-parity.md. - Tests: Flutter cases in the codegen, component codegen and inspect behaviour suites. --- README.md | 2 +- ROADMAP.md | 3 +- crates/doc/src/node.rs | 2 +- crates/engine/src/code_connect.rs | 16 +- crates/engine/src/codegen.rs | 404 ++++++++++++++++++- crates/engine/src/component_codegen.rs | 152 ++++++- crates/engine/src/component_codegen_tests.rs | 43 ++ crates/engine/src/inspect_behaviour_tests.rs | 13 +- crates/engine/src/playground.rs | 4 +- crates/ui-egui/locales/cs.toml | 2 +- crates/ui-egui/locales/de.toml | 2 +- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 2 +- crates/ui-egui/locales/fr.toml | 2 +- crates/ui-egui/locales/id.toml | 2 +- crates/ui-egui/locales/ja.toml | 2 +- crates/ui-egui/locales/ko.toml | 2 +- crates/ui-egui/locales/pt-BR.toml | 2 +- crates/ui-egui/locales/ru.toml | 2 +- crates/ui-egui/locales/sv.toml | 2 +- crates/ui-egui/locales/tr.toml | 2 +- crates/ui-egui/locales/zh-Hans.toml | 2 +- crates/ui-egui/src/dev_mode.rs | 2 +- docs/components-parity.md | 2 +- docs/dev-mode-parity.md | 7 +- docs/file-format-parity.md | 2 +- docs/gaps.md | 4 +- docs/mcp.md | 28 +- docs/target-app-parity.md | 2 +- 29 files changed, 633 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 0910bf9e..96bef198 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil

Prototype and hand off

- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. + Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..9a3fe224 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Flutter code generation; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **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 @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged | | 2026-10-16 | minor | 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. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 00f7d559..d43b4b2f 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -218,7 +218,7 @@ pub struct Annotation { /// (`figcraft_engine::code_connect`). #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct CodeLink { - /// `react`, `swiftui` or `compose`. + /// `react`, `swiftui`, `compose` or `flutter`. pub language: String, /// What the snippet imports from (`@acme/ui`, a Swift module, a Kotlin name). #[serde(default, skip_serializing_if = "String::is_empty")] diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs index 46340636..f554632a 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50; /// The languages a component can be connected in. pub fn language(s: &str) -> Option { - CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose)) + CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::Flutter)) } /// The layer `id`'s links are stored on: its component set, else its component (an instance's @@ -66,6 +66,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { _ if path.is_empty() => String::new(), CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()), + CodeLang::Flutter => format!("import '{}';", path.replace(['\'', '\\'], "")), _ => format!("import {path}"), } } @@ -175,6 +176,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> (Some(l), CodeLang::React) => format!("<{} />", l.name), (Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name), (Some(l), CodeLang::SwiftUi) => format!("{}()", l.name), + (Some(l), CodeLang::Flutter) => format!("{}()", l.name), (Some(l), _) => format!("{{ {}() }}", l.name), (None, _) => value_expr(doc, c, p, v, lang), } @@ -221,7 +223,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String { CodeLang::Leptos => { format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::()) } - CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), + CodeLang::SwiftUi | CodeLang::Flutter => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), _ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().join(", ")), } } @@ -241,12 +243,16 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res let path_ok = match lang { CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || 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 { return Err(match lang { CodeLang::React => 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::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"), }); } @@ -282,7 +288,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result> { /// The `language` parameter (default React). fn lang_param(cmd: &str, p: &Value) -> Result { match text(cmd, p, "language")? { - Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")), + Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose|flutter")), None => Ok(CodeLang::React), } } @@ -312,7 +318,7 @@ pub fn specs() -> Vec { spec!( "codeConnect.set", "Connect to code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (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|swiftui|compose|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"; @@ -427,7 +433,7 @@ fn import(s: &mut Session, p: &Value) -> Result { let (Some(component), Some(lang), Some(name), Some(template)) = (field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str)) else { - warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1)); + warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose|flutter), name and template strings", i + 1)); continue; }; let link = CodeLink { diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 7d35229f..273d84ad 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -1,6 +1,6 @@ //! 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 or Jetpack Compose (stacks for auto -//! layout, absolute offsets otherwise). Components become components whose properties are +//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose 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. @@ -18,13 +18,13 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos 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), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|swiftui|compose|flutter (default css; html, react and leptos 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), unit?: px|rem (CSS, HTML, React and Leptos; 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|swiftui|compose"))?, + Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose|flutter"))?, None => CodeLang::Css, }; let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { @@ -63,10 +63,13 @@ pub enum CodeLang { Leptos, SwiftUi, Compose, + /// A Flutter `StatelessWidget` (Dart) with its widget tree. + Flutter, } impl CodeLang { - pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 7] = + [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose, CodeLang::Flutter]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", @@ -75,6 +78,7 @@ impl CodeLang { CodeLang::Leptos => "leptos", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", + CodeLang::Flutter => "flutter", } } pub fn label(self) -> &'static str { @@ -85,16 +89,25 @@ impl CodeLang { CodeLang::Leptos => "Leptos", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", + CodeLang::Flutter => "Flutter", } } /// Languages whose lengths are CSS pixels (they can be shown in rem). pub fn is_web(self) -> bool { matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) } + /// Languages whose strings interpolate `$` (Kotlin's `$name`, Dart's `$name`), where a literal + /// `$` has to be escaped. + pub fn escapes_dollar(self) -> bool { + matches!(self, CodeLang::Compose | CodeLang::Flutter) + } pub fn from_id(s: &str) -> Option { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { return Some(CodeLang::React); } + 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)) } } @@ -194,7 +207,9 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), - CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), + CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::Flutter => { + crate::component_codegen::code(doc, id, lang) + } } } @@ -270,8 +285,8 @@ fn radius(n: &Node) -> f64 { n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)) } -/// Text content as a string literal (escaped for Swift; Kotlin also escapes `$`). -pub(crate) fn literal(s: &str, kotlin: bool) -> String { +/// Text content as a string literal (escaped for Swift; Kotlin and Dart also escape `$`). +pub(crate) fn literal(s: &str, escape_dollar: bool) -> String { let mut out = String::from("\""); for c in s.chars().take(10_000) { match c { @@ -280,7 +295,7 @@ pub(crate) fn literal(s: &str, kotlin: bool) -> String { '\n' | '\u{2028}' => out.push_str("\\n"), '\r' => {} '\t' => out.push_str("\\t"), - '$' if kotlin => out.push_str("\\$"), + '$' if escape_dollar => out.push_str("\\$"), c if c.is_control() => {} c => out.push(c), } @@ -986,7 +1001,8 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str // ------------------------------------------------------------------ Jetpack Compose -fn kotlin_color(c: Color) -> String { +/// A colour literal as `Color(0xAARRGGBB)` (Kotlin and Dart). +fn argb_color(c: Color) -> String { let [r, g, b, a] = c.to_rgba8(); format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})") } @@ -998,10 +1014,10 @@ fn modifier(n: &Node, shape: &str, padding: bool) -> String { m.push_str(&format!(".shadow({}.dp, {shape})", num(s.radius / 2.0))); } if let Some(c) = first_fill(n) { - m.push_str(&format!(".background({}, {shape})", kotlin_color(c))); + m.push_str(&format!(".background({}, {shape})", argb_color(c))); } if let Some(c) = stroke_color(n) { - m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), kotlin_color(c))); + m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), argb_color(c))); } if n.opacity < 1.0 { m.push_str(&format!(".alpha({}f)", num(f64::from(n.opacity)))); @@ -1050,7 +1066,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { let shape = if radius(n) > 0.0 { format!("RoundedCornerShape({}.dp)", num(radius(n))) } else { "RectangleShape".to_string() }; match &n.kind { NodeKind::Text(t) => { - let color = first_fill(n).map(kotlin_color).unwrap_or_else(|| "Color.Unspecified".into()); + let color = first_fill(n).map(argb_color).unwrap_or_else(|| "Color.Unspecified".into()); let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone()); out.push_str(&format!( "{pad}Text(\n{pad} text = {text},\n{pad} fontSize = {}.sp,\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n", @@ -1112,6 +1128,312 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { } } +// ------------------------------------------------------------------ Flutter + +/// 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, +} + +/// 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 child that fills it stretches the line instead. +fn flutter_cross_align(a: CrossAlign, stretch: bool) -> &'static str { + if stretch { + return "CrossAxisAlignment.stretch"; + } + match a { + CrossAlign::Min => "CrossAxisAlignment.start", + CrossAlign::Center => "CrossAxisAlignment.center", + CrossAlign::Max => "CrossAxisAlignment.end", + CrossAlign::Baseline => "CrossAxisAlignment.baseline", + } +} + +/// A frame's auto layout padding as `EdgeInsets` (`None` when it has none). +fn flutter_padding(n: &Node) -> Option { + let l = n.layout.as_ref()?; + let [t, r, b, left] = l.padding; + if ![t, r, b, left].iter().any(|v| *v > 0.0) { + return None; + } + Some(if t == r && r == b && b == left { + format!("EdgeInsets.all({})", num(t)) + } else if t == b && left == r { + format!("EdgeInsets.symmetric(vertical: {}, horizontal: {})", num(t), num(left)) + } else { + format!("EdgeInsets.fromLTRB({}, {}, {}, {})", num(left), num(t), num(r), num(b)) + }) +} + +/// A layer's fill, shape, corner radius, stroke and shadow as a `BoxDecoration`; `None` when it has +/// none of them. `pad` is the indentation of the constructor itself. +fn box_decoration(n: &Node, pad: &str) -> Option { + let mut parts: Vec = Vec::new(); + if let Some(c) = first_fill(n) { + parts.push(format!("color: {}", argb_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: {})", argb_color(c), num(n.stroke.weight))); + } + if let Some(s) = drop_shadow(n) { + parts.push(format!( + "boxShadow: [BoxShadow(color: {}, blurRadius: {}, offset: Offset({}, {}))]", + argb_color(s.color), + num(s.radius / 2.0), + num(s.offset.0), + num(s.offset.1) + )); + } + if parts.is_empty() { + return None; + } + let inner = format!("{pad} "); + Some(format!("BoxDecoration(\n{}{pad})", parts.iter().map(|p| format!("{inner}{p},\n")).collect::())) +} + +/// Flutter for a layer and its subtree at `depth`. A layer a boolean parameter shows sits behind an +/// `if (…)`: Dart's lists take conditions, so the element itself carries it. +pub(crate) fn flutter(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, place: Place) { + let Some(n) = ctx.doc.node(id) else { return }; + if id != ctx.root + && let Some(p) = ctx.param(n, "visible") + { + out.push_str(&format!("{}if ({})\n", flutter_indent(depth), p.ident)); + flutter_placed(ctx, n, depth + 1, out, place); + return; + } + flutter_placed(ctx, n, depth, out, place); +} + +/// A layer at `depth` and what wraps it: where its parent puts it, then its opacity. Every closing +/// parenthesis sits at its own level, so the caller can end the element with a comma in a list. +fn flutter_placed(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, place: Place) { + if ctx.too_deep(depth) { + let pad = flutter_indent(depth); + out.push_str(&format!("{pad}// {} …\n{pad}const SizedBox()", n.name)); + return; + } + let mut depth = depth; + let mut close: Vec = Vec::new(); + match place { + Place::Flow => {} + Place::At(x, y) => { + let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1)); + out.push_str(&format!("{pad}Positioned(\n{inner}left: {},\n{inner}top: {},\n{inner}child: ", num(x), num(y))); + close.push(format!("\n{pad})")); + depth += 1; + } + Place::Fill => { + let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1)); + out.push_str(&format!("{pad}Expanded(\n{inner}child: ")); + close.push(format!("\n{pad})")); + depth += 1; + } + } + if n.opacity < 1.0 { + let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1)); + out.push_str(&format!("{pad}Opacity(\n{inner}opacity: {},\n{inner}child: ", num(f64::from(n.opacity)))); + close.push(format!("\n{pad})")); + depth += 1; + } + // A wrapper leaves its line open ("child: "), so the widget carries no indentation of its own. + let mut body = String::new(); + flutter_widget(ctx, n, depth, &mut body); + out.push_str(if close.is_empty() { body.as_str() } else { body.trim_start() }); + for c in close.iter().rev() { + out.push_str(c); + } +} + +/// A layer's own look and its subtree as a Flutter widget at `depth`: a complete expression whose +/// closing parenthesis sits at `depth`. +fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { + let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1)); + // Nested instances are calls; an instance swap parameter is a widget. + let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) { + Some(p) => Some(p.ident.clone()), + None => ctx.call(n, CodeLang::Flutter, depth), + }; + if let Some(call) = call { + out.push_str(call.trim_end_matches('\n')); + return; + } + match &n.kind { + NodeKind::Text(t) => { + let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone()); + out.push_str(&format!("{pad}Text(\n{inner}{text},\n")); + match t.align_h { + TextAlignH::Left => {} + TextAlignH::Center => out.push_str(&format!("{inner}textAlign: TextAlign.center,\n")), + TextAlignH::Right => out.push_str(&format!("{inner}textAlign: TextAlign.right,\n")), + TextAlignH::Justified => out.push_str(&format!("{inner}textAlign: TextAlign.justify,\n")), + } + let s = &t.style; + let mut style: Vec = Vec::new(); + if !s.font_family.is_empty() { + style.push(format!("fontFamily: {}", literal(&s.font_family, true))); + } + style.push(format!("fontSize: {}", num(s.font_size))); + style.push(format!("fontWeight: {}", flutter_weight(s.weight()))); + if let Some(c) = first_fill(n) { + style.push(format!("color: {}", argb_color(c))); + } + if s.italic() { + style.push("fontStyle: FontStyle.italic".into()); + } + match s.line_height { + // Dart's height is a multiple of the font size, not a length. + LineHeight::Pixels(v) if v > 0.0 && s.font_size > 0.0 => style.push(format!("height: {}", num(v / s.font_size))), + LineHeight::Percent(p) if p > 0.0 => style.push(format!("height: {}", num(p / 100.0))), + _ => {} + } + match s.letter_spacing { + LetterSpacing::Pixels(v) if v != 0.0 => style.push(format!("letterSpacing: {}", num(v))), + LetterSpacing::Percent(p) if p != 0.0 && s.font_size > 0.0 => style.push(format!("letterSpacing: {}", num(p / 100.0 * s.font_size))), + _ => {} + } + match s.decoration { + TextDecoration::None => {} + TextDecoration::Underline => style.push("decoration: TextDecoration.underline".into()), + TextDecoration::Strikethrough => style.push("decoration: TextDecoration.lineThrough".into()), + } + let sp = flutter_indent(depth + 2); + out.push_str(&format!("{inner}style: TextStyle(\n{}{inner}),\n{pad})", style.iter().map(|x| format!("{sp}{x},\n")).collect::())); + } + NodeKind::Ellipse { .. } | NodeKind::Rectangle => { + out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height))); + if let Some(d) = box_decoration(n, &inner) { + out.push_str(&format!("{inner}decoration: {d},\n")); + } + out.push_str(&format!("{pad})")); + } + k if k.is_frame_like() || matches!(k, NodeKind::Group) => { + let children = ctx.kids(n); + let slot = ctx.param(n, "slot"); + out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height))); + if 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: ")); + let mut kids = String::new(); + flutter_children(ctx, n, &children, slot, depth + 1, &mut kids); + 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`. +fn flutter_children(ctx: &Ctx, n: &Node, children: &[NodeId], slot: Option<&Param>, depth: usize, out: &mut String) { + let doc = ctx.doc; + let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1)); + // Where each child sits, and whether one of them asks the line to stretch. + let mut placed: Vec<(NodeId, Place)> = 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() { + 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 && !cn.child_layout.absolute { 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)); + } + match n.layout.as_ref() { + Some(l) => { + out.push_str(&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))); + } + } + None => out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n")), + } + out.push_str(&format!("{inner}children: [\n")); + let list = flutter_indent(depth + 2); + // A slot shows the instance's content, or else the main's. + 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 + 2 + usize::from(slot.is_some()); + for (id, place) in placed { + flutter(ctx, id, kid, out, place); + out.push_str(",\n"); + } + if slot.is_some() { + out.push_str(&format!("{list}],\n")); + } + out.push_str(&format!("{inner}],\n{pad})")); +} + #[cfg(test)] mod tests { use super::*; @@ -1168,4 +1490,60 @@ mod tests { assert!(kt.contains("Modifier.offset(7.dp, 9.dp)")); assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty()); } + + #[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_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}"); + } } diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index 1416b15f..fe879acd 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -1,5 +1,6 @@ -//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack -//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean → +//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI, Jetpack +//! Compose 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 @@ -10,7 +11,7 @@ use figcraft_doc::*; use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::codegen::{CodeLang, Ctx, Markup, class_name, compose, indent, literal, swift}; +use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, 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::*; use crate::{bad, spec}; @@ -67,14 +68,14 @@ pub fn specs() -> Vec { vec![spec!( "inspect.componentCode", "Component code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (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|swiftui|compose|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").map(|l| CodeLang::from_id(&l)) { - Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l, - Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")), + Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::Flutter))) => l, + Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose|flutter")), None => CodeLang::React, }; let doc = s.doc()?; @@ -107,9 +108,9 @@ pub fn specs() -> Vec { )] } -/// Code for any layer in React, Leptos, SwiftUI or Compose: 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). +/// Code for any layer in React, Leptos, SwiftUI, Compose 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). 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)) { @@ -117,7 +118,15 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { } let comp = main_of(doc, id).and_then(|m| describe(doc, m)); 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 => { @@ -129,6 +138,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { swift(&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); @@ -289,36 +303,57 @@ const RESERVED: &[&str] = &[ "yield", ]; -/// Types the generated SwiftUI and Compose code names: a variant enum may not shadow them. +/// Types the generated SwiftUI, Compose 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", "CircleShape", "Color", "Column", "Composable", + "CrossAxisAlignment", "Edge", "EdgeInsets", "Ellipse", "EmptyView", + "Expanded", "Font", + "FontStyle", "FontWeight", "Group", "HStack", "Image", + "MainAxisAlignment", "Modifier", + "Offset", + "Opacity", + "Positioned", "RectangleShape", "RoundedCornerShape", "RoundedRectangle", "Row", "Shape", + "SizedBox", + "Stack", + "StatelessWidget", "String", "Text", + "TextAlign", + "TextBaseline", + "TextDecoration", + "TextStyle", "Unit", "VStack", "View", @@ -432,6 +467,13 @@ fn cases(values: &[String], swift: bool) -> Vec { .collect() } +/// Enum member names for variant values in Dart: lowerCamel, unique, never a keyword (a `default` +/// value becomes `defaultValue`). +fn dart_cases(values: &[String]) -> Vec { + let mut used = Vec::new(); + values.iter().map(|v| ident(v, &mut used)).collect() +} + /// A Rust string literal. pub(crate) fn rust_string(s: &str) -> String { format!("{:?}", s.chars().take(10_000).collect::()) @@ -464,16 +506,18 @@ 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::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)); @@ -482,9 +526,11 @@ 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}()"), (Some(t), _) => format!("{{ {t}() }}"), (None, CodeLang::React) => "null".into(), (None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(), + (None, CodeLang::Flutter) => "const SizedBox.shrink()".into(), (None, _) => "{}".into(), } } @@ -520,7 +566,7 @@ pub(crate) fn instance_values(doc: &Document, id: NodeId, c: &Component) -> Opti pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option { let c = main_of(doc, id).and_then(|m| describe(doc, m))?; let values = instance_values(doc, id, &c)?; - let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }; + let pad = if matches!(lang, CodeLang::React | CodeLang::Flutter) { " ".repeat(depth) } else { indent(depth) }; // A connected component: its real snippet. shortcut: inside a layer tree it brings no import // line; collect them at the top of the tree's code when people ask for it. if let Some(link) = crate::code_connect::link(doc, id, lang) { @@ -560,7 +606,9 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option let t = if leptos { rust_type(t) } else { t.clone() }; format!("{pad}<{t}{attrs} />\n") } - CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ")), + CodeLang::SwiftUi | CodeLang::Flutter => { + format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ")) + } _ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::>().join(", ")), }) } @@ -593,6 +641,15 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S } format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1)) } + 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)) + } _ => { let mut out = String::new(); for k in kids { @@ -610,6 +667,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), _ => react(doc, c), } @@ -807,3 +865,63 @@ fn compose_definition(doc: &Document, c: &Component) -> String { out.push_str("}\n"); out } + +/// A Flutter `StatelessWidget`: variant enums, a const constructor with defaults, and the widget +/// tree in `build`. +fn flutter_definition(doc: &Document, c: &Component) -> String { + let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 }; + let mut out = String::from(FLUTTER_IMPORT); + for p in &c.params { + if let ParamKind::Variant(opts) = &p.kind { + out.push_str(&format!("enum {} {{ {} }}\n\n", enum_name(c, p, false), dart_cases(opts).join(", "))); + } + } + let t = &c.type_name; + out.push_str(&format!("class {t} extends StatelessWidget {{\n")); + if c.params.is_empty() { + out.push_str(&format!(" const {t}({{super.key}});\n")); + } else { + out.push_str(&format!(" const {t}({{\n super.key,\n")); + for p in &c.params { + match flutter_default(doc, c, p) { + Some(d) => out.push_str(&format!(" this.{} = {d},\n", p.ident)), + None => out.push_str(&format!(" this.{},\n", p.ident)), + } + } + out.push_str(" });\n\n"); + for p in &c.params { + out.push_str(&format!(" final {} {};\n", flutter_type(c, p), p.ident)); + } + } + out.push_str("\n @override\n Widget build(BuildContext context) {\n return "); + let mut body = String::new(); + flutter(&ctx, c.comp, 2, &mut body, Place::Flow); + out.push_str(body.trim_start()); + out.push_str(";\n }\n}\n"); + out +} + +/// A parameter's Dart type. +fn flutter_type(c: &Component, p: &Param) -> String { + match &p.kind { + ParamKind::Variant(_) => enum_name(c, p, false), + ParamKind::Boolean => "bool".into(), + ParamKind::Text => "String".into(), + ParamKind::InstanceSwap => "Widget".into(), + // A slot holds however many layers it was filled with. + ParamKind::Slot => "List?".into(), + } +} + +/// A parameter's default as a Dart constant (`None` for a slot, which has none): Dart wants its +/// defaults to be constants, so an instance swap is a `const` widget. +fn flutter_default(doc: &Document, c: &Component, p: &Param) -> Option { + match &p.kind { + ParamKind::Slot => None, + ParamKind::InstanceSwap => Some(match id_of(&p.default).and_then(|i| component_type(doc, i)) { + Some(t) => format!("const {t}()"), + None => "const SizedBox.shrink()".into(), + }), + _ => Some(value_expr(doc, c, p, &p.default, CodeLang::Flutter)), + } +} diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index 4cd3d776..ffe59057 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -83,6 +83,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"); @@ -117,12 +128,14 @@ 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, "leptos")["usage"], "\n"); // Text that needs escaping is an expression in JSX and escaped per language. ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}})); assert_eq!(component_code(&mut s, i, "react")["usage"], "\n"); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n"); + assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" \\$5\\nnow\")\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); } @@ -139,6 +152,8 @@ fn nested_instances_in_a_layer_tree_are_calls() { assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}"); assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")")); assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")")); + let dart = code(&mut s, screen, "flutter"); + assert!(dart.contains("Card(title: \"Inbox\")") && !dart.contains("Hello"), "{dart}"); let leptos = code(&mut s, screen, "leptos"); assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains(""), "{leptos}"); assert!(!leptos.contains("Hello"), "{leptos}"); @@ -173,6 +188,10 @@ fn variant_sets_share_one_definition() { let kt = code(&mut s, set, "compose"); assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}"); assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}"); + 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 leptos = code(&mut s, set, "leptos"); for want in [ "#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}", @@ -189,6 +208,7 @@ fn variant_sets_share_one_definition() { assert_eq!(component_code(&mut s, i, "react")["usage"], "