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 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 06:41:43 +07:00 committed by GitHub
parent 71c467879d
commit a85df4d5e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 120 additions and 17 deletions

View File

@ -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%).

View File

@ -30,22 +30,67 @@ pub struct Prop {
pub token: Option<String>,
}
pub fn specs() -> Vec<CommandSpec> {
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<String>,
}
/// 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<Asset> {
let mut out = Vec::new();
let mut stack: Vec<NodeId> = 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<CommandSpec> {
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.

View File

@ -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());
}

View File

@ -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<Vec<NodeId>> = 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::<usize>(lang_id)).and_then(|i| CodeLang::ALL.get(i).copied()).unwrap_or_default();

View File

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