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