feat(ui): a calmer header, toolbar, cards, app page and dialogs

Header: the Discord link and the refresh button leave the top bar; refresh sits in the toolbar and
Settings stays on the right.

Toolbar: the filter tabs go (installed apps come first, the rest under a Not installed heading); a
sort dropdown (Name A-Z / Z-A, Recently updated, Frequently used), search, Update all and the
grid/table switch drawn as two icons.

Cards and table: the version sits in the strip, apps that are not installed are drawn in grey until
hovered, the headline and last-updated date replace the status sentence; the table's two version
columns become one.

Footer: Website, GitHub and Discord join the social links as icons with more room between them.

App page: pictures, story and facts re-laid out for windows with and without screenshots.

Dialogs: Settings and release notes become large cards like the app page (a header with the brand
colour, a scrolling body, a pinned footer), Settings in boxed groups, two columns when wide.

Also adds core::release::safe_web_link, so links in release notes open only when they are plain https.
This commit is contained in:
Jeremy
2026-10-16 19:16:40 +01:00
committed by skylar taylor-barrick
parent e85a296426
commit e00be806a1
40 changed files with 1538 additions and 566 deletions
+2 -1
View File
@@ -13,7 +13,8 @@ next to them.
| `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) |
| `packaging/macos/dmg/` (all files) | macOS DMG window background and layout | ArtCraft Launcher contributors, with the ArtCraft mark | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md); the mark is `docs/brand/artcraft-mark-black.svg`, linked from `background.svg` and rendered unmodified into `background.tiff` | MIT OR Apache-2.0, like the code; the ArtCraft mark in it stays an ArtCraft trademark ([`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt)). The TIFF carries LittleCMS's built-in sRGB profile ("No copyright, use freely") |
| `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. |
| `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, Meta Platforms, Inc. and Discord Inc. |
| `assets/social/github.svg`, `assets/social/globe.svg` | GitHub logo (linking to ArtCraft's GitHub) and a globe (linking to ArtCraft's website) | [Simple Icons](https://simpleicons.org/) contributors; [Lucide](https://lucide.dev) contributors | Simple Icons `github.svg` and Lucide `globe.svg` (lucide-static 0.468.0), white for tinting; see [`assets/social/README.md`](assets/social/README.md) | CC0 1.0 (GitHub logo; a trademark of GitHub, Inc.); ISC (globe) |
| `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) |
| (not bundled) the pictures on each app's details page | Screenshots of the Craft Apps, fetched when a page is opened and kept in the launcher's cache folder | The apps' contributors | [storytold/craft-launcher-assets](https://github.com/storytold/craft-launcher-assets), pinned to a tag (`SCREENSHOTS_REV` in `crates/core/src/details.rs`); each picture's source and license are listed in that repository's `ATTRIBUTION.md` | Each app's own license for the interface (MIT OR Apache-2.0); the work shown in them is public domain or made in the app, as that file says |
| `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) |
+10 -2
View File
@@ -1,7 +1,7 @@
Social service icons
====================
`x.svg`, `youtube.svg`, `tiktok.svg` and `instagram.svg` are from [Simple Icons](https://simpleicons.org/)
`x.svg`, `youtube.svg`, `tiktok.svg`, `instagram.svg` and `discord.svg` are from [Simple Icons](https://simpleicons.org/)
16.34.0 (`https://cdn.jsdelivr.net/npm/simple-icons@16.34.0/icons/<name>.svg`), released under
[CC0 1.0](https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md). The only change is
`fill="#ffffff"` on the root element, so the app can tint them to the theme.
@@ -12,5 +12,13 @@ Fonticons, Inc., licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.
no longer carries LinkedIn's logo). Changes: `fill="#ffffff"` for tinting, and the view box widened
from `0 0 448 512` to a centred square `-32 0 512 512`; the artwork is untouched.
`github.svg` is from Simple Icons the same way (`fill="#ffffff"` added, CC0 1.0).
`globe.svg` is the `globe` icon from [Lucide](https://lucide.dev) (lucide-static 0.468.0,
`https://cdn.jsdelivr.net/npm/lucide-static@0.468.0/icons/globe.svg`), licensed
[ISC](https://github.com/lucide-icons/lucide/blob/main/LICENSE). The only change is
`stroke="#ffffff"` instead of `currentColor`, so the app can tint it to the theme. It stands for
ArtCraft's website.
The logos themselves are trademarks of their owners (X Corp., Google LLC, TikTok / ByteDance Ltd.,
Meta Platforms, Inc. and LinkedIn Corporation). They are used only to link to ArtCraft's own accounts on those services.
Meta Platforms, Inc., Discord Inc. and LinkedIn Corporation). They are used only to link to ArtCraft's own accounts on those services.
+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#ffffff"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg role="img" fill="#ffffff" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 837 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.468.0 - ISC -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>

After

Width:  |  Height:  |  Size: 373 B

+26
View File
@@ -60,6 +60,14 @@ fn is_hex64(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
/// Whether `url` is safe to hand to the system browser from untrusted text (release notes):
/// `https` only, a host, no user-info, no spaces or control characters.
pub fn safe_web_link(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else { return false };
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
url.len() <= 2000 && !authority.is_empty() && !authority.contains(['@', '\\']) && !url.chars().any(|c| c.is_control() || c.is_whitespace())
}
/// The prefix every download of `repo` must start with.
pub fn download_prefix(repo: &str) -> String {
format!("https://github.com/{repo}/releases/download/")
@@ -194,6 +202,24 @@ mod tests {
use super::*;
const REPO: &str = "storytold/photocraft";
#[test]
fn only_plain_https_links_pass() {
assert!(safe_web_link("https://github.com/storytold/photocraft/releases/tag/v1"));
for bad in [
"http://example.com/",
"javascript:alert(1)",
"file:///C:/Windows/System32/calc.exe",
"ms-msdt:/id",
"https://",
"https://user@evil.example/",
"https://example.com/a b",
"https://example.com/\nx",
"https:///path",
] {
assert!(!safe_web_link(bad), "{bad:?}");
}
}
const HASH: &str = "8a5edab282632443219e051e4ade2d1d5bbc671c781051bf1437897cbdfea0f1";
fn api_json(url: &str) -> String {
+36 -2
View File
@@ -181,6 +181,12 @@ impl LauncherUi {
egui_extras::install_image_loaders(ctx);
let caps = backend.capabilities();
let platform = backend.platform();
// The page no longer offers Installed, Updates or Not installed, so a saved one would
// hide apps with nothing on screen to undo it.
let mut settings = settings;
if !matches!(settings.filter, Filter::All | Filter::Hidden) {
settings.filter = Filter::All;
}
Self {
backend,
settings,
@@ -655,7 +661,17 @@ impl LauncherUi {
.collect();
let platform = self.platform;
let manage = self.caps.manage_apps;
let desc = self.settings.sort_descending;
// The grid offers Name and Recently updated only; a table column's sort (or the old suite
// order) reads as Name there.
let grid = self.settings.view == ViewMode::Grid;
let (sort, desc) = match self.settings.sort {
SortKey::Name => (SortKey::Name, self.settings.sort_descending),
// Newest and most opened first, whatever the table's columns last did.
SortKey::Released | SortKey::Frequent if grid => (self.settings.sort, true),
SortKey::Released | SortKey::Frequent => (self.settings.sort, self.settings.sort_descending),
_ if grid => (SortKey::Name, false),
other => (other, self.settings.sort_descending),
};
let dir = |o: std::cmp::Ordering| if desc { o.reverse() } else { o };
// Empty cells (not installed, no release) go last whichever way the column sorts.
let opt = |a: Option<std::cmp::Ordering>, a_some: bool, b_some: bool| match (a_some, b_some) {
@@ -667,7 +683,7 @@ impl LauncherUi {
let installed = |e: &AppEntry| e.installed.as_ref().and_then(|i| i.version.clone());
let latest = |e: &AppEntry| e.release().and_then(|r| r.version.clone());
list.sort_by(|(ia, a), (ib, b)| {
let ord = match self.settings.sort {
let ord = match sort {
SortKey::Suite => dir(ia.cmp(ib)),
SortKey::Name => dir(a.app.name.cmp(b.app.name)),
SortKey::Status => {
@@ -681,6 +697,10 @@ impl LauncherUi {
let (x, y) = (latest(a), latest(b));
opt(x.as_ref().zip(y.as_ref()).map(|(x, y)| x.cmp_precedence(y)), x.is_some(), y.is_some())
}
SortKey::Frequent => {
let opened = |e: &AppEntry| self.settings.launches.get(e.app.id).copied().unwrap_or(0);
dir(opened(a).cmp(&opened(b)))
}
SortKey::Released => {
let at = |e: &AppEntry| e.release().and_then(|r| r.published_at);
let (x, y) = (at(a), at(b));
@@ -690,9 +710,23 @@ impl LauncherUi {
// Ties keep suite order.
ord.then(ia.cmp(ib))
});
// Installed apps always come first (the sort holds within each group; the sort is stable).
list.sort_by_key(|(_, e)| !e.status(platform, manage).is_installed());
list.into_iter().map(|(_, e)| e.app.id).collect()
}
/// Opens an app, and counts it for "Frequently used".
pub(crate) fn launch_app(&mut self, id: &str) {
let opened = self.settings.launches.entry(id.to_string()).or_insert(0);
*opened = opened.saturating_add(1);
self.send(Command::Launch { app: id.to_string() });
}
/// Whether the app on screen under `id` is installed on this computer.
pub(crate) fn is_installed(&self, id: &str) -> bool {
self.entry(id).is_some_and(|e| e.status(self.platform, self.caps.manage_apps).is_installed())
}
/// "ArtCraft API", "GitHub" or "ArtCraft API and GitHub": the services behind the releases on
/// screen right now. `None` until something has loaded.
pub(crate) fn sources_label(&self) -> Option<String> {
+82
View File
@@ -0,0 +1,82 @@
//! Controls for the toolbar row: the grid/table switch drawn as two icons, and the refresh button.
//!
//! Invariants: every colour comes from [`Tokens`]; the icons are drawn with the painter, so no
//! font or image has to carry them; nothing here owns state, the caller passes the value in.
use egui::{CornerRadius, Rect, Sense, Ui, vec2};
use crate::settings::ViewMode;
use crate::theme::{self, Tokens};
/// Grid and table as two square icons in one hairline box; the chosen one is inverted. In a
/// right-to-left parent they still read grid, then table.
pub fn view_toggle(ui: &mut Ui, view: &mut ViewMode, tips: [&str; 2]) {
let t = Tokens::get(ui.ctx());
let rtl = ui.layout().prefer_right_to_left();
let mut modes = [(ViewMode::Grid, tips[0]), (ViewMode::Table, tips[1])];
if rtl {
modes.reverse();
}
let mut rects = Vec::with_capacity(2);
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (mode, tip) in modes {
let on = *view == mode;
let (rect, resp) = ui.allocate_exact_size(vec2(theme::CONTROL_H, theme::CONTROL_H), Sense::click());
let (fill, ink) = match (on, resp.hovered()) {
(true, _) => (t.invert_bg, t.invert_fg),
(false, true) => (t.sunken, t.ink_strong),
(false, false) => (egui::Color32::TRANSPARENT, t.muted),
};
let p = ui.painter();
p.rect_filled(rect, CornerRadius::ZERO, fill);
let c = rect.center();
match mode {
ViewMode::Grid => {
for (dx, dy) in [(-1.0, -1.0), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)] {
p.rect_filled(Rect::from_center_size(c + vec2(dx * 3.75, dy * 3.75), vec2(6.0, 6.0)), CornerRadius::ZERO, ink);
}
}
ViewMode::Table => {
for dy in [-5.0, 0.0, 5.0] {
p.rect_filled(Rect::from_center_size(c + vec2(0.0, dy), vec2(15.0, 2.5)), CornerRadius::ZERO, ink);
}
}
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip));
if resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
*view = mode;
}
rects.push(rect);
}
});
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);
for r in rects.iter().skip(1) {
let x = if rtl { r.right() } else { r.left() };
p.vline(x, first.top()..=first.bottom(), t.hairline());
}
}
}
/// The refresh control: a square in the same hairline box as the grid/table switch. While
/// `busy` it shows a spinner and ignores clicks.
pub fn refresh_button(ui: &mut Ui, tip: &str, busy: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(theme::CONTROL_H, theme::CONTROL_H), if busy { Sense::hover() } else { Sense::click() });
let hovered = resp.hovered() && !busy;
let p = ui.painter();
if hovered {
p.rect_filled(rect, CornerRadius::ZERO, t.invert_bg);
}
p.rect_stroke(rect, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
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, "\u{27f3}", 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) }
}
+39
View File
@@ -0,0 +1,39 @@
//! Small pieces of an app card's text: the version in its top strip, and the short description and
//! last-updated line in its body.
//!
//! Invariants: nothing is drawn that has no content; dates are day/month/year.
use artcraft_launcher_core::status::AppStatus;
use egui::{Color32, FontId, Ui};
use crate::i18n::fmt;
use crate::model::AppEntry;
/// "v0.4.0" for an app; "0.5.0 → 0.6.0" when an update is waiting. Empty when nothing is known yet.
pub(crate) fn strip_version(e: &AppEntry, status: &AppStatus) -> String {
let installed = e.installed.as_ref().and_then(|i| i.version.as_ref().map(ToString::to_string));
let latest = e.latest_label();
match (status, installed.as_deref(), latest.as_deref()) {
(AppStatus::UpdateAvailable, Some(i), Some(l)) => format!("{i} → {l}"),
(_, Some(i), _) => format!("v{i}"),
(_, None, Some(l)) => format!("v{l}"),
_ => String::new(),
}
}
/// "Last updated: 07/10/2026": the date of the app's latest release, when there is one.
pub(crate) fn last_updated(e: &AppEntry) -> Option<String> {
let at = e.release().and_then(|r| r.published_at)?;
let (year, month, day) = artcraft_launcher_core::time::civil_from_unix(at);
Some(fmt(tl!("Last updated: {date}"), &[("date", &format!("{day:02}/{month:02}/{year:04}"))]))
}
/// `text` in at most `rows` lines, ending in "…" when it is cut.
pub(crate) fn blurb(ui: &mut Ui, text: &str, color: Color32, rows: usize) {
let mut job = egui::text::LayoutJob::simple(text.to_string(), FontId::proportional(13.0), color, ui.available_width());
job.wrap.max_rows = rows;
job.wrap.break_anywhere = false;
job.wrap.overflow_character = Some('…');
// Not selectable, so a click on the text still opens the app's page.
ui.add(egui::Label::new(job).selectable(false));
}
+161 -42
View File
@@ -1,5 +1,6 @@
//! An app's details drawer: it slides in from the right over a dimmed grid. Hand-written copy and
//! screenshots come from `core::details`; the live facts (version, date, download size, release
//! An app's details page: a card that pops up in the middle of the window over a dimmed grid, with
//! arrows to the next and previous app and a click anywhere outside it to close. Hand-written copy
//! and screenshots come from `core::details`; the live facts (version, date, download size, release
//! notes) from the app's latest GitHub release.
use artcraft_launcher_core::CraftApp;
@@ -13,7 +14,7 @@ use egui::{Color32, CornerRadius, Id, Margin, Order, Rect, RichText, Sense, Stro
use crate::app::LauncherUi;
use crate::model::AppEntry;
use crate::theme::{self, Tokens, rgb, tint};
use crate::views::{FOOTER_H, HEADER_H, date_text};
use crate::views::date_text;
use crate::widgets::{self, Glyph, Kind};
/// Screenshots are 8:5.
@@ -24,6 +25,8 @@ const ICON: f32 = 112.0;
/// The previous / next buttons over a picture.
const ARROW: f32 = 36.0;
const SLIDE_SECS: f32 = 0.22;
/// The width of the main button and its menu in the page's top, when the page is wide.
const ACTION_W: f32 = 300.0;
/// A details picture, as the backend has sent it.
pub(crate) enum Picture {
@@ -113,40 +116,110 @@ impl LauncherUi {
self.details_keys(ctx, details, open);
let t = Tokens::get(ctx);
let area = drawer_area(ctx);
let width = (area.width() * 0.5).clamp(440.0, 680.0).min(area.width());
let left = area.right() - width * k;
// The whole window is the backdrop; the card sits in the middle and rises a little as it fades in.
let area = ctx.content_rect();
let width = (area.width() - 64.0).clamp(480.0, 1280.0).min((area.width() - 16.0).max(1.0));
let height = (area.height() - 40.0).clamp(320.0, 1000.0).min((area.height() - 8.0).max(1.0));
// Wide enough, the page reads in two columns: the pictures and the story, then the facts.
let wide = width >= 900.0;
let panel = Rect::from_center_size(area.center() + vec2(0.0, (1.0 - k) * 24.0), vec2(width, height));
let (previous, next) = self.neighbour_apps(&id);
self.details_scrim(ctx, area, k, &t);
// Not constrained to the screen: the drawer is partly off it while it slides, and a constrained
// area would keep its contents in place while only the panel behind them moved.
egui::Area::new(Id::new("details-drawer")).order(Order::Foreground).fixed_pos(pos2(left, area.top())).constrain(false).show(ctx, |ui| {
let panel = Rect::from_min_size(pos2(left, area.top()), vec2(width, area.height()));
egui::Area::new(Id::new("details-drawer")).order(Order::Foreground).fixed_pos(panel.min).constrain(false).show(ctx, |ui| {
ui.set_opacity(k.clamp(0.0, 1.0));
let painter = ui.painter();
painter.add(theme::shadow(&t).as_shape(panel, CornerRadius::ZERO));
painter.rect_filled(panel, CornerRadius::ZERO, t.bg);
painter.vline(panel.left() + 0.5, panel.y_range(), Stroke::new(1.0, t.line_strong));
painter.rect_stroke(panel, CornerRadius::ZERO, Stroke::new(1.0, t.line_strong), egui::StrokeKind::Inside);
ui.set_min_size(panel.size());
ui.set_max_size(panel.size());
// A bar that stays on screen says there is more below the fold. egui's default one is
// floating and invisible until the pointer is over it, so the drawer uses the solid style.
// floating and invisible until the pointer is over it, so the page uses the solid style,
// and draws it in the app's colour so it stands out from the page.
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().scroll.bar_width = 10.0;
let page_visuals = ui.visuals().clone();
let bar = t.app_ink(entry.app);
{
let v = ui.visuals_mut();
v.extreme_bg_color = t.bg.blend(tint(bar, if t.dark { 0.14 } else { 0.10 }));
v.widgets.inactive.bg_fill = bar.gamma_multiply(0.8);
v.widgets.hovered.bg_fill = bar;
v.widgets.active.bg_fill = bar;
}
// One scroll position per app, so the next app opens at its top.
egui::ScrollArea::vertical().id_salt(("details-scroll", id.clone())).auto_shrink([false, false]).show(ui, |ui| {
// The bar's colours are for the bar alone.
*ui.visuals_mut() = page_visuals;
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 8.0;
self.details_hero(ui, &entry, details, &t);
self.details_hero(ui, &entry, details, &t, (previous.as_deref(), next.as_deref()), wide);
egui::Frame::new().inner_margin(Margin::same(PAD as i8)).show(ui, |ui| {
ui.set_width(ui.available_width());
self.gallery(ui, details, &t, rgb(entry.app.color));
ui.add_space(8.0);
for p in details.paragraphs() {
ui.add(egui::Label::new(RichText::new(p).size(14.5).color(t.ink)).wrap());
let app_color = rgb(entry.app.color);
if wide && details.shots.is_empty() {
// No pictures to fill the left side: the story and features share it, and the
// release facts take the right, so neither side is left empty.
let gap = 32.0;
let total = ui.available_width();
let left_w = ((total - gap) * 0.58).floor();
let right_w = total - gap - left_w;
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = gap;
ui.vertical(|ui| {
ui.set_width(left_w);
ui.spacing_mut().item_spacing.y = 8.0;
for p in details.paragraphs() {
ui.add(egui::Label::new(RichText::new(p).size(14.5).color(t.ink)).wrap());
}
ui.add_space(14.0);
features(ui, details, entry.app, &t);
});
ui.vertical(|ui| {
ui.set_width(right_w);
ui.spacing_mut().item_spacing.y = 8.0;
self.release_section(ui, &entry, details, &t, 2);
});
});
} else if wide {
let gap = 32.0;
let total = ui.available_width();
let left_w = ((total - gap) * 0.58).floor();
let right_w = total - gap - left_w;
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = gap;
ui.vertical(|ui| {
ui.set_width(left_w);
ui.spacing_mut().item_spacing.y = 8.0;
self.gallery(ui, details, &t, app_color);
ui.add_space(8.0);
for p in details.paragraphs() {
ui.add(egui::Label::new(RichText::new(p).size(14.5).color(t.ink)).wrap());
}
});
ui.vertical(|ui| {
ui.set_width(right_w);
ui.spacing_mut().item_spacing.y = 8.0;
features(ui, details, entry.app, &t);
});
});
// The release gets the full width under both columns, so neither column
// is left with an empty tail: its facts sit in one row of four.
ui.add_space(22.0);
ui.painter().hline(ui.max_rect().x_range(), ui.cursor().top() - 11.0, t.hairline());
self.release_section(ui, &entry, details, &t, 4);
} else {
self.gallery(ui, details, &t, app_color);
ui.add_space(8.0);
for p in details.paragraphs() {
ui.add(egui::Label::new(RichText::new(p).size(14.5).color(t.ink)).wrap());
}
ui.add_space(14.0);
features(ui, details, entry.app, &t);
ui.add_space(14.0);
self.release_section(ui, &entry, details, &t, 2);
}
ui.add_space(14.0);
features(ui, details, entry.app, &t);
ui.add_space(14.0);
self.release_section(ui, &entry, details, &t);
});
});
});
@@ -155,6 +228,21 @@ impl LauncherUi {
}
}
/// The apps before and after `id` in the order the grid shows them (wrapping round), among
/// those that have a page.
fn neighbour_apps(&self, id: &str) -> (Option<String>, Option<String>) {
let mut ids: Vec<&str> = self.visible_ids().into_iter().filter(|i| AppDetails::by_id(i).is_some()).collect();
if !ids.contains(&id) {
ids = artcraft_launcher_core::SUITE.iter().map(|a| a.id).filter(|i| AppDetails::by_id(i).is_some()).collect();
}
let Some(at) = ids.iter().position(|i| *i == id) else { return (None, None) };
if ids.len() < 2 {
return (None, None);
}
let n = ids.len();
(Some(ids[(at + n - 1) % n].to_string()), Some(ids[(at + 1) % n].to_string()))
}
/// Esc closes the enlarged picture first, then the drawer; ← and → flip through the pictures.
/// Nothing happens under a dialog, and the arrows leave a text field alone.
fn details_keys(&mut self, ctx: &egui::Context, details: &AppDetails, open: bool) {
@@ -201,7 +289,7 @@ impl LauncherUi {
/// The drawer's top: a wash of the app's colour under a 2 px rule, the close button, the name,
/// version and main button, and the app's links.
fn details_hero(&mut self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens) {
fn details_hero(&mut self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens, around: (Option<&str>, Option<&str>), wide: bool) {
let app = entry.app;
let app_color = rgb(app.color);
let status = entry.status(self.platform, self.caps.manage_apps);
@@ -213,15 +301,30 @@ impl LauncherUi {
if widgets::glyph_button(ui, Glyph::Close, tl!("Close (Esc)")).clicked() {
self.detail = None;
}
// Right to left: next, then previous, so they read as a pair before the close.
if let (_, Some(next)) = around
&& step_button(ui, false, tl!("Next app"), t)
{
self.detail = Some(next.to_string());
}
if let (Some(previous), _) = around
&& step_button(ui, true, tl!("Previous app"), t)
{
self.detail = Some(previous.to_string());
}
});
});
ui.add_space(10.0);
let hero_w = ui.available_width();
ui.horizontal(|ui| {
if let Some(icon) = crate::icons::app(app.id) {
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(ICON, ICON)));
}
ui.add_space(8.0);
// Beside the buttons when the page is wide: the text keeps what they leave.
let text_w = if wide { (hero_w - ICON - 16.0 - ACTION_W - 24.0).max(200.0) } else { (hero_w - ICON - 16.0).max(40.0) };
ui.vertical(|ui| {
ui.set_width(text_w);
ui.spacing_mut().item_spacing.y = 5.0;
widgets::wordmark(ui, app, 32.0);
ui.add(egui::Label::new(RichText::new(details.headline).size(15.0).color(t.ink)).wrap());
@@ -231,12 +334,22 @@ impl LauncherUi {
ui.label(RichText::new(version).size(12.5).color(t.muted));
}
});
if wide {
// The main button and its menu, at the right, level with the name.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.allocate_ui_with_layout(vec2(ACTION_W, theme::CONTROL_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
self.actions_in(ui, entry, &status, ACTION_W, false);
});
});
}
});
ui.add_space(14.0);
// The menu leaves out the links: they are the buttons right below.
let row_w = ui.available_width();
ui.horizontal(|ui| self.actions_in(ui, entry, &status, row_w, false));
link_buttons(ui, app, row_w);
if !wide {
let row_w = ui.available_width();
ui.horizontal(|ui| self.actions_in(ui, entry, &status, row_w, false));
}
link_buttons(ui, app);
});
let r = hero.response.rect;
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 2.0)), CornerRadius::ZERO, app_color);
@@ -244,7 +357,7 @@ impl LauncherUi {
}
/// The facts of the latest release in cells, then its notes.
fn release_section(&self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens) {
fn release_section(&self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens, per_row: usize) {
widgets::eyebrow(ui, "02", tl!("Latest release"));
ui.add_space(4.0);
let Some(r) = entry.release() else {
@@ -264,8 +377,8 @@ impl LauncherUi {
.into_iter()
.filter_map(|(label, value)| value.map(|v| (label, v)))
.collect();
for pair in facts.chunks(2) {
ui.columns(2, |cols| {
for pair in facts.chunks(per_row) {
ui.columns(per_row, |cols| {
for (col, (label, value)) in cols.iter_mut().zip(pair) {
egui::Frame::new().stroke(t.hairline()).inner_margin(Margin::same(12)).show(col, |ui| {
ui.set_width(ui.available_width());
@@ -420,23 +533,29 @@ fn placeholder(ui: &mut Ui, rect: Rect, id: Id, failed: Option<&str>, t: &Tokens
}
}
/// Between the header and the footer, which stay usable. In a window shorter than the two bars
/// together there is no room left: an empty area, never a negative one.
fn drawer_area(ctx: &egui::Context) -> Rect {
let full = ctx.content_rect();
let top = (full.top() + HEADER_H).min(full.bottom());
let bottom = (full.bottom() - FOOTER_H).max(top);
Rect::from_min_max(pos2(full.left(), top), pos2(full.right(), bottom))
/// A square header-style button with a chevron: previous (`left`) or next app. True when clicked.
fn step_button(ui: &mut Ui, left: bool, tip: &str, t: &Tokens) -> bool {
let (rect, resp) = ui.allocate_exact_size(vec2(theme::CONTROL_H, theme::CONTROL_H), Sense::click());
let (fill, ink) = if resp.hovered() { (t.invert_bg, t.invert_fg) } else { (Color32::TRANSPARENT, t.muted) };
let painter = ui.painter();
painter.rect(rect, CornerRadius::ZERO, fill, t.hairline(), egui::StrokeKind::Inside);
let (c, s) = (rect.center(), if left { -1.0 } else { 1.0 });
painter.add(egui::Shape::line(
vec![pos2(c.x - s * 3.0, c.y - 5.5), pos2(c.x + s * 3.0, c.y), pos2(c.x - s * 3.0, c.y + 5.5)],
Stroke::new(1.75, ink),
));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked()
}
/// The app's links as a row of buttons sharing `row_w`; the ↗ says each opens the browser, like the links on the cards.
fn link_buttons(ui: &mut Ui, app: &CraftApp, row_w: f32) {
/// The app's links as a row of buttons each as wide as its label; the ↗ says each opens the
/// browser, like the links on the cards.
fn link_buttons(ui: &mut Ui, app: &CraftApp) {
let links = [(tl!("Website"), app.website()), (tl!("GitHub"), app.repo_url()), (tl!("Releases"), app.releases_url())];
let gap = ui.spacing().item_spacing.x;
let link_w = ((row_w - gap * (links.len() as f32 - 1.0)) / links.len() as f32).floor();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
for (label, url) in links {
if widgets::button(ui, &format!("{label} ↗"), Kind::Secondary, link_w).on_hover_text(url.as_str()).clicked() {
if widgets::button(ui, &format!("{label} ↗"), Kind::Secondary, 0.0).on_hover_text(url.as_str()).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
+38 -246
View File
@@ -1,14 +1,13 @@
//! Modal dialogs: release notes, uninstall confirmation, settings.
use crate::i18n::fmt;
use artcraft_launcher_core::Os;
use artcraft_launcher_core::protocol::Command;
use artcraft_launcher_core::{Os, PackageKind};
use egui::{RichText, vec2};
use crate::app::{Dialog, Host, LauncherUi};
use crate::settings::{CheckEvery, Settings};
use crate::theme::{self, ThemeChoice, Tokens};
use crate::widgets::{self, Cell, Kind};
use crate::theme::{self, Tokens};
use crate::widgets::{self, Kind};
impl LauncherUi {
pub(crate) fn dialogs(&mut self, ctx: &egui::Context, host: &mut dyn Host) {
@@ -19,8 +18,13 @@ impl LauncherUi {
let t = Tokens::get(ctx);
// An area is laid out in the size it had last frame, so dialogs can't share one id: the
// uninstall prompt would open as tall as Settings and never shrink (#88).
let modal = egui::Modal::new(egui::Id::new("dialog").with(dialog.key()))
.frame(egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
// The large dialogs run their header to the card's edges; the small prompts keep a margin.
let large = matches!(dialog, Dialog::Settings | Dialog::Notes(_));
let modal = egui::Modal::new(egui::Id::new("dialog").with(dialog.key())).frame(if large {
crate::sheet::frame(&t)
} else {
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 &mut dialog {
Dialog::Notes(id) => modal.show(ctx, |ui| close = self.notes_dialog(ui, id)),
@@ -88,25 +92,39 @@ impl LauncherUi {
ui.add_space(14.0);
}
/// An app's release notes: a large card like the app page, the notes in a scrolling body.
fn notes_dialog(&mut self, ui: &mut egui::Ui, id: &str) -> bool {
let Some(entry) = self.entry(id).cloned() else { return true };
let Some(release) = entry.release().cloned() else { return true };
ui.set_width(560.0f32.min(ui.ctx().content_rect().width() - 80.0));
let t = Tokens::get(ui.ctx());
let size = crate::sheet::size(ui.ctx(), vec2(880.0, 760.0));
ui.set_width(size.x);
ui.set_max_height(size.y);
let date = release.published_at.map(|d| fmt(tl!("Released {date}"), &[("date", &crate::views::date_text(d))])).unwrap_or_default();
Self::title(ui, crate::icons::app(id), &format!("{} {}", entry.app.name, release.version_label()), &date);
egui::ScrollArea::vertical().max_height(ui.ctx().content_rect().height() * 0.6).show(ui, |ui| {
if release.notes.is_empty() {
ui.label(tl!("These notes weren't fetched (GitHub's request limit was reached). They're on the release page."));
} else {
crate::notes::show_scaled(ui, &release.notes, self.notes_zoom);
}
let hero_h = crate::sheet::hero(ui, crate::icons::app(id), &format!("{} {}", entry.app.name, release.version_label()), &date, |_| {});
let footer_h = theme::CONTROL_H + 32.0;
let body_h = (size.y - hero_h - footer_h - 2.0).max(0.0);
let saved = crate::sheet::accent_scroll(ui, &t);
egui::ScrollArea::vertical().id_salt("notes-body").max_height(body_h).auto_shrink([false, true]).show(ui, |ui| {
*ui.visuals_mut() = saved;
egui::Frame::new().inner_margin(egui::Margin::same(24)).show(ui, |ui| {
ui.set_width(ui.available_width());
if release.notes.is_empty() {
ui.label(tl!("These notes weren't fetched (GitHub's request limit was reached). They're on the release page."));
} else {
crate::notes::show_scaled(ui, &release.notes, self.notes_zoom);
}
});
});
ui.add_space(12.0);
ui.painter().hline(ui.max_rect().x_range(), ui.cursor().top(), t.hairline());
let mut close = false;
ui.horizontal(|ui| {
widgets::hud_link(ui, tl!("Open on GitHub"), &release.html_url);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Close"), Kind::Secondary, 90.0).clicked();
egui::Frame::new().inner_margin(egui::Margin::symmetric(24, 16)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
widgets::hud_link(ui, tl!("Open on GitHub"), &release.html_url);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Close"), Kind::Secondary, 90.0).clicked();
});
});
});
close
@@ -236,7 +254,7 @@ impl LauncherUi {
close
}
fn folder_field(ui: &mut egui::Ui, folder: &mut String, default: Option<&str>, host: &mut dyn Host) {
pub(crate) fn folder_field(ui: &mut egui::Ui, folder: &mut String, default: Option<&str>, host: &mut dyn Host) {
let label = ui.label(tl!("Install folder"));
ui.add(
egui::TextEdit::singleline(folder)
@@ -257,230 +275,4 @@ impl LauncherUi {
}
});
}
fn settings_dialog(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) -> bool {
// Leave room for the frame (48 px) and a 16 px gutter on each side.
let viewport = ui.ctx().content_rect();
ui.set_width(520.0_f32.min((viewport.width() - 80.0).max(0.0)));
// The area remembers its previous size. Reset the available height so the scroll area
// measures against the viewport immediately, including on the first frame after a resize.
ui.set_max_height((viewport.height() - 80.0).max(0.0));
ui.spacing_mut().scroll = egui::style::ScrollStyle { foreground_color: true, ..egui::style::ScrollStyle::solid() };
let title_top = ui.cursor().top();
Self::title(ui, Some(crate::icons::artcraft_mark()), tl!("Settings"), &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION")));
let title_height = ui.cursor().top() - title_top;
let quit_width = widgets::button_width(ui, tl!("Quit ArtCraft Launcher"), 0.0);
let done_width = widgets::button_width(ui, tl!("Done"), 90.0);
let stack_actions = self.caps.manage_apps && quit_width + done_width + ui.spacing().item_spacing.x > ui.available_width();
let footer_height = if stack_actions { 2.0 * theme::CONTROL_H + ui.spacing().item_spacing.y } else { theme::CONTROL_H };
let body_height = (viewport.height() - 80.0 - title_height - footer_height - 24.0 - ui.spacing().item_spacing.y).max(0.0);
egui::ScrollArea::vertical().id_salt("settings-body").max_height(body_height).auto_shrink([false, true]).show(ui, |ui| {
self.settings_contents(ui, host);
});
ui.add_space(24.0);
let mut close = false;
let quit = |ui: &mut egui::Ui, host: &mut dyn Host| {
if self.caps.manage_apps && widgets::button(ui, tl!("Quit ArtCraft Launcher"), Kind::Secondary, 0.0).clicked() {
host.quit();
}
};
if stack_actions {
quit(ui, host);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
});
} else {
ui.horizontal(|ui| {
quit(ui, host);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
});
});
}
close
}
fn settings_contents(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) {
let t = Tokens::get(ui.ctx());
let section = |ui: &mut egui::Ui, s: &str| {
ui.add_space(14.0);
widgets::hud(ui, s, t.faint);
ui.add_space(6.0);
};
let row = |ui: &mut egui::Ui, label: &str, control_width: f32, add: &mut dyn FnMut(&mut egui::Ui)| {
let font = egui::TextStyle::Body.resolve(ui.style());
let label_width = ui.painter().layout_no_wrap(label.to_string(), font, t.ink).size().x;
if label_width + control_width + ui.spacing().item_spacing.x > ui.available_width() {
ui.label(label);
add(ui);
} else {
ui.horizontal(|ui| {
ui.label(label);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui));
});
}
};
let mut lang_code = self.settings.language.clone();
let installed = self.cjk_font;
row(ui, tl!("Language"), 180.0, &mut |ui| {
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code, installed)).width(180.0).show_ui(ui, |ui| {
ui.selectable_value(&mut lang_code, Settings::LANGUAGE_AUTO.to_string(), tl!("Auto"));
for lang in crate::i18n::Lang::all() {
ui.selectable_value(&mut lang_code, lang.code().to_string(), Settings::language_label(lang.code(), installed));
}
});
});
if lang_code != self.settings.language {
self.settings.language = lang_code;
crate::i18n::set_current(crate::app::effective_lang(&self.settings.language));
}
let pref = crate::i18n::Lang::from_pref(&self.settings.language);
if pref.needs_cjk_font() && !theme::cjk_font_available(pref) {
// In English on purpose: the language asked for can't be drawn.
let note = "No font for this language was found on this system, so the interface stays in English. \
Installing one (for example Noto Sans CJK) and restarting fixes it.";
ui.add(egui::Label::new(RichText::new(note).size(12.0).color(t.warn)).wrap());
}
section(ui, tl!("Updates"));
let mut every = self.settings.check_every;
row(ui, tl!("Check for updates"), 150.0, &mut |ui| {
egui::ComboBox::from_id_salt("every").selected_text(every.label()).width(150.0).show_ui(ui, |ui| {
for e in CheckEvery::ALL {
ui.selectable_value(&mut every, e, e.label());
}
});
});
self.settings.check_every = every;
if self.caps.manage_apps
&& ui.checkbox(&mut self.settings.auto_update, tl!("Automatically download and install updates")).changed()
&& self.settings.auto_update
{
self.trigger_auto_updates();
}
ui.checkbox(&mut self.settings.notify, tl!("Notify me when an installed app has an update"));
ui.checkbox(&mut self.settings.prereleases, tl!("Include pre-releases (betas and release candidates)"));
if self.caps.manage_apps {
section(ui, tl!("Default install folder"));
if self.custom_install_folders() {
let mut folder = self.settings.install_locations.root.clone().unwrap_or_default();
Self::folder_field(ui, &mut folder, None, host);
self.settings.install_locations.root = (!folder.is_empty()).then_some(folder);
ui.label(RichText::new(tl!("New installs use this folder. Updates stay in their current location.")).size(12.0).color(t.muted));
if self.platform.is_some_and(|p| p.os == Os::Linux) {
ui.label(RichText::new(tl!("Custom folders use AppImage on Linux.")).size(12.0).color(t.muted));
}
} else {
ui.label(tl!("The system manages install locations here."));
}
}
if self.caps.manage_apps && self.platform.is_some_and(|p| p.os == Os::Windows) {
section(ui, tl!("Installing on Windows"));
let mut choice = self
.settings
.preferred_kinds
.first()
.copied()
.filter(|kind| matches!(kind, PackageKind::Msi | PackageKind::PortableZip))
.or(Some(PackageKind::Msi));
row(ui, tl!("Install new apps as"), 210.0, &mut |ui| {
let label = choice.map_or_else(|| crate::i18n::t(PackageKind::Msi.label()), |kind| crate::i18n::t(kind.label()));
egui::ComboBox::from_id_salt("windows-kind").selected_text(label).width(210.0).show_ui(ui, |ui| {
for kind in [PackageKind::Msi, PackageKind::PortableZip] {
ui.selectable_value(&mut choice, Some(kind), crate::i18n::t(kind.label()));
}
});
});
self.settings.preferred_kinds = choice.into_iter().collect();
ui.label(RichText::new(tl!("Updates always use the format an app was installed with.")).size(12.0).color(t.muted));
}
if self.caps.manage_apps && self.platform.is_some_and(|p| p.os == Os::Linux) {
section(ui, tl!("Installing on Linux"));
let is_flatpak = crate::settings::is_flatpak_sandbox();
let is_appimage = crate::settings::is_appimage();
let default_kind = if is_flatpak {
Some(PackageKind::Flatpak)
} else if is_appimage {
Some(PackageKind::AppImage)
} else {
None
};
let mut choice = self.settings.preferred_kinds.first().copied().or(default_kind);
row(ui, tl!("Install new apps as"), 210.0, &mut |ui| {
let default_label = match default_kind {
Some(k) => crate::i18n::t(k.label()),
None => tl!("System package (recommended)"),
};
let label = choice.map_or(default_label, |k| crate::i18n::t(k.label()));
egui::ComboBox::from_id_salt("kind").selected_text(label).width(210.0).show_ui(ui, |ui| {
if !is_flatpak && !is_appimage {
ui.selectable_value(&mut choice, None, tl!("System package (recommended)"));
}
for k in [PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::AppImage] {
ui.selectable_value(&mut choice, Some(k), crate::i18n::t(k.label()));
}
// The tarball is unpacked and run inside the launcher's own sandbox, so it
// is only ever on offer there (as the fallback when the host has no Flatpak).
if is_flatpak {
ui.selectable_value(&mut choice, Some(PackageKind::TarGz), crate::i18n::t(PackageKind::TarGz.label()));
}
});
});
self.settings.preferred_kinds = choice.into_iter().collect();
ui.label(RichText::new(tl!("Updates always use the format an app was installed with.")).size(12.0).color(t.muted));
}
if self.caps.manage_apps {
section(ui, tl!("Running in the background"));
if host.has_tray() {
ui.checkbox(&mut self.settings.close_to_tray, tl!("Keep running in the tray when the window is closed"));
}
if host.has_desktops() {
ui.checkbox(&mut self.settings.show_on_current_desktop, tl!("Show the window on the current desktop"))
.on_hover_text(tl!("When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on."));
}
if let Some(mut on) = host.login_item()
&& ui.checkbox(&mut on, tl!("Start ArtCraft Launcher when I log in")).changed()
&& let Err(e) = host.set_login_item(on)
{
let ctx = ui.ctx().clone();
self.toast(&ctx, fmt(tl!("Couldn't change the login item: {e}"), &[("e", &e)]), true);
}
}
section(ui, tl!("Community"));
ui.label(RichText::new(tl!("Questions, ideas and news from the ArtCraft team")).color(t.muted));
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
use artcraft_launcher_core::catalog::{GITHUB_URL, WEBSITE_URL};
if widgets::button(ui, tl!("Join the Discord"), Kind::Primary, 0.0).on_hover_text(self.discord_url).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(self.discord_url));
}
if widgets::button(ui, tl!("Website"), Kind::Secondary, 0.0).on_hover_text(WEBSITE_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(WEBSITE_URL));
}
if widgets::button(ui, tl!("GitHub"), Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(GITHUB_URL));
}
// Wrap the social links as a group so their icons stay together.
let count = artcraft_launcher_core::catalog::SOCIAL.len() as f32;
let width = count * (16.0 + 8.0) + (count - 1.0).max(0.0) * 4.0;
ui.allocate_ui_with_layout(vec2(width, theme::CONTROL_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
widgets::social_icons(ui, 16.0);
});
});
section(ui, tl!("Appearance"));
let mut theme_choice = self.settings.theme;
ui.horizontal(|ui| {
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;
}
}
+9
View File
@@ -330,3 +330,12 @@ filter Installed Nainstalováno
Replace what's there? Nahradit, co tam je?
{path} already exists. Installing {name} will overwrite it. {path} už existuje. Instalace aplikace {name} ho přepíše.
Replace Nahradit
Recently updated Nedávno aktualizované
Name (A–Z) Název (A–Z)
Name (Z–A) Název (Z–A)
Frequently used Nejčastěji používané
Next app Další aplikace
Previous app Předchozí aplikace
Show {n} more changes Zobrazit dalších změn: {n}
Last updated: {date} Naposledy aktualizováno: {date}
column Version Verze
1 # Czech (cs) catalog for ArtCraft Launcher.
330
331
332
333
334
335
336
337
338
339
340 column
341
+9
View File
@@ -362,3 +362,12 @@ filter Installed Installiert
Replace what's there? Vorhandenes ersetzen?
{path} already exists. Installing {name} will overwrite it. {path} existiert bereits. Die Installation von {name} überschreibt es.
Replace Ersetzen
Recently updated Zuletzt aktualisiert
Name (A–Z) Name (A–Z)
Name (Z–A) Name (Z–A)
Frequently used Am häufigsten genutzt
Next app Nächste App
Previous app Vorherige App
Show {n} more changes {n} weitere Änderungen anzeigen
Last updated: {date} Zuletzt aktualisiert: {date}
column Version Version
Can't render this file because it contains an unexpected character in line 11 and column 16.
+9
View File
@@ -343,3 +343,12 @@ filter Installed Instaladas
Replace what's there? ¿Reemplazar lo que hay?
{path} already exists. Installing {name} will overwrite it. {path} ya existe. Instalar {name} lo sobrescribirá.
Replace Reemplazar
Recently updated Actualizadas recientemente
Name (A–Z) Nombre (A–Z)
Name (Z–A) Nombre (Z–A)
Frequently used Más usadas
Next app Aplicación siguiente
Previous app Aplicación anterior
Show {n} more changes Mostrar {n} cambios más
Last updated: {date} Última actualización: {date}
column Version Versión
Can't render this file because it contains an unexpected character in line 5 and column 48.
+9
View File
@@ -360,3 +360,12 @@ filter Installed Installées
Replace what's there? Remplacer l’existant ?
{path} already exists. Installing {name} will overwrite it. {path} existe déjà. L’installation de {name} l’écrasera.
Replace Remplacer
Recently updated Récemment mises à jour
Name (A–Z) Nom (A–Z)
Name (Z–A) Nom (Z–A)
Frequently used Les plus utilisées
Next app Application suivante
Previous app Application précédente
Show {n} more changes Afficher {n} modifications de plus
Last updated: {date} Dernière mise à jour : {date}
column Version Version
Can't render this file because it contains an unexpected character in line 5 and column 50.
+9
View File
@@ -363,3 +363,12 @@ filter Installed Telepítve
Replace what's there? Lecseréli, ami ott van?
{path} already exists. Installing {name} will overwrite it. {path} már létezik. A(z) {name} telepítése felülírja.
Replace Csere
Recently updated Nemrég frissítve
Name (A–Z) Név (A–Z)
Name (Z–A) Név (Z–A)
Frequently used Leggyakrabban használt
Next app Következő alkalmazás
Previous app Előző alkalmazás
Show {n} more changes {n} további változás megjelenítése
Last updated: {date} Utoljára frissítve: {date}
column Version Verzió
Can't render this file because it contains an unexpected character in line 11 and column 16.
+9
View File
@@ -365,3 +365,12 @@ filter Installed インストール済み
Replace what's there? 既存の項目を置き換えますか?
{path} already exists. Installing {name} will overwrite it. {path} は既に存在します。{name} をインストールすると上書きされます。
Replace 置き換える
Recently updated 最近更新
Name (A–Z) 名前 (A–Z)
Name (Z–A) 名前 (Z–A)
Frequently used よく使う順
Next app 次のアプリ
Previous app 前のアプリ
Show {n} more changes さらに{n}件の変更を表示
Last updated: {date} 最終更新: {date}
column Version バージョン
Can't render this file because it contains an unexpected character in line 14 and column 44.
+9
View File
@@ -364,3 +364,12 @@ filter Installed Zainstalowane
Replace what's there? Zastąpić to, co tam jest?
{path} already exists. Installing {name} will overwrite it. {path} już istnieje. Instalacja {name} go nadpisze.
Replace Zastąp
Recently updated Ostatnio zaktualizowane
Name (A–Z) Nazwa (A–Z)
Name (Z–A) Nazwa (Z–A)
Frequently used Najczęściej używane
Next app Następna aplikacja
Previous app Poprzednia aplikacja
Show {n} more changes Pokaż {n} więcej zmian
Last updated: {date} Ostatnia aktualizacja: {date}
column Version Wersja
Can't render this file because it contains an unexpected character in line 11 and column 15.
+9
View File
@@ -350,3 +350,12 @@ filter Installed Instalados
Replace what's there? Substituir o que já existe?
{path} already exists. Installing {name} will overwrite it. {path} já existe. Instalar o {name} vai substituí-lo.
Replace Substituir
Recently updated Atualizados recentemente
Name (A–Z) Nome (A–Z)
Name (Z–A) Nome (Z–A)
Frequently used Mais usados
Next app Próximo aplicativo
Previous app Aplicativo anterior
Show {n} more changes Mostrar mais {n} alterações
Last updated: {date} Última atualização: {date}
column Version Versão
Can't render this file because it contains an unexpected character in line 251 and column 66.
+9
View File
@@ -358,3 +358,12 @@ filter Installed Установлены
Replace what's there? Заменить существующее?
{path} already exists. Installing {name} will overwrite it. {path} уже существует. Установка {name} перезапишет его.
Replace Заменить
Recently updated Недавно обновлённые
Name (A–Z) Имя (А–Я)
Name (Z–A) Имя (Я–А)
Frequently used Часто используемые
Next app Следующее приложение
Previous app Предыдущее приложение
Show {n} more changes Показать ещё изменений: {n}
Last updated: {date} Последнее обновление: {date}
column Version Версия
Can't render this file because it contains an unexpected character in line 13 and column 44.
+2
View File
@@ -126,9 +126,11 @@ fn complete_catalogs_cover_every_ui_literal() {
include_str!("../app.rs"),
include_str!("../details.rs"),
include_str!("../dialogs.rs"),
include_str!("../card_parts.rs"),
include_str!("../model.rs"),
include_str!("../notes.rs"),
include_str!("../settings.rs"),
include_str!("../settings_dialog.rs"),
include_str!("../views.rs"),
include_str!("../widgets.rs"),
// The desktop shell's tray menu and tooltip.
+9
View File
@@ -330,3 +330,12 @@ figcraft Interface design and prototyping 界面设计与原型制作
Replace what's there? 替换现有内容?
{path} already exists. Installing {name} will overwrite it. {path} 已存在。安装 {name} 将覆盖它。
Replace 替换
Recently updated 最近更新
Name (A–Z) 名称 (A–Z)
Name (Z–A) 名称 (Z–A)
Frequently used 最常使用
Next app 下一个应用
Previous app 上一个应用
Show {n} more changes 显示另外 {n} 项更改
Last updated: {date} 最后更新:{date}
column Version 版本
Can't render this file because it contains an unexpected character in line 4 and column 3.
+50
View File
@@ -22,6 +22,36 @@ pub fn app(id: &str) -> Option<ImageSource<'static>> {
})
}
/// A Craft App's icon: in colour, or in greys for an app that is not installed. The grey copy is
/// made once per app from the same bundled picture and kept in the context.
pub fn app_image(ctx: &egui::Context, id: &str, in_colour: bool) -> Option<egui::Image<'static>> {
if in_colour {
return app(id).map(egui::Image::new);
}
let key = egui::Id::new(("grey-app-icon", id));
if let Some(texture) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
return Some(egui::Image::new(&texture));
}
let ImageSource::Bytes { bytes, .. } = app(id)? else { return None };
let picture = image::load_from_memory(bytes.as_ref()).ok()?.to_rgba8();
let size = [picture.width() as usize, picture.height() as usize];
let mut pixels = picture.into_raw();
desaturate(&mut pixels);
let texture =
ctx.load_texture(format!("grey-app-icon-{id}"), egui::ColorImage::from_rgba_unmultiplied(size, &pixels), egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(key, texture.clone()));
Some(egui::Image::new(&texture))
}
/// Turns RGBA pixels grey by their luminance, leaving the alpha as it was.
fn desaturate(pixels: &mut [u8]) {
let (pixels, _) = pixels.as_chunks_mut::<4>();
for p in pixels {
let grey = (0.299 * f32::from(p[0]) + 0.587 * f32::from(p[1]) + 0.114 * f32::from(p[2])).round() as u8;
p[..3].fill(grey);
}
}
/// The ArtCraft wordmark, as supplied: dark ink for light backgrounds, white for dark ones.
/// An ArtCraft trademark, not covered by the MIT/Apache license (`docs/brand/LICENSE-brand.txt`).
pub fn artcraft_logo(dark_background: bool) -> ImageSource<'static> {
@@ -42,6 +72,9 @@ pub fn social(service: &str) -> Option<ImageSource<'static>> {
"YouTube" => egui::include_image!("../../../assets/social/youtube.svg"),
"TikTok" => egui::include_image!("../../../assets/social/tiktok.svg"),
"Instagram" => egui::include_image!("../../../assets/social/instagram.svg"),
"GitHub" => egui::include_image!("../../../assets/social/github.svg"),
"Discord" => egui::include_image!("../../../assets/social/discord.svg"),
"Website" => egui::include_image!("../../../assets/social/globe.svg"),
"LinkedIn" => egui::include_image!("../../../assets/social/linkedin.svg"),
_ => return None,
})
@@ -51,3 +84,20 @@ pub fn social(service: &str) -> Option<ImageSource<'static>> {
pub fn artcraft_mark() -> ImageSource<'static> {
egui::include_image!("../../../docs/brand/artcraft-mark.svg")
}
#[cfg(test)]
mod tests {
use super::desaturate;
#[test]
fn grey_pixels_have_no_colour_and_keep_their_alpha() {
let mut px = [255, 0, 0, 200, 0, 128, 255, 17, 10, 10, 10, 255];
desaturate(&mut px);
for p in px.as_chunks::<4>().0 {
assert!(p[0] == p[1] && p[1] == p[2], "{p:?}");
}
assert_eq!([px[3], px[7], px[11]], [200, 17, 255]);
// Pure red is darker than pure white and pure grey stays put.
assert!(px[0] < 255 && px[8] == 10);
}
}
+4
View File
@@ -17,6 +17,8 @@ macro_rules! tl {
}
mod app;
mod bar;
mod card_parts;
#[cfg(not(target_arch = "wasm32"))]
mod cjk_font;
mod details;
@@ -27,6 +29,8 @@ pub mod model;
mod motion;
mod notes;
pub mod settings;
mod settings_dialog;
mod sheet;
pub mod theme;
mod views;
mod widgets;
+75 -26
View File
@@ -34,10 +34,14 @@ pub(crate) fn classify(line: &str) -> Line<'_> {
Line::Text(l)
}
/// How many changes show before the rest folds away.
const SHOWN: usize = 6;
pub(crate) fn show(ui: &mut Ui, notes: &str) {
show_scaled(ui, notes, 1.0);
}
/// The notes at `scale` times their usual size (the notes dialog's own zoom).
pub(crate) fn show_scaled(ui: &mut Ui, notes: &str, scale: f32) {
ui.scope(|ui| {
for font in ui.style_mut().text_styles.values_mut() {
@@ -47,40 +51,75 @@ pub(crate) fn show_scaled(ui: &mut Ui, notes: &str, scale: f32) {
});
}
/// The notes, short: the first few changes in full, the rest behind "Show N more changes", and the
/// link to the full changelog always in view.
fn show_lines(ui: &mut Ui, notes: &str, scale: f32) {
let t = Tokens::get(ui.ctx());
if notes.trim().is_empty() {
ui.label(RichText::new(tl!("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(crate::widgets::heading(h, 16.0 * scale, t.ink_strong));
let (changelog, lines): (Vec<Line<'_>>, Vec<Line<'_>>) = notes.lines().map(classify).partition(|l| full_changelog(l).is_some());
let bullets = lines.iter().filter(|l| matches!(l, Line::Bullet(..))).count();
let (head, rest) = lines.split_at(fold_at(&lines).unwrap_or(lines.len()));
for line in head {
draw(ui, line, &t, scale);
}
if !rest.is_empty() {
let label = crate::i18n::fmt(tl!("Show {n} more changes"), &[("n", &bullets.saturating_sub(SHOWN).to_string())]);
egui::CollapsingHeader::new(label).id_salt(ui.id().with(("more-notes", notes.len()))).show(ui, |ui| {
for line in rest {
draw(ui, line, &t, scale);
}
Line::Bullet(text, link) => {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
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(|| tl!("link").to_string(), |n| format!("#{n}"));
ui.hyperlink_to(RichText::new(short).size(12.0 * scale), url);
}
});
}
Line::Text(text) => {
if let Some(url) = text.strip_prefix("**Full Changelog**: ") {
ui.add_space(6.0);
ui.hyperlink_to(tl!("Full changelog"), url.trim());
} else {
ui.label(RichText::new(text).color(t.muted));
});
}
for line in &changelog {
if let Some(url) = full_changelog(line) {
ui.add_space(6.0);
ui.hyperlink_to(tl!("Full changelog"), url);
}
}
}
/// Where a long list folds: just before its (SHOWN + 1)th change. A short list is shown whole.
fn fold_at(lines: &[Line<'_>]) -> Option<usize> {
let bullets = lines.iter().filter(|l| matches!(l, Line::Bullet(..))).count();
if bullets <= SHOWN + 1 {
return None;
}
lines.iter().enumerate().filter(|(_, l)| matches!(l, Line::Bullet(..))).nth(SHOWN).map(|(i, _)| i)
}
/// The address on a "**Full Changelog**: …" line, when it is a safe web link.
fn full_changelog<'a>(line: &Line<'a>) -> Option<&'a str> {
let Line::Text(text) = line else { return None };
text.strip_prefix("**Full Changelog**: ").map(str::trim).filter(|url| artcraft_launcher_core::release::safe_web_link(url))
}
fn draw(ui: &mut Ui, line: &Line<'_>, t: &Tokens, scale: f32) {
match line {
Line::Heading(h) => {
ui.add_space(6.0);
ui.label(crate::widgets::heading(h, 16.0 * scale, t.ink_strong));
}
Line::Bullet(text, link) => {
// The author's handle is noise in a list of what changed.
let text = text.rsplit_once(" by @").map_or(*text, |(change, _)| change);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.label(RichText::new("•").color(t.faint));
ui.label(RichText::new(text).color(t.ink));
if let Some(url) = link.filter(|u| artcraft_launcher_core::release::safe_web_link(u)) {
let short = url.rsplit('/').next().map_or_else(|| tl!("link").to_string(), |n| format!("#{n}"));
ui.hyperlink_to(RichText::new(short).size(12.0 * scale), url);
}
}
Line::Blank => {
ui.add_space(2.0);
}
});
}
Line::Text(text) => {
ui.label(RichText::new(*text).color(t.muted));
}
Line::Blank => {
ui.add_space(2.0);
}
}
}
@@ -100,4 +139,14 @@ mod tests {
assert_eq!(classify(" "), Line::Blank);
assert_eq!(classify("hello"), Line::Text("hello"));
}
#[test]
fn a_long_list_folds_after_six_changes_and_a_short_one_does_not() {
let many: Vec<Line> = std::iter::once(Line::Heading("What's Changed")).chain((0..10).map(|_| Line::Bullet("change", None))).collect();
// The heading, then six bullets: the seventh bullet (index 7) is the first one folded.
assert_eq!(fold_at(&many), Some(7));
let few: Vec<Line> = (0..7).map(|_| Line::Bullet("change", None)).collect();
assert_eq!(fold_at(&few), None);
assert_eq!(fold_at(&[]), None);
}
}
+6 -1
View File
@@ -40,13 +40,15 @@ pub enum SortKey {
Latest,
/// Release date.
Released,
/// How often the app has been opened from here, most first.
Frequent,
}
impl SortKey {
/// The direction a first click sorts in: newest and highest first for dates and versions,
/// A to Z for text.
pub fn starts_descending(self) -> bool {
matches!(self, Self::Installed | Self::Latest | Self::Released)
matches!(self, Self::Installed | Self::Latest | Self::Released | Self::Frequent)
}
}
@@ -115,6 +117,8 @@ pub struct Settings {
pub last_check: Option<i64>,
/// Catalog ids of apps hidden from the main lists (`photocraft`, …).
pub hidden: std::collections::BTreeSet<String>,
/// How many times each app was opened from the launcher (catalog id → count), for "Frequently used".
pub launches: BTreeMap<String, u32>,
}
impl Settings {
@@ -188,6 +192,7 @@ impl Default for Settings {
notified: BTreeMap::new(),
last_check: None,
hidden: std::collections::BTreeSet::new(),
launches: BTreeMap::new(),
}
}
}
+303
View File
@@ -0,0 +1,303 @@
//! The Settings dialog: a large card in the shape of the app page (see `sheet.rs`), with the
//! groups of settings in boxes, two columns once the window is wide enough.
use artcraft_launcher_core::{Os, PackageKind};
use egui::{RichText, Ui, vec2};
use crate::app::{Host, LauncherUi};
use crate::i18n::fmt;
use crate::settings::{CheckEvery, Settings};
use crate::sheet;
use crate::theme::{self, ThemeChoice, Tokens};
use crate::widgets::{self, Cell, Kind};
/// A label with its control at the right; the control drops under the label when they don't fit.
fn row(ui: &mut Ui, label: &str, control_width: f32, add: &mut dyn FnMut(&mut Ui)) {
let t = Tokens::get(ui.ctx());
let font = egui::TextStyle::Body.resolve(ui.style());
let label_width = ui.painter().layout_no_wrap(label.to_string(), font, t.ink).size().x;
if label_width + control_width + ui.spacing().item_spacing.x > ui.available_width() {
ui.label(label);
add(ui);
} else {
ui.horizontal(|ui| {
ui.label(label);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui));
});
}
}
fn note(ui: &mut Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(text).size(12.0).color(t.muted));
}
impl LauncherUi {
pub(crate) fn settings_dialog(&mut self, ui: &mut Ui, host: &mut dyn Host) -> bool {
let t = Tokens::get(ui.ctx());
let size = sheet::size(ui.ctx(), vec2(1000.0, 820.0));
ui.set_width(size.x);
ui.set_max_height(size.y);
let hero_h = sheet::hero(
ui,
Some(crate::icons::artcraft_mark()),
tl!("Settings"),
&format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION")),
|_| {},
);
let quit_width = widgets::button_width(ui, tl!("Quit ArtCraft Launcher"), 0.0);
let done_width = widgets::button_width(ui, tl!("Done"), 90.0);
let stack_actions = self.caps.manage_apps && quit_width + done_width + ui.spacing().item_spacing.x + 48.0 > size.x;
let footer_h = if stack_actions { 2.0 * theme::CONTROL_H + ui.spacing().item_spacing.y } else { theme::CONTROL_H } + 32.0;
let body_h = (size.y - hero_h - footer_h - 2.0).max(0.0);
let wide = size.x >= sheet::WIDE_FROM;
let saved = sheet::accent_scroll(ui, &t);
egui::ScrollArea::vertical().id_salt("settings-body").max_height(body_h).auto_shrink([false, true]).show(ui, |ui| {
*ui.visuals_mut() = saved;
egui::Frame::new().inner_margin(egui::Margin::same(24)).show(ui, |ui| {
ui.set_width(ui.available_width());
self.settings_contents(ui, host, wide);
});
});
let rule_y = ui.cursor().top();
ui.painter().hline(ui.max_rect().x_range(), rule_y, t.hairline());
let mut close = false;
egui::Frame::new().inner_margin(egui::Margin::symmetric(24, 16)).show(ui, |ui| {
ui.set_width(ui.available_width());
let quit = |ui: &mut Ui, host: &mut dyn Host| {
if self.caps.manage_apps && widgets::button(ui, tl!("Quit ArtCraft Launcher"), Kind::Secondary, 0.0).clicked() {
host.quit();
}
};
if stack_actions {
quit(ui, host);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
});
} else {
ui.horizontal(|ui| {
quit(ui, host);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
});
});
}
});
close
}
fn settings_contents(&mut self, ui: &mut Ui, host: &mut dyn Host, wide: bool) {
// The columns are drawn from two closures that both need `self` and `host`: the pieces
// are drawn one after the other, through a cell, never at the same time.
let state = std::cell::RefCell::new((&mut *self, host));
sheet::columns(
ui,
wide,
24.0,
|ui| {
let (this, _) = &mut *state.borrow_mut();
this.settings_language(ui);
this.settings_updates(ui);
this.settings_appearance(ui);
},
|ui| {
let (this, host) = &mut *state.borrow_mut();
this.settings_install(ui, &mut **host);
this.settings_background(ui, &mut **host);
this.settings_community(ui);
},
);
}
fn settings_language(&mut self, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let mut lang_code = self.settings.language.clone();
let installed = self.cjk_font;
sheet::panel(ui, tl!("Language"), |ui| {
let width = ui.available_width();
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code, installed)).width(width).show_ui(ui, |ui| {
ui.selectable_value(&mut lang_code, Settings::LANGUAGE_AUTO.to_string(), tl!("Auto"));
for lang in crate::i18n::Lang::all() {
ui.selectable_value(&mut lang_code, lang.code().to_string(), Settings::language_label(lang.code(), installed));
}
});
});
if lang_code != self.settings.language {
self.settings.language = lang_code;
crate::i18n::set_current(crate::app::effective_lang(&self.settings.language));
}
let pref = crate::i18n::Lang::from_pref(&self.settings.language);
if pref.needs_cjk_font() && !theme::cjk_font_available(pref) {
// In English on purpose: the language asked for can't be drawn.
let text = "No font for this language was found on this system, so the interface stays in English. \
Installing one (for example Noto Sans CJK) and restarting fixes it.";
ui.add(egui::Label::new(RichText::new(text).size(12.0).color(t.warn)).wrap());
ui.add_space(14.0);
}
}
fn settings_updates(&mut self, ui: &mut Ui) {
sheet::panel(ui, tl!("Updates"), |ui| {
let mut every = self.settings.check_every;
row(ui, tl!("Check for updates"), 150.0, &mut |ui| {
egui::ComboBox::from_id_salt("every").selected_text(every.label()).width(150.0).show_ui(ui, |ui| {
for e in CheckEvery::ALL {
ui.selectable_value(&mut every, e, e.label());
}
});
});
self.settings.check_every = every;
if self.caps.manage_apps
&& ui.checkbox(&mut self.settings.auto_update, tl!("Automatically download and install updates")).changed()
&& self.settings.auto_update
{
self.trigger_auto_updates();
}
ui.checkbox(&mut self.settings.notify, tl!("Notify me when an installed app has an update"));
ui.checkbox(&mut self.settings.prereleases, tl!("Include pre-releases (betas and release candidates)"));
});
}
/// Where apps go and in what format, for the desktop builds that manage installs.
fn settings_install(&mut self, ui: &mut Ui, host: &mut dyn Host) {
if !self.caps.manage_apps {
return;
}
sheet::panel(ui, tl!("Default install folder"), |ui| {
if self.custom_install_folders() {
let mut folder = self.settings.install_locations.root.clone().unwrap_or_default();
Self::folder_field(ui, &mut folder, None, host);
self.settings.install_locations.root = (!folder.is_empty()).then_some(folder);
note(ui, tl!("New installs use this folder. Updates stay in their current location."));
if self.platform.is_some_and(|p| p.os == Os::Linux) {
note(ui, tl!("Custom folders use AppImage on Linux."));
}
} else {
ui.label(tl!("The system manages install locations here."));
}
});
if self.platform.is_some_and(|p| p.os == Os::Windows) {
sheet::panel(ui, tl!("Installing on Windows"), |ui| {
let mut choice = self
.settings
.preferred_kinds
.first()
.copied()
.filter(|kind| matches!(kind, PackageKind::Msi | PackageKind::PortableZip))
.or(Some(PackageKind::Msi));
row(ui, tl!("Install new apps as"), 210.0, &mut |ui| {
let label = choice.map_or_else(|| crate::i18n::t(PackageKind::Msi.label()), |kind| crate::i18n::t(kind.label()));
egui::ComboBox::from_id_salt("windows-kind").selected_text(label).width(210.0).show_ui(ui, |ui| {
for kind in [PackageKind::Msi, PackageKind::PortableZip] {
ui.selectable_value(&mut choice, Some(kind), crate::i18n::t(kind.label()));
}
});
});
self.settings.preferred_kinds = choice.into_iter().collect();
note(ui, tl!("Updates always use the format an app was installed with."));
});
}
if self.platform.is_some_and(|p| p.os == Os::Linux) {
sheet::panel(ui, tl!("Installing on Linux"), |ui| {
let is_flatpak = crate::settings::is_flatpak_sandbox();
let is_appimage = crate::settings::is_appimage();
let default_kind = if is_flatpak {
Some(PackageKind::Flatpak)
} else if is_appimage {
Some(PackageKind::AppImage)
} else {
None
};
let mut choice = self.settings.preferred_kinds.first().copied().or(default_kind);
row(ui, tl!("Install new apps as"), 210.0, &mut |ui| {
let default_label = match default_kind {
Some(k) => crate::i18n::t(k.label()),
None => tl!("System package (recommended)"),
};
let label = choice.map_or(default_label, |k| crate::i18n::t(k.label()));
egui::ComboBox::from_id_salt("kind").selected_text(label).width(210.0).show_ui(ui, |ui| {
if !is_flatpak && !is_appimage {
ui.selectable_value(&mut choice, None, tl!("System package (recommended)"));
}
for k in [PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::AppImage] {
ui.selectable_value(&mut choice, Some(k), crate::i18n::t(k.label()));
}
// The tarball is unpacked and run inside the launcher's own sandbox, so it
// is only ever on offer there (as the fallback when the host has no Flatpak).
if is_flatpak {
ui.selectable_value(&mut choice, Some(PackageKind::TarGz), crate::i18n::t(PackageKind::TarGz.label()));
}
});
});
self.settings.preferred_kinds = choice.into_iter().collect();
note(ui, tl!("Updates always use the format an app was installed with."));
});
}
}
fn settings_background(&mut self, ui: &mut Ui, host: &mut dyn Host) {
// Nothing to show on a host with no tray, desktops or login item: no empty box.
if !self.caps.manage_apps || !(host.has_tray() || host.has_desktops() || host.login_item().is_some()) {
return;
}
sheet::panel(ui, tl!("Running in the background"), |ui| {
if host.has_tray() {
ui.checkbox(&mut self.settings.close_to_tray, tl!("Keep running in the tray when the window is closed"));
}
if host.has_desktops() {
ui.checkbox(&mut self.settings.show_on_current_desktop, tl!("Show the window on the current desktop")).on_hover_text(tl!(
"When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on."
));
}
if let Some(mut on) = host.login_item()
&& ui.checkbox(&mut on, tl!("Start ArtCraft Launcher when I log in")).changed()
&& let Err(e) = host.set_login_item(on)
{
let ctx = ui.ctx().clone();
self.toast(&ctx, fmt(tl!("Couldn't change the login item: {e}"), &[("e", &e)]), true);
}
});
}
fn settings_community(&mut self, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
sheet::panel(ui, tl!("Community"), |ui| {
ui.label(RichText::new(tl!("Questions, ideas and news from the ArtCraft team")).color(t.muted));
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
use artcraft_launcher_core::catalog::{GITHUB_URL, WEBSITE_URL};
if widgets::button(ui, tl!("Join the Discord"), Kind::Primary, 0.0).on_hover_text(self.discord_url).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(self.discord_url));
}
if widgets::button(ui, tl!("Website"), Kind::Secondary, 0.0).on_hover_text(WEBSITE_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(WEBSITE_URL));
}
if widgets::button(ui, tl!("GitHub"), Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(GITHUB_URL));
}
// Wrap the social links as a group so their icons stay together.
let count = artcraft_launcher_core::catalog::SOCIAL.len() as f32;
let width = count * (16.0 + 8.0) + (count - 1.0).max(0.0) * 4.0;
ui.allocate_ui_with_layout(vec2(width, theme::CONTROL_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
widgets::social_icons(ui, 16.0);
});
});
});
}
fn settings_appearance(&mut self, ui: &mut Ui) {
let mut theme_choice = self.settings.theme;
sheet::panel(ui, tl!("Appearance"), |ui| {
ui.horizontal(|ui| {
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;
}
}
+103
View File
@@ -0,0 +1,103 @@
//! The shape the launcher's large dialogs (Settings, release notes) share, so they sit
//! beside the app page as one family: a card that nearly fills the window, a header with a wash of
//! the brand colour under a 2 px rule, a body that scrolls (the bar in the brand colour), and
//! sections laid out in two columns once there is room.
//!
//! Invariants: every colour comes from [`Tokens`]; nothing here owns state; the card is never
//! larger than the window, and a window too narrow for two columns gets one.
use egui::{Color32, Context, CornerRadius, Frame, ImageSource, Margin, Ui, Vec2, vec2};
use crate::theme::{Tokens, tint};
use crate::widgets;
/// The body of a dialog reads in two columns from this width.
pub(crate) const WIDE_FROM: f32 = 760.0;
/// The size of a large dialog: the window less a margin all round, no larger than `max`.
pub(crate) fn size(ctx: &Context, max: Vec2) -> Vec2 {
let screen = ctx.content_rect();
vec2((screen.width() - 64.0).clamp(1.0, max.x), (screen.height() - 48.0).clamp(1.0, max.y))
}
/// The modal's frame: no inner margin, so the header can run to the edges.
pub(crate) fn frame(t: &Tokens) -> Frame {
Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(crate::theme::shadow(t)).inner_margin(0)
}
/// The header: an icon, the title and a small caption, with whatever `right` puts at the right
/// edge. Returns its height.
pub(crate) fn hero(ui: &mut Ui, icon: Option<ImageSource<'static>>, title: &str, caption: &str, right: impl FnOnce(&mut Ui)) -> f32 {
let t = Tokens::get(ui.ctx());
let top = ui.cursor().top();
let shown = Frame::new().fill(tint(t.accent, if t.dark { 0.09 } else { 0.07 })).inner_margin(Margin::symmetric(24, 18)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
if let Some(icon) = icon {
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(44.0, 44.0)));
ui.add_space(6.0);
}
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 4.0;
ui.label(widgets::heading(title, 26.0, t.ink_strong));
if !caption.is_empty() {
widgets::hud(ui, caption, t.faint);
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), right);
});
});
let r = shown.response.rect;
ui.painter().rect_filled(egui::Rect::from_min_size(r.min, vec2(r.width(), 2.0)), CornerRadius::ZERO, t.accent);
ui.painter().hline(r.x_range(), r.bottom() - 0.5, t.hairline());
ui.cursor().top() - top
}
/// A solid scroll bar in the brand colour on a dark tint of it, so it stands out from the page.
/// Returns the visuals to put back inside the scroll area's contents (the bar's colours are for the
/// bar alone).
pub(crate) fn accent_scroll(ui: &mut Ui, t: &Tokens) -> egui::Visuals {
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
ui.spacing_mut().scroll.bar_width = 10.0;
let page = ui.visuals().clone();
let bar: Color32 = t.accent;
let v = ui.visuals_mut();
v.extreme_bg_color = t.bg.blend(tint(bar, if t.dark { 0.14 } else { 0.10 }));
v.widgets.inactive.bg_fill = bar.gamma_multiply(0.8);
v.widgets.hovered.bg_fill = bar;
v.widgets.active.bg_fill = bar;
page
}
/// Two columns of equal width a `gap` apart when `wide`; otherwise `left` then `right`, stacked.
pub(crate) fn columns(ui: &mut Ui, wide: bool, gap: f32, left: impl FnOnce(&mut Ui), right: impl FnOnce(&mut Ui)) {
if !wide {
left(ui);
right(ui);
return;
}
let w = ((ui.available_width() - gap) / 2.0).floor();
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = gap;
ui.vertical(|ui| {
ui.set_width(w);
left(ui);
});
ui.vertical(|ui| {
ui.set_width(w);
right(ui);
});
});
}
/// A titled box: a hairline frame with a small caps heading, for one group of settings.
pub(crate) fn panel(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui)) {
let t = Tokens::get(ui.ctx());
Frame::new().stroke(t.hairline()).inner_margin(16).show(ui, |ui| {
ui.set_width(ui.available_width());
widgets::hud(ui, title, t.faint);
ui.add_space(8.0);
body(ui);
});
ui.add_space(14.0);
}
+223 -149
View File
@@ -5,7 +5,7 @@ use artcraft_launcher_core::details::AppDetails;
use artcraft_launcher_core::platform::pick_asset;
use artcraft_launcher_core::protocol::{Command, JobPhase};
use artcraft_launcher_core::status::AppStatus;
use artcraft_launcher_core::{SUITE, time};
use artcraft_launcher_core::time;
use egui::{Color32, CornerRadius, FontId, Rect, RichText, Sense, Stroke, Ui, vec2};
use crate::app::{Dialog, LauncherUi, TitleBar};
@@ -13,7 +13,7 @@ use crate::i18n::{current, fmt, t, tn, tr_ctx};
use crate::model::{self, AppEntry};
use crate::settings::{Filter, SortKey, ViewMode};
use crate::theme::{self, Tokens, rgb, tint};
use crate::widgets::{self, Cell, Kind};
use crate::widgets::{self, Kind};
/// Translate a relative timestamp ("just now", "5 min ago", …) into the current language.
fn ago_text(secs: i64, now: i64) -> String {
@@ -53,11 +53,9 @@ enum Primary {
pub(crate) const HEADER_H: f32 = 48.0;
/// Height of the footer bar.
pub(crate) const FOOTER_H: f32 = 34.0;
/// The footer drops its platform label rather than leave the status less room than this.
const FOOTER_MIN_STATUS_W: f32 = 300.0;
const CARD_MIN_W: f32 = 320.0;
const CARD_H: f32 = 280.0;
const CARD_H: f32 = 314.0;
/// The card's top strip: number tab and links.
const STRIP_H: f32 = 40.0;
const CARD_PAD: f32 = 20.0;
@@ -88,37 +86,11 @@ impl LauncherUi {
if widgets::nav_icon(ui, "⚙", tl!("Settings"), h, false).clicked() {
self.dialog = Some(Dialog::Settings);
}
if widgets::nav_icon(ui, "⟳", tl!("Check for updates"), h, self.checking).clicked() {
self.check_now();
}
ui.add_space(6.0);
if widgets::nav_link(ui, tl!("Discord"), tl!("Join the ArtCraft community on Discord"), h, false).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(self.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, tl!("Table"), tl!("Show apps as a table")), (ViewMode::Grid, tl!("Grid"), tl!("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);
// The controls reserve their actual widths before the secondary text gets space.
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
let tag_width =
widgets::caps(ui, tl!("Launcher"), theme::HUD_SIZE, theme::mono_bold(), theme::HUD_SPACING, t.ink).size().x + 16.0;
if ui.available_width() >= 17.0 + tag_width {
if ui.available_width() >= 17.0 + widgets::hud_galley(ui, tl!("Craft App Suite"), t.muted).size().x {
widgets::rule(ui, h);
ui.add_space(16.0);
widgets::tag(ui, tl!("Launcher"), t.invert_bg, t.invert_fg);
}
if ui.available_width() >= 12.0 + widgets::hud_galley(ui, tl!("Craft App Suite"), t.muted).size().x {
ui.add_space(12.0);
widgets::hud(ui, tl!("Craft App Suite"), t.muted);
}
});
@@ -200,38 +172,27 @@ impl LauncherUi {
ui.add_space(14.0);
}
self.announcements(ui);
let updates = self.count(Filter::Updates);
let mut compact = false;
ui.horizontal(|ui| {
let updates = self.count(Filter::Updates);
let mut filters = vec![
(Filter::All, tl!("All"), tl!("Every Craft app"), None),
// The tab names a group ("installed ones"); the status pill says "Installed".
(Filter::Installed, tr_ctx(current(), "filter", "Installed"), tl!("Apps on this computer"), None),
(Filter::Updates, tl!("Updates"), tl!("Installed apps with a newer version"), Some(t.warn)),
(Filter::NotInstalled, tl!("Not installed"), tl!("Apps you can install"), None),
];
filters.retain(|(f, ..)| self.caps.manage_apps || *f == Filter::All);
self.refresh_button(ui);
// Hiding an app is only undone from here, so the way back to the hidden ones stays
// while there are any: a toggle, lit while the hidden apps are what the page shows.
if !self.settings.hidden.is_empty() {
filters.push((Filter::Hidden, tl!("Hidden Apps"), tl!("Apps you hid"), None));
}
let filters: Vec<(Filter, Cell<'_>)> =
filters.into_iter().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 {
tl!("Update 1 app").to_string()
} else {
fmt(tl!("Update all ({count})"), &[("count", &updates.to_string())])
};
if widgets::button(ui, &label, Kind::Primary, 0.0).clicked() {
let ids: Vec<&str> =
self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none()).map(|e| e.app.id).collect();
for id in ids {
self.send(Command::Install { app: id.to_string() });
}
}
ui.add_space(4.0);
let showing = self.settings.filter == Filter::Hidden;
let label = format!("{} {}", tl!("Hidden Apps"), self.settings.hidden.len());
if widgets::button(ui, &label, if showing { Kind::Primary } else { Kind::Secondary }, 0.0)
.on_hover_text(tl!("Apps you hid"))
.clicked()
{
self.settings.filter = if showing { Filter::All } else { Filter::Hidden };
}
}
compact = ui.available_width() < 640.0;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Right to left: the view switch at the far right, then the search.
crate::bar::view_toggle(ui, &mut self.settings.view, [tl!("Show apps as cards"), tl!("Show apps as a table")]);
ui.add_space(4.0);
ui.add(
egui::TextEdit::singleline(&mut self.search)
.hint_text(tl!("Search apps"))
@@ -240,26 +201,65 @@ impl LauncherUi {
.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(140.0)
.show_ui(ui, |ui| {
for key in [SortKey::Suite, SortKey::Name, SortKey::Status, SortKey::Installed, SortKey::Latest, SortKey::Released] {
if ui.selectable_label(self.settings.sort == key, sort_label(key)).clicked() {
self.settings.sort = key;
self.settings.sort_descending = key.starts_descending();
}
}
});
if self.settings.view == ViewMode::Grid && !compact {
self.sort_picker(ui);
// The caption only where it fits beside the filters (longer in some languages).
let sort = tl!("Sort");
if widgets::hud_galley(ui, sort, t.faint).size().x + ui.spacing().item_spacing.x <= ui.available_width() {
widgets::hud(ui, sort, t.faint);
}
}
if !compact && self.caps.manage_apps && updates > 0 {
self.update_all_button(ui, updates);
}
});
});
if compact {
ui.add_space(6.0);
ui.horizontal(|ui| {
if self.settings.view == ViewMode::Grid {
self.sort_picker(ui);
widgets::hud(ui, tl!("Sort"), t.faint);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if self.caps.manage_apps && updates > 0 {
self.update_all_button(ui, updates);
}
});
});
}
}
fn refresh_button(&mut self, ui: &mut Ui) {
if crate::bar::refresh_button(ui, tl!("Check for updates"), self.checking).clicked() {
self.check_now();
}
}
fn sort_picker(&mut self, ui: &mut Ui) {
egui::ComboBox::from_id_salt("sort")
.selected_text(RichText::new(grid_sort_label(grid_sort_now(&self.settings))).size(13.0))
.width(190.0)
.show_ui(ui, |ui| {
let current = grid_sort_now(&self.settings);
for choice in GRID_SORTS {
if ui.selectable_label(current == choice, grid_sort_label(choice)).clicked() {
self.settings.sort = choice.0;
self.settings.sort_descending = choice.1;
}
}
});
}
/// Update every eligible app; used in the compact and full-width toolbars.
fn update_all_button(&mut self, ui: &mut Ui, updates: usize) {
let label = if updates == 1 { tl!("Update 1 app").to_string() } else { fmt(tl!("Update all ({count})"), &[("count", &updates.to_string())]) };
if widgets::button(ui, &label, Kind::Primary, 0.0).clicked() {
let ids: Vec<&str> = self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none()).map(|e| e.app.id).collect();
for id in ids {
self.send(Command::Install { app: id.to_string() });
}
}
}
/// News from ArtCraft's release feed (and app renames), until the user dismisses each.
@@ -314,42 +314,36 @@ impl LauncherUi {
let short = fmt(tl!("GitHub limit hit; back in {min} min"), &[("min", &min)]);
(format!("· {full}"), format!("· {short}"))
});
let platform = match self.platform {
Some(p) if self.caps.manage_apps => fmt(tl!("Installing for {platform}"), &[("platform", &p.label())]),
Some(p) => fmt(tl!("Downloads for {platform}"), &[("platform", &p.label())]),
None => tl!("No downloads for this system").into(),
};
use artcraft_launcher_core::catalog::{GITHUB_URL, SOCIAL, WEBSITE_URL};
let links = [(tl!("Discord"), self.discord_url), (tl!("GitHub"), GITHUB_URL), (tl!("Website"), WEBSITE_URL)];
let (icon, link_gap) = (13.0, 16.0);
// The links and icons on the right keep their size; the status on the left gets what is
// left and is cut short with "…", never drawn under them. A narrow window drops the
// platform label first, so the status keeps room to be read.
// Every link is an icon, reading Website, GitHub, Discord, then the social accounts.
let named =
|name: &'static str, label: &str, url: &'static str| widgets::IconLink { name, label: label.to_string(), tip: label.to_string(), url };
let mut links = vec![
named("Website", tl!("Website"), WEBSITE_URL),
named("GitHub", tl!("GitHub"), GITHUB_URL),
widgets::IconLink {
name: "Discord",
label: tl!("Discord").to_string(),
tip: tl!("Join the ArtCraft community on Discord").to_string(),
url: self.discord_url,
},
];
links.extend(SOCIAL.iter().map(|(name, url)| widgets::IconLink {
name,
label: (*name).to_string(),
tip: fmt(tl!("ArtCraft on {name}"), &[("name", name)]),
url,
}));
let (icon, pad) = (13.0, 7.0);
let spacing = ui.spacing().item_spacing.x;
let links_w: f32 =
links.iter().map(|(text, _)| widgets::hud_galley(ui, &format!("{text} ↗"), t.muted).size().x + link_gap).sum::<f32>() - link_gap;
let icons_w = SOCIAL.len() as f32 * (icon + 8.0);
let platform_w = widgets::hud_galley(ui, &platform, t.faint).size().x + 16.0 + spacing;
let left_w = ui.available_width() - icons_w - 12.0 - links_w - 16.0 - 3.0 * spacing;
let show_platform = left_w - platform_w >= FOOTER_MIN_STATUS_W;
// The icons on the right keep their size; the status on the left gets what is left and
// is cut short with "…", never drawn under them.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if show_platform {
widgets::hud(ui, &platform, 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 = 0.0;
widgets::social_icons(ui, icon);
widgets::icon_links(ui, icon, pad, &links);
});
ui.add_space(12.0);
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = link_gap;
for (text, url) in links {
widgets::hud_link(ui, text, url);
}
});
ui.add_space(16.0);
ui.add_space(8.0);
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
// While GitHub is rate limited, saying so matters more than when we last checked.
let avail = ui.available_width();
@@ -377,7 +371,25 @@ impl LauncherUi {
// ---- grid ----------------------------------------------------------------------------
/// Installed apps first (`ids` arrives that way), then the rest under a "Not installed" header.
pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) {
let installed = ids.iter().take_while(|id| self.is_installed(id)).count();
let (top, rest) = ids.split_at(installed);
self.grid_block(ui, top);
if !top.is_empty() && !rest.is_empty() {
let t = Tokens::get(ui.ctx());
ui.add_space(28.0);
widgets::hud(ui, tl!("Not installed"), t.faint);
ui.add_space(12.0);
}
self.grid_block(ui, rest);
ui.add_space(24.0);
}
fn grid_block(&mut self, ui: &mut Ui, ids: &[&'static str]) {
if ids.is_empty() {
return;
}
let width = ui.available_width();
// 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);
@@ -390,7 +402,6 @@ impl LauncherUi {
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, rect: Rect) {
@@ -400,7 +411,17 @@ impl LauncherUi {
let hovered = ui.rect_contains_pointer(rect);
let wipe = if self.reduce_motion { 0.0 } else { 0.3 };
let lift = ui.ctx().animate_bool_with_time(ui.id().with(("card-hover", id)), hovered, wipe);
let app_color = rgb(app.color);
// An app that is not installed is drawn in greys (icon, name, version, accent), and in its
// colours while the pointer is over its card.
let installed = entry.status(self.platform, self.caps.manage_apps).is_installed();
let in_colour = installed || hovered;
let app_color = if installed {
rgb(app.color)
} else {
let c = rgb(app.color);
let grey = Color32::from_gray((0.299 * f32::from(c.r()) + 0.587 * f32::from(c.g()) + 0.114 * f32::from(c.b())).round() as u8);
grey.lerp_to_gamma(c, lift)
};
// The whole card opens the app's page. Added before the card's own links and buttons, so
// they sit on top of it and keep their clicks.
if AppDetails::by_id(id).is_some() {
@@ -421,23 +442,26 @@ impl LauncherUi {
painter.rect_filled(Rect::from_min_size(inside.min, vec2(inside.width() * lift, 2.0)), CornerRadius::ZERO, app_color);
}
// The top strip: the app's number in its colour, and its pages.
// The top strip: the version in the app's colour, and its GitHub page.
let status = entry.status(self.platform, self.caps.manage_apps);
let version = crate::card_parts::strip_version(&entry, &status);
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);
if !version.is_empty() {
let github_w = widgets::hud_galley(&strip_ui, &format!("{} ↗", tl!("GitHub")), t.muted).size().x;
let room = (strip_ui.available_width() - github_w - 16.0).max(0.0);
widgets::hud_elided(&mut strip_ui, &version, if in_colour { t.app_ink(app) } else { t.muted }, room);
}
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, tl!("GitHub"), &app.repo_url());
widgets::hud_link(ui, tl!("Website"), &app.website());
});
}
let status = entry.status(self.platform, self.caps.manage_apps);
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)));
@@ -457,29 +481,46 @@ impl LauncherUi {
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);
if let Some(icon) = crate::icons::app_image(ui.ctx(), app.id, in_colour) {
icon.paint_at(ui, icon_rect);
}
ui.add_space(6.0);
centered_lines(ui, &[&name, &tagline], GAP, |ui| {
// The name's row is as tall as its text, not as a button.
ui.spacing_mut().interact_size.y = 0.0;
ui.horizontal(|ui| {
widgets::wordmark(ui, app, 22.0);
widgets::wordmark_in(ui, app, 22.0, in_colour);
});
ui.add(egui::Label::new(RichText::new(tr_ctx(current(), app.id, app.tagline)).font(tagline.clone()).color(t.muted)).truncate());
});
});
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(fmt(tl!("Couldn't check for updates: {f}"), &[("f", f)]));
// Installed and up to date needs no badge: the strip has the version, and the group says the rest.
if !matches!(status, AppStatus::UpToDate | AppStatus::Installed) {
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(fmt(tl!("Couldn't check for updates: {f}"), &[("f", f)]));
}
});
ui.add_space(8.0);
}
// Until a version is known, the sentence that says why (no release yet, installed…) stays.
if version.is_empty() {
let line = version_line(&entry, &status);
if !line.trim().is_empty() {
ui.label(RichText::new(line).size(12.5).color(t.muted));
ui.add_space(8.0);
}
});
ui.add_space(4.0);
ui.label(RichText::new(version_line(&entry, &status)).size(12.5).color(t.muted));
}
if let Some(details) = AppDetails::by_id(id) {
crate::card_parts::blurb(ui, details.headline, t.muted, 4);
}
if let Some(line) = crate::card_parts::last_updated(&entry) {
ui.add_space(6.0);
ui.add(egui::Label::new(RichText::new(line).size(12.5).color(t.faint)).selectable(false));
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
ui.horizontal(|ui| self.actions(ui, &entry, &status, inner.width()));
});
@@ -513,16 +554,14 @@ impl LauncherUi {
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
.column(Column::remainder().at_least(220.0).clip(true))
.column(Column::exact(176.0))
.column(Column::exact(84.0))
.column(Column::exact(84.0))
.column(Column::exact(150.0))
.column(Column::exact(104.0))
.column(Column::exact(206.0))
.header(42.0, |mut header| {
for (key, label) in [
(Some(SortKey::Name), tl!("App")),
(Some(SortKey::Status), tl!("Status")),
(Some(SortKey::Installed), tr_ctx(current(), "column", "Installed")),
(Some(SortKey::Latest), tr_ctx(current(), "column", "Latest")),
(None, tr_ctx(current(), "column", "Version")),
(Some(SortKey::Released), tr_ctx(current(), "column", "Released")),
(None, ""),
] {
@@ -530,7 +569,13 @@ impl LauncherUi {
if key == Some(SortKey::Name) {
rule(ui);
}
let Some(k) = key else { return };
let Some(k) = key else {
// A column that is not sorted still names itself.
if !label.is_empty() {
widgets::hud(ui, label, t.faint);
}
return;
};
let active = k == self.settings.sort;
let tip = if active { tl!("Click to reverse the order") } else { tl!("Click to sort by this column") };
// Measure first so the whole header cell (label + arrow) is the target.
@@ -588,8 +633,9 @@ impl LauncherUi {
self.detail = Some(id.to_string());
}
}
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)));
let installed = entry.status(self.platform, self.caps.manage_apps).is_installed();
if let Some(icon) = crate::icons::app_image(ui.ctx(), entry.app.id, installed) {
ui.add(icon.fit_to_exact_size(vec2(38.0, 38.0)));
}
ui.add_space(4.0);
ui.vertical(|ui| {
@@ -598,7 +644,7 @@ impl LauncherUi {
// 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);
widgets::wordmark_in(ui, entry.app, TITLE_SIZE, installed);
ui.add(
egui::Label::new(
RichText::new(tr_ctx(current(), entry.app.id, entry.app.tagline)).size(TAGLINE_SIZE).color(t.muted),
@@ -608,22 +654,37 @@ impl LauncherUi {
});
});
row.col(|ui| {
let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
// Installed is all it says: being up to date is the absence of an update.
let (text, tone) = if status == AppStatus::UpToDate && entry.job.is_none() && entry.paused.is_none() {
model::status_text(&AppStatus::Installed, self.caps.manage_apps)
} else {
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(fmt(tl!("Couldn't check for updates: {f}"), &[("f", f)]));
}
});
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),
);
// The version: what is installed, with an arrow to the new one in the app's colour
// when an update waits; the latest for an app not installed yet.
let mut mono = |text: String, color| ui.label(RichText::new(text).family(egui::FontFamily::Monospace).color(color));
match (status == AppStatus::UpdateAvailable).then(|| entry.installed_label().zip(entry.latest_label())).flatten() {
Some((from, to)) => {
mono(format!("{from} → {to}"), t.app_ink(entry.app));
}
None => match (entry.installed_label(), entry.latest_label()) {
(Some(installed), _) => {
mono(installed, t.ink);
}
(None, Some(latest)) => {
mono(latest, t.faint);
}
(None, None) => {
mono("—".into(), t.faint);
}
},
}
});
row.col(|ui| {
let when = entry.release().and_then(|r| r.published_at);
@@ -789,7 +850,7 @@ impl LauncherUi {
}
Primary::Open => {
if widgets::button(ui, tl!("Open"), Kind::Secondary, main_w).clicked() {
self.send(Command::Launch { app: id.clone() });
self.launch_app(&id);
}
}
Primary::Download(url) => {
@@ -827,7 +888,7 @@ impl LauncherUi {
}
if manage && status.is_installed() {
if ui.button(tl!("Open")).clicked() {
self.send(Command::Launch { app: id.clone() });
self.launch_app(&id);
ui.close();
}
if ui.button(t(reveal)).clicked() {
@@ -894,14 +955,27 @@ impl LauncherUi {
}
}
fn sort_label(k: SortKey) -> &'static str {
match k {
SortKey::Suite => tl!("Suite order"),
SortKey::Name => tl!("Name"),
SortKey::Status => tl!("Status"),
SortKey::Installed => tl!("Installed version"),
SortKey::Latest => tl!("Latest version"),
SortKey::Released => tl!("Newest release"),
/// What the grid's sort dropdown offers: the order and, for names, which way. The installed apps
/// always sit above the rest.
const GRID_SORTS: [(SortKey, bool); 4] = [(SortKey::Name, false), (SortKey::Name, true), (SortKey::Released, true), (SortKey::Frequent, true)];
/// Which of [`GRID_SORTS`] the settings come to. Anything else (the old suite order, a table
/// column) reads as Name, A to Z.
fn grid_sort_now(s: &crate::settings::Settings) -> (SortKey, bool) {
match s.sort {
SortKey::Name => (SortKey::Name, s.sort_descending),
SortKey::Released => (SortKey::Released, true),
SortKey::Frequent => (SortKey::Frequent, true),
_ => (SortKey::Name, false),
}
}
fn grid_sort_label((key, descending): (SortKey, bool)) -> &'static str {
match (key, descending) {
(SortKey::Released, _) => tl!("Recently updated"),
(SortKey::Frequent, _) => tl!("Frequently used"),
(_, true) => tl!("Name (Z–A)"),
_ => tl!("Name (A–Z)"),
}
}
+44 -27
View File
@@ -98,21 +98,6 @@ pub fn rule(ui: &mut Ui, height: f32) {
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);
}
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 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 {
@@ -350,6 +335,12 @@ pub struct Cell<'a> {
/// 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 {
cells_apart(ui, value, options, 0.0)
}
/// Like [`cells`], but with `gap` between the options: with a gap each option has a hairline box
/// of its own instead of sharing one.
pub fn cells_apart<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, Cell<'_>)], gap: f32) -> bool {
let t = Tokens::get(ui.ctx());
let mut changed = false;
// In a right-to-left parent the row fills from the right: keep the options' order.
@@ -357,7 +348,7 @@ pub fn cells<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, Cel
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;
ui.spacing_mut().item_spacing.x = gap;
for (opt, cell) in ordered {
let on = *value == *opt;
let label = hud_galley(ui, cell.label, t.ink);
@@ -392,8 +383,12 @@ pub fn cells<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, Cel
rects.push(rect);
}
});
// The hairlines: an outline, and a rule between neighbours.
if let (Some(first), Some(last)) = (rects.first(), rects.last()) {
// The hairlines: an outline each when apart; otherwise one outline and a rule between neighbours.
if gap > 0.0 {
for r in &rects {
ui.painter().rect_stroke(*r, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
}
} else 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());
@@ -497,26 +492,48 @@ pub fn more_menu<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) {
/// ArtCraft's social accounts as a row of small monochrome icons (faint, brighter on hover).
/// In a right-to-left layout they still read left to right.
pub fn social_icons(ui: &mut Ui, size: f32) {
let links: Vec<IconLink> = artcraft_launcher_core::catalog::SOCIAL
.iter()
.map(|(name, url)| IconLink { name, label: (*name).to_string(), tip: crate::i18n::fmt(tl!("ArtCraft on {name}"), &[("name", name)]), url })
.collect();
icon_links(ui, size, 4.0, &links);
}
/// One logo that opens a page: `name` picks the picture, `label` is what a screen reader says,
/// `tip` is the hover text.
pub struct IconLink<'a> {
pub name: &'a str,
pub label: String,
pub tip: String,
pub url: &'a str,
}
/// A row of logo links, given in reading order; each is `size` wide with `pad` on every side.
/// In a right-to-left parent they still read in the order given.
pub fn icon_links(ui: &mut Ui, size: f32, pad: f32, links: &[IconLink<'_>]) {
let t = Tokens::get(ui.ctx());
let rtl = ui.layout().prefer_right_to_left();
let services = artcraft_launcher_core::catalog::SOCIAL;
let ordered: Vec<&(&str, &str)> = if rtl { services.iter().rev().collect() } else { services.iter().collect() };
for (name, url) in ordered {
let Some(icon) = crate::icons::social(name) else { continue };
let pad = 4.0;
let ordered: Vec<&IconLink<'_>> = if rtl { links.iter().rev().collect() } else { links.iter().collect() };
for link in ordered {
let Some(icon) = crate::icons::social(link.name) else { continue };
let (rect, resp) = ui.allocate_exact_size(vec2(size + 2.0 * pad, size + 2.0 * pad), Sense::click());
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(crate::i18n::fmt(tl!("ArtCraft on {name}"), &[("name", name)])).on_hover_cursor(egui::CursorIcon::PointingHand);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, &link.label));
let resp = resp.on_hover_text(&link.tip).on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(*url));
ui.ctx().open_url(egui::OpenUrl::new_tab(link.url));
}
}
}
/// 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 {
wordmark_in(ui, app, size, true)
}
/// [`wordmark`], with "Craft" in the app's colour or, when `in_colour` is false, in grey.
pub fn wordmark_in(ui: &mut Ui, app: &artcraft_launcher_core::CraftApp, size: f32, in_colour: bool) -> 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();
@@ -527,7 +544,7 @@ pub fn wordmark(ui: &mut Ui, app: &artcraft_launcher_core::CraftApp, size: f32)
..Default::default()
};
job.append(prefix, 0.0, format(t.ink_strong));
job.append(craft, 0.0, format(t.app_ink(app)));
job.append(craft, 0.0, format(if in_colour { t.app_ink(app) } else { t.muted }));
ui.add(egui::Label::new(job).selectable(false))
}
+2 -2
View File
@@ -181,7 +181,7 @@ fn simplified_chinese_switches_persists_and_keeps_install_actions() {
h.get_by_label_contains("上次检查:3 分钟前 · 来源:ArtCraft API");
h.get_by_label("更新至 0.5.0");
// Product names stay in English.
h.get_by_label("Launcher");
assert!(h.query_by_label("Launcher").is_none());
snapshot(&mut h, "grid-zh-CN-dark");
h.get_by_label("全部更新(2)").click();
h.run_steps(2);
@@ -190,7 +190,7 @@ fn simplified_chinese_switches_persists_and_keeps_install_actions() {
let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language);
table.run_steps(6);
table.get_by_label("已安装版本");
table.get_by_label("版本");
assert!(table.query_all_by_label_contains("年10月").count() >= 1);
snapshot(&mut table, "table-zh-CN-light");
let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language);
+2 -2
View File
@@ -113,7 +113,7 @@ fn french_ui_translates_labels_dates_and_messages() {
h.run_steps(8);
snapshot(&mut h, "grid-fr-dark");
// Product names stay in English.
h.get_by_label("Launcher");
assert!(h.query_by_label("Launcher").is_none());
h.get_by_label("Craft App Suite");
h.get_by_label("Retouche d'images");
h.get_by_label("Mettre à jour vers 0.5.0");
@@ -124,6 +124,6 @@ fn french_ui_translates_labels_dates_and_messages() {
let mut table = harness(ViewMode::Table);
table.run_steps(8);
snapshot(&mut table, "table-fr-dark");
table.get_by_label("Installée");
table.get_by_label("Version");
assert!(table.query_all_by_label_contains("août 2026").count() >= 1);
}
+3 -3
View File
@@ -169,7 +169,7 @@ fn german_switches_persists_and_keeps_install_actions() {
h.get_by_label_contains("Zuletzt geprüft: vor 3 Min. · über ArtCraft API");
h.get_by_label("Auf 0.5.0 aktualisieren");
// Product names stay in English.
h.get_by_label("Launcher");
assert!(h.query_by_label("Launcher").is_none());
snapshot(&mut h, "grid-de-dark");
h.get_by_label("Alle aktualisieren (2)").click();
h.run_steps(2);
@@ -187,10 +187,10 @@ fn german_switches_persists_and_keeps_install_actions() {
for width in [720.0, 800.0, 1180.0] {
web.set_size(egui::vec2(width, 900.0));
web.run_steps(6);
for label in ["Grid", "Table", "Settings", "Check for updates"] {
for label in ["Show apps as cards", "Show apps as a table", "Settings", "Check for updates"] {
let rect = web.get_by_label(artcraft_launcher_ui_egui::i18n::t(label)).rect();
assert!(rect.left() >= 0.0 && rect.right() <= width, "{width}: {label} {rect:?}");
for label in ["Launcher", "Craft App Suite"] {
for label in ["Craft App Suite"] {
if let Some(text) = web.query_by_label(label) {
assert!(!rect.intersects(text.rect()), "{width}: {label} overlaps {rect:?}");
}
+9 -5
View File
@@ -125,7 +125,7 @@ fn hungarian_ui_translates_labels_dates_and_messages() {
h.run_steps(8);
snapshot(&mut h, "grid-hu-dark");
// Product names stay in English.
h.get_by_label("Launcher");
assert!(h.query_by_label("Launcher").is_none());
h.get_by_label("Craft App Suite");
h.get_by_label("Képszerkesztés");
h.get_by_label("Frissítés: 0.5.0");
@@ -136,7 +136,7 @@ fn hungarian_ui_translates_labels_dates_and_messages() {
let mut table = harness(ViewMode::Table);
table.run_steps(8);
snapshot(&mut table, "table-hu-dark");
table.get_by_label("Telepített");
table.get_by_label("Verzió");
assert!(table.query_all_by_label_contains("2026. aug.").count() >= 1);
check_settings_layout();
}
@@ -158,20 +158,24 @@ fn check_settings_layout() {
assert!(!done.intersects(quit));
let title = h.get_all_by_label("Beállítások").last().unwrap().rect();
assert!(screen.contains_rect(title), "{width}x{height}: title is clipped: {title:?}");
// The community links and the appearance choices sit in different columns (stacked, one
// under the other), so each is scrolled to in turn.
h.get_by_label("Rendszerbeállítás szerint").scroll_to_me();
h.run_steps(8);
let theme = h.get_by_label("Rendszerbeállítás szerint").rect();
assert!(theme.bottom() < done.top(), "appearance controls must scroll above the fixed actions");
h.get_by_label("Csatlakozás a Discordhoz").scroll_to_me();
h.run_steps(8);
// The footer has the same services, and the cards behind the modal have Webhely and GitHub
// links as one 14px line of text; keep only the settings body's buttons (34px) and icons (24px).
let discord = h.get_by_label("Csatlakozás a Discordhoz").rect();
let theme = h.get_by_label("Rendszerbeállítás szerint").rect();
assert!(screen.contains_rect(discord), "community links must be reachable by scrolling");
assert!(theme.bottom() < done.top(), "appearance controls must scroll above the fixed actions");
let mut links = vec![discord];
for label in ["Webhely", "GitHub", "X", "Instagram", "LinkedIn", "YouTube", "TikTok"] {
let candidates: Vec<_> = h
.query_all_by_label(label)
.map(|n| n.rect())
.filter(|r| r.top() >= discord.top() - 1.0 && r.bottom() < theme.top() && r.height() > 20.0)
.filter(|r| r.top() >= discord.top() - 1.0 && r.bottom() < done.top() && r.height() > 20.0)
.collect();
assert_eq!(candidates.len(), 1, "{width}x{height}: settings link {label}: {candidates:?}");
links.extend(candidates);
+2 -2
View File
@@ -184,7 +184,7 @@ fn japanese_switches_persists_and_keeps_install_actions() {
h.get_by_label_contains("最終確認: 3 分前 · 取得元: ArtCraft API");
h.get_by_label("0.5.0 に更新");
// Product names stay in English.
h.get_by_label("Launcher");
assert!(h.query_by_label("Launcher").is_none());
snapshot(&mut h, "grid-ja-dark");
h.get_by_label("すべて更新(2)").click();
h.run_steps(2);
@@ -193,7 +193,7 @@ fn japanese_switches_persists_and_keeps_install_actions() {
let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language);
table.run_steps(6);
table.get_by_label("インストール済みバージョン");
table.get_by_label("バージョン");
assert!(table.query_all_by_label_contains("年10月").count() >= 1);
snapshot(&mut table, "table-ja-light");
let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language);
+1 -1
View File
@@ -113,7 +113,7 @@ fn polish_ui_translates_labels_dates_and_messages() {
h.run_steps(8);
snapshot(&mut h, "grid-pl-dark");
// Product names stay in English.
h.get_by_label("Launcher");
assert!(h.query_by_label("Launcher").is_none());
h.get_by_label("Craft App Suite");
h.get_by_label("Edycja zdjęć");
h.get_by_label("Zaktualizuj do 0.5.0");
+2 -2
View File
@@ -101,7 +101,7 @@ fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
fn portuguese_ui_translates_labels_dates_and_messages() {
let mut h = harness(ViewMode::Grid);
h.run_steps(8);
h.get_by_label("Launcher");
assert!(h.query_by_label("Launcher").is_none());
h.get_by_label("Craft App Suite");
h.get_by_label("Edição de imagens");
h.get_by_label("Atualizar para 0.5.0");
@@ -111,6 +111,6 @@ fn portuguese_ui_translates_labels_dates_and_messages() {
let mut table = harness(ViewMode::Table);
table.run_steps(8);
table.get_by_label("Instalada");
table.get_by_label("Versão");
assert!(table.query_all_by_label_contains("de ago. de 2026").count() >= 1);
}
+2 -2
View File
@@ -113,7 +113,7 @@ fn russian_ui_translates_labels_dates_and_messages() {
h.run_steps(8);
snapshot(&mut h, "grid-ru-dark");
// Product names stay in English.
h.get_by_label("Launcher");
assert!(h.query_by_label("Launcher").is_none());
h.get_by_label("Craft App Suite");
h.get_by_label("Редактирование изображений");
h.get_by_label("Обновить до 0.5.0");
@@ -124,6 +124,6 @@ fn russian_ui_translates_labels_dates_and_messages() {
let mut table = harness(ViewMode::Table);
table.run_steps(8);
snapshot(&mut table, "table-ru-dark");
table.get_by_label("Текущая");
table.get_by_label("Версия");
assert!(table.query_all_by_label_contains("авг. 2026").count() >= 1);
}
+1 -1
View File
@@ -113,7 +113,7 @@ fn spanish_ui_translates_labels_dates_and_messages() {
h.run_steps(8);
snapshot(&mut h, "grid-es-dark");
// Product names stay in English.
h.get_by_label("Launcher");
assert!(h.query_by_label("Launcher").is_none());
h.get_by_label("Craft App Suite");
h.get_by_label("Edición de imágenes");
h.get_by_label("Actualizar a 0.5.0");
+202 -50
View File
@@ -353,6 +353,8 @@ fn a_fresh_install_confirms_the_folder_and_configures_before_installing() {
h.get_by_label("Done").click();
h.run_steps(3);
sent.borrow_mut().clear();
h.get_by_label("Install").scroll_to_me();
h.run_steps(3);
h.get_by_label("Install").click();
h.run_steps(4);
assert!(!sent.borrow().iter().any(|c| matches!(c, Command::Install { .. })));
@@ -365,6 +367,8 @@ fn a_fresh_install_confirms_the_folder_and_configures_before_installing() {
h.get_by_label("Cancel").click();
h.run_steps(3);
assert!(h.state().as_ref().unwrap().settings.install_locations.per_app.is_empty());
h.get_by_label("Install").scroll_to_me();
h.run_steps(3);
h.get_by_label("Install").click();
h.run_steps(3);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Install folder").click();
@@ -391,6 +395,8 @@ fn use_default_removes_an_app_override_and_settings_can_reset_the_root() {
h.state_mut().as_mut().unwrap().settings.install_locations.root = Some("/Volumes/Craft Apps".into());
h.state_mut().as_mut().unwrap().settings.install_locations.per_app.insert("photocraft".into(), "/Volumes/Old Photo".into());
h.run_steps(2);
h.get_by_label("Install").scroll_to_me();
h.run_steps(3);
h.get_by_label("Install").click();
h.run_steps(3);
h.get_by_label("Use default").click();
@@ -490,13 +496,17 @@ fn grid_shows_every_status_and_installs_on_click() {
snapshot(&mut h, "grid-dark");
// Statuses the user asked for: available, installed and up to date, update with version.
assert!(h.query_all_by_label("Available to install").count() >= 3);
assert!(h.query_all_by_label("Installed · up to date").count() >= 2);
assert_eq!(h.query_all_by_label("Installed · up to date").count(), 0, "an installed app carries no badge");
h.get_by_label("Update to 0.5.0");
h.get_by_label("Update to 0.6.0");
h.get_by_label_contains("0.4.1 installed → 0.5.0 released");
// The version sits in the card's top strip, and the date under the description.
h.get_by_label("0.4.1 → 0.5.0");
assert!(h.query_all_by_label("v0.4.0").count() >= 2);
assert!(h.query_all_by_label("Last updated: 07/10/2026").count() >= 2);
// 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
assert!(h.query_all_by_label_contains("Discord").count() >= 1); // the footer icon (the header has none)
assert!(h.query_by_label("Join the ArtCraft community on Discord").is_none(), "no Discord link in the header");
assert!(h.query_all_by_label("Coming soon").count() >= 1);
h.get_by_label_contains("48 MB of 131 MB");
h.get_by_label("Update all (2)").click();
@@ -678,8 +688,6 @@ fn table_view_and_light_theme() {
fn hiding_an_app_removes_it_until_it_is_unhidden() {
let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
h.run_steps(6);
// PhotoCraft has an update, so hiding it also drops the Updates count.
h.get_by_label_contains("Updates 2");
let row = h.get_all_by_label("PhotoCraft").next().unwrap().rect().center().y;
h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
h.run_steps(3);
@@ -687,8 +695,7 @@ fn hiding_an_app_removes_it_until_it_is_unhidden() {
h.run_steps(4);
assert!(h.query_by_label("PhotoCraft").is_none());
assert!(h.state().as_ref().unwrap().settings.hidden.contains("photocraft"));
h.get_by_label_contains("Updates 1");
h.get_by_label_contains("Hidden Apps").click();
h.get_by_label("Hidden Apps 1").click();
h.run_steps(4);
h.get_by_label("PhotoCraft");
assert!(h.query_by_label("WordCraft").is_none());
@@ -698,23 +705,20 @@ fn hiding_an_app_removes_it_until_it_is_unhidden() {
h.get_by_label("Unhide App").click();
h.run_steps(4);
h.get_by_label("PhotoCraft");
assert!(h.query_by_label_contains("Hidden Apps").is_none());
assert!(h.query_by_label("Hidden Apps 1").is_none());
assert!(h.state().as_ref().unwrap().settings.hidden.is_empty());
assert_eq!(h.state().as_ref().unwrap().settings.filter, artcraft_launcher_ui_egui::settings::Filter::All);
}
#[test]
fn switching_views_and_filters() {
fn switching_views() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(6);
h.get_by_label_contains("Table").click();
h.get_by_label("Show apps as a table").click();
h.run_steps(4);
assert_eq!(h.state().as_ref().unwrap().settings.view, ViewMode::Table);
h.get_by_label_contains("Updates").click();
h.run_steps(4);
snapshot(&mut h, "table-updates-dark");
assert!(h.query_by_label("WordCraft").is_none());
h.get_by_label("PhotoCraft");
h.get_by_label("WordCraft");
}
/// How far the middle of these lines of text is below the middle of the app icon beside them, in points.
@@ -768,22 +772,23 @@ fn order(h: &Harness<'_, Option<LauncherUi>>) -> Vec<String> {
fn every_table_column_sorts_both_ways() {
let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
h.run_steps(6);
// Installed: highest first, apps that aren't installed last.
h.get_by_label("Installed").click();
// Status: apps with an update first (suite order breaks the tie), then the up to date ones.
h.get_by_label("Status").click();
h.run_steps(3);
let o = order(&h);
assert_eq!(&o[..4], ["EffectCraft", "PhotoCraft", "LightCraft", "WordCraft"], "{o:?}");
// Reversed: lowest first, the empty cells still last.
h.get_by_label("Installed").click();
assert_eq!(&o[..4], ["PhotoCraft", "EffectCraft", "LightCraft", "WordCraft"], "{o:?}");
// Reversed: the up to date ones first.
h.get_by_label("Status").click();
h.run_steps(3);
let o = order(&h);
assert_eq!(&o[..4], ["WordCraft", "LightCraft", "PhotoCraft", "EffectCraft"], "{o:?}");
// Latest: highest version first, SoundCraft (no release) last.
h.get_by_label("Latest").click();
h.run_steps(3);
let o = order(&h);
assert_eq!(o.first().map(String::as_str), Some("VectorCraft"), "{o:?}"); // 0.7.0
snapshot(&mut h, "table-sorted-latest");
assert_eq!(&o[..4], ["LightCraft", "WordCraft", "PhotoCraft", "EffectCraft"], "{o:?}");
// The old Installed and Latest columns are gone; Version shows the number, and from and to for an update.
assert!(h.query_by_label("Latest").is_none());
h.get_by_label("Version");
assert!(h.query_all_by_label("0.4.0").count() >= 2);
h.get_by_label("0.4.1 → 0.5.0");
h.get_by_label("0.5.0 → 0.6.0");
snapshot(&mut h, "table-sorted-status");
// App: Z to A on the second click.
h.get_by_label("App").click();
h.run_steps(3);
@@ -801,7 +806,7 @@ fn settings_link_to_discord() {
h.run_steps(4);
snapshot(&mut h, "settings-dark");
h.get_by_label("Questions, ideas and news from the ArtCraft team");
// The header button and the one in Settings.
// The footer icon and the button in Settings.
assert!(h.query_all_by_label_contains("Discord").count() >= 2);
}
@@ -842,7 +847,7 @@ fn links_to_the_website_github_and_each_apps_pages() {
let shown_cards = h.query_all_by_label("GitHub").count() - 1;
let shown_sites = h.query_all_by_label("Website").count() - 1;
assert!(shown_cards >= 13, "{shown_cards}");
assert_eq!(shown_sites, 13, "every app has a website page");
assert_eq!(shown_sites, 0, "the cards only link to GitHub; the website is the footer's icon");
// The table's … menu has the app's website and GitHub repo.
let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
h.run_steps(6);
@@ -890,7 +895,7 @@ fn links_open_the_right_pages() {
assert!(github.contains(&"https://github.com/storytold/photocraft".to_string()), "{github:?}");
let sites = all_opened(&mut h, "Website");
assert!(sites.contains(&"https://getartcraft.com".to_string()), "{sites:?}");
assert!(sites.contains(&"https://getartcraft.com/apps/photocraft".to_string()), "{sites:?}");
assert_eq!(sites, vec!["https://getartcraft.com".to_string()], "{sites:?}");
// Social icons in the footer.
assert_eq!(click_opens(&mut h, "X").as_deref(), Some("https://x.com/get_artcraft"));
assert_eq!(click_opens(&mut h, "YouTube").as_deref(), Some("https://www.youtube.com/@OfficialArtcraftStudios"));
@@ -900,7 +905,9 @@ fn links_open_the_right_pages() {
// The … menu.
h.get_all_by_label("…").next().unwrap().click();
h.run_steps(3);
assert_eq!(click_opens(&mut h, "All releases").as_deref(), Some("https://github.com/storytold/photocraft/releases"));
// The first card is the first installed app by name.
let url = click_opens(&mut h, "All releases").unwrap_or_default();
assert!(url.starts_with("https://github.com/storytold/") && url.ends_with("/releases"), "{url}");
}
#[test]
@@ -1249,7 +1256,7 @@ fn clicking_a_card_opens_its_details_drawer() {
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
snapshot(&mut h, "details-dark");
h.get_by_label_contains("The image editor you already know how to use");
h.get_all_by_label_contains("The image editor you already know how to use").last().unwrap();
h.get_by_label_contains("Curves and type layers · 1/4");
// The links the page offers as buttons of their own.
h.get_by_label("Website ↗");
@@ -1265,7 +1272,8 @@ fn clicking_a_card_opens_its_details_drawer() {
// Esc closes it and the page is gone once it has slid out.
h.key_press(egui::Key::Escape);
h.run_steps(10);
assert!(h.query_by_label_contains("The image editor you already know").is_none());
// Only the card's own line is left once the page has slid out.
assert_eq!(h.query_all_by_label_contains("The image editor you already know").count(), 1);
}
#[test]
@@ -1275,7 +1283,7 @@ fn details_drawer_in_the_light_theme() {
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
snapshot(&mut h, "details-light");
h.get_by_label_contains("The image editor you already know how to use");
h.get_all_by_label_contains("The image editor you already know how to use").last().unwrap();
}
fn fetches(sent: &Rc<RefCell<Vec<Command>>>, app: &str) -> usize {
@@ -1301,11 +1309,15 @@ fn opening_a_page_asks_for_its_pictures_once_and_only_while_it_lacks_some() {
// Another page asks for its own, and a page without pictures asks for nothing.
h.key_press(egui::Key::Escape);
h.run_steps(10);
h.get_by_label("VectorCraft details").scroll_to_me();
h.run_steps(3);
h.get_by_label("VectorCraft details").click();
h.run_steps(10);
assert_eq!(fetches(&sent, "vectorcraft"), 1);
h.key_press(egui::Key::Escape);
h.run_steps(10);
h.get_by_label("PdfCraft details").scroll_to_me();
h.run_steps(3);
h.get_by_label("PdfCraft details").click();
h.run_steps(10);
assert_eq!(fetches(&sent, "pdfcraft"), 0);
@@ -1322,7 +1334,7 @@ fn a_frame_stands_in_for_the_pictures_until_they_arrive() {
h.get_by_label("Loading pictures…");
assert!(h.query_by_label("Enlarge picture").is_none(), "there is nothing to enlarge yet");
// The rest of the page is there, and the arrows work on the frames.
h.get_by_label_contains("The image editor you already know how to use");
h.get_all_by_label_contains("The image editor you already know how to use").last().unwrap();
h.get_by_label("Next picture (→)").click();
h.run_steps(3);
h.get_by_label_contains("Levels with a live histogram · 2/4");
@@ -1345,7 +1357,7 @@ fn offline_the_frame_says_so_and_opening_the_page_again_retries() {
h.get_by_label("Pictures couldn't be loaded. Reopen this page to try again.");
assert!(h.query_by_label("Loading pictures…").is_none());
assert!(h.query_by_label("Enlarge picture").is_none());
h.get_by_label_contains("The image editor you already know how to use");
h.get_all_by_label_contains("The image editor you already know how to use").last().unwrap();
// Back online: the page is closed and opened again, which asks again.
pictures.set(Pictures::Online);
h.key_press(egui::Key::Escape);
@@ -1382,10 +1394,12 @@ fn a_page_without_pictures_has_no_gallery() {
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(8);
// PdfCraft's screenshots still show its old name (PrintCraft), so its page has none for now.
h.get_by_label("PdfCraft details").scroll_to_me();
h.run_steps(3);
h.get_by_label("PdfCraft details").click();
h.run_steps(10);
snapshot(&mut h, "details-no-pictures-dark");
h.get_by_label_contains("The open-source PDF workbench.");
h.get_all_by_label_contains("The open-source PDF workbench.").last().unwrap();
h.get_by_label_contains("Search as you type");
assert!(h.query_by_label("Enlarge picture").is_none());
assert!(h.query_by_label("Next picture (→)").is_none());
@@ -1411,10 +1425,11 @@ fn the_lead_picture_can_be_enlarged() {
h.key_press(egui::Key::Escape);
h.run_steps(4);
assert_eq!(h.query_all_by_label("Close (Esc)").count(), 1);
h.get_by_label_contains("The image editor you already know how to use");
h.get_all_by_label_contains("The image editor you already know how to use").last().unwrap();
h.key_press(egui::Key::Escape);
h.run_steps(10);
assert!(h.query_by_label_contains("The image editor you already know").is_none());
// Only the card's own line is left once the page has slid out.
assert_eq!(h.query_all_by_label_contains("The image editor you already know").count(), 1);
}
/// Every page, whatever it holds: no pictures (PdfCraft), one (DesignCraft) or several. The arrow keys are
@@ -1428,9 +1443,11 @@ fn every_page_opens_and_pages_through_its_pictures() {
h.run_steps(8);
for d in &artcraft_launcher_core::details::DETAILS {
let name = SUITE.iter().find(|a| a.id == d.id).map(|a| a.name).unwrap_or(d.id);
h.get_by_label(&format!("{name} details")).scroll_to_me();
h.run_steps(3);
h.get_by_label(&format!("{name} details")).click();
h.run_steps(8);
h.get_by_label_contains(d.headline);
h.get_all_by_label_contains(d.headline).last().unwrap();
let presses = d.shots.len() + 2;
for key in [egui::Key::ArrowRight, egui::Key::ArrowLeft] {
for _ in 0..presses {
@@ -1459,7 +1476,8 @@ fn every_page_opens_and_pages_through_its_pictures() {
}
h.key_press(egui::Key::Escape);
h.run_steps(10);
assert!(h.query_by_label_contains(d.headline).is_none(), "{} did not close", d.id);
// The card keeps its own copy of the line; the page's is gone.
assert!(h.query_all_by_label_contains(d.headline).count() <= 1, "{} did not close", d.id);
}
}
@@ -1488,7 +1506,7 @@ fn the_drawer_and_the_enlarged_picture_survive_any_window_size() {
h.key_press(egui::Key::Escape);
h.run_steps(3);
}
h.get_by_label_contains("The image editor you already know how to use");
h.get_all_by_label_contains("The image editor you already know how to use").last().unwrap();
}
}
@@ -1526,16 +1544,13 @@ fn the_rate_limit_notice_never_runs_under_the_footer_links() {
let checked = h.get_by_label_contains("Checked ").rect();
// Cut short in a narrow window, but enough of it left to read.
assert!(notice.width() >= 120.0 && notice.right() <= width, "{width}: notice {notice:?}");
// Everything on the footer's right: the three links, the social icons and (when there's
// room) the platform.
// Everything on the footer's right: the website, GitHub and Discord icons and the social icons.
let mut right = Vec::new();
for label in ["Website", "GitHub", "Discord", "X", "YouTube", "TikTok", "Instagram", "LinkedIn"] {
right.extend(h.query_all_by_label(label).map(|n| n.rect()).filter(|r| (r.center().y - notice.center().y).abs() < 12.0));
}
assert_eq!(right.len(), 8, "{width}: {right:?}");
let platform = h.query_by_label_contains("Installing for").map(|n| n.rect());
assert_eq!(platform.is_some(), width > 720.0, "{width}: the platform label shows unless the window is narrow");
right.extend(platform);
assert!(h.query_by_label_contains("Installing for").is_none(), "{width}: the footer no longer names the platform");
for r in right {
assert!(!r.intersects(notice), "{width}: notice {notice:?} overlaps {r:?}");
assert!(!r.intersects(checked), "{width}: check time {checked:?} overlaps {r:?}");
@@ -1543,6 +1558,35 @@ fn the_rate_limit_notice_never_runs_under_the_footer_links() {
}
}
#[test]
fn toolbar_keeps_navigation_refresh_search_and_view_on_one_unclipped_row() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
for width in [720.0, 800.0, 1180.0] {
h.set_size(egui::vec2(width, 900.0));
h.run_steps(6);
let refresh = h.get_by_label("Check for updates").rect();
let search = h.query_all_by_role(egui::accesskit::Role::TextInput).next().unwrap().rect();
let view = h.get_by_label("Show apps as cards").rect();
assert!(refresh.top() > 48.0, "{width}: refresh belongs in the toolbar, not the header: {refresh:?}");
assert!((refresh.center().y - search.center().y).abs() < 2.0, "{width}: refresh and search must share a row");
assert!(refresh.right() <= search.left() + 1.0, "{width}: refresh overlaps search: {refresh:?}, {search:?}");
assert!(search.right() <= view.left() + 1.0 && view.right() <= width, "{width}: search overlaps view or view is clipped");
}
}
#[test]
fn the_toolbar_has_no_filter_dropdown() {
use artcraft_launcher_ui_egui::settings::Filter;
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(6);
for label in ["All 13", "Installed 4", "Updates 2", "Not installed 9"] {
assert!(h.query_by_value(label).is_none() && h.query_by_label(label).is_none(), "{label}");
}
// Only the sort dropdown is left.
assert_eq!(h.query_all_by_role(egui::accesskit::Role::ComboBox).count(), 1);
assert_eq!(h.state().as_ref().unwrap().settings.filter, Filter::All);
}
#[test]
fn narrow_header_keeps_the_view_switch_clear_of_brand_text() {
use artcraft_launcher_ui_egui::TitleBar;
@@ -1552,10 +1596,10 @@ fn narrow_header_keeps_the_view_switch_clear_of_brand_text() {
for width in (720..=1180).step_by(10).chain([720, 1180]).map(|w| w as f32) {
h.set_size(egui::vec2(width, 900.0));
h.run_steps(6);
for label in ["Grid", "Table", "Settings", "Check for updates"] {
for label in ["Show apps as cards", "Show apps as a table", "Settings", "Check for updates"] {
let rect = h.get_by_label(label).rect();
assert!(rect.left() >= 0.0 && rect.right() <= width, "{bar:?} {width}: {label} {rect:?}");
for label in ["Launcher", "Craft App Suite"] {
for label in ["Craft App Suite"] {
if let Some(text) = h.query_by_label(label) {
assert!(!rect.intersects(text.rect()), "{bar:?} {width}: {label} overlaps {rect:?}");
}
@@ -1568,14 +1612,14 @@ fn narrow_header_keeps_the_view_switch_clear_of_brand_text() {
}
}
if width == 1180.0 {
h.get_by_label("Launcher");
assert!(h.query_by_label("Launcher").is_none());
h.get_by_label("Craft App Suite");
}
if [720.0, 800.0, 1180.0].contains(&width) {
snapshot(&mut h, &format!("header-{theme:?}-{bar:?}-{width}"));
}
}
h.get_by_label("Table").click();
h.get_by_label("Show apps as a table").click();
h.run_steps(2);
assert_eq!(h.state().as_ref().unwrap().settings.view, ViewMode::Table);
}
@@ -1806,3 +1850,111 @@ fn an_occupied_folder_asks_before_replacing_it() {
assert_eq!(*sent.borrow(), [Command::Replace { app: "photocraft".into() }]);
assert!(h.query_by_label("Replace").is_none());
}
#[test]
fn a_not_installed_card_is_grey_until_the_pointer_is_over_it() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.set_size(egui::vec2(1180.0, 1100.0));
h.run_steps(8);
let icon = h.get_by_label("CADCraft icon").rect();
// The most colourful pixel in the icon: how far apart its red, green and blue are.
let colour = |h: &mut Harness<'_, Option<LauncherUi>>| {
let image = h.render().unwrap();
let mut most = 0_i32;
for y in icon.top() as u32..icon.bottom() as u32 {
for x in icon.left() as u32..icon.right() as u32 {
let p = image.get_pixel(x, y).0;
let (hi, lo) = (p[0].max(p[1]).max(p[2]), p[0].min(p[1]).min(p[2]));
most = most.max(i32::from(hi) - i32::from(lo));
}
}
most
};
assert!(colour(&mut h) <= 8, "a not installed app's icon is grey");
h.hover_at(egui::pos2(icon.center().x + 120.0, icon.center().y + 60.0));
h.run_steps(8);
assert!(colour(&mut h) > 40, "the icon shows its colour under the pointer");
h.hover_at(egui::pos2(5.0, 400.0));
h.run_steps(8);
assert!(colour(&mut h) <= 8, "the colour goes again when the pointer leaves");
}
#[test]
fn the_sort_offers_name_both_ways_recently_updated_and_frequently_used() {
use artcraft_launcher_ui_egui::settings::SortKey;
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(6);
h.get_by_value("Name (A–Z)").click();
h.run_steps(3);
for label in ["Name (A–Z)", "Name (Z–A)", "Recently updated", "Frequently used"] {
h.get_by_label(label);
}
h.get_by_label("Name (Z–A)").click();
h.run_steps(4);
let s = &h.state().as_ref().unwrap().settings;
assert_eq!((s.sort, s.sort_descending), (SortKey::Name, true));
h.get_by_value("Name (Z–A)").click();
h.run_steps(3);
h.get_by_label("Frequently used").click();
h.run_steps(4);
assert_eq!(h.state().as_ref().unwrap().settings.sort, SortKey::Frequent);
}
#[test]
fn opening_an_app_counts_and_frequently_used_puts_the_most_opened_first() {
use artcraft_launcher_ui_egui::settings::SortKey;
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(6);
h.state_mut().as_mut().unwrap().settings.sort = SortKey::Frequent;
h.run_steps(4);
// Installed apps in name order: Effect (updates), Light (Open), Photo (updates), Word (Open).
let opens: Vec<_> = h.get_all_by_label("Open").collect();
assert_eq!(opens.len(), 2);
opens[1].click(); // WordCraft
h.run_steps(3);
assert!(sent.borrow().contains(&Command::Launch { app: "wordcraft".into() }));
assert_eq!(h.state().as_ref().unwrap().settings.launches.get("wordcraft"), Some(&1));
h.run_steps(4);
let word = h.get_by_label("WordCraft details").rect();
let light = h.get_by_label("LightCraft details").rect();
assert!(word.min.y < light.min.y || (word.min.y == light.min.y && word.min.x < light.min.x), "{word:?} {light:?}");
}
#[test]
fn an_apps_page_is_a_card_in_the_middle_with_next_and_previous_arrows_and_closes_from_outside() {
use artcraft_launcher_core::details::AppDetails;
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(6);
let headline = |id: &str| AppDetails::by_id(id).unwrap().headline;
let showing = |h: &Harness<'_, Option<LauncherUi>>, id: &str| h.query_all_by_label_contains(headline(id)).count() >= 2;
h.get_by_label("PhotoCraft details").click();
h.run_steps(12);
assert!(showing(&h, "photocraft"));
// A card with a margin of window all round, not a panel against one edge.
let close = h.get_by_label("Close (Esc)").rect();
assert!(close.right() < 1180.0 - 24.0 && close.left() > 24.0, "{close:?}");
// Next goes to the following app in the grid's order (Word after Photo), and back again.
h.get_by_label("Next app").click();
h.run_steps(12);
assert!(showing(&h, "wordcraft"));
h.get_by_label("Previous app").click();
h.run_steps(12);
assert!(showing(&h, "photocraft"));
// A click on the card's own margin keeps it open (the card is 1116 wide, centred in 1180).
let margin = egui::pos2(36.0, 450.0);
h.hover_at(margin);
h.run_steps(2);
h.drag_at(margin);
h.run_steps(2);
h.drop_at(margin);
h.run_steps(12);
assert!(showing(&h, "photocraft"), "a click on the card closed it");
// A click on the dimmed window outside the card closes it.
h.hover_at(egui::pos2(10.0, 450.0));
h.run_steps(2);
h.drag_at(egui::pos2(10.0, 450.0));
h.run_steps(2);
h.drop_at(egui::pos2(10.0, 450.0));
h.run_steps(30);
assert!(!showing(&h, "photocraft"), "a click outside did not close it");
}