Restyle to match getartcraft.com, add an installer wizard, blue app icon (#23)

The launcher now looks like the apps page on getartcraft.com: the site's
colour tokens, square corners, 1 px hairlines instead of shadows, app cards
in a shared-hairline grid with numbered tabs and "Photo*Craft*" wordmarks,
mono upper-case labels and buttons, and square badges for status.

Fonts are the site's own, embedded in the binary: Archivo (620, 118 % width)
for headings and app names, Inter for body text and Geist Mono for labels,
all variable fonts from google/fonts (OFL-1.1) set to the site's weights.

The window has a custom title bar on Windows and Linux (drag to move,
double-click to maximize, minimize/maximize/close, resize from the edges)
and a transparent one on macOS with the traffic lights in the header.

The MSI used to flash a progress bar and close. Double-clicked, it now opens
a WixUI wizard (Welcome, Ready, progress, Finish with "Launch ArtCraft
Launcher"), with art drawn by package.ps1 from the ArtCraft mark and the
Craft apps' icons. Self-update still runs it with /passive. CI installs the
WiX UI and Util extensions.

The app icon is now the blue "A" mark, getartcraft.com's favicon.
packaging/icons.sh runs on any OS: `cargo xtask icns` packs the .icns.

Also: two clippy fixes in crates/native that newer clippy rejects, and
refreshed README screenshots.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlatandClaude Opus 5.5 authored and GitHub committed 2026-10-10 17:39:01 +07:00
1 parent 6802bc6b89
commit 019330f99a
50 files changed
+1641 -720

No files matched your search

+1 -1
View File
@@ -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 "<key>LSUIElement</key>" 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"
+3 -1
View File
@@ -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:
+3 -1
View File
@@ -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:
+11 -2
View File
@@ -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.<app>.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).
+4 -2
View File
@@ -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) | <https://github.com/rsms/inter> (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, <https://github.com/google/fonts/tree/main/ofl/inter> (`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, <https://github.com/google/fonts/tree/main/ofl/archivo> (`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, <https://github.com/google/fonts/tree/main/ofl/geistmono> (`GeistMono[wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-GeistMono.txt`](assets/fonts/OFL-GeistMono.txt) |
### Craft App Suite icons
+4 -2
View File
@@ -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/<app>), MIT OR Apache-2.0, Copyright (c) 2026 ArtCraft Team and the <App>
contributors.
+4 -3
View File
@@ -7,11 +7,12 @@
<link data-trunk rel="rust" data-bin="artcraft-launcher-web" data-cargo-profile="wasm-release" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.artcraft-launcher.png" />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #0e1014; }
@media (prefers-color-scheme: light) { html, body { background: #f4f5f8; } }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #121316; }
#launcher_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#launcher_loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #9aa1b0; font: 14px system-ui, sans-serif; pointer-events: none; }
color: rgba(242, 241, 238, 0.6); font: 500 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.15em; text-transform: uppercase; pointer-events: none; }
@media (prefers-color-scheme: light) { html, body { background: #f2f1ee; } #launcher_loading { color: rgba(16, 16, 20, 0.62); } }
</style>
</head>
<body>
+14 -3
View File
@@ -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()
}
}
+4 -4
View File
@@ -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/`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
<title>ArtCraft</title>
<path fill="#0b0b0c" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
<path fill="#4e7bfb" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 341 B

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 B

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 950 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
<title>ArtCraft</title>
<path fill="#0b0b0c" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
<path fill="#4e7bfb" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 341 B

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -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.
+93
View File
@@ -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.
+76 -16
View File
@@ -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",
);
+4 -1
View File
@@ -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(),
};
+1 -1
View File
@@ -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()))?;
+57 -41
View File
@@ -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);
}
});
}
}
+30 -32
View File
@@ -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
+3 -7
View File
@@ -46,11 +46,7 @@ pub fn social(service: &str) -> Option<ImageSource<'static>> {
})
}
/// 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")
}
+1 -1
View File
@@ -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;
+6 -6
View File
@@ -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 => {
+163 -83
View File
@@ -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<String> = 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);
}
+350 -329
View File
@@ -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<Section> = 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));
}
+407 -155
View File
@@ -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<Galley> {
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<Galley> {
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<Galley>, 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<Galley>, 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<T: PartialEq + Copy>(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<Color32>)>,
}
/// 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<T: PartialEq + Copy>(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<Color32>) -> 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<f32>, 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<R>(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)
}
+39 -5
View File
@@ -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<LauncherUi>>, label: &str) -> Vec<Strin
fn links_open_the_right_pages() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(6);
assert_eq!(click_opens(&mut h, "💬 Discord").as_deref(), Some("https://discord.gg/artcraft"));
assert_eq!(click_opens(&mut h, "Discord").as_deref(), Some("https://discord.gg/artcraft"));
// The footer's ArtCraft links and each card's own pages.
let github = all_opened(&mut h, "GitHub");
assert!(github.contains(&"https://github.com/storytold".to_string()), "{github:?}");
@@ -452,7 +453,7 @@ fn the_current_desktop_setting_is_only_offered_where_the_os_has_desktops() {
// NoHost has no desktops (Windows, Linux, web): no setting.
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);
assert!(h.query_by_label("Show the window on the current desktop").is_none());
// On by default for everyone; macOS shows it in Settings (DesktopHost::has_desktops).
@@ -467,3 +468,36 @@ fn web_build_offers_downloads() {
assert!(h.query_all_by_label("Download for macOS").count() >= 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<LauncherUi>| {
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)");
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

After

Width:  |  Height:  |  Size: 241 KiB

+12 -16
View File
@@ -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.
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Overrides for the setup wizard's WixUI strings, so its pages name the version being installed
([ArtCraftLauncherVersion] is the full version, pre-release tag included, set in
artcraft-launcher.wxs) and say what the launcher is for.
-->
<WixLocalization Culture="en-US" xmlns="http://wixtoolset.org/schemas/v4/wxl">
<String Id="WelcomeDlgTitle" Value="{\WixUI_Font_Bigger}Welcome to [ProductName] [ArtCraftLauncherVersion]" />
<String Id="WelcomeDlgDescription" Value="ArtCraft Launcher installs, updates and opens the Craft App Suite: PhotoCraft, VectorCraft, FilmCraft and the rest of the open-source ArtCraft apps.&#13;&#10;&#13;&#10;It installs for you only, so no administrator rights are needed. Click Next to continue." />
<String Id="VerifyReadyDlgInstallTitle" Value="{\WixUI_Font_Title}Ready to install [ProductName] [ArtCraftLauncherVersion]" />
<!-- Also shown after a repair or removal from the same wizard, so it doesn't say "installed". -->
<String Id="ExitDialogTitle" Value="{\WixUI_Font_Bigger}Completed the [ProductName] [ArtCraftLauncherVersion] Setup Wizard" />
</WixLocalization>
+39 -4
View File
@@ -1,18 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
ArtCraft Launcher MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
ArtCraft Launcher MSI (WiX Toolset v5: `dotnet tool install -g wix`, plus the UI and Util
extensions: `wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`).
Built by package.ps1:
wix build artcraft-launcher.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
wix build artcraft-launcher.wxs -arch x64 -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext
-culture en-US -loc artcraft-launcher.en-us.wxl -d Version=1.2.3 -d DisplayVersion=1.2.3-rc.1
-d BinDir=... -d IconPath=... -d BannerBmp=... -d DialogBmp=... -o out.msi
Per-user install (no UAC prompt) into %LOCALAPPDATA%\Programs\ArtCraft Launcher, so the
launcher can update itself without elevation, plus a Start Menu shortcut. The shortcut carries
the AppUserModelID ai.storyteller.artcraft-launcher, which Windows needs to show toast
notifications from an unpackaged app and to group the taskbar button. No file associations.
Double-clicked, it opens a setup wizard that names the version: Welcome, Ready, a progress page
and a Finish page with a ticked "Launch ArtCraft Launcher" box. There is no licence page (the
code is MIT OR Apache-2.0; nothing to accept) and no folder page (self-update needs the per-user
folder). The launcher's own self-update runs it with /passive, which skips the wizard.
Per-user packages can't use a file as a component's KeyPath (ICE38), so each component is
keyed on an HKCU value, and the folders we create are removed on uninstall (ICE64).
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package
Name="ArtCraft Launcher"
Manufacturer="Learning Machines LLC"
@@ -31,7 +41,7 @@
<!-- MSI caches this icon using its Id as the filename; keep the .exe extension (ICE50). -->
<Icon Id="ArtCraftLauncherIcon.exe" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="ArtCraftLauncherIcon.exe" />
<Property Id="ARPURLINFOABOUT" Value="https://github.com/storytold/craft-launcher" />
<Property Id="ARPURLINFOABOUT" Value="https://getartcraft.com/apps" />
<Property Id="ARPURLUPDATEINFO" Value="https://github.com/storytold/craft-launcher/releases" />
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
@@ -45,6 +55,31 @@
<Feature Id="Main" Title="ArtCraft Launcher" Level="1">
<ComponentGroupRef Id="ArtCraftLauncherFiles" />
</Feature>
<!-- The setup wizard: WixUI_InstallDir without its licence and folder pages. A later
NewDialog on the same button wins. -->
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
<UI>
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="2" Condition="NOT Installed" />
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2" Condition="NOT Installed" />
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchArtCraftLauncher"
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed" />
</UI>
<!-- Our own art, drawn by package.ps1 from the ArtCraft mark and the Craft apps' icons. -->
<WixVariable Id="WixUIBannerBmp" Value="$(var.BannerBmp)" />
<WixVariable Id="WixUIDialogBmp" Value="$(var.DialogBmp)" />
<!-- The full version (Version is numeric, so it drops a pre-release tag), shown on the wizard's
pages through artcraft-launcher.en-us.wxl. -->
<Property Id="ArtCraftLauncherVersion" Value="$(var.DisplayVersion)" />
<!-- Finish page: say where to find the launcher, and offer to start it (ticked by default). -->
<Property Id="WIXUI_EXITDIALOGOPTIONALTEXT" Value="ArtCraft Launcher $(var.DisplayVersion) is installed. Start it any time from the Start menu to install and update the Craft App Suite." />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Launch ArtCraft Launcher" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
<Property Id="WixShellExecTarget" Value="[#ArtCraftLauncherExe]" />
<!-- Runs in the wizard's own process, so the launcher starts as the user. -->
<CustomAction Id="LaunchArtCraftLauncher" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)" DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
</Package>
<Fragment>
+83 -2
View File
@@ -4,7 +4,7 @@
.DESCRIPTION
Produces, in $env:DIST (default: dist/release):
artcraft-launcher-<version>-windows-<arch>.msi per-user installer (WiX v5)
artcraft-launcher-<version>-windows-<arch>.msi per-user installer with a setup wizard (WiX v5)
artcraft-launcher-<version>-windows-<arch>-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)' {
+117
View File
@@ -0,0 +1,117 @@
//! `cargo xtask icns <out.icns> <in.png>…`: 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<u8>]) -> Result<Vec<u8>, 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 <out.icns> <in.png>…".into());
};
let pngs = inputs.iter().map(|p| std::fs::read(p).map_err(|e| format!("read {p}: {e}"))).collect::<Result<Vec<_>, _>>()?;
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<u8> {
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<u8>)> {
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());
}
}
+4
View File
@@ -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 <out.ico> <in.png>...
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
icns <out.icns> <in.png>...
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(())