diff --git a/ROADMAP.md b/ROADMAP.md index 1fb06a3..17e4a9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -59,7 +59,8 @@ dimension measured side by side. 4. **Components depth**: *landed:* component properties (edit/delete/bind) with the properties panel, add variant, instance swap UI, per-layer overrides with reset, restore main, instance structure protection; Assets panel with live thumbnails, search and drag-to-canvas (drops into - the frame or auto layout slot under the pointer). *Left:* nested instance overrides depth, slots, + the frame or auto layout slot under the pointer); exposed nested instances (their properties + show on the main's instances). *Left:* nested override edge cases, slots, local libraries (publish/use across files), component descriptions UI. 10–14 h. 5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. @@ -140,7 +141,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Frames, groups, sections, masks | 5 | 70% | section tooling (section presets, auto-size to contents, section-level properties) | 3–5 | | Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 | | Text | 8 | 60% | right-to-left/bidi, text style picker depth, axis hover preview | 8–12 | -| Components & variants | 9 | 55% | nested overrides depth, libraries, slots, descriptions | 12–18 | +| Components & variants | 9 | 60% | libraries, slots, descriptions, nested override edge cases | 10–15 | | Styles & variables | 5 | 60% | aliasing UI, scoping, binding padding/font size/opacity/text, string and boolean bindings in the panel | 6–10 | | Prototyping | 6 | 65% | multi-selection editing, scrolling inside device frames | 4–7 | | Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18–32 | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 4ed458b..0ecb1eb 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -342,6 +342,9 @@ pub struct Node { /// Inside an instance: the node of the main component this one mirrors. #[serde(default)] pub source: Option, + /// A nested instance inside a main component whose properties show on the main's instances. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exposed: bool, /// Inside an instance: property groups overridden locally ("fills", "characters", …). #[serde(default)] pub overrides: BTreeSet, @@ -429,6 +432,7 @@ impl Node { var_modes: BTreeMap::new(), constrain_proportions: false, source: None, + exposed: false, overrides: BTreeSet::new(), prop_refs: BTreeMap::new(), plugin_data: BTreeMap::new(), diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 6f04922..4db8826 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -77,6 +77,39 @@ pub fn specs() -> Vec { Ok(json!({"id": i, "parent": slot.parent})) } ), + spec!( + "component.exposeInstance", + "Expose nested instance", + "{id? (an instance inside a main component), exposed? (default true)} — its properties then show on the main's instances", + true, + |s, p| { + let cmd = "component.exposeInstance"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a nested instance"))?; + let exposed = boolean(p, "exposed").unwrap_or(true); + let doc = s.doc_mut()?; + let ok_target = doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })) + && owning_component(doc, id).is_some_and(|c| c != id) + && !inside_instance(doc, id); + if !ok_target { + return Err(bad(cmd, "only instances nested inside a main component can be exposed")); + } + doc.get_mut(id)?.exposed = exposed; + Ok(json!({"id": id, "exposed": exposed})) + } + ), + spec!( + "instance.exposed", + "Exposed nested instances", + "{id? (instance)} → the nested instance copies whose properties it exposes", + false, + |s, p| { + let id = targets(s, p).first().copied().ok_or_else(|| bad("instance.exposed", "select an instance"))?; + let doc = s.doc()?; + let out: Vec = + exposed_copies(doc, id).into_iter().filter_map(|c| doc.node(c).map(|n| json!({"id": c, "name": n.name}))).collect(); + Ok(json!({"instances": out})) + } + ), spec!("component.detach", "Detach instance", "{ids?}", true, detach), spec!("component.resetInstance", "Reset all changes", "{ids?}", true, reset_instance), spec!( diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index fbd543a..c7d5921 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -821,3 +821,27 @@ pub fn mirror_issues(doc: &Document) -> Vec { #[cfg(test)] #[path = "components_tests.rs"] mod tests; + +/// The nested instance copies inside instance `id` whose main-component counterpart is exposed +/// (top-most first; an exposed instance's own exposed nested instances follow it). +pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(id).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default(); + let mut seen = 0usize; + while let Some(c) = stack.pop() { + seen += 1; + if seen > 100_000 { + break; + } + let Some(n) = doc.node(c) else { continue }; + let exposed = matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed); + if exposed { + out.push(c); + } + // Only exposed nested instances pass their own exposed instances up. + if !matches!(n.kind, NodeKind::Instance { .. }) || exposed { + stack.extend(n.children.iter().rev().copied()); + } + } + out +} diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/components_tests.rs index 56c8a1e..8b3bd8f 100644 --- a/crates/engine/src/components_tests.rs +++ b/crates/engine/src/components_tests.rs @@ -588,3 +588,37 @@ fn place_instances_into_frames_and_auto_layout_slots() { } healthy(&s); } + +#[test] +fn exposed_nested_instances_show_their_properties_on_instances() { + let mut s = Session::new(); + let (icon, shape, button, nested) = button(&mut s); + ex(&mut s, "component.addProperty", json!({"id": icon, "name": "Filled", "type": "boolean", "default": true, "layer": shape})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + let exposed = |s: &mut Session| ex(s, "instance.exposed", json!({"id": i}))["instances"].as_array().unwrap().len(); + assert_eq!(exposed(&mut s), 0); + ex(&mut s, "component.exposeInstance", json!({"id": nested})); + assert!(node(&s, nested).exposed); + healthy(&s); + let inested = copy_of(&s, i, nested); + let list = ex(&mut s, "instance.exposed", json!({"id": i})); + assert_eq!(list["instances"][0]["id"], json!(inested.0)); + // The nested copy's property is set from the outer instance and survives main edits. + ex(&mut s, "instance.setProperties", json!({"ids": [inested], "properties": {"Filled": false}})); + let ishape = copy_of(&s, inested, copy_of(&s, nested, shape)); + assert!(!node(&s, ishape).visible); + ex(&mut s, "node.set", json!({"id": button, "props": {"fill": "#00ff00"}})); + let inested = copy_of(&s, i, nested); + let ishape = copy_of(&s, inested, copy_of(&s, nested, shape)); + assert!(!node(&s, ishape).visible, "kept across main edits"); + healthy(&s); + // Un-exposing hides it again; one undo step each. + ex(&mut s, "component.exposeInstance", json!({"id": nested, "exposed": false})); + assert_eq!(exposed(&mut s), 0); + s.undo().unwrap(); + assert_eq!(exposed(&mut s), 1); + // Only nested instances inside a main can be exposed. + assert!(s.execute("component.exposeInstance", &json!({"id": i})).is_err()); + assert!(s.execute("component.exposeInstance", &json!({"id": inested})).is_err()); + assert!(s.execute("component.exposeInstance", &json!({"id": shape})).is_err()); +} diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 412d634..37def5b 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -339,7 +339,7 @@ fn group_label(g: &str) -> String { /// The instance section: main component + swap, variants, properties, actions. fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { let t = Tokens::get(ui.ctx()); - let NodeKind::Instance { component, props } = &n.kind else { return }; + let NodeKind::Instance { component, .. } = &n.kind else { return }; let id = n.id; let missing = !doc.contains(*component); let set = variant_set(doc, *component); @@ -411,6 +411,39 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { app.run("component.goToMain", json!({"id": id})); } }); + // A nested instance inside a main component can expose its properties on the main's instances. + if nested_in_main(doc, id) { + row(ui, 32.0, |ui| { + if widgets::checkbox(ui, n.exposed, "Expose properties to instances") { + app.run("component.exposeInstance", json!({"id": id, "exposed": !n.exposed})); + } + }); + } + instance_props(app, ui, doc, n, &comps); + // Exposed nested instances: their properties, under their names. + if !nested { + for c in figcraft_engine::components::exposed_copies(doc, id) { + let Some(cn) = doc.node(c) else { continue }; + row(ui, 28.0, |ui| { + let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); + icons::paint(ui.painter(), r, Icon::Instance, crate::theme::COMPONENT); + ui.label(egui::RichText::new(&cn.name).font(semibold(11.0)).color(t.text)); + }); + instance_props(app, ui, doc, cn, &comps); + } + } +} + +/// An instance placed inside a main component (not inside another instance). +fn nested_in_main(doc: &Document, id: NodeId) -> bool { + !inside_instance(doc, id) && figcraft_engine::components::owning_component(doc, id).is_some_and(|c| c != id) +} + +/// Variant and component property controls for instance `n`. +fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps: &[(NodeId, String)]) { + let NodeKind::Instance { component, props } = &n.kind else { return }; + let id = n.id; + let set = variant_set(doc, *component); // Variant properties. if let Some(set) = set { let current = doc.node(*component).map(|c| variant_props(&c.name)).unwrap_or_default(); @@ -458,7 +491,7 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); egui::Popup::menu(&resp).width(200.0).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (c, name) in &comps { + for (c, name) in comps { if menu_item(ui, name, "") { app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); ui.close(); diff --git a/docs/mcp.md b/docs/mcp.md index 6385c0c..bacb758 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -95,7 +95,7 @@ Or in a project's `.mcp.json`: | Tool | What it does | |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. | -| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `style.create`, `variable.bind`, `proto.interaction.add`). | +| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `style.create`, `variable.bind`, `proto.interaction.add`). | | `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), text…); returns the updated layers. |