Files
figcraft/apps/figcraft-cli/src/main.rs
T
BFlatandClaude Opus 5.5 a5d5c265fd M7: performance: retained canvas renders, virtual layers panel, shared text layouts, cargo xtask bench (#35)
Large documents stay interactive: the canvas re-renders only what a pan uncovers or an edit
touches, the layers panel lays out only the rows in view, and per-frame work no longer walks the
whole page.

- Benchmark: `cargo xtask bench [SIZES]` builds synthetic pages (`figcraft_engine::synthetic`:
  screens with an auto layout header, a card group, button instances and shapes/text) at
  1k/10k/50k layers and prints engine/render timings (`figcraft-cli perf --layers 1000,10000`) and
  headless whole-UI frame timings (an ignored ui-egui test).
- Retained canvas rendering (`figcraft_render::Retained`, used by the render worker): views snap
  to whole device pixels, so a pan shifts the previous image in place and renders only the
  uncovered strips; an edit renders only where changed layers were and are
  (`Document::changed_ids` diffs the two versions skipping shared structure; ancestors that paint
  from their children's pixels - booleans, effects, masks - count whole; background blur reach
  and noise are allowed for). Zoom, page, options, font or page-level changes, or large changes,
  render the whole view as before. `ui.inspect` reports `canvas.renderWork`.
- The texture image is converted on the render thread, not the UI thread.
- Layers panel: only visible rows are laid out (stable ids while scrolling); rows are cached per
  document version, page, expanded set and filter.
- Frame labels and layout grids of off-screen top-level frames are skipped; the Prototype tab's
  connection sources are cached per document version.
- Text: the layout cache keys by a cheap fingerprint confirmed by `TextData` equality (no Debug
  formatting per lookup, 32k entries) and hands out shared layouts; a layout generation marks
  renders stale when fonts arrive. Overflowing text is culled by its glyphs, so it no longer
  vanishes while its box is off screen.

Before -> after (release, Windows, 1440x900 view, ms; 10k / 50k layers):
- move one layer, re-render @100%:     7.65 / 6.49  -> 0.18 / 0.81
- move one layer, re-render zoomed out: 11.95 / 24.10 -> 0.16 / 0.77
- pan 40 px @100%:                     7.53 / 6.82  -> 0.69 / 0.81
- pan 40 px zoomed out:                12.07 / 24.30 -> 1.62 / 4.17
- full render @100% / zoomed to fit:   7.78 / 44.22 -> 6.56 / 38.83 (10k @100%, 50k fit)
- UI frame, every screen expanded in layers: 6.16 / 39.07 -> 0.24 / 0.57
- UI frame, scrolling that panel:      6.11 / 40.18 -> 0.43 / 0.40
- UI frame, layers filtered by name:   1.03 / 6.92  -> 0.17 / 0.36
- UI frame, Prototype tab:             0.89 / 6.45  -> 0.18 / 0.80
- UI frame, idle:                      0.31 / 1.43  -> 0.11 / 0.39
Unchanged and within budget: single-layer edits through the engine ~0.01 ms, normalize after a
one-node edit < 0.01 ms, hit test 26 / 93 us. Left: save/load at 50k (~0.7 s each), zoomed-out
renders at 50k (~40 ms).

Tests: retained renders match full renders after pans, moves, recolours, hide/remove/add, undo,
masks, overflowing text, edits under a background blur and an inner-shadow/noise layer, a drop-
shadowed group, and a blur panned in from the view's edge; zoom/page/options/page background
render full; junk views don't panic. Layer-row and prototype-source caches follow edits, undo,
page switches, expansion and the filter. `changed_ids`, the text cache, and the perf CLI.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:19:05 +07:00

173 lines
7.0 KiB
Rust

//! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run
//! command scripts, list commands, serve MCP on stdio and measure performance.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod args;
mod files;
mod perf;
use std::io::Write;
use std::process::ExitCode;
use args::Args;
use serde_json::{Value, json};
pub const USAGE: &str = "figcraft-cli — FigCraft without a window
USAGE:
figcraft-cli info <file> [--json]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME]
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
figcraft-cli commands [--json] [--filter TEXT]
figcraft-cli mcp [--attach PORT]
figcraft-cli perf [--layers N[,N...]]
figcraft-cli help | --help | --version
Files: .figcraft (native), .json (FigCraft JSON), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image
layer). `export` writes one file per top-level frame into the <out> directory, or one file when <out>
has an image extension or --node is given. `run` executes a JSON array (or JSON lines) of
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
See docs/cli.md and docs/mcp.md.";
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
match run(&argv) {
Ok(code) => code,
Err(CliError::Usage(msg)) => {
eprintln!("error: {msg}\n\n{USAGE}");
ExitCode::from(2)
}
Err(CliError::Failed(msg)) => {
eprintln!("error: {msg}");
ExitCode::from(1)
}
}
}
#[derive(Debug)]
pub enum CliError {
/// Bad arguments: usage is printed, exit code 2.
Usage(String),
/// The operation failed: exit code 1.
Failed(String),
}
impl From<String> for CliError {
fn from(s: String) -> Self {
CliError::Failed(s)
}
}
pub type CliResult<T> = Result<T, CliError>;
fn run(argv: &[String]) -> CliResult<ExitCode> {
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
match cmd.as_str() {
"help" | "--help" | "-h" => {
println!("{USAGE}");
Ok(ExitCode::SUCCESS)
}
"--version" | "-V" | "version" => {
println!("figcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(ExitCode::SUCCESS)
}
"info" => {
let a = Args::parse(rest, &[], &["json"])?;
let file = a.one_positional("info <file>")?;
files::info(&file, a.flag("json"))
}
"export" => {
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out"], &[])?;
let file = a.one_positional("export <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
files::export(&file, &out, a.value("format").as_deref(), a.scale()?, a.value("page").as_deref(), a.value("node").as_deref())
}
"convert" => {
let a = Args::parse(rest, &[], &[])?;
match a.positional.as_slice() {
[i, o] => files::convert(i, o),
_ => Err(CliError::Usage("convert <in> <out>".into())),
}
}
"render" => {
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
let file = a.one_positional("render <file> -o <out.png>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
}
"run" => {
let a = Args::parse(rest, &["in", "out"], &[])?;
let script = a.one_positional("run <script.json|.jsonl>")?;
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
}
"commands" => {
let a = Args::parse(rest, &["filter"], &["json"])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("commands takes no positional arguments".into()));
}
commands(a.flag("json"), a.value("filter").as_deref());
Ok(ExitCode::SUCCESS)
}
"mcp" => {
let a = Args::parse(rest, &["attach"], &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
mcp(a.value("attach").as_deref())
}
"perf" => {
let a = Args::parse(rest, &["layers"], &[])?;
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
perf::run(&sizes)
}
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
}
}
fn commands(as_json: bool, filter: Option<&str>) {
let f = filter.map(str::to_lowercase).unwrap_or_default();
let list: Vec<&figcraft_engine::CommandSpec> = figcraft_engine::all_commands()
.iter()
.filter(|c| f.is_empty() || c.id.to_lowercase().contains(&f) || c.label.to_lowercase().contains(&f) || c.params.to_lowercase().contains(&f))
.collect();
let stdout = std::io::stdout();
let mut out = stdout.lock();
if as_json {
let v: Vec<Value> = list
.iter()
.map(|c| json!({"id": c.id, "label": c.label, "params": c.params, "menu": c.menu(), "shortcut": c.shortcut(), "undoable": c.history}))
.collect();
let _ = writeln!(out, "{}", serde_json::to_string_pretty(&v).unwrap_or_default());
return;
}
for c in list {
let sc = c.shortcut().map(|s| format!(" ({s})")).unwrap_or_default();
let undo = if c.history { "*" } else { "" };
let _ = writeln!(out, "{}{undo} {}{sc}\n {}", c.id, c.label, c.params);
}
}
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
Some(p) => {
let addr = if p.contains(':') {
p.to_string()
} else {
format!("127.0.0.1:{}", p.parse::<u16>().map_err(|_| CliError::Usage(format!("--attach needs a port number, got `{p}`")))?)
};
let b = figcraft_mcp::Attached::connect(&addr)
.map_err(|e| format!("can't reach the FigCraft app at {addr}: {e} (start it with `figcraft --control PORT`)"))?;
Box::new(b)
}
None => Box::new(figcraft_mcp::Headless::new()),
};
eprintln!("figcraft MCP server ({}) on stdio", backend.describe());
let mut server = figcraft_mcp::Server::new(backend);
let stdin = std::io::stdin();
let stdout = std::io::stdout();
server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?;
Ok(ExitCode::SUCCESS)
}