app icon: regal engraved dragon (owner's ArtCraft drawing), Dock/taskbar/launcher wiring, palette docs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 09:45:30 -04:00
1 parent dabd179d5a
commit 437320509d
29 files changed
+321 -113

No files matched your search

-4
View File
@@ -7,7 +7,3 @@ publish = false
[dependencies]
serde_json = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-render = { workspace = true }
+3 -102
View File
@@ -1,79 +1,9 @@
//! `cargo xtask bundle`: build the release app and wrap it as `dist/VectorCraft.app` (macOS), with an
//! icon rendered by VectorCraft's own renderer.
//! `cargo xtask bundle`: build the release app and wrap it as `dist/VectorCraft.app` (macOS), with the
//! committed app icon `assets/app-icon/vectorcraft.icns` (regenerate it with `packaging/icons.sh`).
use std::path::Path;
use std::process::Command;
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath, shapes};
use vectorcraft_render::Renderer;
/// The app icon: a warm-gradient rounded square with a white pen nib (our own artwork).
pub fn icon_png(size: u32) -> Vec<u8> {
let s = 1024.0;
let mut d = Document::new(s, s);
let l = d.layers[0].id;
let grad = GradientPaint::new(Gradient {
kind: GradientKind::Linear,
stops: vec![
GradientStop { offset: 0.0, color: Color::from_hex("#ff9a3c").unwrap(), opacity: 1.0, midpoint: 0.5 },
GradientStop { offset: 1.0, color: Color::from_hex("#d4145a").unwrap(), opacity: 1.0, midpoint: 0.5 },
],
});
let mut gp = grad;
gp.angle = -45.0;
let bg = d.alloc_id();
d.insert(
Some(l),
9,
Node::path(
bg,
shapes::rounded_rectangle(Rect::new(100.0, 100.0, 924.0, 924.0), 185.0),
Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0),
),
)
.unwrap();
// Pen nib.
let c = Point::new(512.0, 520.0);
let nib = PathData::single(SubPath::new(
vec![
Anchor::corner(Point::new(c.x, c.y + 250.0)),
Anchor::with_handles(Point::new(c.x - 170.0, c.y - 20.0), Point::new(c.x - 150.0, c.y + 80.0), Point::new(c.x - 170.0, c.y - 90.0)),
Anchor::corner(Point::new(c.x - 95.0, c.y - 230.0)),
Anchor::corner(Point::new(c.x + 95.0, c.y - 230.0)),
Anchor::with_handles(Point::new(c.x + 170.0, c.y - 20.0), Point::new(c.x + 170.0, c.y - 90.0), Point::new(c.x + 150.0, c.y + 80.0)),
],
true,
));
let n = d.alloc_id();
d.insert(Some(l), 9, Node::path(n, nib, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0))).unwrap();
let hole = d.alloc_id();
d.insert(
Some(l),
9,
Node::path(
hole,
shapes::ellipse(Rect::from_center_size(Point::new(c.x, c.y - 30.0), (74.0, 74.0))),
Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0),
),
)
.unwrap();
d.node_mut(hole).unwrap().appearance = Appearance::basic(Paint::solid(Color::from_hex("#e8573f").unwrap()), Paint::None, 0.0);
let slit = d.alloc_id();
d.insert(
Some(l),
9,
Node::path(
slit,
shapes::rectangle(Rect::new(c.x - 9.0, c.y + 5.0, c.x + 9.0, c.y + 245.0)),
Appearance::basic(Paint::solid(Color::from_hex("#e0457a").unwrap()), Paint::None, 0.0),
),
)
.unwrap();
Renderer::new().render_region(&d, Rect::new(0.0, 0.0, s, s), size as f64 / s, false).to_png()
}
pub fn run(root: &Path) -> Result<(), String> {
let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.current_dir(root)
@@ -90,36 +20,7 @@ pub fn run(root: &Path) -> Result<(), String> {
std::fs::create_dir_all(app.join("Resources")).map_err(|e| e.to_string())?;
std::fs::copy(target.join("release/vectorcraft"), app.join("MacOS/VectorCraft")).map_err(|e| format!("copy app: {e}"))?;
std::fs::copy(target.join("release/vectorcraft-cli"), app.join("MacOS/vectorcraft-cli")).map_err(|e| format!("copy cli: {e}"))?;
// Icon set → .icns via iconutil (macOS build tool).
let iconset = root.join("dist/VectorCraft.iconset");
let _ = std::fs::remove_dir_all(&iconset);
std::fs::create_dir_all(&iconset).map_err(|e| e.to_string())?;
for (sz, name) in [
(16, "16x16"),
(32, "16x16@2x"),
(32, "32x32"),
(64, "32x32@2x"),
(128, "128x128"),
(256, "128x128@2x"),
(256, "256x256"),
(512, "256x256@2x"),
(512, "512x512"),
(1024, "512x512@2x"),
] {
std::fs::write(iconset.join(format!("icon_{name}.png")), icon_png(sz)).map_err(|e| e.to_string())?;
}
std::fs::write(root.join("dist/VectorCraft-icon.png"), icon_png(1024)).map_err(|e| e.to_string())?;
let ok = Command::new("iconutil")
.args(["-c", "icns"])
.arg(&iconset)
.arg("-o")
.arg(app.join("Resources/VectorCraft.icns"))
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
eprintln!("warning: iconutil failed or missing; bundle has no icon");
}
std::fs::copy(root.join("assets/app-icon/vectorcraft.icns"), app.join("Resources/VectorCraft.icns")).map_err(|e| format!("copy icon: {e}"))?;
let version = env!("CARGO_PKG_VERSION");
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
+64
View File
@@ -0,0 +1,64 @@
//! `cargo xtask ico <out.ico> <png>...`: pack square PNGs (each at most 256 px) into a Windows `.ico`.
//!
//! Every entry is stored as PNG data, which Windows Vista and later read at every size. Used by
//! `packaging/icons.sh`.
use std::path::Path;
/// Width and height from a PNG's IHDR chunk.
fn png_size(png: &[u8]) -> Result<(u32, u32), String> {
if png.len() < 24 || &png[..8] != b"\x89PNG\r\n\x1a\n" || &png[12..16] != b"IHDR" {
return Err("not a PNG".into());
}
let be = |i: usize| u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]);
Ok((be(16), be(20)))
}
/// Build the `.ico` bytes from PNG images.
pub fn pack(pngs: &[Vec<u8>]) -> Result<Vec<u8>, String> {
let count = u16::try_from(pngs.len()).map_err(|_| "too many images")?;
let mut out = Vec::new();
out.extend_from_slice(&[0, 0, 1, 0]);
out.extend_from_slice(&count.to_le_bytes());
let mut offset = 6 + 16 * pngs.len() as u32;
for png in pngs {
let (w, h) = png_size(png)?;
if w > 256 || h > 256 {
return Err(format!("{w}x{h} is larger than 256 px"));
}
// 256 is stored as 0.
out.push((w % 256) as u8);
out.push((h % 256) as u8);
out.extend_from_slice(&[0, 0]); // palette size, reserved
out.extend_from_slice(&1u16.to_le_bytes()); // colour planes
out.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
out.extend_from_slice(&(png.len() as u32).to_le_bytes());
out.extend_from_slice(&offset.to_le_bytes());
offset += png.len() as u32;
}
for png in pngs {
out.extend_from_slice(png);
}
Ok(out)
}
pub fn run(args: &[&str]) -> Result<(), String> {
let [out, inputs @ ..] = args else {
return Err("usage: cargo xtask ico <out.ico> <png>...".into());
};
if inputs.is_empty() {
return Err("usage: cargo xtask ico <out.ico> <png>...".into());
}
let pngs = inputs.iter().map(|p| std::fs::read(p).map_err(|e| format!("{p}: {e}"))).collect::<Result<Vec<_>, _>>()?;
std::fs::write(Path::new(out), pack(&pngs)?).map_err(|e| format!("{out}: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_non_png() {
assert!(pack(&[b"nope".to_vec()]).is_err());
}
}
+5 -1
View File
@@ -5,6 +5,7 @@
mod assets;
mod bundle;
mod ico;
mod layers;
mod stats;
@@ -21,7 +22,9 @@ commands:
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
bundle build dist/VectorCraft.app (macOS) with an icon rendered by VectorCraft
bundle build dist/VectorCraft.app (macOS) with assets/app-icon/vectorcraft.icns
ico <out.ico> <png>...
pack PNGs into a Windows .ico (used by packaging/icons.sh)
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
";
@@ -35,6 +38,7 @@ fn main() -> ExitCode {
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("bundle") => bundle::run(&root()),
Some("ico") => ico::run(&rest),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");