M0.6-M0.7: egui UI3 frontend (canvas, toolbar, layers, design/prototype/inspect panels, menus, color picker, presenter), desktop app with control channel, xtask, generated app icon

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-09 13:03:43 -04:00
co-authored by Claude Opus 5.5
parent 56a312ba5f
commit b3b48281bd
47 changed files with 11675 additions and 16 deletions
+101
View File
@@ -0,0 +1,101 @@
//! The app icon, generated from code with FigCraft's own renderer (original artwork,
//! MIT OR Apache-2.0): a rounded tile in the app colour with a paper "frame" mark — three stacked
//! layer cards and a cursor. Writes assets/app-icon/*.png, the hicolor theme and figcraft.ico.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, NodeKind};
pub const APP_COLOR: &str = "0EA5C6";
const INK: &str = "0B0B0C";
const PAPER: &str = "EFE9DC";
fn build() -> Option<Document> {
let mut d = Document::new("icon");
let p = d.first_page()?;
let bg = d.create(p, NodeKind::Rectangle, "tile", 0.0, 0.0, 512.0, 512.0).ok()?;
let n = d.get_mut(bg).ok()?;
n.corners.radii = [112.0; 4];
n.corners.smoothing = 0.6;
n.fills = vec![Paint::solid(Color::from_hex(APP_COLOR)?)];
// Three stacked cards (layers), back to front.
for (i, (x, y, w, h)) in [(150.0, 96.0, 250.0, 200.0), (122.0, 150.0, 250.0, 200.0), (94.0, 204.0, 250.0, 200.0)].iter().enumerate() {
let c = d.create(p, NodeKind::Rectangle, "card", *x, *y, *w, *h).ok()?;
let n = d.get_mut(c).ok()?;
n.corners.radii = [28.0; 4];
n.corners.smoothing = 0.6;
n.fills = vec![Paint::solid(Color::from_hex(PAPER)?)];
n.strokes = vec![Paint::solid(Color::from_hex(INK)?)];
n.stroke.weight = 14.0;
n.stroke.align = figcraft_doc::StrokeAlign::Inside;
if i == 2 {
// Front card: a frame grid "#".
for (gx, gy, gw, gh) in [(150.0, 228.0, 14.0, 152.0), (250.0, 228.0, 14.0, 152.0), (118.0, 260.0, 178.0, 14.0), (118.0, 330.0, 178.0, 14.0)] {
let g = d.create(p, NodeKind::Rectangle, "grid", gx, gy, gw, gh).ok()?;
let gn = d.get_mut(g).ok()?;
gn.fills = vec![Paint::solid(Color::from_hex(INK)?)];
gn.corners.radii = [7.0; 4];
}
}
}
// Cursor.
let cur = figcraft_doc::VectorNetwork::from_points(&[(0.0, 0.0).into(), (0.0, 120.0).into(), (32.0, 92.0).into(), (52.0, 136.0).into(), (72.0, 128.0).into(), (52.0, 84.0).into(), (92.0, 82.0).into()], true);
let c = d.create(p, NodeKind::Vector { network: cur }, "cursor", 318.0, 300.0, 92.0, 136.0).ok()?;
let n = d.get_mut(c).ok()?;
n.fills = vec![Paint::solid(Color::from_hex(INK)?)];
n.strokes = vec![Paint::solid(Color::from_hex(PAPER)?)];
n.stroke.weight = 10.0;
n.stroke.align = figcraft_doc::StrokeAlign::Outside;
n.stroke.join = figcraft_doc::StrokeJoin::Round;
Some(d)
}
pub fn generate(root: &std::path::Path) -> bool {
let Some(doc) = build() else { return false };
let Some(page) = doc.first_page() else { return false };
let dir = root.join("assets/app-icon");
let _ = std::fs::create_dir_all(dir.join("hicolor"));
let render = |size: u32| -> Option<image::RgbaImage> {
let view = figcraft_render::View { origin: (0.0, 0.0), scale: f64::from(size) / 512.0, width: size, height: size };
let opts = figcraft_render::Options { background: Some(Color::TRANSPARENT), ..Default::default() };
let pix = figcraft_render::render_view(&doc, page, &view, &opts)?;
image::RgbaImage::from_raw(size, size, figcraft_render::to_rgba8(&pix))
};
let mut ok = true;
for size in [16u32, 32, 48, 64, 128, 256, 512, 1024] {
let Some(img) = render(size) else { return false };
let name = if size == 1024 { "figcraft-1024.png".to_string() } else { format!("figcraft-{size}.png") };
ok &= img.save(dir.join(&name)).is_ok();
if size <= 512 {
let hd = dir.join(format!("hicolor/{size}x{size}/apps"));
let _ = std::fs::create_dir_all(&hd);
ok &= img.save(hd.join("ai.storyteller.figcraft.png")).is_ok();
}
}
// Windows .ico (PNG-compressed entries).
let mut ico = Vec::new();
let sizes = [16u32, 32, 48, 256];
let pngs: Vec<Vec<u8>> = sizes
.iter()
.filter_map(|s| {
let img = render(*s)?;
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
})
.collect();
ico.extend_from_slice(&[0, 0, 1, 0, pngs.len() as u8, 0]);
let mut offset = 6 + 16 * pngs.len() as u32;
for (s, png) in sizes.iter().zip(&pngs) {
let b = if *s >= 256 { 0 } else { *s as u8 };
ico.extend_from_slice(&[b, b, 0, 0, 1, 0, 32, 0]);
ico.extend_from_slice(&(png.len() as u32).to_le_bytes());
ico.extend_from_slice(&offset.to_le_bytes());
offset += png.len() as u32;
}
for png in &pngs {
ico.extend_from_slice(png);
}
ok &= std::fs::write(dir.join("figcraft.ico"), ico).is_ok();
eprintln!("icon: wrote {}", dir.display());
ok
}
+63
View File
@@ -0,0 +1,63 @@
//! Crate layering: a crate may depend only on lower layers (or earlier crates of its own layer
//! listed in ORDER). Nothing below the UI depends on egui/eframe.
use std::collections::BTreeMap;
use std::process::Command;
/// (crate suffix, layer)
const TABLE: &[(&str, u8)] = &[
("geom", 0),
("color", 0),
("doc", 1),
("layout", 2),
("text", 2),
("pathops", 2),
("render", 3),
("format", 3),
("svg", 4),
("pdf", 4),
("engine", 5),
("ui-egui", 6),
("mcp", 6),
];
pub fn check(root: &std::path::Path) -> bool {
let out = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.args(["metadata", "--format-version", "1", "--no-deps"])
.current_dir(root)
.output();
let Ok(out) = out else { return false };
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else { return false };
let layer: BTreeMap<String, u8> = TABLE.iter().map(|(n, l)| (format!("figcraft-{n}"), *l)).collect();
let mut ok = true;
for pkg in v["packages"].as_array().cloned().unwrap_or_default() {
let name = pkg["name"].as_str().unwrap_or("").to_string();
let Some(&l) = layer.get(&name) else { continue };
for dep in pkg["dependencies"].as_array().cloned().unwrap_or_default() {
let dn = dep["name"].as_str().unwrap_or("");
if dep["kind"].as_str() == Some("dev") {
continue;
}
if l < 6 && (dn == "egui" || dn == "eframe" || dn == "rfd" || dn == "winit") {
eprintln!("layers: {name} (L{l}) depends on UI crate {dn}");
ok = false;
}
if let Some(&dl) = layer.get(dn)
&& dl >= l
&& !(dl == l && same_layer_ok(&name, dn))
{
eprintln!("layers: {name} (L{l}) may not depend on {dn} (L{dl})");
ok = false;
}
}
}
if ok {
eprintln!("layers: ok");
}
ok
}
fn same_layer_ok(from: &str, to: &str) -> bool {
// Within L0: color uses geom types; within L2: text/layout/pathops are independent.
matches!((from, to), ("figcraft-color", "figcraft-geom") | ("figcraft-render", "figcraft-format"))
}
+147 -1
View File
@@ -1 +1,147 @@
fn main() {}
//! 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
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(),
_ => {
eprintln!("tasks: ci, layers, assets, parity, version [set X.Y.Z], icon, wasm");
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"]))
}
/// 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;
}
if !attribution.contains(&rel) && !attribution.contains(&format!("{}/", rel.rsplit_once('/').map(|x| x.0).unwrap_or(""))) {
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
}
}
}