Restyle to match getartcraft.com, add an installer wizard, blue app icon (#23)
The launcher now looks like the apps page on getartcraft.com: the site's colour tokens, square corners, 1 px hairlines instead of shadows, app cards in a shared-hairline grid with numbered tabs and "Photo*Craft*" wordmarks, mono upper-case labels and buttons, and square badges for status. Fonts are the site's own, embedded in the binary: Archivo (620, 118 % width) for headings and app names, Inter for body text and Geist Mono for labels, all variable fonts from google/fonts (OFL-1.1) set to the site's weights. The window has a custom title bar on Windows and Linux (drag to move, double-click to maximize, minimize/maximize/close, resize from the edges) and a transparent one on macOS with the traffic lights in the header. The MSI used to flash a progress bar and close. Double-clicked, it now opens a WixUI wizard (Welcome, Ready, progress, Finish with "Launch ArtCraft Launcher"), with art drawn by package.ps1 from the ArtCraft mark and the Craft apps' icons. Self-update still runs it with /passive. CI installs the WiX UI and Util extensions. The app icon is now the blue "A" mark, getartcraft.com's favicon. packaging/icons.sh runs on any OS: `cargo xtask icns` packs the .icns. Also: two clippy fixes in crates/native that newer clippy rejects, and refreshed README screenshots. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -51,7 +51,7 @@ jobs:
|
||||
./packaging/windows/check-icons.ps1
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/artcraft-launcher.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
xmllint --noout packaging/windows/artcraft-launcher.wxs packaging/windows/artcraft-launcher.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
if grep -q "<key>LSUIElement</key>" packaging/macos/Info.plist.in; then echo "::error::Info.plist.in must not set LSUIElement"; exit 1; fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.artcraft-launcher.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.artcraft-launcher.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.artcraft-launcher.metainfo.xml"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -31,7 +31,7 @@ xtask/ cargo xtask ci | wasm | version | ico
|
||||
packaging/ macOS, Windows, Linux, FreeBSD and web packaging scripts
|
||||
assets/app-icon/ the launcher's own icon (master SVG, renders, tray icons)
|
||||
assets/app-icons/ the Craft apps' icons, copied from each app's repo
|
||||
assets/fonts/ Inter (OFL-1.1)
|
||||
assets/fonts/ Archivo, Inter, Geist Mono (OFL-1.1): getartcraft.com's faces
|
||||
```
|
||||
|
||||
Layering: `core` depends on no other workspace crate and must build for `wasm32-unknown-unknown`.
|
||||
@@ -62,6 +62,13 @@ settings file must produce an error the user can act on, never a panic. Shared s
|
||||
- Never run an installer with elevated rights silently; tell the user what is about to happen.
|
||||
- Never delete a user's files outside what the launcher itself installed.
|
||||
|
||||
### Look and feel
|
||||
|
||||
The UI matches the apps page on getartcraft.com (`artcraft-website-next` in `artcraft-services`):
|
||||
hard square corners, 1 px hairlines instead of shadows, Archivo (620, 118 % width) for headings
|
||||
and app names, Inter for body text, upper-case Geist Mono labels and buttons, and the site's
|
||||
colour tokens by name (`theme::Tokens`). Build new UI from `widgets.rs`; never hard-code a colour.
|
||||
|
||||
### Other rules
|
||||
|
||||
1. **Assets need attribution.** Every bundled asset gets a row in `ATTRIBUTION.md` (path, title,
|
||||
@@ -69,7 +76,7 @@ settings file must produce an error the user can act on, never a panic. Shared s
|
||||
`docs/brand/` (the header logo and the tray icon) are trademarks, **not MIT/Apache**
|
||||
(`docs/brand/LICENSE-brand.txt`): use them exactly as supplied, never redraw or recolor them.
|
||||
2. **Icons are generated.** The app icon and the tray icon are the supplied ArtCraft "A" mark from
|
||||
`docs/brand/` (a trademark, not MIT/Apache); never edit the renders by hand. Run `packaging/icons.sh` (needs `resvg`; `iconutil` on macOS) and commit the outputs.
|
||||
`docs/brand/` (a trademark, not MIT/Apache); never edit the renders by hand. Run `packaging/icons.sh` (needs `resvg`; runs on any OS) and commit the outputs.
|
||||
`assets/app-icons/` is copied verbatim from each app's repo (`assets/app-icon/hicolor/128x128/apps/ai.storyteller.<app>.png`);
|
||||
refresh it from there, never redraw.
|
||||
3. **Never break wasm.** `cargo xtask wasm` must pass. File-system, process and OS code is
|
||||
@@ -110,6 +117,8 @@ Release assets (keep these names exact; the launcher's own self-update and the R
|
||||
Packaging facts the code relies on:
|
||||
|
||||
- The Windows MSI is **per-user** (`%LOCALAPPDATA%\Programs\ArtCraft Launcher`, no UAC prompt).
|
||||
Double-clicked it opens a WixUI setup wizard (Welcome, Ready, progress, Finish with "Launch"),
|
||||
whose art `package.ps1` draws at build time; self-update runs it with `/passive`, no wizard.
|
||||
Its Start Menu shortcut sets AppUserModelID `ai.storyteller.artcraft-launcher`, which toast
|
||||
notifications need. `portable.txt` beside the exe switches on portable mode
|
||||
(`ArtCraftLauncherData\` next to the exe).
|
||||
|
||||
@@ -10,13 +10,15 @@ next to them.
|
||||
|
||||
| Path | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `assets/app-icon/` (all files) | ArtCraft Launcher app icon: the ArtCraft "A" mark in black | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark-black.svg` by `packaging/icons.sh`, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `assets/app-icon/` (all files) | ArtCraft Launcher app icon: the ArtCraft "A" mark in blue (getartcraft.com's favicon) | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark.svg` by `packaging/icons.sh`, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `assets/social/linkedin.svg` | LinkedIn logo, linking to ArtCraft's company page | Fonticons, Inc. ([Font Awesome Free](https://fontawesome.com) 6.7.2) | Font Awesome Free `svgs/brands/linkedin.svg`; white fill and a square view box, see [`assets/social/README.md`](assets/social/README.md) | CC BY 4.0. The logo is a trademark of LinkedIn Corporation. |
|
||||
| `assets/social/x.svg`, `youtube.svg`, `tiktok.svg`, `instagram.svg` | X, YouTube, TikTok and Instagram logos, linking to ArtCraft's accounts | [Simple Icons](https://simpleicons.org/) contributors | Simple Icons, `fill` set to white for tinting; see [`assets/social/README.md`](assets/social/README.md) | CC0 1.0. The logos are trademarks of X Corp., Google LLC, TikTok / ByteDance and Meta Platforms, Inc. |
|
||||
| `docs/screenshots/artcraft-launcher-grid.png`, `artcraft-launcher-table.png` | Screenshots of ArtCraft Launcher (macOS: grid view in dark theme, table view in light theme), used in the README | ArtCraft Launcher contributors | Captured from the app | MIT OR Apache-2.0 for the screenshot itself; the ArtCraft logo in it stays an ArtCraft trademark ([`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt)) and the app icons in it keep their own licenses (above) |
|
||||
| `docs/brand/artcraft-logo.svg`, `artcraft-logo-white.svg` | ArtCraft wordmark, shown in the window header | ArtCraft Team | Supplied brand files, unmodified (same as PhotoCraft's `docs/brand/`) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `docs/brand/artcraft-mark-64.png`, `artcraft-mark-black-44.png` | ArtCraft "A" mark, the tray / menu-bar icon | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark.svg` and `artcraft-mark-black.svg` by `packaging/icons.sh` | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> (copied from PhotoCraft's `assets/fonts/`) | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/Inter-Variable.ttf` | Inter 4.001, variable (body text) | The Inter Project Authors (Rasmus Andersson) | Google Fonts, <https://github.com/google/fonts/tree/main/ofl/inter> (`Inter[opsz,wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/Archivo-Variable.ttf` | Archivo 2.001, variable (headings and app names) | The Archivo Project Authors (Omnibus-Type) | Google Fonts, <https://github.com/google/fonts/tree/main/ofl/archivo> (`Archivo[wdth,wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-Archivo.txt`](assets/fonts/OFL-Archivo.txt) |
|
||||
| `assets/fonts/GeistMono-Variable.ttf` | Geist Mono 1.701, variable (labels and buttons) | The Geist Project Authors (Vercel) | Google Fonts, <https://github.com/google/fonts/tree/main/ofl/geistmono> (`GeistMono[wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-GeistMono.txt`](assets/fonts/OFL-GeistMono.txt) |
|
||||
|
||||
### Craft App Suite icons
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio
|
||||
|
||||
The ArtCraft name, wordmark, logo and "A" mark (docs/brand/) are trademarks of the ArtCraft Team.
|
||||
They are NOT open source and are NOT covered by the MIT or Apache-2.0 license above. This includes
|
||||
everywhere ArtCraft Launcher shows them: the app icon (assets/app-icon/, the black "A" mark), the
|
||||
everywhere ArtCraft Launcher shows them: the app icon (assets/app-icon/, the blue "A" mark), the
|
||||
ArtCraft logo in the window header and the "A" mark used as the tray / menu-bar icon
|
||||
(docs/brand/artcraft-mark-64.png, artcraft-mark-black-44.png).
|
||||
Their terms are in docs/brand/LICENSE-brand.txt; forks and modified versions must remove them.
|
||||
@@ -18,7 +18,9 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
Social service icons (assets/social/*.svg) — Simple Icons, CC0 1.0 (X, YouTube, TikTok, Instagram),
|
||||
and Font Awesome Free 6.7.2 by Fonticons, Inc., CC BY 4.0 (LinkedIn; https://fontawesome.com/license/free).
|
||||
The logos are trademarks of their respective owners and are used only to link to ArtCraft's accounts.
|
||||
Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Inter font (assets/fonts/Inter-Variable.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Archivo font (assets/fonts/Archivo-Variable.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Archivo.txt.
|
||||
Geist Mono font (assets/fonts/GeistMono-Variable.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-GeistMono.txt.
|
||||
Craft App Suite app icons (assets/app-icons/*.png) — from each app's repository
|
||||
(github.com/storytold/<app>), MIT OR Apache-2.0, Copyright (c) 2026 ArtCraft Team and the <App>
|
||||
contributors.
|
||||
@@ -7,11 +7,12 @@
|
||||
<link data-trunk rel="rust" data-bin="artcraft-launcher-web" data-cargo-profile="wasm-release" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.artcraft-launcher.png" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #0e1014; }
|
||||
@media (prefers-color-scheme: light) { html, body { background: #f4f5f8; } }
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #121316; }
|
||||
#launcher_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#launcher_loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #9aa1b0; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
color: rgba(242, 241, 238, 0.6); font: 500 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: 0.15em; text-transform: uppercase; pointer-events: none; }
|
||||
@media (prefers-color-scheme: light) { html, body { background: #f2f1ee; } #launcher_loading { color: rgba(16, 16, 20, 0.62); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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/`.
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
|
||||
<title>ArtCraft</title>
|
||||
<path fill="#0b0b0c" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
<path fill="#4e7bfb" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 341 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 401 B After Width: | Height: | Size: 369 B |
|
Before Width: | Height: | Size: 582 B After Width: | Height: | Size: 537 B |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 740 B After Width: | Height: | Size: 665 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 950 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
|
||||
<title>ArtCraft</title>
|
||||
<path fill="#0b0b0c" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
<path fill="#4e7bfb" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 341 B |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -46,11 +46,7 @@ pub fn social(service: &str) -> Option<ImageSource<'static>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The ArtCraft "A" mark as supplied: black for light backgrounds, the blue mark for dark ones.
|
||||
pub fn artcraft_mark(dark_background: bool) -> ImageSource<'static> {
|
||||
if dark_background {
|
||||
egui::include_image!("../../../docs/brand/artcraft-mark.svg")
|
||||
} else {
|
||||
egui::include_image!("../../../docs/brand/artcraft-mark-black.svg")
|
||||
}
|
||||
/// The ArtCraft "A" mark in blue, as supplied: the launcher's app icon and getartcraft.com's favicon.
|
||||
pub fn artcraft_mark() -> ImageSource<'static> {
|
||||
egui::include_image!("../../../docs/brand/artcraft-mark.svg")
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Colours, type and shapes. Widgets read [`Tokens::get`]; nothing hard-codes a colour.
|
||||
//! Colours, type and shapes, matching getartcraft.com: hard edges, hairline rules, Archivo
|
||||
//! display type, Inter body text and Geist Mono labels. Widgets read [`Tokens::get`]; nothing
|
||||
//! hard-codes a colour.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals};
|
||||
use egui::epaint::text::VariationCoords;
|
||||
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Stroke, TextStyle, Visuals};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -26,75 +29,103 @@ impl ThemeChoice {
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour tokens for one appearance.
|
||||
/// Colour tokens for one appearance: the website's (`globals.css`), by the same names.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tokens {
|
||||
pub dark: bool,
|
||||
/// Page and card surface.
|
||||
pub bg: Color32,
|
||||
pub surface: Color32,
|
||||
pub card: Color32,
|
||||
pub card_hover: Color32,
|
||||
pub border: Color32,
|
||||
pub border_strong: Color32,
|
||||
pub text: Color32,
|
||||
pub text_muted: Color32,
|
||||
pub text_faint: Color32,
|
||||
/// Badges, wells and row hovers.
|
||||
pub sunken: Color32,
|
||||
/// Body text.
|
||||
pub ink: Color32,
|
||||
/// Headings.
|
||||
pub ink_strong: Color32,
|
||||
/// Secondary text.
|
||||
pub muted: Color32,
|
||||
/// Labels and tertiary text.
|
||||
pub faint: Color32,
|
||||
/// Hairlines.
|
||||
pub line: Color32,
|
||||
/// Outlined buttons and stronger rules.
|
||||
pub line_strong: Color32,
|
||||
/// The brand blue, for surfaces.
|
||||
pub accent: Color32,
|
||||
pub accent_text: Color32,
|
||||
/// The brand blue, for text and icons.
|
||||
pub accent_ink: Color32,
|
||||
/// Primary buttons and hover inversion.
|
||||
pub invert_bg: Color32,
|
||||
pub invert_fg: Color32,
|
||||
pub good: Color32,
|
||||
pub warn: Color32,
|
||||
pub bad: Color32,
|
||||
pub info: Color32,
|
||||
}
|
||||
|
||||
const fn ink_alpha(rgb: u32, alpha: u8) -> Color32 {
|
||||
let [_, r, g, b] = rgb.to_be_bytes();
|
||||
Color32::from_rgba_unmultiplied_const(r, g, b, alpha)
|
||||
}
|
||||
|
||||
const fn hex(rgb: u32) -> Color32 {
|
||||
let [_, r, g, b] = rgb.to_be_bytes();
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
pub const DARK: Self = Self {
|
||||
dark: true,
|
||||
bg: Color32::from_rgb(0x0e, 0x10, 0x14),
|
||||
surface: Color32::from_rgb(0x14, 0x17, 0x1d),
|
||||
card: Color32::from_rgb(0x19, 0x1c, 0x24),
|
||||
card_hover: Color32::from_rgb(0x1f, 0x23, 0x2d),
|
||||
border: Color32::from_rgb(0x26, 0x2a, 0x35),
|
||||
border_strong: Color32::from_rgb(0x36, 0x3c, 0x4a),
|
||||
text: Color32::from_rgb(0xec, 0xee, 0xf2),
|
||||
text_muted: Color32::from_rgb(0x9a, 0xa1, 0xb0),
|
||||
text_faint: Color32::from_rgb(0x67, 0x6e, 0x7d),
|
||||
accent: Color32::from_rgb(0x7c, 0x6c, 0xff),
|
||||
accent_text: Color32::WHITE,
|
||||
good: Color32::from_rgb(0x3d, 0xd6, 0x8c),
|
||||
warn: Color32::from_rgb(0xff, 0xb5, 0x47),
|
||||
bad: Color32::from_rgb(0xff, 0x6b, 0x6b),
|
||||
info: Color32::from_rgb(0x6c, 0xb4, 0xff),
|
||||
bg: hex(0x121316),
|
||||
sunken: hex(0x060607),
|
||||
ink: hex(0xf2f1ee),
|
||||
ink_strong: hex(0xffffff),
|
||||
muted: ink_alpha(0xf2f1ee, 153),
|
||||
faint: ink_alpha(0xf2f1ee, 97),
|
||||
line: ink_alpha(0xf2f1ee, 38),
|
||||
line_strong: ink_alpha(0xf2f1ee, 102),
|
||||
accent: hex(0x2d81ff),
|
||||
accent_ink: hex(0x74aaff),
|
||||
invert_bg: hex(0xffffff),
|
||||
invert_fg: hex(0x0b0b0c),
|
||||
// PdfCraft's and LightCraft's inks, so status colours come from the site's palette.
|
||||
good: hex(0x3fd6b8),
|
||||
warn: hex(0xffc14d),
|
||||
bad: hex(0xff6259),
|
||||
};
|
||||
|
||||
pub const LIGHT: Self = Self {
|
||||
dark: false,
|
||||
bg: Color32::from_rgb(0xf4, 0xf5, 0xf8),
|
||||
surface: Color32::from_rgb(0xff, 0xff, 0xff),
|
||||
card: Color32::from_rgb(0xff, 0xff, 0xff),
|
||||
card_hover: Color32::from_rgb(0xfa, 0xfa, 0xfd),
|
||||
border: Color32::from_rgb(0xe3, 0xe5, 0xeb),
|
||||
border_strong: Color32::from_rgb(0xc9, 0xcd, 0xd6),
|
||||
text: Color32::from_rgb(0x15, 0x17, 0x1c),
|
||||
text_muted: Color32::from_rgb(0x5b, 0x62, 0x70),
|
||||
text_faint: Color32::from_rgb(0x8b, 0x91, 0x9d),
|
||||
accent: Color32::from_rgb(0x5b, 0x4b, 0xf0),
|
||||
accent_text: Color32::WHITE,
|
||||
good: Color32::from_rgb(0x12, 0x8a, 0x52),
|
||||
warn: Color32::from_rgb(0xb8, 0x6e, 0x00),
|
||||
bad: Color32::from_rgb(0xc8, 0x2b, 0x2b),
|
||||
info: Color32::from_rgb(0x1f, 0x6f, 0xd6),
|
||||
bg: hex(0xf2f1ee),
|
||||
sunken: hex(0xe9e8e4),
|
||||
ink: hex(0x101014),
|
||||
ink_strong: hex(0x000000),
|
||||
muted: ink_alpha(0x101014, 158),
|
||||
faint: ink_alpha(0x101014, 107),
|
||||
line: ink_alpha(0x101014, 41),
|
||||
line_strong: ink_alpha(0x101014, 102),
|
||||
accent: hex(0x2d81ff),
|
||||
accent_ink: hex(0x1659c4),
|
||||
invert_bg: hex(0x101014),
|
||||
invert_fg: hex(0xf2f1ee),
|
||||
good: hex(0x0a7563),
|
||||
warn: hex(0x8a5800),
|
||||
bad: hex(0xb3261e),
|
||||
};
|
||||
|
||||
pub fn get(ctx: &egui::Context) -> Self {
|
||||
if ctx.theme() == egui::Theme::Dark { Self::DARK } else { Self::LIGHT }
|
||||
}
|
||||
|
||||
pub fn hairline(&self) -> Stroke {
|
||||
Stroke::new(1.0, self.line)
|
||||
}
|
||||
|
||||
/// An app's text colour (`--app-ink`): readable on this appearance's background.
|
||||
pub fn app_ink(&self, app: &artcraft_launcher_core::CraftApp) -> Color32 {
|
||||
rgb(if self.dark { app.ink_dark } else { app.ink_light })
|
||||
}
|
||||
}
|
||||
|
||||
pub const RADIUS: u8 = 12;
|
||||
pub const RADIUS_SMALL: u8 = 8;
|
||||
|
||||
/// Mixes `c` into the background at `alpha` (0–1): tinted chips and glows.
|
||||
/// Mixes `c` into the background at `alpha` (0–1): tinted washes.
|
||||
pub fn tint(c: Color32, alpha: f32) -> Color32 {
|
||||
let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a)
|
||||
@@ -104,35 +135,44 @@ pub fn rgb(c: [u8; 3]) -> Color32 {
|
||||
Color32::from_rgb(c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
/// The shadow under menus, dialogs and toasts.
|
||||
pub fn shadow(t: &Tokens) -> egui::epaint::Shadow {
|
||||
egui::epaint::Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 36 }) }
|
||||
}
|
||||
|
||||
fn visuals(t: &Tokens) -> Visuals {
|
||||
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
|
||||
v.panel_fill = t.bg;
|
||||
v.window_fill = t.surface;
|
||||
v.extreme_bg_color = if t.dark { Color32::from_rgb(0x0b, 0x0d, 0x11) } else { Color32::from_rgb(0xee, 0xf0, 0xf4) };
|
||||
v.faint_bg_color = t.card;
|
||||
v.window_stroke = Stroke::new(1.0, t.border);
|
||||
v.window_corner_radius = CornerRadius::same(RADIUS + 2);
|
||||
v.menu_corner_radius = CornerRadius::same(RADIUS_SMALL + 2);
|
||||
v.override_text_color = Some(t.text);
|
||||
v.hyperlink_color = t.accent;
|
||||
v.selection.bg_fill = tint(t.accent, 0.35);
|
||||
v.selection.stroke = Stroke::new(1.0, t.accent);
|
||||
let r = CornerRadius::same(RADIUS_SMALL);
|
||||
for (w, fill, stroke) in [
|
||||
(&mut v.widgets.noninteractive, t.card, t.border),
|
||||
(&mut v.widgets.inactive, if t.dark { Color32::from_rgb(0x23, 0x27, 0x31) } else { Color32::from_rgb(0xee, 0xf0, 0xf4) }, t.border),
|
||||
(&mut v.widgets.hovered, if t.dark { Color32::from_rgb(0x2b, 0x30, 0x3c) } else { Color32::from_rgb(0xe4, 0xe7, 0xee) }, t.border_strong),
|
||||
(&mut v.widgets.active, if t.dark { Color32::from_rgb(0x33, 0x39, 0x47) } else { Color32::from_rgb(0xda, 0xde, 0xe7) }, t.border_strong),
|
||||
(&mut v.widgets.open, if t.dark { Color32::from_rgb(0x2b, 0x30, 0x3c) } else { Color32::from_rgb(0xe4, 0xe7, 0xee) }, t.border_strong),
|
||||
v.window_fill = t.bg;
|
||||
v.extreme_bg_color = t.sunken;
|
||||
v.text_edit_bg_color = Some(t.sunken);
|
||||
v.faint_bg_color = t.sunken;
|
||||
v.code_bg_color = t.sunken;
|
||||
v.window_stroke = Stroke::new(1.0, t.line_strong);
|
||||
v.window_corner_radius = CornerRadius::ZERO;
|
||||
v.menu_corner_radius = CornerRadius::ZERO;
|
||||
v.window_shadow = shadow(t);
|
||||
v.popup_shadow = shadow(t);
|
||||
v.override_text_color = Some(t.ink);
|
||||
v.hyperlink_color = t.accent_ink;
|
||||
v.selection.bg_fill = t.accent;
|
||||
v.selection.stroke = Stroke::new(1.0, Color32::WHITE);
|
||||
v.warn_fg_color = t.warn;
|
||||
v.error_fg_color = t.bad;
|
||||
for (w, fill, stroke, fg) in [
|
||||
(&mut v.widgets.noninteractive, t.bg, t.line, t.muted),
|
||||
(&mut v.widgets.inactive, t.bg, t.line, t.ink),
|
||||
(&mut v.widgets.hovered, t.sunken, t.line_strong, t.ink_strong),
|
||||
(&mut v.widgets.active, t.sunken, t.line_strong, t.ink_strong),
|
||||
(&mut v.widgets.open, t.sunken, t.line_strong, t.ink_strong),
|
||||
] {
|
||||
w.bg_fill = fill;
|
||||
w.weak_bg_fill = fill;
|
||||
w.bg_stroke = Stroke::new(1.0, stroke);
|
||||
w.corner_radius = r;
|
||||
w.fg_stroke = Stroke::new(1.0, t.text);
|
||||
w.corner_radius = CornerRadius::ZERO;
|
||||
w.fg_stroke = Stroke::new(1.0, fg);
|
||||
w.expansion = 0.0;
|
||||
}
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text_muted);
|
||||
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.border);
|
||||
v
|
||||
}
|
||||
|
||||
@@ -147,13 +187,16 @@ pub fn apply(ctx: &egui::Context, choice: ThemeChoice) {
|
||||
});
|
||||
ctx.all_styles_mut(|style| {
|
||||
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||||
// Between a checkbox (or radio button) and its label.
|
||||
style.spacing.icon_spacing = 10.0;
|
||||
style.spacing.button_padding = egui::vec2(12.0, 6.0);
|
||||
style.spacing.interact_size.y = 30.0;
|
||||
style.spacing.menu_margin = egui::Margin::same(6);
|
||||
style.spacing.interact_size.y = CONTROL_H;
|
||||
style.spacing.menu_margin = egui::Margin::same(4);
|
||||
style.spacing.window_margin = egui::Margin::same(4);
|
||||
style.text_styles = [
|
||||
(TextStyle::Heading, FontId::new(22.0, semibold())),
|
||||
(TextStyle::Heading, FontId::new(24.0, display())),
|
||||
(TextStyle::Body, FontId::new(14.0, FontFamily::Proportional)),
|
||||
(TextStyle::Button, FontId::new(14.0, medium())),
|
||||
(TextStyle::Button, FontId::new(13.5, medium())),
|
||||
(TextStyle::Small, FontId::new(12.0, FontFamily::Proportional)),
|
||||
(TextStyle::Monospace, FontId::new(13.0, FontFamily::Monospace)),
|
||||
]
|
||||
@@ -161,29 +204,66 @@ pub fn apply(ctx: &egui::Context, choice: ThemeChoice) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Height of buttons, inputs and filter cells (the site's medium button).
|
||||
pub const CONTROL_H: f32 = 34.0;
|
||||
|
||||
/// The site's `.hud-label`: 11 px Geist Mono 500, upper case, 0.15 em apart.
|
||||
pub const HUD_SIZE: f32 = 11.0;
|
||||
pub const HUD_SPACING: f32 = HUD_SIZE * 0.15;
|
||||
/// Buttons: 11 px Geist Mono 700, upper case, 0.12 em apart.
|
||||
pub const BUTTON_SPACING: f32 = HUD_SIZE * 0.12;
|
||||
|
||||
/// Inter 500.
|
||||
pub fn medium() -> FontFamily {
|
||||
FontFamily::Name("medium".into())
|
||||
}
|
||||
|
||||
/// Inter 600.
|
||||
pub fn semibold() -> FontFamily {
|
||||
FontFamily::Name("semibold".into())
|
||||
}
|
||||
|
||||
/// Registers Inter with named weights.
|
||||
/// Archivo 620 at 118 % width: the site's `.font-display` headings and app wordmarks.
|
||||
pub fn display() -> FontFamily {
|
||||
FontFamily::Name("display".into())
|
||||
}
|
||||
|
||||
/// Geist Mono 500: labels and badges.
|
||||
pub fn mono() -> FontFamily {
|
||||
FontFamily::Name("mono".into())
|
||||
}
|
||||
|
||||
/// Geist Mono 700: buttons and app number tabs.
|
||||
pub fn mono_bold() -> FontFamily {
|
||||
FontFamily::Name("mono-bold".into())
|
||||
}
|
||||
|
||||
/// A font registered under `name`: its file, its variation coordinates and its family.
|
||||
type Face<'a> = (&'a str, &'static [u8], &'a [([u8; 4], f32)], FontFamily);
|
||||
|
||||
/// Registers the site's three variable fonts, each at the weights (and width) it uses there.
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
|
||||
const ARCHIVO: &[u8] = include_bytes!("../../../assets/fonts/Archivo-Variable.ttf");
|
||||
const GEIST_MONO: &[u8] = include_bytes!("../../../assets/fonts/GeistMono-Variable.ttf");
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
|
||||
fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
|
||||
};
|
||||
add(&mut fonts, "Inter", include_bytes!("../../../assets/fonts/Inter-Regular.ttf"));
|
||||
add(&mut fonts, "Inter-Medium", include_bytes!("../../../assets/fonts/Inter-Medium.ttf"));
|
||||
add(&mut fonts, "Inter-SemiBold", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"));
|
||||
fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".to_owned());
|
||||
// egui's own fonts stay behind ours, for symbols and emoji ours don't have.
|
||||
let fallback: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
for (family, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] {
|
||||
let mut stack = vec![primary.to_owned()];
|
||||
let faces: [Face<'_>; 7] = [
|
||||
("Inter", INTER, &[(*b"wght", 400.0), (*b"opsz", 14.0)], FontFamily::Proportional),
|
||||
("Inter-Medium", INTER, &[(*b"wght", 500.0), (*b"opsz", 14.0)], medium()),
|
||||
("Inter-SemiBold", INTER, &[(*b"wght", 600.0), (*b"opsz", 14.0)], semibold()),
|
||||
("Archivo-Display", ARCHIVO, &[(*b"wght", 620.0), (*b"wdth", 118.0)], display()),
|
||||
("GeistMono", GEIST_MONO, &[(*b"wght", 400.0)], FontFamily::Monospace),
|
||||
("GeistMono-Medium", GEIST_MONO, &[(*b"wght", 500.0)], mono()),
|
||||
("GeistMono-Bold", GEIST_MONO, &[(*b"wght", 700.0)], mono_bold()),
|
||||
];
|
||||
for (name, bytes, coords, family) in faces {
|
||||
let tweak = FontTweak { coords: VariationCoords::new(coords.iter().copied()), ..Default::default() };
|
||||
fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes).tweak(tweak)));
|
||||
let mut stack = vec![name.to_owned()];
|
||||
stack.extend(fallback.iter().cloned());
|
||||
fonts.families.insert(FontFamily::Name(family.into()), stack);
|
||||
fonts.families.insert(family, stack);
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
//! The header, toolbar, footer, the grid of cards and the table.
|
||||
//! The header, toolbar, footer, the grid of cards and the table, laid out like the apps page on
|
||||
//! getartcraft.com: hairline cells, mono labels and square controls.
|
||||
|
||||
use artcraft_launcher_core::platform::pick_asset;
|
||||
use artcraft_launcher_core::protocol::{Command, JobPhase};
|
||||
use artcraft_launcher_core::status::AppStatus;
|
||||
use artcraft_launcher_core::{AppGroup, time};
|
||||
use egui::{Color32, CornerRadius, RichText, Sense, Stroke, Ui, vec2};
|
||||
use artcraft_launcher_core::{AppGroup, SUITE, time};
|
||||
use egui::{Color32, CornerRadius, Rect, RichText, Sense, Stroke, Ui, vec2};
|
||||
|
||||
use crate::app::{Dialog, LauncherUi};
|
||||
use crate::app::{Dialog, LauncherUi, TitleBar};
|
||||
use crate::model::{self, AppEntry};
|
||||
use crate::settings::{Filter, SortKey, ViewMode};
|
||||
use crate::theme::{self, Tokens, rgb, tint};
|
||||
use crate::widgets;
|
||||
use crate::widgets::{self, Cell, Kind};
|
||||
|
||||
/// What the main button of an app does.
|
||||
enum Primary {
|
||||
@@ -21,62 +22,69 @@ enum Primary {
|
||||
Unavailable(&'static str),
|
||||
}
|
||||
|
||||
/// ArtCraft's tagline, under the launcher's name in the header.
|
||||
const TAGLINE: &str = "Open Source Tools for Creatives, Makers, and Builders";
|
||||
/// Height of the header bar (the site's nav).
|
||||
pub(crate) const HEADER_H: f32 = 48.0;
|
||||
/// Height of the footer bar.
|
||||
pub(crate) const FOOTER_H: f32 = 34.0;
|
||||
|
||||
const CARD_MIN_W: f32 = 300.0;
|
||||
const CARD_H: f32 = 210.0;
|
||||
const GAP: f32 = 14.0;
|
||||
const CARD_MIN_W: f32 = 320.0;
|
||||
const CARD_H: f32 = 280.0;
|
||||
/// The card's top strip: number tab and links.
|
||||
const STRIP_H: f32 = 40.0;
|
||||
const CARD_PAD: f32 = 20.0;
|
||||
/// Room macOS's traffic lights take at the left of the header.
|
||||
const TRAFFIC_LIGHTS_W: f32 = 72.0;
|
||||
|
||||
/// A run of cards, under an optional (number, title) eyebrow.
|
||||
type Section = (Option<(&'static str, &'static str)>, Vec<&'static str>);
|
||||
|
||||
impl LauncherUi {
|
||||
pub(crate) fn header(&mut self, ui: &mut Ui) {
|
||||
/// The header bar; with a custom or macOS title bar it is also the window's title bar.
|
||||
pub(crate) fn header(&mut self, ui: &mut Ui, bar: TitleBar) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let h = HEADER_H;
|
||||
if bar != TitleBar::Native {
|
||||
widgets::title_bar_drag(ui);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
// The ArtCraft wordmark exactly as supplied, then the launcher's own name beside it.
|
||||
// The logo and divider sit centred on the two-line name block beside them.
|
||||
const ROW_H: f32 = 42.0;
|
||||
let logo_size = vec2(24.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 24.0);
|
||||
let (row, _) = ui.allocate_exact_size(vec2(logo_size.x, ROW_H), Sense::hover());
|
||||
egui::Image::new(crate::icons::artcraft_logo(t.dark))
|
||||
.alt_text("ArtCraft")
|
||||
.paint_at(ui, egui::Rect::from_center_size(row.center(), logo_size));
|
||||
ui.add_space(10.0);
|
||||
let (rule, _) = ui.allocate_exact_size(vec2(1.0, ROW_H), Sense::hover());
|
||||
ui.painter().rect_filled(egui::Rect::from_center_size(rule.center(), vec2(1.0, 30.0)), CornerRadius::ZERO, t.border_strong);
|
||||
ui.add_space(10.0);
|
||||
// Leave room for the controls on the right; the tagline gives way first.
|
||||
let room = (ui.available_width() - 300.0).max(120.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_max_width(room);
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
ui.label(RichText::new("Launcher").size(17.0).family(theme::semibold()).color(t.text));
|
||||
ui.add(egui::Label::new(RichText::new(" · Craft App Suite").size(14.0).color(t.text_muted)).truncate());
|
||||
});
|
||||
ui.add(egui::Label::new(RichText::new(TAGLINE).size(12.0).color(t.text_faint)).truncate());
|
||||
});
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
if bar == TitleBar::MacOs {
|
||||
ui.add_space(TRAFFIC_LIGHTS_W);
|
||||
}
|
||||
// The ArtCraft wordmark exactly as supplied, in its own cell.
|
||||
let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);
|
||||
let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());
|
||||
egui::Image::new(crate::icons::artcraft_logo(t.dark)).alt_text("ArtCraft").paint_at(ui, Rect::from_center_size(cell.center(), logo));
|
||||
widgets::rule(ui, h);
|
||||
ui.add_space(16.0);
|
||||
widgets::tag(ui, "Launcher", t.invert_bg, t.invert_fg);
|
||||
ui.add_space(12.0);
|
||||
widgets::hud(ui, "Craft App Suite", t.muted);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::icon_button(ui, "⚙", "Settings").clicked() {
|
||||
if bar == TitleBar::Custom {
|
||||
widgets::window_buttons(ui, h);
|
||||
}
|
||||
if widgets::nav_icon(ui, "⚙", "Settings", h, false).clicked() {
|
||||
self.dialog = Some(Dialog::Settings);
|
||||
}
|
||||
let refresh = ui.add_enabled_ui(!self.checking, |ui| widgets::icon_button(ui, "⟳", "Check for updates")).inner;
|
||||
if refresh.clicked() {
|
||||
if widgets::nav_icon(ui, "⟳", "Check for updates", h, self.checking).clicked() {
|
||||
self.check_now();
|
||||
}
|
||||
if self.checking {
|
||||
ui.add(egui::Spinner::new().size(16.0).color(t.text_muted));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
widgets::segmented(
|
||||
ui,
|
||||
&mut self.settings.view,
|
||||
&[(ViewMode::Grid, "⊞ Grid", "Show apps as cards"), (ViewMode::Table, "☰ Table", "Show apps as a table")],
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
if widgets::discord_button(ui).clicked() {
|
||||
ui.add_space(6.0);
|
||||
if widgets::nav_link(ui, "Discord", "Join the ArtCraft community on Discord", h, false).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(artcraft_launcher_core::catalog::DISCORD_URL));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
widgets::rule(ui, h);
|
||||
ui.add_space(6.0);
|
||||
// Right to left: Table, then Grid, so they read Grid · Table.
|
||||
for (mode, label, tip) in [(ViewMode::Table, "Table", "Show apps as a table"), (ViewMode::Grid, "Grid", "Show apps as cards")] {
|
||||
if widgets::nav_link(ui, label, tip, h, self.settings.view == mode).clicked() {
|
||||
self.settings.view = mode;
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
widgets::rule(ui, h);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -85,13 +93,18 @@ impl LauncherUi {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if let Some(r) = self.launcher_update.clone() {
|
||||
egui::Frame::new()
|
||||
.fill(tint(t.accent, if t.dark { 0.14 } else { 0.08 }))
|
||||
.stroke(Stroke::new(1.0, tint(t.accent, 0.45)))
|
||||
.corner_radius(theme::RADIUS)
|
||||
.inner_margin(egui::Margin::symmetric(14, 8))
|
||||
.fill(tint(t.accent, if t.dark { 0.10 } else { 0.07 }))
|
||||
.stroke(Stroke::new(1.0, tint(t.accent, 0.6)))
|
||||
.inner_margin(egui::Margin::symmetric(16, 10))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(format!("ArtCraft Launcher {} is available.", r.version_label())).family(theme::medium()));
|
||||
widgets::tag(ui, "Update", t.accent, Color32::WHITE);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(format!("ArtCraft Launcher {} is available.", r.version_label()))
|
||||
.family(theme::medium())
|
||||
.color(t.ink_strong),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(phase) = self.launcher_job {
|
||||
let (fraction, caption) = match phase {
|
||||
@@ -106,11 +119,11 @@ impl LauncherUi {
|
||||
widgets::progress(ui, fraction, &caption, t.accent, 220.0);
|
||||
return;
|
||||
}
|
||||
if widgets::icon_button(ui, "×", "Dismiss").clicked() {
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Close, "Dismiss").clicked() {
|
||||
self.launcher_update = None;
|
||||
}
|
||||
if self.caps.self_update {
|
||||
if widgets::primary(ui, "Update and restart", t.accent, 0.0)
|
||||
if widgets::button(ui, "Update and restart", Kind::Primary, 0.0)
|
||||
.on_hover_text("Downloads the new version, replaces this one and opens it again")
|
||||
.clicked()
|
||||
{
|
||||
@@ -118,39 +131,34 @@ impl LauncherUi {
|
||||
}
|
||||
} else {
|
||||
let url = self.platform.and_then(|p| launcher_download(&r, p)).unwrap_or_else(|| r.html_url.clone());
|
||||
if widgets::primary(ui, "Download", t.accent, 0.0).clicked() {
|
||||
if widgets::button(ui, "Download", Kind::Primary, 0.0).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
}
|
||||
if ui.link("What's new").clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(r.html_url.clone()));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
widgets::hud_link(ui, "What's new", &r.html_url);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
let updates = self.count(Filter::Updates);
|
||||
for (filter, label, highlight) in [
|
||||
(Filter::All, "All", None),
|
||||
(Filter::Installed, "Installed", None),
|
||||
(Filter::Updates, "Updates", Some(t.warn)),
|
||||
(Filter::NotInstalled, "Not installed", None),
|
||||
] {
|
||||
if !self.caps.manage_apps && filter != Filter::All {
|
||||
continue;
|
||||
}
|
||||
let n = self.count(filter);
|
||||
if widgets::chip(ui, self.settings.filter == filter, label, n, highlight).clicked() {
|
||||
self.settings.filter = filter;
|
||||
}
|
||||
}
|
||||
let filters: Vec<(Filter, Cell<'_>)> = [
|
||||
(Filter::All, "All", "Every Craft app", None),
|
||||
(Filter::Installed, "Installed", "Apps on this computer", None),
|
||||
(Filter::Updates, "Updates", "Installed apps with a newer version", Some(t.warn)),
|
||||
(Filter::NotInstalled, "Not installed", "Apps you can install", None),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(f, ..)| self.caps.manage_apps || *f == Filter::All)
|
||||
.map(|(f, label, tip, highlight)| (f, Cell { label, tip, count: Some((self.count(f), highlight)) }))
|
||||
.collect();
|
||||
widgets::cells(ui, &mut self.settings.filter, &filters);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if self.caps.manage_apps && updates > 0 {
|
||||
let label = if updates == 1 { "Update 1 app".to_string() } else { format!("Update all ({updates})") };
|
||||
if widgets::primary(ui, &label, t.accent, 0.0).clicked() {
|
||||
if widgets::button(ui, &label, Kind::Primary, 0.0).clicked() {
|
||||
let ids: Vec<&str> = self
|
||||
.entries
|
||||
.iter()
|
||||
@@ -163,11 +171,18 @@ impl LauncherUi {
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
ui.add(egui::TextEdit::singleline(&mut self.search).hint_text("🔍 Search apps").desired_width(200.0).margin(vec2(10.0, 6.0)));
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.search)
|
||||
.hint_text("Search apps")
|
||||
.desired_width(200.0)
|
||||
.min_size(vec2(0.0, theme::CONTROL_H))
|
||||
.vertical_align(egui::Align::Center)
|
||||
.margin(vec2(10.0, 0.0)),
|
||||
);
|
||||
if self.settings.view == ViewMode::Grid {
|
||||
egui::ComboBox::from_id_salt("sort")
|
||||
.selected_text(RichText::new(sort_label(self.settings.sort)).size(13.0))
|
||||
.width(130.0)
|
||||
.width(140.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for key in [
|
||||
SortKey::Suite,
|
||||
@@ -184,6 +199,7 @@ impl LauncherUi {
|
||||
}
|
||||
}
|
||||
});
|
||||
widgets::hud(ui, "Sort", t.faint);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -192,15 +208,14 @@ impl LauncherUi {
|
||||
pub(crate) fn footer(&mut self, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let now = self.backend.now();
|
||||
ui.horizontal(|ui| {
|
||||
let small = |s: String| RichText::new(s).size(12.0).color(t.text_muted);
|
||||
ui.horizontal_centered(|ui| {
|
||||
if self.checking {
|
||||
ui.label(small("Checking for updates…".into()));
|
||||
widgets::hud(ui, "Checking for updates…", t.faint);
|
||||
} else if let Some(at) = self.settings.last_check {
|
||||
let via = self.last_source.map(|s| format!(" · {}", s.label())).unwrap_or_default();
|
||||
ui.label(small(format!("Checked {}{via}", time::format_ago(at, now))));
|
||||
widgets::hud(ui, &format!("Checked {}{via}", time::format_ago(at, now)), t.faint);
|
||||
} else {
|
||||
ui.label(small("Not checked yet".into()));
|
||||
widgets::hud(ui, "Not checked yet", t.faint);
|
||||
}
|
||||
if let Some(until) = self.rate_limited_until.filter(|u| *u > now) {
|
||||
ui.label(
|
||||
@@ -215,20 +230,19 @@ impl LauncherUi {
|
||||
Some(p) => format!("Downloads for {}", p.label()),
|
||||
None => "No downloads for this system".into(),
|
||||
};
|
||||
ui.label(small(label));
|
||||
ui.add_space(14.0);
|
||||
widgets::hud(ui, &label, t.faint);
|
||||
ui.add_space(16.0);
|
||||
// Right to left, so this reads: Website · GitHub · Discord, then the social icons.
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
widgets::social_icons(ui, 13.0);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.add_space(12.0);
|
||||
ui.spacing_mut().item_spacing.x = 16.0;
|
||||
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
|
||||
widgets::small_link(ui, "Discord", DISCORD_URL);
|
||||
ui.label(RichText::new("·").size(11.5).color(t.text_faint));
|
||||
widgets::small_link(ui, "GitHub", GITHUB_URL);
|
||||
ui.label(RichText::new("·").size(11.5).color(t.text_faint));
|
||||
widgets::small_link(ui, "Website", WEBSITE_URL);
|
||||
widgets::hud_link(ui, "Discord", DISCORD_URL);
|
||||
widgets::hud_link(ui, "GitHub", GITHUB_URL);
|
||||
widgets::hud_link(ui, "Website", WEBSITE_URL);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -237,16 +251,17 @@ impl LauncherUi {
|
||||
|
||||
pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) {
|
||||
let width = ui.available_width();
|
||||
let cols = (((width + GAP) / (CARD_MIN_W + GAP)).floor() as usize).max(1);
|
||||
let card_w = ((width - GAP * (cols as f32 - 1.0)) / cols as f32).floor();
|
||||
// Neighbouring cards share their 1 px hairline, like the site's `gap-px` grid.
|
||||
let cols = ((width / CARD_MIN_W).floor() as usize).max(1);
|
||||
let card_w = (width + (cols as f32 - 1.0)) / cols as f32;
|
||||
// In suite order, split into the classic apps and the new ones.
|
||||
let grouped = self.settings.sort == SortKey::Suite && self.settings.filter == crate::settings::Filter::All && self.search.trim().is_empty();
|
||||
let sections: Vec<(Option<&str>, Vec<&'static str>)> = if grouped {
|
||||
[AppGroup::Classic, AppGroup::New]
|
||||
let grouped = self.settings.sort == SortKey::Suite && self.settings.filter == Filter::All && self.search.trim().is_empty();
|
||||
let sections: Vec<Section> = if grouped {
|
||||
[(AppGroup::Classic, "01", "Classic apps"), (AppGroup::New, "02", "New apps")]
|
||||
.iter()
|
||||
.map(|g| {
|
||||
.map(|(g, index, title)| {
|
||||
let list = ids.iter().copied().filter(|id| self.entry(id).is_some_and(|e| e.app.group == *g)).collect();
|
||||
(Some(if *g == AppGroup::Classic { "Classic apps" } else { "New apps" }), list)
|
||||
(Some((*index, *title)), list)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
@@ -257,96 +272,104 @@ impl LauncherUi {
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(title) = title {
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(title.to_uppercase()).size(11.5).family(theme::semibold()).color(t.text_faint));
|
||||
if let Some((index, title)) = title {
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
for row in list.chunks(cols) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = GAP;
|
||||
for id in row {
|
||||
self.card(ui, id, card_w);
|
||||
}
|
||||
widgets::eyebrow(ui, index, title);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
widgets::hud(ui, &format!("{} apps", list.len()), t.faint);
|
||||
});
|
||||
});
|
||||
ui.add_space(GAP);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
let rows = list.len().div_ceil(cols);
|
||||
let height = rows as f32 * (CARD_H - 1.0) + 1.0;
|
||||
let (area, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
|
||||
for (i, id) in list.iter().enumerate() {
|
||||
let (row, col) = (i / cols, i % cols);
|
||||
let min = area.min + vec2(col as f32 * (card_w - 1.0), row as f32 * (CARD_H - 1.0));
|
||||
self.card(ui, id, Rect::from_min_size(min, vec2(card_w, CARD_H)));
|
||||
}
|
||||
ui.add_space(24.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn card(&mut self, ui: &mut Ui, id: &str, width: f32) {
|
||||
fn card(&mut self, ui: &mut Ui, id: &str, rect: Rect) {
|
||||
let Some(entry) = self.entry(id).cloned() else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, CARD_H), Sense::hover());
|
||||
let hovered = resp.hovered() || ui.rect_contains_pointer(rect);
|
||||
let app_color = rgb(entry.app.color);
|
||||
let painter = ui.painter_at(rect.expand(12.0));
|
||||
if hovered {
|
||||
painter.add(
|
||||
egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 110 } else { 28 }) }
|
||||
.as_shape(rect, CornerRadius::same(theme::RADIUS)),
|
||||
let app = entry.app;
|
||||
let hovered = ui.rect_contains_pointer(rect);
|
||||
let lift = ui.ctx().animate_bool_with_time(ui.id().with(("card-hover", id)), hovered, 0.3);
|
||||
let app_color = rgb(app.color);
|
||||
let painter = ui.painter_at(rect.expand(16.0));
|
||||
painter.rect(rect, CornerRadius::ZERO, t.bg, t.hairline(), egui::StrokeKind::Inside);
|
||||
// On hover, a 2 px rule in the app's colour wipes in from the left along the top.
|
||||
if lift > 0.0 {
|
||||
painter.rect_filled(Rect::from_min_size(rect.min, vec2(rect.width() * lift, 2.0)), CornerRadius::ZERO, app_color);
|
||||
}
|
||||
|
||||
// The top strip: the app's number in its colour, and its pages.
|
||||
let strip = Rect::from_min_size(rect.min, vec2(rect.width(), STRIP_H));
|
||||
painter.hline(strip.x_range(), strip.bottom() - 0.5, t.hairline());
|
||||
let number = SUITE.iter().position(|a| a.id == app.id).map_or_else(String::new, |i| format!("{:02}", i + 1));
|
||||
{
|
||||
let mut strip_ui = ui.new_child(
|
||||
egui::UiBuilder::new().max_rect(strip.shrink2(vec2(CARD_PAD, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||
);
|
||||
widgets::tag(&mut strip_ui, &number, app_color, Color32::BLACK);
|
||||
strip_ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 16.0;
|
||||
widgets::hud_link(ui, "GitHub", &app.repo_url());
|
||||
if let Some(site) = app.website() {
|
||||
widgets::hud_link(ui, "Website", &site);
|
||||
}
|
||||
});
|
||||
}
|
||||
painter.rect(
|
||||
rect,
|
||||
CornerRadius::same(theme::RADIUS),
|
||||
if hovered { t.card_hover } else { t.card },
|
||||
Stroke::new(1.0, if hovered { t.border_strong } else { t.border }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
// A soft wash of the app's colour from the top edge.
|
||||
gradient_top(&painter, rect.shrink(1.0), theme::RADIUS as f32 - 1.0, 74.0, app_color, if t.dark { 0.16 } else { 0.10 });
|
||||
|
||||
let status = entry.status(self.platform, self.caps.manage_apps);
|
||||
let inner = rect.shrink2(vec2(16.0, 14.0));
|
||||
let inner = Rect::from_min_max(egui::pos2(rect.left() + CARD_PAD, strip.bottom() + 18.0), rect.max - vec2(CARD_PAD, CARD_PAD));
|
||||
// A detached child: the card's contents never move the next card along.
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
child.set_clip_rect(rect.intersect(ui.clip_rect()));
|
||||
// The app's own pages, top right: its website (classic apps) and its GitHub repo.
|
||||
{
|
||||
let corner = egui::Rect::from_min_max(egui::pos2(inner.right() - 130.0, inner.top()), egui::pos2(inner.right(), inner.top() + 18.0));
|
||||
let mut links = ui.new_child(egui::UiBuilder::new().max_rect(corner).layout(egui::Layout::right_to_left(egui::Align::Min)));
|
||||
links.spacing_mut().item_spacing.x = 10.0;
|
||||
widgets::small_link(&mut links, "GitHub", &entry.app.repo_url());
|
||||
if let Some(site) = entry.app.website() {
|
||||
widgets::small_link(&mut links, "Website", &site);
|
||||
let ui = &mut child;
|
||||
ui.horizontal(|ui| {
|
||||
const ICON: f32 = 56.0;
|
||||
let (slot, _) = ui.allocate_exact_size(vec2(ICON, ICON), Sense::hover());
|
||||
// The icon rises a little on hover, over a soft drop shadow (the site's only shadow).
|
||||
let icon_rect = slot.translate(vec2(0.0, -4.0 * lift));
|
||||
ui.painter().add(
|
||||
egui::epaint::Shadow { offset: [0, 6], blur: 14, spread: 0, color: Color32::from_black_alpha(71) }
|
||||
.as_shape(icon_rect.shrink(4.0), CornerRadius::same(12)),
|
||||
);
|
||||
if let Some(icon) = crate::icons::app(app.id) {
|
||||
egui::Image::new(icon).paint_at(ui, icon_rect);
|
||||
}
|
||||
}
|
||||
{
|
||||
let ui = &mut child;
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(icon) = crate::icons::app(entry.app.id) {
|
||||
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(54.0, 54.0)));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
ui.add_space(3.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(entry.app.name).size(17.0).family(theme::semibold()).color(t.text));
|
||||
if entry.app.group == AppGroup::New {
|
||||
ui.label(RichText::new("NEW").size(9.5).family(theme::semibold()).color(app_color));
|
||||
}
|
||||
});
|
||||
ui.add(egui::Label::new(RichText::new(entry.app.tagline).size(12.5).color(t.text_muted)).truncate());
|
||||
ui.add(egui::Label::new(RichText::new(format!("Like {}", entry.app.inspired_by())).size(11.5).color(t.text_faint)).truncate());
|
||||
ui.add_space(6.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 4.0;
|
||||
ui.horizontal(|ui| {
|
||||
widgets::wordmark(ui, app, 22.0);
|
||||
if app.group == AppGroup::New {
|
||||
widgets::badge(ui, "New", t.app_ink(app));
|
||||
}
|
||||
});
|
||||
ui.add(egui::Label::new(RichText::new(app.tagline).size(13.5).color(t.muted)).truncate());
|
||||
ui.add(egui::Label::new(RichText::new(format!("Like {}", app.inspired_by())).size(12.0).color(t.faint)).truncate());
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
|
||||
let tip = entry.failure().map(|f| format!("Couldn't check for updates: {f}"));
|
||||
let r = widgets::pill(ui, text, tone);
|
||||
if let Some(tip) = tip {
|
||||
r.on_hover_text(tip);
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
ui.label(RichText::new(version_line(&entry, &status, self.backend.now())).size(12.0).color(t.text_muted));
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
|
||||
ui.horizontal(|ui| self.actions(ui, &entry, &status, inner.width()));
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
|
||||
let r = widgets::badge(ui, text, widgets::tone_color(&t, tone));
|
||||
if let Some(f) = entry.failure() {
|
||||
r.on_hover_text(format!("Couldn't check for updates: {f}"));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(version_line(&entry, &status)).size(12.5).color(t.muted));
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
|
||||
ui.horizontal(|ui| self.actions(ui, &entry, &status, inner.width()));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- table ---------------------------------------------------------------------------
|
||||
@@ -356,119 +379,137 @@ impl LauncherUi {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let now = self.backend.now();
|
||||
let mut sort_clicked = None;
|
||||
egui::Frame::new()
|
||||
.fill(t.card)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(theme::RADIUS)
|
||||
.inner_margin(egui::Margin::symmetric(12, 6))
|
||||
.show(ui, |ui| {
|
||||
TableBuilder::new(ui)
|
||||
.striped(false)
|
||||
.vscroll(false)
|
||||
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
|
||||
.column(Column::remainder().at_least(200.0).clip(true))
|
||||
.column(Column::exact(172.0).clip(true))
|
||||
.column(Column::exact(176.0))
|
||||
.column(Column::exact(84.0))
|
||||
.column(Column::exact(84.0))
|
||||
.column(Column::exact(104.0))
|
||||
.column(Column::exact(206.0))
|
||||
.header(34.0, |mut header| {
|
||||
for (key, label) in [
|
||||
(Some(SortKey::Name), "App"),
|
||||
(Some(SortKey::InspiredBy), "Inspired by"),
|
||||
(Some(SortKey::Status), "Status"),
|
||||
(Some(SortKey::Installed), "Installed"),
|
||||
(Some(SortKey::Latest), "Latest"),
|
||||
(Some(SortKey::Released), "Released"),
|
||||
(None, ""),
|
||||
] {
|
||||
header.col(|ui| {
|
||||
let Some(k) = key else { return };
|
||||
let active = k == self.settings.sort;
|
||||
let tip = if active { "Click to reverse the order" } else { "Click to sort by this column" };
|
||||
// Measure first so the whole header cell (label + arrow) is the target.
|
||||
let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::click());
|
||||
let hovered = resp.hovered();
|
||||
let color = if active || hovered { t.text } else { t.text_faint };
|
||||
let p = ui.painter();
|
||||
let font = egui::FontId::new(11.0, theme::semibold());
|
||||
let galley = p.layout_no_wrap(label.to_uppercase(), font.clone(), color);
|
||||
let text_pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0);
|
||||
let arrow_x = text_pos.x + galley.size().x + 5.0;
|
||||
p.galley(text_pos, galley, color);
|
||||
// ▲ / ▼ for the active column; a faint hint on hover.
|
||||
let arrow = if active {
|
||||
Some(self.settings.sort_descending)
|
||||
} else if hovered {
|
||||
Some(k.starts_descending())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(down) = arrow {
|
||||
let c = egui::pos2(arrow_x + 4.0, rect.center().y);
|
||||
let (tip_y, base_y) = if down { (c.y + 3.0, c.y - 2.0) } else { (c.y - 3.0, c.y + 2.0) };
|
||||
let pts = vec![egui::pos2(c.x - 3.5, base_y), egui::pos2(c.x + 3.5, base_y), egui::pos2(c.x, tip_y)];
|
||||
let arrow_color = if active { t.accent } else { t.text_faint };
|
||||
p.add(egui::Shape::convex_polygon(pts, arrow_color, Stroke::NONE));
|
||||
// The app column's name and tagline line heights, to centre them on the icon.
|
||||
const TITLE_SIZE: f32 = 16.0;
|
||||
const TAGLINE_SIZE: f32 = 12.0;
|
||||
let (title_h, tagline_h) = ui
|
||||
.ctx()
|
||||
.fonts_mut(|f| (f.row_height(&egui::FontId::new(TITLE_SIZE, theme::display())), f.row_height(&egui::FontId::proportional(TAGLINE_SIZE))));
|
||||
// Rows are divided by hairlines across the whole table, drawn from the first cell and
|
||||
// clipped to the scroll area.
|
||||
let clip = ui.clip_rect();
|
||||
let span = ui.available_rect_before_wrap().x_range();
|
||||
let rule = |ui: &Ui| {
|
||||
let y = ui.max_rect().bottom() + ui.spacing().item_spacing.y / 2.0;
|
||||
ui.ctx().layer_painter(ui.layer_id()).with_clip_rect(clip).hline(span, y, t.hairline());
|
||||
};
|
||||
egui::Frame::new().fill(t.bg).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| {
|
||||
TableBuilder::new(ui)
|
||||
.striped(false)
|
||||
.vscroll(false)
|
||||
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
|
||||
.column(Column::remainder().at_least(220.0).clip(true))
|
||||
.column(Column::exact(172.0).clip(true))
|
||||
.column(Column::exact(176.0))
|
||||
.column(Column::exact(84.0))
|
||||
.column(Column::exact(84.0))
|
||||
.column(Column::exact(104.0))
|
||||
.column(Column::exact(206.0))
|
||||
.header(42.0, |mut header| {
|
||||
for (key, label) in [
|
||||
(Some(SortKey::Name), "App"),
|
||||
(Some(SortKey::InspiredBy), "Inspired by"),
|
||||
(Some(SortKey::Status), "Status"),
|
||||
(Some(SortKey::Installed), "Installed"),
|
||||
(Some(SortKey::Latest), "Latest"),
|
||||
(Some(SortKey::Released), "Released"),
|
||||
(None, ""),
|
||||
] {
|
||||
header.col(|ui| {
|
||||
if key == Some(SortKey::Name) {
|
||||
rule(ui);
|
||||
}
|
||||
let Some(k) = key else { return };
|
||||
let active = k == self.settings.sort;
|
||||
let tip = if active { "Click to reverse the order" } else { "Click to sort by this column" };
|
||||
// Measure first so the whole header cell (label + arrow) is the target.
|
||||
let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::click());
|
||||
let hovered = resp.hovered();
|
||||
let color = if active || hovered { t.ink_strong } else { t.faint };
|
||||
let galley = widgets::hud_galley(ui, label, color);
|
||||
let text_pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0);
|
||||
let arrow_x = text_pos.x + galley.size().x + 4.0;
|
||||
let p = ui.painter();
|
||||
p.galley(text_pos, galley, color);
|
||||
// ▲ / ▼ for the active column; a faint hint on hover.
|
||||
let arrow = if active {
|
||||
Some(self.settings.sort_descending)
|
||||
} else if hovered {
|
||||
Some(k.starts_descending())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(down) = arrow {
|
||||
let c = egui::pos2(arrow_x + 4.0, rect.center().y);
|
||||
let (tip_y, base_y) = if down { (c.y + 3.0, c.y - 2.0) } else { (c.y - 3.0, c.y + 2.0) };
|
||||
let pts = vec![egui::pos2(c.x - 3.5, base_y), egui::pos2(c.x + 3.5, base_y), egui::pos2(c.x, tip_y)];
|
||||
let arrow_color = if active { t.accent_ink } else { t.faint };
|
||||
p.add(egui::Shape::convex_polygon(pts, arrow_color, Stroke::NONE));
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
if resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
|
||||
sort_clicked = Some(k);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
let last = ids.len().saturating_sub(1);
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let Some(entry) = self.entry(id).cloned() else { continue };
|
||||
let status = entry.status(self.platform, self.caps.manage_apps);
|
||||
body.row(62.0, |mut row| {
|
||||
row.col(|ui| {
|
||||
if i < last {
|
||||
rule(ui);
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
if resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
|
||||
sort_clicked = Some(k);
|
||||
if let Some(icon) = crate::icons::app(entry.app.id) {
|
||||
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(38.0, 38.0)));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.vertical(|ui| {
|
||||
const GAP: f32 = 2.0;
|
||||
ui.spacing_mut().item_spacing.y = GAP;
|
||||
// Name and tagline as one block, centred on the icon.
|
||||
let block = title_h + GAP + tagline_h;
|
||||
ui.add_space(((ui.available_height() - block) / 2.0).max(0.0));
|
||||
widgets::wordmark(ui, entry.app, TITLE_SIZE);
|
||||
ui.add(egui::Label::new(RichText::new(entry.app.tagline).size(TAGLINE_SIZE).color(t.muted)).truncate());
|
||||
});
|
||||
});
|
||||
row.col(|ui| {
|
||||
ui.add(egui::Label::new(RichText::new(entry.app.inspired_by()).size(13.0).color(t.muted)).truncate());
|
||||
});
|
||||
row.col(|ui| {
|
||||
let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
|
||||
let r = widgets::badge(ui, text, widgets::tone_color(&t, tone));
|
||||
if let Some(f) = entry.failure() {
|
||||
r.on_hover_text(format!("Couldn't check for updates: {f}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for id in ids {
|
||||
let Some(entry) = self.entry(id).cloned() else { continue };
|
||||
let status = entry.status(self.platform, self.caps.manage_apps);
|
||||
body.row(58.0, |mut row| {
|
||||
row.col(|ui| {
|
||||
if let Some(icon) = crate::icons::app(entry.app.id) {
|
||||
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(36.0, 36.0)));
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
ui.label(RichText::new(entry.app.name).size(14.5).family(theme::semibold()).color(t.text));
|
||||
ui.add(egui::Label::new(RichText::new(entry.app.tagline).size(12.0).color(t.text_muted)).truncate());
|
||||
});
|
||||
});
|
||||
row.col(|ui| {
|
||||
ui.add(egui::Label::new(RichText::new(entry.app.inspired_by()).size(13.0).color(t.text_muted)).truncate());
|
||||
});
|
||||
row.col(|ui| {
|
||||
let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
|
||||
let r = widgets::pill(ui, text, tone);
|
||||
if let Some(f) = entry.failure() {
|
||||
r.on_hover_text(format!("Couldn't check for updates: {f}"));
|
||||
}
|
||||
});
|
||||
row.col(|ui| {
|
||||
ui.label(
|
||||
RichText::new(entry.installed_label().unwrap_or_else(|| "—".into())).color(if entry.installed.is_some() {
|
||||
t.text
|
||||
} else {
|
||||
t.text_faint
|
||||
}),
|
||||
);
|
||||
});
|
||||
row.col(|ui| {
|
||||
let c = if status == AppStatus::UpdateAvailable { t.warn } else { t.text };
|
||||
ui.label(RichText::new(entry.latest_label().unwrap_or_else(|| "—".into())).color(c));
|
||||
});
|
||||
row.col(|ui| {
|
||||
let when = entry.release().and_then(|r| r.published_at);
|
||||
let r = ui.label(RichText::new(when.map_or_else(|| "—".into(), time::format_date)).color(t.text_muted));
|
||||
if let Some(w) = when {
|
||||
r.on_hover_text(time::format_ago(w, now));
|
||||
}
|
||||
});
|
||||
row.col(|ui| self.actions(ui, &entry, &status, 196.0));
|
||||
row.col(|ui| {
|
||||
let installed = entry.installed_label();
|
||||
let color = if installed.is_some() { t.ink } else { t.faint };
|
||||
ui.label(RichText::new(installed.unwrap_or_else(|| "—".into())).family(egui::FontFamily::Monospace).color(color));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
row.col(|ui| {
|
||||
let c = if status == AppStatus::UpdateAvailable { t.warn } else { t.ink };
|
||||
ui.label(
|
||||
RichText::new(entry.latest_label().unwrap_or_else(|| "—".into())).family(egui::FontFamily::Monospace).color(c),
|
||||
);
|
||||
});
|
||||
row.col(|ui| {
|
||||
let when = entry.release().and_then(|r| r.published_at);
|
||||
let r = ui.label(RichText::new(when.map_or_else(|| "—".into(), time::format_date)).color(t.muted));
|
||||
if let Some(w) = when {
|
||||
r.on_hover_text(time::format_ago(w, now));
|
||||
}
|
||||
});
|
||||
row.col(|ui| self.actions(ui, &entry, &status, 204.0));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(k) = sort_clicked {
|
||||
if self.settings.sort == k {
|
||||
self.settings.sort_descending = !self.settings.sort_descending;
|
||||
@@ -482,10 +523,8 @@ impl LauncherUi {
|
||||
/// The trademark notice under the grid and the table.
|
||||
pub(crate) fn trademark_notice(&self, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(10.0);
|
||||
ui.separator();
|
||||
ui.add_space(6.0);
|
||||
ui.add(egui::Label::new(RichText::new(artcraft_launcher_core::catalog::TRADEMARK_NOTICE).size(11.0).color(t.text_faint)).wrap());
|
||||
ui.add_space(8.0);
|
||||
ui.add(egui::Label::new(RichText::new(artcraft_launcher_core::catalog::TRADEMARK_NOTICE).size(11.0).color(t.faint)).wrap());
|
||||
}
|
||||
|
||||
// ---- actions -------------------------------------------------------------------------
|
||||
@@ -514,14 +553,16 @@ impl LauncherUi {
|
||||
}
|
||||
}
|
||||
|
||||
/// The main button (or progress) and the ⋯ menu.
|
||||
/// The main button (or progress) and the … menu, within `width`.
|
||||
fn actions(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = entry.app.id.to_string();
|
||||
let main_w = (width - 40.0).max(120.0);
|
||||
// One square button (the menu) beside the main one.
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
let main_w = (width - theme::CONTROL_H - gap).max(120.0);
|
||||
if let Some(phase) = entry.job {
|
||||
// Two small buttons (pause, cancel) beside the bar while downloading.
|
||||
let bar_w = (width - 88.0).max(100.0);
|
||||
// Two square buttons (pause, cancel) beside the bar while downloading.
|
||||
let bar_w = (width - 2.0 * (theme::CONTROL_H + gap) - 4.0).max(100.0);
|
||||
let short = bar_w < 200.0;
|
||||
let amount = |done: u64, total: u64| {
|
||||
if total > 0 { format!("{} of {}", widgets::human_bytes(done), widgets::human_bytes(total)) } else { widgets::human_bytes(done) }
|
||||
@@ -540,15 +581,15 @@ impl LauncherUi {
|
||||
} else {
|
||||
format!("Downloading {}{pct}", amount(done, total))
|
||||
};
|
||||
(f, caption, t.accent)
|
||||
(f, caption, rgb(entry.app.color))
|
||||
}
|
||||
JobPhase::Reconnecting { done, total } => {
|
||||
let f = (total > 0).then(|| done as f32 / total as f32);
|
||||
(f, if short { "Reconnecting…".into() } else { format!("Reconnecting… {}", amount(done, total)) }, t.warn)
|
||||
}
|
||||
JobPhase::Verifying => (None, "Verifying checksum…".into(), t.accent),
|
||||
JobPhase::Installing => (None, "Installing…".into(), t.accent),
|
||||
JobPhase::Removing => (None, "Removing…".into(), t.accent),
|
||||
JobPhase::Verifying => (None, "Verifying checksum…".into(), rgb(entry.app.color)),
|
||||
JobPhase::Installing => (None, "Installing…".into(), rgb(entry.app.color)),
|
||||
JobPhase::Removing => (None, "Removing…".into(), rgb(entry.app.color)),
|
||||
};
|
||||
let downloading = matches!(phase, JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. });
|
||||
widgets::progress(ui, fraction, &caption, color, if downloading { bar_w } else { main_w });
|
||||
@@ -566,9 +607,15 @@ impl LauncherUi {
|
||||
&& self.caps.manage_apps
|
||||
{
|
||||
let pct = if p.total > 0 { format!(" · {:.0}%", p.done as f32 / p.total as f32 * 100.0) } else { String::new() };
|
||||
// Resume, discard (32) and the menu (~40), with spacing, within `width`.
|
||||
let label = if width < 260.0 { format!("Resume{pct}") } else { format!("Resume download{pct}") };
|
||||
let resp = widgets::primary(ui, &label, t.accent, (width - 96.0).max(90.0));
|
||||
// Resume, then discard and the menu (two squares).
|
||||
let resume_w = (width - 2.0 * (theme::CONTROL_H + gap)).max(90.0);
|
||||
// Narrow (the table): just "Resume"; the tooltip says how far it got.
|
||||
let label = match resume_w {
|
||||
w if w < 150.0 => "Resume".to_string(),
|
||||
w if w < 220.0 => format!("Resume{pct}"),
|
||||
_ => format!("Resume download{pct}"),
|
||||
};
|
||||
let resp = widgets::button(ui, &label, Kind::Primary, resume_w);
|
||||
let tip = if p.total > 0 {
|
||||
format!("{} of {} downloaded", widgets::human_bytes(p.done), widgets::human_bytes(p.total))
|
||||
} else {
|
||||
@@ -585,17 +632,17 @@ impl LauncherUi {
|
||||
}
|
||||
match self.primary_action(entry, status) {
|
||||
Primary::Install => {
|
||||
if widgets::primary(ui, "Install", t.accent, main_w).clicked() {
|
||||
if widgets::button(ui, "Install", Kind::Primary, main_w).clicked() {
|
||||
self.send(Command::Install { app: id.clone() });
|
||||
}
|
||||
}
|
||||
Primary::Update(v) => {
|
||||
if widgets::primary(ui, &format!("Update to {v}"), t.accent, main_w).clicked() {
|
||||
if widgets::button(ui, &format!("Update to {v}"), Kind::Primary, main_w).clicked() {
|
||||
self.send(Command::Install { app: id.clone() });
|
||||
}
|
||||
}
|
||||
Primary::Open => {
|
||||
if widgets::secondary(ui, "Open", main_w).clicked() {
|
||||
if widgets::button(ui, "Open", Kind::Secondary, main_w).clicked() {
|
||||
self.send(Command::Launch { app: id.clone() });
|
||||
}
|
||||
}
|
||||
@@ -604,12 +651,12 @@ impl LauncherUi {
|
||||
Some(p) => format!("Download for {}", p.os.label()),
|
||||
None => "Download".into(),
|
||||
};
|
||||
if widgets::primary(ui, &label, t.accent, main_w).clicked() {
|
||||
if widgets::button(ui, &label, Kind::Primary, main_w).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
}
|
||||
Primary::Unavailable(label) => {
|
||||
ui.add_enabled_ui(false, |ui| widgets::secondary(ui, label, main_w));
|
||||
ui.add_enabled_ui(false, |ui| widgets::button(ui, label, Kind::Secondary, main_w));
|
||||
}
|
||||
}
|
||||
self.menu(ui, entry, status);
|
||||
@@ -619,8 +666,7 @@ impl LauncherUi {
|
||||
let id = entry.app.id.to_string();
|
||||
let manage = self.caps.manage_apps;
|
||||
let reveal = self.caps.reveal_label;
|
||||
ui.menu_button(RichText::new("…").size(16.0), |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
widgets::more_menu(ui, |ui| {
|
||||
if entry.release().is_some() && ui.button("Release notes").clicked() {
|
||||
self.dialog = Some(Dialog::Notes(id.clone()));
|
||||
ui.close();
|
||||
@@ -694,10 +740,9 @@ fn sort_label(k: SortKey) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Installed 0.4.0 · 0.5.0 out Oct 8, 2026" and friends.
|
||||
fn version_line(e: &AppEntry, status: &AppStatus, now: i64) -> String {
|
||||
/// "0.4.1 installed → 0.5.0 released · Oct 8, 2026" and friends.
|
||||
fn version_line(e: &AppEntry, status: &AppStatus) -> String {
|
||||
let released = e.release().and_then(|r| r.published_at).map(|at| format!(" · {}", time::format_date(at))).unwrap_or_default();
|
||||
let _ = now;
|
||||
match (status, e.installed_label(), e.latest_label()) {
|
||||
(AppStatus::UpdateAvailable, Some(i), Some(l)) => format!("{i} installed → {l} released{released}"),
|
||||
(AppStatus::UpToDate, Some(i), _) => format!("Version {i}{released}"),
|
||||
@@ -718,27 +763,3 @@ fn launcher_download(r: &artcraft_launcher_core::Release, p: artcraft_launcher_c
|
||||
};
|
||||
pick_asset(r, &[artcraft_launcher_core::catalog::LAUNCHER_ID], p, kinds).map(|(a, _)| a.url.clone())
|
||||
}
|
||||
|
||||
/// Fills the top `height` of a rounded rect with opaque `color` at `alpha`, fading to transparent.
|
||||
fn gradient_top(painter: &egui::Painter, rect: egui::Rect, radius: f32, height: f32, color: Color32, alpha: f32) {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let h = height.min(rect.height());
|
||||
let steps = 24;
|
||||
let mut prev: Option<(u32, u32)> = None;
|
||||
for i in 0..=steps {
|
||||
let y = h * i as f32 / steps as f32;
|
||||
// Inset along the rounded corner.
|
||||
let inset = if y < radius { radius - (radius * radius - (radius - y) * (radius - y)).max(0.0).sqrt() } else { 0.0 };
|
||||
let a = 1.0 - i as f32 / steps as f32;
|
||||
let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), (255.0 * alpha * a * a) as u8);
|
||||
let base = mesh.vertices.len() as u32;
|
||||
mesh.colored_vertex(egui::pos2(rect.left() + inset, rect.top() + y), c);
|
||||
mesh.colored_vertex(egui::pos2(rect.right() - inset, rect.top() + y), c);
|
||||
if let Some((l, r)) = prev {
|
||||
mesh.add_triangle(l, r, base);
|
||||
mesh.add_triangle(r, base + 1, base);
|
||||
}
|
||||
prev = Some((base, base + 1));
|
||||
}
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
@@ -1,153 +1,409 @@
|
||||
//! Small building blocks: pills, buttons, segmented controls.
|
||||
//! Small building blocks in getartcraft.com's style: mono labels, square buttons, badges and
|
||||
//! hairline cell groups.
|
||||
//!
|
||||
//! Upper-case text is drawn upper case but announced to screen readers as written.
|
||||
|
||||
use egui::{Color32, CornerRadius, Response, RichText, Sense, Stroke, Ui, vec2};
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, CornerRadius, Galley, Rect, Response, RichText, Sense, Stroke, Ui, vec2};
|
||||
|
||||
use crate::model::Tone;
|
||||
use crate::theme::{self, Tokens, tint};
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
pub fn tone_color(t: &Tokens, tone: Tone) -> Color32 {
|
||||
match tone {
|
||||
Tone::Good => t.good,
|
||||
Tone::Warn => t.warn,
|
||||
Tone::Info => t.info,
|
||||
Tone::Neutral => t.text_muted,
|
||||
Tone::Faint => t.text_faint,
|
||||
Tone::Info => t.accent_ink,
|
||||
Tone::Neutral => t.muted,
|
||||
Tone::Faint => t.faint,
|
||||
}
|
||||
}
|
||||
|
||||
/// A rounded status label with a coloured dot.
|
||||
pub fn pill(ui: &mut Ui, text: &str, tone: Tone) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let c = tone_color(&t, tone);
|
||||
let galley = ui.painter().layout_no_wrap(text.to_string(), egui::FontId::new(12.0, theme::medium()), c);
|
||||
let size = vec2(galley.size().x + 26.0, 22.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(size, Sense::hover());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, CornerRadius::same(11), tint(c, if t.dark { 0.14 } else { 0.11 }));
|
||||
p.circle_filled(egui::pos2(rect.left() + 11.0, rect.center().y), 3.5, c);
|
||||
p.galley(egui::pos2(rect.left() + 19.0, rect.center().y - galley.size().y / 2.0), galley, c);
|
||||
// Painted, so tell screen readers what it says.
|
||||
/// `text` laid out upper case in `family`, `spacing` points between letters.
|
||||
pub fn caps(ui: &Ui, text: &str, size: f32, family: egui::FontFamily, spacing: f32, color: Color32) -> Arc<Galley> {
|
||||
let format = egui::TextFormat { font_id: egui::FontId::new(size, family), color, extra_letter_spacing: spacing, ..Default::default() };
|
||||
ui.painter().layout_job(egui::text::LayoutJob::single_section(text.to_uppercase(), format))
|
||||
}
|
||||
|
||||
/// A `.hud-label`: 11 px Geist Mono, upper case, widely spaced.
|
||||
pub fn hud_galley(ui: &Ui, text: &str, color: Color32) -> Arc<Galley> {
|
||||
caps(ui, text, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, color)
|
||||
}
|
||||
|
||||
/// Paints `galley` at the left of `rect`, centred vertically.
|
||||
fn paint_left(ui: &Ui, rect: Rect, galley: Arc<Galley>, color: Color32) {
|
||||
let pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0);
|
||||
ui.painter().galley_with_override_text_color(pos, galley, color);
|
||||
}
|
||||
|
||||
/// Paints `galley` centred in `rect`.
|
||||
fn paint_centred(ui: &Ui, rect: Rect, galley: Arc<Galley>, color: Color32) {
|
||||
ui.painter().galley_with_override_text_color(rect.center() - galley.size() / 2.0, galley, color);
|
||||
}
|
||||
|
||||
/// A mono label.
|
||||
pub fn hud(ui: &mut Ui, text: &str, color: Color32) -> Response {
|
||||
let galley = hud_galley(ui, text, color);
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::hover());
|
||||
paint_left(ui, rect, galley, color);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
|
||||
resp
|
||||
}
|
||||
|
||||
/// A filled call-to-action button.
|
||||
pub fn primary(ui: &mut Ui, text: &str, fill: Color32, width: f32) -> Response {
|
||||
/// "01 / Classic apps": a section eyebrow, the index faint.
|
||||
pub fn eyebrow(ui: &mut Ui, index: &str, label: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let btn = egui::Button::new(RichText::new(text).color(t.accent_text).family(theme::semibold()))
|
||||
.fill(fill)
|
||||
.stroke(Stroke::NONE)
|
||||
.corner_radius(theme::RADIUS_SMALL)
|
||||
.min_size(vec2(width, 32.0));
|
||||
let resp = ui.add(btn);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(resp.rect, CornerRadius::same(theme::RADIUS_SMALL), Color32::from_white_alpha(18));
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
hud(ui, index, t.faint);
|
||||
hud(ui, " / ", t.faint);
|
||||
hud(ui, label, t.muted);
|
||||
});
|
||||
}
|
||||
|
||||
/// A solid chip with a bold mono label: an app's number tab (the site's `AppTab`).
|
||||
pub fn tag(ui: &mut Ui, text: &str, fill: Color32, ink: Color32) -> Response {
|
||||
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::HUD_SPACING, ink);
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(16.0, 8.0), Sense::hover());
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, fill);
|
||||
paint_centred(ui, rect, galley, ink);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
|
||||
resp
|
||||
}
|
||||
|
||||
/// A vertical hairline `height` tall: divides the header's cells.
|
||||
pub fn rule(ui: &mut Ui, height: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(1.0, height), Sense::hover());
|
||||
ui.painter().vline(rect.center().x, rect.y_range(), t.hairline());
|
||||
}
|
||||
|
||||
/// A header link, like the site's nav: a mono label whose span inverts on hover or when `active`.
|
||||
pub fn nav_link(ui: &mut Ui, text: &str, tip: &str, height: f32, active: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley = hud_galley(ui, text, t.muted);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 20.0, height), Sense::click());
|
||||
let on = active || resp.hovered();
|
||||
let span = Rect::from_center_size(rect.center(), galley.size() + vec2(12.0, 6.0));
|
||||
if on {
|
||||
ui.painter().rect_filled(span, CornerRadius::ZERO, t.invert_bg);
|
||||
}
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
paint_centred(ui, rect, galley, if on { t.invert_fg } else { t.muted });
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, text));
|
||||
resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// A quiet outlined button.
|
||||
pub fn secondary(ui: &mut Ui, text: &str, width: f32) -> Response {
|
||||
/// A square header cell with a symbol and a hairline on its left; inverts on hover. While `busy`
|
||||
/// it shows a spinner and can't be clicked.
|
||||
pub fn nav_icon(ui: &mut Ui, symbol: &str, tip: &str, height: f32, busy: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let btn = egui::Button::new(RichText::new(text).color(t.text).family(theme::medium()))
|
||||
.fill(if t.dark { Color32::from_rgb(0x23, 0x27, 0x31) } else { Color32::from_rgb(0xf1, 0xf2, 0xf6) })
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(theme::RADIUS_SMALL)
|
||||
.min_size(vec2(width, 32.0));
|
||||
ui.add(btn).on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(height, height), if busy { Sense::hover() } else { Sense::click() });
|
||||
let p = ui.painter();
|
||||
let hovered = resp.hovered() && !busy;
|
||||
if hovered {
|
||||
p.rect_filled(rect, CornerRadius::ZERO, t.invert_bg);
|
||||
}
|
||||
p.vline(rect.left() + 0.5, rect.y_range(), t.hairline());
|
||||
if busy {
|
||||
egui::Spinner::new().size(16.0).color(t.muted).paint_at(ui, Rect::from_center_size(rect.center(), vec2(16.0, 16.0)));
|
||||
} else {
|
||||
p.text(rect.center(), egui::Align2::CENTER_CENTER, symbol, egui::FontId::proportional(17.0), if hovered { t.invert_fg } else { t.muted });
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, !busy, tip));
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if busy { resp } else { resp.on_hover_cursor(egui::CursorIcon::PointingHand) }
|
||||
}
|
||||
|
||||
/// A square icon-only button.
|
||||
pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str) -> Response {
|
||||
/// How far in from the window's edges a custom title bar's window resizes ([`window_edges`]).
|
||||
/// The title bar's own controls leave this band alone.
|
||||
const RESIZE_EDGE: f32 = 5.0;
|
||||
|
||||
/// Custom title bars: dragging the header's empty space moves the window, and double-clicking it
|
||||
/// maximizes or restores it. Call before drawing the header, so its controls stay on top.
|
||||
pub fn title_bar_drag(ui: &mut Ui) {
|
||||
let r = ui.max_rect();
|
||||
let rect = Rect::from_min_max(r.min + vec2(RESIZE_EDGE, RESIZE_EDGE), r.max - vec2(RESIZE_EDGE, 0.0));
|
||||
let resp = ui.interact(rect, ui.id().with("title-bar"), Sense::click_and_drag());
|
||||
if resp.drag_started_by(egui::PointerButton::Primary) {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
let maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
|
||||
}
|
||||
}
|
||||
|
||||
/// A window button in a custom title bar.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum WindowButton {
|
||||
Minimize,
|
||||
Maximize,
|
||||
Restore,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Minimize, maximize (or restore) and close, `height` tall, for a right-to-left layout: close
|
||||
/// ends up at the right.
|
||||
pub fn window_buttons(ui: &mut Ui, height: f32) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
if window_button(ui, WindowButton::Close, height).clicked() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
let size = if maximized { WindowButton::Restore } else { WindowButton::Maximize };
|
||||
if window_button(ui, size, height).clicked() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
|
||||
}
|
||||
if window_button(ui, WindowButton::Minimize, height).clicked() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
|
||||
}
|
||||
rule(ui, height);
|
||||
}
|
||||
|
||||
fn window_button(ui: &mut Ui, button: WindowButton, height: f32) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let btn = egui::Button::new(RichText::new(icon).size(16.0).color(t.text_muted))
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.stroke(Stroke::NONE)
|
||||
.min_size(vec2(32.0, 32.0));
|
||||
ui.add(btn).on_hover_text(tip)
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(46.0, height), Sense::hover());
|
||||
// Clickable below the resize band along the top (and, for close, the right edge).
|
||||
let right = if button == WindowButton::Close { RESIZE_EDGE } else { 0.0 };
|
||||
let hit = Rect::from_min_max(rect.min + vec2(0.0, RESIZE_EDGE), rect.max - vec2(right, 0.0));
|
||||
let resp = ui.interact(hit, ui.id().with(("window-button", button as u8)), Sense::click());
|
||||
let (fill, ink) = match (button, resp.hovered()) {
|
||||
(WindowButton::Close, true) => (Color32::from_rgb(0xc4, 0x2b, 0x1c), Color32::WHITE),
|
||||
(_, true) => (t.line, t.ink_strong),
|
||||
(_, false) => (Color32::TRANSPARENT, t.muted),
|
||||
};
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, CornerRadius::ZERO, fill);
|
||||
let c = rect.center();
|
||||
let stroke = Stroke::new(1.0, ink);
|
||||
let label = match button {
|
||||
WindowButton::Minimize => "Minimize",
|
||||
WindowButton::Maximize => "Maximize",
|
||||
WindowButton::Restore => "Restore",
|
||||
WindowButton::Close => "Close",
|
||||
};
|
||||
// Half the glyph's size: 10 px, like Windows' own caption buttons.
|
||||
let s = 5.0;
|
||||
match button {
|
||||
WindowButton::Minimize => {
|
||||
p.hline(c.x - s..=c.x + s, c.y, stroke);
|
||||
}
|
||||
WindowButton::Maximize => {
|
||||
p.rect_stroke(Rect::from_center_size(c, vec2(2.0 * s, 2.0 * s)), CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
|
||||
}
|
||||
WindowButton::Restore => {
|
||||
// A window in front of another.
|
||||
let front = Rect::from_min_size(c + vec2(-s, -s + 2.0), vec2(2.0 * s - 2.0, 2.0 * s - 2.0));
|
||||
p.rect_stroke(front, CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
|
||||
p.hline(c.x - s + 2.0..=c.x + s, c.y - s, stroke);
|
||||
p.vline(c.x + s, c.y - s..=c.y + s - 2.0, stroke);
|
||||
}
|
||||
WindowButton::Close => {
|
||||
p.line_segment([c + vec2(-s, -s), c + vec2(s, s)], stroke);
|
||||
p.line_segment([c + vec2(-s, s), c + vec2(s, -s)], stroke);
|
||||
}
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
resp.on_hover_text(label)
|
||||
}
|
||||
|
||||
/// Two or more mutually exclusive options in one rounded control.
|
||||
pub fn segmented<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, &str, &str)]) -> bool {
|
||||
/// Custom title bars: a hairline round the window, and resizing it from its edges and corners.
|
||||
pub fn window_edges(ctx: &egui::Context) {
|
||||
use egui::CursorIcon as C;
|
||||
use egui::viewport::ResizeDirection as D;
|
||||
let (maximized, fullscreen) = ctx.input(|i| (i.viewport().maximized.unwrap_or(false), i.viewport().fullscreen.unwrap_or(false)));
|
||||
if maximized || fullscreen {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let r = ctx.content_rect();
|
||||
ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("window-border"))).rect_stroke(
|
||||
r,
|
||||
CornerRadius::ZERO,
|
||||
t.hairline(),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|p| r.contains(*p)) else { return };
|
||||
let near = |d: f32| d < RESIZE_EDGE;
|
||||
// A corner takes a little of each edge next to it.
|
||||
let corner = |d: f32| d < 3.0 * RESIZE_EDGE;
|
||||
let (left, right, top, bottom) = (p.x - r.left(), r.right() - p.x, p.y - r.top(), r.bottom() - p.y);
|
||||
let hit = if (near(top) && corner(left)) || (near(left) && corner(top)) {
|
||||
Some((D::NorthWest, C::ResizeNorthWest))
|
||||
} else if (near(top) && corner(right)) || (near(right) && corner(top)) {
|
||||
Some((D::NorthEast, C::ResizeNorthEast))
|
||||
} else if (near(bottom) && corner(left)) || (near(left) && corner(bottom)) {
|
||||
Some((D::SouthWest, C::ResizeSouthWest))
|
||||
} else if (near(bottom) && corner(right)) || (near(right) && corner(bottom)) {
|
||||
Some((D::SouthEast, C::ResizeSouthEast))
|
||||
} else if near(top) {
|
||||
Some((D::North, C::ResizeNorth))
|
||||
} else if near(bottom) {
|
||||
Some((D::South, C::ResizeSouth))
|
||||
} else if near(left) {
|
||||
Some((D::West, C::ResizeWest))
|
||||
} else if near(right) {
|
||||
Some((D::East, C::ResizeEast))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((direction, cursor)) = hit {
|
||||
ctx.set_cursor_icon(cursor);
|
||||
if ctx.input(|i| i.pointer.primary_pressed()) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::BeginResize(direction));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mono link with an ↗: muted, ink on hover. Opens `url` in the browser.
|
||||
pub fn hud_link(ui: &mut Ui, text: &str, url: &str) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley = hud_galley(ui, &format!("{text} ↗"), t.muted);
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::click());
|
||||
paint_left(ui, rect, galley, if resp.hovered() { t.ink_strong } else { t.muted });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, text));
|
||||
let resp = resp.on_hover_text(url).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if resp.clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// A small square status badge: 10 px mono on the sunken surface, in `color`.
|
||||
pub fn badge(ui: &mut Ui, text: &str, color: Color32) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley = caps(ui, text, 10.0, theme::mono(), 1.0, color);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 12.0, 20.0), Sense::hover());
|
||||
ui.painter().rect(rect, CornerRadius::ZERO, t.sunken, t.hairline(), egui::StrokeKind::Inside);
|
||||
paint_centred(ui, rect, galley, color);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
|
||||
resp
|
||||
}
|
||||
|
||||
/// Which of the site's button variants.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// A solid block in the inverted colours; fades a little on hover.
|
||||
Primary,
|
||||
/// Outlined; inverts on hover.
|
||||
Secondary,
|
||||
/// Solid red; fades a little on hover.
|
||||
Danger,
|
||||
}
|
||||
|
||||
/// A square button with a mono upper-case label, at least `width` wide.
|
||||
pub fn button(ui: &mut Ui, text: &str, kind: Kind, width: f32) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::BUTTON_SPACING, t.ink);
|
||||
let size = vec2((galley.size().x + 32.0).max(width), theme::CONTROL_H);
|
||||
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
let enabled = ui.is_enabled();
|
||||
let hovered = resp.hovered() && enabled;
|
||||
let (fill, stroke, color) = match kind {
|
||||
Kind::Primary => (t.invert_bg.gamma_multiply(if hovered { 0.8 } else { 1.0 }), Stroke::NONE, t.invert_fg),
|
||||
Kind::Danger => (t.bad.gamma_multiply(if hovered { 0.8 } else { 1.0 }), Stroke::NONE, Color32::WHITE),
|
||||
Kind::Secondary if hovered => (t.invert_bg, Stroke::NONE, t.invert_fg),
|
||||
Kind::Secondary => (t.bg, Stroke::new(1.0, t.line_strong), t.ink),
|
||||
};
|
||||
// Disabled: the whole button at half strength, like the site's `opacity-50`.
|
||||
let fade = |c: Color32| if enabled { c } else { c.gamma_multiply(0.5) };
|
||||
ui.painter().rect(rect, CornerRadius::ZERO, fade(fill), Stroke::new(stroke.width, fade(stroke.color)), egui::StrokeKind::Inside);
|
||||
paint_centred(ui, rect, galley, fade(color));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, text));
|
||||
if enabled { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } else { resp }
|
||||
}
|
||||
|
||||
/// One option in a [`cells`] group.
|
||||
pub struct Cell<'a> {
|
||||
pub label: &'a str,
|
||||
pub tip: &'a str,
|
||||
/// A count after the label, and the colour it takes when it isn't zero.
|
||||
pub count: Option<(usize, Option<Color32>)>,
|
||||
}
|
||||
|
||||
/// Mutually exclusive options in a row of hairline cells (the site's share bar); the chosen one
|
||||
/// is inverted. Returns whether the choice changed.
|
||||
pub fn cells<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, Cell<'_>)]) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut changed = false;
|
||||
egui::Frame::new()
|
||||
.fill(if t.dark { Color32::from_rgb(0x0e, 0x10, 0x14) } else { Color32::from_rgb(0xec, 0xee, 0xf2) })
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(theme::RADIUS_SMALL + 1)
|
||||
.inner_margin(3)
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
// In a right-to-left parent the row fills from the right: keep the options' order.
|
||||
let rtl = ui.layout().prefer_right_to_left();
|
||||
ui.horizontal(|ui| {
|
||||
let ordered: Vec<&(T, &str, &str)> = if rtl { options.iter().rev().collect() } else { options.iter().collect() };
|
||||
for (opt, label, tip) in ordered {
|
||||
let on = *value == *opt;
|
||||
let btn =
|
||||
egui::Button::new(RichText::new(*label).size(13.0).family(theme::medium()).color(if on { t.text } else { t.text_muted }))
|
||||
.fill(if on { t.card_hover } else { Color32::TRANSPARENT })
|
||||
.stroke(if on { Stroke::new(1.0, t.border_strong) } else { Stroke::NONE })
|
||||
.corner_radius(theme::RADIUS_SMALL - 1)
|
||||
.min_size(vec2(0.0, 26.0));
|
||||
if ui.add(btn).on_hover_text(*tip).clicked() && !on {
|
||||
*value = *opt;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// In a right-to-left parent the row fills from the right: keep the options' order.
|
||||
let rtl = ui.layout().prefer_right_to_left();
|
||||
let ordered: Vec<&(T, Cell<'_>)> = if rtl { options.iter().rev().collect() } else { options.iter().collect() };
|
||||
let mut rects = Vec::with_capacity(options.len());
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
for (opt, cell) in ordered {
|
||||
let on = *value == *opt;
|
||||
let label = hud_galley(ui, cell.label, t.ink);
|
||||
let count = cell.count.map(|(n, highlight)| {
|
||||
let c = if on { t.invert_fg.gamma_multiply(0.6) } else { highlight.filter(|_| n > 0).unwrap_or(t.faint) };
|
||||
(hud_galley(ui, &n.to_string(), c), c)
|
||||
});
|
||||
});
|
||||
let w = label.size().x + count.as_ref().map_or(0.0, |(g, _)| g.size().x + 8.0) + 24.0;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, theme::CONTROL_H), Sense::click());
|
||||
let hovered = resp.hovered();
|
||||
let (fill, ink) = match (on, hovered) {
|
||||
(true, _) => (t.invert_bg, t.invert_fg),
|
||||
(false, true) => (t.sunken, t.ink_strong),
|
||||
(false, false) => (Color32::TRANSPARENT, t.muted),
|
||||
};
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, fill);
|
||||
let text_rect = rect.shrink2(vec2(12.0, 0.0));
|
||||
let label_w = label.size().x;
|
||||
paint_left(ui, text_rect, label, ink);
|
||||
if let Some((g, c)) = count {
|
||||
paint_left(ui, text_rect.with_min_x(text_rect.left() + label_w + 8.0), g, c);
|
||||
}
|
||||
let name = match cell.count {
|
||||
Some((n, _)) => format!("{} {n}", cell.label),
|
||||
None => cell.label.to_string(),
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, &name));
|
||||
if resp.on_hover_text(cell.tip).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() && !on {
|
||||
*value = *opt;
|
||||
changed = true;
|
||||
}
|
||||
rects.push(rect);
|
||||
}
|
||||
});
|
||||
// The hairlines: an outline, and a rule between neighbours.
|
||||
if let (Some(first), Some(last)) = (rects.first(), rects.last()) {
|
||||
let p = ui.painter();
|
||||
p.rect_stroke(first.union(*last), CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
|
||||
let (top, bottom) = (first.top(), first.bottom());
|
||||
for r in rects.iter().skip(1) {
|
||||
let x = if rtl { r.right() } else { r.left() };
|
||||
p.vline(x, top..=bottom, t.hairline());
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// A filter chip with a count.
|
||||
pub fn chip(ui: &mut Ui, on: bool, label: &str, count: usize, highlight: Option<Color32>) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let text_color = if on { t.text } else { t.text_muted };
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
job.append(label, 0.0, egui::TextFormat { font_id: egui::FontId::new(13.0, theme::medium()), color: text_color, ..Default::default() });
|
||||
job.append(
|
||||
&format!(" {count}"),
|
||||
0.0,
|
||||
egui::TextFormat {
|
||||
font_id: egui::FontId::new(12.0, theme::medium()),
|
||||
color: highlight.filter(|_| count > 0).unwrap_or(t.text_faint),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let btn = egui::Button::new(job)
|
||||
.fill(if on { tint(t.accent, if t.dark { 0.20 } else { 0.12 }) } else { Color32::TRANSPARENT })
|
||||
.stroke(if on { Stroke::new(1.0, tint(t.accent, 0.55)) } else { Stroke::new(1.0, t.border) })
|
||||
.corner_radius(CornerRadius::same(15))
|
||||
.min_size(vec2(0.0, 28.0));
|
||||
ui.add(btn).on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// A slim progress bar with a caption.
|
||||
/// A slim square progress bar with a caption above it.
|
||||
pub fn progress(ui: &mut Ui, fraction: Option<f32>, caption: &str, color: Color32, width: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, theme::CONTROL_H), Sense::hover());
|
||||
let p = ui.painter();
|
||||
let bar = egui::Rect::from_min_size(egui::pos2(rect.left(), rect.bottom() - 6.0), vec2(width, 6.0));
|
||||
p.rect_filled(bar, CornerRadius::same(3), t.border);
|
||||
let f = match fraction {
|
||||
Some(f) => f.clamp(0.0, 1.0),
|
||||
let bar = Rect::from_min_size(egui::pos2(rect.left(), rect.bottom() - 4.0), vec2(width, 4.0));
|
||||
p.rect_filled(bar, CornerRadius::ZERO, t.line);
|
||||
match fraction {
|
||||
Some(f) => {
|
||||
p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * f.clamp(0.0, 1.0), bar.height())), CornerRadius::ZERO, color);
|
||||
}
|
||||
None => {
|
||||
// Indeterminate: a sweeping segment.
|
||||
let time = ui.input(|i| i.time) as f32;
|
||||
let x = (time * 0.8).fract();
|
||||
let seg =
|
||||
egui::Rect::from_min_size(egui::pos2(bar.left() + bar.width() * (x * 1.3 - 0.3).max(0.0), bar.top()), vec2(bar.width() * 0.3, 6.0))
|
||||
.intersect(bar);
|
||||
p.rect_filled(seg, CornerRadius::same(3), color);
|
||||
let x = (ui.input(|i| i.time) as f32 * 0.8).fract();
|
||||
let seg = Rect::from_min_size(
|
||||
egui::pos2(bar.left() + bar.width() * (x * 1.3 - 0.3).max(0.0), bar.top()),
|
||||
vec2(bar.width() * 0.3, bar.height()),
|
||||
)
|
||||
.intersect(bar);
|
||||
p.rect_filled(seg, CornerRadius::ZERO, color);
|
||||
ui.ctx().request_repaint();
|
||||
-1.0
|
||||
}
|
||||
};
|
||||
if f >= 0.0 {
|
||||
p.rect_filled(egui::Rect::from_min_size(bar.min, vec2(bar.width() * f, 6.0)), CornerRadius::same(3), color);
|
||||
}
|
||||
p.text(egui::pos2(rect.left(), rect.top() + 7.0), egui::Align2::LEFT_CENTER, caption, egui::FontId::new(12.0, theme::medium()), t.text_muted);
|
||||
p.text(egui::pos2(rect.left(), rect.top() + 9.0), egui::Align2::LEFT_CENTER, caption, egui::FontId::new(12.0, theme::medium()), t.muted);
|
||||
let resp = ui.interact(rect, ui.id().with(("progress", caption)), Sense::hover());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ProgressIndicator, true, caption));
|
||||
}
|
||||
@@ -171,69 +427,44 @@ pub enum Glyph {
|
||||
Close,
|
||||
}
|
||||
|
||||
/// A small square button with a drawn glyph.
|
||||
/// A small outlined square button with a drawn glyph; inverts on hover.
|
||||
pub fn glyph_button(ui: &mut Ui, glyph: Glyph, tip: &str) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(theme::CONTROL_H, theme::CONTROL_H), Sense::click());
|
||||
let hovered = resp.hovered();
|
||||
let p = ui.painter();
|
||||
if hovered {
|
||||
p.rect_filled(
|
||||
rect,
|
||||
CornerRadius::same(theme::RADIUS_SMALL),
|
||||
if t.dark { Color32::from_rgb(0x2b, 0x30, 0x3c) } else { Color32::from_rgb(0xe4, 0xe7, 0xee) },
|
||||
);
|
||||
p.rect_filled(rect, CornerRadius::ZERO, t.invert_bg);
|
||||
} else {
|
||||
p.rect_stroke(rect, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
|
||||
}
|
||||
let c = if hovered { t.text } else { t.text_muted };
|
||||
let c = if hovered { t.invert_fg } else { t.muted };
|
||||
let m = rect.center();
|
||||
match glyph {
|
||||
Glyph::Pause => {
|
||||
for dx in [-3.5, 3.5] {
|
||||
p.rect_filled(egui::Rect::from_center_size(m + vec2(dx, 0.0), vec2(3.0, 11.0)), CornerRadius::same(1), c);
|
||||
for dx in [-3.0, 3.0] {
|
||||
p.rect_filled(Rect::from_center_size(m + vec2(dx, 0.0), vec2(2.5, 10.0)), CornerRadius::ZERO, c);
|
||||
}
|
||||
}
|
||||
Glyph::Close => {
|
||||
let s = 4.5;
|
||||
p.line_segment([m + vec2(-s, -s), m + vec2(s, s)], Stroke::new(1.6, c));
|
||||
p.line_segment([m + vec2(-s, s), m + vec2(s, -s)], Stroke::new(1.6, c));
|
||||
p.line_segment([m + vec2(-s, -s), m + vec2(s, s)], Stroke::new(1.5, c));
|
||||
p.line_segment([m + vec2(-s, s), m + vec2(s, -s)], Stroke::new(1.5, c));
|
||||
}
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
|
||||
resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// "Discord": opens the ArtCraft community server. Outlined in Discord's blurple so it reads as
|
||||
/// a community link rather than an app action.
|
||||
pub fn discord_button(ui: &mut Ui) -> Response {
|
||||
/// The "…" button that opens an app's menu: an outlined square like [`glyph_button`].
|
||||
pub fn more_menu<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let blurple = Color32::from_rgb(0x58, 0x65, 0xf2);
|
||||
let text_color = if t.dark { Color32::from_rgb(0xc9, 0xcd, 0xfb) } else { Color32::from_rgb(0x40, 0x4e, 0xed) };
|
||||
let btn = egui::Button::new(RichText::new("💬 Discord").size(13.0).family(theme::medium()).color(text_color))
|
||||
.fill(tint(blurple, if t.dark { 0.16 } else { 0.08 }))
|
||||
.stroke(Stroke::new(1.0, tint(blurple, if t.dark { 0.55 } else { 0.40 })))
|
||||
.corner_radius(theme::RADIUS_SMALL + 1)
|
||||
.min_size(vec2(0.0, 32.0));
|
||||
ui.add(btn).on_hover_text("Join the ArtCraft community on Discord").on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// A quiet text link: faint until hovered, then accent and underlined. Opens `url` in the browser.
|
||||
pub fn small_link(ui: &mut Ui, text: &str, url: &str) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = egui::FontId::new(11.5, theme::medium());
|
||||
let galley = ui.painter().layout_no_wrap(text.to_string(), font, t.text_faint);
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::click());
|
||||
let color = if resp.hovered() { t.accent } else { t.text_faint };
|
||||
let p = ui.painter();
|
||||
p.galley_with_override_text_color(rect.min, galley, color);
|
||||
if resp.hovered() {
|
||||
p.line_segment([egui::pos2(rect.left(), rect.bottom()), egui::pos2(rect.right(), rect.bottom())], Stroke::new(1.0, color));
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, text));
|
||||
let resp = resp.on_hover_text(url).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if resp.clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
resp
|
||||
let btn =
|
||||
egui::Button::new(RichText::new("…").size(15.0).color(t.muted)).fill(Color32::TRANSPARENT).min_size(vec2(theme::CONTROL_H, theme::CONTROL_H));
|
||||
egui::containers::menu::MenuButton::from_button(btn).ui(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
add_contents(ui)
|
||||
});
|
||||
}
|
||||
|
||||
/// ArtCraft's social accounts as a row of small monochrome icons (faint, brighter on hover).
|
||||
@@ -247,7 +478,7 @@ pub fn social_icons(ui: &mut Ui, size: f32) {
|
||||
let Some(icon) = crate::icons::social(name) else { continue };
|
||||
let pad = 4.0;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(size + 2.0 * pad, size + 2.0 * pad), Sense::click());
|
||||
let color = if resp.hovered() { t.text } else { t.text_faint };
|
||||
let color = if resp.hovered() { t.ink_strong } else { t.faint };
|
||||
egui::Image::new(icon).tint(color).paint_at(ui, rect.shrink(pad));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, *name));
|
||||
let resp = resp.on_hover_text(format!("ArtCraft on {name}")).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
@@ -256,3 +487,24 @@ pub fn social_icons(ui: &mut Ui, size: f32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An app's name as the site sets it: Archivo, with "Craft" in the app's colour.
|
||||
pub fn wordmark(ui: &mut Ui, app: &artcraft_launcher_core::CraftApp, size: f32) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (prefix, craft) = app.name.strip_suffix("Craft").map_or((app.name, ""), |p| (p, "Craft"));
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let format = |color| egui::TextFormat {
|
||||
font_id: egui::FontId::new(size, theme::display()),
|
||||
color,
|
||||
extra_letter_spacing: -0.03 * size,
|
||||
..Default::default()
|
||||
};
|
||||
job.append(prefix, 0.0, format(t.ink_strong));
|
||||
job.append(craft, 0.0, format(t.app_ink(app)));
|
||||
ui.add(egui::Label::new(job).selectable(false))
|
||||
}
|
||||
|
||||
/// A heading in the display face, tracked in like the site's (`tracking-[-0.02em]`).
|
||||
pub fn heading(text: &str, size: f32, color: Color32) -> RichText {
|
||||
RichText::new(text).size(size).family(theme::display()).color(color).extra_letter_spacing(-0.02 * size)
|
||||
}
|
||||
@@ -145,8 +145,9 @@ fn grid_shows_every_status_and_installs_on_click() {
|
||||
h.get_by_label("Update to 0.6.0");
|
||||
h.get_by_label_contains("0.4.1 installed → 0.5.0 released");
|
||||
h.get_by_label("Like Adobe Photoshop®");
|
||||
h.get_by_label("Open Source Tools for Creatives, Makers, and Builders");
|
||||
assert!(h.query_all_by_label_contains("Discord").count() >= 2); // header button and footer link
|
||||
// The window has no title bar of its own here (the web build, and the tests).
|
||||
assert!(h.query_by_label("Close").is_none());
|
||||
assert!(h.query_all_by_label_contains("Discord").count() >= 2); // header link and footer link
|
||||
h.get_by_label_contains("Adobe, Photoshop, Illustrator");
|
||||
assert!(h.query_all_by_label("Coming soon").count() >= 1);
|
||||
h.get_by_label_contains("48 MB of 131 MB");
|
||||
@@ -266,7 +267,7 @@ fn every_table_column_sorts_both_ways() {
|
||||
fn settings_link_to_discord() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
h.get_by_label("⚙").click();
|
||||
h.get_by_label("Settings").click();
|
||||
h.run_steps(4);
|
||||
snapshot(&mut h, "settings-dark");
|
||||
h.get_by_label("Questions, ideas and news from the ArtCraft team");
|
||||
@@ -326,7 +327,7 @@ fn all_opened(h: &mut Harness<'_, Option<LauncherUi>>, label: &str) -> Vec<Strin
|
||||
fn links_open_the_right_pages() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
assert_eq!(click_opens(&mut h, "💬 Discord").as_deref(), Some("https://discord.gg/artcraft"));
|
||||
assert_eq!(click_opens(&mut h, "Discord").as_deref(), Some("https://discord.gg/artcraft"));
|
||||
// The footer's ArtCraft links and each card's own pages.
|
||||
let github = all_opened(&mut h, "GitHub");
|
||||
assert!(github.contains(&"https://github.com/storytold".to_string()), "{github:?}");
|
||||
@@ -452,7 +453,7 @@ fn the_current_desktop_setting_is_only_offered_where_the_os_has_desktops() {
|
||||
// NoHost has no desktops (Windows, Linux, web): no setting.
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
h.get_by_label("⚙").click();
|
||||
h.get_by_label("Settings").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.query_by_label("Show the window on the current desktop").is_none());
|
||||
// On by default for everyone; macOS shows it in Settings (DesktopHost::has_desktops).
|
||||
@@ -467,3 +468,36 @@ fn web_build_offers_downloads() {
|
||||
assert!(h.query_all_by_label("Download for macOS").count() >= 8);
|
||||
assert!(h.query_by_label("Install").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_title_bar_has_window_buttons() {
|
||||
struct FramelessHost;
|
||||
impl Host for FramelessHost {
|
||||
fn title_bar(&self) -> artcraft_launcher_ui_egui::TitleBar {
|
||||
artcraft_launcher_ui_egui::TitleBar::Custom
|
||||
}
|
||||
}
|
||||
let mut backend = Some(Fake { events: events(), sent: Rc::new(RefCell::new(Vec::new())), manage: true, clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).wgpu().build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
{
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() }));
|
||||
}
|
||||
if let Some(l) = state.as_mut() {
|
||||
l.logic(&ui.ctx().clone(), &mut FramelessHost);
|
||||
l.ui(ui, &mut FramelessHost);
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
h.run_steps(6);
|
||||
snapshot(&mut h, "grid-title-bar-dark");
|
||||
h.get_by_label("Minimize");
|
||||
h.get_by_label("Maximize");
|
||||
h.get_by_label("Close").click();
|
||||
h.step();
|
||||
let closes = h.output().viewport_output.values().any(|v| v.commands.contains(&egui::ViewportCommand::Close));
|
||||
assert!(closes, "the close button asks the window to close (the shell decides whether that hides it to the tray)");
|
||||
}
|
||||
|
Before Width: | Height: | Size: 778 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 572 KiB After Width: | Height: | Size: 241 KiB |
@@ -2,19 +2,20 @@
|
||||
# Regenerate every app and tray icon from the ArtCraft "A" mark in docs/brand/, rendered exactly as
|
||||
# supplied. The mark is an ArtCraft trademark, NOT MIT/Apache: docs/brand/LICENSE-brand.txt.
|
||||
#
|
||||
# - App icon (Dock, taskbar, Start menu, Linux menus, installers): the black mark.
|
||||
# - App icon (Dock, taskbar, Start menu, Linux menus, installers): the blue mark, which is also
|
||||
# getartcraft.com's favicon.
|
||||
# - Tray: the black mark as the macOS menu-bar template; the blue mark on Windows, Linux and BSD.
|
||||
#
|
||||
# Needs: resvg (brew install resvg / cargo install resvg). On macOS, iconutil also writes the
|
||||
# .icns. The outputs are committed, so packaging (and the Rust build, which include_bytes!s some
|
||||
# of them) never needs these tools.
|
||||
# Needs: resvg (brew install resvg / cargo install resvg). The .ico and .icns are packed by
|
||||
# `cargo xtask ico` / `cargo xtask icns`, so this runs on any OS. The outputs are committed, so
|
||||
# packaging (and the Rust build, which include_bytes!s some of them) never needs these tools.
|
||||
#
|
||||
# packaging/icons.sh
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DIR="$ROOT/assets/app-icon"
|
||||
BRAND="$ROOT/docs/brand"
|
||||
SVG="$BRAND/artcraft-mark-black.svg"
|
||||
SVG="$BRAND/artcraft-mark.svg"
|
||||
ID="ai.storyteller.artcraft-launcher"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
@@ -51,17 +52,12 @@ done
|
||||
(cd "$ROOT" && cargo run -q -p xtask -- ico "$DIR/artcraft-launcher.ico" "${ICO_PNGS[@]}")
|
||||
|
||||
# macOS .icns.
|
||||
if command -v iconutil >/dev/null; then
|
||||
SET="$TMP/artcraft-launcher.iconset"
|
||||
mkdir -p "$SET"
|
||||
for s in 16 32 128 256 512; do
|
||||
render "$MAC" "$s" "$SET/icon_${s}x${s}.png"
|
||||
render "$MAC" $((s * 2)) "$SET/icon_${s}x${s}@2x.png"
|
||||
done
|
||||
iconutil -c icns -o "$DIR/artcraft-launcher.icns" "$SET"
|
||||
else
|
||||
echo "warning: iconutil not found (macOS only); artcraft-launcher.icns not regenerated" >&2
|
||||
fi
|
||||
ICNS_PNGS=()
|
||||
for s in 16 32 64 128 256 512 1024; do
|
||||
render "$MAC" "$s" "$TMP/icns-$s.png"
|
||||
ICNS_PNGS+=("$TMP/icns-$s.png")
|
||||
done
|
||||
(cd "$ROOT" && cargo run -q -p xtask -- icns "$DIR/artcraft-launcher.icns" "${ICNS_PNGS[@]}")
|
||||
|
||||
# Tray icons: the black mark is the macOS menu-bar template (44 px = 22 pt @2x; macOS tints it);
|
||||
# the blue one is for the Windows notification area and Linux/BSD status notifiers.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Overrides for the setup wizard's WixUI strings, so its pages name the version being installed
|
||||
([ArtCraftLauncherVersion] is the full version, pre-release tag included, set in
|
||||
artcraft-launcher.wxs) and say what the launcher is for.
|
||||
-->
|
||||
<WixLocalization Culture="en-US" xmlns="http://wixtoolset.org/schemas/v4/wxl">
|
||||
<String Id="WelcomeDlgTitle" Value="{\WixUI_Font_Bigger}Welcome to [ProductName] [ArtCraftLauncherVersion]" />
|
||||
<String Id="WelcomeDlgDescription" Value="ArtCraft Launcher installs, updates and opens the Craft App Suite: PhotoCraft, VectorCraft, FilmCraft and the rest of the open-source ArtCraft apps. It installs for you only, so no administrator rights are needed. Click Next to continue." />
|
||||
<String Id="VerifyReadyDlgInstallTitle" Value="{\WixUI_Font_Title}Ready to install [ProductName] [ArtCraftLauncherVersion]" />
|
||||
<!-- Also shown after a repair or removal from the same wizard, so it doesn't say "installed". -->
|
||||
<String Id="ExitDialogTitle" Value="{\WixUI_Font_Bigger}Completed the [ProductName] [ArtCraftLauncherVersion] Setup Wizard" />
|
||||
</WixLocalization>
|
||||
@@ -1,18 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
ArtCraft Launcher MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
|
||||
ArtCraft Launcher MSI (WiX Toolset v5: `dotnet tool install -g wix`, plus the UI and Util
|
||||
extensions: `wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`).
|
||||
Built by package.ps1:
|
||||
|
||||
wix build artcraft-launcher.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
|
||||
wix build artcraft-launcher.wxs -arch x64 -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext
|
||||
-culture en-US -loc artcraft-launcher.en-us.wxl -d Version=1.2.3 -d DisplayVersion=1.2.3-rc.1
|
||||
-d BinDir=... -d IconPath=... -d BannerBmp=... -d DialogBmp=... -o out.msi
|
||||
|
||||
Per-user install (no UAC prompt) into %LOCALAPPDATA%\Programs\ArtCraft Launcher, so the
|
||||
launcher can update itself without elevation, plus a Start Menu shortcut. The shortcut carries
|
||||
the AppUserModelID ai.storyteller.artcraft-launcher, which Windows needs to show toast
|
||||
notifications from an unpackaged app and to group the taskbar button. No file associations.
|
||||
|
||||
Double-clicked, it opens a setup wizard that names the version: Welcome, Ready, a progress page
|
||||
and a Finish page with a ticked "Launch ArtCraft Launcher" box. There is no licence page (the
|
||||
code is MIT OR Apache-2.0; nothing to accept) and no folder page (self-update needs the per-user
|
||||
folder). The launcher's own self-update runs it with /passive, which skips the wizard.
|
||||
|
||||
Per-user packages can't use a file as a component's KeyPath (ICE38), so each component is
|
||||
keyed on an HKCU value, and the folders we create are removed on uninstall (ICE64).
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||
<Package
|
||||
Name="ArtCraft Launcher"
|
||||
Manufacturer="Learning Machines LLC"
|
||||
@@ -31,7 +41,7 @@
|
||||
<!-- MSI caches this icon using its Id as the filename; keep the .exe extension (ICE50). -->
|
||||
<Icon Id="ArtCraftLauncherIcon.exe" SourceFile="$(var.IconPath)" />
|
||||
<Property Id="ARPPRODUCTICON" Value="ArtCraftLauncherIcon.exe" />
|
||||
<Property Id="ARPURLINFOABOUT" Value="https://github.com/storytold/craft-launcher" />
|
||||
<Property Id="ARPURLINFOABOUT" Value="https://getartcraft.com/apps" />
|
||||
<Property Id="ARPURLUPDATEINFO" Value="https://github.com/storytold/craft-launcher/releases" />
|
||||
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
|
||||
|
||||
@@ -45,6 +55,31 @@
|
||||
<Feature Id="Main" Title="ArtCraft Launcher" Level="1">
|
||||
<ComponentGroupRef Id="ArtCraftLauncherFiles" />
|
||||
</Feature>
|
||||
|
||||
<!-- The setup wizard: WixUI_InstallDir without its licence and folder pages. A later
|
||||
NewDialog on the same button wins. -->
|
||||
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
|
||||
<UI>
|
||||
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="2" Condition="NOT Installed" />
|
||||
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2" Condition="NOT Installed" />
|
||||
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchArtCraftLauncher"
|
||||
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed" />
|
||||
</UI>
|
||||
<!-- Our own art, drawn by package.ps1 from the ArtCraft mark and the Craft apps' icons. -->
|
||||
<WixVariable Id="WixUIBannerBmp" Value="$(var.BannerBmp)" />
|
||||
<WixVariable Id="WixUIDialogBmp" Value="$(var.DialogBmp)" />
|
||||
|
||||
<!-- The full version (Version is numeric, so it drops a pre-release tag), shown on the wizard's
|
||||
pages through artcraft-launcher.en-us.wxl. -->
|
||||
<Property Id="ArtCraftLauncherVersion" Value="$(var.DisplayVersion)" />
|
||||
|
||||
<!-- Finish page: say where to find the launcher, and offer to start it (ticked by default). -->
|
||||
<Property Id="WIXUI_EXITDIALOGOPTIONALTEXT" Value="ArtCraft Launcher $(var.DisplayVersion) is installed. Start it any time from the Start menu to install and update the Craft App Suite." />
|
||||
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Launch ArtCraft Launcher" />
|
||||
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
|
||||
<Property Id="WixShellExecTarget" Value="[#ArtCraftLauncherExe]" />
|
||||
<!-- Runs in the wizard's own process, so the launcher starts as the user. -->
|
||||
<CustomAction Id="LaunchArtCraftLauncher" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)" DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
|
||||
</Package>
|
||||
|
||||
<Fragment>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Produces, in $env:DIST (default: dist/release):
|
||||
artcraft-launcher-<version>-windows-<arch>.msi per-user installer (WiX v5)
|
||||
artcraft-launcher-<version>-windows-<arch>.msi per-user installer with a setup wizard (WiX v5)
|
||||
artcraft-launcher-<version>-windows-<arch>-portable.zip artcraft-launcher.exe + portable.txt
|
||||
|
||||
The binary links the C runtime statically (+crt-static), so neither the MSI nor the portable
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
Needs: Rust (MSVC toolchain + the target), the Windows SDK (rc.exe, signtool.exe),
|
||||
and WiX v5: dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
|
||||
.EXAMPLE
|
||||
pwsh packaging/windows/package.ps1 -Arch x64
|
||||
@@ -94,11 +95,91 @@ Copy-Item (Join-Path $Bin $Exe) $Stage
|
||||
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage $Exe)
|
||||
|
||||
# ---- MSI ---------------------------------------------------------------------------------------
|
||||
# The setup wizard's art, drawn here (System.Drawing) in getartcraft.com's style so the MSI shows
|
||||
# ArtCraft rather than WiX's stock bitmaps. Both must be 24-bit BMPs:
|
||||
# - dialog, 493x312: the Welcome and Finish pages' backdrop. Their text sits on the right, so
|
||||
# that stays white; the left panel is ours: the blue ArtCraft mark, the white wordmark, a mono
|
||||
# "Launcher" label and the Craft apps' icons in a hairline grid.
|
||||
# - banner, 493x58: across the top of the inner pages. The page title is drawn over its left
|
||||
# side, so that stays white; the right end is a dark cell with the mark.
|
||||
# The ArtCraft mark and wordmark are drawn as supplied (docs/brand/LICENSE-brand.txt).
|
||||
function New-InstallerArt([string] $BannerOut, [string] $DialogOut) {
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$ink = [System.Drawing.ColorTranslator]::FromHtml('#121316')
|
||||
$accent = [System.Drawing.ColorTranslator]::FromHtml('#2d81ff')
|
||||
$muted = [System.Drawing.Color]::FromArgb(153, 242, 241, 238)
|
||||
$line = [System.Drawing.Color]::FromArgb(38, 242, 241, 238)
|
||||
# The Craft App Suite, in the launcher's order (crates/core/src/catalog.rs).
|
||||
$apps = 'photocraft', 'vectorcraft', 'filmcraft', 'lightcraft', 'pdfcraft', 'effectcraft',
|
||||
'designcraft', 'wordcraft', 'deckcraft', 'gridcraft', 'cadcraft', 'soundcraft'
|
||||
$mark = [System.Drawing.Image]::FromFile((Join-Path $Root 'assets\app-icon\hicolor\256x256\apps\ai.storyteller.artcraft-launcher.png'))
|
||||
$wordmark = [System.Drawing.Image]::FromFile((Join-Path $Root 'docs\brand\artcraft-logo-white.png'))
|
||||
$icons = $apps | ForEach-Object { [System.Drawing.Image]::FromFile((Join-Path $Root "assets\app-icons\$_.png")) }
|
||||
# The site's label face; GDI+ draws a variable font at its default instance (Regular).
|
||||
$fonts = New-Object System.Drawing.Text.PrivateFontCollection
|
||||
$fonts.AddFontFile((Join-Path $Root 'assets\fonts\GeistMono-Variable.ttf'))
|
||||
$labelFont = New-Object System.Drawing.Font $fonts.Families[0], 9, ([System.Drawing.FontStyle]::Regular), ([System.Drawing.GraphicsUnit]::Pixel)
|
||||
try {
|
||||
foreach ($spec in @(@($BannerOut, 493, 58), @($DialogOut, 493, 312))) {
|
||||
$bmp = New-Object System.Drawing.Bitmap $spec[1], $spec[2], ([System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$panel = New-Object System.Drawing.SolidBrush $ink
|
||||
$stripe = New-Object System.Drawing.SolidBrush $accent
|
||||
try {
|
||||
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
|
||||
$g.Clear([System.Drawing.Color]::White)
|
||||
if ($spec[2] -eq 58) {
|
||||
$g.FillRectangle($panel, 405, 0, 88, 58)
|
||||
$g.FillRectangle($stripe, 405, 0, 2, 58)
|
||||
$g.DrawImage($mark, 431, 11, 36, 36)
|
||||
} else {
|
||||
$g.FillRectangle($panel, 0, 0, 164, 312)
|
||||
$g.FillRectangle($stripe, 162, 0, 2, 312)
|
||||
$g.DrawImage($mark, 16, 18, 44, 44)
|
||||
$g.DrawImage($wordmark, 22, 74, 120, [int](120 * $wordmark.Height / $wordmark.Width))
|
||||
# "LAUNCHER" in mono capitals, spaced like the site's labels (0.15 em).
|
||||
$brush = New-Object System.Drawing.SolidBrush $muted
|
||||
$x = 22.0
|
||||
foreach ($ch in 'LAUNCHER'.ToCharArray()) {
|
||||
$g.DrawString([string]$ch, $labelFont, $brush, $x, 94, [System.Drawing.StringFormat]::GenericTypographic)
|
||||
$x += $g.MeasureString([string]$ch, $labelFont, 100, [System.Drawing.StringFormat]::GenericTypographic).Width + 1.35
|
||||
}
|
||||
$brush.Dispose()
|
||||
# The apps, three to a row, in 40 px cells divided by hairlines.
|
||||
$pen = New-Object System.Drawing.Pen $line, 1
|
||||
$left = 22; $top = 126; $cell = 40
|
||||
for ($i = 0; $i -lt $icons.Count; $i++) {
|
||||
$cx = $left + ($i % 3) * $cell
|
||||
$cy = $top + [math]::Floor($i / 3) * $cell
|
||||
$g.DrawRectangle($pen, $cx, $cy, $cell, $cell)
|
||||
$g.DrawImage($icons[$i], $cx + 6, $cy + 6, $cell - 12, $cell - 12)
|
||||
}
|
||||
$pen.Dispose()
|
||||
}
|
||||
} finally { $panel.Dispose(); $stripe.Dispose(); $g.Dispose() }
|
||||
$bmp.Save($spec[0], [System.Drawing.Imaging.ImageFormat]::Bmp)
|
||||
$bmp.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$labelFont.Dispose(); $fonts.Dispose(); $mark.Dispose(); $wordmark.Dispose()
|
||||
$icons | ForEach-Object { $_.Dispose() }
|
||||
}
|
||||
}
|
||||
$Banner = Join-Path $Stage 'installer-banner.bmp'
|
||||
$Dialog = Join-Path $Stage 'installer-dialog.bmp'
|
||||
New-InstallerArt $Banner $Dialog
|
||||
|
||||
$Msi = Join-Path $Dist "$Pkg-$Version-windows-$Arch.msi"
|
||||
& (Join-Path $PSScriptRoot 'check-icons.ps1')
|
||||
Invoke-Native 'wix build' {
|
||||
wix build (Join-Path $PSScriptRoot "$Pkg.wxs") -arch $Arch `
|
||||
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root "assets\app-icon\$Pkg.ico")" `
|
||||
-ext WixToolset.UI.wixext -ext WixToolset.Util.wixext `
|
||||
-culture en-US -loc (Join-Path $PSScriptRoot "$Pkg.en-us.wxl") `
|
||||
-d "Version=$MsiVersion" -d "DisplayVersion=$Version" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root "assets\app-icon\$Pkg.ico")" `
|
||||
-d "BannerBmp=$Banner" -d "DialogBmp=$Dialog" `
|
||||
-o $Msi
|
||||
}
|
||||
Invoke-Native 'MSI shortcut icon validation (ICE50)' {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! `cargo xtask icns <out.icns> <in.png>…`: pack PNG images into a macOS `.icns`, on any OS
|
||||
//! (`iconutil` only exists on macOS).
|
||||
//!
|
||||
//! Every entry is a PNG, which macOS reads for all of these types since 10.7. Each PNG fills
|
||||
//! every slot of its pixel size: a 32 px image is both the 32 pt icon and the 16 pt @2x one.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::ico::png_size;
|
||||
|
||||
/// The PNG entry types, by pixel size.
|
||||
const SLOTS: [(u32, &[u8; 4]); 11] = [
|
||||
(16, b"icp4"),
|
||||
(32, b"icp5"),
|
||||
(32, b"ic11"), // 16 @2x
|
||||
(64, b"icp6"),
|
||||
(64, b"ic12"), // 32 @2x
|
||||
(128, b"ic07"),
|
||||
(256, b"ic08"),
|
||||
(256, b"ic13"), // 128 @2x
|
||||
(512, b"ic09"),
|
||||
(512, b"ic14"), // 256 @2x
|
||||
(1024, b"ic10"), // 512 @2x
|
||||
];
|
||||
|
||||
/// An `.icns` file holding `pngs`, which must be square and one of the sizes in [`SLOTS`].
|
||||
pub fn pack(pngs: &[Vec<u8>]) -> Result<Vec<u8>, String> {
|
||||
if pngs.is_empty() {
|
||||
return Err("need at least one PNG".into());
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
for png in pngs {
|
||||
let (w, h) = png_size(png)?;
|
||||
if w != h {
|
||||
return Err(format!("icon images must be square, got {w}x{h}"));
|
||||
}
|
||||
let types: Vec<&[u8; 4]> = SLOTS.iter().filter(|(size, _)| *size == w).map(|(_, kind)| *kind).collect();
|
||||
if types.is_empty() {
|
||||
return Err(format!("no .icns slot for a {w} px image (use 16, 32, 64, 128, 256, 512 or 1024)"));
|
||||
}
|
||||
let len = u32::try_from(png.len() + 8).map_err(|_| "PNG too large for an .icns".to_string())?;
|
||||
for kind in types {
|
||||
body.extend_from_slice(kind);
|
||||
body.extend_from_slice(&len.to_be_bytes());
|
||||
body.extend_from_slice(png);
|
||||
}
|
||||
}
|
||||
let total = u32::try_from(body.len() + 8).map_err(|_| ".icns too large".to_string())?;
|
||||
let mut out = b"icns".to_vec();
|
||||
out.extend_from_slice(&total.to_be_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn run(args: &[&str]) -> Result<(), String> {
|
||||
let [out, inputs @ ..] = args else {
|
||||
return Err("usage: cargo xtask icns <out.icns> <in.png>…".into());
|
||||
};
|
||||
let pngs = inputs.iter().map(|p| std::fs::read(p).map_err(|e| format!("read {p}: {e}"))).collect::<Result<Vec<_>, _>>()?;
|
||||
let icns = pack(&pngs)?;
|
||||
std::fs::write(Path::new(out), &icns).map_err(|e| format!("write {out}: {e}"))?;
|
||||
println!("{out}: {} images, {} bytes", pngs.len(), icns.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fake_png(size: u32) -> Vec<u8> {
|
||||
let mut v = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
|
||||
v.extend_from_slice(&size.to_be_bytes());
|
||||
v.extend_from_slice(&size.to_be_bytes());
|
||||
v.extend_from_slice(&[8, 6, 0, 0, 0, 1, 2, 3, 4]);
|
||||
v
|
||||
}
|
||||
|
||||
/// The (type, payload) entries of an `.icns`.
|
||||
fn entries(icns: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = 8;
|
||||
while at < icns.len() {
|
||||
let len = u32::from_be_bytes(icns[at + 4..at + 8].try_into().unwrap()) as usize;
|
||||
out.push((String::from_utf8(icns[at..at + 4].to_vec()).unwrap(), icns[at + 8..at + len].to_vec()));
|
||||
at += len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_every_slot_of_each_size() {
|
||||
let (a, b) = (fake_png(32), fake_png(1024));
|
||||
let icns = pack(&[a.clone(), b.clone()]).unwrap();
|
||||
assert_eq!(&icns[..4], b"icns");
|
||||
assert_eq!(u32::from_be_bytes(icns[4..8].try_into().unwrap()) as usize, icns.len());
|
||||
let e = entries(&icns);
|
||||
let kinds: Vec<&str> = e.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(kinds, ["icp5", "ic11", "ic10"]);
|
||||
assert_eq!(e[0].1, a);
|
||||
assert_eq!(e[2].1, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_input() {
|
||||
assert!(pack(&[]).is_err());
|
||||
assert!(pack(&[fake_png(48)]).is_err());
|
||||
assert!(pack(&[b"GIF89a".to_vec()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_icon_is_valid() {
|
||||
let path = crate::root().join("assets/app-icon/artcraft-launcher.icns");
|
||||
let Ok(icns) = std::fs::read(&path) else { return };
|
||||
assert_eq!(&icns[..4], b"icns");
|
||||
assert!(entries(&icns).iter().any(|(k, _)| k == "ic10"), "expected a 1024 px image in {}", path.display());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Pure Rust (std only). External tools (`cargo`) are invoked through `std::process::Command`.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod icns;
|
||||
mod ico;
|
||||
mod version;
|
||||
|
||||
@@ -19,6 +20,8 @@ commands:
|
||||
print the workspace version, or set it (Cargo.toml + Cargo.lock)
|
||||
ico <out.ico> <in.png>...
|
||||
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
|
||||
icns <out.icns> <in.png>...
|
||||
pack square PNGs (16-1024 px) into a macOS .icns, on any OS
|
||||
";
|
||||
|
||||
/// Packages that must build for wasm32-unknown-unknown: the pure core and the web app (which
|
||||
@@ -33,6 +36,7 @@ fn main() -> ExitCode {
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("version") => version::run(&root(), &rest),
|
||||
Some("ico") => ico::run(&rest),
|
||||
Some("icns") => icns::run(&rest),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
|
||||