diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 83d7de9f..9f09a09d 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -51,7 +51,7 @@ jobs:
./packaging/windows/check-icons.ps1
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
- xmllint --noout packaging/windows/artcraft-launcher.wxs packaging/macos/*.plist* packaging/linux/*.xml*
+ xmllint --noout packaging/windows/artcraft-launcher.wxs packaging/windows/artcraft-launcher.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
if grep -q "LSUIElement" packaging/macos/Info.plist.in; then echo "::error::Info.plist.in must not set LSUIElement"; exit 1; fi
desktop-file-validate packaging/linux/ai.storyteller.artcraft-launcher.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.artcraft-launcher.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.artcraft-launcher.metainfo.xml"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6d5016eb..885039f2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -143,7 +143,9 @@ jobs:
run: cargo xtask version set "$ARTCRAFT_LAUNCHER_VERSION"
- name: Install WiX
shell: pwsh
- run: dotnet tool install --global wix --version 5.0.2
+ run: |
+ dotnet tool install --global wix --version 5.0.2
+ wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index cb623dd6..69566c90 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -41,7 +41,9 @@ jobs:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
- run: dotnet tool install --global wix --version 5.0.2
+ run: |
+ dotnet tool install --global wix --version 5.0.2
+ wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
diff --git a/AGENTS.md b/AGENTS.md
index de20f881..494e9615 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -31,7 +31,7 @@ xtask/ cargo xtask ci | wasm | version | ico
packaging/ macOS, Windows, Linux, FreeBSD and web packaging scripts
assets/app-icon/ the launcher's own icon (master SVG, renders, tray icons)
assets/app-icons/ the Craft apps' icons, copied from each app's repo
-assets/fonts/ Inter (OFL-1.1)
+assets/fonts/ Archivo, Inter, Geist Mono (OFL-1.1): getartcraft.com's faces
```
Layering: `core` depends on no other workspace crate and must build for `wasm32-unknown-unknown`.
@@ -62,6 +62,13 @@ settings file must produce an error the user can act on, never a panic. Shared s
- Never run an installer with elevated rights silently; tell the user what is about to happen.
- Never delete a user's files outside what the launcher itself installed.
+### Look and feel
+
+The UI matches the apps page on getartcraft.com (`artcraft-website-next` in `artcraft-services`):
+hard square corners, 1 px hairlines instead of shadows, Archivo (620, 118 % width) for headings
+and app names, Inter for body text, upper-case Geist Mono labels and buttons, and the site's
+colour tokens by name (`theme::Tokens`). Build new UI from `widgets.rs`; never hard-code a colour.
+
### Other rules
1. **Assets need attribution.** Every bundled asset gets a row in `ATTRIBUTION.md` (path, title,
@@ -69,7 +76,7 @@ settings file must produce an error the user can act on, never a panic. Shared s
`docs/brand/` (the header logo and the tray icon) are trademarks, **not MIT/Apache**
(`docs/brand/LICENSE-brand.txt`): use them exactly as supplied, never redraw or recolor them.
2. **Icons are generated.** The app icon and the tray icon are the supplied ArtCraft "A" mark from
- `docs/brand/` (a trademark, not MIT/Apache); never edit the renders by hand. Run `packaging/icons.sh` (needs `resvg`; `iconutil` on macOS) and commit the outputs.
+ `docs/brand/` (a trademark, not MIT/Apache); never edit the renders by hand. Run `packaging/icons.sh` (needs `resvg`; runs on any OS) and commit the outputs.
`assets/app-icons/` is copied verbatim from each app's repo (`assets/app-icon/hicolor/128x128/apps/ai.storyteller..png`);
refresh it from there, never redraw.
3. **Never break wasm.** `cargo xtask wasm` must pass. File-system, process and OS code is
@@ -110,6 +117,8 @@ Release assets (keep these names exact; the launcher's own self-update and the R
Packaging facts the code relies on:
- The Windows MSI is **per-user** (`%LOCALAPPDATA%\Programs\ArtCraft Launcher`, no UAC prompt).
+ Double-clicked it opens a WixUI setup wizard (Welcome, Ready, progress, Finish with "Launch"),
+ whose art `package.ps1` draws at build time; self-update runs it with `/passive`, no wizard.
Its Start Menu shortcut sets AppUserModelID `ai.storyteller.artcraft-launcher`, which toast
notifications need. `portable.txt` beside the exe switches on portable mode
(`ArtCraftLauncherData\` next to the exe).
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 92aacad8..e6df957b 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -10,13 +10,15 @@ next to them.
| Path | Title | Author | Source | License |
|---|---|---|---|---|
-| `assets/app-icon/` (all files) | ArtCraft Launcher app icon: the ArtCraft "A" mark in black | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark-black.svg` by `packaging/icons.sh`, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
+| `assets/app-icon/` (all files) | ArtCraft Launcher app icon: the ArtCraft "A" mark in blue (getartcraft.com's favicon) | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark.svg` by `packaging/icons.sh`, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `assets/social/linkedin.svg` | LinkedIn logo, linking to ArtCraft's company page | Fonticons, Inc. ([Font Awesome Free](https://fontawesome.com) 6.7.2) | Font Awesome Free `svgs/brands/linkedin.svg`; white fill and a square view box, see [`assets/social/README.md`](assets/social/README.md) | CC BY 4.0. The logo is a trademark of LinkedIn Corporation. |
| `assets/social/x.svg`, `youtube.svg`, `tiktok.svg`, `instagram.svg` | X, YouTube, TikTok and Instagram logos, linking to ArtCraft's accounts | [Simple Icons](https://simpleicons.org/) contributors | Simple Icons, `fill` set to white for tinting; see [`assets/social/README.md`](assets/social/README.md) | CC0 1.0. The logos are trademarks of X Corp., Google LLC, TikTok / ByteDance and Meta Platforms, Inc. |
| `docs/screenshots/artcraft-launcher-grid.png`, `artcraft-launcher-table.png` | Screenshots of ArtCraft Launcher (macOS: grid view in dark theme, table view in light theme), used in the README | ArtCraft Launcher contributors | Captured from the app | MIT OR Apache-2.0 for the screenshot itself; the ArtCraft logo in it stays an ArtCraft trademark ([`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt)) and the app icons in it keep their own licenses (above) |
| `docs/brand/artcraft-logo.svg`, `artcraft-logo-white.svg` | ArtCraft wordmark, shown in the window header | ArtCraft Team | Supplied brand files, unmodified (same as PhotoCraft's `docs/brand/`) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/brand/artcraft-mark-64.png`, `artcraft-mark-black-44.png` | ArtCraft "A" mark, the tray / menu-bar icon | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark.svg` and `artcraft-mark-black.svg` by `packaging/icons.sh` | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
-| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | (copied from PhotoCraft's `assets/fonts/`) | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
+| `assets/fonts/Inter-Variable.ttf` | Inter 4.001, variable (body text) | The Inter Project Authors (Rasmus Andersson) | Google Fonts, (`Inter[opsz,wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
+| `assets/fonts/Archivo-Variable.ttf` | Archivo 2.001, variable (headings and app names) | The Archivo Project Authors (Omnibus-Type) | Google Fonts, (`Archivo[wdth,wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-Archivo.txt`](assets/fonts/OFL-Archivo.txt) |
+| `assets/fonts/GeistMono-Variable.ttf` | Geist Mono 1.701, variable (labels and buttons) | The Geist Project Authors (Vercel) | Google Fonts, (`GeistMono[wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-GeistMono.txt`](assets/fonts/OFL-GeistMono.txt) |
### Craft App Suite icons
diff --git a/NOTICE b/NOTICE
index da0820fc..cd760b86 100644
--- a/NOTICE
+++ b/NOTICE
@@ -5,7 +5,7 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio
The ArtCraft name, wordmark, logo and "A" mark (docs/brand/) are trademarks of the ArtCraft Team.
They are NOT open source and are NOT covered by the MIT or Apache-2.0 license above. This includes
-everywhere ArtCraft Launcher shows them: the app icon (assets/app-icon/, the black "A" mark), the
+everywhere ArtCraft Launcher shows them: the app icon (assets/app-icon/, the blue "A" mark), the
ArtCraft logo in the window header and the "A" mark used as the tray / menu-bar icon
(docs/brand/artcraft-mark-64.png, artcraft-mark-black-44.png).
Their terms are in docs/brand/LICENSE-brand.txt; forks and modified versions must remove them.
@@ -18,7 +18,9 @@ list (author, source, license) is in ATTRIBUTION.md.
Social service icons (assets/social/*.svg) — Simple Icons, CC0 1.0 (X, YouTube, TikTok, Instagram),
and Font Awesome Free 6.7.2 by Fonticons, Inc., CC BY 4.0 (LinkedIn; https://fontawesome.com/license/free).
The logos are trademarks of their respective owners and are used only to link to ArtCraft's accounts.
-Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
+Inter font (assets/fonts/Inter-Variable.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
+Archivo font (assets/fonts/Archivo-Variable.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Archivo.txt.
+Geist Mono font (assets/fonts/GeistMono-Variable.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-GeistMono.txt.
Craft App Suite app icons (assets/app-icons/*.png) — from each app's repository
(github.com/storytold/), MIT OR Apache-2.0, Copyright (c) 2026 ArtCraft Team and the
contributors.
diff --git a/apps/artcraft-launcher-web/index.html b/apps/artcraft-launcher-web/index.html
index ea64bfef..95c44611 100644
--- a/apps/artcraft-launcher-web/index.html
+++ b/apps/artcraft-launcher-web/index.html
@@ -7,11 +7,12 @@
diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs
index c10b11f0..80773181 100644
--- a/apps/artcraft-launcher/src/main.rs
+++ b/apps/artcraft-launcher/src/main.rs
@@ -15,7 +15,8 @@ use std::sync::{Arc, OnceLock};
use artcraft_launcher_native::single_instance::{self, Instance};
use artcraft_launcher_native::{NativeBackend, login};
-use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
+use artcraft_launcher_ui_egui::theme::Tokens;
+use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings, TitleBar};
const APP_ID: &str = "ai.storyteller.artcraft-launcher";
const SETTINGS_KEY: &str = "settings";
@@ -45,6 +46,12 @@ fn main() -> ExitCode {
.with_inner_size([1120.0, 760.0])
.with_min_inner_size([720.0, 500.0])
.with_visible(!hidden);
+ // The header is the title bar (see `DesktopHost::title_bar`).
+ viewport = if cfg!(target_os = "macos") {
+ viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false)
+ } else {
+ viewport.with_decorations(false)
+ };
if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
@@ -73,6 +80,10 @@ struct DesktopHost {
}
impl Host for DesktopHost {
+ fn title_bar(&self) -> TitleBar {
+ if cfg!(target_os = "macos") { TitleBar::MacOs } else { TitleBar::Custom }
+ }
+
fn notify(&mut self, title: &str, body: &str) {
let (title, body) = (title.to_string(), body.to_string());
// Some platforms block while the notification is delivered.
@@ -222,7 +233,7 @@ impl eframe::App for Shell {
eframe::set_value(storage, SETTINGS_KEY, &self.ui.settings);
}
- fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
- egui::Color32::from_rgb(0x0e, 0x10, 0x14).to_normalized_gamma_f32()
+ fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
+ (if visuals.dark_mode { Tokens::DARK.bg } else { Tokens::LIGHT.bg }).to_normalized_gamma_f32()
}
}
diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md
index 2e5d4bc3..bb7b596f 100644
--- a/assets/app-icon/README.md
+++ b/assets/app-icon/README.md
@@ -1,7 +1,7 @@
# ArtCraft Launcher app icon
-The app icon is the **ArtCraft "A" mark in black**, exactly as supplied in
-[`docs/brand/artcraft-mark-black.svg`](../../docs/brand/artcraft-mark-black.svg). Every file in this
+The app icon is the **ArtCraft "A" mark in blue** (getartcraft.com's favicon), exactly as supplied in
+[`docs/brand/artcraft-mark.svg`](../../docs/brand/artcraft-mark.svg). Every file in this
directory is rendered from it by `packaging/icons.sh`.
**These files are an ArtCraft trademark. They are not open source and not covered by ArtCraft
@@ -12,7 +12,7 @@ versions.
## Files
-- `artcraft-launcher.svg`: a copy of `docs/brand/artcraft-mark-black.svg`; also the hicolor scalable icon.
+- `artcraft-launcher.svg`: a copy of `docs/brand/artcraft-mark.svg`; also the hicolor scalable icon.
- `artcraft-launcher-1024.png`: 1024 px render, with extra transparent margin for Apple's icon grid.
- `artcraft-launcher.icns`: macOS bundle icon (`CFBundleIconFile`).
- `artcraft-launcher.ico`: Windows icon, 16–256 px, embedded in the `.exe` by
@@ -24,6 +24,6 @@ versions.
## Regenerate
-Run `packaging/icons.sh`. It needs `resvg`, plus `iconutil` on macOS; the `.ico` is packed by
+Run `packaging/icons.sh` on any OS. It needs `resvg`; the `.ico` and `.icns` are packed by
`cargo xtask ico`. Never edit these files by hand: change the source only if the ArtCraft Team
supplies a new mark in `docs/brand/`.
diff --git a/assets/app-icon/artcraft-launcher-1024.png b/assets/app-icon/artcraft-launcher-1024.png
index 081c9215..b3d07fcc 100644
Binary files a/assets/app-icon/artcraft-launcher-1024.png and b/assets/app-icon/artcraft-launcher-1024.png differ
diff --git a/assets/app-icon/artcraft-launcher.icns b/assets/app-icon/artcraft-launcher.icns
index 2d3d65d9..b96dee91 100644
Binary files a/assets/app-icon/artcraft-launcher.icns and b/assets/app-icon/artcraft-launcher.icns differ
diff --git a/assets/app-icon/artcraft-launcher.ico b/assets/app-icon/artcraft-launcher.ico
index 3bd1f77b..4203d445 100644
Binary files a/assets/app-icon/artcraft-launcher.ico and b/assets/app-icon/artcraft-launcher.ico differ
diff --git a/assets/app-icon/artcraft-launcher.svg b/assets/app-icon/artcraft-launcher.svg
index 63943537..72e191f8 100644
--- a/assets/app-icon/artcraft-launcher.svg
+++ b/assets/app-icon/artcraft-launcher.svg
@@ -1,4 +1,4 @@
diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.artcraft-launcher.png
index 7b268c10..2f9c2ce3 100644
Binary files a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.artcraft-launcher.png and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.artcraft-launcher.png differ
diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.artcraft-launcher.png
index b216c113..47f2e539 100644
Binary files a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.artcraft-launcher.png and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.artcraft-launcher.png differ
diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.artcraft-launcher.png
index f127753e..21eeb99b 100644
Binary files a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.artcraft-launcher.png and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.artcraft-launcher.png differ
diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png
index 2801563a..bab0f014 100644
Binary files a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.artcraft-launcher.png differ
diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.artcraft-launcher.png
index 8b63ccd7..91bc140c 100644
Binary files a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.artcraft-launcher.png and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.artcraft-launcher.png differ
diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.artcraft-launcher.png
index ff70bbc2..a07e34f0 100644
Binary files a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.artcraft-launcher.png and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.artcraft-launcher.png differ
diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.artcraft-launcher.png
index d52bad0b..cc968a3f 100644
Binary files a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.artcraft-launcher.png and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.artcraft-launcher.png differ
diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.artcraft-launcher.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.artcraft-launcher.png
index 1bb35a0b..5d87042c 100644
Binary files a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.artcraft-launcher.png and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.artcraft-launcher.png differ
diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.artcraft-launcher.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.artcraft-launcher.svg
index 63943537..72e191f8 100644
--- a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.artcraft-launcher.svg
+++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.artcraft-launcher.svg
@@ -1,4 +1,4 @@
diff --git a/assets/fonts/Archivo-Variable.ttf b/assets/fonts/Archivo-Variable.ttf
new file mode 100644
index 00000000..cc64253d
Binary files /dev/null and b/assets/fonts/Archivo-Variable.ttf differ
diff --git a/assets/fonts/GeistMono-Variable.ttf b/assets/fonts/GeistMono-Variable.ttf
new file mode 100644
index 00000000..173867dc
Binary files /dev/null and b/assets/fonts/GeistMono-Variable.ttf differ
diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf
deleted file mode 100644
index 458cd060..00000000
Binary files a/assets/fonts/Inter-Medium.ttf and /dev/null differ
diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf
deleted file mode 100644
index b7aaca8d..00000000
Binary files a/assets/fonts/Inter-Regular.ttf and /dev/null differ
diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf
deleted file mode 100644
index 47f8ab1d..00000000
Binary files a/assets/fonts/Inter-SemiBold.ttf and /dev/null differ
diff --git a/assets/fonts/Inter-Variable.ttf b/assets/fonts/Inter-Variable.ttf
new file mode 100644
index 00000000..047c92f6
Binary files /dev/null and b/assets/fonts/Inter-Variable.ttf differ
diff --git a/assets/fonts/OFL-Archivo.txt b/assets/fonts/OFL-Archivo.txt
new file mode 100644
index 00000000..8597481e
--- /dev/null
+++ b/assets/fonts/OFL-Archivo.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/assets/fonts/OFL-GeistMono.txt b/assets/fonts/OFL-GeistMono.txt
new file mode 100644
index 00000000..61f6f4b7
--- /dev/null
+++ b/assets/fonts/OFL-GeistMono.txt
@@ -0,0 +1,93 @@
+Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs
index 900b0f77..2e97f8e6 100644
--- a/crates/core/src/catalog.rs
+++ b/crates/core/src/catalog.rs
@@ -34,8 +34,12 @@ pub struct CraftApp {
/// The familiar app it reimplements, for orientation.
pub like: &'static str,
pub group: AppGroup,
- /// Brand colour (sRGB).
+ /// Brand colour (sRGB), for surfaces: getartcraft.com's `--app`.
pub color: [u8; 3],
+ /// The brand colour for text on a light background (the site's light `--app-ink`).
+ pub ink_light: [u8; 3],
+ /// The brand colour for text on a dark background (the site's dark `--app-ink`).
+ pub ink_dark: [u8; 3],
/// GitHub `owner/repo`.
pub repo: &'static str,
/// Older names its release assets may carry (PdfCraft shipped as PrintCraft until 0.2.1).
@@ -82,23 +86,39 @@ const fn app(
tagline: &'static str,
like: &'static str,
group: AppGroup,
- color: [u8; 3],
+ [color, ink_light, ink_dark]: [[u8; 3]; 3],
repo: &'static str,
) -> CraftApp {
- CraftApp { id, name, tagline, like, group, color, repo, former_ids: &[] }
+ CraftApp { id, name, tagline, like, group, color, ink_light, ink_dark, repo, former_ids: &[] }
}
/// The Craft App Suite, in display order: the seven classic apps, then the five new ones.
pub const SUITE: [CraftApp; 12] = [
- app("photocraft", "PhotoCraft", "Image editing", "Adobe Photoshop", AppGroup::Classic, [0x2f, 0x7b, 0xf5], "storytold/photocraft"),
- app("vectorcraft", "VectorCraft", "Vector illustration", "Adobe Illustrator", AppGroup::Classic, [0xe8, 0x57, 0x3f], "storytold/vectorcraft"),
+ app(
+ "photocraft",
+ "PhotoCraft",
+ "Image editing",
+ "Adobe Photoshop",
+ AppGroup::Classic,
+ [[0x2f, 0x7b, 0xf5], [0x1a, 0x5b, 0xc4], [0x7f, 0xb0, 0xff]],
+ "storytold/photocraft",
+ ),
+ app(
+ "vectorcraft",
+ "VectorCraft",
+ "Vector illustration",
+ "Adobe Illustrator",
+ AppGroup::Classic,
+ [[0xe8, 0x57, 0x3f], [0xb8, 0x3a, 0x24], [0xff, 0x8a, 0x70]],
+ "storytold/vectorcraft",
+ ),
app(
"filmcraft",
"FilmCraft",
"Video editing, color and sound",
"Adobe Premiere Pro",
AppGroup::Classic,
- [0x8b, 0x5c, 0xf6],
+ [[0x8b, 0x5c, 0xf6], [0x6a, 0x3f, 0xd6], [0xb6, 0x9c, 0xff]],
"storytold/filmcraft",
),
app(
@@ -107,7 +127,7 @@ pub const SUITE: [CraftApp; 12] = [
"Photo library and raw development",
"Adobe Lightroom",
AppGroup::Classic,
- [0xf2, 0xa5, 0x16],
+ [[0xf2, 0xa5, 0x16], [0x8a, 0x58, 0x00], [0xff, 0xc1, 0x4d]],
"storytold/lightcraft",
),
CraftApp {
@@ -118,7 +138,7 @@ pub const SUITE: [CraftApp; 12] = [
"Reading, organizing and protecting PDFs",
"Adobe Acrobat",
AppGroup::Classic,
- [0x12, 0xa5, 0x8a],
+ [[0x12, 0xa5, 0x8a], [0x0a, 0x75, 0x63], [0x3f, 0xd6, 0xb8]],
"storytold/pdfcraft",
)
},
@@ -128,21 +148,61 @@ pub const SUITE: [CraftApp; 12] = [
"Motion graphics and visual effects",
"Adobe After Effects",
AppGroup::Classic,
- [0xe0, 0x36, 0x8f],
+ [[0xe0, 0x36, 0x8f], [0xb0, 0x20, 0x6c], [0xff, 0x7a, 0xb8]],
"storytold/effectcraft",
),
- app("designcraft", "DesignCraft", "Page layout and publishing", "Adobe InDesign", AppGroup::Classic, [0x7b, 0xb5, 0x1c], "storytold/designcraft"),
- app("wordcraft", "WordCraft", "Writing and document design", "Microsoft Word", AppGroup::New, [0x3f, 0x5f, 0xe0], "storytold/wordcraft"),
- app("deckcraft", "DeckCraft", "Presentations and slide shows", "Microsoft PowerPoint", AppGroup::New, [0xf0, 0x7a, 0x1e], "storytold/deckcraft"),
- app("gridcraft", "GridCraft", "Spreadsheets and data", "Microsoft Excel", AppGroup::New, [0x1f, 0x9d, 0x55], "storytold/gridcraft"),
- app("cadcraft", "CADCraft", "Computer-aided design and drafting", "Autodesk AutoCAD", AppGroup::New, [0x3e, 0xa6, 0xe8], "storytold/cadcraft"),
+ app(
+ "designcraft",
+ "DesignCraft",
+ "Page layout and publishing",
+ "Adobe InDesign",
+ AppGroup::Classic,
+ [[0x7b, 0xb5, 0x1c], [0x45, 0x70, 0x08], [0xa6, 0xe0, 0x4a]],
+ "storytold/designcraft",
+ ),
+ app(
+ "wordcraft",
+ "WordCraft",
+ "Writing and document design",
+ "Microsoft Word",
+ AppGroup::New,
+ [[0x3f, 0x5f, 0xe0], [0x2a, 0x44, 0xb8], [0x8e, 0xa2, 0xff]],
+ "storytold/wordcraft",
+ ),
+ app(
+ "deckcraft",
+ "DeckCraft",
+ "Presentations and slide shows",
+ "Microsoft PowerPoint",
+ AppGroup::New,
+ [[0xf0, 0x7a, 0x1e], [0xa8, 0x4f, 0x0a], [0xff, 0xa0, 0x60]],
+ "storytold/deckcraft",
+ ),
+ app(
+ "gridcraft",
+ "GridCraft",
+ "Spreadsheets and data",
+ "Microsoft Excel",
+ AppGroup::New,
+ [[0x1f, 0x9d, 0x55], [0x12, 0x70, 0x3a], [0x4f, 0xd6, 0x8a]],
+ "storytold/gridcraft",
+ ),
+ app(
+ "cadcraft",
+ "CADCraft",
+ "Computer-aided design and drafting",
+ "Autodesk AutoCAD",
+ AppGroup::New,
+ [[0x3e, 0xa6, 0xe8], [0x1a, 0x6f, 0xa8], [0x7c, 0xc8, 0xff]],
+ "storytold/cadcraft",
+ ),
app(
"soundcraft",
"SoundCraft",
"Recording, editing and mixing audio",
"Avid Pro Tools",
AppGroup::New,
- [0x1f, 0xa5, 0xc9],
+ [[0x1f, 0xa5, 0xc9], [0x0e, 0x72, 0x90], [0x5f, 0xd0, 0xee]],
"storytold/soundcraft",
),
];
@@ -180,7 +240,7 @@ pub const LAUNCHER: CraftApp = app(
"Installs and updates the Craft App Suite",
"",
AppGroup::New,
- [0x0b, 0x0b, 0x0c],
+ [[0x0b, 0x0b, 0x0c], [0x0b, 0x0b, 0x0c], [0xf2, 0xf1, 0xee]],
"storytold/craft-launcher",
);
diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs
index aff93c0c..c535ff91 100644
--- a/crates/native/src/backend.rs
+++ b/crates/native/src/backend.rs
@@ -349,7 +349,10 @@ fn run_install(
{
let mut m = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner);
let changed = match new_record {
- Some(r) => m.apps.insert(app.id.to_string(), r).is_some() || true,
+ Some(r) => {
+ m.apps.insert(app.id.to_string(), r);
+ true
+ }
// Installed the platform's own way: any old launcher record is stale.
None => m.apps.remove(app.id).is_some(),
};
diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs
index ae932456..f82f154a 100644
--- a/crates/native/src/install.rs
+++ b/crates/native/src/install.rs
@@ -215,7 +215,7 @@ pub fn uninstall(app: &CraftApp, installed: &Installed, record: Option<&Record>)
#[allow(dead_code)]
/// Deletes an AppImage, and the menu entry it created for itself on first run.
fn remove_appimage(app: &CraftApp, path: &Path) -> Result<(), String> {
- if !path.extension().is_some_and(|e| e == "AppImage") || !path.is_file() {
+ if path.extension().is_none_or(|e| e != "AppImage") || !path.is_file() {
return Err(format!("{} isn't an AppImage, so it was left alone", path.display()));
}
std::fs::remove_file(path).map_err(|e| format!("couldn't remove {} ({e})", path.display()))?;
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
index 16063c92..b02e5cdf 100644
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -11,8 +11,26 @@ use crate::model::AppEntry;
use crate::settings::{Filter, Settings, SortKey, ViewMode};
use crate::theme::{self, Tokens};
+/// Who draws the window's title bar.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum TitleBar {
+ /// Nothing to draw: the OS draws one, or there's no window (the web build).
+ #[default]
+ Native,
+ /// The window has no OS title bar: the header is the title bar (drag it to move the window,
+ /// double-click to maximize), with minimize, maximize and close at its right end, and the
+ /// window's edges resize it.
+ Custom,
+ /// macOS: the header runs under a transparent title bar, with the traffic lights at its left.
+ MacOs,
+}
+
/// What only the shell can do. Every method has a do-nothing default (the web build).
pub trait Host {
+ /// Who draws the title bar.
+ fn title_bar(&self) -> TitleBar {
+ TitleBar::Native
+ }
/// Shows a system notification.
fn notify(&mut self, _title: &str, _body: &str) {}
/// A tray (menu bar) icon exists, so closing the window can keep the launcher running.
@@ -307,19 +325,22 @@ impl LauncherUi {
ctx.request_repaint_after(Duration::from_millis(50));
}
+ // The bars' separator lines are the hairlines under the header and over the footer.
egui::Panel::top("header")
- .frame(
- egui::Frame::new().fill(t.surface).inner_margin(egui::Margin { left: 20, right: 20, top: 14, bottom: 12 }).stroke(egui::Stroke::NONE),
- )
- .show(ui, |ui| self.header(ui));
+ .frame(egui::Frame::new().fill(t.bg))
+ .exact_size(crate::views::HEADER_H)
+ .resizable(false)
+ .show(ui, |ui| self.header(ui, host.title_bar()));
egui::Panel::bottom("footer")
- .frame(egui::Frame::new().fill(t.surface).inner_margin(egui::Margin::symmetric(20, 7)))
+ .frame(egui::Frame::new().fill(t.bg).inner_margin(egui::Margin::symmetric(20, 0)))
+ .exact_size(crate::views::FOOTER_H)
+ .resizable(false)
.show(ui, |ui| self.footer(ui));
egui::CentralPanel::default()
- .frame(egui::Frame::new().fill(t.bg).inner_margin(egui::Margin { left: 20, right: 20, top: 14, bottom: 0 }))
+ .frame(egui::Frame::new().fill(t.bg).inner_margin(egui::Margin { left: 20, right: 20, top: 18, bottom: 0 }))
.show(ui, |ui| {
self.toolbar(ui);
- ui.add_space(6.0);
+ ui.add_space(18.0);
let ids = self.visible_ids();
// The table is wider than a narrow window: let it scroll sideways too.
let scroll = match self.settings.view {
@@ -330,7 +351,7 @@ impl LauncherUi {
if ids.is_empty() {
ui.add_space(40.0);
ui.vertical_centered(|ui| {
- ui.label(egui::RichText::new("No apps match").size(16.0).color(t.text_muted));
+ crate::widgets::hud(ui, "No apps match", t.muted);
});
} else {
match self.settings.view {
@@ -344,6 +365,9 @@ impl LauncherUi {
});
self.dialogs(&ctx, host);
self.draw_toasts(&ctx);
+ if host.title_bar() == TitleBar::Custom {
+ crate::widgets::window_edges(&ctx);
+ }
}
/// The ids to show, filtered and sorted.
@@ -431,40 +455,32 @@ impl LauncherUi {
}
ctx.request_repaint_after(Duration::from_millis(250));
let t = Tokens::get(ctx);
- egui::Area::new(egui::Id::new("toasts")).anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-20.0, -44.0)).order(egui::Order::Foreground).show(
- ctx,
- |ui| {
- ui.set_max_width(380.0);
- let mut dismiss = None;
- for (i, toast) in self.toasts.iter().enumerate() {
- let accent = if toast.error { t.bad } else { t.good };
- let r = egui::Frame::new()
- .fill(t.surface)
- .stroke(egui::Stroke::new(1.0, t.border_strong))
- .corner_radius(theme::RADIUS)
- .shadow(egui::epaint::Shadow {
- offset: [0, 6],
- blur: 24,
- spread: 0,
- color: egui::Color32::from_black_alpha(if t.dark { 120 } else { 40 }),
- })
- .inner_margin(egui::Margin::symmetric(14, 10))
- .show(ui, |ui| {
- ui.horizontal(|ui| {
- let (rect, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover());
- ui.painter().circle_filled(rect.center(), 4.0, accent);
- ui.add(egui::Label::new(egui::RichText::new(&toast.text).color(t.text)).wrap());
- });
+ let anchor = egui::vec2(-20.0, -(crate::views::FOOTER_H + 12.0));
+ egui::Area::new(egui::Id::new("toasts")).anchor(egui::Align2::RIGHT_BOTTOM, anchor).order(egui::Order::Foreground).show(ctx, |ui| {
+ ui.set_max_width(380.0);
+ let mut dismiss = None;
+ for (i, toast) in self.toasts.iter().enumerate() {
+ let accent = if toast.error { t.bad } else { t.good };
+ let r = egui::Frame::new()
+ .fill(t.bg)
+ .stroke(egui::Stroke::new(1.0, t.line_strong))
+ .shadow(theme::shadow(&t))
+ .inner_margin(egui::Margin::symmetric(14, 10))
+ .show(ui, |ui| {
+ ui.horizontal(|ui| {
+ let (rect, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
+ ui.painter().rect_filled(rect, egui::CornerRadius::ZERO, accent);
+ ui.add(egui::Label::new(egui::RichText::new(&toast.text).color(t.ink)).wrap());
});
- if r.response.interact(egui::Sense::click()).clicked() {
- dismiss = Some(i);
- }
- ui.add_space(6.0);
+ });
+ if r.response.interact(egui::Sense::click()).clicked() {
+ dismiss = Some(i);
}
- if let Some(i) = dismiss {
- self.toasts.remove(i);
- }
- },
- );
+ ui.add_space(6.0);
+ }
+ if let Some(i) = dismiss {
+ self.toasts.remove(i);
+ }
+ });
}
}
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 16c36d21..6857277c 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -7,15 +7,14 @@ use egui::{RichText, vec2};
use crate::app::{Dialog, Host, LauncherUi};
use crate::settings::CheckEvery;
use crate::theme::{self, ThemeChoice, Tokens};
-use crate::widgets;
+use crate::widgets::{self, Cell, Kind};
impl LauncherUi {
pub(crate) fn dialogs(&mut self, ctx: &egui::Context, host: &mut dyn Host) {
let Some(dialog) = self.dialog.clone() else { return };
let t = Tokens::get(ctx);
- let modal = egui::Modal::new(egui::Id::new("dialog")).frame(
- egui::Frame::new().fill(t.surface).stroke(egui::Stroke::new(1.0, t.border_strong)).corner_radius(theme::RADIUS + 4).inner_margin(22),
- );
+ let modal = egui::Modal::new(egui::Id::new("dialog"))
+ .frame(egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
let mut close = false;
let response = match &dialog {
Dialog::Notes(id) => modal.show(ctx, |ui| close = self.notes_dialog(ui, id)),
@@ -33,15 +32,16 @@ impl LauncherUi {
if let Some(icon) = icon {
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(40.0, 40.0)));
}
+ ui.add_space(4.0);
ui.vertical(|ui| {
- ui.spacing_mut().item_spacing.y = 0.0;
- ui.label(RichText::new(title).size(18.0).family(theme::semibold()).color(t.text));
+ ui.spacing_mut().item_spacing.y = 4.0;
+ ui.label(widgets::heading(title, 22.0, t.ink_strong));
if !subtitle.is_empty() {
- ui.label(RichText::new(subtitle).size(12.5).color(t.text_muted));
+ widgets::hud(ui, subtitle, t.faint);
}
});
});
- ui.add_space(8.0);
+ ui.add_space(14.0);
}
fn notes_dialog(&mut self, ui: &mut egui::Ui, id: &str) -> bool {
@@ -60,11 +60,9 @@ impl LauncherUi {
ui.add_space(12.0);
let mut close = false;
ui.horizontal(|ui| {
- if ui.link("Open on GitHub").clicked() {
- ui.ctx().open_url(egui::OpenUrl::new_tab(release.html_url.clone()));
- }
+ widgets::hud_link(ui, "Open on GitHub", &release.html_url);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- close = widgets::secondary(ui, "Close", 90.0).clicked();
+ close = widgets::button(ui, "Close", Kind::Secondary, 90.0).clicked();
});
});
close
@@ -87,15 +85,15 @@ impl LauncherUi {
) => "Your system's package manager will remove it, and may ask for your password. Your documents and settings stay.",
_ => "The app will be removed. Your documents and settings stay.",
};
- ui.label(RichText::new(how).color(t.text_muted));
- ui.add_space(16.0);
+ ui.label(RichText::new(how).color(t.muted));
+ ui.add_space(20.0);
let mut close = false;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- if widgets::primary(ui, "Uninstall", t.bad, 110.0).clicked() {
+ if widgets::button(ui, "Uninstall", Kind::Danger, 110.0).clicked() {
self.send(Command::Uninstall { app: id.to_string() });
close = true;
}
- if widgets::secondary(ui, "Cancel", 90.0).clicked() {
+ if widgets::button(ui, "Cancel", Kind::Secondary, 90.0).clicked() {
close = true;
}
});
@@ -104,13 +102,13 @@ impl LauncherUi {
fn settings_dialog(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) -> bool {
let t = Tokens::get(ui.ctx());
- ui.set_width(460.0);
- // The app icon is the black mark; on a dark dialog show the blue one.
- Self::title(ui, Some(crate::icons::artcraft_mark(t.dark)), "Settings", &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION")));
+ // Wide enough for the community buttons and every social icon on one row.
+ ui.set_width(520.0);
+ Self::title(ui, Some(crate::icons::artcraft_mark()), "Settings", &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION")));
let section = |ui: &mut egui::Ui, s: &str| {
- ui.add_space(10.0);
- ui.label(RichText::new(s.to_uppercase()).size(11.0).family(theme::semibold()).color(t.text_faint));
- ui.add_space(2.0);
+ ui.add_space(14.0);
+ widgets::hud(ui, s, t.faint);
+ ui.add_space(6.0);
};
let row = |ui: &mut egui::Ui, label: &str, add: &mut dyn FnMut(&mut egui::Ui)| {
ui.horizontal(|ui| {
@@ -145,7 +143,7 @@ impl LauncherUi {
});
});
self.settings.preferred_kinds = choice.into_iter().collect();
- ui.label(RichText::new("Updates always use the format an app was installed with.").size(12.0).color(t.text_muted));
+ ui.label(RichText::new("Updates always use the format an app was installed with.").size(12.0).color(t.muted));
}
if self.caps.manage_apps {
@@ -167,17 +165,17 @@ impl LauncherUi {
}
section(ui, "Community");
- ui.label(RichText::new("Questions, ideas and news from the ArtCraft team").color(t.text_muted));
- ui.add_space(4.0);
+ ui.label(RichText::new("Questions, ideas and news from the ArtCraft team").color(t.muted));
+ ui.add_space(6.0);
ui.horizontal(|ui| {
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
- if widgets::discord_button(ui).clicked() {
+ if widgets::button(ui, "Join the Discord", Kind::Primary, 0.0).on_hover_text(DISCORD_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(DISCORD_URL));
}
- if widgets::secondary(ui, "Website", 0.0).on_hover_text(WEBSITE_URL).clicked() {
+ if widgets::button(ui, "Website", Kind::Secondary, 0.0).on_hover_text(WEBSITE_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(WEBSITE_URL));
}
- if widgets::secondary(ui, "GitHub", 0.0).on_hover_text(GITHUB_URL).clicked() {
+ if widgets::button(ui, "GitHub", Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(GITHUB_URL));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -189,18 +187,18 @@ impl LauncherUi {
section(ui, "Appearance");
let mut theme_choice = self.settings.theme;
ui.horizontal(|ui| {
- widgets::segmented(ui, &mut theme_choice, &ThemeChoice::ALL.map(|c| (c, c.label(), c.label())));
+ widgets::cells(ui, &mut theme_choice, &ThemeChoice::ALL.map(|c| (c, Cell { label: c.label(), tip: c.label(), count: None })));
});
self.settings.theme = theme_choice;
- ui.add_space(18.0);
+ ui.add_space(24.0);
let mut close = false;
ui.horizontal(|ui| {
- if self.caps.manage_apps && widgets::secondary(ui, "Quit ArtCraft Launcher", 0.0).clicked() {
+ if self.caps.manage_apps && widgets::button(ui, "Quit ArtCraft Launcher", Kind::Secondary, 0.0).clicked() {
host.quit();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- close = widgets::primary(ui, "Done", t.accent, 90.0).clicked();
+ close = widgets::button(ui, "Done", Kind::Primary, 90.0).clicked();
});
});
close
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index 4ba5aaa1..8ad51b1b 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -46,11 +46,7 @@ pub fn social(service: &str) -> Option> {
})
}
-/// The ArtCraft "A" mark as supplied: black for light backgrounds, the blue mark for dark ones.
-pub fn artcraft_mark(dark_background: bool) -> ImageSource<'static> {
- if dark_background {
- egui::include_image!("../../../docs/brand/artcraft-mark.svg")
- } else {
- egui::include_image!("../../../docs/brand/artcraft-mark-black.svg")
- }
+/// The ArtCraft "A" mark in blue, as supplied: the launcher's app icon and getartcraft.com's favicon.
+pub fn artcraft_mark() -> ImageSource<'static> {
+ egui::include_image!("../../../docs/brand/artcraft-mark.svg")
}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 13df6c4b..5c42b478 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -18,5 +18,5 @@ pub mod theme;
mod views;
mod widgets;
-pub use app::{Host, LauncherUi};
+pub use app::{Host, LauncherUi, TitleBar};
pub use settings::Settings;
diff --git a/crates/ui-egui/src/notes.rs b/crates/ui-egui/src/notes.rs
index e7fbff68..033b15e5 100644
--- a/crates/ui-egui/src/notes.rs
+++ b/crates/ui-egui/src/notes.rs
@@ -2,7 +2,7 @@
use egui::{RichText, Ui};
-use crate::theme::{self, Tokens};
+use crate::theme::Tokens;
/// One line of notes, classified.
#[derive(Debug, PartialEq, Eq)]
@@ -37,20 +37,20 @@ pub(crate) fn classify(line: &str) -> Line<'_> {
pub(crate) fn show(ui: &mut Ui, notes: &str) {
let t = Tokens::get(ui.ctx());
if notes.trim().is_empty() {
- ui.label(RichText::new("No notes were published with this release.").color(t.text_muted));
+ ui.label(RichText::new("No notes were published with this release.").color(t.muted));
return;
}
for line in notes.lines() {
match classify(line) {
Line::Heading(h) => {
ui.add_space(6.0);
- ui.label(RichText::new(h).size(15.0).family(theme::semibold()).color(t.text));
+ ui.label(crate::widgets::heading(h, 16.0, t.ink_strong));
}
Line::Bullet(text, link) => {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
- ui.label(RichText::new("•").color(t.text_faint));
- ui.label(RichText::new(text).color(t.text));
+ ui.label(RichText::new("•").color(t.faint));
+ ui.label(RichText::new(text).color(t.ink));
if let Some(url) = link {
let short = url.rsplit('/').next().map_or_else(|| "link".to_string(), |n| format!("#{n}"));
ui.hyperlink_to(RichText::new(short).size(12.0), url);
@@ -62,7 +62,7 @@ pub(crate) fn show(ui: &mut Ui, notes: &str) {
ui.add_space(6.0);
ui.hyperlink_to("Full changelog", url.trim());
} else {
- ui.label(RichText::new(text).color(t.text_muted));
+ ui.label(RichText::new(text).color(t.muted));
}
}
Line::Blank => {
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
index bdc72ec6..ea4bfeef 100644
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -1,8 +1,11 @@
-//! Colours, type and shapes. Widgets read [`Tokens::get`]; nothing hard-codes a colour.
+//! Colours, type and shapes, matching getartcraft.com: hard edges, hairline rules, Archivo
+//! display type, Inter body text and Geist Mono labels. Widgets read [`Tokens::get`]; nothing
+//! hard-codes a colour.
use std::sync::Arc;
-use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals};
+use egui::epaint::text::VariationCoords;
+use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Stroke, TextStyle, Visuals};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -26,75 +29,103 @@ impl ThemeChoice {
}
}
-/// Colour tokens for one appearance.
+/// Colour tokens for one appearance: the website's (`globals.css`), by the same names.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tokens {
pub dark: bool,
+ /// Page and card surface.
pub bg: Color32,
- pub surface: Color32,
- pub card: Color32,
- pub card_hover: Color32,
- pub border: Color32,
- pub border_strong: Color32,
- pub text: Color32,
- pub text_muted: Color32,
- pub text_faint: Color32,
+ /// Badges, wells and row hovers.
+ pub sunken: Color32,
+ /// Body text.
+ pub ink: Color32,
+ /// Headings.
+ pub ink_strong: Color32,
+ /// Secondary text.
+ pub muted: Color32,
+ /// Labels and tertiary text.
+ pub faint: Color32,
+ /// Hairlines.
+ pub line: Color32,
+ /// Outlined buttons and stronger rules.
+ pub line_strong: Color32,
+ /// The brand blue, for surfaces.
pub accent: Color32,
- pub accent_text: Color32,
+ /// The brand blue, for text and icons.
+ pub accent_ink: Color32,
+ /// Primary buttons and hover inversion.
+ pub invert_bg: Color32,
+ pub invert_fg: Color32,
pub good: Color32,
pub warn: Color32,
pub bad: Color32,
- pub info: Color32,
+}
+
+const fn ink_alpha(rgb: u32, alpha: u8) -> Color32 {
+ let [_, r, g, b] = rgb.to_be_bytes();
+ Color32::from_rgba_unmultiplied_const(r, g, b, alpha)
+}
+
+const fn hex(rgb: u32) -> Color32 {
+ let [_, r, g, b] = rgb.to_be_bytes();
+ Color32::from_rgb(r, g, b)
}
impl Tokens {
pub const DARK: Self = Self {
dark: true,
- bg: Color32::from_rgb(0x0e, 0x10, 0x14),
- surface: Color32::from_rgb(0x14, 0x17, 0x1d),
- card: Color32::from_rgb(0x19, 0x1c, 0x24),
- card_hover: Color32::from_rgb(0x1f, 0x23, 0x2d),
- border: Color32::from_rgb(0x26, 0x2a, 0x35),
- border_strong: Color32::from_rgb(0x36, 0x3c, 0x4a),
- text: Color32::from_rgb(0xec, 0xee, 0xf2),
- text_muted: Color32::from_rgb(0x9a, 0xa1, 0xb0),
- text_faint: Color32::from_rgb(0x67, 0x6e, 0x7d),
- accent: Color32::from_rgb(0x7c, 0x6c, 0xff),
- accent_text: Color32::WHITE,
- good: Color32::from_rgb(0x3d, 0xd6, 0x8c),
- warn: Color32::from_rgb(0xff, 0xb5, 0x47),
- bad: Color32::from_rgb(0xff, 0x6b, 0x6b),
- info: Color32::from_rgb(0x6c, 0xb4, 0xff),
+ bg: hex(0x121316),
+ sunken: hex(0x060607),
+ ink: hex(0xf2f1ee),
+ ink_strong: hex(0xffffff),
+ muted: ink_alpha(0xf2f1ee, 153),
+ faint: ink_alpha(0xf2f1ee, 97),
+ line: ink_alpha(0xf2f1ee, 38),
+ line_strong: ink_alpha(0xf2f1ee, 102),
+ accent: hex(0x2d81ff),
+ accent_ink: hex(0x74aaff),
+ invert_bg: hex(0xffffff),
+ invert_fg: hex(0x0b0b0c),
+ // PdfCraft's and LightCraft's inks, so status colours come from the site's palette.
+ good: hex(0x3fd6b8),
+ warn: hex(0xffc14d),
+ bad: hex(0xff6259),
};
pub const LIGHT: Self = Self {
dark: false,
- bg: Color32::from_rgb(0xf4, 0xf5, 0xf8),
- surface: Color32::from_rgb(0xff, 0xff, 0xff),
- card: Color32::from_rgb(0xff, 0xff, 0xff),
- card_hover: Color32::from_rgb(0xfa, 0xfa, 0xfd),
- border: Color32::from_rgb(0xe3, 0xe5, 0xeb),
- border_strong: Color32::from_rgb(0xc9, 0xcd, 0xd6),
- text: Color32::from_rgb(0x15, 0x17, 0x1c),
- text_muted: Color32::from_rgb(0x5b, 0x62, 0x70),
- text_faint: Color32::from_rgb(0x8b, 0x91, 0x9d),
- accent: Color32::from_rgb(0x5b, 0x4b, 0xf0),
- accent_text: Color32::WHITE,
- good: Color32::from_rgb(0x12, 0x8a, 0x52),
- warn: Color32::from_rgb(0xb8, 0x6e, 0x00),
- bad: Color32::from_rgb(0xc8, 0x2b, 0x2b),
- info: Color32::from_rgb(0x1f, 0x6f, 0xd6),
+ bg: hex(0xf2f1ee),
+ sunken: hex(0xe9e8e4),
+ ink: hex(0x101014),
+ ink_strong: hex(0x000000),
+ muted: ink_alpha(0x101014, 158),
+ faint: ink_alpha(0x101014, 107),
+ line: ink_alpha(0x101014, 41),
+ line_strong: ink_alpha(0x101014, 102),
+ accent: hex(0x2d81ff),
+ accent_ink: hex(0x1659c4),
+ invert_bg: hex(0x101014),
+ invert_fg: hex(0xf2f1ee),
+ good: hex(0x0a7563),
+ warn: hex(0x8a5800),
+ bad: hex(0xb3261e),
};
pub fn get(ctx: &egui::Context) -> Self {
if ctx.theme() == egui::Theme::Dark { Self::DARK } else { Self::LIGHT }
}
+
+ pub fn hairline(&self) -> Stroke {
+ Stroke::new(1.0, self.line)
+ }
+
+ /// An app's text colour (`--app-ink`): readable on this appearance's background.
+ pub fn app_ink(&self, app: &artcraft_launcher_core::CraftApp) -> Color32 {
+ rgb(if self.dark { app.ink_dark } else { app.ink_light })
+ }
}
-pub const RADIUS: u8 = 12;
-pub const RADIUS_SMALL: u8 = 8;
-
-/// Mixes `c` into the background at `alpha` (0–1): tinted chips and glows.
+/// Mixes `c` into the background at `alpha` (0–1): tinted washes.
pub fn tint(c: Color32, alpha: f32) -> Color32 {
let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a)
@@ -104,35 +135,44 @@ pub fn rgb(c: [u8; 3]) -> Color32 {
Color32::from_rgb(c[0], c[1], c[2])
}
+/// The shadow under menus, dialogs and toasts.
+pub fn shadow(t: &Tokens) -> egui::epaint::Shadow {
+ egui::epaint::Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 36 }) }
+}
+
fn visuals(t: &Tokens) -> Visuals {
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
v.panel_fill = t.bg;
- v.window_fill = t.surface;
- v.extreme_bg_color = if t.dark { Color32::from_rgb(0x0b, 0x0d, 0x11) } else { Color32::from_rgb(0xee, 0xf0, 0xf4) };
- v.faint_bg_color = t.card;
- v.window_stroke = Stroke::new(1.0, t.border);
- v.window_corner_radius = CornerRadius::same(RADIUS + 2);
- v.menu_corner_radius = CornerRadius::same(RADIUS_SMALL + 2);
- v.override_text_color = Some(t.text);
- v.hyperlink_color = t.accent;
- v.selection.bg_fill = tint(t.accent, 0.35);
- v.selection.stroke = Stroke::new(1.0, t.accent);
- let r = CornerRadius::same(RADIUS_SMALL);
- for (w, fill, stroke) in [
- (&mut v.widgets.noninteractive, t.card, t.border),
- (&mut v.widgets.inactive, if t.dark { Color32::from_rgb(0x23, 0x27, 0x31) } else { Color32::from_rgb(0xee, 0xf0, 0xf4) }, t.border),
- (&mut v.widgets.hovered, if t.dark { Color32::from_rgb(0x2b, 0x30, 0x3c) } else { Color32::from_rgb(0xe4, 0xe7, 0xee) }, t.border_strong),
- (&mut v.widgets.active, if t.dark { Color32::from_rgb(0x33, 0x39, 0x47) } else { Color32::from_rgb(0xda, 0xde, 0xe7) }, t.border_strong),
- (&mut v.widgets.open, if t.dark { Color32::from_rgb(0x2b, 0x30, 0x3c) } else { Color32::from_rgb(0xe4, 0xe7, 0xee) }, t.border_strong),
+ v.window_fill = t.bg;
+ v.extreme_bg_color = t.sunken;
+ v.text_edit_bg_color = Some(t.sunken);
+ v.faint_bg_color = t.sunken;
+ v.code_bg_color = t.sunken;
+ v.window_stroke = Stroke::new(1.0, t.line_strong);
+ v.window_corner_radius = CornerRadius::ZERO;
+ v.menu_corner_radius = CornerRadius::ZERO;
+ v.window_shadow = shadow(t);
+ v.popup_shadow = shadow(t);
+ v.override_text_color = Some(t.ink);
+ v.hyperlink_color = t.accent_ink;
+ v.selection.bg_fill = t.accent;
+ v.selection.stroke = Stroke::new(1.0, Color32::WHITE);
+ v.warn_fg_color = t.warn;
+ v.error_fg_color = t.bad;
+ for (w, fill, stroke, fg) in [
+ (&mut v.widgets.noninteractive, t.bg, t.line, t.muted),
+ (&mut v.widgets.inactive, t.bg, t.line, t.ink),
+ (&mut v.widgets.hovered, t.sunken, t.line_strong, t.ink_strong),
+ (&mut v.widgets.active, t.sunken, t.line_strong, t.ink_strong),
+ (&mut v.widgets.open, t.sunken, t.line_strong, t.ink_strong),
] {
w.bg_fill = fill;
w.weak_bg_fill = fill;
w.bg_stroke = Stroke::new(1.0, stroke);
- w.corner_radius = r;
- w.fg_stroke = Stroke::new(1.0, t.text);
+ w.corner_radius = CornerRadius::ZERO;
+ w.fg_stroke = Stroke::new(1.0, fg);
+ w.expansion = 0.0;
}
- v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text_muted);
- v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.border);
v
}
@@ -147,13 +187,16 @@ pub fn apply(ctx: &egui::Context, choice: ThemeChoice) {
});
ctx.all_styles_mut(|style| {
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
+ // Between a checkbox (or radio button) and its label.
+ style.spacing.icon_spacing = 10.0;
style.spacing.button_padding = egui::vec2(12.0, 6.0);
- style.spacing.interact_size.y = 30.0;
- style.spacing.menu_margin = egui::Margin::same(6);
+ style.spacing.interact_size.y = CONTROL_H;
+ style.spacing.menu_margin = egui::Margin::same(4);
+ style.spacing.window_margin = egui::Margin::same(4);
style.text_styles = [
- (TextStyle::Heading, FontId::new(22.0, semibold())),
+ (TextStyle::Heading, FontId::new(24.0, display())),
(TextStyle::Body, FontId::new(14.0, FontFamily::Proportional)),
- (TextStyle::Button, FontId::new(14.0, medium())),
+ (TextStyle::Button, FontId::new(13.5, medium())),
(TextStyle::Small, FontId::new(12.0, FontFamily::Proportional)),
(TextStyle::Monospace, FontId::new(13.0, FontFamily::Monospace)),
]
@@ -161,29 +204,66 @@ pub fn apply(ctx: &egui::Context, choice: ThemeChoice) {
});
}
+/// Height of buttons, inputs and filter cells (the site's medium button).
+pub const CONTROL_H: f32 = 34.0;
+
+/// The site's `.hud-label`: 11 px Geist Mono 500, upper case, 0.15 em apart.
+pub const HUD_SIZE: f32 = 11.0;
+pub const HUD_SPACING: f32 = HUD_SIZE * 0.15;
+/// Buttons: 11 px Geist Mono 700, upper case, 0.12 em apart.
+pub const BUTTON_SPACING: f32 = HUD_SIZE * 0.12;
+
+/// Inter 500.
pub fn medium() -> FontFamily {
FontFamily::Name("medium".into())
}
+/// Inter 600.
pub fn semibold() -> FontFamily {
FontFamily::Name("semibold".into())
}
-/// Registers Inter with named weights.
+/// Archivo 620 at 118 % width: the site's `.font-display` headings and app wordmarks.
+pub fn display() -> FontFamily {
+ FontFamily::Name("display".into())
+}
+
+/// Geist Mono 500: labels and badges.
+pub fn mono() -> FontFamily {
+ FontFamily::Name("mono".into())
+}
+
+/// Geist Mono 700: buttons and app number tabs.
+pub fn mono_bold() -> FontFamily {
+ FontFamily::Name("mono-bold".into())
+}
+
+/// A font registered under `name`: its file, its variation coordinates and its family.
+type Face<'a> = (&'a str, &'static [u8], &'a [([u8; 4], f32)], FontFamily);
+
+/// Registers the site's three variable fonts, each at the weights (and width) it uses there.
pub fn install_fonts(ctx: &egui::Context) {
+ const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
+ const ARCHIVO: &[u8] = include_bytes!("../../../assets/fonts/Archivo-Variable.ttf");
+ const GEIST_MONO: &[u8] = include_bytes!("../../../assets/fonts/GeistMono-Variable.ttf");
let mut fonts = FontDefinitions::default();
- let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
- fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
- };
- add(&mut fonts, "Inter", include_bytes!("../../../assets/fonts/Inter-Regular.ttf"));
- add(&mut fonts, "Inter-Medium", include_bytes!("../../../assets/fonts/Inter-Medium.ttf"));
- add(&mut fonts, "Inter-SemiBold", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"));
- fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".to_owned());
+ // egui's own fonts stay behind ours, for symbols and emoji ours don't have.
let fallback: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
- for (family, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] {
- let mut stack = vec![primary.to_owned()];
+ let faces: [Face<'_>; 7] = [
+ ("Inter", INTER, &[(*b"wght", 400.0), (*b"opsz", 14.0)], FontFamily::Proportional),
+ ("Inter-Medium", INTER, &[(*b"wght", 500.0), (*b"opsz", 14.0)], medium()),
+ ("Inter-SemiBold", INTER, &[(*b"wght", 600.0), (*b"opsz", 14.0)], semibold()),
+ ("Archivo-Display", ARCHIVO, &[(*b"wght", 620.0), (*b"wdth", 118.0)], display()),
+ ("GeistMono", GEIST_MONO, &[(*b"wght", 400.0)], FontFamily::Monospace),
+ ("GeistMono-Medium", GEIST_MONO, &[(*b"wght", 500.0)], mono()),
+ ("GeistMono-Bold", GEIST_MONO, &[(*b"wght", 700.0)], mono_bold()),
+ ];
+ for (name, bytes, coords, family) in faces {
+ let tweak = FontTweak { coords: VariationCoords::new(coords.iter().copied()), ..Default::default() };
+ fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes).tweak(tweak)));
+ let mut stack = vec![name.to_owned()];
stack.extend(fallback.iter().cloned());
- fonts.families.insert(FontFamily::Name(family.into()), stack);
+ fonts.families.insert(family, stack);
}
ctx.set_fonts(fonts);
}
diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs
index dfc8c7d3..3c06673f 100644
--- a/crates/ui-egui/src/views.rs
+++ b/crates/ui-egui/src/views.rs
@@ -1,16 +1,17 @@
-//! The header, toolbar, footer, the grid of cards and the table.
+//! The header, toolbar, footer, the grid of cards and the table, laid out like the apps page on
+//! getartcraft.com: hairline cells, mono labels and square controls.
use artcraft_launcher_core::platform::pick_asset;
use artcraft_launcher_core::protocol::{Command, JobPhase};
use artcraft_launcher_core::status::AppStatus;
-use artcraft_launcher_core::{AppGroup, time};
-use egui::{Color32, CornerRadius, RichText, Sense, Stroke, Ui, vec2};
+use artcraft_launcher_core::{AppGroup, SUITE, time};
+use egui::{Color32, CornerRadius, Rect, RichText, Sense, Stroke, Ui, vec2};
-use crate::app::{Dialog, LauncherUi};
+use crate::app::{Dialog, LauncherUi, TitleBar};
use crate::model::{self, AppEntry};
use crate::settings::{Filter, SortKey, ViewMode};
use crate::theme::{self, Tokens, rgb, tint};
-use crate::widgets;
+use crate::widgets::{self, Cell, Kind};
/// What the main button of an app does.
enum Primary {
@@ -21,62 +22,69 @@ enum Primary {
Unavailable(&'static str),
}
-/// ArtCraft's tagline, under the launcher's name in the header.
-const TAGLINE: &str = "Open Source Tools for Creatives, Makers, and Builders";
+/// Height of the header bar (the site's nav).
+pub(crate) const HEADER_H: f32 = 48.0;
+/// Height of the footer bar.
+pub(crate) const FOOTER_H: f32 = 34.0;
-const CARD_MIN_W: f32 = 300.0;
-const CARD_H: f32 = 210.0;
-const GAP: f32 = 14.0;
+const CARD_MIN_W: f32 = 320.0;
+const CARD_H: f32 = 280.0;
+/// The card's top strip: number tab and links.
+const STRIP_H: f32 = 40.0;
+const CARD_PAD: f32 = 20.0;
+/// Room macOS's traffic lights take at the left of the header.
+const TRAFFIC_LIGHTS_W: f32 = 72.0;
+
+/// A run of cards, under an optional (number, title) eyebrow.
+type Section = (Option<(&'static str, &'static str)>, Vec<&'static str>);
impl LauncherUi {
- pub(crate) fn header(&mut self, ui: &mut Ui) {
+ /// The header bar; with a custom or macOS title bar it is also the window's title bar.
+ pub(crate) fn header(&mut self, ui: &mut Ui, bar: TitleBar) {
let t = Tokens::get(ui.ctx());
+ let h = HEADER_H;
+ if bar != TitleBar::Native {
+ widgets::title_bar_drag(ui);
+ }
ui.horizontal(|ui| {
- // The ArtCraft wordmark exactly as supplied, then the launcher's own name beside it.
- // The logo and divider sit centred on the two-line name block beside them.
- const ROW_H: f32 = 42.0;
- let logo_size = vec2(24.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 24.0);
- let (row, _) = ui.allocate_exact_size(vec2(logo_size.x, ROW_H), Sense::hover());
- egui::Image::new(crate::icons::artcraft_logo(t.dark))
- .alt_text("ArtCraft")
- .paint_at(ui, egui::Rect::from_center_size(row.center(), logo_size));
- ui.add_space(10.0);
- let (rule, _) = ui.allocate_exact_size(vec2(1.0, ROW_H), Sense::hover());
- ui.painter().rect_filled(egui::Rect::from_center_size(rule.center(), vec2(1.0, 30.0)), CornerRadius::ZERO, t.border_strong);
- ui.add_space(10.0);
- // Leave room for the controls on the right; the tagline gives way first.
- let room = (ui.available_width() - 300.0).max(120.0);
- ui.vertical(|ui| {
- ui.set_max_width(room);
- ui.spacing_mut().item_spacing.y = 1.0;
- ui.horizontal(|ui| {
- ui.spacing_mut().item_spacing.x = 0.0;
- ui.label(RichText::new("Launcher").size(17.0).family(theme::semibold()).color(t.text));
- ui.add(egui::Label::new(RichText::new(" · Craft App Suite").size(14.0).color(t.text_muted)).truncate());
- });
- ui.add(egui::Label::new(RichText::new(TAGLINE).size(12.0).color(t.text_faint)).truncate());
- });
+ ui.spacing_mut().item_spacing.x = 0.0;
+ if bar == TitleBar::MacOs {
+ ui.add_space(TRAFFIC_LIGHTS_W);
+ }
+ // The ArtCraft wordmark exactly as supplied, in its own cell.
+ let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);
+ let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());
+ egui::Image::new(crate::icons::artcraft_logo(t.dark)).alt_text("ArtCraft").paint_at(ui, Rect::from_center_size(cell.center(), logo));
+ widgets::rule(ui, h);
+ ui.add_space(16.0);
+ widgets::tag(ui, "Launcher", t.invert_bg, t.invert_fg);
+ ui.add_space(12.0);
+ widgets::hud(ui, "Craft App Suite", t.muted);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- if widgets::icon_button(ui, "⚙", "Settings").clicked() {
+ if bar == TitleBar::Custom {
+ widgets::window_buttons(ui, h);
+ }
+ if widgets::nav_icon(ui, "⚙", "Settings", h, false).clicked() {
self.dialog = Some(Dialog::Settings);
}
- let refresh = ui.add_enabled_ui(!self.checking, |ui| widgets::icon_button(ui, "⟳", "Check for updates")).inner;
- if refresh.clicked() {
+ if widgets::nav_icon(ui, "⟳", "Check for updates", h, self.checking).clicked() {
self.check_now();
}
- if self.checking {
- ui.add(egui::Spinner::new().size(16.0).color(t.text_muted));
- }
- ui.add_space(8.0);
- widgets::segmented(
- ui,
- &mut self.settings.view,
- &[(ViewMode::Grid, "⊞ Grid", "Show apps as cards"), (ViewMode::Table, "☰ Table", "Show apps as a table")],
- );
- ui.add_space(8.0);
- if widgets::discord_button(ui).clicked() {
+ ui.add_space(6.0);
+ if widgets::nav_link(ui, "Discord", "Join the ArtCraft community on Discord", h, false).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(artcraft_launcher_core::catalog::DISCORD_URL));
}
+ ui.add_space(6.0);
+ widgets::rule(ui, h);
+ ui.add_space(6.0);
+ // Right to left: Table, then Grid, so they read Grid · Table.
+ for (mode, label, tip) in [(ViewMode::Table, "Table", "Show apps as a table"), (ViewMode::Grid, "Grid", "Show apps as cards")] {
+ if widgets::nav_link(ui, label, tip, h, self.settings.view == mode).clicked() {
+ self.settings.view = mode;
+ }
+ }
+ ui.add_space(6.0);
+ widgets::rule(ui, h);
});
});
}
@@ -85,13 +93,18 @@ impl LauncherUi {
let t = Tokens::get(ui.ctx());
if let Some(r) = self.launcher_update.clone() {
egui::Frame::new()
- .fill(tint(t.accent, if t.dark { 0.14 } else { 0.08 }))
- .stroke(Stroke::new(1.0, tint(t.accent, 0.45)))
- .corner_radius(theme::RADIUS)
- .inner_margin(egui::Margin::symmetric(14, 8))
+ .fill(tint(t.accent, if t.dark { 0.10 } else { 0.07 }))
+ .stroke(Stroke::new(1.0, tint(t.accent, 0.6)))
+ .inner_margin(egui::Margin::symmetric(16, 10))
.show(ui, |ui| {
ui.horizontal(|ui| {
- ui.label(RichText::new(format!("ArtCraft Launcher {} is available.", r.version_label())).family(theme::medium()));
+ widgets::tag(ui, "Update", t.accent, Color32::WHITE);
+ ui.add_space(4.0);
+ ui.label(
+ RichText::new(format!("ArtCraft Launcher {} is available.", r.version_label()))
+ .family(theme::medium())
+ .color(t.ink_strong),
+ );
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(phase) = self.launcher_job {
let (fraction, caption) = match phase {
@@ -106,11 +119,11 @@ impl LauncherUi {
widgets::progress(ui, fraction, &caption, t.accent, 220.0);
return;
}
- if widgets::icon_button(ui, "×", "Dismiss").clicked() {
+ if widgets::glyph_button(ui, widgets::Glyph::Close, "Dismiss").clicked() {
self.launcher_update = None;
}
if self.caps.self_update {
- if widgets::primary(ui, "Update and restart", t.accent, 0.0)
+ if widgets::button(ui, "Update and restart", Kind::Primary, 0.0)
.on_hover_text("Downloads the new version, replaces this one and opens it again")
.clicked()
{
@@ -118,39 +131,34 @@ impl LauncherUi {
}
} else {
let url = self.platform.and_then(|p| launcher_download(&r, p)).unwrap_or_else(|| r.html_url.clone());
- if widgets::primary(ui, "Download", t.accent, 0.0).clicked() {
+ if widgets::button(ui, "Download", Kind::Primary, 0.0).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
- if ui.link("What's new").clicked() {
- ui.ctx().open_url(egui::OpenUrl::new_tab(r.html_url.clone()));
- }
+ ui.add_space(8.0);
+ widgets::hud_link(ui, "What's new", &r.html_url);
});
});
});
- ui.add_space(10.0);
+ ui.add_space(14.0);
}
ui.horizontal(|ui| {
- ui.spacing_mut().item_spacing.x = 6.0;
let updates = self.count(Filter::Updates);
- for (filter, label, highlight) in [
- (Filter::All, "All", None),
- (Filter::Installed, "Installed", None),
- (Filter::Updates, "Updates", Some(t.warn)),
- (Filter::NotInstalled, "Not installed", None),
- ] {
- if !self.caps.manage_apps && filter != Filter::All {
- continue;
- }
- let n = self.count(filter);
- if widgets::chip(ui, self.settings.filter == filter, label, n, highlight).clicked() {
- self.settings.filter = filter;
- }
- }
+ let filters: Vec<(Filter, Cell<'_>)> = [
+ (Filter::All, "All", "Every Craft app", None),
+ (Filter::Installed, "Installed", "Apps on this computer", None),
+ (Filter::Updates, "Updates", "Installed apps with a newer version", Some(t.warn)),
+ (Filter::NotInstalled, "Not installed", "Apps you can install", None),
+ ]
+ .into_iter()
+ .filter(|(f, ..)| self.caps.manage_apps || *f == Filter::All)
+ .map(|(f, label, tip, highlight)| (f, Cell { label, tip, count: Some((self.count(f), highlight)) }))
+ .collect();
+ widgets::cells(ui, &mut self.settings.filter, &filters);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if self.caps.manage_apps && updates > 0 {
let label = if updates == 1 { "Update 1 app".to_string() } else { format!("Update all ({updates})") };
- if widgets::primary(ui, &label, t.accent, 0.0).clicked() {
+ if widgets::button(ui, &label, Kind::Primary, 0.0).clicked() {
let ids: Vec<&str> = self
.entries
.iter()
@@ -163,11 +171,18 @@ impl LauncherUi {
}
ui.add_space(4.0);
}
- ui.add(egui::TextEdit::singleline(&mut self.search).hint_text("🔍 Search apps").desired_width(200.0).margin(vec2(10.0, 6.0)));
+ ui.add(
+ egui::TextEdit::singleline(&mut self.search)
+ .hint_text("Search apps")
+ .desired_width(200.0)
+ .min_size(vec2(0.0, theme::CONTROL_H))
+ .vertical_align(egui::Align::Center)
+ .margin(vec2(10.0, 0.0)),
+ );
if self.settings.view == ViewMode::Grid {
egui::ComboBox::from_id_salt("sort")
.selected_text(RichText::new(sort_label(self.settings.sort)).size(13.0))
- .width(130.0)
+ .width(140.0)
.show_ui(ui, |ui| {
for key in [
SortKey::Suite,
@@ -184,6 +199,7 @@ impl LauncherUi {
}
}
});
+ widgets::hud(ui, "Sort", t.faint);
}
});
});
@@ -192,15 +208,14 @@ impl LauncherUi {
pub(crate) fn footer(&mut self, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let now = self.backend.now();
- ui.horizontal(|ui| {
- let small = |s: String| RichText::new(s).size(12.0).color(t.text_muted);
+ ui.horizontal_centered(|ui| {
if self.checking {
- ui.label(small("Checking for updates…".into()));
+ widgets::hud(ui, "Checking for updates…", t.faint);
} else if let Some(at) = self.settings.last_check {
let via = self.last_source.map(|s| format!(" · {}", s.label())).unwrap_or_default();
- ui.label(small(format!("Checked {}{via}", time::format_ago(at, now))));
+ widgets::hud(ui, &format!("Checked {}{via}", time::format_ago(at, now)), t.faint);
} else {
- ui.label(small("Not checked yet".into()));
+ widgets::hud(ui, "Not checked yet", t.faint);
}
if let Some(until) = self.rate_limited_until.filter(|u| *u > now) {
ui.label(
@@ -215,20 +230,19 @@ impl LauncherUi {
Some(p) => format!("Downloads for {}", p.label()),
None => "No downloads for this system".into(),
};
- ui.label(small(label));
- ui.add_space(14.0);
+ widgets::hud(ui, &label, t.faint);
+ ui.add_space(16.0);
// Right to left, so this reads: Website · GitHub · Discord, then the social icons.
ui.scope(|ui| {
- ui.spacing_mut().item_spacing.x = 2.0;
+ ui.spacing_mut().item_spacing.x = 0.0;
widgets::social_icons(ui, 13.0);
});
- ui.add_space(8.0);
+ ui.add_space(12.0);
+ ui.spacing_mut().item_spacing.x = 16.0;
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
- widgets::small_link(ui, "Discord", DISCORD_URL);
- ui.label(RichText::new("·").size(11.5).color(t.text_faint));
- widgets::small_link(ui, "GitHub", GITHUB_URL);
- ui.label(RichText::new("·").size(11.5).color(t.text_faint));
- widgets::small_link(ui, "Website", WEBSITE_URL);
+ widgets::hud_link(ui, "Discord", DISCORD_URL);
+ widgets::hud_link(ui, "GitHub", GITHUB_URL);
+ widgets::hud_link(ui, "Website", WEBSITE_URL);
});
});
}
@@ -237,16 +251,17 @@ impl LauncherUi {
pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) {
let width = ui.available_width();
- let cols = (((width + GAP) / (CARD_MIN_W + GAP)).floor() as usize).max(1);
- let card_w = ((width - GAP * (cols as f32 - 1.0)) / cols as f32).floor();
+ // Neighbouring cards share their 1 px hairline, like the site's `gap-px` grid.
+ let cols = ((width / CARD_MIN_W).floor() as usize).max(1);
+ let card_w = (width + (cols as f32 - 1.0)) / cols as f32;
// In suite order, split into the classic apps and the new ones.
- let grouped = self.settings.sort == SortKey::Suite && self.settings.filter == crate::settings::Filter::All && self.search.trim().is_empty();
- let sections: Vec<(Option<&str>, Vec<&'static str>)> = if grouped {
- [AppGroup::Classic, AppGroup::New]
+ let grouped = self.settings.sort == SortKey::Suite && self.settings.filter == Filter::All && self.search.trim().is_empty();
+ let sections: Vec = if grouped {
+ [(AppGroup::Classic, "01", "Classic apps"), (AppGroup::New, "02", "New apps")]
.iter()
- .map(|g| {
+ .map(|(g, index, title)| {
let list = ids.iter().copied().filter(|id| self.entry(id).is_some_and(|e| e.app.group == *g)).collect();
- (Some(if *g == AppGroup::Classic { "Classic apps" } else { "New apps" }), list)
+ (Some((*index, *title)), list)
})
.collect()
} else {
@@ -257,96 +272,104 @@ impl LauncherUi {
if list.is_empty() {
continue;
}
- if let Some(title) = title {
- ui.add_space(6.0);
- ui.label(RichText::new(title.to_uppercase()).size(11.5).family(theme::semibold()).color(t.text_faint));
+ if let Some((index, title)) = title {
ui.add_space(4.0);
- }
- for row in list.chunks(cols) {
ui.horizontal(|ui| {
- ui.spacing_mut().item_spacing.x = GAP;
- for id in row {
- self.card(ui, id, card_w);
- }
+ widgets::eyebrow(ui, index, title);
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ widgets::hud(ui, &format!("{} apps", list.len()), t.faint);
+ });
});
- ui.add_space(GAP);
+ ui.add_space(10.0);
}
+ let rows = list.len().div_ceil(cols);
+ let height = rows as f32 * (CARD_H - 1.0) + 1.0;
+ let (area, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
+ for (i, id) in list.iter().enumerate() {
+ let (row, col) = (i / cols, i % cols);
+ let min = area.min + vec2(col as f32 * (card_w - 1.0), row as f32 * (CARD_H - 1.0));
+ self.card(ui, id, Rect::from_min_size(min, vec2(card_w, CARD_H)));
+ }
+ ui.add_space(24.0);
}
}
- fn card(&mut self, ui: &mut Ui, id: &str, width: f32) {
+ fn card(&mut self, ui: &mut Ui, id: &str, rect: Rect) {
let Some(entry) = self.entry(id).cloned() else { return };
let t = Tokens::get(ui.ctx());
- let (rect, resp) = ui.allocate_exact_size(vec2(width, CARD_H), Sense::hover());
- let hovered = resp.hovered() || ui.rect_contains_pointer(rect);
- let app_color = rgb(entry.app.color);
- let painter = ui.painter_at(rect.expand(12.0));
- if hovered {
- painter.add(
- egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 110 } else { 28 }) }
- .as_shape(rect, CornerRadius::same(theme::RADIUS)),
- );
+ let app = entry.app;
+ let hovered = ui.rect_contains_pointer(rect);
+ let lift = ui.ctx().animate_bool_with_time(ui.id().with(("card-hover", id)), hovered, 0.3);
+ let app_color = rgb(app.color);
+ let painter = ui.painter_at(rect.expand(16.0));
+ painter.rect(rect, CornerRadius::ZERO, t.bg, t.hairline(), egui::StrokeKind::Inside);
+ // On hover, a 2 px rule in the app's colour wipes in from the left along the top.
+ if lift > 0.0 {
+ painter.rect_filled(Rect::from_min_size(rect.min, vec2(rect.width() * lift, 2.0)), CornerRadius::ZERO, app_color);
+ }
+
+ // The top strip: the app's number in its colour, and its pages.
+ let strip = Rect::from_min_size(rect.min, vec2(rect.width(), STRIP_H));
+ painter.hline(strip.x_range(), strip.bottom() - 0.5, t.hairline());
+ let number = SUITE.iter().position(|a| a.id == app.id).map_or_else(String::new, |i| format!("{:02}", i + 1));
+ {
+ let mut strip_ui = ui.new_child(
+ egui::UiBuilder::new().max_rect(strip.shrink2(vec2(CARD_PAD, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)),
+ );
+ widgets::tag(&mut strip_ui, &number, app_color, Color32::BLACK);
+ strip_ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ ui.spacing_mut().item_spacing.x = 16.0;
+ widgets::hud_link(ui, "GitHub", &app.repo_url());
+ if let Some(site) = app.website() {
+ widgets::hud_link(ui, "Website", &site);
+ }
+ });
}
- painter.rect(
- rect,
- CornerRadius::same(theme::RADIUS),
- if hovered { t.card_hover } else { t.card },
- Stroke::new(1.0, if hovered { t.border_strong } else { t.border }),
- egui::StrokeKind::Inside,
- );
- // A soft wash of the app's colour from the top edge.
- gradient_top(&painter, rect.shrink(1.0), theme::RADIUS as f32 - 1.0, 74.0, app_color, if t.dark { 0.16 } else { 0.10 });
let status = entry.status(self.platform, self.caps.manage_apps);
- let inner = rect.shrink2(vec2(16.0, 14.0));
+ let inner = Rect::from_min_max(egui::pos2(rect.left() + CARD_PAD, strip.bottom() + 18.0), rect.max - vec2(CARD_PAD, CARD_PAD));
// A detached child: the card's contents never move the next card along.
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
- // The app's own pages, top right: its website (classic apps) and its GitHub repo.
- {
- let corner = egui::Rect::from_min_max(egui::pos2(inner.right() - 130.0, inner.top()), egui::pos2(inner.right(), inner.top() + 18.0));
- let mut links = ui.new_child(egui::UiBuilder::new().max_rect(corner).layout(egui::Layout::right_to_left(egui::Align::Min)));
- links.spacing_mut().item_spacing.x = 10.0;
- widgets::small_link(&mut links, "GitHub", &entry.app.repo_url());
- if let Some(site) = entry.app.website() {
- widgets::small_link(&mut links, "Website", &site);
+ let ui = &mut child;
+ ui.horizontal(|ui| {
+ const ICON: f32 = 56.0;
+ let (slot, _) = ui.allocate_exact_size(vec2(ICON, ICON), Sense::hover());
+ // The icon rises a little on hover, over a soft drop shadow (the site's only shadow).
+ let icon_rect = slot.translate(vec2(0.0, -4.0 * lift));
+ ui.painter().add(
+ egui::epaint::Shadow { offset: [0, 6], blur: 14, spread: 0, color: Color32::from_black_alpha(71) }
+ .as_shape(icon_rect.shrink(4.0), CornerRadius::same(12)),
+ );
+ if let Some(icon) = crate::icons::app(app.id) {
+ egui::Image::new(icon).paint_at(ui, icon_rect);
}
- }
- {
- let ui = &mut child;
- ui.horizontal(|ui| {
- if let Some(icon) = crate::icons::app(entry.app.id) {
- ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(54.0, 54.0)));
- }
- ui.add_space(4.0);
- ui.vertical(|ui| {
- ui.spacing_mut().item_spacing.y = 2.0;
- ui.add_space(3.0);
- ui.horizontal(|ui| {
- ui.label(RichText::new(entry.app.name).size(17.0).family(theme::semibold()).color(t.text));
- if entry.app.group == AppGroup::New {
- ui.label(RichText::new("NEW").size(9.5).family(theme::semibold()).color(app_color));
- }
- });
- ui.add(egui::Label::new(RichText::new(entry.app.tagline).size(12.5).color(t.text_muted)).truncate());
- ui.add(egui::Label::new(RichText::new(format!("Like {}", entry.app.inspired_by())).size(11.5).color(t.text_faint)).truncate());
+ ui.add_space(6.0);
+ ui.vertical(|ui| {
+ ui.spacing_mut().item_spacing.y = 4.0;
+ ui.horizontal(|ui| {
+ widgets::wordmark(ui, app, 22.0);
+ if app.group == AppGroup::New {
+ widgets::badge(ui, "New", t.app_ink(app));
+ }
});
+ ui.add(egui::Label::new(RichText::new(app.tagline).size(13.5).color(t.muted)).truncate());
+ ui.add(egui::Label::new(RichText::new(format!("Like {}", app.inspired_by())).size(12.0).color(t.faint)).truncate());
});
- ui.add_space(10.0);
- ui.horizontal(|ui| {
- let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
- let tip = entry.failure().map(|f| format!("Couldn't check for updates: {f}"));
- let r = widgets::pill(ui, text, tone);
- if let Some(tip) = tip {
- r.on_hover_text(tip);
- }
- });
- ui.add_space(2.0);
- ui.label(RichText::new(version_line(&entry, &status, self.backend.now())).size(12.0).color(t.text_muted));
- ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
- ui.horizontal(|ui| self.actions(ui, &entry, &status, inner.width()));
- });
- }
+ });
+ ui.add_space(14.0);
+ ui.horizontal(|ui| {
+ let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
+ let r = widgets::badge(ui, text, widgets::tone_color(&t, tone));
+ if let Some(f) = entry.failure() {
+ r.on_hover_text(format!("Couldn't check for updates: {f}"));
+ }
+ });
+ ui.add_space(4.0);
+ ui.label(RichText::new(version_line(&entry, &status)).size(12.5).color(t.muted));
+ ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
+ ui.horizontal(|ui| self.actions(ui, &entry, &status, inner.width()));
+ });
}
// ---- table ---------------------------------------------------------------------------
@@ -356,119 +379,137 @@ impl LauncherUi {
let t = Tokens::get(ui.ctx());
let now = self.backend.now();
let mut sort_clicked = None;
- egui::Frame::new()
- .fill(t.card)
- .stroke(Stroke::new(1.0, t.border))
- .corner_radius(theme::RADIUS)
- .inner_margin(egui::Margin::symmetric(12, 6))
- .show(ui, |ui| {
- TableBuilder::new(ui)
- .striped(false)
- .vscroll(false)
- .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
- .column(Column::remainder().at_least(200.0).clip(true))
- .column(Column::exact(172.0).clip(true))
- .column(Column::exact(176.0))
- .column(Column::exact(84.0))
- .column(Column::exact(84.0))
- .column(Column::exact(104.0))
- .column(Column::exact(206.0))
- .header(34.0, |mut header| {
- for (key, label) in [
- (Some(SortKey::Name), "App"),
- (Some(SortKey::InspiredBy), "Inspired by"),
- (Some(SortKey::Status), "Status"),
- (Some(SortKey::Installed), "Installed"),
- (Some(SortKey::Latest), "Latest"),
- (Some(SortKey::Released), "Released"),
- (None, ""),
- ] {
- header.col(|ui| {
- let Some(k) = key else { return };
- let active = k == self.settings.sort;
- let tip = if active { "Click to reverse the order" } else { "Click to sort by this column" };
- // Measure first so the whole header cell (label + arrow) is the target.
- let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::click());
- let hovered = resp.hovered();
- let color = if active || hovered { t.text } else { t.text_faint };
- let p = ui.painter();
- let font = egui::FontId::new(11.0, theme::semibold());
- let galley = p.layout_no_wrap(label.to_uppercase(), font.clone(), color);
- let text_pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0);
- let arrow_x = text_pos.x + galley.size().x + 5.0;
- p.galley(text_pos, galley, color);
- // ▲ / ▼ for the active column; a faint hint on hover.
- let arrow = if active {
- Some(self.settings.sort_descending)
- } else if hovered {
- Some(k.starts_descending())
- } else {
- None
- };
- if let Some(down) = arrow {
- let c = egui::pos2(arrow_x + 4.0, rect.center().y);
- let (tip_y, base_y) = if down { (c.y + 3.0, c.y - 2.0) } else { (c.y - 3.0, c.y + 2.0) };
- let pts = vec![egui::pos2(c.x - 3.5, base_y), egui::pos2(c.x + 3.5, base_y), egui::pos2(c.x, tip_y)];
- let arrow_color = if active { t.accent } else { t.text_faint };
- p.add(egui::Shape::convex_polygon(pts, arrow_color, Stroke::NONE));
+ // The app column's name and tagline line heights, to centre them on the icon.
+ const TITLE_SIZE: f32 = 16.0;
+ const TAGLINE_SIZE: f32 = 12.0;
+ let (title_h, tagline_h) = ui
+ .ctx()
+ .fonts_mut(|f| (f.row_height(&egui::FontId::new(TITLE_SIZE, theme::display())), f.row_height(&egui::FontId::proportional(TAGLINE_SIZE))));
+ // Rows are divided by hairlines across the whole table, drawn from the first cell and
+ // clipped to the scroll area.
+ let clip = ui.clip_rect();
+ let span = ui.available_rect_before_wrap().x_range();
+ let rule = |ui: &Ui| {
+ let y = ui.max_rect().bottom() + ui.spacing().item_spacing.y / 2.0;
+ ui.ctx().layer_painter(ui.layer_id()).with_clip_rect(clip).hline(span, y, t.hairline());
+ };
+ egui::Frame::new().fill(t.bg).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| {
+ TableBuilder::new(ui)
+ .striped(false)
+ .vscroll(false)
+ .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
+ .column(Column::remainder().at_least(220.0).clip(true))
+ .column(Column::exact(172.0).clip(true))
+ .column(Column::exact(176.0))
+ .column(Column::exact(84.0))
+ .column(Column::exact(84.0))
+ .column(Column::exact(104.0))
+ .column(Column::exact(206.0))
+ .header(42.0, |mut header| {
+ for (key, label) in [
+ (Some(SortKey::Name), "App"),
+ (Some(SortKey::InspiredBy), "Inspired by"),
+ (Some(SortKey::Status), "Status"),
+ (Some(SortKey::Installed), "Installed"),
+ (Some(SortKey::Latest), "Latest"),
+ (Some(SortKey::Released), "Released"),
+ (None, ""),
+ ] {
+ header.col(|ui| {
+ if key == Some(SortKey::Name) {
+ rule(ui);
+ }
+ let Some(k) = key else { return };
+ let active = k == self.settings.sort;
+ let tip = if active { "Click to reverse the order" } else { "Click to sort by this column" };
+ // Measure first so the whole header cell (label + arrow) is the target.
+ let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::click());
+ let hovered = resp.hovered();
+ let color = if active || hovered { t.ink_strong } else { t.faint };
+ let galley = widgets::hud_galley(ui, label, color);
+ let text_pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0);
+ let arrow_x = text_pos.x + galley.size().x + 4.0;
+ let p = ui.painter();
+ p.galley(text_pos, galley, color);
+ // ▲ / ▼ for the active column; a faint hint on hover.
+ let arrow = if active {
+ Some(self.settings.sort_descending)
+ } else if hovered {
+ Some(k.starts_descending())
+ } else {
+ None
+ };
+ if let Some(down) = arrow {
+ let c = egui::pos2(arrow_x + 4.0, rect.center().y);
+ let (tip_y, base_y) = if down { (c.y + 3.0, c.y - 2.0) } else { (c.y - 3.0, c.y + 2.0) };
+ let pts = vec![egui::pos2(c.x - 3.5, base_y), egui::pos2(c.x + 3.5, base_y), egui::pos2(c.x, tip_y)];
+ let arrow_color = if active { t.accent_ink } else { t.faint };
+ p.add(egui::Shape::convex_polygon(pts, arrow_color, Stroke::NONE));
+ }
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
+ if resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
+ sort_clicked = Some(k);
+ }
+ });
+ }
+ })
+ .body(|mut body| {
+ let last = ids.len().saturating_sub(1);
+ for (i, id) in ids.iter().enumerate() {
+ let Some(entry) = self.entry(id).cloned() else { continue };
+ let status = entry.status(self.platform, self.caps.manage_apps);
+ body.row(62.0, |mut row| {
+ row.col(|ui| {
+ if i < last {
+ rule(ui);
}
- resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
- if resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
- sort_clicked = Some(k);
+ if let Some(icon) = crate::icons::app(entry.app.id) {
+ ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(38.0, 38.0)));
+ }
+ ui.add_space(4.0);
+ ui.vertical(|ui| {
+ const GAP: f32 = 2.0;
+ ui.spacing_mut().item_spacing.y = GAP;
+ // Name and tagline as one block, centred on the icon.
+ let block = title_h + GAP + tagline_h;
+ ui.add_space(((ui.available_height() - block) / 2.0).max(0.0));
+ widgets::wordmark(ui, entry.app, TITLE_SIZE);
+ ui.add(egui::Label::new(RichText::new(entry.app.tagline).size(TAGLINE_SIZE).color(t.muted)).truncate());
+ });
+ });
+ row.col(|ui| {
+ ui.add(egui::Label::new(RichText::new(entry.app.inspired_by()).size(13.0).color(t.muted)).truncate());
+ });
+ row.col(|ui| {
+ let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
+ let r = widgets::badge(ui, text, widgets::tone_color(&t, tone));
+ if let Some(f) = entry.failure() {
+ r.on_hover_text(format!("Couldn't check for updates: {f}"));
}
});
- }
- })
- .body(|mut body| {
- for id in ids {
- let Some(entry) = self.entry(id).cloned() else { continue };
- let status = entry.status(self.platform, self.caps.manage_apps);
- body.row(58.0, |mut row| {
- row.col(|ui| {
- if let Some(icon) = crate::icons::app(entry.app.id) {
- ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(36.0, 36.0)));
- }
- ui.vertical(|ui| {
- ui.spacing_mut().item_spacing.y = 1.0;
- ui.label(RichText::new(entry.app.name).size(14.5).family(theme::semibold()).color(t.text));
- ui.add(egui::Label::new(RichText::new(entry.app.tagline).size(12.0).color(t.text_muted)).truncate());
- });
- });
- row.col(|ui| {
- ui.add(egui::Label::new(RichText::new(entry.app.inspired_by()).size(13.0).color(t.text_muted)).truncate());
- });
- row.col(|ui| {
- let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
- let r = widgets::pill(ui, text, tone);
- if let Some(f) = entry.failure() {
- r.on_hover_text(format!("Couldn't check for updates: {f}"));
- }
- });
- row.col(|ui| {
- ui.label(
- RichText::new(entry.installed_label().unwrap_or_else(|| "—".into())).color(if entry.installed.is_some() {
- t.text
- } else {
- t.text_faint
- }),
- );
- });
- row.col(|ui| {
- let c = if status == AppStatus::UpdateAvailable { t.warn } else { t.text };
- ui.label(RichText::new(entry.latest_label().unwrap_or_else(|| "—".into())).color(c));
- });
- row.col(|ui| {
- let when = entry.release().and_then(|r| r.published_at);
- let r = ui.label(RichText::new(when.map_or_else(|| "—".into(), time::format_date)).color(t.text_muted));
- if let Some(w) = when {
- r.on_hover_text(time::format_ago(w, now));
- }
- });
- row.col(|ui| self.actions(ui, &entry, &status, 196.0));
+ row.col(|ui| {
+ let installed = entry.installed_label();
+ let color = if installed.is_some() { t.ink } else { t.faint };
+ ui.label(RichText::new(installed.unwrap_or_else(|| "—".into())).family(egui::FontFamily::Monospace).color(color));
});
- }
- });
- });
+ row.col(|ui| {
+ let c = if status == AppStatus::UpdateAvailable { t.warn } else { t.ink };
+ ui.label(
+ RichText::new(entry.latest_label().unwrap_or_else(|| "—".into())).family(egui::FontFamily::Monospace).color(c),
+ );
+ });
+ row.col(|ui| {
+ let when = entry.release().and_then(|r| r.published_at);
+ let r = ui.label(RichText::new(when.map_or_else(|| "—".into(), time::format_date)).color(t.muted));
+ if let Some(w) = when {
+ r.on_hover_text(time::format_ago(w, now));
+ }
+ });
+ row.col(|ui| self.actions(ui, &entry, &status, 204.0));
+ });
+ }
+ });
+ });
if let Some(k) = sort_clicked {
if self.settings.sort == k {
self.settings.sort_descending = !self.settings.sort_descending;
@@ -482,10 +523,8 @@ impl LauncherUi {
/// The trademark notice under the grid and the table.
pub(crate) fn trademark_notice(&self, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
- ui.add_space(10.0);
- ui.separator();
- ui.add_space(6.0);
- ui.add(egui::Label::new(RichText::new(artcraft_launcher_core::catalog::TRADEMARK_NOTICE).size(11.0).color(t.text_faint)).wrap());
+ ui.add_space(8.0);
+ ui.add(egui::Label::new(RichText::new(artcraft_launcher_core::catalog::TRADEMARK_NOTICE).size(11.0).color(t.faint)).wrap());
}
// ---- actions -------------------------------------------------------------------------
@@ -514,14 +553,16 @@ impl LauncherUi {
}
}
- /// The main button (or progress) and the ⋯ menu.
+ /// The main button (or progress) and the … menu, within `width`.
fn actions(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32) {
let t = Tokens::get(ui.ctx());
let id = entry.app.id.to_string();
- let main_w = (width - 40.0).max(120.0);
+ // One square button (the menu) beside the main one.
+ let gap = ui.spacing().item_spacing.x;
+ let main_w = (width - theme::CONTROL_H - gap).max(120.0);
if let Some(phase) = entry.job {
- // Two small buttons (pause, cancel) beside the bar while downloading.
- let bar_w = (width - 88.0).max(100.0);
+ // Two square buttons (pause, cancel) beside the bar while downloading.
+ let bar_w = (width - 2.0 * (theme::CONTROL_H + gap) - 4.0).max(100.0);
let short = bar_w < 200.0;
let amount = |done: u64, total: u64| {
if total > 0 { format!("{} of {}", widgets::human_bytes(done), widgets::human_bytes(total)) } else { widgets::human_bytes(done) }
@@ -540,15 +581,15 @@ impl LauncherUi {
} else {
format!("Downloading {}{pct}", amount(done, total))
};
- (f, caption, t.accent)
+ (f, caption, rgb(entry.app.color))
}
JobPhase::Reconnecting { done, total } => {
let f = (total > 0).then(|| done as f32 / total as f32);
(f, if short { "Reconnecting…".into() } else { format!("Reconnecting… {}", amount(done, total)) }, t.warn)
}
- JobPhase::Verifying => (None, "Verifying checksum…".into(), t.accent),
- JobPhase::Installing => (None, "Installing…".into(), t.accent),
- JobPhase::Removing => (None, "Removing…".into(), t.accent),
+ JobPhase::Verifying => (None, "Verifying checksum…".into(), rgb(entry.app.color)),
+ JobPhase::Installing => (None, "Installing…".into(), rgb(entry.app.color)),
+ JobPhase::Removing => (None, "Removing…".into(), rgb(entry.app.color)),
};
let downloading = matches!(phase, JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. });
widgets::progress(ui, fraction, &caption, color, if downloading { bar_w } else { main_w });
@@ -566,9 +607,15 @@ impl LauncherUi {
&& self.caps.manage_apps
{
let pct = if p.total > 0 { format!(" · {:.0}%", p.done as f32 / p.total as f32 * 100.0) } else { String::new() };
- // Resume, discard (32) and the menu (~40), with spacing, within `width`.
- let label = if width < 260.0 { format!("Resume{pct}") } else { format!("Resume download{pct}") };
- let resp = widgets::primary(ui, &label, t.accent, (width - 96.0).max(90.0));
+ // Resume, then discard and the menu (two squares).
+ let resume_w = (width - 2.0 * (theme::CONTROL_H + gap)).max(90.0);
+ // Narrow (the table): just "Resume"; the tooltip says how far it got.
+ let label = match resume_w {
+ w if w < 150.0 => "Resume".to_string(),
+ w if w < 220.0 => format!("Resume{pct}"),
+ _ => format!("Resume download{pct}"),
+ };
+ let resp = widgets::button(ui, &label, Kind::Primary, resume_w);
let tip = if p.total > 0 {
format!("{} of {} downloaded", widgets::human_bytes(p.done), widgets::human_bytes(p.total))
} else {
@@ -585,17 +632,17 @@ impl LauncherUi {
}
match self.primary_action(entry, status) {
Primary::Install => {
- if widgets::primary(ui, "Install", t.accent, main_w).clicked() {
+ if widgets::button(ui, "Install", Kind::Primary, main_w).clicked() {
self.send(Command::Install { app: id.clone() });
}
}
Primary::Update(v) => {
- if widgets::primary(ui, &format!("Update to {v}"), t.accent, main_w).clicked() {
+ if widgets::button(ui, &format!("Update to {v}"), Kind::Primary, main_w).clicked() {
self.send(Command::Install { app: id.clone() });
}
}
Primary::Open => {
- if widgets::secondary(ui, "Open", main_w).clicked() {
+ if widgets::button(ui, "Open", Kind::Secondary, main_w).clicked() {
self.send(Command::Launch { app: id.clone() });
}
}
@@ -604,12 +651,12 @@ impl LauncherUi {
Some(p) => format!("Download for {}", p.os.label()),
None => "Download".into(),
};
- if widgets::primary(ui, &label, t.accent, main_w).clicked() {
+ if widgets::button(ui, &label, Kind::Primary, main_w).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
Primary::Unavailable(label) => {
- ui.add_enabled_ui(false, |ui| widgets::secondary(ui, label, main_w));
+ ui.add_enabled_ui(false, |ui| widgets::button(ui, label, Kind::Secondary, main_w));
}
}
self.menu(ui, entry, status);
@@ -619,8 +666,7 @@ impl LauncherUi {
let id = entry.app.id.to_string();
let manage = self.caps.manage_apps;
let reveal = self.caps.reveal_label;
- ui.menu_button(RichText::new("…").size(16.0), |ui| {
- ui.set_min_width(200.0);
+ widgets::more_menu(ui, |ui| {
if entry.release().is_some() && ui.button("Release notes").clicked() {
self.dialog = Some(Dialog::Notes(id.clone()));
ui.close();
@@ -694,10 +740,9 @@ fn sort_label(k: SortKey) -> &'static str {
}
}
-/// "Installed 0.4.0 · 0.5.0 out Oct 8, 2026" and friends.
-fn version_line(e: &AppEntry, status: &AppStatus, now: i64) -> String {
+/// "0.4.1 installed → 0.5.0 released · Oct 8, 2026" and friends.
+fn version_line(e: &AppEntry, status: &AppStatus) -> String {
let released = e.release().and_then(|r| r.published_at).map(|at| format!(" · {}", time::format_date(at))).unwrap_or_default();
- let _ = now;
match (status, e.installed_label(), e.latest_label()) {
(AppStatus::UpdateAvailable, Some(i), Some(l)) => format!("{i} installed → {l} released{released}"),
(AppStatus::UpToDate, Some(i), _) => format!("Version {i}{released}"),
@@ -718,27 +763,3 @@ fn launcher_download(r: &artcraft_launcher_core::Release, p: artcraft_launcher_c
};
pick_asset(r, &[artcraft_launcher_core::catalog::LAUNCHER_ID], p, kinds).map(|(a, _)| a.url.clone())
}
-
-/// Fills the top `height` of a rounded rect with opaque `color` at `alpha`, fading to transparent.
-fn gradient_top(painter: &egui::Painter, rect: egui::Rect, radius: f32, height: f32, color: Color32, alpha: f32) {
- let mut mesh = egui::Mesh::default();
- let h = height.min(rect.height());
- let steps = 24;
- let mut prev: Option<(u32, u32)> = None;
- for i in 0..=steps {
- let y = h * i as f32 / steps as f32;
- // Inset along the rounded corner.
- let inset = if y < radius { radius - (radius * radius - (radius - y) * (radius - y)).max(0.0).sqrt() } else { 0.0 };
- let a = 1.0 - i as f32 / steps as f32;
- let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), (255.0 * alpha * a * a) as u8);
- let base = mesh.vertices.len() as u32;
- mesh.colored_vertex(egui::pos2(rect.left() + inset, rect.top() + y), c);
- mesh.colored_vertex(egui::pos2(rect.right() - inset, rect.top() + y), c);
- if let Some((l, r)) = prev {
- mesh.add_triangle(l, r, base);
- mesh.add_triangle(r, base + 1, base);
- }
- prev = Some((base, base + 1));
- }
- painter.add(egui::Shape::mesh(mesh));
-}
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index b6646777..d6d2fca6 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -1,153 +1,409 @@
-//! Small building blocks: pills, buttons, segmented controls.
+//! Small building blocks in getartcraft.com's style: mono labels, square buttons, badges and
+//! hairline cell groups.
+//!
+//! Upper-case text is drawn upper case but announced to screen readers as written.
-use egui::{Color32, CornerRadius, Response, RichText, Sense, Stroke, Ui, vec2};
+use std::sync::Arc;
+
+use egui::{Color32, CornerRadius, Galley, Rect, Response, RichText, Sense, Stroke, Ui, vec2};
use crate::model::Tone;
-use crate::theme::{self, Tokens, tint};
+use crate::theme::{self, Tokens};
pub fn tone_color(t: &Tokens, tone: Tone) -> Color32 {
match tone {
Tone::Good => t.good,
Tone::Warn => t.warn,
- Tone::Info => t.info,
- Tone::Neutral => t.text_muted,
- Tone::Faint => t.text_faint,
+ Tone::Info => t.accent_ink,
+ Tone::Neutral => t.muted,
+ Tone::Faint => t.faint,
}
}
-/// A rounded status label with a coloured dot.
-pub fn pill(ui: &mut Ui, text: &str, tone: Tone) -> Response {
- let t = Tokens::get(ui.ctx());
- let c = tone_color(&t, tone);
- let galley = ui.painter().layout_no_wrap(text.to_string(), egui::FontId::new(12.0, theme::medium()), c);
- let size = vec2(galley.size().x + 26.0, 22.0);
- let (rect, resp) = ui.allocate_exact_size(size, Sense::hover());
- let p = ui.painter();
- p.rect_filled(rect, CornerRadius::same(11), tint(c, if t.dark { 0.14 } else { 0.11 }));
- p.circle_filled(egui::pos2(rect.left() + 11.0, rect.center().y), 3.5, c);
- p.galley(egui::pos2(rect.left() + 19.0, rect.center().y - galley.size().y / 2.0), galley, c);
- // Painted, so tell screen readers what it says.
+/// `text` laid out upper case in `family`, `spacing` points between letters.
+pub fn caps(ui: &Ui, text: &str, size: f32, family: egui::FontFamily, spacing: f32, color: Color32) -> Arc {
+ let format = egui::TextFormat { font_id: egui::FontId::new(size, family), color, extra_letter_spacing: spacing, ..Default::default() };
+ ui.painter().layout_job(egui::text::LayoutJob::single_section(text.to_uppercase(), format))
+}
+
+/// A `.hud-label`: 11 px Geist Mono, upper case, widely spaced.
+pub fn hud_galley(ui: &Ui, text: &str, color: Color32) -> Arc {
+ caps(ui, text, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, color)
+}
+
+/// Paints `galley` at the left of `rect`, centred vertically.
+fn paint_left(ui: &Ui, rect: Rect, galley: Arc, color: Color32) {
+ let pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0);
+ ui.painter().galley_with_override_text_color(pos, galley, color);
+}
+
+/// Paints `galley` centred in `rect`.
+fn paint_centred(ui: &Ui, rect: Rect, galley: Arc, color: Color32) {
+ ui.painter().galley_with_override_text_color(rect.center() - galley.size() / 2.0, galley, color);
+}
+
+/// A mono label.
+pub fn hud(ui: &mut Ui, text: &str, color: Color32) -> Response {
+ let galley = hud_galley(ui, text, color);
+ let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::hover());
+ paint_left(ui, rect, galley, color);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
resp
}
-/// A filled call-to-action button.
-pub fn primary(ui: &mut Ui, text: &str, fill: Color32, width: f32) -> Response {
+/// "01 / Classic apps": a section eyebrow, the index faint.
+pub fn eyebrow(ui: &mut Ui, index: &str, label: &str) {
let t = Tokens::get(ui.ctx());
- let btn = egui::Button::new(RichText::new(text).color(t.accent_text).family(theme::semibold()))
- .fill(fill)
- .stroke(Stroke::NONE)
- .corner_radius(theme::RADIUS_SMALL)
- .min_size(vec2(width, 32.0));
- let resp = ui.add(btn);
- if resp.hovered() {
- ui.painter().rect_filled(resp.rect, CornerRadius::same(theme::RADIUS_SMALL), Color32::from_white_alpha(18));
+ ui.horizontal(|ui| {
+ ui.spacing_mut().item_spacing.x = 0.0;
+ hud(ui, index, t.faint);
+ hud(ui, " / ", t.faint);
+ hud(ui, label, t.muted);
+ });
+}
+
+/// A solid chip with a bold mono label: an app's number tab (the site's `AppTab`).
+pub fn tag(ui: &mut Ui, text: &str, fill: Color32, ink: Color32) -> Response {
+ let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::HUD_SPACING, ink);
+ let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(16.0, 8.0), Sense::hover());
+ ui.painter().rect_filled(rect, CornerRadius::ZERO, fill);
+ paint_centred(ui, rect, galley, ink);
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
+ resp
+}
+
+/// A vertical hairline `height` tall: divides the header's cells.
+pub fn rule(ui: &mut Ui, height: f32) {
+ let t = Tokens::get(ui.ctx());
+ let (rect, _) = ui.allocate_exact_size(vec2(1.0, height), Sense::hover());
+ ui.painter().vline(rect.center().x, rect.y_range(), t.hairline());
+}
+
+/// A header link, like the site's nav: a mono label whose span inverts on hover or when `active`.
+pub fn nav_link(ui: &mut Ui, text: &str, tip: &str, height: f32, active: bool) -> Response {
+ let t = Tokens::get(ui.ctx());
+ let galley = hud_galley(ui, text, t.muted);
+ let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 20.0, height), Sense::click());
+ let on = active || resp.hovered();
+ let span = Rect::from_center_size(rect.center(), galley.size() + vec2(12.0, 6.0));
+ if on {
+ ui.painter().rect_filled(span, CornerRadius::ZERO, t.invert_bg);
}
- resp.on_hover_cursor(egui::CursorIcon::PointingHand)
+ paint_centred(ui, rect, galley, if on { t.invert_fg } else { t.muted });
+ resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, text));
+ resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand)
}
-/// A quiet outlined button.
-pub fn secondary(ui: &mut Ui, text: &str, width: f32) -> Response {
+/// A square header cell with a symbol and a hairline on its left; inverts on hover. While `busy`
+/// it shows a spinner and can't be clicked.
+pub fn nav_icon(ui: &mut Ui, symbol: &str, tip: &str, height: f32, busy: bool) -> Response {
let t = Tokens::get(ui.ctx());
- let btn = egui::Button::new(RichText::new(text).color(t.text).family(theme::medium()))
- .fill(if t.dark { Color32::from_rgb(0x23, 0x27, 0x31) } else { Color32::from_rgb(0xf1, 0xf2, 0xf6) })
- .stroke(Stroke::new(1.0, t.border))
- .corner_radius(theme::RADIUS_SMALL)
- .min_size(vec2(width, 32.0));
- ui.add(btn).on_hover_cursor(egui::CursorIcon::PointingHand)
+ let (rect, resp) = ui.allocate_exact_size(vec2(height, height), if busy { Sense::hover() } else { Sense::click() });
+ let p = ui.painter();
+ let hovered = resp.hovered() && !busy;
+ if hovered {
+ p.rect_filled(rect, CornerRadius::ZERO, t.invert_bg);
+ }
+ p.vline(rect.left() + 0.5, rect.y_range(), t.hairline());
+ if busy {
+ egui::Spinner::new().size(16.0).color(t.muted).paint_at(ui, Rect::from_center_size(rect.center(), vec2(16.0, 16.0)));
+ } else {
+ p.text(rect.center(), egui::Align2::CENTER_CENTER, symbol, egui::FontId::proportional(17.0), if hovered { t.invert_fg } else { t.muted });
+ }
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, !busy, tip));
+ let resp = resp.on_hover_text(tip);
+ if busy { resp } else { resp.on_hover_cursor(egui::CursorIcon::PointingHand) }
}
-/// A square icon-only button.
-pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str) -> Response {
+/// How far in from the window's edges a custom title bar's window resizes ([`window_edges`]).
+/// The title bar's own controls leave this band alone.
+const RESIZE_EDGE: f32 = 5.0;
+
+/// Custom title bars: dragging the header's empty space moves the window, and double-clicking it
+/// maximizes or restores it. Call before drawing the header, so its controls stay on top.
+pub fn title_bar_drag(ui: &mut Ui) {
+ let r = ui.max_rect();
+ let rect = Rect::from_min_max(r.min + vec2(RESIZE_EDGE, RESIZE_EDGE), r.max - vec2(RESIZE_EDGE, 0.0));
+ let resp = ui.interact(rect, ui.id().with("title-bar"), Sense::click_and_drag());
+ if resp.drag_started_by(egui::PointerButton::Primary) {
+ ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
+ }
+ if resp.double_clicked() {
+ let maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
+ ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
+ }
+}
+
+/// A window button in a custom title bar.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum WindowButton {
+ Minimize,
+ Maximize,
+ Restore,
+ Close,
+}
+
+/// Minimize, maximize (or restore) and close, `height` tall, for a right-to-left layout: close
+/// ends up at the right.
+pub fn window_buttons(ui: &mut Ui, height: f32) {
+ let ctx = ui.ctx().clone();
+ let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
+ if window_button(ui, WindowButton::Close, height).clicked() {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+ }
+ let size = if maximized { WindowButton::Restore } else { WindowButton::Maximize };
+ if window_button(ui, size, height).clicked() {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
+ }
+ if window_button(ui, WindowButton::Minimize, height).clicked() {
+ ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
+ }
+ rule(ui, height);
+}
+
+fn window_button(ui: &mut Ui, button: WindowButton, height: f32) -> Response {
let t = Tokens::get(ui.ctx());
- let btn = egui::Button::new(RichText::new(icon).size(16.0).color(t.text_muted))
- .fill(Color32::TRANSPARENT)
- .stroke(Stroke::NONE)
- .min_size(vec2(32.0, 32.0));
- ui.add(btn).on_hover_text(tip)
+ let (rect, _) = ui.allocate_exact_size(vec2(46.0, height), Sense::hover());
+ // Clickable below the resize band along the top (and, for close, the right edge).
+ let right = if button == WindowButton::Close { RESIZE_EDGE } else { 0.0 };
+ let hit = Rect::from_min_max(rect.min + vec2(0.0, RESIZE_EDGE), rect.max - vec2(right, 0.0));
+ let resp = ui.interact(hit, ui.id().with(("window-button", button as u8)), Sense::click());
+ let (fill, ink) = match (button, resp.hovered()) {
+ (WindowButton::Close, true) => (Color32::from_rgb(0xc4, 0x2b, 0x1c), Color32::WHITE),
+ (_, true) => (t.line, t.ink_strong),
+ (_, false) => (Color32::TRANSPARENT, t.muted),
+ };
+ let p = ui.painter();
+ p.rect_filled(rect, CornerRadius::ZERO, fill);
+ let c = rect.center();
+ let stroke = Stroke::new(1.0, ink);
+ let label = match button {
+ WindowButton::Minimize => "Minimize",
+ WindowButton::Maximize => "Maximize",
+ WindowButton::Restore => "Restore",
+ WindowButton::Close => "Close",
+ };
+ // Half the glyph's size: 10 px, like Windows' own caption buttons.
+ let s = 5.0;
+ match button {
+ WindowButton::Minimize => {
+ p.hline(c.x - s..=c.x + s, c.y, stroke);
+ }
+ WindowButton::Maximize => {
+ p.rect_stroke(Rect::from_center_size(c, vec2(2.0 * s, 2.0 * s)), CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
+ }
+ WindowButton::Restore => {
+ // A window in front of another.
+ let front = Rect::from_min_size(c + vec2(-s, -s + 2.0), vec2(2.0 * s - 2.0, 2.0 * s - 2.0));
+ p.rect_stroke(front, CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
+ p.hline(c.x - s + 2.0..=c.x + s, c.y - s, stroke);
+ p.vline(c.x + s, c.y - s..=c.y + s - 2.0, stroke);
+ }
+ WindowButton::Close => {
+ p.line_segment([c + vec2(-s, -s), c + vec2(s, s)], stroke);
+ p.line_segment([c + vec2(-s, s), c + vec2(s, -s)], stroke);
+ }
+ }
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
+ resp.on_hover_text(label)
}
-/// Two or more mutually exclusive options in one rounded control.
-pub fn segmented(ui: &mut Ui, value: &mut T, options: &[(T, &str, &str)]) -> bool {
+/// Custom title bars: a hairline round the window, and resizing it from its edges and corners.
+pub fn window_edges(ctx: &egui::Context) {
+ use egui::CursorIcon as C;
+ use egui::viewport::ResizeDirection as D;
+ let (maximized, fullscreen) = ctx.input(|i| (i.viewport().maximized.unwrap_or(false), i.viewport().fullscreen.unwrap_or(false)));
+ if maximized || fullscreen {
+ return;
+ }
+ let t = Tokens::get(ctx);
+ let r = ctx.content_rect();
+ ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("window-border"))).rect_stroke(
+ r,
+ CornerRadius::ZERO,
+ t.hairline(),
+ egui::StrokeKind::Inside,
+ );
+ let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|p| r.contains(*p)) else { return };
+ let near = |d: f32| d < RESIZE_EDGE;
+ // A corner takes a little of each edge next to it.
+ let corner = |d: f32| d < 3.0 * RESIZE_EDGE;
+ let (left, right, top, bottom) = (p.x - r.left(), r.right() - p.x, p.y - r.top(), r.bottom() - p.y);
+ let hit = if (near(top) && corner(left)) || (near(left) && corner(top)) {
+ Some((D::NorthWest, C::ResizeNorthWest))
+ } else if (near(top) && corner(right)) || (near(right) && corner(top)) {
+ Some((D::NorthEast, C::ResizeNorthEast))
+ } else if (near(bottom) && corner(left)) || (near(left) && corner(bottom)) {
+ Some((D::SouthWest, C::ResizeSouthWest))
+ } else if (near(bottom) && corner(right)) || (near(right) && corner(bottom)) {
+ Some((D::SouthEast, C::ResizeSouthEast))
+ } else if near(top) {
+ Some((D::North, C::ResizeNorth))
+ } else if near(bottom) {
+ Some((D::South, C::ResizeSouth))
+ } else if near(left) {
+ Some((D::West, C::ResizeWest))
+ } else if near(right) {
+ Some((D::East, C::ResizeEast))
+ } else {
+ None
+ };
+ if let Some((direction, cursor)) = hit {
+ ctx.set_cursor_icon(cursor);
+ if ctx.input(|i| i.pointer.primary_pressed()) {
+ ctx.send_viewport_cmd(egui::ViewportCommand::BeginResize(direction));
+ }
+ }
+}
+
+/// A mono link with an ↗: muted, ink on hover. Opens `url` in the browser.
+pub fn hud_link(ui: &mut Ui, text: &str, url: &str) -> Response {
+ let t = Tokens::get(ui.ctx());
+ let galley = hud_galley(ui, &format!("{text} ↗"), t.muted);
+ let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::click());
+ paint_left(ui, rect, galley, if resp.hovered() { t.ink_strong } else { t.muted });
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, text));
+ let resp = resp.on_hover_text(url).on_hover_cursor(egui::CursorIcon::PointingHand);
+ if resp.clicked() {
+ ui.ctx().open_url(egui::OpenUrl::new_tab(url));
+ }
+ resp
+}
+
+/// A small square status badge: 10 px mono on the sunken surface, in `color`.
+pub fn badge(ui: &mut Ui, text: &str, color: Color32) -> Response {
+ let t = Tokens::get(ui.ctx());
+ let galley = caps(ui, text, 10.0, theme::mono(), 1.0, color);
+ let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 12.0, 20.0), Sense::hover());
+ ui.painter().rect(rect, CornerRadius::ZERO, t.sunken, t.hairline(), egui::StrokeKind::Inside);
+ paint_centred(ui, rect, galley, color);
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
+ resp
+}
+
+/// Which of the site's button variants.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Kind {
+ /// A solid block in the inverted colours; fades a little on hover.
+ Primary,
+ /// Outlined; inverts on hover.
+ Secondary,
+ /// Solid red; fades a little on hover.
+ Danger,
+}
+
+/// A square button with a mono upper-case label, at least `width` wide.
+pub fn button(ui: &mut Ui, text: &str, kind: Kind, width: f32) -> Response {
+ let t = Tokens::get(ui.ctx());
+ let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::BUTTON_SPACING, t.ink);
+ let size = vec2((galley.size().x + 32.0).max(width), theme::CONTROL_H);
+ let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
+ let enabled = ui.is_enabled();
+ let hovered = resp.hovered() && enabled;
+ let (fill, stroke, color) = match kind {
+ Kind::Primary => (t.invert_bg.gamma_multiply(if hovered { 0.8 } else { 1.0 }), Stroke::NONE, t.invert_fg),
+ Kind::Danger => (t.bad.gamma_multiply(if hovered { 0.8 } else { 1.0 }), Stroke::NONE, Color32::WHITE),
+ Kind::Secondary if hovered => (t.invert_bg, Stroke::NONE, t.invert_fg),
+ Kind::Secondary => (t.bg, Stroke::new(1.0, t.line_strong), t.ink),
+ };
+ // Disabled: the whole button at half strength, like the site's `opacity-50`.
+ let fade = |c: Color32| if enabled { c } else { c.gamma_multiply(0.5) };
+ ui.painter().rect(rect, CornerRadius::ZERO, fade(fill), Stroke::new(stroke.width, fade(stroke.color)), egui::StrokeKind::Inside);
+ paint_centred(ui, rect, galley, fade(color));
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, text));
+ if enabled { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } else { resp }
+}
+
+/// One option in a [`cells`] group.
+pub struct Cell<'a> {
+ pub label: &'a str,
+ pub tip: &'a str,
+ /// A count after the label, and the colour it takes when it isn't zero.
+ pub count: Option<(usize, Option)>,
+}
+
+/// Mutually exclusive options in a row of hairline cells (the site's share bar); the chosen one
+/// is inverted. Returns whether the choice changed.
+pub fn cells(ui: &mut Ui, value: &mut T, options: &[(T, Cell<'_>)]) -> bool {
let t = Tokens::get(ui.ctx());
let mut changed = false;
- egui::Frame::new()
- .fill(if t.dark { Color32::from_rgb(0x0e, 0x10, 0x14) } else { Color32::from_rgb(0xec, 0xee, 0xf2) })
- .stroke(Stroke::new(1.0, t.border))
- .corner_radius(theme::RADIUS_SMALL + 1)
- .inner_margin(3)
- .show(ui, |ui| {
- ui.spacing_mut().item_spacing.x = 2.0;
- // In a right-to-left parent the row fills from the right: keep the options' order.
- let rtl = ui.layout().prefer_right_to_left();
- ui.horizontal(|ui| {
- let ordered: Vec<&(T, &str, &str)> = if rtl { options.iter().rev().collect() } else { options.iter().collect() };
- for (opt, label, tip) in ordered {
- let on = *value == *opt;
- let btn =
- egui::Button::new(RichText::new(*label).size(13.0).family(theme::medium()).color(if on { t.text } else { t.text_muted }))
- .fill(if on { t.card_hover } else { Color32::TRANSPARENT })
- .stroke(if on { Stroke::new(1.0, t.border_strong) } else { Stroke::NONE })
- .corner_radius(theme::RADIUS_SMALL - 1)
- .min_size(vec2(0.0, 26.0));
- if ui.add(btn).on_hover_text(*tip).clicked() && !on {
- *value = *opt;
- changed = true;
- }
- }
+ // In a right-to-left parent the row fills from the right: keep the options' order.
+ let rtl = ui.layout().prefer_right_to_left();
+ let ordered: Vec<&(T, Cell<'_>)> = if rtl { options.iter().rev().collect() } else { options.iter().collect() };
+ let mut rects = Vec::with_capacity(options.len());
+ ui.scope(|ui| {
+ ui.spacing_mut().item_spacing.x = 0.0;
+ for (opt, cell) in ordered {
+ let on = *value == *opt;
+ let label = hud_galley(ui, cell.label, t.ink);
+ let count = cell.count.map(|(n, highlight)| {
+ let c = if on { t.invert_fg.gamma_multiply(0.6) } else { highlight.filter(|_| n > 0).unwrap_or(t.faint) };
+ (hud_galley(ui, &n.to_string(), c), c)
});
- });
+ let w = label.size().x + count.as_ref().map_or(0.0, |(g, _)| g.size().x + 8.0) + 24.0;
+ let (rect, resp) = ui.allocate_exact_size(vec2(w, theme::CONTROL_H), Sense::click());
+ let hovered = resp.hovered();
+ let (fill, ink) = match (on, hovered) {
+ (true, _) => (t.invert_bg, t.invert_fg),
+ (false, true) => (t.sunken, t.ink_strong),
+ (false, false) => (Color32::TRANSPARENT, t.muted),
+ };
+ ui.painter().rect_filled(rect, CornerRadius::ZERO, fill);
+ let text_rect = rect.shrink2(vec2(12.0, 0.0));
+ let label_w = label.size().x;
+ paint_left(ui, text_rect, label, ink);
+ if let Some((g, c)) = count {
+ paint_left(ui, text_rect.with_min_x(text_rect.left() + label_w + 8.0), g, c);
+ }
+ let name = match cell.count {
+ Some((n, _)) => format!("{} {n}", cell.label),
+ None => cell.label.to_string(),
+ };
+ resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &name));
+ if resp.on_hover_text(cell.tip).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() && !on {
+ *value = *opt;
+ changed = true;
+ }
+ rects.push(rect);
+ }
+ });
+ // The hairlines: an outline, and a rule between neighbours.
+ if let (Some(first), Some(last)) = (rects.first(), rects.last()) {
+ let p = ui.painter();
+ p.rect_stroke(first.union(*last), CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
+ let (top, bottom) = (first.top(), first.bottom());
+ for r in rects.iter().skip(1) {
+ let x = if rtl { r.right() } else { r.left() };
+ p.vline(x, top..=bottom, t.hairline());
+ }
+ }
changed
}
-/// A filter chip with a count.
-pub fn chip(ui: &mut Ui, on: bool, label: &str, count: usize, highlight: Option) -> Response {
- let t = Tokens::get(ui.ctx());
- let text_color = if on { t.text } else { t.text_muted };
- let mut job = egui::text::LayoutJob::default();
- job.append(label, 0.0, egui::TextFormat { font_id: egui::FontId::new(13.0, theme::medium()), color: text_color, ..Default::default() });
- job.append(
- &format!(" {count}"),
- 0.0,
- egui::TextFormat {
- font_id: egui::FontId::new(12.0, theme::medium()),
- color: highlight.filter(|_| count > 0).unwrap_or(t.text_faint),
- ..Default::default()
- },
- );
- let btn = egui::Button::new(job)
- .fill(if on { tint(t.accent, if t.dark { 0.20 } else { 0.12 }) } else { Color32::TRANSPARENT })
- .stroke(if on { Stroke::new(1.0, tint(t.accent, 0.55)) } else { Stroke::new(1.0, t.border) })
- .corner_radius(CornerRadius::same(15))
- .min_size(vec2(0.0, 28.0));
- ui.add(btn).on_hover_cursor(egui::CursorIcon::PointingHand)
-}
-
-/// A slim progress bar with a caption.
+/// A slim square progress bar with a caption above it.
pub fn progress(ui: &mut Ui, fraction: Option, caption: &str, color: Color32, width: f32) {
let t = Tokens::get(ui.ctx());
- let (rect, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover());
+ let (rect, _) = ui.allocate_exact_size(vec2(width, theme::CONTROL_H), Sense::hover());
let p = ui.painter();
- let bar = egui::Rect::from_min_size(egui::pos2(rect.left(), rect.bottom() - 6.0), vec2(width, 6.0));
- p.rect_filled(bar, CornerRadius::same(3), t.border);
- let f = match fraction {
- Some(f) => f.clamp(0.0, 1.0),
+ let bar = Rect::from_min_size(egui::pos2(rect.left(), rect.bottom() - 4.0), vec2(width, 4.0));
+ p.rect_filled(bar, CornerRadius::ZERO, t.line);
+ match fraction {
+ Some(f) => {
+ p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * f.clamp(0.0, 1.0), bar.height())), CornerRadius::ZERO, color);
+ }
None => {
// Indeterminate: a sweeping segment.
- let time = ui.input(|i| i.time) as f32;
- let x = (time * 0.8).fract();
- let seg =
- egui::Rect::from_min_size(egui::pos2(bar.left() + bar.width() * (x * 1.3 - 0.3).max(0.0), bar.top()), vec2(bar.width() * 0.3, 6.0))
- .intersect(bar);
- p.rect_filled(seg, CornerRadius::same(3), color);
+ let x = (ui.input(|i| i.time) as f32 * 0.8).fract();
+ let seg = Rect::from_min_size(
+ egui::pos2(bar.left() + bar.width() * (x * 1.3 - 0.3).max(0.0), bar.top()),
+ vec2(bar.width() * 0.3, bar.height()),
+ )
+ .intersect(bar);
+ p.rect_filled(seg, CornerRadius::ZERO, color);
ui.ctx().request_repaint();
- -1.0
}
- };
- if f >= 0.0 {
- p.rect_filled(egui::Rect::from_min_size(bar.min, vec2(bar.width() * f, 6.0)), CornerRadius::same(3), color);
}
- p.text(egui::pos2(rect.left(), rect.top() + 7.0), egui::Align2::LEFT_CENTER, caption, egui::FontId::new(12.0, theme::medium()), t.text_muted);
+ p.text(egui::pos2(rect.left(), rect.top() + 9.0), egui::Align2::LEFT_CENTER, caption, egui::FontId::new(12.0, theme::medium()), t.muted);
let resp = ui.interact(rect, ui.id().with(("progress", caption)), Sense::hover());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ProgressIndicator, true, caption));
}
@@ -171,69 +427,44 @@ pub enum Glyph {
Close,
}
-/// A small square button with a drawn glyph.
+/// A small outlined square button with a drawn glyph; inverts on hover.
pub fn glyph_button(ui: &mut Ui, glyph: Glyph, tip: &str) -> Response {
let t = Tokens::get(ui.ctx());
- let (rect, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click());
+ let (rect, resp) = ui.allocate_exact_size(vec2(theme::CONTROL_H, theme::CONTROL_H), Sense::click());
let hovered = resp.hovered();
let p = ui.painter();
if hovered {
- p.rect_filled(
- rect,
- CornerRadius::same(theme::RADIUS_SMALL),
- if t.dark { Color32::from_rgb(0x2b, 0x30, 0x3c) } else { Color32::from_rgb(0xe4, 0xe7, 0xee) },
- );
+ p.rect_filled(rect, CornerRadius::ZERO, t.invert_bg);
+ } else {
+ p.rect_stroke(rect, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
}
- let c = if hovered { t.text } else { t.text_muted };
+ let c = if hovered { t.invert_fg } else { t.muted };
let m = rect.center();
match glyph {
Glyph::Pause => {
- for dx in [-3.5, 3.5] {
- p.rect_filled(egui::Rect::from_center_size(m + vec2(dx, 0.0), vec2(3.0, 11.0)), CornerRadius::same(1), c);
+ for dx in [-3.0, 3.0] {
+ p.rect_filled(Rect::from_center_size(m + vec2(dx, 0.0), vec2(2.5, 10.0)), CornerRadius::ZERO, c);
}
}
Glyph::Close => {
let s = 4.5;
- p.line_segment([m + vec2(-s, -s), m + vec2(s, s)], Stroke::new(1.6, c));
- p.line_segment([m + vec2(-s, s), m + vec2(s, -s)], Stroke::new(1.6, c));
+ p.line_segment([m + vec2(-s, -s), m + vec2(s, s)], Stroke::new(1.5, c));
+ p.line_segment([m + vec2(-s, s), m + vec2(s, -s)], Stroke::new(1.5, c));
}
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand)
}
-/// "Discord": opens the ArtCraft community server. Outlined in Discord's blurple so it reads as
-/// a community link rather than an app action.
-pub fn discord_button(ui: &mut Ui) -> Response {
+/// The "…" button that opens an app's menu: an outlined square like [`glyph_button`].
+pub fn more_menu(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) {
let t = Tokens::get(ui.ctx());
- let blurple = Color32::from_rgb(0x58, 0x65, 0xf2);
- let text_color = if t.dark { Color32::from_rgb(0xc9, 0xcd, 0xfb) } else { Color32::from_rgb(0x40, 0x4e, 0xed) };
- let btn = egui::Button::new(RichText::new("💬 Discord").size(13.0).family(theme::medium()).color(text_color))
- .fill(tint(blurple, if t.dark { 0.16 } else { 0.08 }))
- .stroke(Stroke::new(1.0, tint(blurple, if t.dark { 0.55 } else { 0.40 })))
- .corner_radius(theme::RADIUS_SMALL + 1)
- .min_size(vec2(0.0, 32.0));
- ui.add(btn).on_hover_text("Join the ArtCraft community on Discord").on_hover_cursor(egui::CursorIcon::PointingHand)
-}
-
-/// A quiet text link: faint until hovered, then accent and underlined. Opens `url` in the browser.
-pub fn small_link(ui: &mut Ui, text: &str, url: &str) -> Response {
- let t = Tokens::get(ui.ctx());
- let font = egui::FontId::new(11.5, theme::medium());
- let galley = ui.painter().layout_no_wrap(text.to_string(), font, t.text_faint);
- let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::click());
- let color = if resp.hovered() { t.accent } else { t.text_faint };
- let p = ui.painter();
- p.galley_with_override_text_color(rect.min, galley, color);
- if resp.hovered() {
- p.line_segment([egui::pos2(rect.left(), rect.bottom()), egui::pos2(rect.right(), rect.bottom())], Stroke::new(1.0, color));
- }
- resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, text));
- let resp = resp.on_hover_text(url).on_hover_cursor(egui::CursorIcon::PointingHand);
- if resp.clicked() {
- ui.ctx().open_url(egui::OpenUrl::new_tab(url));
- }
- resp
+ let btn =
+ egui::Button::new(RichText::new("…").size(15.0).color(t.muted)).fill(Color32::TRANSPARENT).min_size(vec2(theme::CONTROL_H, theme::CONTROL_H));
+ egui::containers::menu::MenuButton::from_button(btn).ui(ui, |ui| {
+ ui.set_min_width(200.0);
+ add_contents(ui)
+ });
}
/// ArtCraft's social accounts as a row of small monochrome icons (faint, brighter on hover).
@@ -247,7 +478,7 @@ pub fn social_icons(ui: &mut Ui, size: f32) {
let Some(icon) = crate::icons::social(name) else { continue };
let pad = 4.0;
let (rect, resp) = ui.allocate_exact_size(vec2(size + 2.0 * pad, size + 2.0 * pad), Sense::click());
- let color = if resp.hovered() { t.text } else { t.text_faint };
+ let color = if resp.hovered() { t.ink_strong } else { t.faint };
egui::Image::new(icon).tint(color).paint_at(ui, rect.shrink(pad));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, *name));
let resp = resp.on_hover_text(format!("ArtCraft on {name}")).on_hover_cursor(egui::CursorIcon::PointingHand);
@@ -256,3 +487,24 @@ pub fn social_icons(ui: &mut Ui, size: f32) {
}
}
}
+
+/// An app's name as the site sets it: Archivo, with "Craft" in the app's colour.
+pub fn wordmark(ui: &mut Ui, app: &artcraft_launcher_core::CraftApp, size: f32) -> Response {
+ let t = Tokens::get(ui.ctx());
+ let (prefix, craft) = app.name.strip_suffix("Craft").map_or((app.name, ""), |p| (p, "Craft"));
+ let mut job = egui::text::LayoutJob::default();
+ let format = |color| egui::TextFormat {
+ font_id: egui::FontId::new(size, theme::display()),
+ color,
+ extra_letter_spacing: -0.03 * size,
+ ..Default::default()
+ };
+ job.append(prefix, 0.0, format(t.ink_strong));
+ job.append(craft, 0.0, format(t.app_ink(app)));
+ ui.add(egui::Label::new(job).selectable(false))
+}
+
+/// A heading in the display face, tracked in like the site's (`tracking-[-0.02em]`).
+pub fn heading(text: &str, size: f32, color: Color32) -> RichText {
+ RichText::new(text).size(size).family(theme::display()).color(color).extra_letter_spacing(-0.02 * size)
+}
diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs
index 3d08c589..ea6ba3fb 100644
--- a/crates/ui-egui/tests/ui.rs
+++ b/crates/ui-egui/tests/ui.rs
@@ -145,8 +145,9 @@ fn grid_shows_every_status_and_installs_on_click() {
h.get_by_label("Update to 0.6.0");
h.get_by_label_contains("0.4.1 installed → 0.5.0 released");
h.get_by_label("Like Adobe Photoshop®");
- h.get_by_label("Open Source Tools for Creatives, Makers, and Builders");
- assert!(h.query_all_by_label_contains("Discord").count() >= 2); // header button and footer link
+ // The window has no title bar of its own here (the web build, and the tests).
+ assert!(h.query_by_label("Close").is_none());
+ assert!(h.query_all_by_label_contains("Discord").count() >= 2); // header link and footer link
h.get_by_label_contains("Adobe, Photoshop, Illustrator");
assert!(h.query_all_by_label("Coming soon").count() >= 1);
h.get_by_label_contains("48 MB of 131 MB");
@@ -266,7 +267,7 @@ fn every_table_column_sorts_both_ways() {
fn settings_link_to_discord() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(6);
- h.get_by_label("⚙").click();
+ h.get_by_label("Settings").click();
h.run_steps(4);
snapshot(&mut h, "settings-dark");
h.get_by_label("Questions, ideas and news from the ArtCraft team");
@@ -326,7 +327,7 @@ fn all_opened(h: &mut Harness<'_, Option>, label: &str) -> Vec= 8);
assert!(h.query_by_label("Install").is_none());
}
+
+#[test]
+fn custom_title_bar_has_window_buttons() {
+ struct FramelessHost;
+ impl Host for FramelessHost {
+ fn title_bar(&self) -> artcraft_launcher_ui_egui::TitleBar {
+ artcraft_launcher_ui_egui::TitleBar::Custom
+ }
+ }
+ let mut backend = Some(Fake { events: events(), sent: Rc::new(RefCell::new(Vec::new())), manage: true, clock: Rc::new(Cell::new(NOW)) });
+ let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).wgpu().build_ui_state(
+ move |ui, state: &mut Option| {
+ if state.is_none()
+ && let Some(b) = backend.take()
+ {
+ *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() }));
+ }
+ if let Some(l) = state.as_mut() {
+ l.logic(&ui.ctx().clone(), &mut FramelessHost);
+ l.ui(ui, &mut FramelessHost);
+ }
+ },
+ None,
+ );
+ h.run_steps(6);
+ snapshot(&mut h, "grid-title-bar-dark");
+ h.get_by_label("Minimize");
+ h.get_by_label("Maximize");
+ h.get_by_label("Close").click();
+ h.step();
+ let closes = h.output().viewport_output.values().any(|v| v.commands.contains(&egui::ViewportCommand::Close));
+ assert!(closes, "the close button asks the window to close (the shell decides whether that hides it to the tray)");
+}
diff --git a/docs/screenshots/artcraft-launcher-grid.png b/docs/screenshots/artcraft-launcher-grid.png
index dc794c27..5fb4d007 100644
Binary files a/docs/screenshots/artcraft-launcher-grid.png and b/docs/screenshots/artcraft-launcher-grid.png differ
diff --git a/docs/screenshots/artcraft-launcher-table.png b/docs/screenshots/artcraft-launcher-table.png
index 37687882..a6da1b88 100644
Binary files a/docs/screenshots/artcraft-launcher-table.png and b/docs/screenshots/artcraft-launcher-table.png differ
diff --git a/packaging/icons.sh b/packaging/icons.sh
index feb55eb6..b004d0dd 100755
--- a/packaging/icons.sh
+++ b/packaging/icons.sh
@@ -2,19 +2,20 @@
# Regenerate every app and tray icon from the ArtCraft "A" mark in docs/brand/, rendered exactly as
# supplied. The mark is an ArtCraft trademark, NOT MIT/Apache: docs/brand/LICENSE-brand.txt.
#
-# - App icon (Dock, taskbar, Start menu, Linux menus, installers): the black mark.
+# - App icon (Dock, taskbar, Start menu, Linux menus, installers): the blue mark, which is also
+# getartcraft.com's favicon.
# - Tray: the black mark as the macOS menu-bar template; the blue mark on Windows, Linux and BSD.
#
-# Needs: resvg (brew install resvg / cargo install resvg). On macOS, iconutil also writes the
-# .icns. The outputs are committed, so packaging (and the Rust build, which include_bytes!s some
-# of them) never needs these tools.
+# Needs: resvg (brew install resvg / cargo install resvg). The .ico and .icns are packed by
+# `cargo xtask ico` / `cargo xtask icns`, so this runs on any OS. The outputs are committed, so
+# packaging (and the Rust build, which include_bytes!s some of them) never needs these tools.
#
# packaging/icons.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIR="$ROOT/assets/app-icon"
BRAND="$ROOT/docs/brand"
-SVG="$BRAND/artcraft-mark-black.svg"
+SVG="$BRAND/artcraft-mark.svg"
ID="ai.storyteller.artcraft-launcher"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
@@ -51,17 +52,12 @@ done
(cd "$ROOT" && cargo run -q -p xtask -- ico "$DIR/artcraft-launcher.ico" "${ICO_PNGS[@]}")
# macOS .icns.
-if command -v iconutil >/dev/null; then
- SET="$TMP/artcraft-launcher.iconset"
- mkdir -p "$SET"
- for s in 16 32 128 256 512; do
- render "$MAC" "$s" "$SET/icon_${s}x${s}.png"
- render "$MAC" $((s * 2)) "$SET/icon_${s}x${s}@2x.png"
- done
- iconutil -c icns -o "$DIR/artcraft-launcher.icns" "$SET"
-else
- echo "warning: iconutil not found (macOS only); artcraft-launcher.icns not regenerated" >&2
-fi
+ICNS_PNGS=()
+for s in 16 32 64 128 256 512 1024; do
+ render "$MAC" "$s" "$TMP/icns-$s.png"
+ ICNS_PNGS+=("$TMP/icns-$s.png")
+done
+(cd "$ROOT" && cargo run -q -p xtask -- icns "$DIR/artcraft-launcher.icns" "${ICNS_PNGS[@]}")
# Tray icons: the black mark is the macOS menu-bar template (44 px = 22 pt @2x; macOS tints it);
# the blue one is for the Windows notification area and Linux/BSD status notifiers.
diff --git a/packaging/windows/artcraft-launcher.en-us.wxl b/packaging/windows/artcraft-launcher.en-us.wxl
new file mode 100644
index 00000000..45c72c3e
--- /dev/null
+++ b/packaging/windows/artcraft-launcher.en-us.wxl
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/packaging/windows/artcraft-launcher.wxs b/packaging/windows/artcraft-launcher.wxs
index 0da89070..66354a28 100644
--- a/packaging/windows/artcraft-launcher.wxs
+++ b/packaging/windows/artcraft-launcher.wxs
@@ -1,18 +1,28 @@
-
+
-
+
@@ -45,6 +55,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1
index 3a9e0f11..ac8d44e2 100644
--- a/packaging/windows/package.ps1
+++ b/packaging/windows/package.ps1
@@ -4,7 +4,7 @@
.DESCRIPTION
Produces, in $env:DIST (default: dist/release):
- artcraft-launcher--windows-.msi per-user installer (WiX v5)
+ artcraft-launcher--windows-.msi per-user installer with a setup wizard (WiX v5)
artcraft-launcher--windows--portable.zip artcraft-launcher.exe + portable.txt
The binary links the C runtime statically (+crt-static), so neither the MSI nor the portable
@@ -13,6 +13,7 @@
Needs: Rust (MSVC toolchain + the target), the Windows SDK (rc.exe, signtool.exe),
and WiX v5: dotnet tool install --global wix --version 5.0.2
+ wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
.EXAMPLE
pwsh packaging/windows/package.ps1 -Arch x64
@@ -94,11 +95,91 @@ Copy-Item (Join-Path $Bin $Exe) $Stage
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage $Exe)
# ---- MSI ---------------------------------------------------------------------------------------
+# The setup wizard's art, drawn here (System.Drawing) in getartcraft.com's style so the MSI shows
+# ArtCraft rather than WiX's stock bitmaps. Both must be 24-bit BMPs:
+# - dialog, 493x312: the Welcome and Finish pages' backdrop. Their text sits on the right, so
+# that stays white; the left panel is ours: the blue ArtCraft mark, the white wordmark, a mono
+# "Launcher" label and the Craft apps' icons in a hairline grid.
+# - banner, 493x58: across the top of the inner pages. The page title is drawn over its left
+# side, so that stays white; the right end is a dark cell with the mark.
+# The ArtCraft mark and wordmark are drawn as supplied (docs/brand/LICENSE-brand.txt).
+function New-InstallerArt([string] $BannerOut, [string] $DialogOut) {
+ Add-Type -AssemblyName System.Drawing
+ $ink = [System.Drawing.ColorTranslator]::FromHtml('#121316')
+ $accent = [System.Drawing.ColorTranslator]::FromHtml('#2d81ff')
+ $muted = [System.Drawing.Color]::FromArgb(153, 242, 241, 238)
+ $line = [System.Drawing.Color]::FromArgb(38, 242, 241, 238)
+ # The Craft App Suite, in the launcher's order (crates/core/src/catalog.rs).
+ $apps = 'photocraft', 'vectorcraft', 'filmcraft', 'lightcraft', 'pdfcraft', 'effectcraft',
+ 'designcraft', 'wordcraft', 'deckcraft', 'gridcraft', 'cadcraft', 'soundcraft'
+ $mark = [System.Drawing.Image]::FromFile((Join-Path $Root 'assets\app-icon\hicolor\256x256\apps\ai.storyteller.artcraft-launcher.png'))
+ $wordmark = [System.Drawing.Image]::FromFile((Join-Path $Root 'docs\brand\artcraft-logo-white.png'))
+ $icons = $apps | ForEach-Object { [System.Drawing.Image]::FromFile((Join-Path $Root "assets\app-icons\$_.png")) }
+ # The site's label face; GDI+ draws a variable font at its default instance (Regular).
+ $fonts = New-Object System.Drawing.Text.PrivateFontCollection
+ $fonts.AddFontFile((Join-Path $Root 'assets\fonts\GeistMono-Variable.ttf'))
+ $labelFont = New-Object System.Drawing.Font $fonts.Families[0], 9, ([System.Drawing.FontStyle]::Regular), ([System.Drawing.GraphicsUnit]::Pixel)
+ try {
+ foreach ($spec in @(@($BannerOut, 493, 58), @($DialogOut, 493, 312))) {
+ $bmp = New-Object System.Drawing.Bitmap $spec[1], $spec[2], ([System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
+ $panel = New-Object System.Drawing.SolidBrush $ink
+ $stripe = New-Object System.Drawing.SolidBrush $accent
+ try {
+ $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
+ $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
+ $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
+ $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
+ $g.Clear([System.Drawing.Color]::White)
+ if ($spec[2] -eq 58) {
+ $g.FillRectangle($panel, 405, 0, 88, 58)
+ $g.FillRectangle($stripe, 405, 0, 2, 58)
+ $g.DrawImage($mark, 431, 11, 36, 36)
+ } else {
+ $g.FillRectangle($panel, 0, 0, 164, 312)
+ $g.FillRectangle($stripe, 162, 0, 2, 312)
+ $g.DrawImage($mark, 16, 18, 44, 44)
+ $g.DrawImage($wordmark, 22, 74, 120, [int](120 * $wordmark.Height / $wordmark.Width))
+ # "LAUNCHER" in mono capitals, spaced like the site's labels (0.15 em).
+ $brush = New-Object System.Drawing.SolidBrush $muted
+ $x = 22.0
+ foreach ($ch in 'LAUNCHER'.ToCharArray()) {
+ $g.DrawString([string]$ch, $labelFont, $brush, $x, 94, [System.Drawing.StringFormat]::GenericTypographic)
+ $x += $g.MeasureString([string]$ch, $labelFont, 100, [System.Drawing.StringFormat]::GenericTypographic).Width + 1.35
+ }
+ $brush.Dispose()
+ # The apps, three to a row, in 40 px cells divided by hairlines.
+ $pen = New-Object System.Drawing.Pen $line, 1
+ $left = 22; $top = 126; $cell = 40
+ for ($i = 0; $i -lt $icons.Count; $i++) {
+ $cx = $left + ($i % 3) * $cell
+ $cy = $top + [math]::Floor($i / 3) * $cell
+ $g.DrawRectangle($pen, $cx, $cy, $cell, $cell)
+ $g.DrawImage($icons[$i], $cx + 6, $cy + 6, $cell - 12, $cell - 12)
+ }
+ $pen.Dispose()
+ }
+ } finally { $panel.Dispose(); $stripe.Dispose(); $g.Dispose() }
+ $bmp.Save($spec[0], [System.Drawing.Imaging.ImageFormat]::Bmp)
+ $bmp.Dispose()
+ }
+ } finally {
+ $labelFont.Dispose(); $fonts.Dispose(); $mark.Dispose(); $wordmark.Dispose()
+ $icons | ForEach-Object { $_.Dispose() }
+ }
+}
+$Banner = Join-Path $Stage 'installer-banner.bmp'
+$Dialog = Join-Path $Stage 'installer-dialog.bmp'
+New-InstallerArt $Banner $Dialog
+
$Msi = Join-Path $Dist "$Pkg-$Version-windows-$Arch.msi"
& (Join-Path $PSScriptRoot 'check-icons.ps1')
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot "$Pkg.wxs") -arch $Arch `
- -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root "assets\app-icon\$Pkg.ico")" `
+ -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext `
+ -culture en-US -loc (Join-Path $PSScriptRoot "$Pkg.en-us.wxl") `
+ -d "Version=$MsiVersion" -d "DisplayVersion=$Version" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root "assets\app-icon\$Pkg.ico")" `
+ -d "BannerBmp=$Banner" -d "DialogBmp=$Dialog" `
-o $Msi
}
Invoke-Native 'MSI shortcut icon validation (ICE50)' {
diff --git a/xtask/src/icns.rs b/xtask/src/icns.rs
new file mode 100644
index 00000000..2ffc9b54
--- /dev/null
+++ b/xtask/src/icns.rs
@@ -0,0 +1,117 @@
+//! `cargo xtask icns …`: pack PNG images into a macOS `.icns`, on any OS
+//! (`iconutil` only exists on macOS).
+//!
+//! Every entry is a PNG, which macOS reads for all of these types since 10.7. Each PNG fills
+//! every slot of its pixel size: a 32 px image is both the 32 pt icon and the 16 pt @2x one.
+
+use std::path::Path;
+
+use crate::ico::png_size;
+
+/// The PNG entry types, by pixel size.
+const SLOTS: [(u32, &[u8; 4]); 11] = [
+ (16, b"icp4"),
+ (32, b"icp5"),
+ (32, b"ic11"), // 16 @2x
+ (64, b"icp6"),
+ (64, b"ic12"), // 32 @2x
+ (128, b"ic07"),
+ (256, b"ic08"),
+ (256, b"ic13"), // 128 @2x
+ (512, b"ic09"),
+ (512, b"ic14"), // 256 @2x
+ (1024, b"ic10"), // 512 @2x
+];
+
+/// An `.icns` file holding `pngs`, which must be square and one of the sizes in [`SLOTS`].
+pub fn pack(pngs: &[Vec]) -> Result, String> {
+ if pngs.is_empty() {
+ return Err("need at least one PNG".into());
+ }
+ let mut body = Vec::new();
+ for png in pngs {
+ let (w, h) = png_size(png)?;
+ if w != h {
+ return Err(format!("icon images must be square, got {w}x{h}"));
+ }
+ let types: Vec<&[u8; 4]> = SLOTS.iter().filter(|(size, _)| *size == w).map(|(_, kind)| *kind).collect();
+ if types.is_empty() {
+ return Err(format!("no .icns slot for a {w} px image (use 16, 32, 64, 128, 256, 512 or 1024)"));
+ }
+ let len = u32::try_from(png.len() + 8).map_err(|_| "PNG too large for an .icns".to_string())?;
+ for kind in types {
+ body.extend_from_slice(kind);
+ body.extend_from_slice(&len.to_be_bytes());
+ body.extend_from_slice(png);
+ }
+ }
+ let total = u32::try_from(body.len() + 8).map_err(|_| ".icns too large".to_string())?;
+ let mut out = b"icns".to_vec();
+ out.extend_from_slice(&total.to_be_bytes());
+ out.extend_from_slice(&body);
+ Ok(out)
+}
+
+pub fn run(args: &[&str]) -> Result<(), String> {
+ let [out, inputs @ ..] = args else {
+ return Err("usage: cargo xtask icns …".into());
+ };
+ let pngs = inputs.iter().map(|p| std::fs::read(p).map_err(|e| format!("read {p}: {e}"))).collect::, _>>()?;
+ let icns = pack(&pngs)?;
+ std::fs::write(Path::new(out), &icns).map_err(|e| format!("write {out}: {e}"))?;
+ println!("{out}: {} images, {} bytes", pngs.len(), icns.len());
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn fake_png(size: u32) -> Vec {
+ let mut v = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
+ v.extend_from_slice(&size.to_be_bytes());
+ v.extend_from_slice(&size.to_be_bytes());
+ v.extend_from_slice(&[8, 6, 0, 0, 0, 1, 2, 3, 4]);
+ v
+ }
+
+ /// The (type, payload) entries of an `.icns`.
+ fn entries(icns: &[u8]) -> Vec<(String, Vec)> {
+ let mut out = Vec::new();
+ let mut at = 8;
+ while at < icns.len() {
+ let len = u32::from_be_bytes(icns[at + 4..at + 8].try_into().unwrap()) as usize;
+ out.push((String::from_utf8(icns[at..at + 4].to_vec()).unwrap(), icns[at + 8..at + len].to_vec()));
+ at += len;
+ }
+ out
+ }
+
+ #[test]
+ fn fills_every_slot_of_each_size() {
+ let (a, b) = (fake_png(32), fake_png(1024));
+ let icns = pack(&[a.clone(), b.clone()]).unwrap();
+ assert_eq!(&icns[..4], b"icns");
+ assert_eq!(u32::from_be_bytes(icns[4..8].try_into().unwrap()) as usize, icns.len());
+ let e = entries(&icns);
+ let kinds: Vec<&str> = e.iter().map(|(k, _)| k.as_str()).collect();
+ assert_eq!(kinds, ["icp5", "ic11", "ic10"]);
+ assert_eq!(e[0].1, a);
+ assert_eq!(e[2].1, b);
+ }
+
+ #[test]
+ fn rejects_bad_input() {
+ assert!(pack(&[]).is_err());
+ assert!(pack(&[fake_png(48)]).is_err());
+ assert!(pack(&[b"GIF89a".to_vec()]).is_err());
+ }
+
+ #[test]
+ fn committed_icon_is_valid() {
+ let path = crate::root().join("assets/app-icon/artcraft-launcher.icns");
+ let Ok(icns) = std::fs::read(&path) else { return };
+ assert_eq!(&icns[..4], b"icns");
+ assert!(entries(&icns).iter().any(|(k, _)| k == "ic10"), "expected a 1024 px image in {}", path.display());
+ }
+}
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index 6e58a271..4e587b24 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -3,6 +3,7 @@
//! Pure Rust (std only). External tools (`cargo`) are invoked through `std::process::Command`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+mod icns;
mod ico;
mod version;
@@ -19,6 +20,8 @@ commands:
print the workspace version, or set it (Cargo.toml + Cargo.lock)
ico ...
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
+ icns ...
+ pack square PNGs (16-1024 px) into a macOS .icns, on any OS
";
/// Packages that must build for wasm32-unknown-unknown: the pure core and the web app (which
@@ -33,6 +36,7 @@ fn main() -> ExitCode {
Some("wasm") => cmd_wasm(),
Some("version") => version::run(&root(), &rest),
Some("ico") => ico::run(&rest),
+ Some("icns") => icns::run(&rest),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())