M4: expose nested instances' properties on instances (#22)
- A nested instance inside a main component can be exposed (`component.exposeInstance`, an
"Expose properties to instances" toggle in its panel); the main's instances then list it by
name with its variant and component property controls, recursively for exposed instances
inside it. Values set there are nested overrides and survive main edits.
- `instance.exposed {id}` lists an instance's exposed nested copies (for the CLI and MCP).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c54509a574
commit
ff0f5a40d7
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -342,6 +342,9 @@ pub struct Node {
|
|||
/// Inside an instance: the node of the main component this one mirrors.
|
||||
#[serde(default)]
|
||||
pub source: Option<NodeId>,
|
||||
/// 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<String>,
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -77,6 +77,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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<serde_json::Value> =
|
||||
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!(
|
||||
|
|
|
|||
|
|
@ -821,3 +821,27 @@ pub fn mirror_issues(doc: &Document) -> Vec<String> {
|
|||
#[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<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<NodeId> = 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
Loading…
Reference in New Issue