Files
figcraft/xtask/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

196 lines
6.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.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
//! - `bench` release-build timings at 1k/10k/50k layers (`bench 1000,10000` for other sizes)
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(),
"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, 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-engine", "-p", "figcraft-ui-egui", "-p", "figcraft-web"]))
}
/// 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;
let mut md = format!(
"# Parity report\n\nGenerated by `cargo xtask parity`: FigCraft 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 ROADMAP.md for depth and fidelity.\n");
let path = root().join("docs/parity.md");
if let Err(e) = std::fs::write(&path, md) {
eprintln!("{}: {e}", path.display());
return false;
}
eprintln!("parity: {done}/{total} ({pct:.1}%) → docs/parity.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
}
}
}