264 lines
15 KiB
Rust
264 lines
15 KiB
Rust
//! Component code: components become React / SwiftUI / Compose components whose properties are
|
|
//! parameters, and instances become calls passing the values they set.
|
|
|
|
use crate::Session;
|
|
use crate::codegen::{CodeLang, code_for};
|
|
use crate::component_codegen::{ParamKind, describe, main_of};
|
|
use crate::components::tests::{ex, idv};
|
|
use figcraft_doc::*;
|
|
use serde_json::{Value, json};
|
|
|
|
fn code(s: &mut Session, id: NodeId, lang: &str) -> String {
|
|
ex(s, "inspect.code", json!({"id": id, "language": lang}))["code"].as_str().unwrap_or_default().to_string()
|
|
}
|
|
|
|
fn component_code(s: &mut Session, id: NodeId, lang: &str) -> Value {
|
|
ex(s, "inspect.componentCode", json!({"id": id, "language": lang}))
|
|
}
|
|
|
|
/// A "Card" component with a "Title" text (text property), a "Dot" ellipse (boolean property
|
|
/// "Show dot") and a "Bg" rectangle. Returns (card, title, dot).
|
|
fn card(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
|
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
|
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}}));
|
|
let title = idv(&ex(s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
|
|
let dot = idv(&ex(s, "shape.ellipse", json!({"x": 170, "y": 10, "width": 20, "height": 20, "parent": f, "props": {"name": "Dot"}})));
|
|
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
|
ex(s, "component.addProperty", json!({"id": dot, "name": "Show dot", "type": "boolean", "default": true}));
|
|
ex(s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
|
|
(c, title, dot)
|
|
}
|
|
|
|
/// A "Button" set with State=Default|Hover variants (the hover background blue). Returns (set,
|
|
/// default variant, hover variant).
|
|
fn button_set(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
|
let mut variants = Vec::new();
|
|
for (k, (state, color)) in [("Default", "#ff0000"), ("Hover", "#0000ff")].iter().enumerate() {
|
|
let f = idv(&ex(s, "frame.create", json!({"x": k as f64 * 150.0, "y": 300, "width": 100, "height": 40, "name": format!("State={state}")})));
|
|
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": color}}));
|
|
variants.push(idv(&ex(s, "component.create", json!({"ids": [f]}))));
|
|
}
|
|
let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()})));
|
|
ex(s, "node.set", json!({"id": set, "props": {"name": "Button"}}));
|
|
(set, variants[0], variants[1])
|
|
}
|
|
|
|
#[test]
|
|
fn components_take_their_properties_as_parameters() {
|
|
let mut s = Session::new();
|
|
let (c, _, _) = card(&mut s);
|
|
let react = code(&mut s, c, "react");
|
|
for want in [
|
|
"export interface CardProps {",
|
|
" showDot?: boolean;",
|
|
" title?: string;",
|
|
"export function Card({",
|
|
" showDot = true,",
|
|
" title = \"Hello\",",
|
|
"}: CardProps) {",
|
|
"{showDot && (",
|
|
">{title}</p>",
|
|
"/* card.css */",
|
|
"border-radius: 50%;",
|
|
] {
|
|
assert!(react.contains(want), "React has {want:?}:\n{react}");
|
|
}
|
|
let swift = code(&mut s, c, "swiftui");
|
|
for want in ["struct Card: View {", " var showDot: Bool = true", " var title: String = \"Hello\"", "if showDot {", "Text(title)"] {
|
|
assert!(swift.contains(want), "SwiftUI has {want:?}:\n{swift}");
|
|
}
|
|
let kt = code(&mut s, c, "compose");
|
|
for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] {
|
|
assert!(kt.contains(want), "Compose has {want:?}:\n{kt}");
|
|
}
|
|
// The command describes the properties.
|
|
let v = component_code(&mut s, c, "react");
|
|
assert_eq!(v["name"], "Card");
|
|
assert_eq!(
|
|
v["properties"],
|
|
json!([
|
|
{"name": "Show dot", "param": "showDot", "type": "boolean", "default": true},
|
|
{"name": "Title", "param": "title", "type": "text", "default": "Hello"},
|
|
])
|
|
);
|
|
assert_eq!(v["definition"].as_str(), Some(react.as_str()));
|
|
assert!(v.get("usage").is_none(), "a main has no call");
|
|
// Deterministic.
|
|
assert_eq!(code(&mut s, c, "react"), react);
|
|
}
|
|
|
|
#[test]
|
|
fn instances_are_calls_passing_the_values_they_set() {
|
|
let mut s = Session::new();
|
|
let (c, _, _) = card(&mut s);
|
|
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
|
let v = component_code(&mut s, i, "react");
|
|
assert_eq!(v["usage"], "<Card />\n", "defaults aren't passed");
|
|
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "Buy"}}));
|
|
let v = component_code(&mut s, i, "react");
|
|
assert_eq!(v["usage"], "<Card showDot={false} title=\"Buy\" />\n");
|
|
assert_eq!(v["properties"][0]["value"], false);
|
|
assert_eq!(v["properties"][1]["value"], "Buy");
|
|
// inspect.code on an instance: the call, then the definition.
|
|
let react = code(&mut s, i, "react");
|
|
assert!(react.starts_with("<Card showDot={false} title=\"Buy\" />\n"), "{react}");
|
|
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");
|
|
// Text that needs escaping is an expression in JSX and escaped per language.
|
|
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}}));
|
|
assert_eq!(component_code(&mut s, i, "react")["usage"], "<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");
|
|
}
|
|
|
|
#[test]
|
|
fn nested_instances_in_a_layer_tree_are_calls() {
|
|
let mut s = Session::new();
|
|
let (c, _, _) = card(&mut s);
|
|
let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Home screen"})));
|
|
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen})));
|
|
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Inbox"}}));
|
|
let react = code(&mut s, screen, "react");
|
|
assert!(react.contains("export function HomeScreen() {"), "{react}");
|
|
assert!(react.contains("<Card title=\"Inbox\" />"), "{react}");
|
|
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\")"));
|
|
// HTML and CSS stay plain markup.
|
|
assert!(code(&mut s, screen, "html").contains("Inbox"));
|
|
}
|
|
|
|
#[test]
|
|
fn variant_sets_share_one_definition() {
|
|
let mut s = Session::new();
|
|
let (set, default, hover) = button_set(&mut s);
|
|
let doc = s.doc().unwrap();
|
|
assert_eq!(main_of(doc, set), Some(default));
|
|
assert_eq!(main_of(doc, hover), Some(default), "every variant is defined by the set's first");
|
|
let c = describe(doc, default).unwrap();
|
|
assert_eq!(c.type_name, "Button");
|
|
assert_eq!(c.params.len(), 1);
|
|
assert_eq!(c.params[0].ident, "state");
|
|
assert_eq!(c.params[0].kind, ParamKind::Variant(vec!["Default".into(), "Hover".into()]));
|
|
let react = code(&mut s, set, "react");
|
|
assert!(react.contains(" state?: \"Default\" | \"Hover\";"), "{react}");
|
|
assert!(react.contains(" state = \"Default\","), "{react}");
|
|
assert!(react.contains("data-state={state}"), "{react}");
|
|
// The hover variant's difference is a rule on its data attribute.
|
|
assert!(react.contains(".button[data-state=\"Hover\"] .bg {\n background: #0000FF;"), "{react}");
|
|
let swift = code(&mut s, set, "swiftui");
|
|
assert!(
|
|
swift.contains(" enum State: String, CaseIterable {\n case `default` = \"Default\"\n case hover = \"Hover\""),
|
|
"{swift}"
|
|
);
|
|
assert!(swift.contains(" var state: State = .`default`"), "{swift}");
|
|
let kt = code(&mut s, set, "compose");
|
|
assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
|
|
assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}");
|
|
// An instance showing the hover variant passes it.
|
|
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, "react")["usage"], "<Button state=\"Hover\" />\n");
|
|
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n");
|
|
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
|
|
}
|
|
|
|
#[test]
|
|
fn instance_swap_properties_take_views() {
|
|
let mut s = Session::new();
|
|
let icon = |s: &mut Session, name: &str, x: f64| {
|
|
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 400, "width": 24, "height": 24, "name": name})));
|
|
ex(s, "shape.star", json!({"x": 0, "y": 0, "width": 24, "height": 24, "parent": f}));
|
|
idv(&ex(s, "component.create", json!({"ids": [f]})))
|
|
};
|
|
let (heart, star) = (icon(&mut s, "Heart icon", 0.0), icon(&mut s, "Star icon", 50.0));
|
|
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 120, "height": 40, "name": "Chip"})));
|
|
let nested = idv(&ex(&mut s, "instance.create", json!({"component": heart, "x": 8, "y": 8, "parent": f})));
|
|
let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
|
ex(&mut s, "component.addProperty", json!({"id": chip, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested}));
|
|
let react = code(&mut s, chip, "react");
|
|
assert!(react.contains(" icon?: React.ReactNode;") && react.contains(" icon = <HeartIcon />,"), "{react}");
|
|
assert!(react.contains("{icon}"), "the nested instance is the parameter: {react}");
|
|
let swift = code(&mut s, chip, "swiftui");
|
|
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 i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
|
|
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": star}}));
|
|
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");
|
|
}
|
|
|
|
#[test]
|
|
fn slots_take_optional_content() {
|
|
let mut s = Session::new();
|
|
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Panel"})));
|
|
let content = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 50, "width": 280, "height": 140, "parent": f, "name": "Content"})));
|
|
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": content, "props": {"name": "Placeholder"}}));
|
|
let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
|
ex(&mut s, "component.makeSlot", json!({"id": content}));
|
|
let react = code(&mut s, panel, "react");
|
|
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,"));
|
|
// An instance filling the slot passes its content.
|
|
let g = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 40, "height": 20, "name": "Badge"})));
|
|
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");
|
|
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}");
|
|
}
|
|
|
|
#[test]
|
|
fn identifiers_are_escaped_and_unique() {
|
|
let mut s = Session::new();
|
|
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Text"})));
|
|
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
|
for (name, ty, default) in [
|
|
("default", "boolean", json!(false)),
|
|
("Show icon?", "boolean", json!(true)),
|
|
("Label", "text", json!("a")),
|
|
("label", "text", json!("b")),
|
|
("2 columns", "boolean", json!(false)),
|
|
("Color", "variant", json!("Red")),
|
|
] {
|
|
ex(&mut s, "component.addProperty", json!({"id": c, "name": name, "type": ty, "default": default}));
|
|
}
|
|
let doc = s.doc().unwrap();
|
|
let comp = describe(doc, c).unwrap();
|
|
assert_eq!(comp.type_name, "TextComponent", "never shadows SwiftUI's Text");
|
|
let idents: Vec<(&str, &str)> = comp.params.iter().map(|p| (p.prop.as_str(), p.ident.as_str())).collect();
|
|
for want in [("default", "defaultValue"), ("Show icon?", "showIcon"), ("2 columns", "p2Columns"), ("Color", "color")] {
|
|
assert!(idents.contains(&want), "{want:?} in {idents:?}");
|
|
}
|
|
let labels: Vec<&str> = comp.params.iter().filter(|p| p.prop.eq_ignore_ascii_case("label")).map(|p| p.ident.as_str()).collect();
|
|
assert_eq!(labels.len(), 2);
|
|
assert_ne!(labels[0], labels[1], "unique: {labels:?}");
|
|
let color = comp.params.iter().find(|p| p.prop == "Color").map(|p| p.enum_name.clone());
|
|
assert_eq!(color.as_deref(), Some("ColorVariant"), "never shadows SwiftUI's Color");
|
|
let swift = code_for(doc, c, CodeLang::SwiftUi);
|
|
assert!(swift.contains("struct TextComponent: View {") && swift.contains("enum ColorVariant: String, CaseIterable {"), "{swift}");
|
|
// 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": 999_999})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn bound_variables_become_custom_properties() {
|
|
let mut s = Session::new();
|
|
let (c, _, _) = card(&mut s);
|
|
ex(&mut s, "variable.createCollection", json!({"name": "Tokens"}));
|
|
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "Colors/Brand 500", "type": "color", "value": "#3366FF"}));
|
|
ex(&mut s, "variable.bind", json!({"ids": [c], "field": "fill", "variable": "Colors/Brand 500"}));
|
|
let css = code(&mut s, c, "css");
|
|
assert!(css.contains("background: var(--colors-brand-500, #3366FF);"), "{css}");
|
|
let react = code(&mut s, c, "react");
|
|
assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}");
|
|
}
|