M3: instance-swap properties in the Dev Mode component playground

This commit is contained in:
Marco Arnulfo
2026-10-23 03:28:55 +02:00
parent 80f916326f
commit af0b150736
5 changed files with 147 additions and 39 deletions
+52 -30
View File
@@ -88,7 +88,7 @@ fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str)
/// Local components (and component sets, not their variants), for swap menus: (id, label), page
/// by page. Listed once per document revision (a file with thousands of layers isn't walked every
/// frame).
fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc<Vec<(NodeId, String)>> {
pub(crate) fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc<Vec<(NodeId, String)>> {
type Cached = ((u64, u64), std::sync::Arc<Vec<(NodeId, String)>>);
let key = app.session.ds().map(|d| (d.uid, d.revision)).unwrap_or_default();
let id = Id::new("component_panel_local_components");
@@ -708,35 +708,9 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
});
}
PropDef::InstanceSwap { preferred, .. } => {
let cur = figcraft_engine::params::id_of(&v);
let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into());
let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance));
menu_popup(&resp).width(200.0).show(|ui| {
let t = Tokens::get(ui.ctx());
let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary));
let mut pick = |ui: &mut Ui, c: NodeId, name: &str| {
let current = cur == Some(c);
if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") {
if !current {
app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}}));
}
ui.close();
}
};
let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect();
if !preferred.is_empty() {
heading(ui, tr("Preferred"));
for (c, name) in &preferred {
pick(ui, *c, name);
}
heading(ui, tr("All components"));
}
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (c, name) in comps {
pick(ui, *c, name);
}
});
});
if let Some(c) = swap_dropdown(ui, doc, figcraft_engine::params::id_of(&v), &preferred, comps, w, "").1 {
app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}}));
}
}
PropDef::Variant { .. } => {}
PropDef::Slot { preferred } => {
@@ -749,6 +723,54 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
}
}
/// The dropdown of an instance-swap property showing component `cur`: its menu lists the
/// property's preferred components first (when it has any), then every component in the file. The
/// dropdown, and the component picked (picking the current one picks nothing). `name` labels it for
/// assistive technology.
pub(crate) fn swap_dropdown(
ui: &mut Ui,
doc: &Document,
cur: Option<NodeId>,
preferred: &[NodeId],
comps: &[(NodeId, String)],
w: f32,
name: &str,
) -> (egui::Response, Option<NodeId>) {
let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into());
let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance));
if !name.is_empty() {
crate::a11y::combo(&resp, name, &label, crate::a11y::menu_open(&resp));
}
let mut picked = None;
menu_popup(&resp).width(200.0).show(|ui| {
let t = Tokens::get(ui.ctx());
let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary));
let mut pick = |ui: &mut Ui, c: NodeId, name: &str| {
let current = cur == Some(c);
if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") {
if !current {
picked = Some(c);
}
ui.close();
}
};
let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect();
if !preferred.is_empty() {
heading(ui, tr("Preferred"));
for (c, name) in &preferred {
pick(ui, *c, name);
}
heading(ui, tr("All components"));
}
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (c, name) in comps {
pick(ui, *c, name);
}
});
});
(resp, picked)
}
fn prop_label(ui: &mut Ui, name: &str, icon: Icon) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(84.0, 20.0), Sense::hover());
+14 -4
View File
@@ -281,13 +281,14 @@ pub fn code(app: &mut FigApp, ui: &Ui, id: NodeId, lang: CodeLang) -> String {
}
/// The component playground, for a selected instance, component or component set: a preview of the
/// component and a control for each variant, boolean and text property. The values tried here
/// change the preview and the code only (returned when any is set: the Code section shows it),
/// never the document.
/// component and a control for each variant, boolean, text and instance-swap property. The values
/// tried here change the preview and the code only (returned when any is set: the Code section
/// shows it), never the document.
pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, lang: CodeLang) -> Option<String> {
figcraft_engine::playground::component_of(doc, id)?;
let t = Tokens::get(ui.ctx());
let Ok((uid, revision)) = app.session.ds().map(|d| (d.uid, d.revision)) else { return None };
let comps = crate::component_panel::local_components(app, ui, doc);
let play = &mut app.ui.dev.playground;
if play.target != Some(id) {
*play = Playground { target: Some(id), ..Playground::default() };
@@ -357,7 +358,16 @@ pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: Nod
set = Some((name.to_string(), json!(new.chars().take(10_000).collect::<String>())));
}
}),
// Instance swaps and slots show as the component has them.
"instanceSwap" => row(ui, 32.0, |ui| {
widgets::label(ui, name, t.text_secondary);
let preferred: Vec<NodeId> = p["preferred"].as_array().into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect();
let w = ui.available_width().max(60.0);
if let Some(c) = crate::component_panel::swap_dropdown(ui, doc, figcraft_engine::params::id_of(value), &preferred, &comps, w, name).1
{
set = Some((name.to_string(), json!(c.0)));
}
}),
// Slots show as the component has them.
_ => {}
}
}
+77 -2
View File
@@ -1,8 +1,10 @@
//! Dev Mode in the UI, driven headless (the harness is `present_tests`'): the compare view opens
//! from `dev.compare {show}`, switches to the overlay and closes with Escape before the focus view
//! does; the focus view's Escape; the annotation filter.
//! does; the focus view's Escape; the annotation filter; the playground's instance-swap menu.
use egui::{Key, pos2};
use egui::accesskit::{Node, Role};
use egui::{Event, Key, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
@@ -80,3 +82,76 @@ fn annotation_filter_and_categories_panel() {
assert_eq!(CategoryFilter::parse(&doc, "none"), Some(CategoryFilter::Uncategorized));
assert_eq!(CategoryFilter::parse(&doc, "nope"), None);
}
/// A "Button" component whose nested "Heart" instance is its instance-swap property "Icon", a
/// second icon "Star", and an instance of the button selected in the Inspect tab. Returns (ui,
/// instance, heart, star).
fn swap_setup() -> (crate::layers_drag_tests::Ui, NodeId, NodeId, NodeId) {
let mut s = Session::new();
let icon = |s: &mut Session, name: &str, x: i32| {
let f = id(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": 24, "height": 24, "name": name})).unwrap());
id(&s.execute("component.create", &json!({"ids": [f]})).unwrap())
};
let (heart, star) = (icon(&mut s, "Heart", 0), icon(&mut s, "Star", 40));
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 100, "width": 120, "height": 40, "name": "Button"})).unwrap());
let nested = id(&s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 108, "parent": f})).unwrap());
let button = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap());
s.execute("component.addProperty", &json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap();
let inst = id(&s.execute("instance.create", &json!({"component": button, "x": 0, "y": 300})).unwrap());
s.execute("select.set", &json!({"ids": [inst]})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Inspect;
u.ctx.enable_accesskit();
(u, inst, heart, star)
}
fn tree(u: &mut crate::layers_drag_tests::Ui) -> Vec<Node> {
u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect()
}
fn centre(n: &Node) -> Pos2 {
let b = n.bounds().unwrap();
pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
}
fn click(u: &mut crate::layers_drag_tests::Ui, at: Pos2) {
u.button(at, true);
u.button(at, false);
}
/// The playground's dropdown for property `name`, scrolled into view.
fn playground_combo(u: &mut crate::layers_drag_tests::Ui, name: &str) -> Option<Node> {
for _ in 0..20 {
let combo = tree(u).into_iter().find(|n| n.role() == Role::ComboBox && n.label() == Some(name))?;
if centre(&combo).y < 860.0 {
return Some(combo);
}
let wheel =
Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -100.0), phase: TouchPhase::Move, modifiers: Default::default() };
u.frame(vec![Event::PointerMoved(pos2(1300.0, 500.0)), wheel]);
for _ in 0..30 {
u.frame(vec![]);
}
}
None
}
#[test]
fn the_playground_swaps_an_instance_in_the_preview_only() {
let (mut u, inst, heart, star) = swap_setup();
let (doc, undo) = (u.app.session.ds().unwrap().doc.clone(), u.app.session.undo_depth());
let combo = playground_combo(&mut u, "Icon").expect("the playground has a dropdown for the instance-swap property");
assert_eq!(combo.value(), Some("Heart"));
// Open it and pick Star: the item that wasn't there before the menu opened.
let before: Vec<Pos2> = tree(&mut u).iter().filter(|n| n.label() == Some("Star")).map(centre).collect();
click(&mut u, centre(&combo));
let item = tree(&mut u).into_iter().find(|n| n.label() == Some("Star") && !before.contains(&centre(n))).expect("Star in the menu");
click(&mut u, centre(&item));
let combo = playground_combo(&mut u, "Icon").unwrap();
assert_eq!(combo.value(), Some("Star"), "the playground shows the swapped icon");
// The file isn't touched: the instance still has the heart, nothing to undo.
assert!(std::sync::Arc::ptr_eq(&doc, &u.app.session.ds().unwrap().doc));
assert_eq!(u.app.session.undo_depth(), undo);
let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap();
assert_eq!(props["properties"][0]["value"], json!(heart.0), "{star:?}");
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -29,7 +29,7 @@ accounts).
| Assets panel: folders by page, frame and slash names (fold away), thumbnails, search, drag into frames and auto layout slots | done | virtualized; `component.list` gives each one's `folder` |
| Hidden from publishing: a leading `.` or `_` in the name | done | the name decides both ways (adding the prefix takes a published component out, removing it brings it back); Hide when publishing in the Assets panel's context menu and Libraries ▸ Hide when publishing (see [ui-parity](ui-parity.md)) |
| Instances pasted into other files follow their mains | done | missing main restorable |
| Component playground (try properties without placing) | partial | variant, boolean and text properties in the Inspect tab and `component.playground`; instance swaps and slots have no control there yet (0.5–1 h) |
| Component playground (try properties without placing) | partial | variant, boolean, text and instance-swap properties in the Inspect tab (the swap menu lists the preferred components first, then every component in the file, as in the Design panel) and `component.playground`; slots have no control there yet |
| Real-world libraries QA | **not checked** | [gaps](gaps.md) #3 |
The behaviour of instances, overrides, variants and swaps is checked against the public
@@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. |
| 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. |
| 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged |
| 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently |
+1 -1
View File
@@ -104,7 +104,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 |
| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 |
| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 |
| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean, text and instance-swap properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
| 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 |
| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 |
| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 |