From a85df4d5e56ca6adb336d6746e3140cc43f9b178 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 06:41:43 +0700 Subject: [PATCH] M6: Inspect tab: assets list with export (#47) The Inspect tab lists the layers in and under the selection that have export settings, with their formats and sizes. Click one to export it, or Export all from the section header (one file through a save dialog, several into a folder). New command inspect.assets. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/devinspect.rs | 75 +++++++++++++++++++++------ crates/engine/src/devinspect_tests.rs | 20 +++++++ crates/ui-egui/src/right.rs | 38 ++++++++++++++ docs/mcp.md | 2 +- 5 files changed, 120 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fc4d9fb..b554fcd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -168,7 +168,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Styles & variables | 5 | 72% | binding effects, line height and letter spacing, variable groups in the editor sidebar | 3–5 | | Prototyping | 6 | 70% | scrolling inside device frames, prototype preview pane in the editor | 3–5 | | Import/export | 6 | 58% | PDF (in progress), .fig import, REST import of conditional and variable-mode prototype actions | 13–25 | -| Dev mode / inspect | 2 | 65% | asset export list in Inspect, code for whole frames as components | 3–4 | +| Dev mode / inspect | 2 | 72% | code for whole frames as components | 1–3 | | Comments, versions, misc | 3 | 45% | comment polish, plugins (WASM) | 9–15 | Weighted feature depth ≈ **58%** (self-graded; the table's arithmetic gives ~59%). diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index ea2c82d..9cced37 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -30,22 +30,67 @@ pub struct Prop { pub token: Option, } -pub fn specs() -> Vec { - vec![spec!( - "inspect.properties", - "Inspect properties", - "{id?} → {sections: [{title, rows: [{label, value, color?, token?}]}]} layout (relative to the parent), auto layout, typography, fills, strokes and effects, with style and variable names", - false, - |s, p| { - let id = match id(p, "id") { - Some(i) => i, - None => s.selection().first().copied().ok_or_else(|| crate::bad("inspect.properties", "select a layer or pass `id`"))?, - }; - let doc = s.doc()?; - doc.node(id).ok_or_else(|| crate::bad("inspect.properties", "no such layer"))?; - Ok(json!({"sections": serde_json::to_value(properties(doc, id)).unwrap_or(Value::Null)})) +/// A layer in the inspected selection that has export settings. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Asset { + pub id: NodeId, + pub name: String, + /// Its export settings as shown ("PNG 2x", "SVG 1x"…). + pub exports: Vec, +} + +/// Most assets listed for one selection. +const MAX_ASSETS: usize = 200; + +/// The layers in and under `ids` that have export settings, in layer order. +pub fn assets(doc: &Document, ids: &[NodeId]) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = ids.iter().rev().copied().collect(); + // Bounded: the Inspect tab asks every frame. + let mut visited = 0usize; + while let Some(id) = stack.pop() { + visited += 1; + if out.len() >= MAX_ASSETS || visited > 20_000 { + break; } - )] + let Some(n) = doc.node(id) else { continue }; + if !n.exports.is_empty() && !out.iter().any(|a: &Asset| a.id == id) { + let exports = n.exports.iter().map(|e| format!("{} {}", e.format.label(), e.size.label())).collect(); + out.push(Asset { id, name: n.name.clone(), exports }); + } + stack.extend(n.children.iter().rev().copied()); + } + out +} + +pub fn specs() -> Vec { + vec![ + spec!( + "inspect.assets", + "Inspect assets", + "{ids? (default the selection)} → {assets: [{id, name, exports}]} the layers in and under them with export settings (export them with `file.exportLayers`)", + false, + |s, p| { + let ids = targets(s, p); + Ok(json!({"assets": serde_json::to_value(assets(s.doc()?, &ids)).unwrap_or(Value::Null)})) + } + ), + spec!( + "inspect.properties", + "Inspect properties", + "{id?} → {sections: [{title, rows: [{label, value, color?, token?}]}]} layout (relative to the parent), auto layout, typography, fills, strokes and effects, with style and variable names", + false, + |s, p| { + let id = match id(p, "id") { + Some(i) => i, + None => s.selection().first().copied().ok_or_else(|| crate::bad("inspect.properties", "select a layer or pass `id`"))?, + }; + let doc = s.doc()?; + doc.node(id).ok_or_else(|| crate::bad("inspect.properties", "no such layer"))?; + Ok(json!({"sections": serde_json::to_value(properties(doc, id)).unwrap_or(Value::Null)})) + } + ), + ] } /// A number as a designer reads it: at most two decimals, no trailing zeros. diff --git a/crates/engine/src/devinspect_tests.rs b/crates/engine/src/devinspect_tests.rs index b10a9c2..be25e02 100644 --- a/crates/engine/src/devinspect_tests.rs +++ b/crates/engine/src/devinspect_tests.rs @@ -82,3 +82,23 @@ fn redlines_measure_on_hover_without_alt() { s.tool.redlines = true; assert!(hover(&mut s) > 0, "Inspect measures on hover"); } + +#[test] +fn assets_list_layers_with_export_settings_under_the_selection() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let icon = id(s.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 20, "height": 20, "parent": f})).unwrap()); + s.execute("shape.rectangle", &json!({"x": 40, "y": 10, "width": 20, "height": 20, "parent": f})).unwrap(); + s.execute("export.add", &json!({"ids": [icon]})).unwrap(); + s.execute("export.add", &json!({"ids": [icon]})).unwrap(); + let doc = s.doc().unwrap(); + let a = crate::devinspect::assets(doc, &[f]); + assert_eq!(a.len(), 1, "only the layer with settings"); + assert_eq!(a[0].id, icon); + assert_eq!(a[0].exports.len(), 2); + assert!(a[0].exports[0].ends_with("1x"), "{:?}", a[0].exports); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let out = s.execute("inspect.assets", &json!({})).unwrap(); + assert_eq!(out["assets"][0]["name"], "Ellipse"); + assert!(crate::devinspect::assets(s.doc().unwrap(), &[]).is_empty()); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index cc55bb7..0bf0ced 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -1344,6 +1344,43 @@ fn inspect_section(app: &mut FigApp, ui: &mut Ui, s: &figcraft_engine::devinspec ui.add_space(4.0); } +/// The selection's layers with export settings: click one to export it, or export them all. +fn inspect_assets(app: &mut FigApp, ui: &mut Ui, assets: &[figcraft_engine::devinspect::Asset]) { + let t = Tokens::get(ui.ctx()); + widgets::separator(ui); + let all = widgets::section_header(ui, "Assets", if assets.is_empty() { &[] } else { &[(Icon::Export, "Export all")] }, false).is_some(); + if assets.is_empty() { + row(ui, 28.0, |ui| widgets::label(ui, "No export settings. Add them in the Design tab.", t.text_secondary)); + return; + } + let mut export: Option> = all.then(|| assets.iter().map(|a| a.id).collect()); + for a in assets { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + let resp = resp.on_hover_text(format!("Export {}", a.name)); + if resp.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - PAD - 6.0, r.center().y), vec2(12.0, 12.0)), Icon::Export, t.icon); + } + let name_r = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y), pos2(r.min.x + 96.0 - 4.0, r.max.y)); + ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x, r.center().y), Align2::LEFT_CENTER, &a.name, regular(11.0), t.text); + let fmt_r = Rect::from_min_max(pos2(r.min.x + 96.0, r.min.y), pos2(r.max.x - PAD - 16.0, r.max.y)); + ui.painter().with_clip_rect(fmt_r).text( + pos2(fmt_r.min.x, r.center().y), + Align2::LEFT_CENTER, + a.exports.join(", "), + regular(11.0), + t.text_secondary, + ); + if resp.clicked() { + export = Some(vec![a.id]); + } + } + if let Some(ids) = export { + crate::export_panel::export_layers(app, &ids); + } + ui.add_space(4.0); +} + fn inspect(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Ok(ds) = app.session.ds() else { return }; @@ -1360,6 +1397,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) { for s in figcraft_engine::devinspect::properties(&doc, first) { inspect_section(app, ui, &s); } + inspect_assets(app, ui, &figcraft_engine::devinspect::assets(&doc, &sel)); use figcraft_engine::codegen::{CodeLang, code_for}; let lang_id = Id::new("inspect_lang"); let lang = ui.data(|d| d.get_temp::(lang_id)).and_then(|i| CodeLang::ALL.get(i).copied()).unwrap_or_default(); diff --git a/docs/mcp.md b/docs/mcp.md index 90caba0..cd2a5fc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -100,7 +100,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), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `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), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `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`), handoff status (`devStatus: none\|readyForDev\|completed`), text…); returns the updated layers. |