mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-11-02 18:07:04 +00:00
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:
committed by
skylar taylor-barrick
parent
e85a296426
commit
e00be806a1
+2
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
||||
|
||||
@@ -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.
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user