mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
56a312ba5f
commit
b3b48281bd
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user