mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 20:06:53 -06:00
Widgets for FigCraft's own WebAssembly plugins (plugin API 3; API 1 and 2 plugins run unchanged), plus the panel tail.
- **Widgets.** A manifest with `"api": 3, "widget": true` makes a widget plugin. Plugins > Widgets (or `widget.insert {plugin, x?, y?}`) puts its widget on the page: the plugin is called with `widget: {id, state, event}` and hands back a description through the new `widget_render` import (`{state?, root}`: frames with auto layout, text, rectangles, ellipses and images it carries as base64). FigCraft validates the description (4 MB, 256 layers, 16 deep, finite numbers within ranges, hex colours, text length and control characters, a 16 KB state, images 1 MB each / 2 MB together / 4096 px a side) and builds it as a frame whose layers are locked, so the widget moves as one layer.
- **Clicks.** A plain click with the Move tool on a part the plugin named as a `target` (the cursor turns into a pointing hand there; quick repeated clicks each count) calls the plugin back in a fresh, metered sandbox (same fuel, memory and call limits as runs; nothing kept between calls, so the state and the click alone decide the result) as one undo step, and the widget is redrawn. Dragging moves it; Shift/Cmd/Alt clicks only select. Agents use `widget.click {id, target}` and `widget.list`.
- **State in the document.** The plugin id and the state live in the widget's own layer data (`figcraft.widget*`), so widgets survive save and reload, duplicate/copy and paste (the copy is a widget of its own), undo/redo, export (the built layers draw) and live sessions (synced like any layer data). No plugin can write `figcraft.widget*` data or call `widget.*`; a callback can only rebuild its own widget, and touches other layers only through the commands its manifest permits. A widget whose plugin is missing stays as drawn.
- **Example.** `plugins/examples/counter`: a counter between - and + buttons (read access, no assets). The SDK gained `Input::widget` and `render_widget`.
- **Panel tail.** Panels of several plugins are open at once, side by side (`plugin.ui` without an id returns the one opened last); a colour field's swatch opens FigCraft's colour picker beside the plugin's window (the plugin hears of the colour when a drag ends; also for parameters forms). Swatch grid's palette panel gained a label colour field.
- Docs: plugins.md (ABI v3, widget description, commands, limits), mcp.md, target-app-parity area 18, ROADMAP, gaps gap 6, roadmap M11; Spanish labels for the new commands.
264 lines
9.9 KiB
Rust
264 lines
9.9 KiB
Rust
//! Project tasks: `cargo xtask <task>`.
|
|
//!
|
|
//! - `ci` fmt check, clippy -D warnings, tests, layers, assets, wasm check
|
|
//! - `layers` crate dependency layering check
|
|
//! - `assets` every file under assets/ and docs/ has a row in ATTRIBUTION.md
|
|
//! - `parity` menu-catalog coverage → docs/parity-checklist.md
|
|
//! - `version` print the version, or `version set X.Y.Z`
|
|
//! - `icon` regenerate the app icon PNGs/ICO from code (original artwork)
|
|
//! - `wasm` check the core crates and web app for wasm32, and build the plugin SDK and examples
|
|
//! - `plugins` format-check, lint, test and build the plugin SDK and example plugins (`plugins/`, a
|
|
//! workspace of its own) for wasm32: `<target>/plugins/dist/<example>/` holds each manifest and
|
|
//! module, ready for Plugins ▸ Development ▸ Import plugin from manifest…
|
|
//! - `bench` release-build timings at 1k/10k/50k layers (`bench 1000,10000` for other sizes) and
|
|
//! with a synthetic design system used by 2,000 instances
|
|
|
|
mod icon;
|
|
mod layers;
|
|
|
|
use std::process::{Command, ExitCode};
|
|
|
|
fn run(cmd: &mut Command) -> bool {
|
|
eprintln!("$ {cmd:?}");
|
|
cmd.status().map(|s| s.success()).unwrap_or(false)
|
|
}
|
|
|
|
fn cargo() -> Command {
|
|
Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
|
|
}
|
|
|
|
fn root() -> std::path::PathBuf {
|
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().map(|p| p.to_path_buf()).unwrap_or_default()
|
|
}
|
|
|
|
fn main() -> ExitCode {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let task = args.first().map(String::as_str).unwrap_or("help");
|
|
let ok = match task {
|
|
"ci" => ci(),
|
|
"layers" => layers::check(&root()),
|
|
"assets" => assets(),
|
|
"parity" => parity(),
|
|
"version" => version(args.get(1).map(String::as_str), args.get(2).map(String::as_str)),
|
|
"icon" => icon::generate(&root()),
|
|
"wasm" => wasm(),
|
|
"plugins" => plugins(),
|
|
"bench" => bench(args.get(1).map_or("1000,10000,50000", String::as_str)),
|
|
_ => {
|
|
eprintln!("tasks: ci, layers, assets, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]");
|
|
task == "help"
|
|
}
|
|
};
|
|
if ok { ExitCode::SUCCESS } else { ExitCode::FAILURE }
|
|
}
|
|
|
|
fn ci() -> bool {
|
|
run(cargo().args(["fmt", "--all", "--check"]))
|
|
&& run(cargo().args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))
|
|
&& run(cargo().args(["test", "--workspace"]))
|
|
&& layers::check(&root())
|
|
&& assets()
|
|
&& wasm()
|
|
}
|
|
|
|
fn wasm() -> bool {
|
|
run(cargo().args([
|
|
"check",
|
|
"--target",
|
|
"wasm32-unknown-unknown",
|
|
"-p",
|
|
"figcraft-collab",
|
|
"-p",
|
|
"figcraft-collab-net",
|
|
"-p",
|
|
"figcraft-engine",
|
|
"-p",
|
|
"figcraft-ui-egui",
|
|
"-p",
|
|
"figcraft-web",
|
|
])) && plugins()
|
|
}
|
|
|
|
/// The example plugins in `plugins/examples` (each folder's name is its package; its module is
|
|
/// `<name with _>.wasm`).
|
|
const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter"];
|
|
|
|
/// The plugin workspace: fmt, clippy and build for wasm32, host tests, then each example's manifest
|
|
/// and module side by side in `<target>/plugins/dist/<example>/`.
|
|
fn plugins() -> bool {
|
|
let root = root();
|
|
let manifest = root.join("plugins").join("Cargo.toml");
|
|
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("target")).join("plugins");
|
|
let ws = |args: &[&str]| {
|
|
let mut c = cargo();
|
|
c.args(args).arg("--manifest-path").arg(&manifest);
|
|
c
|
|
};
|
|
let wasm = ["--target", "wasm32-unknown-unknown", "--locked"];
|
|
let ok = run(&mut ws(&["fmt", "--all", "--check"]))
|
|
&& run(ws(&["clippy", "--workspace", "--release"]).args(wasm).arg("--target-dir").arg(&target).args(["--", "-D", "warnings"]))
|
|
&& run(ws(&["test", "--workspace", "--locked"]).arg("--target-dir").arg(&target))
|
|
&& run(ws(&["build", "--workspace", "--release"]).args(wasm).arg("--target-dir").arg(&target));
|
|
if !ok {
|
|
return false;
|
|
}
|
|
for name in EXAMPLE_PLUGINS {
|
|
let module = format!("{}.wasm", name.replace('-', "_"));
|
|
let dist = target.join("dist").join(name);
|
|
let copied = std::fs::create_dir_all(&dist)
|
|
.and_then(|()| std::fs::copy(root.join("plugins/examples").join(name).join("figcraft-plugin.json"), dist.join("figcraft-plugin.json")))
|
|
.and_then(|_| std::fs::copy(target.join("wasm32-unknown-unknown/release").join(&module), dist.join(&module)));
|
|
match copied {
|
|
Ok(bytes) => eprintln!("plugins: {} ({} KB)", dist.display(), bytes / 1024),
|
|
Err(e) => {
|
|
eprintln!("plugins: {name}: {e}");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Engine/render timings (`figcraft-cli perf`) and UI frame timings (an ignored test driving the
|
|
/// whole UI headless), release builds, for each comma-separated document size in layers.
|
|
fn bench(sizes: &str) -> bool {
|
|
run(cargo().args(["run", "--release", "-p", "figcraft-cli", "--", "perf", "--layers", sizes]))
|
|
&& run(cargo().env("FIGCRAFT_BENCH_LAYERS", sizes).args([
|
|
"test",
|
|
"--release",
|
|
"-p",
|
|
"figcraft-ui-egui",
|
|
"--lib",
|
|
"perf_ui_frames",
|
|
"--",
|
|
"--ignored",
|
|
"--nocapture",
|
|
]))
|
|
}
|
|
|
|
/// Every asset file must be listed in ATTRIBUTION.md.
|
|
fn assets() -> bool {
|
|
let root = root();
|
|
let attribution = std::fs::read_to_string(root.join("ATTRIBUTION.md")).unwrap_or_default();
|
|
let mut missing = Vec::new();
|
|
for dir in ["assets", "docs/brand", "docs/images"] {
|
|
walk(&root.join(dir), &mut |p| {
|
|
let rel = p.strip_prefix(&root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
|
let name = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
|
if name.starts_with('.') || name.ends_with(".md") || name.ends_with(".txt") {
|
|
return;
|
|
}
|
|
// Listed by path, or by any ancestor directory written as `dir/`.
|
|
let mut listed = attribution.contains(&format!("`{rel}`"));
|
|
let mut cur = rel.as_str();
|
|
while let Some((parent, _)) = cur.rsplit_once('/') {
|
|
if attribution.contains(&format!("`{parent}/`")) {
|
|
listed = true;
|
|
break;
|
|
}
|
|
cur = parent;
|
|
}
|
|
if !listed {
|
|
missing.push(rel);
|
|
}
|
|
});
|
|
}
|
|
if !missing.is_empty() {
|
|
eprintln!("assets without an ATTRIBUTION.md row:\n {}", missing.join("\n "));
|
|
return false;
|
|
}
|
|
eprintln!("assets: ok");
|
|
true
|
|
}
|
|
|
|
fn walk(dir: &std::path::Path, f: &mut dyn FnMut(&std::path::Path)) {
|
|
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
|
for e in rd.flatten() {
|
|
let p = e.path();
|
|
if p.is_dir() {
|
|
walk(&p, f);
|
|
} else {
|
|
f(&p);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parity() -> bool {
|
|
let extra = [
|
|
"file.open",
|
|
"file.import",
|
|
"file.placeImage",
|
|
"file.save",
|
|
"file.saveAs",
|
|
"file.export",
|
|
"file.exportSvg",
|
|
"edit.copyAsText",
|
|
"edit.copyAsCss",
|
|
"edit.copyAsSvg",
|
|
"edit.copyAsPng",
|
|
"edit.copyLink",
|
|
];
|
|
let (done, total, missing) = figcraft_engine::catalog::parity(&extra);
|
|
let pct = done as f64 * 100.0 / total.max(1) as f64;
|
|
// The generation date: the date of the last commit (no clock dependency in xtask).
|
|
let date = Command::new("git")
|
|
.args(["log", "-1", "--format=%cs"])
|
|
.current_dir(root())
|
|
.output()
|
|
.ok()
|
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
|
.map(|d| d.trim().to_string())
|
|
.unwrap_or_default();
|
|
let mut md = format!(
|
|
"# Parity checklist\n\n> **Generated:** {date} by `cargo xtask parity`. Do not edit by hand.\n\nFigCraft commands registered for the incumbent's main menu and toolbar ({} commands in the engine).\n\n**{done} / {total} menu items ({pct:.1}%)**\n\n",
|
|
figcraft_engine::all_commands().len()
|
|
);
|
|
if missing.is_empty() {
|
|
md.push_str("Every catalog item has a command.\n");
|
|
} else {
|
|
md.push_str("## Missing\n\n");
|
|
for m in &missing {
|
|
md.push_str(&format!("- `{m}`\n"));
|
|
}
|
|
}
|
|
md.push_str("\nMenu coverage measures breadth only; see docs/target-app-parity.md for depth and fidelity.\n");
|
|
let path = root().join("docs/parity-checklist.md");
|
|
if let Err(e) = std::fs::write(&path, md) {
|
|
eprintln!("{}: {e}", path.display());
|
|
return false;
|
|
}
|
|
eprintln!("parity: {done}/{total} ({pct:.1}%) → docs/parity-checklist.md");
|
|
true
|
|
}
|
|
|
|
fn version(sub: Option<&str>, value: Option<&str>) -> bool {
|
|
let path = root().join("Cargo.toml");
|
|
let Ok(text) = std::fs::read_to_string(&path) else { return false };
|
|
let cur = text
|
|
.lines()
|
|
.skip_while(|l| !l.starts_with("[workspace.package]"))
|
|
.find_map(|l| l.strip_prefix("version = \"").and_then(|r| r.strip_suffix('"')))
|
|
.unwrap_or("0.0.0")
|
|
.to_string();
|
|
match (sub, value) {
|
|
(Some("set"), Some(v)) => {
|
|
let valid =
|
|
v.split(['-', '+']).next().is_some_and(|core| core.split('.').count() == 3 && core.split('.').all(|p| p.parse::<u64>().is_ok()));
|
|
if !valid {
|
|
eprintln!("version must look like 1.2.3 or 1.2.3-rc.1");
|
|
return false;
|
|
}
|
|
let new = text.replacen(&format!("version = \"{cur}\""), &format!("version = \"{v}\""), 1);
|
|
if std::fs::write(&path, new).is_err() {
|
|
return false;
|
|
}
|
|
eprintln!("version {cur} → {v}");
|
|
run(cargo().args(["update", "--workspace"]))
|
|
}
|
|
_ => {
|
|
println!("{cur}");
|
|
true
|
|
}
|
|
}
|
|
}
|