diff --git a/Cargo.lock b/Cargo.lock index 7836e5b29..0753fc7d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,6 +923,7 @@ dependencies = [ "ureq", "wgpu", "winit", + "winresource", "zip", ] @@ -2347,6 +2348,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "sha1" version = "0.10.7" @@ -2664,6 +2674,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2694,6 +2719,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "tracing" version = "0.1.44" @@ -3559,6 +3590,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -3649,6 +3690,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" version = "0.1.0" +dependencies = [ + "horizon-theme", +] [[package]] name = "xxhash-rust" diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index e0b3d3189..12aeaae4a 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -34,6 +34,10 @@ gilrs = "0.11" # HTTPS to the Minecraft services (online-mode session join). ureq = { version = "2", default-features = false, features = ["tls"] } +[target.'cfg(windows)'.build-dependencies] +# The icon resource in the .exe (build.rs). +winresource = "0.1" + [features] # Records the call sites of mob random draws (`mob_ai_probe` traces). rng-trace = ["horizon-core/rng-trace"] diff --git a/crates/horizon-client/build.rs b/crates/horizon-client/build.rs new file mode 100644 index 000000000..d99b304f3 --- /dev/null +++ b/crates/horizon-client/build.rs @@ -0,0 +1,15 @@ +//! Embeds Horizon's icon (`packaging/icons/horizon.ico`) in the Windows executables, as icon +//! resource 1: Explorer and the task bar show it, and the window takes it as its icon. +fn main() { + let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let ico = format!("{dir}/../../packaging/icons/horizon.ico"); + println!("cargo:rerun-if-changed={ico}"); + #[cfg(windows)] + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + let mut resource = winresource::WindowsResource::new(); + resource.set_icon(&ico); + if let Err(e) = resource.compile() { + panic!("embedding {ico}: {e}"); + } + } +} diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index e3793580a..0d9c394e4 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -8199,9 +8199,16 @@ impl ApplicationHandler for Game<'_> { // The Window constructor: created fullscreen when options.txt says so. let fullscreen = self.settings.fullscreen; self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen); + let attributes = Window::default_attributes() + .with_title("Horizon") + .with_window_icon(horizon_client::window::icon()); + // Wayland names the window by its app id, which the horizon.desktop entry matches. + #[cfg(target_os = "linux")] + let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name( + attributes, "horizon", "horizon", + ); let window = match event_loop.create_window( - Window::default_attributes() - .with_title("Horizon") + attributes .with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0)) .with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))), ) { diff --git a/crates/horizon-client/src/window.rs b/crates/horizon-client/src/window.rs index 3dbb74a21..7c77d85c6 100644 --- a/crates/horizon-client/src/window.rs +++ b/crates/horizon-client/src/window.rs @@ -307,10 +307,30 @@ impl FullscreenState { } } +/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes +/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read. +pub fn icon() -> Option { + let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG)); + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); + let mut reader = decoder.read_info().ok()?; + let mut bytes = vec![0; reader.output_buffer_size()?]; + let info = reader.next_frame(&mut bytes).ok()?; + if info.color_type != png::ColorType::Rgba { + return None; + } + bytes.truncate(info.buffer_size()); + winit::window::Icon::from_rgba(bytes, info.width, info.height).ok() +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn the_window_icon_decodes() { + assert!(icon().is_some()); + } + #[test] fn fullscreen_follows_the_option_only_when_it_changes() { let mut f = FullscreenState::new(false); diff --git a/crates/horizon-theme/icons/horizon-64.png b/crates/horizon-theme/icons/horizon-64.png new file mode 100644 index 000000000..1add8b2d6 Binary files /dev/null and b/crates/horizon-theme/icons/horizon-64.png differ diff --git a/crates/horizon-theme/src/lib.rs b/crates/horizon-theme/src/lib.rs index 3ed778c7f..ced8731c1 100644 --- a/crates/horizon-theme/src/lib.rs +++ b/crates/horizon-theme/src/lib.rs @@ -20,6 +20,10 @@ pub mod fonts { pub const GEIST_MONO: &[u8] = include_bytes!("../fonts/GeistMono-Regular.ttf"); } +/// Horizon's icon at 64 px (PNG), for window title bars and task bars at run time. Rendered from +/// `docs/media/brand/icon.svg` by `cargo run -p xtask -- icons`. +pub const ICON_PNG: &[u8] = include_bytes!("../icons/horizon-64.png"); + /// How a type step sizes on the web. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Web { diff --git a/docs/media/brand/README.md b/docs/media/brand/README.md new file mode 100644 index 000000000..a453f4470 --- /dev/null +++ b/docs/media/brand/README.md @@ -0,0 +1,54 @@ +# Horizon's mark and icons + +**The mark.** An H whose crossbar is the horizon: the line runs edge to edge in Horizon's violet, +the stems above it in full strength and their reflection below at a third. It is drawn on whole +units of a 16-unit grid, so it is sharp at 16 px and holds at 1024 px. `docs/design-system.md` +places it in the design system; `candidates/` keeps the two alternatives that were shown with it. + +## Masters (edit these) + +| File | What | +|------|------| +| `icon.svg` | the app icon on the 16 grid: the full square with 22 % corners (Windows, Linux, favicons) | +| `icon-macos.svg` | the same tile on Apple's 1024 grid (824 px tile, 185 px corners, transparent margin) | +| `avatar.svg` | full-bleed square for services that round or crop the image (GitHub, Discord) | +| `avatar-kira.svg` | the bot's avatar: the mark on violet, so Kira reads apart from the project | +| `mark.svg`, `mark-dark.svg` | the mark alone, for light and for dark backgrounds | +| `social/card.html`, `social/banner.html` | the GitHub social preview and the Discord banner, as pages | + +## Generated (don't edit) + +`cargo run -p xtask -- icons` (needs `rsvg-convert` from librsvg) renders the masters into: + +| Output | Used by | +|--------|---------| +| `packaging/icons/hicolor//apps/horizon.png` (16–512) and `scalable/apps/horizon.svg` | Linux: copy the tree into `~/.local/share/icons/hicolor` (or `/usr/share/icons/hicolor`) | +| `packaging/linux/horizon-launcher.desktop`, `horizon.desktop` (hand-written) | Linux: `~/.local/share/applications`; Wayland matches the windows' app ids `horizon-launcher` and `horizon` | +| `packaging/icons/horizon.ico` | Windows: embedded in `horizon-client.exe`, its tools and `horizon-launcher.exe` as icon resource 1 (`build.rs` of each, Windows builds only) | +| `packaging/icons/horizon.icns` | macOS: an app bundle's `Contents/Resources/horizon.icns` (`CFBundleIconFile`) | +| `crates/horizon-theme/icons/horizon-64.png` | the game window's icon at run time (X11 and Windows title bars, `window::icon`) | +| `website/public/favicon.svg`, `favicon-32.png`, `apple-touch-icon.png` | the website | +| `icon-1024.png`, `icon-macos-1024.png` | previews, store listings | +| `social/github-avatar-500.png` | GitHub: the organisation or account avatar | +| `social/discord-icon-512.png` | Discord: the server icon | +| `social/kira-avatar-512.png` | Discord: Kira's avatar | + +The two pages in `social/` are screenshots, at their exact size, with Playwright from the +website's dev dependencies: + +```sh +cd website && npm install +node -e 'const {chromium}=require("playwright");(async()=>{const b=await chromium.launch();for(const[f,w,h,o]of[["card",1280,640,"github-social-1280x640"],["banner",960,540,"discord-banner-960x540"]]){const p=await b.newPage({viewport:{width:w,height:h}});await p.goto("file://"+require("path").resolve("../docs/media/brand/social/"+f+".html"));await p.waitForTimeout(300);await p.screenshot({path:"../docs/media/brand/social/"+o+".png"})}await b.close()})()' +``` + +## Uploading (the accounts are Ludovic's) + +- **GitHub avatar**: github.com/settings/profile (or the organisation's settings), *Edit* the + picture, upload `social/github-avatar-500.png`. +- **GitHub social preview**: the repository's *Settings › General › Social preview › Edit*, + upload `social/github-social-1280x640.png`. +- **Discord server icon and banner**: *Server Settings › Server Profile*: icon + `social/discord-icon-512.png`; banner `social/discord-banner-960x540.png` (the banner needs + boost level 2). +- **Kira's avatar**: discord.com/developers/applications › Kira › *Bot* › icon, upload + `social/kira-avatar-512.png` (or *General Information › App Icon* for the application too). diff --git a/docs/media/brand/avatar-kira.svg b/docs/media/brand/avatar-kira.svg new file mode 100644 index 000000000..cf0d34697 --- /dev/null +++ b/docs/media/brand/avatar-kira.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/media/brand/avatar.svg b/docs/media/brand/avatar.svg new file mode 100644 index 000000000..5645fb98f --- /dev/null +++ b/docs/media/brand/avatar.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/media/brand/candidates/a-sun-slices.svg b/docs/media/brand/candidates/a-sun-slices.svg new file mode 100644 index 000000000..b21591ff7 --- /dev/null +++ b/docs/media/brand/candidates/a-sun-slices.svg @@ -0,0 +1 @@ + diff --git a/docs/media/brand/candidates/b-horizon-h.svg b/docs/media/brand/candidates/b-horizon-h.svg new file mode 100644 index 000000000..94fb593fc --- /dev/null +++ b/docs/media/brand/candidates/b-horizon-h.svg @@ -0,0 +1,10 @@ + + Horizon + + + + + + + + diff --git a/docs/media/brand/candidates/c-sunrise.svg b/docs/media/brand/candidates/c-sunrise.svg new file mode 100644 index 000000000..3e53422f5 --- /dev/null +++ b/docs/media/brand/candidates/c-sunrise.svg @@ -0,0 +1 @@ + diff --git a/docs/media/brand/icon-1024.png b/docs/media/brand/icon-1024.png new file mode 100644 index 000000000..913c93439 Binary files /dev/null and b/docs/media/brand/icon-1024.png differ diff --git a/docs/media/brand/icon-macos-1024.png b/docs/media/brand/icon-macos-1024.png new file mode 100644 index 000000000..439572b96 Binary files /dev/null and b/docs/media/brand/icon-macos-1024.png differ diff --git a/docs/media/brand/icon-macos.svg b/docs/media/brand/icon-macos.svg new file mode 100644 index 000000000..174ecc778 --- /dev/null +++ b/docs/media/brand/icon-macos.svg @@ -0,0 +1,12 @@ + + Horizon + + + + + + + + + + diff --git a/docs/media/brand/icon.svg b/docs/media/brand/icon.svg new file mode 100644 index 000000000..94fb593fc --- /dev/null +++ b/docs/media/brand/icon.svg @@ -0,0 +1,10 @@ + + Horizon + + + + + + + + diff --git a/docs/media/brand/mark-dark.svg b/docs/media/brand/mark-dark.svg new file mode 100644 index 000000000..318b2ba06 --- /dev/null +++ b/docs/media/brand/mark-dark.svg @@ -0,0 +1,6 @@ + + Horizon + + + + diff --git a/docs/media/brand/mark.svg b/docs/media/brand/mark.svg new file mode 100644 index 000000000..3fe9e2226 --- /dev/null +++ b/docs/media/brand/mark.svg @@ -0,0 +1,6 @@ + + Horizon + + + + diff --git a/docs/media/brand/social/banner.html b/docs/media/brand/social/banner.html new file mode 100644 index 000000000..0082a6bb8 --- /dev/null +++ b/docs/media/brand/social/banner.html @@ -0,0 +1,19 @@ +
+
horizon
+
HorizonMINECRAFT: JAVA EDITION 26.3 · RUST
+

The game you know, rebuilt in Rust.

+

An unofficial client and game logic, checked against vanilla by differential harnesses.

+
diff --git a/docs/media/brand/social/card.html b/docs/media/brand/social/card.html new file mode 100644 index 000000000..2b34542b8 --- /dev/null +++ b/docs/media/brand/social/card.html @@ -0,0 +1,19 @@ +
+
horizon
+
HorizonMINECRAFT: JAVA EDITION 26.3 · RUST
+

The game you know, rebuilt in Rust.

+

An unofficial client and game logic, checked against vanilla by differential harnesses.

+
diff --git a/docs/media/brand/social/discord-banner-960x540.png b/docs/media/brand/social/discord-banner-960x540.png new file mode 100644 index 000000000..8e55ab873 Binary files /dev/null and b/docs/media/brand/social/discord-banner-960x540.png differ diff --git a/docs/media/brand/social/discord-icon-512.png b/docs/media/brand/social/discord-icon-512.png new file mode 100644 index 000000000..ad0d8fece Binary files /dev/null and b/docs/media/brand/social/discord-icon-512.png differ diff --git a/docs/media/brand/social/github-avatar-500.png b/docs/media/brand/social/github-avatar-500.png new file mode 100644 index 000000000..c67d63c10 Binary files /dev/null and b/docs/media/brand/social/github-avatar-500.png differ diff --git a/docs/media/brand/social/github-social-1280x640.png b/docs/media/brand/social/github-social-1280x640.png new file mode 100644 index 000000000..e3748b052 Binary files /dev/null and b/docs/media/brand/social/github-social-1280x640.png differ diff --git a/docs/media/brand/social/kira-avatar-512.png b/docs/media/brand/social/kira-avatar-512.png new file mode 100644 index 000000000..dff6f0df1 Binary files /dev/null and b/docs/media/brand/social/kira-avatar-512.png differ diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock index 39972745d..28735933f 100644 --- a/launcher/Cargo.lock +++ b/launcher/Cargo.lock @@ -2584,6 +2584,7 @@ dependencies = [ "serde_json", "tar", "ureq", + "winresource", "zip", ] @@ -6937,6 +6938,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" +dependencies = [ + "toml 1.1.8+spec-1.1.0", + "version_check", +] + [[package]] name = "winsafe" version = "0.0.19" diff --git a/launcher/Cargo.toml b/launcher/Cargo.toml index 1d2e259df..9a37d254c 100644 --- a/launcher/Cargo.toml +++ b/launcher/Cargo.toml @@ -35,5 +35,9 @@ keyring = { version = "3", features = ["apple-native"] } [target.'cfg(target_os = "windows")'.dependencies] keyring = { version = "3", features = ["windows-native"] } +[target.'cfg(windows)'.build-dependencies] +# The icon resource in the .exe (build.rs). +winresource = "0.1" + [profile.release] strip = true diff --git a/launcher/build.rs b/launcher/build.rs new file mode 100644 index 000000000..bc2ea1f12 --- /dev/null +++ b/launcher/build.rs @@ -0,0 +1,15 @@ +//! Embeds Horizon's icon (`packaging/icons/horizon.ico`) in the Windows executables, as icon +//! resource 1: Explorer and the task bar show it, and the window takes it as its icon. +fn main() { + let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let ico = format!("{dir}/../packaging/icons/horizon.ico"); + println!("cargo:rerun-if-changed={ico}"); + #[cfg(windows)] + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + let mut resource = winresource::WindowsResource::new(); + resource.set_icon(&ico); + if let Err(e) = resource.compile() { + panic!("embedding {ico}: {e}"); + } + } +} diff --git a/packaging/icons/hicolor/128x128/apps/horizon.png b/packaging/icons/hicolor/128x128/apps/horizon.png new file mode 100644 index 000000000..82aa50508 Binary files /dev/null and b/packaging/icons/hicolor/128x128/apps/horizon.png differ diff --git a/packaging/icons/hicolor/16x16/apps/horizon.png b/packaging/icons/hicolor/16x16/apps/horizon.png new file mode 100644 index 000000000..034e64bca Binary files /dev/null and b/packaging/icons/hicolor/16x16/apps/horizon.png differ diff --git a/packaging/icons/hicolor/24x24/apps/horizon.png b/packaging/icons/hicolor/24x24/apps/horizon.png new file mode 100644 index 000000000..107297346 Binary files /dev/null and b/packaging/icons/hicolor/24x24/apps/horizon.png differ diff --git a/packaging/icons/hicolor/256x256/apps/horizon.png b/packaging/icons/hicolor/256x256/apps/horizon.png new file mode 100644 index 000000000..5610f6605 Binary files /dev/null and b/packaging/icons/hicolor/256x256/apps/horizon.png differ diff --git a/packaging/icons/hicolor/32x32/apps/horizon.png b/packaging/icons/hicolor/32x32/apps/horizon.png new file mode 100644 index 000000000..3603cbe0f Binary files /dev/null and b/packaging/icons/hicolor/32x32/apps/horizon.png differ diff --git a/packaging/icons/hicolor/48x48/apps/horizon.png b/packaging/icons/hicolor/48x48/apps/horizon.png new file mode 100644 index 000000000..63f1d4e17 Binary files /dev/null and b/packaging/icons/hicolor/48x48/apps/horizon.png differ diff --git a/packaging/icons/hicolor/512x512/apps/horizon.png b/packaging/icons/hicolor/512x512/apps/horizon.png new file mode 100644 index 000000000..9bf6b6764 Binary files /dev/null and b/packaging/icons/hicolor/512x512/apps/horizon.png differ diff --git a/packaging/icons/hicolor/64x64/apps/horizon.png b/packaging/icons/hicolor/64x64/apps/horizon.png new file mode 100644 index 000000000..1add8b2d6 Binary files /dev/null and b/packaging/icons/hicolor/64x64/apps/horizon.png differ diff --git a/packaging/icons/hicolor/scalable/apps/horizon.svg b/packaging/icons/hicolor/scalable/apps/horizon.svg new file mode 100644 index 000000000..94fb593fc --- /dev/null +++ b/packaging/icons/hicolor/scalable/apps/horizon.svg @@ -0,0 +1,10 @@ + + Horizon + + + + + + + + diff --git a/packaging/icons/horizon.icns b/packaging/icons/horizon.icns new file mode 100644 index 000000000..4c691e990 Binary files /dev/null and b/packaging/icons/horizon.icns differ diff --git a/packaging/icons/horizon.ico b/packaging/icons/horizon.ico new file mode 100644 index 000000000..b580493d6 Binary files /dev/null and b/packaging/icons/horizon.ico differ diff --git a/packaging/linux/horizon-launcher.desktop b/packaging/linux/horizon-launcher.desktop new file mode 100644 index 000000000..2e0000e64 --- /dev/null +++ b/packaging/linux/horizon-launcher.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Horizon +GenericName=Game launcher +Comment=Install, prepare and play Horizon, Minecraft: Java Edition 26.3 rewritten in Rust +Exec=horizon-launcher +Icon=horizon +Terminal=false +Categories=Game; +StartupWMClass=horizon-launcher diff --git a/packaging/linux/horizon.desktop b/packaging/linux/horizon.desktop new file mode 100644 index 000000000..1331e5c4a --- /dev/null +++ b/packaging/linux/horizon.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Horizon +Comment=Minecraft: Java Edition 26.3, rewritten in Rust +Exec=horizon-client +Icon=horizon +Terminal=false +Categories=Game; +StartupWMClass=horizon +NoDisplay=true diff --git a/website/public/apple-touch-icon.png b/website/public/apple-touch-icon.png index 9c5938d10..6a3b2dbcd 100644 Binary files a/website/public/apple-touch-icon.png and b/website/public/apple-touch-icon.png differ diff --git a/website/public/favicon-32.png b/website/public/favicon-32.png index ec73ff4a1..3603cbe0f 100644 Binary files a/website/public/favicon-32.png and b/website/public/favicon-32.png differ diff --git a/website/public/favicon.svg b/website/public/favicon.svg index 2ef5c91a4..94fb593fc 100644 --- a/website/public/favicon.svg +++ b/website/public/favicon.svg @@ -1,62 +1,10 @@ - + Horizon - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6f54fa132..18544f617 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -8,12 +8,16 @@ //! //! `tokens`: writes `website/src/tokens.css`, the design tokens of `crates/horizon-theme` as CSS //! custom properties (a horizon-theme test fails when the file is out of date). +//! +//! `icons`: renders the icon masters in `docs/media/brand` (with `rsvg-convert`, from librsvg) +//! into every size the platforms and the website use, and packs `packaging/icons/horizon.ico` +//! and `horizon.icns`. `docs/media/brand/README.md` lists what goes where. use std::fs; use std::path::{Component, Path, PathBuf}; use std::process::{Command, ExitCode}; -const USAGE: &str = "usage: cargo run -p xtask -- package BINARY OUTDIR | tokens"; +const USAGE: &str = "usage: cargo run -p xtask -- package BINARY OUTDIR | tokens | icons"; /// What the release folder takes from the repository at HEAD, as `git archive` writes it. const ARCHIVED: [&str; 5] = [ @@ -34,6 +38,7 @@ fn main() -> ExitCode { let result = match args.as_slice() { [task, binary, out] if task == "package" => package(Path::new(binary), Path::new(out)), [task] if task == "tokens" => tokens(), + [task] if task == "icons" => icons(), _ => Err(USAGE.to_string()), }; match result { @@ -69,6 +74,145 @@ fn tokens() -> Result<(), String> { Ok(()) } +/// The square icon's sizes: Linux's hicolor theme, and the ones the .ico holds. +const HICOLOR: [u32; 8] = [16, 24, 32, 48, 64, 128, 256, 512]; +const ICO: [u32; 7] = [16, 24, 32, 48, 64, 128, 256]; +/// The .icns entries: type and pixel size, macOS's tile drawn on Apple's grid. +const ICNS: [(&[u8; 4], u32); 11] = [ + (b"icp4", 16), + (b"icp5", 32), + (b"icp6", 64), + (b"ic07", 128), + (b"ic08", 256), + (b"ic09", 512), + (b"ic10", 1024), + (b"ic11", 32), + (b"ic12", 64), + (b"ic13", 256), + (b"ic14", 512), +]; + +fn render(svg: &Path, size: (u32, u32)) -> Result, String> { + let output = Command::new("rsvg-convert") + .arg("-w") + .arg(size.0.to_string()) + .arg("-h") + .arg(size.1.to_string()) + .arg(svg) + .output() + .map_err(|e| format!("rsvg-convert (librsvg): {e}"))?; + if !output.status.success() { + return Err(format!( + "rsvg-convert {}: {}", + svg.display(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(output.stdout) +} + +fn write(path: &Path, data: &[u8]) -> Result<(), String> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + fs::write(path, data).map_err(|e| format!("{}: {e}", path.display())) +} + +/// An .ico whose images are PNGs (Windows Vista and later read them at every size). +fn ico(images: &[(u32, Vec)]) -> Vec { + let mut out = vec![0, 0, 1, 0]; + out.extend((images.len() as u16).to_le_bytes()); + let mut offset = 6 + 16 * images.len(); + for (size, png) in images { + // A width or height of 256 is written as 0. + let side = if *size >= 256 { 0 } else { *size as u8 }; + out.extend([side, side, 0, 0]); + out.extend(1u16.to_le_bytes()); + out.extend(32u16.to_le_bytes()); + out.extend((png.len() as u32).to_le_bytes()); + out.extend((offset as u32).to_le_bytes()); + offset += png.len(); + } + for (_, png) in images { + out.extend(png); + } + out +} + +/// An .icns of PNG entries. +fn icns(entries: &[(&[u8; 4], Vec)]) -> Vec { + let body: usize = entries.iter().map(|(_, png)| 8 + png.len()).sum(); + let mut out = b"icns".to_vec(); + out.extend(((8 + body) as u32).to_be_bytes()); + for (kind, png) in entries { + out.extend(*kind); + out.extend(((8 + png.len()) as u32).to_be_bytes()); + out.extend(png); + } + out +} + +fn icons() -> Result<(), String> { + let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let brand = repo.join("docs/media/brand"); + let square = brand.join("icon.svg"); + let packaging = repo.join("packaging/icons"); + for size in HICOLOR { + let png = render(&square, (size, size))?; + write( + &packaging.join(format!("hicolor/{size}x{size}/apps/horizon.png")), + &png, + )?; + } + write( + &packaging.join("hicolor/scalable/apps/horizon.svg"), + &fs::read(&square).map_err(|e| format!("{}: {e}", square.display()))?, + )?; + let images = ICO + .iter() + .map(|&s| render(&square, (s, s)).map(|png| (s, png))) + .collect::, _>>()?; + write(&packaging.join("horizon.ico"), &ico(&images))?; + let macos = brand.join("icon-macos.svg"); + let entries = ICNS + .iter() + .map(|&(kind, s)| render(&macos, (s, s)).map(|png| (kind, png))) + .collect::, _>>()?; + write(&packaging.join("horizon.icns"), &icns(&entries))?; + // The window icon the game and the launcher set at run time (X11 and Windows title bars). + write( + &repo.join("crates/horizon-theme/icons/horizon-64.png"), + &render(&square, (64, 64))?, + )?; + // Avatars and the website's icons. + let avatar = brand.join("avatar.svg"); + for (svg, size, out) in [ + ( + &avatar, + 500, + "docs/media/brand/social/github-avatar-500.png", + ), + (&avatar, 512, "docs/media/brand/social/discord-icon-512.png"), + ( + &brand.join("avatar-kira.svg"), + 512, + "docs/media/brand/social/kira-avatar-512.png", + ), + (&avatar, 180, "website/public/apple-touch-icon.png"), + (&square, 32, "website/public/favicon-32.png"), + (&square, 1024, "docs/media/brand/icon-1024.png"), + (&macos, 1024, "docs/media/brand/icon-macos-1024.png"), + ] { + write(&repo.join(out), &render(svg, (size, size))?)?; + } + write( + &repo.join("website/public/favicon.svg"), + &fs::read(&square).map_err(|e| format!("{}: {e}", square.display()))?, + )?; + println!("wrote packaging/icons, the window icon, the avatars and the website's icons"); + Ok(()) +} + fn package(binary: &Path, out: &Path) -> Result<(), String> { let top = git( Path::new(env!("CARGO_MANIFEST_DIR")),