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. |