mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
Merge main into #1421
apply_order takes #1458's numbering; a $var wrapper ranks 7, after boundVariables.
This commit is contained in:
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::{CellAlign, MainAlign, NodeId, Sizing};
|
||||
use figcraft_doc::{CellAlign, CrossAlign, LayoutMode, MainAlign, NodeId, Sizing};
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
@@ -233,3 +233,46 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
|
||||
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
|
||||
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
}
|
||||
|
||||
/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
|
||||
/// an object that replaces the whole auto layout) applies first and the other keys tune it.
|
||||
#[test]
|
||||
fn auto_layout_keys_in_one_call_tune_the_mode_given_beside_them() {
|
||||
for (key, mode) in ["layout", "layoutMode"].into_iter().flat_map(|k| [(k, json!("vertical")), (k, json!({"mode": "vertical"}))]) {
|
||||
let mut props = json!({"gap": 8, "padding": 4, "crossAlign": "center"});
|
||||
props[key] = mode;
|
||||
let mut s = Session::new();
|
||||
let created = id(s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap());
|
||||
let set_on = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
set(&mut s, set_on, props.clone());
|
||||
for f in [created, set_on] {
|
||||
let l = s.doc().unwrap().node(f).unwrap().layout.clone().unwrap();
|
||||
assert_eq!((l.mode, l.gap, l.padding, l.cross_align), (LayoutMode::Vertical, 8.0, [4.0; 4], CrossAlign::Center), "{props}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto layout keys beside a `layout` that turns auto layout off would turn it back on: the call
|
||||
/// is refused and the layer stays as it was.
|
||||
#[test]
|
||||
fn auto_layout_keys_beside_no_auto_layout_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
for props in
|
||||
[json!({"layout": null, "padding": 4}), json!({"layout": "none", "gap": 8, "padding": 4}), json!({"layoutMode": "NONE", "wrap": true})]
|
||||
{
|
||||
let e = s.execute("node.set", &json!({"id": f, "props": props})).unwrap_err().to_string();
|
||||
assert!(e.contains("turns auto layout off"), "{props}: {e}");
|
||||
assert!(s.doc().unwrap().node(f).unwrap().layout.is_none(), "{props}");
|
||||
let e = s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap_err().to_string();
|
||||
assert!(e.contains("turns auto layout off"), "{props}: {e}");
|
||||
}
|
||||
let e = s.execute("node.set", &json!({"id": f, "props": {"layout": null, "gap": 8, "padding": 4}})).unwrap_err().to_string();
|
||||
assert!(e.contains("`gap`, `padding` are auto layout properties, and `layout` in the same call"), "{e}");
|
||||
let e = s.execute("node.set", &json!({"id": f, "props": {"layoutMode": "NONE", "wrap": true}})).unwrap_err().to_string();
|
||||
assert!(e.contains("`wrap` is an auto layout property, and `layoutMode` in the same call"), "{e}");
|
||||
// Without other auto layout keys, `layout: null` still removes it.
|
||||
set(&mut s, f, json!({"layout": "vertical", "gap": 8}));
|
||||
set(&mut s, f, json!({"layout": null, "name": "Plain"}));
|
||||
assert!(s.doc().unwrap().node(f).unwrap().layout.is_none());
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"component.playground",
|
||||
"Component playground",
|
||||
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
|
||||
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui|flutter (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "component.playground";
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
|
||||
};
|
||||
let lang = match string(p, "language") {
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui|flutter"))?,
|
||||
None => CodeLang::React,
|
||||
};
|
||||
let (copy, inst) = build(s.doc()?, target, &values)?;
|
||||
|
||||
@@ -39,6 +39,18 @@ pub fn step_name(step: usize, json: &Value) -> Result<Option<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a step key other than `command`, `params` and `as` (`step` is its index, for the
|
||||
/// error): `args` in place of `params` would run the command without them.
|
||||
pub fn refuse_unknown_step_keys(step: usize, json: &Value) -> Result<()> {
|
||||
let Some(o) = json.as_object() else { return Ok(()) };
|
||||
let unknown: Vec<String> = o.keys().filter(|k| !matches!(k.as_str(), "command" | "params" | "as")).map(|k| format!("`{k}`")).collect();
|
||||
if unknown.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let s = if unknown.len() == 1 { "" } else { "s" };
|
||||
Err(bad("command.batch", format!("step {step}: unknown key{s} {} (a step takes command, params and as)", unknown.join(", "))))
|
||||
}
|
||||
|
||||
/// The steps of one batch run so far: their results, and which step each `as` name belongs to.
|
||||
/// A nested `command.batch` step gets its own scope, so its references are to its own steps.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
//!
|
||||
//! The selected shapes' outlines split the plane into **regions** (the faces of their arrangement):
|
||||
//! connected areas covered by the same shapes. Merging regions makes one new vector from them,
|
||||
//! styled like the top-most shape covering the first region; removing regions deletes them. Either
|
||||
//! way every shape covering a picked region loses that area (it becomes a vector, or is deleted
|
||||
//! when nothing is left), so the rest of the drawing stays exactly where it was.
|
||||
//! styled like the top-most fill or stroke covering the first selected region (using that vector
|
||||
//! region's paints when applicable); removing regions deletes them. Either way every shape covering
|
||||
//! a picked region loses that area (it becomes a vector, or is deleted when nothing is left), so
|
||||
//! the rest of the drawing stays exactly where it was.
|
||||
//!
|
||||
//! **Strokes and open paths.** A visible stroke takes part as its outlined area (a [`Part`] of its
|
||||
//! own, above the shape's fill), so stroked lines and open paths split the regions they cross and
|
||||
@@ -37,6 +38,9 @@ use crate::{EngineError, Result, Session, bad, spec};
|
||||
pub struct Part {
|
||||
pub id: NodeId,
|
||||
pub stroke: bool,
|
||||
/// The source vector region for a fill (`None` for ordinary fills and strokes).
|
||||
#[serde(skip)]
|
||||
pub region: Option<usize>,
|
||||
}
|
||||
|
||||
/// One region of the arrangement, in page coordinates.
|
||||
@@ -126,7 +130,16 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
|
||||
if !(figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_))) {
|
||||
return None;
|
||||
}
|
||||
figcraft_render::node_outline(doc, n)?
|
||||
match (&n.kind, part.region) {
|
||||
(NodeKind::Vector { network }, Some(i)) => {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
let region = net.regions.get(i)?;
|
||||
let path = net.region_path(i)?;
|
||||
(path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
|
||||
}
|
||||
_ => figcraft_render::node_outline(doc, n)?,
|
||||
}
|
||||
};
|
||||
p.apply_affine(doc.world_transform(part.id));
|
||||
(!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd))
|
||||
@@ -155,10 +168,17 @@ fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement>
|
||||
let mut inputs = Vec::new();
|
||||
for id in eligible(&doc, ids) {
|
||||
let before = parts.len();
|
||||
for part in [Part { id, stroke: false }, Part { id, stroke: true }] {
|
||||
if inputs.len() < figcraft_pathops::MAX_ARRANGEMENT_INPUTS
|
||||
&& let Some(o) = world_area(&doc, part)
|
||||
{
|
||||
let fill_parts: Vec<Part> = match &doc.get(id)?.kind {
|
||||
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
|
||||
network.regions.iter().enumerate().map(|(region, _)| Part { id, stroke: false, region: Some(region) }).collect()
|
||||
}
|
||||
_ => vec![Part { id, stroke: false, region: None }],
|
||||
};
|
||||
for part in fill_parts.into_iter().chain([Part { id, stroke: true, region: None }]) {
|
||||
if let Some(o) = world_area(&doc, part) {
|
||||
if inputs.len() >= figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
|
||||
return Err(EngineError::Other("shape builder: too many fill regions and strokes to process safely".into()));
|
||||
}
|
||||
parts.push(part);
|
||||
inputs.push(o);
|
||||
}
|
||||
@@ -253,6 +273,68 @@ fn network_from(path: &BezPath, to_local: Affine) -> Option<VectorNetwork> {
|
||||
Some(net)
|
||||
}
|
||||
|
||||
fn append_network(into: &mut VectorNetwork, mut part: VectorNetwork) {
|
||||
let (vertices, segments) = (into.vertices.len(), into.segments.len());
|
||||
for segment in &mut part.segments {
|
||||
segment.a += vertices;
|
||||
segment.b += vertices;
|
||||
}
|
||||
for region in &mut part.regions {
|
||||
for loop_segments in &mut region.loops {
|
||||
for segment in loop_segments {
|
||||
*segment += segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
into.vertices.extend(part.vertices);
|
||||
into.segments.extend(part.segments);
|
||||
into.regions.extend(part.regions);
|
||||
}
|
||||
|
||||
fn paint_frame(region: Option<&figcraft_doc::Region>, node: &Node) -> (f64, f64, Affine) {
|
||||
region.map_or((node.width, node.height, Affine::IDENTITY), |r| r.paint_frame(node.width, node.height))
|
||||
}
|
||||
|
||||
/// Rebuild `id` from each remaining source region, retaining region order, paint stacks and frames.
|
||||
fn reshape_vector_regions(doc: &mut Document, id: NodeId, areas: &[(usize, BezPath)]) -> Result<()> {
|
||||
let old = doc.get(id)?.clone();
|
||||
let (world, network) = match &old.kind {
|
||||
NodeKind::Vector { network } => (doc.world_transform(id), network),
|
||||
_ => return Err(EngineError::Other("expected a vector with regions".into())),
|
||||
};
|
||||
if !(world.determinant().is_finite() && world.determinant().abs() > 1e-12) {
|
||||
return Err(EngineError::Other("can't reshape a layer scaled to nothing".into()));
|
||||
}
|
||||
let mut rebuilt = VectorNetwork::default();
|
||||
for (index, area) in areas {
|
||||
let Some(source) = network.regions.get(*index) else { continue };
|
||||
let Some(mut part) = network_from(area, world.inverse()) else { return Err(EngineError::Other("invalid geometry".into())) };
|
||||
if part.regions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (width, height, frame) = paint_frame(Some(source), &old);
|
||||
for region in &mut part.regions {
|
||||
region.fills = source.fills.clone();
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width, height, transform: frame });
|
||||
}
|
||||
append_network(&mut rebuilt, part);
|
||||
}
|
||||
let Some(bounds) = rebuilt.bounds() else {
|
||||
let n = doc.get_mut(id)?;
|
||||
n.kind = NodeKind::Vector { network: rebuilt };
|
||||
return Ok(());
|
||||
};
|
||||
rebuilt.translate(-bounds.x0, -bounds.y0);
|
||||
let n = doc.get_mut(id)?;
|
||||
n.kind = NodeKind::Vector { network: rebuilt };
|
||||
n.children.clear();
|
||||
n.corners = Corners::default();
|
||||
n.transform *= Affine::translate((bounds.x0, bounds.y0));
|
||||
n.width = bounds.width();
|
||||
n.height = bounds.height();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace shape `id`'s geometry with the page-space area `area`, keeping its transform and style.
|
||||
fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
|
||||
let w = doc.world_transform(id);
|
||||
@@ -273,10 +355,16 @@ fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
|
||||
crate::vectoredit::refit(doc, id)
|
||||
}
|
||||
|
||||
/// A new vector for the page-space area `area`, styled like part `style` (a stroke part: filled
|
||||
/// with the stroke's paints), just above its shape.
|
||||
/// A new vector for page-space `area`, styled like `style` (a stroke part: filled with stroke
|
||||
/// paints), just above its shape. Vector region paints and their original frame stay attached.
|
||||
fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<NodeId> {
|
||||
let mut src = doc.get(style.id)?.clone();
|
||||
let source_region = match (&src.kind, style.region) {
|
||||
(NodeKind::Vector { network }, Some(i)) => network.regions.get(i),
|
||||
_ => None,
|
||||
};
|
||||
let (paint_width, paint_height, source_paint_frame) = paint_frame(source_region, &src);
|
||||
let region_fills = source_region.and_then(|r| r.fills.clone());
|
||||
if style.stroke {
|
||||
src.fills = std::mem::take(&mut src.strokes);
|
||||
src.stroke = StrokeProps::default();
|
||||
@@ -291,6 +379,12 @@ fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<Node
|
||||
let mut net = network_from(area, pw.inverse()).ok_or_else(|| EngineError::Other("invalid geometry".into()))?;
|
||||
let bb = net.bounds().ok_or_else(|| EngineError::Other("empty region".into()))?;
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let to_world = pw * Affine::translate((bb.x0, bb.y0));
|
||||
let paint_transform = to_world.inverse() * doc.world_transform(style) * source_paint_frame;
|
||||
for region in &mut net.regions {
|
||||
region.fills = region_fills.clone();
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let mut v = Node::new(id, NodeKind::Vector { network: net }, src.name.clone(), bb.width(), bb.height());
|
||||
v.transform = Affine::translate((bb.x0, bb.y0));
|
||||
@@ -321,7 +415,9 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
// Merging every region of a single shape would only replace it with a copy of itself.
|
||||
let whole_shape =
|
||||
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
|
||||
if merge && whole_shape {
|
||||
// A vector's distinct region paints are separate fill parts that merging does unify.
|
||||
let fill_parts = touched_parts.iter().filter(|part| !part.stroke).count();
|
||||
if merge && whole_shape && fill_parts <= 1 {
|
||||
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
@@ -347,7 +443,8 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
let mut changed = Vec::new();
|
||||
let mut outlined: Vec<(NodeId, NodeId)> = Vec::new();
|
||||
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
|
||||
let (fill, stroke) = (Part { id: src, stroke: false }, Part { id: src, stroke: true });
|
||||
let stroke = Part { id: src, stroke: true, region: None };
|
||||
let fill_parts: Vec<Part> = a.parts.iter().copied().filter(|part| part.id == src && !part.stroke).collect();
|
||||
// The stroke becomes an outline of its own (what's left of it), the shape loses it.
|
||||
if a.parts.contains(&stroke) {
|
||||
if let Some(area) = kept(stroke)? {
|
||||
@@ -357,16 +454,35 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
n.strokes.clear();
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
let left = if !a.parts.contains(&fill) {
|
||||
None
|
||||
} else if touched_parts.contains(&fill) {
|
||||
kept(fill)?
|
||||
} else {
|
||||
if fill_parts.is_empty() {
|
||||
doc.remove(src)?;
|
||||
removed.push(src);
|
||||
continue;
|
||||
}
|
||||
let touched_fills: Vec<Part> = fill_parts.iter().copied().filter(|part| touched_parts.contains(part)).collect();
|
||||
if touched_fills.is_empty() {
|
||||
// The fill wasn't picked: the shape stays as it is.
|
||||
changed.push(src);
|
||||
continue;
|
||||
};
|
||||
match left {
|
||||
}
|
||||
if fill_parts.iter().any(|part| part.region.is_some()) {
|
||||
let mut areas = Vec::new();
|
||||
for part in &fill_parts {
|
||||
let Some(region) = part.region else { continue };
|
||||
if let Some(area) = kept(*part)? {
|
||||
areas.push((region, area));
|
||||
}
|
||||
}
|
||||
if areas.is_empty() {
|
||||
doc.remove(src)?;
|
||||
removed.push(src);
|
||||
} else {
|
||||
reshape_vector_regions(doc, src, &areas)?;
|
||||
changed.push(src);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match kept(fill_parts[0])? {
|
||||
Some(area) => {
|
||||
reshape(doc, src, &area)?;
|
||||
changed.push(src);
|
||||
@@ -427,7 +543,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"shapeBuilder.merge",
|
||||
"Merge regions",
|
||||
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first; the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
|
||||
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first selected region (using its vector-region paints); the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
|
||||
true,
|
||||
|s, p| apply(s, "shapeBuilder.merge", p, true)
|
||||
),
|
||||
@@ -464,6 +580,10 @@ pub fn key(s: &mut Session, name: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "shapebuilder_region_paints_tests.rs"]
|
||||
mod region_paints_tests;
|
||||
|
||||
impl Session {
|
||||
/// The Shape builder tool: hover highlights a region, click/drag collects regions, release
|
||||
/// merges (or removes, with Alt or in Remove mode) them as one undo step. Clicking outside
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
use figcraft_color::{Color, Gradient, GradientKind, Paint};
|
||||
use figcraft_doc::{NodeId, NodeKind, Region, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::{Affine, Point, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::shapebuilder::arrangement;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, width: f64, height: f64, fill: &str) -> NodeId {
|
||||
id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": width, "height": height, "props": {"fill": fill}})))
|
||||
}
|
||||
|
||||
fn append(into: &mut VectorNetwork, mut part: VectorNetwork) {
|
||||
let (vertices, segments) = (into.vertices.len(), into.segments.len());
|
||||
for segment in &mut part.segments {
|
||||
segment.a += vertices;
|
||||
segment.b += vertices;
|
||||
}
|
||||
for region in &mut part.regions {
|
||||
for lp in &mut region.loops {
|
||||
for segment in lp {
|
||||
*segment += segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
into.vertices.extend(part.vertices);
|
||||
into.segments.extend(part.segments);
|
||||
into.regions.extend(part.regions);
|
||||
}
|
||||
|
||||
fn box_path(x0: f64, y0: f64, x1: f64, y1: f64) -> VectorNetwork {
|
||||
VectorNetwork::from_points(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
}
|
||||
|
||||
fn split_vector(s: &mut Session) -> NodeId {
|
||||
let id = rect(s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut network = VectorNetwork::default();
|
||||
append(&mut network, box_path(0.0, 0.0, 50.0, 100.0));
|
||||
append(&mut network, box_path(50.0, 0.0, 100.0, 100.0));
|
||||
network.regions[0].fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]);
|
||||
network.regions[1].fills = Some(vec![Paint::solid(Color::from_hex("#0000ff").unwrap())]);
|
||||
s.doc_mut().unwrap().get_mut(id).unwrap().kind = NodeKind::Vector { network };
|
||||
id
|
||||
}
|
||||
|
||||
fn network(s: &Session, id: NodeId) -> &VectorNetwork {
|
||||
let NodeKind::Vector { network } = &s.doc().unwrap().node(id).unwrap().kind else { panic!("expected vector") };
|
||||
// This helper's borrow is tied to the session document.
|
||||
network
|
||||
}
|
||||
|
||||
fn world_region_area(s: &Session, id: NodeId, index: usize) -> f64 {
|
||||
let doc = s.doc().unwrap();
|
||||
let mut path = network(s, id).region_path(index).unwrap();
|
||||
path.apply_affine(doc.world_transform(id));
|
||||
path.area().abs()
|
||||
}
|
||||
|
||||
fn normalized_paint_world(s: &Session, id: NodeId, index: usize) -> Affine {
|
||||
let doc = s.doc().unwrap();
|
||||
let node = doc.node(id).unwrap();
|
||||
let (width, height, transform) = network(s, id).regions[index].paint_frame(node.width, node.height);
|
||||
doc.world_transform(id) * transform * Affine::scale_non_uniform(width, height)
|
||||
}
|
||||
|
||||
fn assert_same_transform(a: Affine, b: Affine) {
|
||||
for (x, y) in a.as_coeffs().into_iter().zip(b.as_coeffs()) {
|
||||
assert!((x - y).abs() < 1e-8, "paint frame changed: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrangement_keeps_internal_paint_boundaries_when_another_shape_crosses_them() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
|
||||
let left = a.region_at(Point::new(35.0, 50.0)).unwrap();
|
||||
let right = a.region_at(Point::new(65.0, 50.0)).unwrap();
|
||||
assert_ne!(left, right, "the old paint boundary must split the crossing shape's overlap");
|
||||
assert!(a.regions[left].parts.iter().any(|p| p.id == vector && p.region == Some(0)));
|
||||
assert!(a.regions[right].parts.iter().any(|p| p.id == vector && p.region == Some(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_unstyled_nested_evenodd_regions_keep_the_hole_unpainted() {
|
||||
let mut s = Session::new();
|
||||
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut net = box_path(0.0, 0.0, 100.0, 100.0);
|
||||
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
|
||||
append(&mut net, inner);
|
||||
for region in &mut net.regions {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
region.fills = None;
|
||||
region.paint_space = None;
|
||||
}
|
||||
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net };
|
||||
let crossing = rect(&mut s, -5.0, 45.0, 15.0, 10.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
|
||||
assert!(a.region_at(Point::new(50.0, 50.0)).is_none(), "the legacy combined even-odd fill leaves its nested center empty");
|
||||
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[5, 50]]}));
|
||||
let NodeKind::Vector { network } = &s.doc().unwrap().node(vector).unwrap().kind else { panic!("expected vector") };
|
||||
assert!(!network.fill_contains(Point::new(50.0, 50.0)), "the nested hole survives removal");
|
||||
assert!(network.fill_contains(Point::new(85.0, 50.0)), "unselected outer area remains filled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_all_painted_regions_uses_the_first_picked_region_style() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
s.set_selection(vec![vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
|
||||
let merged = id(&result);
|
||||
assert!(!s.doc().unwrap().contains(vector));
|
||||
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!((world_region_area(&s, merged, 0) - 10_000.0).abs() < 0.1);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(network(&s, vector).regions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_all_inherited_gradient_regions_with_distinct_frames_is_not_a_noop() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.fills = vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))];
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
|
||||
for (region, offset) in network.regions.iter_mut().zip([0.0, 12.0]) {
|
||||
region.fills = None;
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::translate((offset, 0.0)) });
|
||||
}
|
||||
}
|
||||
let before_a = normalized_paint_world(&s, vector, 0);
|
||||
let before_b = normalized_paint_world(&s, vector, 1);
|
||||
assert_ne!(before_a, before_b, "source regions use distinct inherited-gradient frames");
|
||||
|
||||
s.set_selection(vec![vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
|
||||
let merged = id(&result);
|
||||
assert!(!s.doc().unwrap().contains(vector), "the selected regions are merged into a new vector");
|
||||
assert_eq!(network(&s, merged).regions.len(), 1);
|
||||
assert_eq!(network(&s, merged).regions[0].fills, None, "node gradient remains inherited");
|
||||
assert!(matches!(s.doc().unwrap().node(merged).unwrap().fills[0].kind, figcraft_color::PaintKind::Gradient(_)));
|
||||
assert_same_transform(normalized_paint_world(&s, merged, 0), before_a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exceeding_arrangement_capacity_never_drops_unprocessed_regions() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let mut net = VectorNetwork::default();
|
||||
for i in 0..=figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
|
||||
let mut part = box_path(i as f64 * 12.0, 0.0, i as f64 * 12.0 + 10.0, 10.0);
|
||||
part.regions[0].fills = Some(vec![Paint::solid(Color::BLACK)]);
|
||||
append(&mut net, part);
|
||||
}
|
||||
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net.clone() };
|
||||
s.set_selection(vec![vector]);
|
||||
let error = s.execute("shapeBuilder.remove", &json!({"points": [[5, 5]]})).unwrap_err();
|
||||
assert!(error.to_string().contains("too many fill regions"));
|
||||
assert_eq!(network(&s, vector), &net);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_changes_only_the_picked_painted_face() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
|
||||
assert!((world_region_area(&s, vector, 0) - 2500.0).abs() < 0.1);
|
||||
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "the untouched blue region keeps its area");
|
||||
let net = network(&s, vector);
|
||||
assert_eq!(net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(net.regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_keeps_inherited_and_explicitly_empty_region_fills_distinct() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let NodeKind::Vector { network } = &mut doc.get_mut(vector).unwrap().kind else { panic!("expected vector") };
|
||||
network.regions[0].fills = Some(Vec::new());
|
||||
network.regions[1].fills = None;
|
||||
}
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
|
||||
assert_eq!(network(&s, vector).regions[0].fills, Some(Vec::new()));
|
||||
assert_eq!(network(&s, vector).regions[1].fills, None);
|
||||
assert_eq!(s.doc().unwrap().node(vector).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_uses_selected_region_paints_and_retains_other_region_stack() {
|
||||
let mut s = Session::new();
|
||||
// Create crossing first, then vector above it so vector region supplies merge appearance.
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
let vector = split_vector(&mut s);
|
||||
s.set_selection(vec![crossing, vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[35, 50]]}));
|
||||
let merged = id(&result);
|
||||
let merged_net = network(&s, merged);
|
||||
assert_eq!(merged_net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "unselected blue region survives");
|
||||
assert_eq!(network(&s, vector).regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
|
||||
assert!((world_region_area(&s, merged, 0) - 2500.0).abs() < 0.1);
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(network(&s, vector).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!(s.redo().unwrap());
|
||||
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_region_keeps_a_transformed_source_gradient_frame() {
|
||||
let mut s = Session::new();
|
||||
let crossing = rect(&mut s, 0.0, 0.0, 300.0, 200.0, "#ffffff");
|
||||
let vector = split_vector(&mut s);
|
||||
let mut transform = Affine::translate((100.0, 20.0)) * Affine::rotate(0.2);
|
||||
transform *= Affine::scale_non_uniform(1.2, 0.8);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.transform = transform;
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
|
||||
network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]);
|
||||
}
|
||||
let before = normalized_paint_world(&s, vector, 0);
|
||||
let point = s.doc().unwrap().world_transform(vector) * Point::new(35.0, 50.0);
|
||||
s.set_selection(vec![crossing, vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[point.x, point.y]]}));
|
||||
let merged = id(&result);
|
||||
assert!(matches!(&network(&s, merged).regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
|
||||
assert_same_transform(before, normalized_paint_world(&s, merged, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_preserves_holes_and_transformed_gradient_frames() {
|
||||
let mut s = Session::new();
|
||||
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut outer = box_path(0.0, 0.0, 100.0, 100.0);
|
||||
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
|
||||
let outer_loop = outer.regions[0].loops[0].clone();
|
||||
let inner_loop: Vec<usize> = inner.regions[0].loops[0].iter().map(|i| i + outer.segments.len()).collect();
|
||||
append(&mut outer, inner);
|
||||
outer.regions = vec![Region {
|
||||
loops: vec![outer_loop, inner_loop],
|
||||
winding: WindingRule::EvenOdd,
|
||||
fills: Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]),
|
||||
paint_space: None,
|
||||
}];
|
||||
let transform = Affine::translate((120.0, 20.0)) * Affine::rotate(0.2);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.kind = NodeKind::Vector { network: outer };
|
||||
node.transform = transform;
|
||||
}
|
||||
let before = normalized_paint_world(&s, vector, 0);
|
||||
let world = s.doc().unwrap().world_transform(vector) * Point::new(10.0, 50.0);
|
||||
let overlay = rect(&mut s, world.x - 5.0, world.y - 5.0, 10.0, 10.0, "#ffffff");
|
||||
s.set_selection(vec![overlay, vector]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[world.x, world.y]]}));
|
||||
let net = network(&s, vector);
|
||||
assert_eq!(net.regions.len(), 1);
|
||||
assert_eq!(net.regions[0].loops.len(), 3, "the original hole and removed face stay separate loops");
|
||||
assert!(!net.region_contains(0, Point::new(50.0, 50.0)), "the hole remains empty");
|
||||
assert!(!net.region_contains(0, Point::new(10.0, 50.0)), "the removed face remains empty");
|
||||
assert!(net.region_contains(0, Point::new(90.0, 50.0)), "the unselected ring remains filled");
|
||||
assert_same_transform(before, normalized_paint_world(&s, vector, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_left_region_keeps_the_right_region_paint_frame() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
{
|
||||
let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(vector).unwrap().kind else { panic!("expected vector") };
|
||||
network.regions[1].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#0000ff").unwrap()))]);
|
||||
}
|
||||
let before = normalized_paint_world(&s, vector, 1);
|
||||
s.set_selection(vec![vector]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[25, 50]]}));
|
||||
let node = s.doc().unwrap().node(vector).unwrap();
|
||||
assert!((node.width - 50.0).abs() < 1e-6, "the layer is reboxed to the surviving region: {}", node.width);
|
||||
let net = network(&s, vector);
|
||||
assert_eq!(net.regions.len(), 1);
|
||||
assert!(matches!(&net.regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
|
||||
assert!((world_region_area(&s, vector, 0) - 5000.0).abs() < 1e-6);
|
||||
assert_same_transform(before, normalized_paint_world(&s, vector, 0));
|
||||
}
|
||||
@@ -1219,17 +1219,31 @@ pub(crate) fn cut_across(s: &mut Session, a: Point, b: Point) -> Result<Vec<Node
|
||||
/// under it, the others get the same (points outside closed areas are skipped). A vector without
|
||||
/// fills gets the default fill so the painted area shows.
|
||||
pub fn paint_all(doc: &mut Document, id: NodeId, pts: &[Point]) -> Result<bool> {
|
||||
let inherited = doc.get(id)?.fills.clone();
|
||||
let filled = edit(doc, id, |net| {
|
||||
let mut want = None;
|
||||
for p in pts {
|
||||
want = net.paint_to(*p, want).or(want);
|
||||
want = net.paint_to_with(*p, want, Some(&inherited)).or(want);
|
||||
}
|
||||
want
|
||||
})?
|
||||
.ok_or_else(|| bad("vector.edit.paint", "click inside a closed area"))?;
|
||||
let n = doc.get_mut(id)?;
|
||||
if filled && n.fills.iter().all(|f| !f.visible) {
|
||||
n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)];
|
||||
let default = figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE);
|
||||
let targets: std::collections::BTreeSet<usize> = match &n.kind {
|
||||
NodeKind::Vector { network } => pts.iter().filter_map(|point| network.region_at(*point)).collect(),
|
||||
_ => std::collections::BTreeSet::new(),
|
||||
};
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
for (index, region) in network.regions.iter_mut().enumerate() {
|
||||
if region.fills.is_none() && !targets.contains(&index) {
|
||||
// Freeze an untouched inherited region before adding a node-wide default.
|
||||
region.fills = Some(Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
n.fills = vec![default];
|
||||
}
|
||||
Ok(filled)
|
||||
}
|
||||
|
||||
@@ -19,15 +19,21 @@ pub fn move_vertices(net: &mut VectorNetwork, idx: &[usize], d: Vec2) {
|
||||
if !finite(d) {
|
||||
return;
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for i in idx {
|
||||
if seen.insert(*i)
|
||||
&& let Some(v) = net.vertices.get_mut(*i)
|
||||
{
|
||||
let seen: std::collections::BTreeSet<usize> = idx.iter().copied().filter(|i| *i < net.vertices.len()).collect();
|
||||
for i in &seen {
|
||||
if let Some(v) = net.vertices.get_mut(*i) {
|
||||
v.x += d.x;
|
||||
v.y += d.y;
|
||||
}
|
||||
}
|
||||
if seen.len() == net.vertices.len() {
|
||||
let translate = Affine::translate(d);
|
||||
for region in &mut net.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = translate * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map vertices `idx` through `m` (scale, rotate, flip…); the handles at them follow its linear
|
||||
@@ -65,6 +71,13 @@ pub fn transform_vertices(net: &mut VectorNetwork, idx: &[usize], m: Affine) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if set.len() == net.vertices.len() {
|
||||
for region in &mut net.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = m * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a handle to `rel` (relative to its vertex). The vertex's other handle follows its mirroring
|
||||
@@ -586,7 +599,7 @@ pub fn part(net: &VectorNetwork, verts: &[usize]) -> VectorNetwork {
|
||||
let loops: Vec<Vec<usize>> =
|
||||
r.loops.iter().map(|l| l.iter().filter_map(|i| smap.get(*i).copied().flatten()).collect::<Vec<_>>()).filter(|l| !l.is_empty()).collect();
|
||||
if !loops.is_empty() {
|
||||
out.regions.push(Region { loops, winding: r.winding });
|
||||
out.regions.push(Region { loops, ..r.clone() });
|
||||
}
|
||||
}
|
||||
reopen_loops(&mut out);
|
||||
@@ -648,7 +661,7 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
|
||||
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
|
||||
if unfilled {
|
||||
lp.insert(0, si);
|
||||
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero });
|
||||
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
|
||||
}
|
||||
}
|
||||
Some(si)
|
||||
@@ -862,6 +875,14 @@ mod tests {
|
||||
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0)], false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_part_keeps_region_paint_metadata() {
|
||||
let mut n = square();
|
||||
n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
|
||||
let part = part(&n, &[0, 1, 2, 3]);
|
||||
assert_eq!(part.regions[0].fills, n.regions[0].fills);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_follow_mirroring() {
|
||||
let mut n = open_line();
|
||||
@@ -899,7 +920,7 @@ mod tests {
|
||||
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
|
||||
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
|
||||
n.segments.push(Segment::line(0, 2));
|
||||
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
|
||||
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
|
||||
let p = Point::new(25.0, 10.0);
|
||||
assert!(n.fill_contains(p));
|
||||
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
|
||||
|
||||
@@ -52,9 +52,8 @@ pub fn hatch_spacing(zoom: f64) -> Option<f64> {
|
||||
(s.is_finite() && s > 0.0).then_some(s)
|
||||
}
|
||||
|
||||
/// Whether vector `n` shows a fill (only filled areas are regions).
|
||||
fn filled(n: &figcraft_doc::Node) -> bool {
|
||||
n.fills.iter().any(|f| f.visible)
|
||||
fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool {
|
||||
net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible))
|
||||
}
|
||||
|
||||
/// The network as its fill draws it: rounded points drawn in (region indexes are kept).
|
||||
@@ -73,7 +72,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
|
||||
return None;
|
||||
}
|
||||
let net = network(doc, ve.id)?;
|
||||
if net.regions.is_empty() || !doc.node(ve.id).is_some_and(filled) {
|
||||
let node = doc.node(ve.id)?;
|
||||
if net.regions.is_empty() || !net.regions.iter().enumerate().any(|(i, _)| region_filled(node, net, i)) {
|
||||
return None;
|
||||
}
|
||||
let w = doc.world_transform(ve.id);
|
||||
@@ -90,7 +90,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
|
||||
if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) {
|
||||
return None;
|
||||
}
|
||||
drawn(net).region_at(lp)
|
||||
let drawn = drawn(net);
|
||||
(0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp))
|
||||
}
|
||||
|
||||
/// The regions to hatch: the selected ones and the hovered one (not while a drag is on), each once.
|
||||
|
||||
@@ -31,7 +31,7 @@ fn nested(regions: Vec<Region>) -> VectorNetwork {
|
||||
}
|
||||
|
||||
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
|
||||
Region { loops, winding }
|
||||
Region { loops, winding, fills: None, ..Default::default() }
|
||||
}
|
||||
|
||||
/// The 7.1 region: both squares as one region.
|
||||
@@ -118,6 +118,17 @@ fn only_filled_areas_are_regions() {
|
||||
assert!(ve(&s).regions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_region_hit_ignores_empty_override_and_needs_no_layer_fill() {
|
||||
let mut n = overlapping();
|
||||
n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
|
||||
n.regions[1].fills = Some(Vec::new());
|
||||
let mut s = Session::new();
|
||||
let id = editing_vector(&mut s, n);
|
||||
s.doc_mut().unwrap().get_mut(id).unwrap().fills.clear();
|
||||
assert_eq!(hover(&mut s, 100.0, 100.0, 1.0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_and_press_select_a_region_with_all_its_points() {
|
||||
// 20.
|
||||
|
||||
@@ -244,7 +244,7 @@ pub(crate) fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"vector.edit.widthPoint",
|
||||
"Width point",
|
||||
"{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)",
|
||||
"{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight), side? (left|right)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)",
|
||||
true,
|
||||
width_point
|
||||
)]
|
||||
|
||||
+111
-1
@@ -63,6 +63,8 @@ macro_rules! spec {
|
||||
struct Registry {
|
||||
list: Vec<CommandSpec>,
|
||||
index: HashMap<&'static str, usize>,
|
||||
/// The parameter names each command's doc lists (`list` order; see [`doc_param_names`]).
|
||||
names: Vec<Option<Vec<&'static str>>>,
|
||||
}
|
||||
|
||||
fn registry() -> &'static Registry {
|
||||
@@ -123,7 +125,8 @@ fn registry() -> &'static Registry {
|
||||
log::warn!("duplicate command id {}", s.id);
|
||||
}
|
||||
}
|
||||
Registry { list, index }
|
||||
let names = list.iter().map(|s| doc_param_names(s.params)).collect();
|
||||
Registry { list, index, names }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,6 +139,113 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
r.index.get(id).and_then(|i| r.list.get(*i))
|
||||
}
|
||||
|
||||
/// The parameter names command `id` takes, from its doc; `None` when the doc isn't a `{…}` list
|
||||
/// (`node.set` documents its props, and refuses the ones it doesn't know itself).
|
||||
pub fn param_names(id: &str) -> Option<&'static [&'static str]> {
|
||||
let r = registry();
|
||||
r.index.get(id).and_then(|i| r.names.get(*i)).and_then(|n| n.as_deref())
|
||||
}
|
||||
|
||||
/// Refuse params naming something the command doesn't take: a misspelt name would otherwise be
|
||||
/// ignored and the command answer as if it hadn't been given. `ids` and `id` are always taken:
|
||||
/// the layers a command acts on (`params::targets` reads either).
|
||||
pub fn refuse_unknown_params(id: &str, params: &Value) -> Result<()> {
|
||||
let (Some(names), Some(given)) = (param_names(id), params.as_object()) else { return Ok(()) };
|
||||
let taken = |k: &str| matches!(k, "ids" | "id") || names.contains(&k);
|
||||
let unknown: Vec<String> = given.keys().filter(|k| !taken(k)).map(|k| format!("`{k}`")).collect();
|
||||
if unknown.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let s = if unknown.len() == 1 { "" } else { "s" };
|
||||
let takes = if names.is_empty() { "none".to_string() } else { names.join(", ") };
|
||||
Err(crate::bad(id, format!("unknown parameter{s} {} (it takes {takes})", unknown.join(", "))))
|
||||
}
|
||||
|
||||
/// The top-level items of a `{…}` params doc (`{ids?, direction? (horizontal|vertical)}` →
|
||||
/// `ids?`, ` direction? (horizontal|vertical)`), with those of the alternatives after it
|
||||
/// (`{x, y} | {points}`); `None` when the doc doesn't start with one.
|
||||
pub(crate) fn doc_items(doc: &'static str) -> Option<Vec<&'static str>> {
|
||||
let mut body = doc.trim_start().strip_prefix('{')?;
|
||||
let (mut items, mut depth, mut start) = (Vec::new(), 0usize, 0);
|
||||
loop {
|
||||
let mut rest = None;
|
||||
for (i, c) in body.char_indices() {
|
||||
match c {
|
||||
'{' | '[' | '(' => depth += 1,
|
||||
'}' | ']' | ')' if depth > 0 => depth -= 1,
|
||||
'}' | ',' if depth == 0 => {
|
||||
items.push(body.get(start..i).unwrap_or_default());
|
||||
start = i + c.len_utf8();
|
||||
if c == '}' {
|
||||
rest = body.get(start..);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let rest = rest?;
|
||||
match rest.trim_start().strip_prefix('|').and_then(|r| r.trim_start().strip_prefix('{')) {
|
||||
Some(next) => (body, start) = (next, 0),
|
||||
None => return Some(items),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The names one doc item gives: its first word and the words joined to it by `|`, `/` or `+`
|
||||
/// (`id?/ids?`, `path | data (base64) + name`, `value? + newValue?`), without the `?` of optional
|
||||
/// ones. Bracketed text describes a name, and what follows a `:`, `=` or `→` its value.
|
||||
pub(crate) fn item_names(item: &'static str) -> Vec<&'static str> {
|
||||
let mut outside = Vec::new();
|
||||
let (mut depth, mut start) = (0usize, None);
|
||||
for (i, c) in item.char_indices() {
|
||||
let value_follows = depth == 0 && matches!(c, ':' | '=' | '→');
|
||||
let in_word = depth == 0 && !value_follows && !c.is_whitespace() && !matches!(c, '(' | '[' | '{' | ')' | ']' | '}');
|
||||
match c {
|
||||
'(' | '[' | '{' => depth += 1,
|
||||
')' | ']' | '}' => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
match (in_word, start) {
|
||||
(true, None) => start = Some(i),
|
||||
(false, Some(s)) => {
|
||||
outside.push(item.get(s..i).unwrap_or_default());
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if value_follows {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
outside.push(item.get(s..).unwrap_or_default());
|
||||
}
|
||||
let is_name =
|
||||
|n: &&str| n.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
let mut words = outside.into_iter();
|
||||
let (mut names, mut word) = (Vec::new(), words.next());
|
||||
while let Some(w) = word {
|
||||
names.extend(w.split(['/', '|']).map(|n| n.trim_end_matches('?')).filter(is_name));
|
||||
word = match words.next() {
|
||||
Some("|" | "/" | "+") => words.next(),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Every name a `{…}` params doc lists, once each (see [`item_names`]).
|
||||
fn doc_param_names(doc: &'static str) -> Option<Vec<&'static str>> {
|
||||
let mut names = Vec::new();
|
||||
for n in doc_items(doc)?.into_iter().flat_map(item_names) {
|
||||
if !names.contains(&n) {
|
||||
names.push(n);
|
||||
}
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
|
||||
/// `{"ok": true}` shorthand.
|
||||
pub fn ok() -> Result<Value> {
|
||||
Ok(serde_json::json!({"ok": true}))
|
||||
|
||||
@@ -576,7 +576,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"id": id, "link": link(id)}))
|
||||
}
|
||||
),
|
||||
spec!("comment.reply", "Reply to comment", "{id, text, author?}", true, |s, p| {
|
||||
spec!("comment.reply", "Reply to comment", "{id | link, text, author?}", true, |s, p| {
|
||||
let id = req_comment("comment.reply", s, p)?;
|
||||
let who = author(s, p);
|
||||
let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs());
|
||||
@@ -586,7 +586,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
c.touched_by(&who);
|
||||
Ok(json!({"reply": c.replies.len() - 1}))
|
||||
}),
|
||||
spec!("comment.edit", "Edit comment", "{id, text, reply? (index; omit for the first message), author?}", true, |s, p| {
|
||||
spec!("comment.edit", "Edit comment", "{id | link, text, reply? (index; omit for the first message), author?}", true, |s, p| {
|
||||
let id = req_comment("comment.edit", s, p)?;
|
||||
let t = text("comment.edit", p)?;
|
||||
let who = author(s, p);
|
||||
@@ -607,7 +607,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"comment.react",
|
||||
"React to comment",
|
||||
"{id, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}",
|
||||
"{id | link, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let id = req_comment("comment.react", s, p)?;
|
||||
@@ -629,7 +629,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"on": on}))
|
||||
}
|
||||
),
|
||||
spec!("comment.resolve", "Resolve comment", "{id, resolved? (default true)}", true, |s, p| {
|
||||
spec!("comment.resolve", "Resolve comment", "{id | link, resolved? (default true)}", true, |s, p| {
|
||||
let id = req_comment("comment.resolve", s, p)?;
|
||||
let v = boolean(p, "resolved").unwrap_or(true);
|
||||
if let Some(c) = s.doc_mut()?.comment_mut(id) {
|
||||
@@ -640,7 +640,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"comment.move",
|
||||
"Move comment",
|
||||
"{id, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}",
|
||||
"{id | link, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}",
|
||||
true,
|
||||
|s, p| {
|
||||
let id = req_comment("comment.move", s, p)?;
|
||||
@@ -655,24 +655,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}
|
||||
),
|
||||
spec!("comment.delete", "Delete comment", "{id, reply? (index: delete one reply; omit to delete the thread), author?}", true, |s, p| {
|
||||
let id = req_comment("comment.delete", s, p)?;
|
||||
let who = author(s, p);
|
||||
let doc = s.doc_mut()?;
|
||||
let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?;
|
||||
match message_index("comment.delete", c, p)? {
|
||||
0 => {
|
||||
own("comment.delete", &c.first, &who, "delete this thread")?;
|
||||
doc.comments.retain(|c| c.id != id);
|
||||
}
|
||||
i => {
|
||||
let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?;
|
||||
own("comment.delete", m, &who, "delete this reply")?;
|
||||
c.replies.remove(i - 1);
|
||||
spec!(
|
||||
"comment.delete",
|
||||
"Delete comment",
|
||||
"{id | link, reply? (index: delete one reply; omit to delete the thread), author?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let id = req_comment("comment.delete", s, p)?;
|
||||
let who = author(s, p);
|
||||
let doc = s.doc_mut()?;
|
||||
let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?;
|
||||
match message_index("comment.delete", c, p)? {
|
||||
0 => {
|
||||
own("comment.delete", &c.first, &who, "delete this thread")?;
|
||||
doc.comments.retain(|c| c.id != id);
|
||||
}
|
||||
i => {
|
||||
let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?;
|
||||
own("comment.delete", m, &who, "delete this reply")?;
|
||||
c.replies.remove(i - 1);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
),
|
||||
spec!(
|
||||
"comment.markRead",
|
||||
"Mark comment as read",
|
||||
@@ -711,7 +717,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"comment.next",
|
||||
"Next comment",
|
||||
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query? (the list, as comment.list), author?}",
|
||||
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query?, frame?, resolved? (the list, as comment.list), author?}",
|
||||
false,
|
||||
|s, p| {
|
||||
let dir = num(p, "dir").unwrap_or(1.0);
|
||||
@@ -748,7 +754,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({"zoom": v.zoom}))
|
||||
}),
|
||||
spec!("comment.link", "Copy link to comment", "{id}", false, |s, p| {
|
||||
spec!("comment.link", "Copy link to comment", "{id | link}", false, |s, p| {
|
||||
let id = req_comment("comment.link", s, p)?;
|
||||
Ok(json!({"link": link(id)}))
|
||||
}),
|
||||
|
||||
@@ -53,37 +53,61 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"node.create",
|
||||
"Create layer",
|
||||
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? (for text), path? (SVG path data for vector), props? {node.set keys}, select? (default true)}",
|
||||
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]), closed? (vector), props? {node.set keys}, select? (default true)}",
|
||||
true,
|
||||
create
|
||||
),
|
||||
spec!(
|
||||
"frame.create",
|
||||
"Create frame",
|
||||
"{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?} (a preset without x/y lands to the right of the page's frames)",
|
||||
"{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, index?, name?, props?, select? (default true)} (a preset without x/y lands to the right of the page's frames)",
|
||||
true,
|
||||
frame_create
|
||||
),
|
||||
spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "rectangle")),
|
||||
spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "ellipse")),
|
||||
spec!("shape.polygon", "Polygon", "{x, y, width, height, parent?, props? {pointCount}}", true, |s, p| create_kind(s, p, "polygon")),
|
||||
spec!("shape.star", "Star", "{x, y, width, height, parent?, props? {pointCount, innerRadius}}", true, |s, p| create_kind(s, p, "star")),
|
||||
spec!("shape.line", "Line", "{x, y, width (length), parent?, props? {rotation}}", true, |s, p| create_kind(s, p, "line")),
|
||||
spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, props?}", true, |s, p| create_kind(s, p, "arrow")),
|
||||
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
|
||||
create_kind(s, p, "text")
|
||||
spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| {
|
||||
create_kind(s, p, "rectangle")
|
||||
}),
|
||||
spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(
|
||||
s, p, "ellipse"
|
||||
)),
|
||||
spec!(
|
||||
"shape.polygon",
|
||||
"Polygon",
|
||||
"{x, y, width, height, parent?, index?, name?, props? {pointCount}, select? (default true)}",
|
||||
true,
|
||||
|s, p| create_kind(s, p, "polygon")
|
||||
),
|
||||
spec!(
|
||||
"shape.star",
|
||||
"Star",
|
||||
"{x, y, width, height, parent?, index?, name?, props? {pointCount, innerRadius}, select? (default true)}",
|
||||
true,
|
||||
|s, p| create_kind(s, p, "star")
|
||||
),
|
||||
spec!("shape.line", "Line", "{x, y, width (length), parent?, index?, name?, props? {rotation}, select? (default true)}", true, |s, p| {
|
||||
create_kind(s, p, "line")
|
||||
}),
|
||||
spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(
|
||||
s, p, "arrow"
|
||||
)),
|
||||
spec!(
|
||||
"text.create",
|
||||
"Text",
|
||||
"{text | characters, x, y, parent?, index?, name? (default: the first line), width? (fixed width wraps), props? (fontSize, fontFamily, …), select? (default true)}",
|
||||
true,
|
||||
|s, p| { create_kind(s, p, "text") }
|
||||
),
|
||||
spec!(
|
||||
"vector.create",
|
||||
"Vector",
|
||||
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
|
||||
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}",
|
||||
true,
|
||||
|s, p| create_kind(s, p, "vector")
|
||||
),
|
||||
spec!(
|
||||
"image.place",
|
||||
"Place image",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, name? (default Image), target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
|
||||
true,
|
||||
place_image
|
||||
),
|
||||
|
||||
@@ -8,8 +8,8 @@ use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::{
|
||||
blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets,
|
||||
string, targets,
|
||||
blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_id, opt_index, paint_of, parse, req_id, req_index, req_num, req_string,
|
||||
req_targets, string, targets,
|
||||
};
|
||||
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
|
||||
|
||||
@@ -29,6 +29,80 @@ use select::{
|
||||
select_parent, select_same, select_sibling, toggle,
|
||||
};
|
||||
|
||||
/// The layer types `node.find` takes, as `NodeKind::type_name` spells them.
|
||||
const LAYER_TYPES: [&str; 16] = [
|
||||
"PAGE",
|
||||
"FRAME",
|
||||
"GROUP",
|
||||
"SECTION",
|
||||
"RECTANGLE",
|
||||
"ELLIPSE",
|
||||
"POLYGON",
|
||||
"STAR",
|
||||
"LINE",
|
||||
"VECTOR",
|
||||
"TEXT",
|
||||
"BOOLEAN_OPERATION",
|
||||
"COMPONENT",
|
||||
"COMPONENT_SET",
|
||||
"INSTANCE",
|
||||
"SLICE",
|
||||
];
|
||||
|
||||
/// Lowercase for `node.find`'s substring filters. Greek final sigma becomes σ on both sides, so a
|
||||
/// query lowercased on its own (`Σ` to `σ`) still matches a name ending in it (`ΟΣ` to `ος`).
|
||||
fn fold(s: &str) -> String {
|
||||
s.to_lowercase().replace('ς', "σ")
|
||||
}
|
||||
|
||||
fn node_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "node.find";
|
||||
let filter = |key| -> Result<Option<String>> {
|
||||
get(p, key).map(|v| v.as_str().map(fold).ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose()
|
||||
};
|
||||
let name = filter("name")?;
|
||||
let text = filter("text")?;
|
||||
let component = filter("component")?;
|
||||
let kind = filter("type")?;
|
||||
if let Some(kind) = &kind
|
||||
&& !LAYER_TYPES.iter().any(|t| t.eq_ignore_ascii_case(kind))
|
||||
{
|
||||
return Err(bad(cmd, format!("unknown type; use one of {}", LAYER_TYPES.join(", "))));
|
||||
}
|
||||
let limit = match get(p, "limit") {
|
||||
None => 100,
|
||||
Some(v) => v.as_u64().filter(|n| *n <= 10000).ok_or_else(|| bad(cmd, "`limit` must be an integer from 0 to 10000"))? as usize,
|
||||
};
|
||||
let root = opt_id(cmd, p, "root")?.map_or_else(|| s.page(), Ok)?;
|
||||
let doc = s.doc()?;
|
||||
// Keep one child iterator per ancestor, not a materialised subtree or all pending siblings.
|
||||
let mut stack = vec![doc.get(root)?.children.iter()];
|
||||
let mut ids = Vec::new();
|
||||
let mut total = 0usize;
|
||||
while let Some(children) = stack.last_mut() {
|
||||
let Some(&id) = children.next() else {
|
||||
stack.pop();
|
||||
continue;
|
||||
};
|
||||
let n = doc.get(id)?;
|
||||
stack.push(n.children.iter());
|
||||
if name.as_ref().is_some_and(|q| !fold(&n.name).contains(q))
|
||||
|| kind.as_ref().is_some_and(|q| !n.kind.type_name().eq_ignore_ascii_case(q))
|
||||
|| text.as_ref().is_some_and(|q| !matches!(&n.kind, NodeKind::Text(t) if fold(&t.characters).contains(q)))
|
||||
|| component.as_ref().is_some_and(
|
||||
|q| !matches!(&n.kind, NodeKind::Instance { component, .. } if doc.node(*component).is_some_and(|c| fold(&c.name).contains(q))),
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if ids.len() < limit {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(json!({"ids": ids, "total": total}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))),
|
||||
@@ -235,6 +309,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let i = req_id("node.get", p, "id")?;
|
||||
Ok(crate::inspect::node_json(s.doc()?, i, 1))
|
||||
}),
|
||||
spec!(
|
||||
"node.find",
|
||||
"Find layers",
|
||||
"{root? (strict descendants; default current page), name?, type? (canonical layer type), text?, component? (main component name), limit? (0-10000; default 100)}: every filter must match; name, text and component are case-insensitive substrings, type is exact (any case); returns {ids, total} and does not select",
|
||||
false,
|
||||
node_find
|
||||
),
|
||||
spec!(
|
||||
"node.reparent",
|
||||
"Move layer into",
|
||||
@@ -248,8 +329,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.doc_mut()?.reorder(i, idx)?;
|
||||
ok()
|
||||
}),
|
||||
spec!("fill.add", "Add fill", "{ids?, paint? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
|
||||
spec!("stroke.add", "Add stroke", "{ids?, paint? (default black)}", true, |s, p| paint_list(s, p, false, true)),
|
||||
spec!("fill.add", "Add fill", "{ids?, paint? | color? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
|
||||
spec!("stroke.add", "Add stroke", "{ids?, paint? | color? (default black)}", true, |s, p| paint_list(s, p, false, true)),
|
||||
spec!("fill.remove", "Remove fill", "{ids?, index}", true, |s, p| paint_list(s, p, true, false)),
|
||||
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
|
||||
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
|
||||
@@ -273,7 +354,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"fill.pattern",
|
||||
"Pattern fill",
|
||||
"{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?} the source layer tiled; it updates as the source changes",
|
||||
"{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?, visible?, blend?} the source layer tiled; it updates as the source changes",
|
||||
true,
|
||||
pattern_fill
|
||||
),
|
||||
@@ -354,7 +435,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"export.update",
|
||||
"Edit export setting",
|
||||
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}",
|
||||
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "export.update";
|
||||
@@ -387,6 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for (i, c) in a.iter().enumerate() {
|
||||
let cmd =
|
||||
c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
|
||||
crate::batch::refuse_unknown_step_keys(i, c)?;
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) });
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write};
|
||||
pub use open::MAX_RECENT;
|
||||
#[cfg(test)]
|
||||
use open::append_pages;
|
||||
use open::{import, open, open_recent};
|
||||
use open::{import, open, open_recent, revert};
|
||||
pub(crate) use save::save_ends_preview;
|
||||
pub use save::{FileSnapshot, file_bytes, thumbnail};
|
||||
use save::{color_profile, mark_saved, save, save_as};
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.open",
|
||||
"Open",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file), reload? (a file already open: read it again into its tab, which must have no unsaved changes)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open returns its `index`, with `stale: true` when the file changed on disk since the tab read or saved it, or `reloaded: true` after `reload`)",
|
||||
false,
|
||||
open
|
||||
),
|
||||
@@ -110,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.export",
|
||||
"Export",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
|
||||
false,
|
||||
export
|
||||
),
|
||||
@@ -121,13 +121,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
false,
|
||||
export_layers
|
||||
),
|
||||
spec!("file.exportSvg", "Export selection as SVG", "{ids?, path?}", false, |s, p| {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("format".into(), json!("svg"));
|
||||
spec!(
|
||||
"file.exportSvg",
|
||||
"Export selection as SVG",
|
||||
"{ids?, path?, scale? | size?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke? (file.export's SVG options)}",
|
||||
false,
|
||||
|s, p| {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("format".into(), json!("svg"));
|
||||
}
|
||||
export(s, &q)
|
||||
}
|
||||
export(s, &q)
|
||||
}),
|
||||
),
|
||||
spec!(
|
||||
"file.exportFramesPdf",
|
||||
"Export frames to PDF",
|
||||
@@ -142,7 +148,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
import
|
||||
),
|
||||
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
|
||||
spec!(
|
||||
"file.placeImage",
|
||||
"Place image",
|
||||
"{path | data, x?, y?, width?, height?, parent?, name?, target?, mode? (as image.place)}",
|
||||
true,
|
||||
super::create::place_image
|
||||
),
|
||||
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
|
||||
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
|
||||
let from = req_index("file.moveTab", p, "from")?;
|
||||
@@ -191,21 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"index": s.active}))
|
||||
}),
|
||||
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
|
||||
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
|
||||
// Stamped before reading: a change made during the read makes the next save refuse.
|
||||
let on_disk = crate::library::stat(&path).ok();
|
||||
let (loaded, _) = load_path(&path, None)?;
|
||||
let i = s.active;
|
||||
let uid = s.ds()?.uid;
|
||||
if let Some(slot) = s.docs.get_mut(i) {
|
||||
*slot = DocState::new(loaded.doc, Some(path.clone()))
|
||||
.with_versions(loaded.versions)
|
||||
.with_branches(loaded.branches)
|
||||
.with_images_left_in_file(&path, loaded.images_past_budget);
|
||||
slot.on_disk = on_disk;
|
||||
}
|
||||
// Edits on its branch tabs are reverted too.
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
revert(s)?;
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
|
||||
@@ -62,11 +62,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return open_data(s, &d, p, variables.as_ref());
|
||||
}
|
||||
let path = req_string("file.open", p, "path")?;
|
||||
// Already open: switch to it.
|
||||
// Already open: switch to it, and say when the file changed on disk since this tab read or
|
||||
// saved it (`stale`), or read it again (`reload`).
|
||||
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
|
||||
s.active = i;
|
||||
remember(s, &path);
|
||||
return Ok(json!({"index": i}));
|
||||
let stale = s.ds()?.on_disk.as_ref().is_some_and(|read| crate::library::stat(&path).ok().as_ref() != Some(read));
|
||||
if !boolean(p, "reload").unwrap_or(false) {
|
||||
return Ok(if stale { json!({"index": i, "stale": true}) } else { json!({"index": i}) });
|
||||
}
|
||||
if s.ds()?.is_dirty() {
|
||||
return Err(EngineError::Disabled(
|
||||
"file.open".into(),
|
||||
"the open copy has unsaved changes: save them, or Revert to saved to drop them".into(),
|
||||
));
|
||||
}
|
||||
if s.live().doc_uid.is_some() {
|
||||
return Err(EngineError::Disabled("file.open".into(), "leave the live session before reading the file again".into()));
|
||||
}
|
||||
revert(s)?;
|
||||
return Ok(json!({"index": i, "reloaded": true}));
|
||||
}
|
||||
// Stamped before reading: a change made during the read makes the next save refuse.
|
||||
let on_disk = crate::library::stat(&path).ok();
|
||||
@@ -95,6 +110,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Replace the active tab's document with its file as saved (`file.revert`, `file.open {reload}`).
|
||||
pub(super) fn revert(s: &mut Session) -> Result<()> {
|
||||
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
|
||||
// Stamped before reading: a change made during the read makes the next save refuse.
|
||||
let on_disk = crate::library::stat(&path).ok();
|
||||
let (loaded, _) = load_path(&path, None)?;
|
||||
let i = s.active;
|
||||
let uid = s.ds()?.uid;
|
||||
if let Some(slot) = s.docs.get_mut(i) {
|
||||
*slot = DocState::new(loaded.doc, Some(path.clone()))
|
||||
.with_versions(loaded.versions)
|
||||
.with_branches(loaded.branches)
|
||||
.with_images_left_in_file(&path, loaded.images_past_budget);
|
||||
slot.on_disk = on_disk;
|
||||
}
|
||||
// Edits on its branch tabs are reverted too.
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells
|
||||
/// the format. It opens as a new document with no path, so the first save asks where to put it.
|
||||
fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result<Value> {
|
||||
|
||||
@@ -405,23 +405,37 @@ pub(crate) fn variable_prop<'a>(cmd: &str, key: &str, value: &'a Value) -> Resul
|
||||
Ok(Some(name))
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
|
||||
/// links after the values so a link wins over values its style sets, variable bindings last so
|
||||
/// paints set in the same call don't detach them. `$var` wrappers also win over style links.
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, the
|
||||
/// auto layout mode before the keys that tune it (an object `layout` replaces the whole auto
|
||||
/// layout), style links after the values so a link wins over values its style sets, variable
|
||||
/// bindings last so paints set in the same call don't detach them. `$var` wrappers also win over style links.
|
||||
fn apply_order(k: &str, v: &Value) -> u8 {
|
||||
if v.get("$var").is_some() {
|
||||
return 6;
|
||||
return 7;
|
||||
}
|
||||
match k {
|
||||
"path" | "width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
|
||||
"boundVariables" => 5,
|
||||
_ => 1,
|
||||
"layout" | "layoutMode" => 1,
|
||||
"rotation" => 3,
|
||||
"x" | "y" => 4,
|
||||
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 5,
|
||||
"boundVariables" => 6,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto layout keys given beside a `layout` (or `layoutMode`) that turns auto layout off: they
|
||||
/// would turn it back on (`layout_mut`), so the call is refused rather than one of them losing.
|
||||
fn layout_keys_beside_none(cmd: &str, props: &Map<String, Value>) -> Result<()> {
|
||||
let off = ["layout", "layoutMode"]
|
||||
.into_iter()
|
||||
.find(|k| props.get(*k).is_some_and(|v| v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))));
|
||||
let tuning: Vec<String> = props.keys().filter(|k| LAYOUT_KEYS.contains(&k.as_str())).map(|k| format!("`{k}`")).collect();
|
||||
let Some(off) = off.filter(|_| !tuning.is_empty()) else { return Ok(()) };
|
||||
let (what, are) = if tuning.len() == 1 { ("an auto layout property", "is") } else { ("auto layout properties", "are") };
|
||||
Err(bad(cmd, format!("{} {are} {what}, and `{off}` in the same call turns auto layout off", tuning.join(", "))))
|
||||
}
|
||||
|
||||
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
|
||||
/// into W or H.
|
||||
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
|
||||
@@ -1034,6 +1048,7 @@ fn apply_variable(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value)
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
layout_keys_beside_none(cmd, props)?;
|
||||
let mut unknown = Vec::new();
|
||||
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
|
||||
keys.sort_by_key(|(k, v)| apply_order(k, v));
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"proto.interaction.add",
|
||||
"Add interaction",
|
||||
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
|
||||
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], dest? (without action: a click-navigate to it), transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs? | duration_ms?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}, delayMs? | delay_ms? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
|
||||
true,
|
||||
add
|
||||
),
|
||||
@@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"proto.interaction.set",
|
||||
"Edit interaction",
|
||||
"{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?, delayMs?}",
|
||||
"{id?, index, interaction? {..} (replaces it whole), trigger?, action? (first action), actions?, transition?, durationMs? | duration_ms?, easing?, delayMs? | delay_ms?} patch fields on top of the current interaction (or of interaction)",
|
||||
true,
|
||||
set
|
||||
),
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{
|
||||
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps,
|
||||
VectorNetwork, WindingRule, proto,
|
||||
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, RegionPaintSpace, Segment, Sizing, StrokeAlign, StrokeJoin,
|
||||
StrokeProps, VectorNetwork, WindingRule, proto,
|
||||
};
|
||||
use figcraft_geom::{Affine, BezPath, Rect, Shape};
|
||||
use serde_json::{Value, json};
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add),
|
||||
spec!("layout.suggest", "Suggest auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add),
|
||||
spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page),
|
||||
spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| {
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?;
|
||||
@@ -385,56 +385,35 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let pw = doc.world_transform(parent);
|
||||
let mut net = VectorNetwork::default();
|
||||
for i in &ids {
|
||||
let Some(n) = doc.node(*i) else { continue };
|
||||
// Vectors keep their own network (open paths and unfilled loops have no fill outline);
|
||||
// booleans flatten to their result; strokes don't get outlined.
|
||||
let mut part = match &n.kind {
|
||||
NodeKind::Vector { network } => network.clone(),
|
||||
_ => {
|
||||
let Some((op, rule)) = (match &n.kind {
|
||||
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
|
||||
figcraft_render::subtree_outline(doc, n, 0)
|
||||
}
|
||||
_ => figcraft_render::node_outline(doc, n),
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let mut part = VectorNetwork::from_path(&op);
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
for r in &mut part.regions {
|
||||
r.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
part
|
||||
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
|
||||
let fallback_fills = first
|
||||
.as_ref()
|
||||
.filter(|node| matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty())
|
||||
.map(|_| vec![Paint::solid(Color::DEFAULT_SHAPE)]);
|
||||
let output_fills = first
|
||||
.as_ref()
|
||||
.map(|node| {
|
||||
if matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty() {
|
||||
fallback_fills.clone().unwrap_or_default()
|
||||
} else {
|
||||
node.fills.clone()
|
||||
}
|
||||
};
|
||||
// Into the common parent's space.
|
||||
let all: Vec<usize> = (0..part.vertices.len()).collect();
|
||||
crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i));
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
net.vertices.extend(part.vertices);
|
||||
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
|
||||
net.regions.extend(
|
||||
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
|
||||
);
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut net = VectorNetwork::default();
|
||||
for id in &ids {
|
||||
collect_flatten_parts(doc, *id, pw, fallback_fills.as_deref(), &output_fills, &mut net, 0)?;
|
||||
}
|
||||
let Some(bb) = net.bounds() else {
|
||||
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
|
||||
};
|
||||
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let vid = doc.alloc_id();
|
||||
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
|
||||
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
|
||||
v.transform = Affine::translate((bb.x0, bb.y0));
|
||||
if let Some(f) = first {
|
||||
v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() {
|
||||
vec![Paint::solid(Color::DEFAULT_SHAPE)]
|
||||
} else {
|
||||
f.fills.clone()
|
||||
};
|
||||
v.fills = output_fills;
|
||||
v.strokes = f.strokes.clone();
|
||||
v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() };
|
||||
v.effects = f.effects.clone();
|
||||
@@ -449,6 +428,99 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": vid}))
|
||||
}
|
||||
|
||||
fn collect_flatten_parts(
|
||||
doc: &Document,
|
||||
id: NodeId,
|
||||
parent_world: Affine,
|
||||
fallback_fills: Option<&[Paint]>,
|
||||
output_fills: &[Paint],
|
||||
net: &mut VectorNetwork,
|
||||
depth: usize,
|
||||
) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(bad("vector.flatten", "group nesting exceeds 64"));
|
||||
}
|
||||
let Some(node) = doc.node(id) else { return Ok(()) };
|
||||
if matches!(node.kind, NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) && !node.children.is_empty() {
|
||||
// Match subtree_outline: a nonempty frame contributes its own visible fill, then
|
||||
// visible descendants in stacking order. Child clipping remains the container's outline rule.
|
||||
if node.kind.is_frame_like()
|
||||
&& node.fills.iter().any(|paint| paint.visible)
|
||||
&& let Some(path) = figcraft_doc::geometry::shape_path(node)
|
||||
{
|
||||
let frame = VectorNetwork::from_path(&path);
|
||||
let into_parent = parent_world.inverse() * doc.world_transform(id);
|
||||
append_flatten_part(node, fallback_fills, output_fills, into_parent, frame, net);
|
||||
}
|
||||
for child in &node.children {
|
||||
if !doc.node(*child).is_some_and(|child| child.visible) {
|
||||
continue;
|
||||
}
|
||||
collect_flatten_parts(doc, *child, parent_world, fallback_fills, output_fills, net, depth + 1)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let part = match &node.kind {
|
||||
NodeKind::Vector { network } => {
|
||||
let mut part = network.clone();
|
||||
if !part.regions.is_empty() && part.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()) {
|
||||
let winding = if part.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
|
||||
WindingRule::EvenOdd
|
||||
} else {
|
||||
WindingRule::NonZero
|
||||
};
|
||||
let loops = std::mem::take(&mut part.regions).into_iter().flat_map(|region| region.loops).collect();
|
||||
part.regions = vec![Region { loops, winding, ..Default::default() }];
|
||||
}
|
||||
part
|
||||
}
|
||||
_ => {
|
||||
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) };
|
||||
let mut part = VectorNetwork::from_path(&path);
|
||||
for region in &mut part.regions {
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
part
|
||||
}
|
||||
};
|
||||
let into_parent = parent_world.inverse() * doc.world_transform(id);
|
||||
append_flatten_part(node, fallback_fills, output_fills, into_parent, part, net);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_flatten_part(
|
||||
node: &Node,
|
||||
fallback_fills: Option<&[Paint]>,
|
||||
output_fills: &[Paint],
|
||||
into_parent: Affine,
|
||||
mut part: VectorNetwork,
|
||||
net: &mut VectorNetwork,
|
||||
) {
|
||||
for region in &mut part.regions {
|
||||
let (width, height, paint_transform) = region.paint_frame(node.width, node.height);
|
||||
if region.fills.is_none() {
|
||||
let effective = if node.fills.is_empty() { fallback_fills.unwrap_or(&node.fills) } else { &node.fills };
|
||||
if effective != output_fills {
|
||||
region.fills = Some(effective.to_vec());
|
||||
}
|
||||
}
|
||||
region.paint_space = Some(RegionPaintSpace { width, height, transform: paint_transform });
|
||||
}
|
||||
let vertices: Vec<usize> = (0..part.vertices.len()).collect();
|
||||
crate::vectoredit::ops::transform_vertices(&mut part, &vertices, into_parent);
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
net.vertices.extend(part.vertices);
|
||||
net.segments.extend(part.segments.into_iter().map(|segment| Segment { a: segment.a + nv, b: segment.b + nv, ..segment }));
|
||||
net.regions.extend(
|
||||
part.regions.into_iter().map(|region| Region {
|
||||
loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + ns).collect()).collect(),
|
||||
..region
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.outlineStroke", s, p)?;
|
||||
let mut out = Vec::new();
|
||||
@@ -489,7 +561,8 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v.fills = n.strokes.clone();
|
||||
v.strokes.clear();
|
||||
v.styles.fill = n.styles.stroke;
|
||||
if n.fills.iter().any(|f| f.visible) && closed {
|
||||
let region_fill = matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.as_ref().is_some_and(|fills| fills.iter().any(|f| f.visible))));
|
||||
if (n.fills.iter().any(|f| f.visible) || region_fill) && closed {
|
||||
// Keep the fill as its own layer under the outline, grouped.
|
||||
doc.insert(v, parent, idx.map(|x| x + 1))?;
|
||||
let fill_id = i;
|
||||
@@ -634,12 +707,53 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get_mut(i)?;
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
let fill = network.fill_path();
|
||||
let src = if fill.elements().is_empty() { network.stroke_path() } else { fill };
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default());
|
||||
let regions = !network.regions.is_empty();
|
||||
let mut net = VectorNetwork::from_path(&simplified);
|
||||
if !regions {
|
||||
let source = network.clone();
|
||||
let mut net = VectorNetwork::default();
|
||||
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
|
||||
if legacy && !source.regions.is_empty() {
|
||||
let winding = if source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
|
||||
WindingRule::EvenOdd
|
||||
} else {
|
||||
WindingRule::NonZero
|
||||
};
|
||||
let path = source.fill_path();
|
||||
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
|
||||
Ok(path) => (path, WindingRule::EvenOdd),
|
||||
Err(_) => (path, winding),
|
||||
};
|
||||
let combined = Region { winding: output_winding, ..Default::default() };
|
||||
append_region_path(&mut net, &path, &combined, output_winding);
|
||||
} else {
|
||||
for (index, region) in source.regions.iter().enumerate() {
|
||||
let Some(path) = source.region_path(index) else { continue };
|
||||
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
|
||||
append_region_path(&mut net, &path, region, region.winding);
|
||||
}
|
||||
}
|
||||
if !source.regions.is_empty() {
|
||||
let mut used = vec![false; source.segments.len()];
|
||||
for segment in source.regions.iter().flat_map(|region| region.loops.iter().flatten()) {
|
||||
if let Some(used) = used.get_mut(*segment) {
|
||||
*used = true;
|
||||
}
|
||||
}
|
||||
let mut open = source.clone();
|
||||
open.segments = source.segments.iter().enumerate().filter_map(|(index, segment)| (!used[index]).then_some(*segment)).collect();
|
||||
open.regions.clear();
|
||||
if !open.segments.is_empty() {
|
||||
let path = open.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let mut loose = VectorNetwork::from_path(&simplified);
|
||||
loose.regions.clear();
|
||||
append_network(&mut net, loose);
|
||||
}
|
||||
}
|
||||
if source.regions.is_empty() {
|
||||
let path = source.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
net = VectorNetwork::from_path(&simplified);
|
||||
net.regions.clear();
|
||||
}
|
||||
*network = net;
|
||||
@@ -657,22 +771,99 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !n.children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let res = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
|
||||
};
|
||||
let Ok(mut res) = res else { continue };
|
||||
let bb = res.bounding_box();
|
||||
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let nm = doc.get_mut(i)?;
|
||||
let mut net = VectorNetwork::from_path(&res);
|
||||
for r in &mut net.regions {
|
||||
r.winding = WindingRule::EvenOdd;
|
||||
if d == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
|
||||
let NodeKind::Vector { network: source } = &n.kind else {
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let result = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let mut result = result;
|
||||
let bounds = result.bounding_box();
|
||||
result.apply_affine(Affine::translate((-bounds.x0, -bounds.y0)));
|
||||
let node = doc.get_mut(i)?;
|
||||
node.kind = NodeKind::Vector { network: VectorNetwork::from_path(&result) };
|
||||
node.transform *= Affine::translate((bounds.x0, bounds.y0));
|
||||
node.width = bounds.width();
|
||||
node.height = bounds.height();
|
||||
continue;
|
||||
};
|
||||
let mut net = VectorNetwork::default();
|
||||
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
|
||||
if source.regions.is_empty() {
|
||||
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let mut res = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let bb = res.bounding_box();
|
||||
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: VectorNetwork::from_path(&res) };
|
||||
nm.transform *= Affine::translate((bb.x0, bb.y0));
|
||||
nm.width = bb.width();
|
||||
nm.height = bb.height();
|
||||
continue;
|
||||
}
|
||||
if legacy {
|
||||
let path = source.fill_path();
|
||||
let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let result = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let mut part = VectorNetwork::from_path(&result);
|
||||
let (paint_width, paint_height, paint_transform) = source.regions[0].paint_frame(n.width, n.height);
|
||||
for region in &mut part.regions {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
region.fills = source.regions[0].fills.clone();
|
||||
region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
}
|
||||
append_network(&mut net, part);
|
||||
} else {
|
||||
for (index, region) in source.regions.iter().enumerate() {
|
||||
let Some(path) = source.region_path(index) else { continue };
|
||||
let mut paint_region = region.clone();
|
||||
let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height);
|
||||
paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd)
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
let res = if d >= 0.0 {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
|
||||
} else {
|
||||
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
|
||||
}
|
||||
.map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
// A collapsed negative offset removes this region, while other source regions stay.
|
||||
if res.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
append_region_path(&mut net, &res, &paint_region, WindingRule::EvenOdd);
|
||||
}
|
||||
}
|
||||
let Some(bb) = net.bounds() else {
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: net };
|
||||
nm.width = 0.0;
|
||||
nm.height = 0.0;
|
||||
continue;
|
||||
};
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let nm = doc.get_mut(i)?;
|
||||
nm.kind = NodeKind::Vector { network: net };
|
||||
nm.transform *= Affine::translate((bb.x0, bb.y0));
|
||||
nm.width = bb.width();
|
||||
@@ -681,6 +872,25 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn append_region_path(net: &mut VectorNetwork, path: &BezPath, source: &Region, winding: WindingRule) {
|
||||
let mut part = VectorNetwork::from_path(path);
|
||||
for region in &mut part.regions {
|
||||
region.winding = winding;
|
||||
region.fills = source.fills.clone();
|
||||
region.paint_space = source.paint_space;
|
||||
}
|
||||
append_network(net, part);
|
||||
}
|
||||
|
||||
fn append_network(net: &mut VectorNetwork, part: VectorNetwork) {
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
net.vertices.extend(part.vertices);
|
||||
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
|
||||
net.regions.extend(
|
||||
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Join open vectors whose end points touch into one vector.
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.join", s, p)?;
|
||||
@@ -695,7 +905,7 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let last = network.vertices.len() - 1;
|
||||
network.segments.push(Segment::line(last, 0));
|
||||
let all: Vec<usize> = (0..network.segments.len()).collect();
|
||||
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero });
|
||||
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero, fills: None, paint_space: None });
|
||||
}
|
||||
}
|
||||
return ok();
|
||||
|
||||
@@ -164,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.create",
|
||||
"Create variable",
|
||||
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}",
|
||||
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?}",
|
||||
true,
|
||||
var_create
|
||||
),
|
||||
|
||||
@@ -279,13 +279,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.ok_or_else(|| bad("text.decoration", "`decoration`: none|underline|strikethrough"))?;
|
||||
style(s, p, |st| st.decoration = d)
|
||||
}),
|
||||
spec!("text.listStyle", "List style", "{style: none|bulleted|numbered, ids?} while editing: the selected paragraphs", true, |s, p| {
|
||||
let l: ListStyle = get(p, "style")
|
||||
.or_else(|| get(p, "list"))
|
||||
.and_then(super::props::enum_of)
|
||||
.ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
|
||||
set_list(s, p, l, false)
|
||||
}),
|
||||
spec!(
|
||||
"text.listStyle",
|
||||
"List style",
|
||||
"{style | list: none|bulleted|numbered, ids?} while editing: the selected paragraphs",
|
||||
true,
|
||||
|s, p| {
|
||||
let l: ListStyle = get(p, "style")
|
||||
.or_else(|| get(p, "list"))
|
||||
.and_then(super::props::enum_of)
|
||||
.ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
|
||||
set_list(s, p, l, false)
|
||||
}
|
||||
),
|
||||
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
|
||||
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
|
||||
para(s, p, |t| t.paragraph_indent = v)
|
||||
@@ -374,7 +380,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("text.endPreview", "End text preview", "{} puts the document back as it was before text.preview → {ended}", false, |s, _| {
|
||||
Ok(json!({"ended": crate::textpreview::end(s)}))
|
||||
}),
|
||||
spec!("text.set", "Set text", "{id?, characters}", true, |s, p| {
|
||||
spec!("text.set", "Set text", "{id?, characters | text}", true, |s, p| {
|
||||
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
|
||||
let ids = req_targets("text.set", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
@@ -390,7 +396,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"text.style",
|
||||
"Style text range",
|
||||
"{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textCase?, hyperlink?, textFills?}",
|
||||
"{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontVariations? | variationSettings?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textDecorationStyle?, textDecorationThickness?, textDecorationOffset?, textDecorationSkipInk?, textDecorationColor?, textCase?, hyperlink?, textFills?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let ids = req_targets("text.style", s, p)?;
|
||||
|
||||
@@ -373,7 +373,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"pref.uiScale",
|
||||
"Interface size",
|
||||
"{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
|
||||
"{scale? (a size from 0.75 to 3 relative to the system's display scale: 1 is the system's own size, so 1.25 is 125 %, not 125; outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
|
||||
false,
|
||||
|s, p| {
|
||||
match get(p, "scale") {
|
||||
|
||||
@@ -106,6 +106,17 @@ fn components_take_their_properties_as_parameters() {
|
||||
] {
|
||||
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
|
||||
}
|
||||
let dart = code(&mut s, c, "flutter");
|
||||
for want in [
|
||||
"import 'package:flutter/material.dart';\n\nclass Card extends StatelessWidget {\n",
|
||||
" const Card({\n super.key,\n this.showDot = true,\n this.title = \"Hello\",\n });\n",
|
||||
" final bool showDot;\n final String title;\n",
|
||||
" Widget build(BuildContext context) {\n return Container(",
|
||||
"if (showDot)\n",
|
||||
"Text(\n title,",
|
||||
] {
|
||||
assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
|
||||
}
|
||||
// The command describes the properties.
|
||||
let v = component_code(&mut s, c, "react");
|
||||
assert_eq!(v["name"], "Card");
|
||||
@@ -140,6 +151,7 @@ fn instances_are_calls_passing_the_values_they_set() {
|
||||
assert!(react.contains("export function Card({"));
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Buy\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title=\"Buy\" />\n");
|
||||
// Text that needs escaping is an expression in JSX and escaped per language.
|
||||
@@ -147,6 +159,7 @@ fn instances_are_calls_passing_the_values_they_set() {
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" \\$5\\nnow\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
|
||||
// A quoted title becomes a binding whose attribute value is escaped: the expression itself
|
||||
@@ -175,6 +188,8 @@ fn nested_instances_in_a_layer_tree_are_calls() {
|
||||
assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}");
|
||||
assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")"));
|
||||
assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")"));
|
||||
let dart = code(&mut s, screen, "flutter");
|
||||
assert!(dart.contains("Card(title: \"Inbox\")") && !dart.contains("Hello"), "{dart}");
|
||||
let leptos = code(&mut s, screen, "leptos");
|
||||
assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains("<Card title=\"Inbox\" />"), "{leptos}");
|
||||
assert!(!leptos.contains("Hello"), "{leptos}");
|
||||
@@ -232,6 +247,19 @@ fn variant_sets_share_one_definition() {
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flutter_variant_sets_share_one_enum() {
|
||||
let mut s = Session::new();
|
||||
let (set, _, _) = button_set(&mut s);
|
||||
let dart = code(&mut s, set, "flutter");
|
||||
assert!(dart.contains("enum ButtonState { defaultValue, hover }\n"), "{dart}");
|
||||
assert!(dart.contains(" this.state = ButtonState.defaultValue,\n"), "{dart}");
|
||||
assert!(dart.contains(" final ButtonState state;\n"), "{dart}");
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
|
||||
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Button(state: ButtonState.hover)\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_swap_properties_take_views() {
|
||||
let mut s = Session::new();
|
||||
@@ -252,6 +280,10 @@ fn instance_swap_properties_take_views() {
|
||||
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
|
||||
let kt = code(&mut s, chip, "compose");
|
||||
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
|
||||
let dart = code(&mut s, chip, "flutter");
|
||||
assert!(dart.contains(" final Widget icon;\n") && dart.contains(" this.icon = const HeartIcon(),\n"), "{dart}");
|
||||
assert!(dart.contains("child: icon\n"), "the nested instance is the parameter: {dart}");
|
||||
assert!(!dart.contains("child: Container("), "the swap's widget tree isn't spelled out: {dart}");
|
||||
let ark = code(&mut s, chip, "arkui");
|
||||
for want in [
|
||||
" @BuilderParam icon: () => void = this.defaultIconBuilder",
|
||||
@@ -272,6 +304,7 @@ fn instance_swap_properties_take_views() {
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
|
||||
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Chip(icon: StarIcon())\n");
|
||||
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Chip :icon=\"StarIcon\" />\n");
|
||||
@@ -289,6 +322,9 @@ fn slots_take_optional_content() {
|
||||
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
|
||||
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
|
||||
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
|
||||
let dart = code(&mut s, panel, "flutter");
|
||||
assert!(dart.contains(" final List<Widget>? content;\n") && dart.contains(" this.content,\n"), "{dart}");
|
||||
assert!(dart.contains("...?content,\n") && dart.contains("if (content == null) ...[\n"), "{dart}");
|
||||
let ark = code(&mut s, panel, "arkui");
|
||||
for want in [
|
||||
" @BuilderParam content: () => void = this.defaultContentBuilder",
|
||||
@@ -305,11 +341,14 @@ fn slots_take_optional_content() {
|
||||
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 400, "y": 0})));
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Panel />\n", "the main's content isn't passed");
|
||||
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Panel()\n", "the main's content isn't passed");
|
||||
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true}));
|
||||
let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}");
|
||||
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("<Panel content={|| view! {\n") && usage.contains("<Badge />") && usage.ends_with("}} />\n"), "{usage}");
|
||||
let usage = component_code(&mut s, i, "flutter")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("Panel(content: [\n") && usage.contains("Badge()"), "{usage}");
|
||||
let usage = component_code(&mut s, i, "arkui")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("Panel({ content: () => {\n") && usage.contains("Badge()") && usage.ends_with("} })\n"), "{usage}");
|
||||
}
|
||||
@@ -359,10 +398,22 @@ fn identifiers_are_escaped_and_unique() {
|
||||
] {
|
||||
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
|
||||
}
|
||||
// Dart: lowerCamel members, keywords suffixed, never a type the widget tree names.
|
||||
let dart = code_for(doc, c, CodeLang::Flutter);
|
||||
for want in [
|
||||
"class TextComponent extends StatelessWidget {",
|
||||
"enum TextComponentColorVariant { red }\n",
|
||||
" this.defaultValue = false,",
|
||||
" this.p2Columns = false,",
|
||||
" final String type;",
|
||||
] {
|
||||
assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
|
||||
}
|
||||
// Errors.
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
|
||||
assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "html"})).is_err());
|
||||
assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "flutter"})).is_ok());
|
||||
assert!(s.execute("inspect.componentCode", &json!({"id": 999_999})).is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"annotation.add",
|
||||
"Add annotation",
|
||||
"{id? (default the first selected layer), label? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects], category? (id or name)} → {id, index} (a note, pinned properties or both)",
|
||||
"{id? (default the first selected layer), label? | text? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects], category? (id or name)} → {id, index} (a note, pinned properties or both)",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "annotation.add";
|
||||
@@ -270,7 +270,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"annotation.edit",
|
||||
"Edit annotation",
|
||||
"{id?, index, label?, properties?: [..] (replaces the pinned properties), category? (id or name; null: none)}",
|
||||
"{id?, index, label? | text?, properties?: [..] (replaces the pinned properties), category? (id or name; null: none)}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "annotation.edit";
|
||||
|
||||
@@ -37,8 +37,9 @@ const MAX_WARNINGS: usize = 50;
|
||||
|
||||
/// The languages a component can be connected in.
|
||||
pub fn language(s: &str) -> Option<CodeLang> {
|
||||
CodeLang::from_id(s)
|
||||
.filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))
|
||||
CodeLang::from_id(s).filter(|l| {
|
||||
matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi | CodeLang::Flutter)
|
||||
})
|
||||
}
|
||||
|
||||
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
|
||||
@@ -69,6 +70,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
|
||||
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
|
||||
// A single-file component is a default export.
|
||||
CodeLang::Vue => format!("import {} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
|
||||
CodeLang::Flutter => format!("import '{}';", path.replace(['\'', '\\'], "")),
|
||||
_ => format!("import {path}"),
|
||||
}
|
||||
}
|
||||
@@ -273,6 +275,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
|
||||
(Some(l), CodeLang::Vue) => l.name.clone(),
|
||||
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
|
||||
(Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name),
|
||||
(Some(l), CodeLang::Flutter) => format!("{}()", l.name),
|
||||
(Some(l), _) => format!("{{ {}() }}", l.name),
|
||||
(None, _) => value_expr(doc, c, p, v, lang),
|
||||
}
|
||||
@@ -456,7 +459,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
|
||||
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::<String>())
|
||||
}
|
||||
CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{p}}}")).collect::<String>()),
|
||||
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
CodeLang::SwiftUi | CodeLang::Flutter => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
}
|
||||
@@ -478,6 +481,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
|
||||
CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
|
||||
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
|
||||
CodeLang::Vue => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./@-".contains(ch)),
|
||||
CodeLang::Flutter => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./:-".contains(ch)),
|
||||
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
|
||||
};
|
||||
if path.chars().count() > MAX_IMPORT || !path_ok {
|
||||
@@ -486,6 +490,9 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
|
||||
CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
|
||||
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
|
||||
CodeLang::Vue => format!("importPath: a module path (letters, digits, _ . / @ and -), up to {MAX_IMPORT} characters"),
|
||||
CodeLang::Flutter => {
|
||||
format!("importPath: a Dart import (package:acme/card.dart; letters, digits, _ . / : and -), up to {MAX_IMPORT} characters")
|
||||
}
|
||||
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
|
||||
});
|
||||
}
|
||||
@@ -521,7 +528,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
|
||||
/// The `language` parameter (default React).
|
||||
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
|
||||
match text(cmd, p, "language")? {
|
||||
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui")),
|
||||
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui|flutter")),
|
||||
None => Ok(CodeLang::React),
|
||||
}
|
||||
}
|
||||
@@ -551,7 +558,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"codeConnect.set",
|
||||
"Connect to code",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui|flutter (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "codeConnect.set";
|
||||
@@ -666,7 +673,10 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (Some(component), Some(lang), Some(name), Some(template)) =
|
||||
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
|
||||
else {
|
||||
warnings.push(format!("link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui), name and template strings", i + 1));
|
||||
warnings.push(format!(
|
||||
"link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui|flutter), name and template strings",
|
||||
i + 1
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let link = CodeLink {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the
|
||||
//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS;
|
||||
//! stacks for auto layout, absolute offsets otherwise). Components become components whose
|
||||
//! properties are parameters and instances become calls passing their values (see
|
||||
//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose, ArkUI (HarmonyOS) or
|
||||
//! Flutter (stacks for auto layout, absolute offsets otherwise). Components become components
|
||||
//! whose properties are parameters and instances become calls passing their values (see
|
||||
//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The
|
||||
//! output is a starting point, not a pixel-exact translation.
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::{bad, spec};
|
||||
mod arkui;
|
||||
mod compose;
|
||||
mod css;
|
||||
mod flutter;
|
||||
mod html;
|
||||
mod swift;
|
||||
|
||||
@@ -30,6 +31,7 @@ pub(crate) use css::Tokens;
|
||||
pub(crate) use css::css_decls;
|
||||
use css::{css, tok};
|
||||
pub use css::{css_var_name, css_var_of};
|
||||
pub(crate) use flutter::{FLUTTER_IMPORT, Place, flutter, flutter_indent};
|
||||
use html::html;
|
||||
pub(crate) use html::{Markup, class_name};
|
||||
pub(crate) use swift::swift;
|
||||
@@ -38,13 +40,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"inspect.code",
|
||||
"Code for layer",
|
||||
"{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
"{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui|flutter (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.code";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
|
||||
let lang = match string(p, "language") {
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui"))?,
|
||||
Some(l) => {
|
||||
CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui|flutter"))?
|
||||
}
|
||||
None => CodeLang::Css,
|
||||
};
|
||||
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
|
||||
@@ -96,10 +100,12 @@ pub enum CodeLang {
|
||||
Compose,
|
||||
/// HarmonyOS ArkUI: an ArkTS struct (`@Component`, `build`).
|
||||
ArkUi,
|
||||
/// A Flutter `StatelessWidget` (Dart) with its widget tree.
|
||||
Flutter,
|
||||
}
|
||||
|
||||
impl CodeLang {
|
||||
pub const ALL: [CodeLang; 9] = [
|
||||
pub const ALL: [CodeLang; 10] = [
|
||||
CodeLang::Css,
|
||||
CodeLang::Html,
|
||||
CodeLang::React,
|
||||
@@ -109,6 +115,7 @@ impl CodeLang {
|
||||
CodeLang::SwiftUi,
|
||||
CodeLang::Compose,
|
||||
CodeLang::ArkUi,
|
||||
CodeLang::Flutter,
|
||||
];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
@@ -121,6 +128,7 @@ impl CodeLang {
|
||||
CodeLang::SwiftUi => "swiftui",
|
||||
CodeLang::Compose => "compose",
|
||||
CodeLang::ArkUi => "arkui",
|
||||
CodeLang::Flutter => "flutter",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -134,6 +142,7 @@ impl CodeLang {
|
||||
CodeLang::SwiftUi => "SwiftUI",
|
||||
CodeLang::Compose => "Compose",
|
||||
CodeLang::ArkUi => "ArkUI",
|
||||
CodeLang::Flutter => "Flutter",
|
||||
}
|
||||
}
|
||||
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
|
||||
@@ -141,6 +150,11 @@ impl CodeLang {
|
||||
pub fn is_web(self) -> bool {
|
||||
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Vue)
|
||||
}
|
||||
/// Languages whose strings interpolate `$` (Kotlin's `$name`, Dart's `$name`), where a literal
|
||||
/// `$` has to be escaped.
|
||||
pub fn escapes_dollar(self) -> bool {
|
||||
matches!(self, CodeLang::Compose | CodeLang::Flutter)
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<CodeLang> {
|
||||
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
|
||||
return Some(CodeLang::React);
|
||||
@@ -151,6 +165,9 @@ impl CodeLang {
|
||||
if s.eq_ignore_ascii_case("arkts") {
|
||||
return Some(CodeLang::ArkUi);
|
||||
}
|
||||
if s.eq_ignore_ascii_case("dart") {
|
||||
return Some(CodeLang::Flutter);
|
||||
}
|
||||
CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
@@ -262,7 +279,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
CodeLang::Css => css(doc, id),
|
||||
CodeLang::Html => html(doc, id),
|
||||
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => {
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi | CodeLang::Flutter => {
|
||||
crate::component_codegen::code(doc, id, lang)
|
||||
}
|
||||
}
|
||||
@@ -549,6 +566,111 @@ mod tests {
|
||||
assert!(ark.contains(&format!(".position({{ x: {}, y: {} }})", num(x), num(y))), "the absolute child keeps its place: {ark}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flutter_builds_a_widget_tree() {
|
||||
let mut s = Session::new();
|
||||
let f = card(&mut s);
|
||||
let doc = s.doc().unwrap();
|
||||
let dart = code_for(doc, f, CodeLang::Flutter);
|
||||
// The card hugs its content, so its width is the layout's, not the 200 it was created with.
|
||||
let w = doc.node(f).map(|n| n.width).unwrap_or_default();
|
||||
assert!(dart.starts_with(&format!("Container(\n width: {},\n height: 100,\n", num(w))), "{dart}");
|
||||
assert!(dart.contains("borderRadius: BorderRadius.circular(12)"), "{dart}");
|
||||
assert!(dart.contains("child: Row(\n"), "{dart}");
|
||||
assert!(dart.contains(" Text(\n \"Say \\\"hi\\\" \\$5\\nnow\",\n"), "escaped: {dart}");
|
||||
assert!(dart.contains("fontWeight: FontWeight.w400,"), "{dart}");
|
||||
assert!(dart.contains("shape: BoxShape.circle"), "{dart}");
|
||||
assert!(dart.ends_with(')'), "a bare expression, like the other native targets: {dart}");
|
||||
// Through the command: `dart` names the same language, and rem is for the web only.
|
||||
let v = s.execute("inspect.code", &json!({"id": f, "language": "flutter"})).unwrap();
|
||||
assert_eq!(v["code"].as_str().unwrap(), dart);
|
||||
assert_eq!(v["language"], "flutter");
|
||||
assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "dart"})).unwrap()["language"], "flutter");
|
||||
let rem = s.execute("inspect.code", &json!({"id": f, "language": "flutter", "unit": "rem"})).unwrap();
|
||||
assert_eq!(rem["code"].as_str().unwrap(), dart, "rem doesn't apply to Dart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flutter_positions_free_children_and_bounds_the_tree() {
|
||||
let mut s = Session::new();
|
||||
let root = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 5, "height": 5, "parent": root})).unwrap();
|
||||
let mut parent = root;
|
||||
for _ in 0..20 {
|
||||
parent = id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent})).unwrap()["id"]).unwrap();
|
||||
}
|
||||
let dart = code_for(s.doc().unwrap(), root, CodeLang::Flutter);
|
||||
assert!(dart.contains("Stack(\n alignment: Alignment.topLeft,\n"), "{dart}");
|
||||
assert!(dart.contains("Positioned(\n left: 7,\n top: 9,\n"), "{dart}");
|
||||
// A wrapper's closing parenthesis sits on its own line, not glued to the widget's.
|
||||
assert!(dart.contains(" )\n ),\n"), "{dart}");
|
||||
assert!(dart.contains("…"), "the depth is capped: {dart}");
|
||||
assert!(dart.contains("const SizedBox()"), "a capped layer is still a widget in the list: {dart}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flutter_keeps_a_capped_layers_name_on_its_comment_line() {
|
||||
let mut s = Session::new();
|
||||
let root = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
|
||||
let mut parent = root;
|
||||
for _ in 0..20 {
|
||||
let p = json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": "deep\nvoid main() {}"});
|
||||
parent = id_of(&s.execute("frame.create", &p).unwrap()["id"]).unwrap();
|
||||
}
|
||||
let dart = code_for(s.doc().unwrap(), root, CodeLang::Flutter);
|
||||
assert!(dart.contains("…"), "the depth is capped: {dart}");
|
||||
assert!(!dart.lines().any(|l| l.trim_start().starts_with("void main()")), "a name never leaves its comment: {dart}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flutter_expands_and_stretches_fill_children() {
|
||||
let mut s = Session::new();
|
||||
let row = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 40})).unwrap()["id"]).unwrap();
|
||||
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap()["id"]).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap();
|
||||
s.execute("layout.add", &json!({"ids": [row], "direction": "horizontal"})).unwrap();
|
||||
s.execute("node.set", &json!({"id": a, "props": {"sizingH": "fill", "sizingV": "fill"}})).unwrap();
|
||||
let dart = code_for(s.doc().unwrap(), row, CodeLang::Flutter);
|
||||
assert!(dart.contains("child: Row(\n"), "{dart}");
|
||||
assert!(dart.contains("Expanded(\n"), "a child filling the main axis takes the room left: {dart}");
|
||||
assert!(dart.contains("crossAxisAlignment: CrossAxisAlignment.stretch,"), "a child filling the cross axis stretches the line: {dart}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flutter_lifts_a_child_that_ignores_its_auto_layout_parent_out_of_the_line() {
|
||||
let mut s = Session::new();
|
||||
let row = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 40})).unwrap()["id"]).unwrap();
|
||||
let flow = id_of(&s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 50, "height": 20, "parent": row})).unwrap()["id"]).unwrap();
|
||||
let over = id_of(&s.execute("shape.ellipse", &json!({"x": 150, "y": 6, "width": 30, "height": 30, "parent": row})).unwrap()["id"]).unwrap();
|
||||
s.execute("layout.add", &json!({"ids": [row], "direction": "horizontal"})).unwrap();
|
||||
s.execute("node.set", &json!({"id": row, "props": {"padding": [4, 4, 4, 4]}})).unwrap();
|
||||
// It asks for both axes, which the line would read as "stretch me and take the room left".
|
||||
s.execute("node.set", &json!({"id": over, "props": {"absolute": true, "sizingH": "fill", "sizingV": "fill"}})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let dart = code_for(doc, row, CodeLang::Flutter);
|
||||
let at = |needle: &str| dart.find(needle).unwrap_or_else(|| panic!("no {needle:?}: {dart}"));
|
||||
// Over the line, in a Stack at the parent's own box, at its own offsets.
|
||||
let [_, _, _, _, x, y] = doc.node(over).unwrap().transform.as_coeffs();
|
||||
assert!(
|
||||
dart.contains(&format!("Positioned(\n left: {},\n top: {},\n", num(x), num(y))),
|
||||
"the absolute child keeps its place: {dart}"
|
||||
);
|
||||
let stack = at("child: Stack(\n");
|
||||
let fill = at("Positioned.fill(\n");
|
||||
let padding = at("padding: EdgeInsets.all(4),");
|
||||
let line = at("child: Row(\n");
|
||||
assert!(stack < fill && fill < padding && padding < line, "the line sits in the Stack, padded: {dart}");
|
||||
assert!(!dart[..padding].contains("padding: "), "the parent's padding would offset the absolute child: {dart}");
|
||||
// Only the flow child is in the line, so neither `Expanded` nor a stretch comes from it.
|
||||
let over_at = at("\n Positioned(\n");
|
||||
let line_part = &dart[line..over_at];
|
||||
assert!(line_part.contains("width: 50,"), "the flow child is in the line: {dart}");
|
||||
assert!(!line_part.contains("width: 30,"), "the absolute child left the line: {dart}");
|
||||
assert!(!dart.contains("Expanded(\n"), "it takes no room in the line either: {dart}");
|
||||
assert!(dart.contains("crossAxisAlignment: CrossAxisAlignment.start,"), "{dart}");
|
||||
assert!(doc.node(flow).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arkui_wraps_a_positioned_instance_swap_so_the_position_lands_on_a_container() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
use figcraft_doc::AutoLayout;
|
||||
|
||||
use super::swift::one_line;
|
||||
use super::{
|
||||
CodeLang, CrossAlign, Ctx, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Param, Sizing, TextAlignH, TextDecoration,
|
||||
drop_shadow, first_fill, kotlin_color, literal, num, radius, stroke_color,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Flutter (Dart)
|
||||
|
||||
/// The import a generated Flutter unit starts with (a widget tree on its own needs it too).
|
||||
pub(crate) const FLUTTER_IMPORT: &str = "import 'package:flutter/material.dart';\n\n";
|
||||
|
||||
/// Dart indents by two spaces.
|
||||
pub(crate) fn flutter_indent(depth: usize) -> String {
|
||||
" ".repeat(depth)
|
||||
}
|
||||
|
||||
/// Where a child sits in its parent: in a Stack at its position in the frame, taking the room left
|
||||
/// in a Row or Column, or wherever the parent puts it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Place {
|
||||
Flow,
|
||||
At(f64, f64),
|
||||
Fill,
|
||||
}
|
||||
|
||||
/// A child and where its parent puts it.
|
||||
type Placed = (NodeId, Place);
|
||||
|
||||
/// Dart's `FontWeight.w600` (a weight is hundreds).
|
||||
fn flutter_weight(w: u16) -> String {
|
||||
format!("FontWeight.w{}", (w.clamp(100, 900) / 100) * 100)
|
||||
}
|
||||
|
||||
/// A Row or Column's main-axis alignment (`None` for the default, the start).
|
||||
fn flutter_main_align(a: MainAlign) -> Option<&'static str> {
|
||||
match a {
|
||||
MainAlign::Min => None,
|
||||
MainAlign::Center => Some("MainAxisAlignment.center"),
|
||||
MainAlign::Max => Some("MainAxisAlignment.end"),
|
||||
MainAlign::SpaceBetween => Some("MainAxisAlignment.spaceBetween"),
|
||||
MainAlign::SpaceAround => Some("MainAxisAlignment.spaceAround"),
|
||||
MainAlign::SpaceEvenly => Some("MainAxisAlignment.spaceEvenly"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A Row or Column's cross-axis alignment; a flow child that fills it stretches the line instead.
|
||||
fn flutter_cross_align(a: CrossAlign, stretch: bool) -> &'static str {
|
||||
if stretch {
|
||||
return "CrossAxisAlignment.stretch";
|
||||
}
|
||||
match a {
|
||||
CrossAlign::Min => "CrossAxisAlignment.start",
|
||||
CrossAlign::Center => "CrossAxisAlignment.center",
|
||||
CrossAlign::Max => "CrossAxisAlignment.end",
|
||||
CrossAlign::Baseline => "CrossAxisAlignment.baseline",
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame's auto layout padding as `EdgeInsets` (`None` when it has none).
|
||||
fn flutter_padding(n: &Node) -> Option<String> {
|
||||
let l = n.layout.as_ref()?;
|
||||
let [t, r, b, left] = l.padding;
|
||||
if ![t, r, b, left].iter().any(|v| *v > 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(if t == r && r == b && b == left {
|
||||
format!("EdgeInsets.all({})", num(t))
|
||||
} else if t == b && left == r {
|
||||
format!("EdgeInsets.symmetric(vertical: {}, horizontal: {})", num(t), num(left))
|
||||
} else {
|
||||
format!("EdgeInsets.fromLTRB({}, {}, {}, {})", num(left), num(t), num(r), num(b))
|
||||
})
|
||||
}
|
||||
|
||||
/// A layer's fill, shape, corner radius, stroke and shadow as a `BoxDecoration`; `None` when it has
|
||||
/// none of them. `pad` is the indentation of the constructor itself.
|
||||
fn box_decoration(n: &Node, pad: &str) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(c) = first_fill(n) {
|
||||
parts.push(format!("color: {}", kotlin_color(c)));
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Ellipse { .. }) {
|
||||
parts.push("shape: BoxShape.circle".into());
|
||||
} else if radius(n) > 0.0 {
|
||||
parts.push(format!("borderRadius: BorderRadius.circular({})", num(radius(n))));
|
||||
}
|
||||
if let Some(c) = stroke_color(n) {
|
||||
parts.push(format!("border: Border.all(color: {}, width: {})", kotlin_color(c), num(n.stroke.weight)));
|
||||
}
|
||||
if let Some(s) = drop_shadow(n) {
|
||||
parts.push(format!(
|
||||
"boxShadow: [BoxShadow(color: {}, blurRadius: {}, offset: Offset({}, {}))]",
|
||||
kotlin_color(s.color),
|
||||
num(s.radius / 2.0),
|
||||
num(s.offset.0),
|
||||
num(s.offset.1)
|
||||
));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let inner = format!("{pad} ");
|
||||
Some(format!("BoxDecoration(\n{}{pad})", parts.iter().map(|p| format!("{inner}{p},\n")).collect::<String>()))
|
||||
}
|
||||
|
||||
/// Flutter for a layer and its subtree at `depth`. A layer a boolean parameter shows sits behind an
|
||||
/// `if (…)`: Dart's lists take conditions, so the element itself carries it.
|
||||
pub(crate) fn flutter(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, place: Place) {
|
||||
let Some(n) = ctx.doc.node(id) else { return };
|
||||
if id != ctx.root
|
||||
&& let Some(p) = ctx.param(n, "visible")
|
||||
{
|
||||
out.push_str(&format!("{}if ({})\n", flutter_indent(depth), p.ident));
|
||||
flutter_placed(ctx, n, depth + 1, out, place);
|
||||
return;
|
||||
}
|
||||
flutter_placed(ctx, n, depth, out, place);
|
||||
}
|
||||
|
||||
/// A layer at `depth` and what wraps it: where its parent puts it, then its opacity. Every closing
|
||||
/// parenthesis sits at its own level, so the caller can end the element with a comma in a list.
|
||||
fn flutter_placed(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, place: Place) {
|
||||
if ctx.too_deep(depth) {
|
||||
let pad = flutter_indent(depth);
|
||||
out.push_str(&format!("{pad}// {} …\n{pad}const SizedBox()", one_line(&n.name)));
|
||||
return;
|
||||
}
|
||||
let mut depth = depth;
|
||||
let mut close: Vec<String> = Vec::new();
|
||||
match place {
|
||||
Place::Flow => {}
|
||||
Place::At(x, y) => {
|
||||
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
|
||||
out.push_str(&format!("{pad}Positioned(\n{inner}left: {},\n{inner}top: {},\n{inner}child: ", num(x), num(y)));
|
||||
close.push(format!("\n{pad})"));
|
||||
depth += 1;
|
||||
}
|
||||
Place::Fill => {
|
||||
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
|
||||
out.push_str(&format!("{pad}Expanded(\n{inner}child: "));
|
||||
close.push(format!("\n{pad})"));
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
|
||||
out.push_str(&format!("{pad}Opacity(\n{inner}opacity: {},\n{inner}child: ", num(f64::from(n.opacity))));
|
||||
close.push(format!("\n{pad})"));
|
||||
depth += 1;
|
||||
}
|
||||
// A wrapper leaves its line open ("child: "), so the widget carries no indentation of its own.
|
||||
let mut body = String::new();
|
||||
flutter_widget(ctx, n, depth, &mut body);
|
||||
out.push_str(if close.is_empty() { body.as_str() } else { body.trim_start() });
|
||||
for c in close.iter().rev() {
|
||||
out.push_str(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer's own look and its subtree as a Flutter widget at `depth`: a complete expression whose
|
||||
/// closing parenthesis sits at `depth`.
|
||||
fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
|
||||
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
|
||||
// Nested instances are calls; an instance swap parameter is a widget.
|
||||
let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
|
||||
Some(p) => Some(p.ident.clone()),
|
||||
None => ctx.call(n, CodeLang::Flutter, depth).map(|(code, _)| code),
|
||||
};
|
||||
if let Some(call) = call {
|
||||
out.push_str(call.trim_end_matches('\n'));
|
||||
return;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone());
|
||||
out.push_str(&format!("{pad}Text(\n{inner}{text},\n"));
|
||||
match t.align_h {
|
||||
TextAlignH::Left => {}
|
||||
TextAlignH::Center => out.push_str(&format!("{inner}textAlign: TextAlign.center,\n")),
|
||||
TextAlignH::Right => out.push_str(&format!("{inner}textAlign: TextAlign.right,\n")),
|
||||
TextAlignH::Justified => out.push_str(&format!("{inner}textAlign: TextAlign.justify,\n")),
|
||||
}
|
||||
let s = &t.style;
|
||||
let mut style: Vec<String> = Vec::new();
|
||||
if !s.font_family.is_empty() {
|
||||
style.push(format!("fontFamily: {}", literal(&s.font_family, true)));
|
||||
}
|
||||
style.push(format!("fontSize: {}", num(s.font_size)));
|
||||
style.push(format!("fontWeight: {}", flutter_weight(s.weight())));
|
||||
if let Some(c) = first_fill(n) {
|
||||
style.push(format!("color: {}", kotlin_color(c)));
|
||||
}
|
||||
if s.italic() {
|
||||
style.push("fontStyle: FontStyle.italic".into());
|
||||
}
|
||||
match s.line_height {
|
||||
// Dart's height is a multiple of the font size, not a length.
|
||||
LineHeight::Pixels(v) if v > 0.0 && s.font_size > 0.0 => style.push(format!("height: {}", num(v / s.font_size))),
|
||||
LineHeight::Percent(p) if p > 0.0 => style.push(format!("height: {}", num(p / 100.0))),
|
||||
_ => {}
|
||||
}
|
||||
match s.letter_spacing {
|
||||
LetterSpacing::Pixels(v) if v != 0.0 => style.push(format!("letterSpacing: {}", num(v))),
|
||||
LetterSpacing::Percent(p) if p != 0.0 && s.font_size > 0.0 => style.push(format!("letterSpacing: {}", num(p / 100.0 * s.font_size))),
|
||||
_ => {}
|
||||
}
|
||||
match s.decoration {
|
||||
TextDecoration::None => {}
|
||||
TextDecoration::Underline => style.push("decoration: TextDecoration.underline".into()),
|
||||
TextDecoration::Strikethrough => style.push("decoration: TextDecoration.lineThrough".into()),
|
||||
}
|
||||
let sp = flutter_indent(depth + 2);
|
||||
out.push_str(&format!("{inner}style: TextStyle(\n{}{inner}),\n{pad})", style.iter().map(|x| format!("{sp}{x},\n")).collect::<String>()));
|
||||
}
|
||||
NodeKind::Ellipse { .. } | NodeKind::Rectangle => {
|
||||
out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
|
||||
if let Some(d) = box_decoration(n, &inner) {
|
||||
out.push_str(&format!("{inner}decoration: {d},\n"));
|
||||
}
|
||||
out.push_str(&format!("{pad})"));
|
||||
}
|
||||
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
|
||||
let children = ctx.kids(n);
|
||||
let slot = ctx.param(n, "slot");
|
||||
// The children block says whether it kept the padding for itself (an absolute child
|
||||
// puts the line in a Stack at the parent's own box).
|
||||
let mut kids = String::new();
|
||||
let padded_inside = !(children.is_empty() && slot.is_none()) && flutter_children(ctx, n, &children, slot, depth + 1, &mut kids);
|
||||
out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
|
||||
if !padded_inside && let Some(p) = flutter_padding(n) {
|
||||
out.push_str(&format!("{inner}padding: {p},\n"));
|
||||
}
|
||||
if let Some(d) = box_decoration(n, &inner) {
|
||||
out.push_str(&format!("{inner}decoration: {d},\n"));
|
||||
}
|
||||
if children.is_empty() && slot.is_none() {
|
||||
out.push_str(&format!("{pad})"));
|
||||
return;
|
||||
}
|
||||
out.push_str(&format!("{inner}alignment: Alignment.topLeft,\n{inner}child: "));
|
||||
out.push_str(kids.trim_start());
|
||||
out.push_str(&format!(",\n{pad})"));
|
||||
}
|
||||
_ => out.push_str(&format!(
|
||||
"{pad}// {}: export as an image asset\n{pad}Image.asset({}, width: {}, height: {})",
|
||||
n.kind.label(),
|
||||
literal(&format!("{}.png", n.name), true),
|
||||
num(n.width),
|
||||
num(n.height)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Row, Column or Stack holding a frame-like layer's children, at `depth`. Returns whether it
|
||||
/// took the parent's padding over (see [`flutter_widget`]).
|
||||
fn flutter_children(ctx: &Ctx, n: &Node, children: &[NodeId], slot: Option<&Param>, depth: usize, out: &mut String) -> bool {
|
||||
let doc = ctx.doc;
|
||||
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
|
||||
// Where each child sits, and whether one of the children in the line asks it to stretch.
|
||||
let mut placed: Vec<Placed> = Vec::new();
|
||||
let mut stretch = false;
|
||||
for c in children {
|
||||
let Some(cn) = doc.node(*c) else { continue };
|
||||
let place = match n.layout.as_ref() {
|
||||
// A child that ignores its auto layout parent keeps its own offsets: it leaves the
|
||||
// line, so it neither stretches it nor takes the room left in it.
|
||||
Some(_) if cn.child_layout.absolute => {
|
||||
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
|
||||
Place::At(x, y)
|
||||
}
|
||||
Some(l) => {
|
||||
let vertical = l.mode == LayoutMode::Vertical;
|
||||
let (main, cross) = if vertical {
|
||||
(cn.child_layout.sizing_v, cn.child_layout.sizing_h)
|
||||
} else {
|
||||
(cn.child_layout.sizing_h, cn.child_layout.sizing_v)
|
||||
};
|
||||
stretch |= cross == Sizing::Fill;
|
||||
if main == Sizing::Fill { Place::Fill } else { Place::Flow }
|
||||
}
|
||||
// Free children sit at their position in the frame.
|
||||
None => {
|
||||
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
|
||||
Place::At(x, y)
|
||||
}
|
||||
};
|
||||
placed.push((*c, place));
|
||||
}
|
||||
let Some(l) = n.layout.as_ref() else {
|
||||
out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n{inner}children: [\n"));
|
||||
flutter_items(ctx, &placed, slot, depth + 2, out);
|
||||
out.push_str(&format!("{inner}],\n{pad})"));
|
||||
return false;
|
||||
};
|
||||
let (over, flow): (Vec<Placed>, Vec<Placed>) = placed.into_iter().partition(|(_, p)| matches!(p, Place::At(..)));
|
||||
if over.is_empty() {
|
||||
out.push_str(&flutter_line(ctx, l, &flow, slot, stretch, depth));
|
||||
return false;
|
||||
}
|
||||
// A child at its own offsets goes over the line, in a Stack at the parent's own box, so its
|
||||
// offsets stay the parent's and the line keeps the parent's padding.
|
||||
let pad_edges = flutter_padding(n);
|
||||
let list = flutter_indent(depth + 2);
|
||||
let line_depth = depth + 3 + usize::from(pad_edges.is_some());
|
||||
out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n{inner}children: [\n"));
|
||||
if !flow.is_empty() || slot.is_some() {
|
||||
// Filling the Stack gives the line the parent's size, which `Expanded` children need.
|
||||
out.push_str(&format!("{list}Positioned.fill(\n{list} child: "));
|
||||
match pad_edges {
|
||||
Some(p) => {
|
||||
out.push_str(&format!("Padding(\n{list} padding: {p},\n{list} child: "));
|
||||
out.push_str(flutter_line(ctx, l, &flow, slot, stretch, line_depth).trim_start());
|
||||
out.push_str(&format!(",\n{list} )"));
|
||||
}
|
||||
None => out.push_str(flutter_line(ctx, l, &flow, slot, stretch, line_depth).trim_start()),
|
||||
}
|
||||
out.push_str(&format!(",\n{list}),\n"));
|
||||
}
|
||||
flutter_items(ctx, &over, None, depth + 2, out);
|
||||
out.push_str(&format!("{inner}],\n{pad})"));
|
||||
true
|
||||
}
|
||||
|
||||
/// The Row or Column holding a frame's flow children, at `depth`: a complete widget expression.
|
||||
fn flutter_line(ctx: &Ctx, l: &AutoLayout, flow: &[Placed], slot: Option<&Param>, stretch: bool, depth: usize) -> String {
|
||||
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
|
||||
let mut out = format!("{pad}{}(\n", if l.mode == LayoutMode::Vertical { "Column" } else { "Row" });
|
||||
if let Some(m) = flutter_main_align(l.main_align) {
|
||||
out.push_str(&format!("{inner}mainAxisAlignment: {m},\n"));
|
||||
}
|
||||
out.push_str(&format!("{inner}crossAxisAlignment: {},\n", flutter_cross_align(l.cross_align, stretch)));
|
||||
if !stretch && matches!(l.cross_align, CrossAlign::Baseline) {
|
||||
out.push_str(&format!("{inner}textBaseline: TextBaseline.alphabetic,\n"));
|
||||
}
|
||||
// The gap between the children; the space distributions already spread them.
|
||||
if l.gap > 0.0 && flutter_main_align(l.main_align).is_none_or(|m| !m.contains("Space")) {
|
||||
out.push_str(&format!("{inner}spacing: {},\n", num(l.gap)));
|
||||
}
|
||||
out.push_str(&format!("{inner}children: [\n"));
|
||||
flutter_items(ctx, flow, slot, depth + 2, &mut out);
|
||||
out.push_str(&format!("{inner}],\n{pad})"));
|
||||
out
|
||||
}
|
||||
|
||||
/// A children list (`[` already open) at `depth`; a slot shows the instance's content, or else the
|
||||
/// main's.
|
||||
fn flutter_items(ctx: &Ctx, kids: &[Placed], slot: Option<&Param>, depth: usize, out: &mut String) {
|
||||
let list = flutter_indent(depth);
|
||||
if let Some(p) = slot {
|
||||
let name = &p.ident;
|
||||
out.push_str(&format!("{list}...?{name},\n"));
|
||||
out.push_str(&format!("{list}if ({name} == null) ...[\n"));
|
||||
}
|
||||
let kid = depth + usize::from(slot.is_some());
|
||||
for (id, place) in kids {
|
||||
flutter(ctx, *id, kid, out, *place);
|
||||
out.push_str(",\n");
|
||||
}
|
||||
if slot.is_some() {
|
||||
out.push_str(&format!("{list}],\n"));
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
//! Component code: a component (or component set) becomes a React, Leptos, Vue, SwiftUI, Jetpack
|
||||
//! Compose or ArkUI component whose properties are its parameters (a variant property → a union or
|
||||
//! enum, boolean → a flag showing the layers bound to it, text → a string, instance swap → a view
|
||||
//! parameter, slot → optional content), and an instance becomes a call passing the values it sets.
|
||||
//! Variants of a set share one definition (the set's default variant); in React and Leptos their
|
||||
//! differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode` and
|
||||
//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its
|
||||
//! real snippet instead.
|
||||
//! Compose, ArkUI or Flutter component whose properties are its parameters (a variant property → a
|
||||
//! union or enum, boolean → a flag showing the layers bound to it, text → a string, instance swap →
|
||||
//! a view parameter, slot → optional content), and an instance becomes a call passing the values it
|
||||
//! sets. Variants of a set share one definition (the set's default variant); in React and Leptos
|
||||
//! their differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode`
|
||||
//! and used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows
|
||||
//! its real snippet instead.
|
||||
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, PropDef};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift};
|
||||
use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift};
|
||||
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
|
||||
use crate::params::{id_of, string, targets};
|
||||
use crate::{bad, spec};
|
||||
@@ -68,14 +68,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"inspect.componentCode",
|
||||
"Component code",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui|flutter (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.componentCode";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
|
||||
let lang = match string(p, "language") {
|
||||
// One list of code languages, shared with codeConnect (see `code_connect::language`).
|
||||
Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui"))?,
|
||||
Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui|flutter"))?,
|
||||
None => CodeLang::React,
|
||||
};
|
||||
let doc = s.doc()?;
|
||||
@@ -108,10 +108,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)]
|
||||
}
|
||||
|
||||
/// Code for any layer in React, Leptos, Vue, SwiftUI, Compose or ArkUI: an instance's call and its
|
||||
/// component's definition, a component's (or set's) definition, or the layer tree (React and
|
||||
/// Leptos wrap it in a function component), with the imports of the connected components it calls
|
||||
/// at the top.
|
||||
/// Code for any layer in React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter: an instance's call
|
||||
/// and its component's definition, a component's (or set's) definition, or the layer tree (React
|
||||
/// and Leptos wrap it in a function component), with the imports of the connected components it
|
||||
/// calls at the top.
|
||||
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
|
||||
@@ -119,7 +119,15 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
}
|
||||
let comp = main_of(doc, id).and_then(|m| describe(doc, m));
|
||||
let code = match (&n.kind, comp) {
|
||||
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
|
||||
(NodeKind::Instance { .. }, Some(c)) => {
|
||||
let call = usage(doc, id, lang, 0).unwrap_or_default();
|
||||
let def = definition(doc, &c, lang);
|
||||
match def.strip_prefix(FLUTTER_IMPORT) {
|
||||
// Dart's imports come before the code that uses them.
|
||||
Some(rest) if lang == CodeLang::Flutter => format!("{FLUTTER_IMPORT}{call}\n{rest}"),
|
||||
_ => format!("{call}\n{def}"),
|
||||
}
|
||||
}
|
||||
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
|
||||
_ => match lang {
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::Vue => {
|
||||
@@ -140,6 +148,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
arkui(&Ctx::new(doc, id), id, 0, &mut out, &[]);
|
||||
out
|
||||
}
|
||||
CodeLang::Flutter => {
|
||||
let mut out = String::new();
|
||||
flutter(&Ctx::new(doc, id), id, 0, &mut out, Place::Flow);
|
||||
out
|
||||
}
|
||||
_ => {
|
||||
let mut out = String::new();
|
||||
compose(&Ctx::new(doc, id), id, 0, &mut out);
|
||||
@@ -328,24 +341,33 @@ const RESERVED: &[&str] = &[
|
||||
"yield",
|
||||
];
|
||||
|
||||
/// Types the generated SwiftUI, Compose and ArkUI code names: a variant enum may not shadow them.
|
||||
/// Types the generated SwiftUI, Compose, ArkUI and Flutter code names: a variant enum may not shadow them.
|
||||
const TYPES: &[&str] = &[
|
||||
"Alignment",
|
||||
"AnyView",
|
||||
"Arrangement",
|
||||
"Bool",
|
||||
"Boolean",
|
||||
"Border",
|
||||
"BorderRadius",
|
||||
"BorderSide",
|
||||
"Box",
|
||||
"BoxDecoration",
|
||||
"BoxShadow",
|
||||
"BoxShape",
|
||||
"BuildContext",
|
||||
"CaseIterable",
|
||||
"Circle",
|
||||
"CircleShape",
|
||||
"Color",
|
||||
"Column",
|
||||
"Composable",
|
||||
"CrossAxisAlignment",
|
||||
"Edge",
|
||||
"EdgeInsets",
|
||||
"Ellipse",
|
||||
"EmptyView",
|
||||
"Expanded",
|
||||
"FlexAlign",
|
||||
"Font",
|
||||
"FontStyle",
|
||||
@@ -354,19 +376,28 @@ const TYPES: &[&str] = &[
|
||||
"HStack",
|
||||
"HorizontalAlign",
|
||||
"Image",
|
||||
"MainAxisAlignment",
|
||||
"Modifier",
|
||||
"Offset",
|
||||
"Opacity",
|
||||
"Positioned",
|
||||
"Rect",
|
||||
"RectangleShape",
|
||||
"RoundedCornerShape",
|
||||
"RoundedRectangle",
|
||||
"Row",
|
||||
"Shape",
|
||||
"SizedBox",
|
||||
"Stack",
|
||||
"StatelessWidget",
|
||||
"String",
|
||||
"Text",
|
||||
"TextAlign",
|
||||
"TextBaseline",
|
||||
"TextCase",
|
||||
"TextDecoration",
|
||||
"TextDecorationType",
|
||||
"TextStyle",
|
||||
"Unit",
|
||||
"VStack",
|
||||
"VerticalAlign",
|
||||
@@ -481,6 +512,13 @@ fn cases(values: &[String], swift: bool) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Enum member names for variant values in Dart: lowerCamel, unique, never a keyword (a `default`
|
||||
/// value becomes `defaultValue`).
|
||||
fn dart_cases(values: &[String]) -> Vec<String> {
|
||||
let mut used = Vec::new();
|
||||
values.iter().map(|v| ident(v, &mut used)).collect()
|
||||
}
|
||||
|
||||
/// A Rust string literal.
|
||||
pub(crate) fn rust_string(s: &str) -> String {
|
||||
format!("{:?}", s.chars().take(10_000).collect::<String>())
|
||||
@@ -533,7 +571,7 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
||||
let i = opts.iter().position(|o| Some(o.as_str()) == value.as_str()).unwrap_or(0);
|
||||
format!("{}::{}", leptos_enum(c, p), rust_cases(opts).get(i).cloned().unwrap_or_else(|| "Unknown".into()))
|
||||
}
|
||||
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
|
||||
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l.escapes_dollar()),
|
||||
(ParamKind::Variant(opts), l) => {
|
||||
let v = value.as_str().unwrap_or("");
|
||||
if l == CodeLang::Vue {
|
||||
@@ -542,10 +580,12 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
||||
if l == CodeLang::React {
|
||||
return js_string(v);
|
||||
}
|
||||
let swift = l == CodeLang::SwiftUi;
|
||||
let i = opts.iter().position(|o| o == v).unwrap_or(0);
|
||||
let case = cases(opts, swift).get(i).cloned().unwrap_or_else(|| "unknown".into());
|
||||
if swift { format!(".{case}") } else { format!("{}.{case}", enum_name(c, p, false)) }
|
||||
match l {
|
||||
CodeLang::SwiftUi => format!(".{}", cases(opts, true).get(i).cloned().unwrap_or_else(|| "unknown".into())),
|
||||
CodeLang::Flutter => format!("{}.{}", enum_name(c, p, false), dart_cases(opts).get(i).cloned().unwrap_or_else(|| "unknown".into())),
|
||||
_ => format!("{}.{}", enum_name(c, p, false), cases(opts, false).get(i).cloned().unwrap_or_else(|| "Unknown".into())),
|
||||
}
|
||||
}
|
||||
(ParamKind::InstanceSwap, l) => {
|
||||
let t = id_of(value).and_then(|i| component_type(doc, i));
|
||||
@@ -556,12 +596,14 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
||||
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
|
||||
(None, CodeLang::Leptos) => "|| ()".into(),
|
||||
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
|
||||
(Some(t), CodeLang::Flutter) => format!("{t}()"),
|
||||
// ArkUI takes a builder: `@BuilderParam` is a `() => void`.
|
||||
(Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"),
|
||||
(None, CodeLang::ArkUi) => "() => {}".into(),
|
||||
(Some(t), _) => format!("{{ {t}() }}"),
|
||||
(None, _) if js => "null".into(),
|
||||
(None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(),
|
||||
(None, CodeLang::Flutter) => "const SizedBox.shrink()".into(),
|
||||
(None, _) => "{}".into(),
|
||||
}
|
||||
}
|
||||
@@ -616,7 +658,7 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
|
||||
pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option<(String, String)> {
|
||||
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
|
||||
let values = instance_values(doc, id, &c)?;
|
||||
let pad = if matches!(lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) };
|
||||
let pad = if matches!(lang, CodeLang::React | CodeLang::Vue | CodeLang::Flutter) { " ".repeat(depth) } else { indent(depth) };
|
||||
// A connected component: its real snippet (its import is at the top of the code: `code`).
|
||||
if let Some(link) = crate::code_connect::link(doc, id, lang) {
|
||||
return Some((crate::code_connect::render(doc, &c, &values, link, lang, &pad), String::new()));
|
||||
@@ -684,7 +726,9 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept
|
||||
.collect();
|
||||
if slots.is_empty() { format!("{pad}<{t}{attrs} />\n") } else { format!("{pad}<{t}{attrs}>\n{slots}{pad}</{t}>\n") }
|
||||
}
|
||||
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
|
||||
CodeLang::SwiftUi | CodeLang::Flutter => {
|
||||
format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", "))
|
||||
}
|
||||
CodeLang::ArkUi => {
|
||||
let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ");
|
||||
// ArkTS calls take an object literal, or nothing at all.
|
||||
@@ -743,6 +787,15 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize, pref
|
||||
}
|
||||
(format!("() => {{\n{out}{}}}", indent(depth + 1)), String::new())
|
||||
}
|
||||
CodeLang::Flutter => {
|
||||
// A slot holds a list of widgets, so it takes however many layers the slot has.
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
flutter(&ctx, k, depth + 1, &mut out, Place::Flow);
|
||||
out.push_str(",\n");
|
||||
}
|
||||
(format!("[\n{out}{}]", flutter_indent(depth)), String::new())
|
||||
}
|
||||
_ => {
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
@@ -760,6 +813,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
||||
match lang {
|
||||
CodeLang::SwiftUi => swift_definition(doc, c),
|
||||
CodeLang::Compose => compose_definition(doc, c),
|
||||
CodeLang::Flutter => flutter_definition(doc, c),
|
||||
CodeLang::Leptos => leptos(doc, c),
|
||||
CodeLang::Vue => vue(doc, c),
|
||||
CodeLang::ArkUi => arkui_definition(doc, c),
|
||||
@@ -994,6 +1048,66 @@ fn compose_definition(doc: &Document, c: &Component) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// A Flutter `StatelessWidget`: variant enums, a const constructor with defaults, and the widget
|
||||
/// tree in `build`.
|
||||
fn flutter_definition(doc: &Document, c: &Component) -> String {
|
||||
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
|
||||
let mut out = String::from(FLUTTER_IMPORT);
|
||||
for p in &c.params {
|
||||
if let ParamKind::Variant(opts) = &p.kind {
|
||||
out.push_str(&format!("enum {} {{ {} }}\n\n", enum_name(c, p, false), dart_cases(opts).join(", ")));
|
||||
}
|
||||
}
|
||||
let t = &c.type_name;
|
||||
out.push_str(&format!("class {t} extends StatelessWidget {{\n"));
|
||||
if c.params.is_empty() {
|
||||
out.push_str(&format!(" const {t}({{super.key}});\n"));
|
||||
} else {
|
||||
out.push_str(&format!(" const {t}({{\n super.key,\n"));
|
||||
for p in &c.params {
|
||||
match flutter_default(doc, c, p) {
|
||||
Some(d) => out.push_str(&format!(" this.{} = {d},\n", p.ident)),
|
||||
None => out.push_str(&format!(" this.{},\n", p.ident)),
|
||||
}
|
||||
}
|
||||
out.push_str(" });\n\n");
|
||||
for p in &c.params {
|
||||
out.push_str(&format!(" final {} {};\n", flutter_type(c, p), p.ident));
|
||||
}
|
||||
}
|
||||
out.push_str("\n @override\n Widget build(BuildContext context) {\n return ");
|
||||
let mut body = String::new();
|
||||
flutter(&ctx, c.comp, 2, &mut body, Place::Flow);
|
||||
out.push_str(body.trim_start());
|
||||
out.push_str(";\n }\n}\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// A parameter's Dart type.
|
||||
fn flutter_type(c: &Component, p: &Param) -> String {
|
||||
match &p.kind {
|
||||
ParamKind::Variant(_) => enum_name(c, p, false),
|
||||
ParamKind::Boolean => "bool".into(),
|
||||
ParamKind::Text => "String".into(),
|
||||
ParamKind::InstanceSwap => "Widget".into(),
|
||||
// A slot holds however many layers it was filled with.
|
||||
ParamKind::Slot => "List<Widget>?".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A parameter's default as a Dart constant (`None` for a slot, which has none): Dart wants its
|
||||
/// defaults to be constants, so an instance swap is a `const` widget.
|
||||
fn flutter_default(doc: &Document, c: &Component, p: &Param) -> Option<String> {
|
||||
match &p.kind {
|
||||
ParamKind::Slot => None,
|
||||
ParamKind::InstanceSwap => Some(match id_of(&p.default).and_then(|i| component_type(doc, i)) {
|
||||
Some(t) => format!("const {t}()"),
|
||||
None => "const SizedBox.shrink()".into(),
|
||||
}),
|
||||
_ => Some(value_expr(doc, c, p, &p.default, CodeLang::Flutter)),
|
||||
}
|
||||
}
|
||||
|
||||
/// An ArkUI (`@Component`) struct: variant enums, `@Prop` parameters, a default builder for each
|
||||
/// instance swap and slot, and the `build` body.
|
||||
fn arkui_definition(doc: &Document, c: &Component) -> String {
|
||||
|
||||
@@ -188,7 +188,7 @@ fn threads_replies_and_resolving() {
|
||||
|c| c.replies.iter().map(|m| m.text.as_str()).eq(["Sure", "Done"]),
|
||||
"replies stay in the order they were written",
|
||||
),
|
||||
("comment.resolve", json!({"id": c, "author": "Sam"}), |c| c.resolved, "anyone in the file can resolve a thread, not just its author"),
|
||||
("comment.resolve", json!({"id": c}), |c| c.resolved, "anyone in the file can resolve a thread, not just its author (it takes no author)"),
|
||||
("comment.resolve", json!({"id": c, "resolved": false}), |c| !c.resolved, "a resolved thread can be reopened"),
|
||||
("comment.resolve", json!({"id": c}), |c| c.resolved, "resolving again marks it resolved"),
|
||||
(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Inspect behaviour suite, written from the public documentation of design tools' inspect and
|
||||
//! developer handoff modes: the reported layout (size, position, rotation, radius, opacity, blend),
|
||||
//! colour formats, units, typography, the generated CSS, SwiftUI and Compose for common layers,
|
||||
//! the asset list and redline measurements. Each rule is a table row or an assert with a one-line
|
||||
//! derivation; where the documentation is silent the row says so and pins today's behaviour.
|
||||
//! colour formats, units, typography, the generated CSS, SwiftUI, Compose and Flutter for common
|
||||
//! layers, the asset list and redline measurements. Each rule is a table row or an assert with a
|
||||
//! one-line derivation; where the documentation is silent the row says so and pins today's
|
||||
//! behaviour.
|
||||
|
||||
// The tables read best as tuples.
|
||||
#![allow(clippy::type_complexity)]
|
||||
@@ -241,12 +242,16 @@ fn native_code_for_a_simple_layer() {
|
||||
set(&mut s, &[r], json!({"cornerRadius": 8}));
|
||||
let swift = code(&mut s, r, json!({"language": "swiftui"}));
|
||||
let compose = code(&mut s, r, json!({"language": "compose"}));
|
||||
let flutter = code(&mut s, r, json!({"language": "flutter"}));
|
||||
// (code, expected fragment, derivation)
|
||||
let rows: [(&str, &str, &str); 4] = [
|
||||
let rows: [(&str, &str, &str); 7] = [
|
||||
(&swift, "frame(width: 100, height: 50)", "SwiftUI sizes a view with frame, in points"),
|
||||
(&swift, "Color(red: 1, green: 0, blue: 0, opacity: 1)", "docs silent: the fill as a SwiftUI colour"),
|
||||
(&compose, "Modifier.size(100.dp, 50.dp)", "Compose sizes in dp"),
|
||||
(&compose, "RoundedCornerShape(8.dp)", "docs silent: corner radius as a rounded shape"),
|
||||
(&flutter, "width: 100,\n height: 50,", "Flutter sizes a Container in logical pixels"),
|
||||
(&flutter, "Color(0xFFFF0000)", "docs silent: the fill as a Flutter colour"),
|
||||
(&flutter, "borderRadius: BorderRadius.circular(8)", "docs silent: corner radius as a rounded border"),
|
||||
];
|
||||
for (c, want, why) in rows {
|
||||
assert!(c.contains(want), "{want}: {why}\n{c}");
|
||||
|
||||
@@ -351,6 +351,8 @@ mod menu_gaps_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_override_tests;
|
||||
#[cfg(test)]
|
||||
mod node_find_tests;
|
||||
#[cfg(test)]
|
||||
mod node_set_refusals_tests;
|
||||
#[cfg(test)]
|
||||
mod normalize_guard_tests;
|
||||
@@ -495,6 +497,8 @@ mod tiff_tests;
|
||||
#[cfg(test)]
|
||||
mod transform_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod unknown_params_tests;
|
||||
#[cfg(test)]
|
||||
mod variable_props_tests;
|
||||
#[cfg(test)]
|
||||
mod variable_tests;
|
||||
|
||||
@@ -158,8 +158,8 @@ fn viewport(page: NodeId, x: f64, y: f64, w: f64, h: f64) -> Presence {
|
||||
#[test]
|
||||
fn without_a_link_live_sessions_are_unavailable() {
|
||||
let mut s = Session::new();
|
||||
for cmd in ["collab.host", "collab.join"] {
|
||||
let e = s.execute(cmd, &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap_err();
|
||||
for (cmd, p) in [("collab.host", json!({})), ("collab.join", json!({"link": "figcraft://join?host=a:1&file=f&token=t"}))] {
|
||||
let e = s.execute(cmd, &p).unwrap_err();
|
||||
assert!(matches!(e, EngineError::Disabled(..)), "{cmd}: {e}");
|
||||
}
|
||||
assert_eq!(s.execute("collab.leave", &json!({})).unwrap()["ended"], false);
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Finding layers without changing the selection, page or edit history (#996).
|
||||
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, idv};
|
||||
|
||||
fn add(s: &mut Session, parent: NodeId, kind: NodeKind, name: &str) -> NodeId {
|
||||
s.doc_mut().unwrap().create(parent, kind, name, 0.0, 0.0, 100.0, 100.0).unwrap()
|
||||
}
|
||||
|
||||
fn found(s: &mut Session, params: Value, ids: &[NodeId]) {
|
||||
assert_eq!(ex(s, "node.find", params), json!({"ids": ids, "total": ids.len()}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_find_filters_are_literal_case_insensitive_and_anded() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let frame = add(&mut s, page, NodeKind::Frame, "CAFÉ [Card]");
|
||||
let title = add(&mut s, frame, NodeKind::Text(TextData::new("HÉLLO 世界")), "Café Title");
|
||||
let other = add(&mut s, frame, NodeKind::Text(TextData::new("Other")), "Title");
|
||||
let rect = add(&mut s, page, NodeKind::Rectangle, "HÉLLO 世界");
|
||||
found(&mut s, json!({"name": "CAFÉ"}), &[frame, title]);
|
||||
found(&mut s, json!({"name": "[cArD]"}), &[frame]);
|
||||
found(&mut s, json!({"type": "tExT"}), &[title, other]);
|
||||
found(&mut s, json!({"text": "héLLo 世"}), &[title]);
|
||||
found(&mut s, json!({"name": "title", "type": "TEXT", "text": "世界"}), &[title]);
|
||||
found(&mut s, json!({"name": "café", "type": "RECTANGLE"}), &[]);
|
||||
found(&mut s, json!({"text": "héllo", "name": "HÉLLO"}), &[]);
|
||||
found(&mut s, json!({"text": ""}), &[title, other]);
|
||||
found(&mut s, json!({"name": ""}), &[frame, title, other, rect]);
|
||||
found(&mut s, json!({"name": ".*"}), &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_find_matches_a_greek_final_sigma_both_ways() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let os = add(&mut s, page, NodeKind::Text(TextData::new("ΛΟΓΟΣ")), "ΟΣ");
|
||||
found(&mut s, json!({"name": "Σ"}), &[os]);
|
||||
found(&mut s, json!({"name": "ς"}), &[os]);
|
||||
found(&mut s, json!({"text": "λογοσ"}), &[os]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_find_scope_is_preorder_strict_descendants_in_stored_order() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let root = add(&mut s, page, NodeKind::Frame, "Root");
|
||||
let first = add(&mut s, root, NodeKind::Frame, "First");
|
||||
let nested = add(&mut s, first, NodeKind::Rectangle, "Nested");
|
||||
let second = add(&mut s, root, NodeKind::Rectangle, "Second");
|
||||
let outside = add(&mut s, page, NodeKind::Rectangle, "Outside");
|
||||
s.doc_mut().unwrap().get_mut(first).unwrap().visible = false;
|
||||
s.doc_mut().unwrap().get_mut(second).unwrap().locked = true;
|
||||
found(&mut s, json!({}), &[root, first, nested, second, outside]);
|
||||
found(&mut s, json!({"root": root}), &[first, nested, second]);
|
||||
found(&mut s, json!({"root": root, "name": "root"}), &[]);
|
||||
found(&mut s, json!({"root": nested}), &[]);
|
||||
found(&mut s, json!({"root": page}), &[root, first, nested, second, outside]);
|
||||
s.doc_mut().unwrap().reorder(second, 0).unwrap();
|
||||
found(&mut s, json!({"root": root}), &[second, first, nested]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_find_other_page_requires_root_and_does_not_switch_pages() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let here = add(&mut s, page, NodeKind::Frame, "Here");
|
||||
let other_page = s.doc_mut().unwrap().add_page("Other");
|
||||
let root = add(&mut s, other_page, NodeKind::Frame, "There");
|
||||
let child = add(&mut s, root, NodeKind::Rectangle, "Child");
|
||||
found(&mut s, json!({}), &[here]);
|
||||
found(&mut s, json!({"root": other_page}), &[root, child]);
|
||||
found(&mut s, json!({"root": root}), &[child]);
|
||||
assert_eq!(s.page().unwrap(), page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_find_instances_match_main_name_and_visit_actual_children_only() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let main = add(&mut s, page, NodeKind::Component { description: String::new(), props: Default::default() }, "CAFÉ [Button]");
|
||||
let original = add(&mut s, main, NodeKind::Text(TextData::new("Original")), "Original");
|
||||
let inst = idv(&ex(&mut s, "instance.create", json!({"component": main})));
|
||||
let child = s.doc().unwrap().get(inst).unwrap().children[0];
|
||||
ex(&mut s, "node.set", json!({"ids": [inst], "props": {"name": "Copy"}}));
|
||||
ex(&mut s, "text.set", json!({"id": child, "text": "Changed"}));
|
||||
let broken = add(&mut s, page, NodeKind::Instance { component: NodeId(u64::MAX), props: Default::default() }, "Missing");
|
||||
found(&mut s, json!({"component": "café [bUt"}), &[inst]);
|
||||
found(&mut s, json!({"component": "", "type": "instance", "name": "copy"}), &[inst]);
|
||||
found(&mut s, json!({"component": "copy"}), &[]);
|
||||
found(&mut s, json!({"component": "button", "text": "Changed"}), &[]);
|
||||
found(&mut s, json!({"root": inst}), &[child]);
|
||||
found(&mut s, json!({"root": inst, "text": "original"}), &[]);
|
||||
found(&mut s, json!({"root": inst, "text": "changed"}), &[child]);
|
||||
found(&mut s, json!({}), &[main, original, inst, child, broken]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_find_accepts_every_canonical_type() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let kinds = [
|
||||
NodeKind::Frame,
|
||||
NodeKind::Group,
|
||||
NodeKind::Section { contents_hidden: false },
|
||||
NodeKind::Rectangle,
|
||||
NodeKind::Ellipse { start: 0.0, sweep: 1.0, inner: 0.0 },
|
||||
NodeKind::Polygon { count: 3 },
|
||||
NodeKind::Star { count: 5, inner_ratio: 0.5 },
|
||||
NodeKind::Line,
|
||||
NodeKind::Vector { network: Default::default() },
|
||||
NodeKind::Text(TextData::new("Text")),
|
||||
NodeKind::BooleanOp { op: BoolOp::Union },
|
||||
NodeKind::Component { description: String::new(), props: Default::default() },
|
||||
NodeKind::ComponentSet { description: String::new() },
|
||||
NodeKind::Instance { component: NodeId(u64::MAX), props: Default::default() },
|
||||
NodeKind::Slice,
|
||||
];
|
||||
for kind in kinds {
|
||||
let name = kind.type_name();
|
||||
let id = add(&mut s, page, kind, name);
|
||||
assert_eq!(ex(&mut s, "node.get", json!({"id": id}))["type"], name);
|
||||
found(&mut s, json!({"type": name.to_ascii_lowercase()}), &[id]);
|
||||
}
|
||||
found(&mut s, json!({"type": "PAGE"}), &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_find_limit_caps_ids_not_total_and_zero_counts_only() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let ids: Vec<_> = (0..105).map(|_| add(&mut s, page, NodeKind::Rectangle, "Match")).collect();
|
||||
assert_eq!(ex(&mut s, "node.find", json!({})), json!({"ids": &ids[..100], "total": 105}));
|
||||
assert_eq!(ex(&mut s, "node.find", json!({"limit": 2})), json!({"ids": &ids[..2], "total": 105}));
|
||||
assert_eq!(ex(&mut s, "node.find", json!({"limit": 0})), json!({"ids": [], "total": 105}));
|
||||
found(&mut s, json!({"limit": 10000}), &ids);
|
||||
found(&mut s, json!({"name": "missing", "limit": 0}), &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_find_rejects_bad_root_type_filters_and_limits() {
|
||||
let mut s = Session::new();
|
||||
for params in [
|
||||
json!({"root": 999999}),
|
||||
json!({"root": "nope"}),
|
||||
json!({"root": {"id": 1}}),
|
||||
json!({"type": "BOOLEANOP"}),
|
||||
json!({"type": "FRAME "}),
|
||||
json!({"type": ""}),
|
||||
json!({"type": 3}),
|
||||
json!({"name": []}),
|
||||
json!({"text": true}),
|
||||
json!({"component": {}}),
|
||||
json!({"limit": -1}),
|
||||
json!({"limit": 10001}),
|
||||
json!({"limit": 1.5}),
|
||||
json!({"limit": 1.0}),
|
||||
json!({"limit": "2"}),
|
||||
json!({"limit": true}),
|
||||
json!({"limit": []}),
|
||||
json!({"limit": 1e308}),
|
||||
] {
|
||||
assert!(s.execute("node.find", ¶ms).is_err(), "{params}");
|
||||
}
|
||||
let error = s.execute("node.find", &json!({"type": "x".repeat(10000)})).unwrap_err().to_string();
|
||||
assert!(error.contains("unknown type"), "{error}");
|
||||
assert!(error.len() < 300, "unknown types are not echoed whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_find_leaves_document_selection_page_and_history_untouched() {
|
||||
let mut s = Session::new();
|
||||
let layer = idv(&ex(&mut s, "shape.rectangle", json!({"width": 10, "height": 10})));
|
||||
ex(&mut s, "node.set", json!({"ids": [layer], "props": {"name": "Previous edit"}}));
|
||||
ex(&mut s, "node.set", json!({"ids": [layer], "props": {"name": "Undone edit"}}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
ex(&mut s, "select.set", json!({"ids": [layer]}));
|
||||
let ds = s.ds().unwrap();
|
||||
let (doc, selection, page, undo, redo) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.redo.len());
|
||||
assert!(undo > 0 && redo > 0);
|
||||
found(&mut s, json!({"name": "previous"}), &[layer]);
|
||||
found(&mut s, json!({"name": "missing"}), &[]);
|
||||
ex(&mut s, "node.find", json!({"limit": 0}));
|
||||
assert!(s.execute("node.find", &json!({"root": 999999})).is_err());
|
||||
let ds = s.ds().unwrap();
|
||||
assert!(Arc::ptr_eq(&ds.doc, &doc));
|
||||
assert_eq!((&ds.selection, ds.page, ds.history.undo.len(), ds.history.redo.len()), (&selection, page, undo, redo));
|
||||
assert_eq!(ex(&mut s, "edit.undo", json!({}))["undone"], true);
|
||||
assert_ne!(s.doc().unwrap().get(layer).unwrap().name, "Previous edit");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo - 1);
|
||||
}
|
||||
@@ -74,3 +74,37 @@ fn auto_save_leaves_changes_made_on_disk_alone() {
|
||||
assert_eq!(page_on_disk(&path), "CLI VENCEU");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// The name of the page the active tab shows.
|
||||
fn page_in_tab(s: &Session) -> String {
|
||||
let ds = s.ds().unwrap();
|
||||
ds.doc.node(ds.page).unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// Opening a file that is already open says when it changed on disk since the tab read or saved
|
||||
/// it, and `reload` reads it again into the tab, unless the tab has unsaved changes (#997).
|
||||
#[test]
|
||||
fn opening_an_open_file_says_it_is_stale_and_reloads_on_request() {
|
||||
let path = temp_file("reopen");
|
||||
let mut setup = Session::new();
|
||||
setup.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
setup.execute("file.save", &json!({"path": path})).unwrap();
|
||||
let mut app = Session::new();
|
||||
let index = app.execute("file.open", &json!({"path": path})).unwrap()["index"].clone();
|
||||
let before = page_in_tab(&app);
|
||||
assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index}), "unchanged: not stale");
|
||||
let mut cli = Session::new();
|
||||
cli.execute("file.open", &json!({"path": path})).unwrap();
|
||||
cli.execute("page.rename", &json!({"name": "Renamed on disk by the CLI"})).unwrap();
|
||||
cli.execute("file.save", &json!({})).unwrap();
|
||||
assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index, "stale": true}));
|
||||
assert_eq!(page_in_tab(&app), before, "without reload the tab keeps what it read");
|
||||
assert_eq!(app.execute("file.open", &json!({"path": path, "reload": true})).unwrap(), json!({"index": index, "reloaded": true}));
|
||||
assert_eq!(page_in_tab(&app), "Renamed on disk by the CLI");
|
||||
assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index}), "read again: no longer stale");
|
||||
app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let e = app.execute("file.open", &json!({"path": path, "reload": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("unsaved changes"), "{e}");
|
||||
assert!(app.ds().unwrap().is_dirty(), "the unsaved edit is still there");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ impl Session {
|
||||
/// as it was.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
crate::cmd::refuse_unknown_params(id, params)?;
|
||||
board::refuse_on_board(self, id)?;
|
||||
// Revert replaces the document's uid: refuse before ending previews or syncing branches.
|
||||
if id == "file.revert" && self.live().doc_uid.is_some() {
|
||||
|
||||
@@ -166,6 +166,20 @@ fn a_centred_stroke_on_a_sharp_star_makes_three_regions() {
|
||||
assert_eq!(covers, vec![(false, true), (true, false), (true, true)], "fill only, stroke only, both");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_every_region_of_one_stroked_shape_is_a_no_op() {
|
||||
let mut s = Session::new();
|
||||
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}})));
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}}));
|
||||
s.set_selection(vec![r]);
|
||||
let count = ex(&mut s, "shapeBuilder.regions", json!({}))["regions"].as_array().unwrap().len();
|
||||
assert!(count > 1, "fill and stroke make several regions");
|
||||
let before = s.doc().unwrap().clone();
|
||||
let out = ex(&mut s, "shapeBuilder.merge", json!({"regions": (0..count).collect::<Vec<_>>()}));
|
||||
assert!(out["id"].is_null(), "{out}");
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_regions_survive_junk() {
|
||||
for j in [
|
||||
|
||||
@@ -168,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.share",
|
||||
"Share with a join code",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
false,
|
||||
share
|
||||
),
|
||||
@@ -182,7 +182,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.join",
|
||||
"Join live session",
|
||||
"{code: a join code (K7M-2QX-9TD from a server, asked at the server in Preferences; a longer one for the local network; spaces, dashes and case don't matter, O reads as 0 and I or L as 1) | link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=… (a session an app hosts, or a server's share link; a code here works too)} → the new session: {session, status: \"connecting\", …}; its file opens in a tab of its own (other files' sessions go on) and `collab.peers` tells when it's live (`sessions` lists it until then, and if it fails)",
|
||||
"{code | link (a join code: K7M-2QX-9TD from a server, asked at the server in Preferences; a longer one for the local network; spaces, dashes and case don't matter, O reads as 0 and I or L as 1; or an invite link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=…, from a session an app hosts or a server's share link; either key takes either)} → the new session: {session, status: \"connecting\", …}; its file opens in a tab of its own (other files' sessions go on) and `collab.peers` tells when it's live (`sessions` lists it until then, and if it fails)",
|
||||
false,
|
||||
join
|
||||
),
|
||||
@@ -210,7 +210,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.manageFile",
|
||||
"Manage published file",
|
||||
"{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink {access?: view (default) | edit, label?} | revoke {link (a link's id)} | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename {name} | remove (the file is deleted from the server and leaves your online files)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}",
|
||||
"{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink | revoke | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename | remove (the file is deleted from the server and leaves your online files), access? (createLink: view (default) | edit), label? (createLink), link? (revoke: a link's id), name? (rename)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}",
|
||||
false,
|
||||
manage_file
|
||||
),
|
||||
|
||||
@@ -459,6 +459,9 @@ fn every_command_survives_junk_params() {
|
||||
// would write to it.)
|
||||
json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}),
|
||||
json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}),
|
||||
// Layer search roots, filters and limits as junk.
|
||||
json!({"root": {"id": 2}, "name": [1], "type": "nope", "text": true, "component": [], "limit": -1}),
|
||||
json!({"root": 999999, "limit": 1e308}),
|
||||
// Batch step names and reference paths as junk.
|
||||
json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}),
|
||||
json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [
|
||||
@@ -483,11 +486,19 @@ fn every_command_survives_junk_params() {
|
||||
continue;
|
||||
}
|
||||
for j in &junk {
|
||||
let mut s = sess();
|
||||
let _ = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
||||
let _ = s.execute("text.create", &json!({"text": "t"}));
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
// Each junk object as given (names the command doesn't take are refused) and cut down
|
||||
// to the names it takes, so the junk values still reach its parsers.
|
||||
let mut taken = j.clone();
|
||||
if let (Some(names), Some(o)) = (crate::cmd::param_names(spec.id), taken.as_object_mut()) {
|
||||
o.retain(|k, _| matches!(k.as_str(), "ids" | "id") || names.contains(&k.as_str()));
|
||||
}
|
||||
for params in if taken == *j { vec![j] } else { vec![j, &taken] } {
|
||||
let mut s = sess();
|
||||
let _ = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
||||
let _ = s.execute("text.create", &json!({"text": "t"}));
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, params) {
|
||||
panic!("{cmd} panicked on {params}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! A parameter name a command doesn't take is refused, not ignored: a misspelt name would make the
|
||||
//! command answer as if it hadn't been given (#1413).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::{all_commands, param_names};
|
||||
|
||||
fn err(s: &mut Session, cmd: &str, params: Value) -> String {
|
||||
match s.execute(cmd, ¶ms) {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(v) => panic!("{cmd} {params} ran: {v}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The issue's repro: a misspelt `filter` listed the resolved thread as if no filter were given.
|
||||
#[test]
|
||||
fn a_misspelt_parameter_is_refused_and_named() {
|
||||
let mut s = Session::new();
|
||||
s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "a"})).unwrap();
|
||||
s.execute("comment.resolve", &json!({"id": 1})).unwrap();
|
||||
let e = err(&mut s, "comment.list", json!({"filterr": "open"}));
|
||||
assert!(e.contains("unknown parameter `filterr`") && e.contains("it takes page, filter, sort"), "{e}");
|
||||
assert_eq!(s.execute("comment.list", &json!({"filter": "open"})).unwrap()["comments"].as_array().map(Vec::len), Some(0));
|
||||
let e = err(&mut s, "edit.undo", json!({"steps": 2, "stepz": 1}));
|
||||
assert!(e.contains("unknown parameters `steps`, `stepz` (it takes none)"), "{e}");
|
||||
}
|
||||
|
||||
/// `ids` and `id` name the layers a command acts on, documented or not (`params::targets` reads
|
||||
/// either), so `{id}` keeps working where a doc only says `ids?`.
|
||||
#[test]
|
||||
fn ids_and_id_are_always_taken() {
|
||||
let mut s = Session::new();
|
||||
let r = crate::params::id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap();
|
||||
assert_eq!(param_names("align.left"), Some(&["ids"][..]));
|
||||
s.execute("align.left", &json!({"id": r})).unwrap();
|
||||
s.execute("edit.undo", &json!({"ids": []})).unwrap();
|
||||
}
|
||||
|
||||
/// Names a doc gives as alternatives (`id? | ids?`, `id?/ids?`, `path | data`) are all taken, and
|
||||
/// `null` params or no params at all stay fine.
|
||||
#[test]
|
||||
fn documented_alternatives_and_empty_params_are_taken() {
|
||||
assert_eq!(param_names("vector.fillRule").map(|n| n.contains(&"id") && n.contains(&"ids")), Some(true));
|
||||
assert_eq!(param_names("text.setFeatures").map(|n| n.contains(&"id") && n.contains(&"ids")), Some(true));
|
||||
assert_eq!(param_names("file.import").map(|n| n.contains(&"path") && n.contains(&"data") && n.contains(&"name")), Some(true));
|
||||
assert_eq!(param_names("file.export").map(|n| n.contains(&"scale") && n.contains(&"size")), Some(true));
|
||||
assert_eq!(param_names("collab.share").map(|n| n.contains(&"via") && !n.contains(&"local")), Some(true));
|
||||
assert_eq!(param_names("component.editProperty").map(|n| n.contains(&"value") && n.contains(&"newValue")), Some(true));
|
||||
// Docs whose shape used to hide names: a stray brace, a comma in an example, a second object.
|
||||
assert_eq!(param_names("proto.interaction.add").map(|n| n.contains(&"delayMs") && n.contains(&"dest")), Some(true));
|
||||
assert_eq!(param_names("collab.join"), Some(&["code", "link"][..]));
|
||||
assert_eq!(param_names("proto.interaction.set").map(|n| n.contains(&"trigger") && n.contains(&"interaction")), Some(true));
|
||||
// Commands that run another command's code take its names too.
|
||||
assert_eq!(param_names("file.placeImage").map(|n| ["width", "height", "target", "mode"].iter().all(|k| n.contains(k))), Some(true));
|
||||
assert_eq!(param_names("file.exportSvg").map(|n| ["scale", "size", "contentsOnly", "svgOutlineText"].iter().all(|k| n.contains(k))), Some(true));
|
||||
// Alternative groups: `{x, y} | {points: …}`.
|
||||
assert_eq!(param_names("vector.edit.paint"), Some(&["x", "y", "points"][..]));
|
||||
assert_eq!(param_names("vector.edit.cut"), Some(&["segment", "t", "vertex", "from", "to"][..]));
|
||||
// `node.set` documents its props and checks them itself.
|
||||
assert_eq!(param_names("node.set"), None);
|
||||
let mut s = Session::new();
|
||||
for p in [json!(null), json!({})] {
|
||||
s.execute("select.all", &p).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Every top-level item of every `{…}` params doc gives a name, so no parameter a doc describes is
|
||||
/// refused.
|
||||
#[test]
|
||||
fn every_doc_item_names_a_parameter() {
|
||||
let nameless: Vec<String> = all_commands()
|
||||
.iter()
|
||||
.flat_map(|c| crate::cmd::doc_items(c.params).unwrap_or_default().into_iter().map(move |i| (c.id, i)))
|
||||
.filter(|(_, i)| !i.trim().is_empty() && crate::cmd::item_names(i).is_empty())
|
||||
.map(|(id, i)| format!("{id}: `{i}`"))
|
||||
.collect();
|
||||
assert!(nameless.is_empty(), "doc items without a name: {nameless:#?}");
|
||||
}
|
||||
|
||||
/// A batch step key other than `command`, `params` and `as` (`args` for `params`) is refused, and
|
||||
/// so is a step's unknown parameter, naming the step.
|
||||
#[test]
|
||||
fn batch_steps_refuse_unknown_keys_and_parameters() {
|
||||
let mut s = Session::new();
|
||||
let e = err(&mut s, "command.batch", json!({"commands": [{"command": "select.all"}, {"command": "inspect.code", "args": {"id": 2}}]}));
|
||||
assert!(e.contains("step 1: unknown key `args`"), "{e}");
|
||||
let e =
|
||||
err(&mut s, "command.batch", json!({"commands": [{"command": "select.all"}, {"command": "comment.list", "params": {"filterr": "open"}}]}));
|
||||
assert!(e.contains("comment.list") && e.contains("step 1: unknown parameter `filterr`"), "{e}");
|
||||
}
|
||||
@@ -7,9 +7,9 @@
|
||||
// The tables read best as tuples.
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use figcraft_doc::{HandleMirroring, NodeId, NodeKind, VectorNetwork};
|
||||
use figcraft_doc::{HandleMirroring, NodeId, NodeKind, Region, Segment, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::kurbo::ParamCurve;
|
||||
use figcraft_geom::{Point, Shape};
|
||||
use figcraft_geom::{BezPath, Point, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
@@ -17,6 +17,276 @@ use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use crate::vectoredit::{VectorEdit, network};
|
||||
|
||||
#[test]
|
||||
fn region_paints_survive_simplify_offset_and_bucket_normalization() {
|
||||
let mut s = Session::new();
|
||||
let id = square(&mut s, false);
|
||||
ex_to_vector(s.doc_mut().unwrap(), id);
|
||||
let mut gradient = figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK);
|
||||
gradient.from = (0.2, 0.1);
|
||||
gradient.to = (0.8, 0.9);
|
||||
let fills = vec![
|
||||
figcraft_color::Paint::gradient(gradient),
|
||||
figcraft_color::Paint::image("test-image", figcraft_color::ImageScaleMode::Crop),
|
||||
figcraft_color::Paint::pattern(123),
|
||||
];
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
|
||||
network.regions[0].fills = Some(fills.clone());
|
||||
}
|
||||
ex(&mut s, "vector.simplify", json!({"ids": [id]}));
|
||||
assert_eq!(net(&s, id).regions[0].fills, Some(fills.clone()), "simplify keeps the full stack");
|
||||
ex(&mut s, "vector.offset", json!({"ids": [id], "offset": 2}));
|
||||
assert_eq!(net(&s, id).regions[0].fills, Some(fills.clone()), "offset keeps the full stack");
|
||||
|
||||
// Add a divider so the legacy single region must split into independently painted faces.
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
let network = net(&s, id);
|
||||
let (topseg, botseg) = (
|
||||
network.segments.iter().position(|sg| network.vertices[sg.a].y == 0.0 && network.vertices[sg.b].y == 0.0).unwrap(),
|
||||
network.segments.iter().position(|sg| network.vertices[sg.a].y == 104.0 && network.vertices[sg.b].y == 104.0).unwrap(),
|
||||
);
|
||||
let a = ex(&mut s, "vector.edit.addVertex", json!({"segment": topseg, "t": 0.5}))["vertex"].as_u64().unwrap();
|
||||
let b = ex(&mut s, "vector.edit.addVertex", json!({"segment": botseg, "t": 0.5}))["vertex"].as_u64().unwrap();
|
||||
ex(&mut s, "vector.edit.connect", json!({"a": a, "b": b}));
|
||||
ex(&mut s, "vector.edit.paint", json!({"x": 25, "y": 52}));
|
||||
let normalized = net(&s, id);
|
||||
assert_eq!(normalized.regions.len(), 1, "unfilled face is removed while the other stays");
|
||||
assert_eq!(normalized.regions[0].fills, Some(fills.clone()), "untouched face keeps the original stack");
|
||||
assert!(!normalized.fill_contains(Point::new(27.0, 54.0)));
|
||||
assert!(normalized.fill_contains(Point::new(77.0, 54.0)));
|
||||
s.undo().unwrap();
|
||||
assert!(net(&s, id).fill_contains(Point::new(25.0, 52.0)), "bucket change is undoable");
|
||||
ex(&mut s, "vector.edit.end", json!({}));
|
||||
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap();
|
||||
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
let flattened_net = net(&s, flattened);
|
||||
assert!(flattened_net.regions.iter().all(|region| region.fills == Some(fills.clone())), "flatten keeps each source region stack");
|
||||
assert!(flattened_net.regions.iter().all(|region| region.paint_space.is_some()), "flatten keeps source paint frames");
|
||||
assert!(flattened_net.fill_contains(Point::new(25.0, 52.0)));
|
||||
let prior_frame =
|
||||
flattened_net.regions[0].paint_frame(s.doc().unwrap().get(flattened).unwrap().width, s.doc().unwrap().get(flattened).unwrap().height);
|
||||
let prior_world_frame = s.doc().unwrap().world_transform(flattened) * prior_frame.2;
|
||||
let regrouped = id_of(&ex(&mut s, "object.group", json!({"ids": [flattened]}))["id"]).unwrap();
|
||||
let flattened_again = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [regrouped]}))["id"]).unwrap();
|
||||
let after = net(&s, flattened_again);
|
||||
let after_frame =
|
||||
after.regions[0].paint_frame(s.doc().unwrap().get(flattened_again).unwrap().width, s.doc().unwrap().get(flattened_again).unwrap().height);
|
||||
let after_world_frame = s.doc().unwrap().world_transform(flattened_again) * after_frame.2;
|
||||
assert!(
|
||||
prior_world_frame.as_coeffs().iter().zip(after_world_frame.as_coeffs()).all(|(before, after)| (before - after).abs() < 1e-8),
|
||||
"flattening an already framed gradient preserves its source coordinates"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_and_inherited_region_paints_survive_refit() {
|
||||
let mut s = Session::new();
|
||||
let empty_id = square(&mut s, false);
|
||||
ex_to_vector(s.doc_mut().unwrap(), empty_id);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
if let NodeKind::Vector { network } = &mut doc.get_mut(empty_id).unwrap().kind {
|
||||
network.regions[0].fills = Some(Vec::new());
|
||||
}
|
||||
}
|
||||
ex(&mut s, "vector.simplify", json!({"ids": [empty_id]}));
|
||||
ex(&mut s, "vector.offset", json!({"ids": [empty_id], "offset": 2}));
|
||||
assert_eq!(net(&s, empty_id).regions[0].fills, Some(Vec::new()), "explicit empty paint stack survives refit");
|
||||
|
||||
let inherited_id = square(&mut s, false);
|
||||
ex_to_vector(s.doc_mut().unwrap(), inherited_id);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
if let NodeKind::Vector { network } = &mut doc.get_mut(inherited_id).unwrap().kind {
|
||||
network.regions[0].fills = None;
|
||||
}
|
||||
}
|
||||
ex(&mut s, "vector.offset", json!({"ids": [inherited_id], "offset": 2}));
|
||||
let inherited_net = net(&s, inherited_id);
|
||||
let inherited = &inherited_net.regions[0];
|
||||
assert_eq!(inherited.fills, None, "inherited paints remain inherited after offset");
|
||||
let inherited_space = inherited.paint_space.expect("offset binds inherited paints to the original frame");
|
||||
assert_eq!((inherited_space.width, inherited_space.height), (100.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_keeps_inherited_region_paints_inherited() {
|
||||
let mut s = Session::new();
|
||||
let inherited_flatten = square(&mut s, false);
|
||||
let inherited_fills = s.doc().unwrap().get(inherited_flatten).unwrap().fills.clone();
|
||||
ex_to_vector(s.doc_mut().unwrap(), inherited_flatten);
|
||||
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(inherited_flatten).unwrap().kind {
|
||||
network.regions[0].fills = None;
|
||||
}
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [inherited_flatten]}))["id"]).unwrap();
|
||||
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert!(
|
||||
net(&s, flattened).regions.iter().all(|region| region.fills.is_none()),
|
||||
"flatten keeps inherited paints inherited when output node has same fills"
|
||||
);
|
||||
assert_eq!(s.doc().unwrap().get(flattened).unwrap().fills, inherited_fills);
|
||||
|
||||
let plain = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 60}))["id"]).unwrap();
|
||||
let plain_fills = s.doc().unwrap().get(plain).unwrap().fills.clone();
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [plain]}))["id"]).unwrap();
|
||||
let flattened_plain = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert!(net(&s, flattened_plain).regions.iter().all(|region| region.fills.is_none()), "plain shapes inherit the flattened node's fill");
|
||||
assert_eq!(s.doc().unwrap().get(flattened_plain).unwrap().fills, plain_fills);
|
||||
let edited_fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
|
||||
s.doc_mut().unwrap().get_mut(flattened_plain).unwrap().fills = edited_fills.clone();
|
||||
assert_eq!(net(&s, flattened_plain).regions[0].fills, None, "changing the flattened node fill updates inherited geometry");
|
||||
|
||||
let different_source = square(&mut s, false);
|
||||
s.doc_mut().unwrap().get_mut(different_source).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
|
||||
ex_to_vector(s.doc_mut().unwrap(), different_source);
|
||||
let source_fills = s.doc().unwrap().get(different_source).unwrap().fills.clone();
|
||||
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(different_source).unwrap().kind {
|
||||
network.regions[0].fills = None;
|
||||
}
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [different_source]}))["id"]).unwrap();
|
||||
s.doc_mut().unwrap().get_mut(group).unwrap().fills.clear();
|
||||
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert_eq!(net(&s, flattened).regions[0].fills, Some(source_fills), "flatten retains paints that differ from output node fills");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_offset_preserves_the_gradients_world_paint_frame() {
|
||||
let mut s = Session::new();
|
||||
let legacy_gradient = square(&mut s, false);
|
||||
let mut gradient = figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK);
|
||||
gradient.from = (0.2, 0.1);
|
||||
gradient.to = (0.8, 0.9);
|
||||
s.doc_mut().unwrap().get_mut(legacy_gradient).unwrap().fills = vec![figcraft_color::Paint::gradient(gradient)];
|
||||
ex_to_vector(s.doc_mut().unwrap(), legacy_gradient);
|
||||
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(legacy_gradient).unwrap().kind {
|
||||
network.regions[0].fills = None;
|
||||
}
|
||||
let before = net(&s, legacy_gradient).regions[0].paint_frame(100.0, 100.0).2;
|
||||
let before_world = s.doc().unwrap().world_transform(legacy_gradient) * before;
|
||||
ex(&mut s, "vector.offset", json!({"ids": [legacy_gradient], "offset": 2}));
|
||||
let after_net = net(&s, legacy_gradient);
|
||||
let after = after_net.regions[0]
|
||||
.paint_frame(s.doc().unwrap().get(legacy_gradient).unwrap().width, s.doc().unwrap().get(legacy_gradient).unwrap().height)
|
||||
.2;
|
||||
let after_world = s.doc().unwrap().world_transform(legacy_gradient) * after;
|
||||
assert!(
|
||||
before_world.as_coeffs().iter().zip(after_world.as_coeffs()).all(|(before, after)| (before - after).abs() < 1e-8),
|
||||
"legacy offset preserves the gradient's world paint frame"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_fills_an_explicitly_empty_face_and_leaves_the_other_empty() {
|
||||
let mut s = Session::new();
|
||||
let empty_faces =
|
||||
id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L100 0 L100 100 L0 100 Z M200 0 L300 0 L300 100 L200 100 Z"}))["id"]).unwrap();
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(empty_faces).unwrap();
|
||||
node.fills.clear();
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector fixture") };
|
||||
assert!(network.regions.iter().all(|region| region.fills.is_none()));
|
||||
}
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": empty_faces}));
|
||||
ex(&mut s, "vector.edit.paint", json!({"x": 50, "y": 50}));
|
||||
let split = net(&s, empty_faces);
|
||||
let left =
|
||||
split.regions.iter().enumerate().find(|(index, _)| split.region_contains(*index, Point::new(50.0, 50.0))).map(|(_, region)| region).unwrap();
|
||||
let right =
|
||||
split.regions.iter().enumerate().find(|(index, _)| split.region_contains(*index, Point::new(250.0, 50.0))).map(|(_, region)| region).unwrap();
|
||||
assert_eq!(left.fills, None, "clicking an explicitly empty face fills it");
|
||||
assert_eq!(right.fills, Some(Vec::new()), "untouched inherited-empty face stays empty");
|
||||
assert!(!s.doc().unwrap().get(empty_faces).unwrap().fills.is_empty(), "the painted target gets the layer default fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_simplify_combines_mixed_winding_regions() {
|
||||
let mut s = Session::new();
|
||||
let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L100 0 L100 100 L0 100 Z M25 25 L75 25 L75 75 L25 75 Z"}))["id"]).unwrap();
|
||||
let mut second_path = BezPath::new();
|
||||
second_path.move_to((50.0, 0.0));
|
||||
second_path.line_to((150.0, 0.0));
|
||||
second_path.line_to((150.0, 100.0));
|
||||
second_path.line_to((50.0, 100.0));
|
||||
second_path.close_path();
|
||||
let second = VectorNetwork::from_path(&second_path);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
|
||||
network.regions[0].winding = WindingRule::EvenOdd;
|
||||
let vertex_offset = network.vertices.len();
|
||||
let segment_offset = network.segments.len();
|
||||
network.vertices.extend(second.vertices);
|
||||
network.segments.extend(second.segments.into_iter().map(|segment| Segment {
|
||||
a: segment.a + vertex_offset,
|
||||
b: segment.b + vertex_offset,
|
||||
..segment
|
||||
}));
|
||||
network.regions.extend(second.regions.into_iter().map(|region| Region {
|
||||
loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + segment_offset).collect()).collect(),
|
||||
..region
|
||||
}));
|
||||
network.regions[1].winding = WindingRule::NonZero;
|
||||
}
|
||||
ex(&mut s, "vector.simplify", json!({"ids": [id]}));
|
||||
let simplified = net(&s, id);
|
||||
assert_eq!(simplified.regions.len(), 1, "legacy regions simplify as one combined fill");
|
||||
assert!(!simplified.fill_contains(Point::new(60.0, 10.0)), "overlap cancels under combined even-odd winding");
|
||||
assert!(!simplified.fill_contains(Point::new(40.0, 40.0)), "nested contour remains a hole");
|
||||
assert!(simplified.fill_contains(Point::new(120.0, 50.0)), "non-overlapping area remains filled");
|
||||
assert!(simplified.regions[0].fills.is_none());
|
||||
assert!(simplified.regions[0].paint_space.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_evenodd_holes_survive_bucket_offset_and_flatten() {
|
||||
let mut s = Session::new();
|
||||
let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L200 0 L200 200 L0 200 Z M50 50 L150 50 L150 150 L50 150 Z"}))["id"]).unwrap();
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
|
||||
network.regions[0].winding = figcraft_doc::WindingRule::EvenOdd;
|
||||
}
|
||||
assert!(!net(&s, id).fill_contains(Point::new(100.0, 100.0)), "center starts as an even-odd hole");
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.paint", json!({"x": 100, "y": 100}));
|
||||
assert!(net(&s, id).fill_contains(Point::new(100.0, 100.0)), "bucket fills a legacy even-odd hole");
|
||||
s.undo().unwrap();
|
||||
ex(&mut s, "vector.edit.end", json!({}));
|
||||
|
||||
let flatten_source =
|
||||
id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L200 0 L200 200 L0 200 Z M50 50 L150 50 L150 150 L50 150 Z"}))["id"]).unwrap();
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
if let NodeKind::Vector { network } = &mut doc.get_mut(flatten_source).unwrap().kind {
|
||||
network.regions[0].winding = figcraft_doc::WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [flatten_source]}))["id"]).unwrap();
|
||||
let flattened_legacy = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert_eq!(net(&s, flattened_legacy).regions.len(), 1, "flatten combines unstyled legacy loops");
|
||||
assert!(!net(&s, flattened_legacy).fill_contains(Point::new(100.0, 100.0)), "flatten preserves the legacy even-odd hole");
|
||||
|
||||
ex(&mut s, "vector.offset", json!({"ids": [id], "offset": 2}));
|
||||
assert!(!net(&s, id).fill_contains(Point::new(100.0, 100.0)), "offset retains the even-odd hole");
|
||||
let group = id_of(&ex(&mut s, "object.group", json!({"ids": [id]}))["id"]).unwrap();
|
||||
let flattened = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [group]}))["id"]).unwrap();
|
||||
assert_eq!(net(&s, flattened).regions.len(), 1, "legacy region loops stay grouped");
|
||||
assert!(!net(&s, flattened).fill_contains(Point::new(100.0, 100.0)), "flatten retains the even-odd hole");
|
||||
}
|
||||
|
||||
/// Give vector `id` a painted region, converting a plain shape to a vector first.
|
||||
fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
|
||||
let n = doc.get(id).unwrap();
|
||||
let (path, _) = figcraft_render::node_outline(doc, n).unwrap();
|
||||
let mut network = VectorNetwork::from_path(&path);
|
||||
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
|
||||
doc.get_mut(id).unwrap().kind = NodeKind::Vector { network };
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
@@ -88,8 +88,8 @@ fn two_squares() -> VectorNetwork {
|
||||
Segment::line(3, 4),
|
||||
],
|
||||
regions: vec![
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -114,8 +114,8 @@ fn two_lenses() -> VectorNetwork {
|
||||
Segment { a: 0, b: 1, ta: (0.0, 50.0), tb: (0.0, 50.0) },
|
||||
],
|
||||
regions: vec![
|
||||
Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero },
|
||||
Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,10 @@ fn one_shared_segment() -> VectorNetwork {
|
||||
VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)],
|
||||
segments: vec![Segment { a: 0, b: 1, ta: (0.0, -60.0), tb: (0.0, -60.0) }],
|
||||
regions: vec![Region { loops: vec![vec![0]], winding: WindingRule::NonZero }, Region { loops: vec![vec![0]], winding: WindingRule::NonZero }],
|
||||
regions: vec![
|
||||
Region { loops: vec![vec![0]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
Region { loops: vec![vec![0]], winding: WindingRule::NonZero, ..Default::default() },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user