mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
dabd179d5a
commit
437320509d
29 files changed
+321
-113
No files matched your search
@@ -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
@@ -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"?>
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
|
||||
Reference in new issue
Block a user