diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index db7b85a2..43e2ea59 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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) | diff --git a/assets/social/README.md b/assets/social/README.md index 328ba96b..ff071cfa 100644 --- a/assets/social/README.md +++ b/assets/social/README.md @@ -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/.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. diff --git a/assets/social/discord.svg b/assets/social/discord.svg new file mode 100644 index 00000000..1f82712b --- /dev/null +++ b/assets/social/discord.svg @@ -0,0 +1 @@ +Discord \ No newline at end of file diff --git a/assets/social/github.svg b/assets/social/github.svg new file mode 100644 index 00000000..66a07135 --- /dev/null +++ b/assets/social/github.svg @@ -0,0 +1 @@ +GitHub \ No newline at end of file diff --git a/assets/social/globe.svg b/assets/social/globe.svg new file mode 100644 index 00000000..0d3505e8 --- /dev/null +++ b/assets/social/globe.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/core/src/release.rs b/crates/core/src/release.rs index 1f2998da..f975d176 100644 --- a/crates/core/src/release.rs +++ b/crates/core/src/release.rs @@ -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 { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 93b837ca..91bd7f5a 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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, 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 { diff --git a/crates/ui-egui/src/bar.rs b/crates/ui-egui/src/bar.rs new file mode 100644 index 00000000..709379dd --- /dev/null +++ b/crates/ui-egui/src/bar.rs @@ -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) } +} diff --git a/crates/ui-egui/src/card_parts.rs b/crates/ui-egui/src/card_parts.rs new file mode 100644 index 00000000..ea6829af --- /dev/null +++ b/crates/ui-egui/src/card_parts.rs @@ -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 { + 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)); +} diff --git a/crates/ui-egui/src/details.rs b/crates/ui-egui/src/details.rs index 3f62e645..7d751c63 100644 --- a/crates/ui-egui/src/details.rs +++ b/crates/ui-egui/src/details.rs @@ -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, Option) { + 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)); } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7f6f1eb9..e425395b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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; - } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 869cc51d..b49f367f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 08e325d7..39b9e0ae 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 93e21926..fb8f523a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d15ab44e..54eadfbb 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9aeaac86..465b5142 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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ó diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 84a74f7d..8116866e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 バージョン diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 25d64838..07e12653 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv index 7857dd3d..6d9c41b6 100644 --- a/crates/ui-egui/src/i18n/pt-BR.tsv +++ b/crates/ui-egui/src/i18n/pt-BR.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 27b751b9..0dcf6a61 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Версия diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 524b69a4..111359f6 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -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. diff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv index 4e1ef143..ae0ed8aa 100644 --- a/crates/ui-egui/src/i18n/zh-CN.tsv +++ b/crates/ui-egui/src/i18n/zh-CN.tsv @@ -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 版本 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index eb79df57..d4cb8381 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -22,6 +22,36 @@ pub fn app(id: &str) -> Option> { }) } +/// 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> { + 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::(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> { "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> { 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); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3198378f..add7a5d8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/notes.rs b/crates/ui-egui/src/notes.rs index 371bacfd..c2734d24 100644 --- a/crates/ui-egui/src/notes.rs +++ b/crates/ui-egui/src/notes.rs @@ -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>, Vec>) = 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 { + 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 = 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 = (0..7).map(|_| Line::Bullet("change", None)).collect(); + assert_eq!(fold_at(&few), None); + assert_eq!(fold_at(&[]), None); + } } diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index 1ea79976..35d46668 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -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, /// Catalog ids of apps hidden from the main lists (`photocraft`, …). pub hidden: std::collections::BTreeSet, + /// How many times each app was opened from the launcher (catalog id → count), for "Frequently used". + pub launches: BTreeMap, } impl Settings { @@ -188,6 +192,7 @@ impl Default for Settings { notified: BTreeMap::new(), last_check: None, hidden: std::collections::BTreeSet::new(), + launches: BTreeMap::new(), } } } diff --git a/crates/ui-egui/src/settings_dialog.rs b/crates/ui-egui/src/settings_dialog.rs new file mode 100644 index 00000000..b2f909cf --- /dev/null +++ b/crates/ui-egui/src/settings_dialog.rs @@ -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; + } +} diff --git a/crates/ui-egui/src/sheet.rs b/crates/ui-egui/src/sheet.rs new file mode 100644 index 00000000..45b391f4 --- /dev/null +++ b/crates/ui-egui/src/sheet.rs @@ -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>, 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); +} diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index 421ddd19..a980c2d4 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -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::() - 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)"), } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1f5562eb..b66ddaf4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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(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(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(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(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(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 = 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)) } diff --git a/crates/ui-egui/tests/chinese.rs b/crates/ui-egui/tests/chinese.rs index ff644870..9f795ad3 100644 --- a/crates/ui-egui/tests/chinese.rs +++ b/crates/ui-egui/tests/chinese.rs @@ -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); diff --git a/crates/ui-egui/tests/french.rs b/crates/ui-egui/tests/french.rs index af9a9eb5..89a4f15d 100644 --- a/crates/ui-egui/tests/french.rs +++ b/crates/ui-egui/tests/french.rs @@ -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); } diff --git a/crates/ui-egui/tests/german.rs b/crates/ui-egui/tests/german.rs index eb80c393..3d71f653 100644 --- a/crates/ui-egui/tests/german.rs +++ b/crates/ui-egui/tests/german.rs @@ -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:?}"); } diff --git a/crates/ui-egui/tests/hungarian.rs b/crates/ui-egui/tests/hungarian.rs index a80a4eb6..6c1738d9 100644 --- a/crates/ui-egui/tests/hungarian.rs +++ b/crates/ui-egui/tests/hungarian.rs @@ -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); diff --git a/crates/ui-egui/tests/japanese.rs b/crates/ui-egui/tests/japanese.rs index b21b88cf..8a8f4d42 100644 --- a/crates/ui-egui/tests/japanese.rs +++ b/crates/ui-egui/tests/japanese.rs @@ -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); diff --git a/crates/ui-egui/tests/polish.rs b/crates/ui-egui/tests/polish.rs index 6293f431..e4ea4e41 100644 --- a/crates/ui-egui/tests/polish.rs +++ b/crates/ui-egui/tests/polish.rs @@ -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"); diff --git a/crates/ui-egui/tests/portuguese.rs b/crates/ui-egui/tests/portuguese.rs index e3eea7e3..1225efd9 100644 --- a/crates/ui-egui/tests/portuguese.rs +++ b/crates/ui-egui/tests/portuguese.rs @@ -101,7 +101,7 @@ fn harness(view: ViewMode) -> Harness<'static, Option> { 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); } diff --git a/crates/ui-egui/tests/russian.rs b/crates/ui-egui/tests/russian.rs index 204a84dd..c96bc2d6 100644 --- a/crates/ui-egui/tests/russian.rs +++ b/crates/ui-egui/tests/russian.rs @@ -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); } diff --git a/crates/ui-egui/tests/spanish.rs b/crates/ui-egui/tests/spanish.rs index 8f85180e..ceb1bad2 100644 --- a/crates/ui-egui/tests/spanish.rs +++ b/crates/ui-egui/tests/spanish.rs @@ -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"); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index a6cf16bd..fafa5cb7 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -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>) -> Vec { 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>>, 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>| { + 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>, 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"); +}