mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-11-02 18:07:04 +00:00
Add UI localisation (i18n) with a Russian catalog
Every user-visible string in the interface is now looked up from a
per-language catalog at render time instead of being compiled in as
English. English stays the source of truth and the lookup key; a missing
row falls back to English rather than failing.
- crates/ui-egui/src/i18n: `t`/`tl!` for literals, `tr_ctx` (one English
word needing different translations), `tr_id` (command-id keyed),
`trn`/`tn` (plurals), `fmt` ({name} placeholders filled after the
lookup) and `message`/`msg` (backend status and error text, translated
where it is shown). Split into lang.rs (the registry and locale tags),
detect.rs (the system language), translate.rs (lookups) and catalog.rs
(the TSV parser); mod.rs only declares them.
- ru.tsv: the whole interface in Russian, with East Slavic plural forms,
month abbreviations, a day-month-year date template and each app's
tagline. Product and app names (ArtCraft Launcher, Craft App Suite,
PhotoCraft…) stay in English.
- The language follows the system by default: ARTCRAFT_LAUNCHER_LOCALE,
then the OS's preferred UI languages through the sys-locale crate
(desktop only; LANGUAGE/LC_*/LANG on Linux and the BSDs, the preferred
languages on macOS, the user's UI languages on Windows), and `?lang=`
then `navigator.languages` on the web. Settings > Language overrides it.
- core stays pure and wasm-safe: `time::ago_kind` and `month_short_en`
let the UI render relative times and dates in the current language.
Ported onto main from the original PR (#28): every string main added
since (feed notices and alerts, the "using ArtCraft API and GitHub"
footer, window buttons, waiting and retry states, the install-cancelled
toasts, filter tooltips, package formats, the reveal label) goes through
the catalog, with Russian rows. Also changed from the original:
- `@msg` templates now match real messages: `Couldn't open {name}: {e}`
translates "Couldn't open PhotoCraft: …", the most specific template
wins. Rows for messages the launcher never sends (clipboard, recovery
data) are gone; rows for its install/update/remove toasts are added.
- The locale probes no longer spawn /usr/bin/defaults or reg.exe; the
small, dependency-free sys-locale crate asks the OS instead.
- Folded in from #34's framework fixes: catalog tests split rows with
trim_end (trim dropped the leading TAB, so plain rows were silently
skipped), the English plural fallback goes by n == 1, @plural rows take
1 to 3 forms, and Settings::language_label comes from the registry.
- Tests are registry-driven: placeholders, plural form counts and, for
every catalog marked complete, coverage of every tl!/t literal, every
tn plural key, each tagline and the package formats. A Russian UI test
renders the grid and table. Catalog warnings go to the log, not stderr.
- ru.tsv: "Launcher" and "Craft App Suite" stay in English; "лаунчер" is
spelled one way; "Verifying…" (Проверка целостности…) and "Discard
download" (Удалить скачанное) no longer read like "Checking…" and
"Cancel download"; the table's Installed/Latest headers are short
enough to fit; the reveal rows match the backend's "Show in …" labels.
- The toolbar drops its "Sort" caption when longer labels leave no room
for it, rather than drawing over the filters.
- Unused settings helpers and LANGUAGE_CODES were removed, and core's doc
comments now describe what the UI actually translates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,13 @@ hard square corners, 1 px hairlines instead of shadows, Archivo (620, 118 % widt
|
||||
and app names, Inter for body text, upper-case Geist Mono labels and buttons, and the site's
|
||||
colour tokens by name (`theme::Tokens`). Build new UI from `widgets.rs`; never hard-code a colour.
|
||||
|
||||
### Languages
|
||||
|
||||
Every user-facing string goes through `crates/ui-egui/src/i18n` (`tl!("…")` for literals, `fmt`
|
||||
for `{placeholders}`, `tn` for counts). English in the code is the lookup key; adding a string
|
||||
means adding its row to every catalog marked `complete_menus` (`ru.tsv`), which the i18n tests
|
||||
check. Product and app names (ArtCraft Launcher, Craft App Suite, PhotoCraft…) stay in English.
|
||||
|
||||
### Other rules
|
||||
|
||||
1. **Assets need attribution.** Every bundled asset gets a row in `ATTRIBUTION.md` (path, title,
|
||||
|
||||
Generated
+12
-2
@@ -334,6 +334,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
"sys-locale",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -849,7 +850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3944,7 +3945,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4370,6 +4371,15 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
|
||||
@@ -6,14 +6,68 @@ use std::rc::Rc;
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Origin, Source};
|
||||
use artcraft_launcher_core::release::{self, Release};
|
||||
use artcraft_launcher_core::{Arch, Os, Platform, SUITE, catalog, version};
|
||||
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
|
||||
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings, i18n};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
const SETTINGS_KEY: &str = "settings";
|
||||
|
||||
/// Tell the UI the browser's languages (`?lang=ru` in the address first, as `ARTCRAFT_LAUNCHER_LOCALE`
|
||||
/// does on the desktop) before the first frame, so the Automatic interface language follows the
|
||||
/// browser; and show the loading text in that language.
|
||||
fn detect_language() {
|
||||
let window = match web_sys::window() {
|
||||
Some(w) => w,
|
||||
None => return,
|
||||
};
|
||||
let document = match window.document() {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
let mut tags = Vec::new();
|
||||
|
||||
// ?lang=xx in the URL takes priority.
|
||||
if let Ok(href) = window.location().href()
|
||||
&& let Some(idx) = href.find('?')
|
||||
{
|
||||
for pair in href[idx + 1..].split('&') {
|
||||
if let Some((k, v)) = pair.split_once('=')
|
||||
&& k == "lang"
|
||||
&& !v.is_empty()
|
||||
{
|
||||
tags.push(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then the browser's preferred languages.
|
||||
let navigator = window.navigator();
|
||||
let langs: js_sys::Array = navigator.languages();
|
||||
for i in 0..langs.length() {
|
||||
if let Some(v) = langs.get(i).as_string() {
|
||||
tags.push(v);
|
||||
}
|
||||
}
|
||||
if let Some(lang) = navigator.language() {
|
||||
tags.push(lang);
|
||||
}
|
||||
i18n::set_system_locales(&tags);
|
||||
|
||||
// Screen readers and spell-check follow the page language: match <html lang>
|
||||
// to what was detected (`en` when the browser asks for nothing supported).
|
||||
if let Some(root) = document.document_element() {
|
||||
let _ = root.set_attribute("lang", i18n::system_lang().code());
|
||||
}
|
||||
|
||||
// Update loading text in the detected language.
|
||||
if let Some(el) = document.get_element_by_id("launcher_loading") {
|
||||
el.set_text_content(Some(i18n::tr(i18n::system_lang(), "Loading ArtCraft Launcher…")));
|
||||
}
|
||||
}
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Warn).ok();
|
||||
// Detect language before starting the UI.
|
||||
detect_language();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(canvas) = document.get_element_by_id("launcher_canvas").and_then(|c| c.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
|
||||
@@ -30,6 +30,8 @@ type Wake = Arc<OnceLock<Box<dyn Fn() + Send + Sync>>>;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||
// Detect system language in the background so the first frame can use it.
|
||||
artcraft_launcher_ui_egui::i18n::detect_system_lang_in_background();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("artcraft-launcher {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! The Craft App Suite: the twelve apps the launcher knows about.
|
||||
//!
|
||||
//! The list is data so a later version can fetch it from an API; [`SUITE`] is the built-in copy.
|
||||
//!
|
||||
//! Text here is English, the source the UI translates from: each `tagline` has a catalog row
|
||||
//! keyed by the app id (`tr_ctx(app.id, app.tagline)` in `ui-egui`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -29,7 +32,7 @@ pub struct CraftApp {
|
||||
pub id: &'static str,
|
||||
/// Display name (`PhotoCraft`).
|
||||
pub name: &'static str,
|
||||
/// What it's for, in a few words.
|
||||
/// What it's for, in a few words (English; the UI translates it by app id).
|
||||
pub tagline: &'static str,
|
||||
/// The familiar app it reimplements, for orientation.
|
||||
pub like: &'static str,
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
//!
|
||||
//! Every Craft App release names its files `<app>-<version>-<os>-<arch>[-portable].<ext>`, for
|
||||
//! example `photocraft-0.5.0-windows-x64.msi` or `photocraft-0.5.0-linux-aarch64.AppImage`.
|
||||
//!
|
||||
//! Labels are English. The UI translates [`PackageKind::label`]; OS and architecture names are
|
||||
//! shown as they are.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
+111
-10
@@ -1,4 +1,37 @@
|
||||
//! Just enough calendar to read GitHub's timestamps and show them, without a date crate.
|
||||
//!
|
||||
//! # Internationalisation
|
||||
//! [`format_ago`] and [`format_date`] are English-only fallbacks. Translatable UI must use
|
||||
//! [`ago_kind`] plus [`month_short_en`] (or [`civil_from_unix`] directly) and render through
|
||||
//! the UI crate's `tl!` / `trn` / `fmt` helpers, so this crate stays pure and wasm-safe with
|
||||
//! no dependency on `ui-egui`:
|
||||
//! - [`TimeAgo::JustNow`] → `tl!("just now")`
|
||||
//! - [`TimeAgo::Minutes(n)`] → `trn(n, "{n} min ago", "{n} min ago")`
|
||||
//! - [`TimeAgo::Hours(n)`] → `trn(n, "{n} h ago", "{n} h ago")`
|
||||
//! - [`TimeAgo::Yesterday`] → `tl!("yesterday")`
|
||||
//! - [`TimeAgo::Days(n)`] → `trn(n, "{n} day ago", "{n} days ago")`
|
||||
//! - [`TimeAgo::Date(secs)`] → `fmt(tl!("{month} {day}, {year}"), …)` with the month from
|
||||
//! [`month_short_en`] translated via `tr_ctx("month", …)` and `{day}` / `{year}` filled in.
|
||||
|
||||
/// What a relative timestamp means, without any language attached.
|
||||
///
|
||||
/// The UI translates each variant (`tl!` / `trn` / `fmt`); see the module docs. Counts are
|
||||
/// non-negative. [`TimeAgo::Date`] carries the original Unix time for the date fallback.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum TimeAgo {
|
||||
/// Less than a minute old (or in the future).
|
||||
JustNow,
|
||||
/// Minutes old (1–59).
|
||||
Minutes(u64),
|
||||
/// Hours old (1–23).
|
||||
Hours(u64),
|
||||
/// Between one and two days old.
|
||||
Yesterday,
|
||||
/// Days old (2–29).
|
||||
Days(u64),
|
||||
/// Thirty days or older: show the calendar date; carries the original Unix time.
|
||||
Date(i64),
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch for an RFC 3339 UTC timestamp such as `2026-10-08T14:52:50Z`.
|
||||
pub fn parse_rfc3339(text: &str) -> Option<i64> {
|
||||
@@ -61,23 +94,51 @@ pub fn civil_from_unix(secs: i64) -> (i64, u32, u32) {
|
||||
|
||||
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/// English three-letter month abbreviation for month `m` (1–12); `"?"` when out of range.
|
||||
/// Source key for `tr_ctx("month", …)` at the call site; never shown untranslated in
|
||||
/// non-English UI.
|
||||
pub fn month_short_en(m: u32) -> &'static str {
|
||||
if (1..=12).contains(&m) { MONTHS.get((m - 1) as usize).copied().unwrap_or("?") } else { "?" }
|
||||
}
|
||||
|
||||
/// `Oct 8, 2026`
|
||||
pub fn format_date(secs: i64) -> String {
|
||||
let (y, m, d) = civil_from_unix(secs);
|
||||
let month = MONTHS.get(m.saturating_sub(1) as usize).copied().unwrap_or("?");
|
||||
let month = month_short_en(m);
|
||||
format!("{month} {d}, {y}")
|
||||
}
|
||||
|
||||
/// `just now`, `5 min ago`, `3 h ago`, `yesterday`, `4 days ago`, or the date.
|
||||
pub fn format_ago(secs: i64, now: i64) -> String {
|
||||
/// Classify a timestamp for translation-neutral rendering (no allocation, no English).
|
||||
/// Thresholds match [`format_ago`]: <60s → [`TimeAgo::JustNow`], <1h → minutes,
|
||||
/// <1d → hours, <2d → yesterday, <30d → days, older → [`TimeAgo::Date`].
|
||||
pub fn ago_kind(secs: i64, now: i64) -> TimeAgo {
|
||||
let dt = now.saturating_sub(secs);
|
||||
match dt {
|
||||
i64::MIN..60 => "just now".into(),
|
||||
60..3600 => format!("{} min ago", dt / 60),
|
||||
3600..86_400 => format!("{} h ago", dt / 3600),
|
||||
86_400..172_800 => "yesterday".into(),
|
||||
172_800..2_592_000 => format!("{} days ago", dt / 86_400),
|
||||
_ => format_date(secs),
|
||||
if dt < 60 {
|
||||
TimeAgo::JustNow
|
||||
} else if dt < 3600 {
|
||||
TimeAgo::Minutes(u64::try_from(dt / 60).unwrap_or(0))
|
||||
} else if dt < 86_400 {
|
||||
TimeAgo::Hours(u64::try_from(dt / 3600).unwrap_or(0))
|
||||
} else if dt < 172_800 {
|
||||
TimeAgo::Yesterday
|
||||
} else if dt < 2_592_000 {
|
||||
TimeAgo::Days(u64::try_from(dt / 86_400).unwrap_or(0))
|
||||
} else {
|
||||
TimeAgo::Date(secs)
|
||||
}
|
||||
}
|
||||
|
||||
/// `just now`, `5 min ago`, `3 h ago`, `yesterday`, `4 days ago`, or the date.
|
||||
///
|
||||
/// English-only fallback; translatable UI should match on [`ago_kind`] instead.
|
||||
pub fn format_ago(secs: i64, now: i64) -> String {
|
||||
match ago_kind(secs, now) {
|
||||
TimeAgo::JustNow => "just now".into(),
|
||||
TimeAgo::Minutes(n) => format!("{n} min ago"),
|
||||
TimeAgo::Hours(n) => format!("{n} h ago"),
|
||||
TimeAgo::Yesterday => "yesterday".into(),
|
||||
TimeAgo::Days(n) => format!("{n} days ago"),
|
||||
TimeAgo::Date(s) => format_date(s),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,4 +169,44 @@ mod tests {
|
||||
assert_eq!(format_ago(now - 5 * 86_400, now), "5 days ago");
|
||||
assert_eq!(format_ago(now - 90 * 86_400, now), "Jul 10, 2026");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ago_kind_matches_the_english_fallback() {
|
||||
let now = 1_791_471_170;
|
||||
let cases: [(i64, TimeAgo, &str); 7] = [
|
||||
(5, TimeAgo::JustNow, "just now"),
|
||||
(600, TimeAgo::Minutes(10), "10 min ago"),
|
||||
(7200, TimeAgo::Hours(2), "2 h ago"),
|
||||
(90_000, TimeAgo::Yesterday, "yesterday"),
|
||||
(5 * 86_400, TimeAgo::Days(5), "5 days ago"),
|
||||
(90 * 86_400, TimeAgo::Date(now - 90 * 86_400), "Jul 10, 2026"),
|
||||
(-30, TimeAgo::JustNow, "just now"),
|
||||
];
|
||||
for (delta, kind, english) in cases {
|
||||
assert_eq!(ago_kind(now - delta, now), kind, "{english}");
|
||||
assert_eq!(format_ago(now - delta, now), english);
|
||||
}
|
||||
// Boundaries.
|
||||
assert_eq!(ago_kind(now - 59, now), TimeAgo::JustNow);
|
||||
assert_eq!(ago_kind(now - 60, now), TimeAgo::Minutes(1));
|
||||
assert_eq!(ago_kind(now - 3599, now), TimeAgo::Minutes(59));
|
||||
assert_eq!(ago_kind(now - 3600, now), TimeAgo::Hours(1));
|
||||
assert_eq!(ago_kind(now - 86_399, now), TimeAgo::Hours(23));
|
||||
assert_eq!(ago_kind(now - 86_400, now), TimeAgo::Yesterday);
|
||||
assert_eq!(ago_kind(now - 172_799, now), TimeAgo::Yesterday);
|
||||
assert_eq!(ago_kind(now - 172_800, now), TimeAgo::Days(2));
|
||||
assert_eq!(ago_kind(now - 2_591_999, now), TimeAgo::Days(29));
|
||||
assert!(matches!(ago_kind(now - 2_592_000, now), TimeAgo::Date(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_keys_cover_the_calendar() {
|
||||
let names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let m = u32::try_from(i + 1).unwrap_or(1);
|
||||
assert_eq!(month_short_en(m), *name);
|
||||
}
|
||||
assert_eq!(month_short_en(0), "?");
|
||||
assert_eq!(month_short_en(13), "?");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
serde = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# The OS's preferred UI languages, without spawning `defaults` or `reg.exe`.
|
||||
sys-locale = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
+22
-14
@@ -9,6 +9,7 @@ use artcraft_launcher_core::release::Release;
|
||||
use artcraft_launcher_core::status::AppStatus;
|
||||
use artcraft_launcher_core::{Platform, SUITE};
|
||||
|
||||
use crate::i18n::set_current;
|
||||
use crate::model::AppEntry;
|
||||
use crate::settings::{Filter, Settings, SortKey, ViewMode};
|
||||
use crate::theme::{self, Tokens};
|
||||
@@ -183,6 +184,7 @@ impl LauncherUi {
|
||||
|
||||
/// Non-drawing work: runs every frame and also while the window is hidden in the tray.
|
||||
pub fn logic(&mut self, ctx: &egui::Context, host: &mut dyn Host) {
|
||||
set_current(crate::i18n::Lang::from_pref(&self.settings.language));
|
||||
let options = BackendOptions { kinds: self.settings.preferred_kinds.clone(), prereleases: self.settings.prereleases };
|
||||
if self.sent_options.as_ref() != Some(&options) {
|
||||
self.backend.send(Command::Configure(options.clone()));
|
||||
@@ -287,17 +289,17 @@ impl LauncherUi {
|
||||
Ok(msg) => self.toast(ctx, msg, false),
|
||||
// The paused card says it all.
|
||||
Err(e) if e == PAUSED => {}
|
||||
Err(e) if e == CANCELLED && launcher => self.toast(ctx, "Update cancelled", false),
|
||||
Err(e) if e == CANCELLED && launcher => self.toast(ctx, tl!("Update cancelled"), false),
|
||||
// An install's own cancellations end as INSTALL_CANCELLED, so past the
|
||||
// download this is a removal called off while it waited for the installer.
|
||||
Err(e) if e == CANCELLED && matches!(last, Some(JobPhase::Waiting | JobPhase::Removing)) => {
|
||||
self.toast(ctx, "Uninstall cancelled", false);
|
||||
self.toast(ctx, tl!("Uninstall cancelled"), false);
|
||||
}
|
||||
Err(e) if e == CANCELLED => self.toast(ctx, "Download cancelled", false),
|
||||
Err(e) if e == CANCELLED => self.toast(ctx, tl!("Download cancelled"), false),
|
||||
Err(e) if e == INSTALL_CANCELLED && kept => {
|
||||
self.toast(ctx, "Install cancelled. The download is kept for the next try.", false);
|
||||
self.toast(ctx, tl!("Install cancelled. The download is kept for the next try."), false);
|
||||
}
|
||||
Err(e) if e == INSTALL_CANCELLED => self.toast(ctx, "Install cancelled", false),
|
||||
Err(e) if e == INSTALL_CANCELLED => self.toast(ctx, tl!("Install cancelled"), false),
|
||||
Err(e) => self.toast(ctx, e, true),
|
||||
}
|
||||
true
|
||||
@@ -337,9 +339,13 @@ impl LauncherUi {
|
||||
}
|
||||
if self.settings.notify {
|
||||
let (title, body) = match fresh.as_slice() {
|
||||
[(_, name, v)] => (format!("{name} {v} is available"), "Open ArtCraft Launcher to update.".to_string()),
|
||||
[(_, name, v)] => {
|
||||
let body: String = tl!("Open ArtCraft Launcher to update.").to_string();
|
||||
(crate::i18n::fmt(tl!("{name} {v} is available"), &[("name", name), ("v", v)]), body)
|
||||
}
|
||||
many => {
|
||||
(format!("{} updates are available", many.len()), many.iter().map(|(_, n, v)| format!("{n} {v}")).collect::<Vec<_>>().join(", "))
|
||||
let body: String = many.iter().map(|(_, n, v)| format!("{n} {v}")).collect::<Vec<_>>().join(", ");
|
||||
(crate::i18n::fmt(tl!("{count} updates are available"), &[("count", &many.len().to_string())]), body)
|
||||
}
|
||||
};
|
||||
host.notify(&title, &body);
|
||||
@@ -352,6 +358,8 @@ impl LauncherUi {
|
||||
/// Draws the launcher.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) {
|
||||
let ctx = ui.ctx().clone();
|
||||
// Set the current language for this frame based on settings.
|
||||
set_current(crate::i18n::Lang::from_pref(&self.settings.language));
|
||||
if !self.fonts_ready {
|
||||
self.fonts_ready = true;
|
||||
ctx.request_repaint();
|
||||
@@ -361,24 +369,24 @@ impl LauncherUi {
|
||||
theme::apply(&ctx, self.settings.theme);
|
||||
self.applied_theme = Some(self.settings.theme);
|
||||
}
|
||||
let t = Tokens::get(&ctx);
|
||||
let tokens = Tokens::get(&ctx);
|
||||
if self.entries.iter().any(|e| e.job.is_some()) || self.checking {
|
||||
ctx.request_repaint_after(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
// The bars' separator lines are the hairlines under the header and over the footer.
|
||||
egui::Panel::top("header")
|
||||
.frame(egui::Frame::new().fill(t.bg))
|
||||
.frame(egui::Frame::new().fill(tokens.bg))
|
||||
.exact_size(crate::views::HEADER_H)
|
||||
.resizable(false)
|
||||
.show(ui, |ui| self.header(ui, host.title_bar()));
|
||||
egui::Panel::bottom("footer")
|
||||
.frame(egui::Frame::new().fill(t.bg).inner_margin(egui::Margin::symmetric(20, 0)))
|
||||
.frame(egui::Frame::new().fill(tokens.bg).inner_margin(egui::Margin::symmetric(20, 0)))
|
||||
.exact_size(crate::views::FOOTER_H)
|
||||
.resizable(false)
|
||||
.show(ui, |ui| self.footer(ui));
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame::new().fill(t.bg).inner_margin(egui::Margin { left: 20, right: 20, top: 18, bottom: 0 }))
|
||||
.frame(egui::Frame::new().fill(tokens.bg).inner_margin(egui::Margin { left: 20, right: 20, top: 18, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
self.toolbar(ui);
|
||||
ui.add_space(18.0);
|
||||
@@ -392,7 +400,7 @@ impl LauncherUi {
|
||||
if ids.is_empty() {
|
||||
ui.add_space(40.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
crate::widgets::hud(ui, "No apps match", t.muted);
|
||||
crate::widgets::hud(ui, tl!("No apps match"), tokens.muted);
|
||||
});
|
||||
} else {
|
||||
match self.settings.view {
|
||||
@@ -489,7 +497,7 @@ impl LauncherUi {
|
||||
}
|
||||
}
|
||||
match (api, github) {
|
||||
(true, true) => Some(format!("{} and {}", Origin::ArtCraftApi.label(), Origin::GitHub.label())),
|
||||
(true, true) => Some(crate::i18n::fmt(tl!("{a} and {b}"), &[("a", Origin::ArtCraftApi.label()), ("b", Origin::GitHub.label())])),
|
||||
(true, false) => Some(Origin::ArtCraftApi.label().to_string()),
|
||||
(false, true) => Some(Origin::GitHub.label().to_string()),
|
||||
(false, false) => None,
|
||||
@@ -534,7 +542,7 @@ impl LauncherUi {
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::ZERO, accent);
|
||||
ui.add(egui::Label::new(egui::RichText::new(&toast.text).color(t.ink)).wrap());
|
||||
ui.add(egui::Label::new(egui::RichText::new(crate::i18n::msg(&toast.text)).color(t.ink)).wrap());
|
||||
});
|
||||
});
|
||||
if r.response.interact(egui::Sense::click()).clicked() {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Modal dialogs: release notes, uninstall confirmation, settings.
|
||||
|
||||
use crate::i18n::fmt;
|
||||
use artcraft_launcher_core::protocol::Command;
|
||||
use artcraft_launcher_core::{Os, PackageKind, time};
|
||||
use artcraft_launcher_core::{Os, PackageKind};
|
||||
use egui::{RichText, vec2};
|
||||
|
||||
use crate::app::{Dialog, Host, LauncherUi};
|
||||
use crate::settings::CheckEvery;
|
||||
use crate::settings::{CheckEvery, Settings};
|
||||
use crate::theme::{self, ThemeChoice, Tokens};
|
||||
use crate::widgets::{self, Cell, Kind};
|
||||
|
||||
@@ -45,9 +46,9 @@ impl LauncherUi {
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ok = widgets::button(ui, "OK", Kind::Primary, 90.0).clicked();
|
||||
ok = widgets::button(ui, tl!("OK"), Kind::Primary, 90.0).clicked();
|
||||
if let Some(link) = &alert.link
|
||||
&& widgets::button(ui, "Open link", Kind::Secondary, 110.0).on_hover_text(link.as_str()).clicked()
|
||||
&& widgets::button(ui, tl!("Open link"), Kind::Secondary, 110.0).on_hover_text(link.as_str()).clicked()
|
||||
{
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(link.clone()));
|
||||
}
|
||||
@@ -81,11 +82,11 @@ impl LauncherUi {
|
||||
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 date = release.published_at.map(|d| format!("Released {}", time::format_date(d))).unwrap_or_default();
|
||||
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("These notes weren't fetched (GitHub's request limit was reached). They're on the release page.");
|
||||
ui.label(tl!("These notes weren't fetched (GitHub's request limit was reached). They're on the release page."));
|
||||
} else {
|
||||
crate::notes::show(ui, &release.notes);
|
||||
}
|
||||
@@ -93,9 +94,9 @@ impl LauncherUi {
|
||||
ui.add_space(12.0);
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
widgets::hud_link(ui, "Open on GitHub", &release.html_url);
|
||||
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, "Close", Kind::Secondary, 90.0).clicked();
|
||||
close = widgets::button(ui, tl!("Close"), Kind::Secondary, 90.0).clicked();
|
||||
});
|
||||
});
|
||||
close
|
||||
@@ -105,28 +106,28 @@ impl LauncherUi {
|
||||
let Some(entry) = self.entry(id).cloned() else { return true };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_width(420.0);
|
||||
Self::title(ui, crate::icons::app(id), &format!("Uninstall {}?", entry.app.name), "");
|
||||
Self::title(ui, crate::icons::app(id), &fmt(tl!("Uninstall {name}?"), &[("name", entry.app.name)]), "");
|
||||
let how = match entry.installed.as_ref().map(|i| i.source) {
|
||||
Some(artcraft_launcher_core::InstallSource::MacApp) => "The app will be moved to the Trash. Your documents and settings stay.",
|
||||
Some(artcraft_launcher_core::InstallSource::MacApp) => tl!("The app will be moved to the Trash. Your documents and settings stay."),
|
||||
Some(artcraft_launcher_core::InstallSource::Msi) => {
|
||||
"Windows will remove it like any installed program. Your documents and settings stay."
|
||||
tl!("Windows will remove it like any installed program. Your documents and settings stay.")
|
||||
}
|
||||
Some(
|
||||
artcraft_launcher_core::InstallSource::Deb
|
||||
| artcraft_launcher_core::InstallSource::Rpm
|
||||
| artcraft_launcher_core::InstallSource::Pacman,
|
||||
) => "Your system's package manager will remove it, and may ask for your password. Your documents and settings stay.",
|
||||
_ => "The app will be removed. Your documents and settings stay.",
|
||||
) => tl!("Your system's package manager will remove it, and may ask for your password. Your documents and settings stay."),
|
||||
_ => tl!("The app will be removed. Your documents and settings stay."),
|
||||
};
|
||||
ui.label(RichText::new(how).color(t.muted));
|
||||
ui.add_space(20.0);
|
||||
let mut close = false;
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::button(ui, "Uninstall", Kind::Danger, 110.0).clicked() {
|
||||
if widgets::button(ui, tl!("Uninstall"), Kind::Danger, 110.0).clicked() {
|
||||
self.send(Command::Uninstall { app: id.to_string() });
|
||||
close = true;
|
||||
}
|
||||
if widgets::button(ui, "Cancel", Kind::Secondary, 90.0).clicked() {
|
||||
if widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
@@ -137,7 +138,7 @@ impl LauncherUi {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Wide enough for the community buttons and every social icon on one row.
|
||||
ui.set_width(520.0);
|
||||
Self::title(ui, Some(crate::icons::artcraft_mark()), "Settings", &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION")));
|
||||
Self::title(ui, Some(crate::icons::artcraft_mark()), tl!("Settings"), &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION")));
|
||||
let section = |ui: &mut egui::Ui, s: &str| {
|
||||
ui.add_space(14.0);
|
||||
widgets::hud(ui, s, t.faint);
|
||||
@@ -150,9 +151,23 @@ impl LauncherUi {
|
||||
});
|
||||
};
|
||||
|
||||
section(ui, "Updates");
|
||||
let mut lang_code = self.settings.language.clone();
|
||||
row(ui, tl!("Language"), &mut |ui| {
|
||||
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code)).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(), lang.name());
|
||||
}
|
||||
});
|
||||
});
|
||||
if lang_code != self.settings.language {
|
||||
self.settings.language = lang_code;
|
||||
crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.settings.language));
|
||||
}
|
||||
|
||||
section(ui, tl!("Updates"));
|
||||
let mut every = self.settings.check_every;
|
||||
row(ui, "Check for updates", &mut |ui| {
|
||||
row(ui, tl!("Check for updates"), &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());
|
||||
@@ -160,55 +175,55 @@ impl LauncherUi {
|
||||
});
|
||||
});
|
||||
self.settings.check_every = every;
|
||||
ui.checkbox(&mut self.settings.notify, "Notify me when an installed app has an update");
|
||||
ui.checkbox(&mut self.settings.prereleases, "Include pre-releases (betas and release candidates)");
|
||||
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 && self.platform.is_some_and(|p| p.os == Os::Linux) {
|
||||
section(ui, "Installing on Linux");
|
||||
section(ui, tl!("Installing on Linux"));
|
||||
let mut choice = self.settings.preferred_kinds.first().copied();
|
||||
row(ui, "Install new apps as", &mut |ui| {
|
||||
let label = choice.map_or("System package (recommended)", PackageKind::label);
|
||||
row(ui, tl!("Install new apps as"), &mut |ui| {
|
||||
let label = choice.map_or(tl!("System package (recommended)"), |k| crate::i18n::t(k.label()));
|
||||
egui::ComboBox::from_id_salt("kind").selected_text(label).width(210.0).show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut choice, None, "System package (recommended)");
|
||||
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), k.label());
|
||||
ui.selectable_value(&mut choice, Some(k), crate::i18n::t(k.label()));
|
||||
}
|
||||
});
|
||||
});
|
||||
self.settings.preferred_kinds = choice.into_iter().collect();
|
||||
ui.label(RichText::new("Updates always use the format an app was installed with.").size(12.0).color(t.muted));
|
||||
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, "Running in the background");
|
||||
section(ui, tl!("Running in the background"));
|
||||
if host.has_tray() {
|
||||
ui.checkbox(&mut self.settings.close_to_tray, "Keep running in the tray when the window is closed");
|
||||
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, "Show the window on the current desktop")
|
||||
.on_hover_text("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.");
|
||||
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, "Start ArtCraft Launcher when I log in").changed()
|
||||
&& 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, format!("Couldn't change the login item: {e}"), true);
|
||||
self.toast(&ctx, fmt(tl!("Couldn't change the login item: {e}"), &[("e", &e)]), true);
|
||||
}
|
||||
}
|
||||
|
||||
section(ui, "Community");
|
||||
ui.label(RichText::new("Questions, ideas and news from the ArtCraft team").color(t.muted));
|
||||
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(|ui| {
|
||||
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
|
||||
if widgets::button(ui, "Join the Discord", Kind::Primary, 0.0).on_hover_text(DISCORD_URL).clicked() {
|
||||
if widgets::button(ui, tl!("Join the Discord"), Kind::Primary, 0.0).on_hover_text(DISCORD_URL).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(DISCORD_URL));
|
||||
}
|
||||
if widgets::button(ui, "Website", Kind::Secondary, 0.0).on_hover_text(WEBSITE_URL).clicked() {
|
||||
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, "GitHub", Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() {
|
||||
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));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -217,7 +232,7 @@ impl LauncherUi {
|
||||
});
|
||||
});
|
||||
|
||||
section(ui, "Appearance");
|
||||
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 })));
|
||||
@@ -227,11 +242,11 @@ impl LauncherUi {
|
||||
ui.add_space(24.0);
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
if self.caps.manage_apps && widgets::button(ui, "Quit ArtCraft Launcher", Kind::Secondary, 0.0).clicked() {
|
||||
if self.caps.manage_apps && widgets::button(ui, tl!("Quit ArtCraft Launcher"), Kind::Secondary, 0.0).clicked() {
|
||||
host.quit();
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close = widgets::button(ui, "Done", Kind::Primary, 90.0).clicked();
|
||||
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
|
||||
});
|
||||
});
|
||||
close
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! TSV catalog parsing for UI localisation.
|
||||
//!
|
||||
//! Format (one entry per line):
|
||||
//! context<TAB>source<TAB>translation
|
||||
//! - Empty context = plain UI string (`tl!`, `t`).
|
||||
//! - `@id` context = keyed by command id (`tr_id`).
|
||||
//! - `@plural` context = plural forms: the English `one|other` key, and the language's forms
|
||||
//! separated by `|` (one form for a language whose nouns don't inflect for number, up to
|
||||
//! three: `one|few|many` for Russian).
|
||||
//! - `@msg` context = a status/error message, or a template whose `{placeholders}` match any text
|
||||
//! (`message`, `msg`).
|
||||
//! - Any other context = disambiguation for `tr_ctx`.
|
||||
//!
|
||||
//! Escapes in source/translation: `\t` `\n` `\\` (parsed once at load time).
|
||||
//! Placeholders `{name}` must appear in both source and translation.
|
||||
//! A trailing `…` (U+2026) in source must be preserved in translation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A parsed translation catalog for one language.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Catalog {
|
||||
/// Plain strings: English -> translation.
|
||||
plain: HashMap<String, String>,
|
||||
/// Contextual strings: context -> (English -> translation).
|
||||
contextual: HashMap<String, HashMap<String, String>>,
|
||||
/// Command-id keyed strings: id -> translation.
|
||||
by_id: HashMap<String, String>,
|
||||
/// Plural forms: English "one|other" -> the language's forms.
|
||||
plural: HashMap<String, Vec<String>>,
|
||||
/// Messages: English message or template -> translation.
|
||||
messages: HashMap<String, String>,
|
||||
/// The `@msg` rows with placeholders, matched against messages that have no exact row.
|
||||
templates: Vec<Template>,
|
||||
}
|
||||
|
||||
/// An `@msg` template, split into its literal text and placeholders.
|
||||
#[derive(Debug)]
|
||||
struct Template {
|
||||
/// Literal text around the placeholders: always one more than `names`.
|
||||
literals: Vec<String>,
|
||||
names: Vec<String>,
|
||||
translation: String,
|
||||
}
|
||||
|
||||
impl Template {
|
||||
fn new(source: &str, translation: String) -> Option<Self> {
|
||||
let mut literals = Vec::new();
|
||||
let mut names = Vec::new();
|
||||
let mut rest = source;
|
||||
while let Some(open) = rest.find('{') {
|
||||
let close = open + rest.get(open..)?.find('}')?;
|
||||
literals.push(rest.get(..open)?.to_string());
|
||||
names.push(rest.get(open + 1..close)?.to_string());
|
||||
rest = rest.get(close + 1..)?;
|
||||
}
|
||||
literals.push(rest.to_string());
|
||||
// Two placeholders in a row can't be told apart.
|
||||
if names.is_empty() || literals.iter().skip(1).rev().skip(1).any(String::is_empty) {
|
||||
return None;
|
||||
}
|
||||
Some(Self { literals, names, translation })
|
||||
}
|
||||
|
||||
/// The length of the template's literal text: the more of a message a template spells out,
|
||||
/// the better it fits.
|
||||
fn specificity(&self) -> usize {
|
||||
self.literals.iter().map(String::len).sum()
|
||||
}
|
||||
|
||||
/// The translation with this message's placeholder values filled in, if `s` fits the template.
|
||||
/// Each placeholder matches at least one character; between two literals it takes the
|
||||
/// shortest text that fits, the last one takes everything up to the final literal.
|
||||
fn apply(&self, s: &str) -> Option<String> {
|
||||
let (first, between) = self.literals.split_first()?;
|
||||
let mut rest = s.strip_prefix(first.as_str())?;
|
||||
let mut values = Vec::with_capacity(self.names.len());
|
||||
for (i, literal) in between.iter().enumerate() {
|
||||
let value = if i + 1 == between.len() {
|
||||
let value = rest.strip_suffix(literal.as_str())?;
|
||||
rest = "";
|
||||
value
|
||||
} else {
|
||||
// Skip one character, so the value isn't empty.
|
||||
let skip = rest.chars().next()?.len_utf8();
|
||||
let at = skip + rest.get(skip..)?.find(literal.as_str())?;
|
||||
let value = rest.get(..at)?;
|
||||
rest = rest.get(at + literal.len()..)?;
|
||||
value
|
||||
};
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
values.push(value);
|
||||
}
|
||||
let args: Vec<(&str, &str)> = self.names.iter().map(String::as_str).zip(values).collect();
|
||||
Some(super::translate::fmt(&self.translation, &args))
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Parse a catalog from TSV content. Malformed rows are logged and skipped.
|
||||
pub fn parse(source: &str) -> Self {
|
||||
let mut cat = Catalog::default();
|
||||
for (line_no, line) in source.lines().enumerate() {
|
||||
let line_no = line_no + 1;
|
||||
// Only the end: a plain row starts with its (empty) context and a TAB.
|
||||
let line = line.trim_end();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
let [context, source_text, translation] = parts.as_slice() else {
|
||||
log::warn!("i18n catalog: line {line_no}: expected 3 tab-separated fields, got {}", parts.len());
|
||||
continue;
|
||||
};
|
||||
let (context, source_text, translation) = (unescape(context), unescape(source_text), unescape(translation));
|
||||
let duplicate = match context.as_str() {
|
||||
"" => cat.plain.insert(source_text.clone(), translation).is_some(),
|
||||
"@id" => cat.by_id.insert(source_text.clone(), translation).is_some(),
|
||||
"@plural" => {
|
||||
let forms: Vec<String> = translation.split('|').map(str::to_string).collect();
|
||||
if forms.len() > 3 {
|
||||
log::warn!("i18n catalog: line {line_no}: @plural expects 1 to 3 forms separated by '|', got {}", forms.len());
|
||||
continue;
|
||||
}
|
||||
cat.plural.insert(source_text.clone(), forms).is_some()
|
||||
}
|
||||
"@msg" => {
|
||||
if source_text.contains('{') {
|
||||
match Template::new(&source_text, translation.clone()) {
|
||||
Some(t) => cat.templates.push(t),
|
||||
None => log::warn!("i18n catalog: line {line_no}: @msg template can't be matched: {source_text:?}"),
|
||||
}
|
||||
}
|
||||
cat.messages.insert(source_text.clone(), translation).is_some()
|
||||
}
|
||||
_ => cat.contextual.entry(context.clone()).or_default().insert(source_text.clone(), translation).is_some(),
|
||||
};
|
||||
if duplicate {
|
||||
log::warn!("i18n catalog: line {line_no}: duplicate key {context:?} {source_text:?}");
|
||||
}
|
||||
}
|
||||
// The most specific template first.
|
||||
cat.templates.sort_by_key(|t| std::cmp::Reverse(t.specificity()));
|
||||
cat
|
||||
}
|
||||
|
||||
/// Look up a plain string.
|
||||
pub fn plain(&self, s: &str) -> Option<&str> {
|
||||
self.plain.get(s).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Look up a contextual string.
|
||||
pub fn contextual(&self, context: &str, s: &str) -> Option<&str> {
|
||||
self.contextual.get(context)?.get(s).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Look up a command-id keyed string.
|
||||
pub fn id(&self, id: &str) -> Option<&str> {
|
||||
self.by_id.get(id).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Look up plural forms for an English "one|other" key; the form at `index` (0 = one).
|
||||
/// A row with fewer forms than the language's rule uses its last form for the rest.
|
||||
pub fn plural(&self, key: &str, index: usize) -> Option<&str> {
|
||||
let forms = self.plural.get(key)?;
|
||||
forms.get(index).or_else(|| forms.last()).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Translate a message: an exact `@msg` row, else the most specific template it fits.
|
||||
pub fn message(&self, s: &str) -> Option<String> {
|
||||
if let Some(t) = self.messages.get(s) {
|
||||
return Some(t.clone());
|
||||
}
|
||||
self.templates.iter().find_map(|t| t.apply(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Unescape `\t`, `\n`, `\\` in a TSV field.
|
||||
pub(super) fn unescape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('t') => out.push('\t'),
|
||||
Some('n') => out.push('\n'),
|
||||
Some('\\') => out.push('\\'),
|
||||
Some(other) => {
|
||||
out.push('\\');
|
||||
out.push(other);
|
||||
}
|
||||
None => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Working out the system language: the OS's preferred UI languages on the desktop, the
|
||||
//! browser's on the web.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::lang::{Lang, lang_from_tag};
|
||||
|
||||
/// The system language, worked out once.
|
||||
static SYSTEM: OnceLock<Lang> = OnceLock::new();
|
||||
|
||||
/// Work out the system language on a background thread, so the first frame doesn't wait for the
|
||||
/// locale probe. Call it at startup; a frame that needs the language before the probe finishes
|
||||
/// waits for it. A no-op on wasm.
|
||||
pub fn detect_system_lang_in_background() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let _ = std::thread::Builder::new().name("locale-probe".into()).spawn(|| {
|
||||
system_lang();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The system language (cached). English when it can't be determined.
|
||||
pub fn system_lang() -> Lang {
|
||||
// Tests drive the UI by its English labels whatever the developer's locale is.
|
||||
if cfg!(test) {
|
||||
return Lang::EN;
|
||||
}
|
||||
*SYSTEM.get_or_init(detect_system_lang)
|
||||
}
|
||||
|
||||
/// Fix the system language before anything asks for it (integration tests, which are compiled
|
||||
/// without `cfg(test)`, use it to stay independent of the developer's locale).
|
||||
pub fn set_test_lang(lang: Lang) {
|
||||
let _ = SYSTEM.set(lang);
|
||||
}
|
||||
|
||||
/// Tell the UI the system's preferred languages, most preferred first, where it can't find them
|
||||
/// itself: the web shell passes the browser's (`navigator.languages`). The first supported one is
|
||||
/// the system language (English if none is); call it before the first frame. Has no effect once
|
||||
/// the system language is known.
|
||||
pub fn set_system_locales<S: AsRef<str>>(tags: &[S]) {
|
||||
let lang = first_supported(tags.iter().map(AsRef::as_ref)).unwrap_or(Lang::EN);
|
||||
// Already set means already detected (or reported): the first answer stands.
|
||||
let _ = SYSTEM.set(lang);
|
||||
}
|
||||
|
||||
/// The first supported language in a preference list, most preferred first.
|
||||
pub(super) fn first_supported<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<Lang> {
|
||||
tags.into_iter().find_map(lang_from_tag)
|
||||
}
|
||||
|
||||
/// `ARTCRAFT_LAUNCHER_LOCALE` overrides everything; then the OS's preferred UI languages
|
||||
/// (`sys-locale`: `LANGUAGE`/`LC_*`/`LANG` on Linux and the BSDs, the preferred-languages list on
|
||||
/// macOS, which apps opened from the Finder need since they inherit no `LANG`, and the user's UI
|
||||
/// languages on Windows).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn detect_system_lang() -> Lang {
|
||||
if let Some(l) = std::env::var("ARTCRAFT_LAUNCHER_LOCALE").ok().and_then(|v| first_supported(v.split(':'))) {
|
||||
return l;
|
||||
}
|
||||
let locales: Vec<String> = sys_locale::get_locales().collect();
|
||||
first_supported(locales.iter().map(String::as_str)).unwrap_or(Lang::EN)
|
||||
}
|
||||
|
||||
/// The browser's languages arrive through [`set_system_locales`]; without them, English.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn detect_system_lang() -> Lang {
|
||||
Lang::EN
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! The language registry and matching locale tags to it.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::catalog::Catalog;
|
||||
use super::detect::system_lang;
|
||||
|
||||
/// One supported UI language.
|
||||
pub struct LangInfo {
|
||||
/// Canonical BCP 47 code (`en`, `ru`). Also the `interfaceLanguage` value.
|
||||
pub code: &'static str,
|
||||
/// The language's name in itself, shown in the Preferences dropdown.
|
||||
pub name: &'static str,
|
||||
/// Catalog file contents (empty for the built-in English).
|
||||
pub source: &'static str,
|
||||
/// Plural form index for a count (English: 0 = one, 1 = other; Russian: 0 = one, 1 = few,
|
||||
/// 2 = many).
|
||||
pub plural: fn(u64) -> usize,
|
||||
/// Number of plural forms the catalog's `@plural` rows carry (checked by tests).
|
||||
pub plural_forms: usize,
|
||||
/// Must the catalog cover every `tl!` literal? (checked by tests)
|
||||
pub complete_menus: bool,
|
||||
catalog: OnceLock<Catalog>,
|
||||
}
|
||||
|
||||
fn plural_one_other(n: u64) -> usize {
|
||||
usize::from(n != 1)
|
||||
}
|
||||
|
||||
/// Russian and Ukrainian integer counts: 1 (but not 11) is `one`, 2–4 (but not 12–14)
|
||||
/// `few`, everything else `many`.
|
||||
pub(super) fn plural_east_slavic(n: u64) -> usize {
|
||||
match n % 100 {
|
||||
11..=14 => 2,
|
||||
_ => match n % 10 {
|
||||
1 => 0,
|
||||
2..=4 => 1,
|
||||
_ => 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 2] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, plural_forms: 2, complete_menus: false, catalog: OnceLock::new() },
|
||||
LangInfo {
|
||||
code: "ru",
|
||||
name: "Русский",
|
||||
source: include_str!("ru.tsv"),
|
||||
plural: plural_east_slavic,
|
||||
plural_forms: 3,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
pub(super) fn catalog(&self) -> &Catalog {
|
||||
self.catalog.get_or_init(|| Catalog::parse(self.source))
|
||||
}
|
||||
}
|
||||
|
||||
/// A language the UI can be shown in (a handle into [`LANGUAGES`]).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Lang(pub(super) &'static LangInfo);
|
||||
|
||||
impl std::fmt::Debug for Lang {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Lang({})", self.0.code)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Lang {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0.code == other.0.code
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Lang {}
|
||||
|
||||
impl Lang {
|
||||
pub const EN: Lang = Lang(&LANGUAGES[0]);
|
||||
pub const RU: Lang = Lang(&LANGUAGES[1]);
|
||||
|
||||
pub fn code(self) -> &'static str {
|
||||
self.0.code
|
||||
}
|
||||
|
||||
/// A language by its exact code (case-insensitive).
|
||||
pub fn from_code(code: &str) -> Option<Lang> {
|
||||
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
|
||||
}
|
||||
|
||||
/// Resolve the `interfaceLanguage` preference: a language code, or `auto` (and anything
|
||||
/// unknown) to follow the system locale.
|
||||
pub fn from_pref(pref: &str) -> Lang {
|
||||
Lang::from_code(pref).unwrap_or_else(system_lang)
|
||||
}
|
||||
|
||||
/// Every registered language.
|
||||
pub fn all() -> impl Iterator<Item = Lang> {
|
||||
LANGUAGES.iter().map(Lang)
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
self.0.name
|
||||
}
|
||||
|
||||
/// Does this language's catalog claim to cover every `tl!` literal?
|
||||
pub fn complete_menus(self) -> bool {
|
||||
self.0.complete_menus
|
||||
}
|
||||
|
||||
/// Index into [`LANGUAGES`].
|
||||
pub(super) fn index(self) -> usize {
|
||||
LANGUAGES.iter().position(|l| l.code == self.0.code).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(super) fn catalog(self) -> &'static Catalog {
|
||||
self.0.catalog()
|
||||
}
|
||||
}
|
||||
|
||||
/// Candidate language codes for a locale tag, most specific first: `ru_RU.UTF-8` →
|
||||
/// `ru-ru`, `ru`.
|
||||
pub(super) fn candidates(tag: &str) -> Vec<String> {
|
||||
let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-").to_ascii_lowercase();
|
||||
let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect();
|
||||
(1..=parts.len()).rev().filter_map(|n| parts.get(..n)).map(|p| p.join("-")).collect()
|
||||
}
|
||||
|
||||
/// The registered language for a locale tag such as `ru_RU.UTF-8`, `ru-RU`; `None` if
|
||||
/// the language isn't supported. `C`/`POSIX` mean English.
|
||||
pub fn lang_from_tag(tag: &str) -> Option<Lang> {
|
||||
let cands = candidates(tag);
|
||||
if matches!(cands.last().map(String::as_str), Some("c" | "posix")) {
|
||||
return Some(Lang::EN);
|
||||
}
|
||||
cands.iter().find_map(|c| Lang::from_code(c))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! UI localisation for ArtCraft Launcher. Strings in code stay English and are the default
|
||||
//! lookup keys; a per-language catalog (`*.tsv`) maps them to display text at render time.
|
||||
//!
|
||||
//! # Adding a language
|
||||
//! 1. Add `xx.tsv` next to `ru.tsv` (copy its header; translate from the *meaning* of the
|
||||
//! English text, clean-room).
|
||||
//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule).
|
||||
//!
|
||||
//! That is all: the Preferences dropdown, the system-locale match and the catalog tests
|
||||
//! (parse, placeholders, plural forms, coverage of every `tl!` literal) pick it up from the
|
||||
//! registry.
|
||||
//!
|
||||
//! # Looking strings up
|
||||
//! - [`t`] / the `tl!` macro: a plain string in the language the UI is drawn in.
|
||||
//! [`tr`] takes the language explicitly; [`tr_ctx`] when one English word needs different
|
||||
//! translations.
|
||||
//! - [`tr_id`]: a command-id keyed string with the English label as fallback (menu items).
|
||||
//! - [`trn`]: plural-aware (`{n}` is filled in). [`fmt`]: fill `{name}` placeholders after [`tr`].
|
||||
//! - [`message`] / [`msg`]: a status or error message, translated only where it is shown.
|
||||
//! The message itself stays English (the control channel, tests read it); `@msg` catalog
|
||||
//! rows hold whole messages or templates such as `Couldn't open {name}: {e}`, whose
|
||||
//! placeholders match any text and are carried over into the translation.
|
||||
|
||||
mod catalog;
|
||||
mod detect;
|
||||
mod lang;
|
||||
mod translate;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use detect::{detect_system_lang_in_background, set_system_locales, set_test_lang, system_lang};
|
||||
pub use lang::{LANGUAGES, Lang, LangInfo, lang_from_tag};
|
||||
pub use translate::{current, fmt, has, message, msg, set_current, t, t_id, tn, tr, tr_ctx, tr_id, trn};
|
||||
@@ -0,0 +1,314 @@
|
||||
# Russian (ru) catalog for ArtCraft Launcher.
|
||||
# Format: context<TAB>source<TAB>translation
|
||||
# Empty context = plain UI string (tl!, t).
|
||||
# @id = command-id keyed (tr_id).
|
||||
# @plural = plural forms: one|other (English) or one|few|many (Russian).
|
||||
# @msg = status/error message template (message, msg).
|
||||
# Other context = disambiguation for tr_ctx.
|
||||
#
|
||||
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral,
|
||||
# international Russian, using terms Russian-speaking users of creative software know.
|
||||
#
|
||||
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
|
||||
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
|
||||
# name "Craft App Suite",
|
||||
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
|
||||
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft), "GitHub", "Discord", "Website".
|
||||
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
|
||||
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
|
||||
# {what}, {unit}, {real}, {e} must appear in translation.
|
||||
|
||||
Launcher Launcher
|
||||
Craft App Suite Craft App Suite
|
||||
Open Source Tools for Creatives, Makers, and Builders Инструменты с открытым кодом для творцов, мастеров и создателей
|
||||
Settings Настройки
|
||||
Check for updates Проверить обновления
|
||||
Grid Сетка
|
||||
Table Таблица
|
||||
Show apps as cards Показывать приложения карточками
|
||||
Show apps as a table Показывать приложения таблицей
|
||||
All Все
|
||||
Updates Обновления
|
||||
Not installed Не установлены
|
||||
Search apps Поиск приложений
|
||||
Sort Сортировка
|
||||
Suite order По классу приложений
|
||||
Name Имя
|
||||
Inspired by Вдохновлено
|
||||
Status Статус
|
||||
Installed version Установленная версия
|
||||
Latest version Последняя версия
|
||||
Newest release Новый релиз
|
||||
Discord Discord
|
||||
GitHub GitHub
|
||||
Website Веб-сайт
|
||||
Classic apps Классические приложения
|
||||
New apps Новые приложения
|
||||
NEW НОВОЕ
|
||||
Install Установить
|
||||
Update to {v} Обновить до {v}
|
||||
Open Открыть
|
||||
Download for {os} Скачать для {os}
|
||||
Download Скачать
|
||||
Coming soon Скоро
|
||||
Checking… Проверка…
|
||||
Not available Недоступно
|
||||
Offline Офлайн
|
||||
Downloading {done} of {total} Скачивание {done} из {total}
|
||||
Downloading… Скачивание…
|
||||
Reconnecting… Переподключение…
|
||||
Verifying… Проверка целостности…
|
||||
Installing, then restarting… Установка, затем перезапуск…
|
||||
Installing… Установка…
|
||||
Removing… Удаление…
|
||||
Pause download Приостановить скачивание
|
||||
Cancel download Отменить скачивание
|
||||
Resume download Продолжить скачивание
|
||||
Discard download Удалить скачанное
|
||||
Release notes Заметки к релизу
|
||||
Show in Finder Показать в Finder
|
||||
Show in Explorer Показать в Проводнике
|
||||
Show in Files Показать в файловом менеджере
|
||||
Reinstall Переустановить
|
||||
Uninstall… Удалить…
|
||||
All releases Все релизы
|
||||
ArtCraft Launcher {version} is available. Доступен ArtCraft Launcher {version}.
|
||||
Dismiss Закрыть
|
||||
Update and restart Обновить и перезапустить
|
||||
What's new Что нового
|
||||
No notes were published with this release. Заметки к этому релизу не публиковались.
|
||||
Released {date} Релиз {date}
|
||||
Open on GitHub Открыть на GitHub
|
||||
Close Закрыть
|
||||
ArtCraft Launcher {version} ArtCraft Launcher {version}
|
||||
Only when I ask Только по запросу
|
||||
Notify me when an installed app has an update Уведомлять, когда у установленного приложения есть обновление
|
||||
Include pre-releases (betas and release candidates) Включать предварительные релизы (бета и RC)
|
||||
Installing on Linux Установка в Linux
|
||||
Install new apps as Устанавливать новые приложения как
|
||||
System package (recommended) Системный пакет (рек.)
|
||||
Updates always use the format an app was installed with. Обновления всегда используют формат, с которым приложение было установлено.
|
||||
Running in the background Работа в фоне
|
||||
Keep running in the tray when the window is closed Оставаться в трее при закрытии окна
|
||||
Show the window on the current desktop Показывать окно на текущем рабочем столе
|
||||
Start ArtCraft Launcher when I log in Запускать ArtCraft Launcher при входе в систему
|
||||
Community Сообщество
|
||||
Questions, ideas and news from the ArtCraft team Вопросы, идеи и новости от команды ArtCraft
|
||||
Appearance Внешний вид
|
||||
Quit ArtCraft Launcher Выйти из ArtCraft Launcher
|
||||
Done Готово
|
||||
Uninstall {name}? Удалить {name}?
|
||||
The app will be moved to the Trash. Your documents and settings stay. Приложение будет перемещено в Корзину. Ваши документы и настройки сохранятся.
|
||||
Windows will remove it like any installed program. Your documents and settings stay. Windows удалит его как любую установленную программу. Ваши документы и настройки сохранятся.
|
||||
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Ваш системный менеджер пакетов удалит его и может запросить пароль. Ваши документы и настройки сохранятся.
|
||||
The app will be removed. Your documents and settings stay. Приложение будет удалено. Ваши документы и настройки сохранятся.
|
||||
Uninstall Удалить
|
||||
Cancel Отмена
|
||||
Checked {ago} Проверено {ago}
|
||||
just now только что
|
||||
yesterday вчера
|
||||
{month} {day}, {year} {day} {month} {year}
|
||||
|
||||
Automatically Автоматически
|
||||
Every Craft app Все приложения Craft
|
||||
Apps on this computer Приложения на этом компьютере
|
||||
Installed apps with a newer version Установленные приложения, у которых есть новая версия
|
||||
Apps you can install Приложения, которые можно установить
|
||||
Checked {ago} · using {sources} Проверено {ago} · через {sources}
|
||||
{a} and {b} {a} и {b}
|
||||
Join the Discord Присоединиться к Discord
|
||||
Learn more Подробнее
|
||||
Minimize Свернуть
|
||||
Maximize Развернуть
|
||||
Restore Восстановить
|
||||
New Новое
|
||||
OK ОК
|
||||
Open link Открыть ссылку
|
||||
Update Обновить
|
||||
{name} icon Значок {name}
|
||||
Loading ArtCraft Launcher… Загрузка ArtCraft Launcher…
|
||||
Cancel update Отменить обновление
|
||||
Stop waiting; the update can be started again later Не ждать; обновление можно будет запустить позже
|
||||
Download again Скачать заново
|
||||
Install cancelled Установка отменена
|
||||
Install cancelled. The download is kept for the next try. Установка отменена. Скачанный файл сохранён для следующей попытки.
|
||||
Update cancelled Обновление отменено
|
||||
Uninstall cancelled Удаление отменено
|
||||
Retry install Повторить установку
|
||||
Retry update to {version} Повторить обновление до {version}
|
||||
The download is still here. It's checked again, then installed. Скачанный файл сохранён. Он будет проверен ещё раз, затем установлен.
|
||||
Waiting for another app… Ожидание другого приложения…
|
||||
Waiting for other installs… Ожидание других установок…
|
||||
|
||||
# Package formats offered for download (PackageKind::label)
|
||||
Installer (.msi) Установщик (.msi)
|
||||
Portable (.zip) Портативная версия (.zip)
|
||||
Disk image (.dmg) Образ диска (.dmg)
|
||||
Debian package (.deb) Пакет Debian (.deb)
|
||||
RPM package (.rpm) Пакет RPM (.rpm)
|
||||
Flatpak bundle Пакет Flatpak
|
||||
Tarball (.tar.gz) Архив (.tar.gz)
|
||||
|
||||
# Contextual: each app's tagline, keyed by app id
|
||||
photocraft Image editing Редактирование изображений
|
||||
vectorcraft Vector illustration Векторная иллюстрация
|
||||
filmcraft Video editing, color and sound Монтаж видео, цветокоррекция и звук
|
||||
lightcraft Photo library and raw development Фотобиблиотека и проявка RAW
|
||||
pdfcraft Reading, organizing and protecting PDFs Чтение, упорядочивание и защита PDF
|
||||
effectcraft Motion graphics and visual effects Моушн-графика и визуальные эффекты
|
||||
designcraft Page layout and publishing Вёрстка и издательское дело
|
||||
wordcraft Writing and document design Тексты и оформление документов
|
||||
deckcraft Presentations and slide shows Презентации и слайд-шоу
|
||||
gridcraft Spreadsheets and data Электронные таблицы и данные
|
||||
cadcraft Computer-aided design and drafting Автоматизированное проектирование и черчение
|
||||
soundcraft Recording, editing and mixing audio Запись, редактирование и сведение звука
|
||||
|
||||
# Contextual: month abbreviations for date rendering
|
||||
month Jan янв.
|
||||
month Feb февр.
|
||||
month Mar март
|
||||
month Apr апр.
|
||||
month May май
|
||||
month Jun июнь
|
||||
month Jul июль
|
||||
month Aug авг.
|
||||
month Sep сент.
|
||||
month Oct окт.
|
||||
month Nov нояб.
|
||||
month Dec дек.
|
||||
Not checked yet Ещё не проверялось
|
||||
GitHub's hourly limit was reached; full details return in {min} min Достигнут часовой лимит GitHub; полные детали вернутся через {min} мин
|
||||
Installing for {platform} Установка для {platform}
|
||||
Downloads for {platform} Скачивание для {platform}
|
||||
No downloads for this system Нет загрузок для этой системы
|
||||
Join the ArtCraft community on Discord Присоединиться к сообществу ArtCraft в Discord
|
||||
ArtCraft on {name} ArtCraft в {name}
|
||||
Couldn't open {name}: {e} Не удалось открыть {name}: {e}
|
||||
Download cancelled Скачивание отменено
|
||||
Available to install Доступно для установки
|
||||
Available Доступно
|
||||
Installed · up to date Установлено · актуально
|
||||
Update available Доступно обновление
|
||||
Installed Установлено
|
||||
installed установлено
|
||||
Not available here Недоступно здесь
|
||||
Updating… Обновление…
|
||||
Update paused Обновление приостановлено
|
||||
Download paused Скачивание приостановлено
|
||||
{version} installed → {latest} released {version} установлено → {latest} выпущено
|
||||
Version {version} Версия {version}
|
||||
{installed} installed · latest {latest} {installed} установлено · последняя {latest}
|
||||
Version {version} installed Версия {version} установлена
|
||||
Version {latest} Версия {latest}
|
||||
No release published yet Релиз ещё не опубликован
|
||||
ArtCraft Launcher ArtCraft Launcher
|
||||
App Приложение
|
||||
Latest Последняя
|
||||
Released Релиз
|
||||
Auto Авто
|
||||
Language Язык
|
||||
Ascending По возрастанию
|
||||
Descending По убыванию
|
||||
Click to reverse the order Нажмите, чтобы изменить порядок
|
||||
Click to sort by this column Нажмите, чтобы сортировать по этому столбцу
|
||||
Like {name} Аналог {name}
|
||||
{done} of {total} {done} из {total}
|
||||
Downloading Скачивание
|
||||
Verifying checksum… Проверка контрольной суммы…
|
||||
Resume Продолжить
|
||||
{done} of {total} downloaded {done} из {total} скачано
|
||||
{done} downloaded Скачано {done}
|
||||
Checking for updates… Проверка обновлений…
|
||||
Downloads the new version, replaces this one and opens it again Скачивает новую версию, заменяет текущую и открывает её заново
|
||||
Couldn't check for updates: {f} Не удалось проверить обновления: {f}
|
||||
Website (coming soon) Веб-сайт (скоро)
|
||||
GitHub repository Репозиторий GitHub
|
||||
ArtCraft ArtCraft
|
||||
Update 1 app Обновить 1 приложение
|
||||
Update all ({count}) Обновить все ({count})
|
||||
⊞ Grid ⊞ Сетка
|
||||
☰ Table ☰ Таблица
|
||||
🔍 Search apps 🔍 Поиск приложений
|
||||
{name} {version} {name} {version}
|
||||
Match system Как в системе
|
||||
Dark Тёмная
|
||||
Light Светлая
|
||||
Full changelog Полный список изменений
|
||||
link ссылка
|
||||
No apps match Нет подходящих приложений
|
||||
Open ArtCraft Launcher to update. Откройте ArtCraft Launcher, чтобы обновить.
|
||||
{name} {v} is available Доступно обновление {name} {v}
|
||||
{count} updates are available Доступны обновления ({count})
|
||||
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Заметки не были загружены (достигнут лимит запросов GitHub). Они есть на странице релиза.
|
||||
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. Когда включено, открытие лаунчера из строки меню переносит его окно на текущий рабочий стол. Когда выключено, macOS переключает вас на рабочий стол, где находилось окно.
|
||||
Couldn't change the login item: {e} Не удалось изменить пункт автозагрузки: {e}
|
||||
|
||||
# Contextual: table column headers
|
||||
column App Приложение
|
||||
column Installed Текущая
|
||||
column Latest Последняя
|
||||
column Released Дата релиза
|
||||
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed Установлены
|
||||
|
||||
# Plural forms for Russian (one|few|many)
|
||||
@plural {n} app|{n} apps {n} приложение|{n} приложения|{n} приложений
|
||||
@plural {n} min ago|{n} min ago {n} минуту назад|{n} минуты назад|{n} минут назад
|
||||
@plural {n} h ago|{n} h ago {n} час назад|{n} часа назад|{n} часов назад
|
||||
@plural {n} day ago|{n} days ago {n} день назад|{n} дня назад|{n} дней назад
|
||||
|
||||
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
|
||||
@msg Couldn't open {name}: {e} Не удалось открыть {name}: {e}
|
||||
@msg Download cancelled Скачивание отменено
|
||||
@msg {name} updated to {version} Приложение {name} обновлено до {version}
|
||||
@msg {name} installed: {version} Приложение {name} установлено: {version}
|
||||
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) Приложение {name} обновлено до {version} (в этом релизе нет контрольных сумм, поэтому проверен только размер)
|
||||
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Приложение {name} установлено: {version} (в этом релизе нет контрольных сумм, поэтому проверен только размер)
|
||||
@msg {name} was removed Приложение {name} удалено
|
||||
@msg {name} is running. Quit it, then try again. Приложение {name} запущено. Закройте его и попробуйте снова.
|
||||
|
||||
# Command IDs for menu items (@id)
|
||||
# Format: @id<TAB>command_id<TAB>translation
|
||||
@id app.install Установить
|
||||
@id app.update Обновить до {v}
|
||||
@id app.open Открыть
|
||||
@id app.download Скачать для {os}
|
||||
@id app.reveal Показать в Finder
|
||||
@id app.reinstall Переустановить
|
||||
@id app.uninstall Удалить…
|
||||
@id app.notes Заметки к релизу
|
||||
@id app.website Веб-сайт
|
||||
@id app.github Репозиторий на GitHub
|
||||
@id app.releases Все релизы
|
||||
@id launcher.update Обновить и перезапустить
|
||||
@id launcher.download Скачать
|
||||
@id launcher.notes Что нового
|
||||
@id launcher.dismiss Закрыть
|
||||
@id settings.open Настройки
|
||||
@id settings.quit Выйти из ArtCraft Launcher
|
||||
@id view.grid Сетка
|
||||
@id view.table Таблица
|
||||
@id filter.all Все
|
||||
@id filter.installed Установленные
|
||||
@id filter.updates Обновления
|
||||
@id filter.notinstalled Не установленные
|
||||
@id sort.suite По классу приложений
|
||||
@id sort.name Имя
|
||||
@id sort.inspired Вдохновлено
|
||||
@id sort.status Статус
|
||||
@id sort.installed Установленная версия
|
||||
@id sort.latest Последняя версия
|
||||
@id sort.released Новый релиз
|
||||
@id help.discord Discord
|
||||
@id help.github GitHub
|
||||
@id help.website Веб-сайт
|
||||
@id update.check Проверить обновления
|
||||
@id update.check_auto Проверять обновления автоматически
|
||||
@id update.notify Уведомлять, когда у установленного приложения есть обновление
|
||||
@id update.prereleases Включать предварительные релизы (бета и RC)
|
||||
@id linux.preferred_kind Устанавливать новые приложения как
|
||||
@id tray.close_to_tray Оставаться в трее при закрытии окна
|
||||
@id tray.show_on_current_desktop Показывать окно на текущем рабочем столе
|
||||
@id tray.login_item Запускать ArtCraft Launcher при входе в систему
|
||||
@id appearance.theme Внешний вид
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 44.
|
@@ -0,0 +1,294 @@
|
||||
//! Tests for the i18n catalogs (format, duplicates, placeholders, plural forms, coverage) and the
|
||||
//! lookups.
|
||||
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
use super::catalog::{Catalog, unescape};
|
||||
use super::lang::plural_east_slavic;
|
||||
use super::{LANGUAGES, Lang, LangInfo, lang_from_tag, message, tr, trn};
|
||||
|
||||
/// Each catalog's rows as (line number, context, source, translation), split exactly as the
|
||||
/// parser does: `trim_end` only, since a plain row *starts* with a TAB.
|
||||
fn rows(lang: &LangInfo) -> Vec<(usize, &str, &str, &str)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, line) in lang.source.lines().enumerate() {
|
||||
let line = line.trim_end();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = line.split('\t').collect();
|
||||
let [context, source, translation] = fields.as_slice() else {
|
||||
panic!("{} line {}: expected 3 tab-separated fields: {line:?}", lang.code, i + 1);
|
||||
};
|
||||
out.push((i + 1, *context, *source, *translation));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn catalogs() -> impl Iterator<Item = &'static LangInfo> {
|
||||
LANGUAGES.iter().filter(|l| !l.source.is_empty())
|
||||
}
|
||||
|
||||
fn placeholders(template: &str) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
let mut rest = template;
|
||||
while let Some(open) = rest.find('{') {
|
||||
let Some(len) = rest[open..].find('}') else { break };
|
||||
let name = &rest[open + 1..open + len];
|
||||
if !name.is_empty() {
|
||||
out.insert(name.to_string());
|
||||
}
|
||||
rest = &rest[open + len + 1..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogs_have_rows_and_no_empty_translations() {
|
||||
for lang in catalogs() {
|
||||
let rows = rows(lang);
|
||||
assert!(rows.iter().filter(|r| r.1.is_empty()).count() > 50, "{}: plain rows were skipped", lang.code);
|
||||
for (line, _, source, translation) in rows {
|
||||
assert!(!source.is_empty() && !translation.is_empty(), "{} line {line}: empty field", lang.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_duplicate_keys() {
|
||||
for lang in catalogs() {
|
||||
let mut seen = HashSet::new();
|
||||
for (line, context, source, _) in rows(lang) {
|
||||
assert!(seen.insert((context, source)), "{} line {line}: duplicate key {context:?} {source:?}", lang.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_match_in_translation() {
|
||||
for lang in catalogs() {
|
||||
for (line, context, source, translation) in rows(lang) {
|
||||
if context == "@id" {
|
||||
continue; // keyed by id: the English label isn't in the row
|
||||
}
|
||||
// Translations may reorder placeholders, but must keep each one.
|
||||
let (src, tgt) = (placeholders(source), placeholders(translation));
|
||||
assert_eq!(src, tgt, "{} line {line}: placeholders differ: {source:?} -> {translation:?}", lang.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plural_forms_count_matches_language() {
|
||||
for lang in catalogs() {
|
||||
for (line, context, _, translation) in rows(lang) {
|
||||
if context == "@plural" {
|
||||
let forms = translation.split('|').count();
|
||||
assert_eq!(forms, lang.plural_forms, "{} line {line}: {translation:?}", lang.code);
|
||||
}
|
||||
}
|
||||
// The plural rule never asks for a form the catalog doesn't have.
|
||||
for n in 0..200 {
|
||||
assert!((lang.plural)(n) < lang.plural_forms, "{}: {n}", lang.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_msg_row_translates_its_own_text() {
|
||||
for lang in catalogs() {
|
||||
let cat = Catalog::parse(lang.source);
|
||||
for (line, context, source, translation) in rows(lang) {
|
||||
if context == "@msg" {
|
||||
assert_eq!(cat.message(&unescape(source)).as_deref(), Some(unescape(translation).as_str()), "{} line {line}", lang.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipsis_preserved() {
|
||||
for lang in catalogs() {
|
||||
for (line, context, source, translation) in rows(lang) {
|
||||
if context.starts_with('@') {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(source.ends_with('…'), translation.ends_with('…'), "{} line {line}: {source:?} -> {translation:?}", lang.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every string the UI looks up by literal (`tl!("…")`, `t("…")`) in the language of each catalog
|
||||
/// that claims to be complete.
|
||||
#[test]
|
||||
fn complete_catalogs_cover_every_ui_literal() {
|
||||
let sources = [
|
||||
include_str!("../app.rs"),
|
||||
include_str!("../dialogs.rs"),
|
||||
include_str!("../model.rs"),
|
||||
include_str!("../notes.rs"),
|
||||
include_str!("../settings.rs"),
|
||||
include_str!("../views.rs"),
|
||||
include_str!("../widgets.rs"),
|
||||
];
|
||||
let mut keys = BTreeSet::new();
|
||||
for source in sources {
|
||||
// `t("…")` calls, not `fmt(`, `.get(` and the like.
|
||||
for (i, _) in source.match_indices("t(\"") {
|
||||
if source[..i].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.') {
|
||||
continue;
|
||||
}
|
||||
let start = i + 3;
|
||||
if let Some(len) = source[start..].find('"') {
|
||||
keys.insert(&source[start..start + len]);
|
||||
}
|
||||
}
|
||||
for (i, _) in source.match_indices("tl!(\"") {
|
||||
let start = i + 5;
|
||||
if let Some(len) = source[start..].find('"') {
|
||||
keys.insert(&source[start..start + len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(keys.len() > 100, "found only {} UI literals", keys.len());
|
||||
// Plural keys: `tn(n, "one", "other")`.
|
||||
let mut plurals = BTreeSet::new();
|
||||
for source in sources {
|
||||
for (i, _) in source.match_indices("tn(") {
|
||||
if source[..i].ends_with(|c: char| c.is_alphanumeric() || c == '_') {
|
||||
continue;
|
||||
}
|
||||
let forms: Vec<&str> = source[i..].split('"').skip(1).step_by(2).take(2).collect();
|
||||
if let [one, other] = forms.as_slice() {
|
||||
plurals.insert(format!("{one}|{other}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(plurals.len() >= 4, "found only {plurals:?}");
|
||||
for lang in Lang::all().filter(|l| l.complete_menus()) {
|
||||
let missing: Vec<_> = keys.iter().filter(|k| !super::has(lang, k)).collect();
|
||||
assert!(missing.is_empty(), "{} is missing translations for {missing:#?}", lang.code());
|
||||
let missing: Vec<_> = plurals.iter().filter(|k| lang.catalog().plural(k, 0).is_none()).collect();
|
||||
assert!(missing.is_empty(), "{} is missing @plural rows for {missing:#?}", lang.code());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_catalogs_translate_every_tagline() {
|
||||
for lang in Lang::all().filter(|l| l.complete_menus()) {
|
||||
for app in &artcraft_launcher_core::SUITE {
|
||||
assert_ne!(super::tr_ctx(lang, app.id, app.tagline), app.tagline, "{}: {}", lang.code(), app.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_names_stay_in_english() {
|
||||
for lang in Lang::all() {
|
||||
for name in ["ArtCraft Launcher", "Craft App Suite", "Launcher"] {
|
||||
assert_eq!(tr(lang, name), name, "{}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_plural_rule_works() {
|
||||
for n in [1, 21, 31, 101] {
|
||||
assert_eq!(plural_east_slavic(n), 0, "{n}");
|
||||
}
|
||||
for n in [2, 3, 4, 22, 23, 24, 102] {
|
||||
assert_eq!(plural_east_slavic(n), 1, "{n}");
|
||||
}
|
||||
for n in [0, 5, 11, 12, 13, 14, 15, 100, 111] {
|
||||
assert_eq!(plural_east_slavic(n), 2, "{n}");
|
||||
}
|
||||
assert_eq!(trn(Lang::RU, 21, "{n} day ago", "{n} days ago"), "21 день назад");
|
||||
assert_eq!(trn(Lang::RU, 3, "{n} day ago", "{n} days ago"), "3 дня назад");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untranslated_plurals_fall_back_to_english_by_count() {
|
||||
// Russian's rule says "one" for 21, but English says "other".
|
||||
assert_eq!(trn(Lang::RU, 21, "{n} unknown item", "{n} unknown items"), "21 unknown items");
|
||||
assert_eq!(trn(Lang::RU, 1, "{n} unknown item", "{n} unknown items"), "1 unknown item");
|
||||
assert_eq!(trn(Lang::EN, 0, "{n} app", "{n} apps"), "0 apps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_tags_resolve() {
|
||||
assert_eq!(lang_from_tag("ru_RU.UTF-8"), Some(Lang::RU));
|
||||
assert_eq!(lang_from_tag("ru-RU"), Some(Lang::RU));
|
||||
assert_eq!(lang_from_tag("en-US"), Some(Lang::EN));
|
||||
assert_eq!(lang_from_tag("C"), Some(Lang::EN));
|
||||
assert_eq!(lang_from_tag("C.UTF-8"), Some(Lang::EN));
|
||||
assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN));
|
||||
assert_eq!(lang_from_tag("de-DE"), None);
|
||||
assert_eq!(lang_from_tag(""), None);
|
||||
assert_eq!(super::detect::first_supported(["de-DE", "ru-RU", "en-US"]), Some(Lang::RU));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_labels_come_from_the_registry() {
|
||||
use crate::settings::Settings;
|
||||
for lang in Lang::all() {
|
||||
assert_eq!(Settings::language_label(lang.code()), lang.name());
|
||||
}
|
||||
assert_eq!(Settings::language_label(Settings::LANGUAGE_AUTO), "Auto");
|
||||
assert_eq!(Settings::language_label("xx"), "Auto");
|
||||
}
|
||||
|
||||
// The parser.
|
||||
|
||||
#[test]
|
||||
fn parse_rows_of_every_kind() {
|
||||
let cat = Catalog::parse(
|
||||
"# comment\n\thello\tworld\nmenu\tOpen\tОткрыть\n@id\tfile.open\tОткрыть файл\n@plural\t{n} file|{n} files\t{n} файл|{n} файла|{n} файлов\n\
|
||||
@plural\t{n} app|{n} apps\t{n} 个应用\nbroken row\n",
|
||||
);
|
||||
assert_eq!(cat.plain("hello"), Some("world"));
|
||||
assert_eq!(cat.contextual("menu", "Open"), Some("Открыть"));
|
||||
assert_eq!(cat.id("file.open"), Some("Открыть файл"));
|
||||
assert_eq!(cat.plural("{n} file|{n} files", 0), Some("{n} файл"));
|
||||
assert_eq!(cat.plural("{n} file|{n} files", 2), Some("{n} файлов"));
|
||||
// One form serves every count.
|
||||
assert_eq!(cat.plural("{n} app|{n} apps", 1), Some("{n} 个应用"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescape_works() {
|
||||
assert_eq!(unescape("hello\\tworld"), "hello\tworld");
|
||||
assert_eq!(unescape("line1\\nline2"), "line1\nline2");
|
||||
assert_eq!(unescape("backslash\\\\here"), "backslash\\here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_templates_match_real_messages() {
|
||||
let cat = Catalog::parse(
|
||||
"@msg\tDownload cancelled\tСкачивание отменено\n\
|
||||
@msg\tCouldn't open {name}: {e}\tНе удалось открыть {name}: {e}\n\
|
||||
@msg\t{name} updated to {version}\t{name} обновлено до {version}\n\
|
||||
@msg\t{name} updated to {version} (no checksum)\t{name} обновлено до {version} (без суммы)\n\
|
||||
@msg\t{a}{b}\tnever\n",
|
||||
);
|
||||
assert_eq!(cat.message("Download cancelled").as_deref(), Some("Скачивание отменено"));
|
||||
assert_eq!(cat.message("Couldn't open PhotoCraft: no such file: x").as_deref(), Some("Не удалось открыть PhotoCraft: no such file: x"));
|
||||
assert_eq!(cat.message("PhotoCraft updated to 0.5.0").as_deref(), Some("PhotoCraft обновлено до 0.5.0"));
|
||||
// The template that spells out more of the message wins.
|
||||
assert_eq!(cat.message("PhotoCraft updated to 0.5.0 (no checksum)").as_deref(), Some("PhotoCraft обновлено до 0.5.0 (без суммы)"));
|
||||
// Placeholders never match nothing, and unknown messages aren't touched.
|
||||
assert_eq!(cat.message("Couldn't open : x"), None);
|
||||
assert_eq!(cat.message("Something else"), None);
|
||||
assert_eq!(message(Lang::EN, "Something else"), "Something else");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_catalogs_translate_package_formats() {
|
||||
use artcraft_launcher_core::PackageKind;
|
||||
let kinds =
|
||||
[PackageKind::Msi, PackageKind::PortableZip, PackageKind::Dmg, PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::TarGz];
|
||||
for lang in Lang::all().filter(|l| l.complete_menus()) {
|
||||
for kind in kinds {
|
||||
assert!(super::has(lang, kind.label()), "{}: {}", lang.code(), kind.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Looking strings up in the language the UI is drawn in.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use super::lang::{LANGUAGES, Lang};
|
||||
|
||||
/// Index into [`LANGUAGES`] of the language the UI is drawn in this frame.
|
||||
static CURRENT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Set the UI language for drawing (the shell calls this once per frame from the preference), so
|
||||
/// widgets can translate without every call site carrying a language around.
|
||||
pub fn set_current(lang: Lang) {
|
||||
CURRENT.store(lang.index(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The language the UI is drawn in.
|
||||
pub fn current() -> Lang {
|
||||
LANGUAGES.get(CURRENT.load(Ordering::Relaxed)).map_or(Lang::EN, Lang)
|
||||
}
|
||||
|
||||
/// Does `lang` have a catalog entry for this plain string? (English never does: it is the source.)
|
||||
pub fn has(lang: Lang, s: &str) -> bool {
|
||||
lang.catalog().plain(s).is_some()
|
||||
}
|
||||
|
||||
/// Translate an English UI string into the current language ([`tr`] with [`current`]).
|
||||
pub fn t(s: &str) -> &str {
|
||||
tr(current(), s)
|
||||
}
|
||||
|
||||
/// Translate an English UI string; unknown strings come back unchanged.
|
||||
pub fn tr(lang: Lang, s: &str) -> &str {
|
||||
lang.catalog().plain(s).unwrap_or(s)
|
||||
}
|
||||
|
||||
/// Like [`tr`], for an English string that needs a disambiguating `context`.
|
||||
pub fn tr_ctx<'a>(lang: Lang, context: &str, s: &'a str) -> &'a str {
|
||||
lang.catalog().contextual(context, s).unwrap_or_else(|| tr(lang, s))
|
||||
}
|
||||
|
||||
/// A string keyed by its command id, falling back to the translation of the English `label`.
|
||||
pub fn tr_id<'a>(lang: Lang, id: &str, label: &'a str) -> &'a str {
|
||||
lang.catalog().id(id).unwrap_or_else(|| tr(lang, label))
|
||||
}
|
||||
|
||||
/// [`tr_id`] in the current language.
|
||||
pub fn t_id<'a>(id: &str, label: &'a str) -> &'a str {
|
||||
tr_id(current(), id, label)
|
||||
}
|
||||
|
||||
/// Fill `{name}` placeholders. Unknown placeholders are left as written.
|
||||
pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
|
||||
let mut out = template.to_string();
|
||||
for (k, v) in args {
|
||||
out = out.replace(&format!("{{{k}}}"), v);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A plural-aware string: `one`/`other` are the English forms (with `{n}` where the count goes),
|
||||
/// and together (`one|other`) the key of the language's `@plural` row, which holds as many forms
|
||||
/// as the language has. Without a row, English: `one` for 1, `other` for everything else.
|
||||
pub fn trn(lang: Lang, n: u64, one: &str, other: &str) -> String {
|
||||
let idx = (lang.0.plural)(n);
|
||||
let english = if n == 1 { one } else { other };
|
||||
let text = lang.catalog().plural(&format!("{one}|{other}"), idx).unwrap_or(english);
|
||||
fmt(text, &[("n", &n.to_string())])
|
||||
}
|
||||
|
||||
/// [`trn`] in the current language.
|
||||
pub fn tn(n: u64, one: &str, other: &str) -> String {
|
||||
trn(current(), n, one, other)
|
||||
}
|
||||
|
||||
/// Translate a status or error message for display (see the module docs). Unknown messages, and
|
||||
/// the parts of a message a template's placeholders stand for (names, versions, reasons), stay
|
||||
/// as they are.
|
||||
pub fn message(lang: Lang, s: &str) -> String {
|
||||
lang.catalog().message(s).unwrap_or_else(|| s.to_string())
|
||||
}
|
||||
|
||||
/// [`message`] in the current language, remembering the last answer: the status bar asks for the
|
||||
/// same message every frame.
|
||||
pub fn msg(s: &str) -> String {
|
||||
static LAST: std::sync::Mutex<Option<(&'static str, String, String)>> = std::sync::Mutex::new(None);
|
||||
let lang = current();
|
||||
let mut last = LAST.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((code, src, out)) = last.as_ref()
|
||||
&& *code == lang.code()
|
||||
&& src == s
|
||||
{
|
||||
return out.clone();
|
||||
}
|
||||
let out = message(lang, s);
|
||||
*last = Some((lang.code(), s.to_string(), out.clone()));
|
||||
out
|
||||
}
|
||||
@@ -8,8 +8,17 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
/// Translate an English UI string into the current language (see [`i18n`]).
|
||||
#[macro_export]
|
||||
macro_rules! tl {
|
||||
($s:literal) => {
|
||||
$crate::i18n::t($s)
|
||||
};
|
||||
}
|
||||
|
||||
mod app;
|
||||
mod dialogs;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod model;
|
||||
mod notes;
|
||||
@@ -19,4 +28,8 @@ mod views;
|
||||
mod widgets;
|
||||
|
||||
pub use app::{Host, LauncherUi, TitleBar};
|
||||
pub use i18n::{
|
||||
Lang, LangInfo, current, detect_system_lang_in_background, fmt, has, lang_from_tag, message, msg, set_current, set_system_locales, system_lang,
|
||||
t, t_id, tn, tr, tr_ctx, tr_id, trn,
|
||||
};
|
||||
pub use settings::Settings;
|
||||
|
||||
+18
-16
@@ -6,6 +6,8 @@ use artcraft_launcher_core::release::Release;
|
||||
use artcraft_launcher_core::status::{self, AppStatus, Latest};
|
||||
use artcraft_launcher_core::{CraftApp, Installed};
|
||||
|
||||
use crate::i18n::t;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppEntry {
|
||||
pub app: &'static CraftApp,
|
||||
@@ -55,7 +57,7 @@ impl AppEntry {
|
||||
}
|
||||
|
||||
pub fn installed_label(&self) -> Option<String> {
|
||||
self.installed.as_ref().map(|i| i.version.as_ref().map_or_else(|| "installed".to_string(), ToString::to_string))
|
||||
self.installed.as_ref().map(|i| i.version.as_ref().map_or_else(|| t("installed").to_string(), ToString::to_string))
|
||||
}
|
||||
|
||||
pub fn latest_label(&self) -> Option<String> {
|
||||
@@ -80,27 +82,27 @@ pub enum Tone {
|
||||
Faint,
|
||||
}
|
||||
|
||||
pub fn status_text(status: &AppStatus, manage_apps: bool) -> (&'static str, Tone) {
|
||||
pub fn status_text(status: &AppStatus, manage_apps: bool) -> (String, Tone) {
|
||||
match status {
|
||||
AppStatus::Checking => ("Checking…", Tone::Faint),
|
||||
AppStatus::Available if manage_apps => ("Available to install", Tone::Info),
|
||||
AppStatus::Available => ("Available", Tone::Info),
|
||||
AppStatus::UpToDate => ("Installed · up to date", Tone::Good),
|
||||
AppStatus::UpdateAvailable => ("Update available", Tone::Warn),
|
||||
AppStatus::Installed => ("Installed", Tone::Neutral),
|
||||
AppStatus::Unavailable => ("Not available here", Tone::Faint),
|
||||
AppStatus::NoRelease => ("Coming soon", Tone::Faint),
|
||||
AppStatus::Checking => (t("Checking…").to_string(), Tone::Faint),
|
||||
AppStatus::Available if manage_apps => (t("Available to install").to_string(), Tone::Info),
|
||||
AppStatus::Available => (t("Available").to_string(), Tone::Info),
|
||||
AppStatus::UpToDate => (t("Installed · up to date").to_string(), Tone::Good),
|
||||
AppStatus::UpdateAvailable => (t("Update available").to_string(), Tone::Warn),
|
||||
AppStatus::Installed => (t("Installed").to_string(), Tone::Neutral),
|
||||
AppStatus::Unavailable => (t("Not available here").to_string(), Tone::Faint),
|
||||
AppStatus::NoRelease => (t("Coming soon").to_string(), Tone::Faint),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`status_text`], but saying so when a download is paused or reconnecting.
|
||||
pub fn entry_status_text(e: &AppEntry, status: &AppStatus, manage_apps: bool) -> (&'static str, Tone) {
|
||||
pub fn entry_status_text(e: &AppEntry, status: &AppStatus, manage_apps: bool) -> (String, Tone) {
|
||||
match (e.job, e.paused) {
|
||||
(Some(JobPhase::Reconnecting { .. }), _) => ("Reconnecting…", Tone::Warn),
|
||||
(Some(JobPhase::Downloading { .. }), _) if status.is_installed() => ("Updating…", Tone::Info),
|
||||
(Some(JobPhase::Downloading { .. }), _) => ("Downloading…", Tone::Info),
|
||||
(None, Some(_)) if status.is_installed() => ("Update paused", Tone::Warn),
|
||||
(None, Some(_)) => ("Download paused", Tone::Warn),
|
||||
(Some(JobPhase::Reconnecting { .. }), _) => (t("Reconnecting…").to_string(), Tone::Warn),
|
||||
(Some(JobPhase::Downloading { .. }), _) if status.is_installed() => (t("Updating…").to_string(), Tone::Info),
|
||||
(Some(JobPhase::Downloading { .. }), _) => (t("Downloading…").to_string(), Tone::Info),
|
||||
(None, Some(_)) if status.is_installed() => (t("Update paused").to_string(), Tone::Warn),
|
||||
(None, Some(_)) => (t("Download paused").to_string(), Tone::Warn),
|
||||
_ => status_text(status, manage_apps),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(crate) fn classify(line: &str) -> Line<'_> {
|
||||
pub(crate) fn show(ui: &mut Ui, notes: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if notes.trim().is_empty() {
|
||||
ui.label(RichText::new("No notes were published with this release.").color(t.muted));
|
||||
ui.label(RichText::new(tl!("No notes were published with this release.")).color(t.muted));
|
||||
return;
|
||||
}
|
||||
for line in notes.lines() {
|
||||
@@ -52,7 +52,7 @@ pub(crate) fn show(ui: &mut Ui, notes: &str) {
|
||||
ui.label(RichText::new("•").color(t.faint));
|
||||
ui.label(RichText::new(text).color(t.ink));
|
||||
if let Some(url) = link {
|
||||
let short = url.rsplit('/').next().map_or_else(|| "link".to_string(), |n| format!("#{n}"));
|
||||
let short = url.rsplit('/').next().map_or_else(|| tl!("link").to_string(), |n| format!("#{n}"));
|
||||
ui.hyperlink_to(RichText::new(short).size(12.0), url);
|
||||
}
|
||||
});
|
||||
@@ -60,7 +60,7 @@ pub(crate) fn show(ui: &mut Ui, notes: &str) {
|
||||
Line::Text(text) => {
|
||||
if let Some(url) = text.strip_prefix("**Full Changelog**: ") {
|
||||
ui.add_space(6.0);
|
||||
ui.hyperlink_to("Full changelog", url.trim());
|
||||
ui.hyperlink_to(tl!("Full changelog"), url.trim());
|
||||
} else {
|
||||
ui.label(RichText::new(text).color(t.muted));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::BTreeMap;
|
||||
use artcraft_launcher_core::PackageKind;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::i18n::t;
|
||||
use crate::theme::ThemeChoice;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -62,10 +63,10 @@ impl CheckEvery {
|
||||
/// mean "automatically" too.
|
||||
pub const ALL: [Self; 2] = [Self::Hour, Self::Never];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
pub fn label(self) -> String {
|
||||
match self {
|
||||
Self::Hour | Self::FourHours | Self::Day => "Automatically",
|
||||
Self::Never => "Only when I ask",
|
||||
Self::Hour | Self::FourHours | Self::Day => t("Automatically").to_string(),
|
||||
Self::Never => t("Only when I ask").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +88,8 @@ pub struct Settings {
|
||||
pub sort: SortKey,
|
||||
pub sort_descending: bool,
|
||||
pub theme: ThemeChoice,
|
||||
/// Interface language: `auto` (follow the system) or a language code from the registry.
|
||||
pub language: String,
|
||||
/// Closing the window keeps the launcher in the tray (menu bar) instead of quitting.
|
||||
pub close_to_tray: bool,
|
||||
/// macOS: showing the window brings it to the current desktop (Space) instead of switching to
|
||||
@@ -106,6 +109,18 @@ pub struct Settings {
|
||||
pub last_check: Option<i64>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Machine-readable code for "follow the system language".
|
||||
pub const LANGUAGE_AUTO: &'static str = "auto";
|
||||
|
||||
/// Display name for a language `code`: a language's name in itself, from the registry
|
||||
/// ([`LANGUAGES`](crate::i18n::LANGUAGES)), or "Auto" for `auto` and unknown codes, like
|
||||
/// [`Lang::from_pref`](crate::i18n::Lang::from_pref).
|
||||
pub fn language_label(code: &str) -> String {
|
||||
crate::i18n::Lang::from_code(code).map_or_else(|| t("Auto").to_string(), |lang| lang.name().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -114,6 +129,7 @@ impl Default for Settings {
|
||||
sort: SortKey::Suite,
|
||||
sort_descending: false,
|
||||
theme: ThemeChoice::System,
|
||||
language: "auto".to_string(),
|
||||
close_to_tray: true,
|
||||
show_on_current_desktop: true,
|
||||
check_every: CheckEvery::Hour,
|
||||
|
||||
@@ -20,11 +20,14 @@ pub enum ThemeChoice {
|
||||
impl ThemeChoice {
|
||||
pub const ALL: [Self; 3] = [Self::System, Self::Dark, Self::Light];
|
||||
|
||||
/// The English key translated at draw time into the current UI language
|
||||
/// (the shell sets it per frame via `i18n::set_current`). Same-crate lookup,
|
||||
/// pure and wasm-safe; no new dependency. Call sites stay unchanged.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "Match system",
|
||||
Self::Dark => "Dark",
|
||||
Self::Light => "Light",
|
||||
Self::System => crate::i18n::t("Match system"),
|
||||
Self::Dark => crate::i18n::t("Dark"),
|
||||
Self::Light => crate::i18n::t("Light"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+189
-122
@@ -8,11 +8,35 @@ use artcraft_launcher_core::{AppGroup, SUITE, time};
|
||||
use egui::{Color32, CornerRadius, FontId, Rect, RichText, Sense, Stroke, Ui, vec2};
|
||||
|
||||
use crate::app::{Dialog, LauncherUi, TitleBar};
|
||||
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};
|
||||
|
||||
/// Translate a relative timestamp ("just now", "5 min ago", …) into the current language.
|
||||
fn ago_text(secs: i64, now: i64) -> String {
|
||||
match time::ago_kind(secs, now) {
|
||||
time::TimeAgo::JustNow => t("just now").to_string(),
|
||||
time::TimeAgo::Minutes(n) => tn(n, "{n} min ago", "{n} min ago"),
|
||||
time::TimeAgo::Hours(n) => tn(n, "{n} h ago", "{n} h ago"),
|
||||
time::TimeAgo::Yesterday => t("yesterday").to_string(),
|
||||
time::TimeAgo::Days(n) => tn(n, "{n} day ago", "{n} days ago"),
|
||||
time::TimeAgo::Date(secs) => {
|
||||
let (y, m, d) = time::civil_from_unix(secs);
|
||||
let month = tr_ctx(current(), "month", time::month_short_en(m));
|
||||
fmt(tl!("{month} {day}, {year}"), &[("month", month), ("day", &d.to_string()), ("year", &y.to_string())])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate an absolute date ("Oct 8, 2026" → "8 окт. 2026") into the current language.
|
||||
pub(crate) fn date_text(secs: i64) -> String {
|
||||
let (y, m, d) = time::civil_from_unix(secs);
|
||||
let month = tr_ctx(current(), "month", time::month_short_en(m));
|
||||
fmt(tl!("{month} {day}, {year}"), &[("month", month), ("day", &d.to_string()), ("year", &y.to_string())])
|
||||
}
|
||||
|
||||
/// What the main button of an app does.
|
||||
enum Primary {
|
||||
Install,
|
||||
@@ -56,31 +80,33 @@ impl LauncherUi {
|
||||
// The ArtCraft wordmark exactly as supplied, in its own cell.
|
||||
let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);
|
||||
let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());
|
||||
egui::Image::new(crate::icons::artcraft_logo(t.dark)).alt_text("ArtCraft").paint_at(ui, Rect::from_center_size(cell.center(), logo));
|
||||
egui::Image::new(crate::icons::artcraft_logo(t.dark)).alt_text(tl!("ArtCraft")).paint_at(ui, Rect::from_center_size(cell.center(), logo));
|
||||
widgets::rule(ui, h);
|
||||
ui.add_space(16.0);
|
||||
widgets::tag(ui, "Launcher", t.invert_bg, t.invert_fg);
|
||||
widgets::tag(ui, tl!("Launcher"), t.invert_bg, t.invert_fg);
|
||||
ui.add_space(12.0);
|
||||
widgets::hud(ui, "Craft App Suite", t.muted);
|
||||
widgets::hud(ui, tl!("Craft App Suite"), t.muted);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if bar == TitleBar::Custom {
|
||||
widgets::window_buttons(ui, h);
|
||||
}
|
||||
if widgets::nav_icon(ui, "⚙", "Settings", h, false).clicked() {
|
||||
if widgets::nav_icon(ui, "⚙", tl!("Settings"), h, false).clicked() {
|
||||
self.dialog = Some(Dialog::Settings);
|
||||
}
|
||||
if widgets::nav_icon(ui, "⟳", "Check for updates", h, self.checking).clicked() {
|
||||
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, "Discord", "Join the ArtCraft community on Discord", h, false).clicked() {
|
||||
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(artcraft_launcher_core::catalog::DISCORD_URL));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
widgets::rule(ui, h);
|
||||
ui.add_space(6.0);
|
||||
// Right to left: Table, then Grid, so they read Grid · Table.
|
||||
for (mode, label, tip) in [(ViewMode::Table, "Table", "Show apps as a table"), (ViewMode::Grid, "Grid", "Show apps as cards")] {
|
||||
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;
|
||||
}
|
||||
@@ -100,10 +126,10 @@ impl LauncherUi {
|
||||
.inner_margin(egui::Margin::symmetric(16, 10))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::tag(ui, "Update", t.accent, Color32::WHITE);
|
||||
widgets::tag(ui, tl!("Update"), t.accent, Color32::WHITE);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(format!("ArtCraft Launcher {} is available.", r.version_label()))
|
||||
RichText::new(fmt(tl!("ArtCraft Launcher {version} is available."), &[("version", &r.version_label())]))
|
||||
.family(theme::medium())
|
||||
.color(t.ink_strong),
|
||||
);
|
||||
@@ -112,17 +138,20 @@ impl LauncherUi {
|
||||
let (fraction, caption) = match phase {
|
||||
JobPhase::Downloading { done, total } if total > 0 => (
|
||||
Some(done as f32 / total as f32),
|
||||
format!("Downloading {} of {}", widgets::human_bytes(done), widgets::human_bytes(total)),
|
||||
fmt(
|
||||
tl!("Downloading {done} of {total}"),
|
||||
&[("done", &widgets::human_bytes(done)), ("total", &widgets::human_bytes(total))],
|
||||
),
|
||||
),
|
||||
JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. } => (None, "Downloading…".to_string()),
|
||||
JobPhase::Verifying => (None, "Verifying…".to_string()),
|
||||
JobPhase::Waiting => (None, "Waiting for other installs…".to_string()),
|
||||
JobPhase::Installing | JobPhase::Removing => (None, "Installing, then restarting…".to_string()),
|
||||
JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. } => (None, tl!("Downloading…").to_string()),
|
||||
JobPhase::Verifying => (None, tl!("Verifying…").to_string()),
|
||||
JobPhase::Waiting => (None, tl!("Waiting for other installs…").to_string()),
|
||||
JobPhase::Installing | JobPhase::Removing => (None, tl!("Installing, then restarting…").to_string()),
|
||||
};
|
||||
// Another app's install can hang on a password prompt: the wait can be called off.
|
||||
if phase == JobPhase::Waiting
|
||||
&& widgets::glyph_button(ui, widgets::Glyph::Close, "Cancel update")
|
||||
.on_hover_text("Stop waiting; the update can be started again later")
|
||||
&& widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Cancel update"))
|
||||
.on_hover_text(tl!("Stop waiting; the update can be started again later"))
|
||||
.clicked()
|
||||
{
|
||||
self.send(Command::Cancel { app: artcraft_launcher_core::catalog::LAUNCHER_ID.to_string() });
|
||||
@@ -130,24 +159,24 @@ impl LauncherUi {
|
||||
widgets::progress(ui, fraction, &caption, t.accent, 220.0);
|
||||
return;
|
||||
}
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Close, "Dismiss").clicked() {
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Dismiss")).clicked() {
|
||||
self.launcher_update = None;
|
||||
}
|
||||
if self.caps.self_update {
|
||||
if widgets::button(ui, "Update and restart", Kind::Primary, 0.0)
|
||||
.on_hover_text("Downloads the new version, replaces this one and opens it again")
|
||||
if widgets::button(ui, tl!("Update and restart"), Kind::Primary, 0.0)
|
||||
.on_hover_text(tl!("Downloads the new version, replaces this one and opens it again"))
|
||||
.clicked()
|
||||
{
|
||||
self.send(Command::UpdateLauncher);
|
||||
}
|
||||
} else {
|
||||
let url = self.platform.and_then(|p| launcher_download(&r, p)).unwrap_or_else(|| r.html_url.clone());
|
||||
if widgets::button(ui, "Download", Kind::Primary, 0.0).clicked() {
|
||||
if widgets::button(ui, tl!("Download"), Kind::Primary, 0.0).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
widgets::hud_link(ui, "What's new", &r.html_url);
|
||||
widgets::hud_link(ui, tl!("What's new"), &r.html_url);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -157,10 +186,11 @@ impl LauncherUi {
|
||||
ui.horizontal(|ui| {
|
||||
let updates = self.count(Filter::Updates);
|
||||
let filters: Vec<(Filter, Cell<'_>)> = [
|
||||
(Filter::All, "All", "Every Craft app", None),
|
||||
(Filter::Installed, "Installed", "Apps on this computer", None),
|
||||
(Filter::Updates, "Updates", "Installed apps with a newer version", Some(t.warn)),
|
||||
(Filter::NotInstalled, "Not installed", "Apps you can install", None),
|
||||
(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),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(f, ..)| self.caps.manage_apps || *f == Filter::All)
|
||||
@@ -169,7 +199,11 @@ impl LauncherUi {
|
||||
widgets::cells(ui, &mut self.settings.filter, &filters);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if self.caps.manage_apps && updates > 0 {
|
||||
let label = if updates == 1 { "Update 1 app".to_string() } else { format!("Update all ({updates})") };
|
||||
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
|
||||
@@ -185,7 +219,7 @@ impl LauncherUi {
|
||||
}
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.search)
|
||||
.hint_text("Search apps")
|
||||
.hint_text(tl!("Search apps"))
|
||||
.desired_width(200.0)
|
||||
.min_size(vec2(0.0, theme::CONTROL_H))
|
||||
.vertical_align(egui::Align::Center)
|
||||
@@ -211,7 +245,11 @@ impl LauncherUi {
|
||||
}
|
||||
}
|
||||
});
|
||||
widgets::hud(ui, "Sort", t.faint);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -226,11 +264,11 @@ impl LauncherUi {
|
||||
egui::Frame::new().fill(t.sunken).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(14, 10)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Close, "Dismiss").clicked() {
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Dismiss")).clicked() {
|
||||
self.settings.dismissed_notices.insert(notice.id.clone());
|
||||
}
|
||||
if let Some(link) = ¬ice.link {
|
||||
widgets::hud_link(ui, "Learn more", link);
|
||||
widgets::hud_link(ui, tl!("Learn more"), link);
|
||||
}
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
@@ -253,25 +291,35 @@ impl LauncherUi {
|
||||
let now = self.backend.now();
|
||||
ui.horizontal_centered(|ui| {
|
||||
if self.checking {
|
||||
widgets::hud(ui, "Checking for updates…", t.faint);
|
||||
widgets::hud(ui, tl!("Checking for updates…"), t.faint);
|
||||
} else if let Some(at) = self.settings.last_check {
|
||||
let using = self.sources_label().map(|s| format!(" · using {s}")).unwrap_or_default();
|
||||
widgets::hud(ui, &format!("Checked {}{using}", time::format_ago(at, now)), t.faint);
|
||||
let ago = ago_text(at, now);
|
||||
let text = match self.sources_label() {
|
||||
Some(using) => fmt(tl!("Checked {ago} · using {sources}"), &[("ago", &ago), ("sources", &using)]),
|
||||
None => fmt(tl!("Checked {ago}"), &[("ago", &ago)]),
|
||||
};
|
||||
widgets::hud(ui, &text, t.faint);
|
||||
} else {
|
||||
widgets::hud(ui, "Not checked yet", t.faint);
|
||||
widgets::hud(ui, tl!("Not checked yet"), t.faint);
|
||||
}
|
||||
if let Some(until) = self.rate_limited_until.filter(|u| *u > now) {
|
||||
ui.label(
|
||||
RichText::new(format!("· GitHub's hourly limit was reached; full details return in {} min", ((until - now) / 60).max(1)))
|
||||
.size(12.0)
|
||||
.color(t.warn),
|
||||
RichText::new(format!(
|
||||
"· {}",
|
||||
fmt(
|
||||
tl!("GitHub's hourly limit was reached; full details return in {min} min"),
|
||||
&[("min", &((until - now) / 60).max(1).to_string())]
|
||||
)
|
||||
))
|
||||
.size(12.0)
|
||||
.color(t.warn),
|
||||
);
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let label = match self.platform {
|
||||
Some(p) if self.caps.manage_apps => format!("Installing for {}", p.label()),
|
||||
Some(p) => format!("Downloads for {}", p.label()),
|
||||
None => "No downloads for this system".into(),
|
||||
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(),
|
||||
};
|
||||
widgets::hud(ui, &label, t.faint);
|
||||
ui.add_space(16.0);
|
||||
@@ -283,9 +331,9 @@ impl LauncherUi {
|
||||
ui.add_space(12.0);
|
||||
ui.spacing_mut().item_spacing.x = 16.0;
|
||||
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
|
||||
widgets::hud_link(ui, "Discord", DISCORD_URL);
|
||||
widgets::hud_link(ui, "GitHub", GITHUB_URL);
|
||||
widgets::hud_link(ui, "Website", WEBSITE_URL);
|
||||
widgets::hud_link(ui, tl!("Discord"), DISCORD_URL);
|
||||
widgets::hud_link(ui, tl!("GitHub"), GITHUB_URL);
|
||||
widgets::hud_link(ui, tl!("Website"), WEBSITE_URL);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -300,7 +348,7 @@ impl LauncherUi {
|
||||
// In suite order, split into the classic apps and the new ones.
|
||||
let grouped = self.settings.sort == SortKey::Suite && self.settings.filter == Filter::All && self.search.trim().is_empty();
|
||||
let sections: Vec<Section> = if grouped {
|
||||
[(AppGroup::Classic, "01", "Classic apps"), (AppGroup::New, "02", "New apps")]
|
||||
[(AppGroup::Classic, "01", tl!("Classic apps")), (AppGroup::New, "02", tl!("New apps"))]
|
||||
.iter()
|
||||
.map(|(g, index, title)| {
|
||||
let list = ids.iter().copied().filter(|id| self.entry(id).is_some_and(|e| e.app.group == *g)).collect();
|
||||
@@ -320,7 +368,7 @@ impl LauncherUi {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::eyebrow(ui, index, title);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
widgets::hud(ui, &format!("{} apps", list.len()), t.faint);
|
||||
widgets::hud(ui, &tn(list.len() as u64, "{n} app", "{n} apps"), t.faint);
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
@@ -362,9 +410,9 @@ impl LauncherUi {
|
||||
widgets::tag(&mut strip_ui, &number, app_color, Color32::BLACK);
|
||||
strip_ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 16.0;
|
||||
widgets::hud_link(ui, "GitHub", &app.repo_url());
|
||||
widgets::hud_link(ui, tl!("GitHub"), &app.repo_url());
|
||||
if let Some(site) = app.website() {
|
||||
widgets::hud_link(ui, "Website", &site);
|
||||
widgets::hud_link(ui, tl!("Website"), &site);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -382,7 +430,7 @@ impl LauncherUi {
|
||||
// The slot is as tall as the text beside it when that is taller, so the two share a centre.
|
||||
let block = lines_height(ui, &[&name, &tagline, &like], GAP);
|
||||
let (slot, icon_response) = ui.allocate_exact_size(vec2(ICON, block.max(ICON)), Sense::hover());
|
||||
icon_response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, format!("{} icon", app.name)));
|
||||
icon_response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, fmt(tl!("{name} icon"), &[("name", app.name)])));
|
||||
// The icon rises a little on hover, over a soft drop shadow (the site's only shadow).
|
||||
let icon_rect = Rect::from_center_size(slot.center(), vec2(ICON, ICON)).translate(vec2(0.0, -4.0 * lift));
|
||||
ui.painter().add(
|
||||
@@ -399,19 +447,22 @@ impl LauncherUi {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::wordmark(ui, app, 22.0);
|
||||
if app.group == AppGroup::New {
|
||||
widgets::badge(ui, "New", t.app_ink(app));
|
||||
widgets::badge(ui, tl!("New"), t.app_ink(app));
|
||||
}
|
||||
});
|
||||
ui.add(egui::Label::new(RichText::new(app.tagline).font(tagline.clone()).color(t.muted)).truncate());
|
||||
ui.add(egui::Label::new(RichText::new(format!("Like {}", app.inspired_by())).font(like.clone()).color(t.faint)).truncate());
|
||||
ui.add(egui::Label::new(RichText::new(tr_ctx(current(), app.id, app.tagline)).font(tagline.clone()).color(t.muted)).truncate());
|
||||
ui.add(
|
||||
egui::Label::new(RichText::new(fmt(tl!("Like {name}"), &[("name", &app.inspired_by())])).font(like.clone()).color(t.faint))
|
||||
.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));
|
||||
let r = widgets::badge(ui, &text, widgets::tone_color(&t, tone));
|
||||
if let Some(f) = entry.failure() {
|
||||
r.on_hover_text(format!("Couldn't check for updates: {f}"));
|
||||
r.on_hover_text(fmt(tl!("Couldn't check for updates: {f}"), &[("f", f)]));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
@@ -456,12 +507,12 @@ impl LauncherUi {
|
||||
.column(Column::exact(206.0))
|
||||
.header(42.0, |mut header| {
|
||||
for (key, label) in [
|
||||
(Some(SortKey::Name), "App"),
|
||||
(Some(SortKey::InspiredBy), "Inspired by"),
|
||||
(Some(SortKey::Status), "Status"),
|
||||
(Some(SortKey::Installed), "Installed"),
|
||||
(Some(SortKey::Latest), "Latest"),
|
||||
(Some(SortKey::Released), "Released"),
|
||||
(Some(SortKey::Name), tl!("App")),
|
||||
(Some(SortKey::InspiredBy), tl!("Inspired by")),
|
||||
(Some(SortKey::Status), tl!("Status")),
|
||||
(Some(SortKey::Installed), tr_ctx(current(), "column", "Installed")),
|
||||
(Some(SortKey::Latest), tr_ctx(current(), "column", "Latest")),
|
||||
(Some(SortKey::Released), tr_ctx(current(), "column", "Released")),
|
||||
(None, ""),
|
||||
] {
|
||||
header.col(|ui| {
|
||||
@@ -470,7 +521,7 @@ impl LauncherUi {
|
||||
}
|
||||
let Some(k) = key else { return };
|
||||
let active = k == self.settings.sort;
|
||||
let tip = if active { "Click to reverse the order" } else { "Click to sort by this column" };
|
||||
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.
|
||||
let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::click());
|
||||
let hovered = resp.hovered();
|
||||
@@ -523,7 +574,12 @@ impl LauncherUi {
|
||||
let block = title_h + GAP + tagline_h;
|
||||
ui.add_space(((ui.available_height() - block) / 2.0).max(0.0));
|
||||
widgets::wordmark(ui, entry.app, TITLE_SIZE);
|
||||
ui.add(egui::Label::new(RichText::new(entry.app.tagline).size(TAGLINE_SIZE).color(t.muted)).truncate());
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(tr_ctx(current(), entry.app.id, entry.app.tagline)).size(TAGLINE_SIZE).color(t.muted),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
});
|
||||
});
|
||||
row.col(|ui| {
|
||||
@@ -531,9 +587,9 @@ impl LauncherUi {
|
||||
});
|
||||
row.col(|ui| {
|
||||
let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
|
||||
let r = widgets::badge(ui, text, widgets::tone_color(&t, tone));
|
||||
let r = widgets::badge(ui, &text, widgets::tone_color(&t, tone));
|
||||
if let Some(f) = entry.failure() {
|
||||
r.on_hover_text(format!("Couldn't check for updates: {f}"));
|
||||
r.on_hover_text(fmt(tl!("Couldn't check for updates: {f}"), &[("f", f)]));
|
||||
}
|
||||
});
|
||||
row.col(|ui| {
|
||||
@@ -549,9 +605,9 @@ impl LauncherUi {
|
||||
});
|
||||
row.col(|ui| {
|
||||
let when = entry.release().and_then(|r| r.published_at);
|
||||
let r = ui.label(RichText::new(when.map_or_else(|| "—".into(), time::format_date)).color(t.muted));
|
||||
let r = ui.label(RichText::new(when.map_or_else(|| "—".into(), date_text)).color(t.muted));
|
||||
if let Some(w) = when {
|
||||
r.on_hover_text(time::format_ago(w, now));
|
||||
r.on_hover_text(ago_text(w, now));
|
||||
}
|
||||
});
|
||||
row.col(|ui| self.actions(ui, &entry, &status, 204.0));
|
||||
@@ -586,21 +642,23 @@ impl LauncherUi {
|
||||
pick_asset(r, &stems, p, p.download_kinds()).map(|(a, _)| a.url.clone())
|
||||
});
|
||||
return match (status, url) {
|
||||
(AppStatus::NoRelease, _) => Primary::Unavailable("Coming soon"),
|
||||
(AppStatus::Checking, _) => Primary::Unavailable("Checking…"),
|
||||
(AppStatus::NoRelease, _) => Primary::Unavailable(tl!("Coming soon")),
|
||||
(AppStatus::Checking, _) => Primary::Unavailable(tl!("Checking…")),
|
||||
(_, Some(url)) => Primary::Download(url),
|
||||
(_, None) => Primary::Unavailable("Not available"),
|
||||
(_, None) => Primary::Unavailable(tl!("Not available")),
|
||||
};
|
||||
}
|
||||
match status {
|
||||
AppStatus::Available if entry.ready => Primary::Retry("Retry install".into()),
|
||||
AppStatus::UpdateAvailable if entry.ready => Primary::Retry(format!("Retry update to {}", entry.latest_label().unwrap_or_default())),
|
||||
AppStatus::Available if entry.ready => Primary::Retry(tl!("Retry install").into()),
|
||||
AppStatus::UpdateAvailable if entry.ready => {
|
||||
Primary::Retry(fmt(tl!("Retry update to {version}"), &[("version", &entry.latest_label().unwrap_or_default())]))
|
||||
}
|
||||
AppStatus::Available => Primary::Install,
|
||||
AppStatus::UpdateAvailable => Primary::Update(entry.latest_label().unwrap_or_default()),
|
||||
AppStatus::UpToDate | AppStatus::Installed => Primary::Open,
|
||||
AppStatus::Checking => Primary::Unavailable("Checking…"),
|
||||
AppStatus::NoRelease => Primary::Unavailable("Coming soon"),
|
||||
AppStatus::Unavailable => Primary::Unavailable(if entry.failure().is_some() { "Offline" } else { "Not available" }),
|
||||
AppStatus::Checking => Primary::Unavailable(tl!("Checking…")),
|
||||
AppStatus::NoRelease => Primary::Unavailable(tl!("Coming soon")),
|
||||
AppStatus::Unavailable => Primary::Unavailable(if entry.failure().is_some() { tl!("Offline") } else { tl!("Not available") }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,7 +674,11 @@ impl LauncherUi {
|
||||
let bar_w = (width - 2.0 * (theme::CONTROL_H + gap) - 4.0).max(100.0);
|
||||
let short = bar_w < 200.0;
|
||||
let amount = |done: u64, total: u64| {
|
||||
if total > 0 { format!("{} of {}", widgets::human_bytes(done), widgets::human_bytes(total)) } else { widgets::human_bytes(done) }
|
||||
if total > 0 {
|
||||
fmt(tl!("{done} of {total}"), &[("done", &widgets::human_bytes(done)), ("total", &widgets::human_bytes(total))])
|
||||
} else {
|
||||
widgets::human_bytes(done)
|
||||
}
|
||||
};
|
||||
let (fraction, caption, color) = match phase {
|
||||
JobPhase::Downloading { done, total } => {
|
||||
@@ -624,35 +686,37 @@ impl LauncherUi {
|
||||
let pct = f.map(|f| format!(" · {:.0}%", f * 100.0)).unwrap_or_default();
|
||||
let caption = if bar_w < 150.0 {
|
||||
match f {
|
||||
Some(f) => format!("{:.0}% of {}", f * 100.0, widgets::human_bytes(total)),
|
||||
Some(f) => {
|
||||
fmt(tl!("{done} of {total}"), &[("done", &format!("{:.0}%", f * 100.0)), ("total", &widgets::human_bytes(total))])
|
||||
}
|
||||
None => widgets::human_bytes(done),
|
||||
}
|
||||
} else if short {
|
||||
format!("{}{pct}", amount(done, total))
|
||||
} else {
|
||||
format!("Downloading {}{pct}", amount(done, total))
|
||||
format!("{} {}{pct}", tl!("Downloading"), amount(done, total))
|
||||
};
|
||||
(f, caption, rgb(entry.app.color))
|
||||
}
|
||||
JobPhase::Reconnecting { done, total } => {
|
||||
let f = (total > 0).then(|| done as f32 / total as f32);
|
||||
(f, if short { "Reconnecting…".into() } else { format!("Reconnecting… {}", amount(done, total)) }, t.warn)
|
||||
(f, if short { tl!("Reconnecting…").into() } else { format!("{} {}", tl!("Reconnecting…"), amount(done, total)) }, t.warn)
|
||||
}
|
||||
JobPhase::Verifying => (None, "Verifying checksum…".into(), rgb(entry.app.color)),
|
||||
JobPhase::Waiting => (None, "Waiting for another app…".into(), rgb(entry.app.color)),
|
||||
JobPhase::Installing => (None, "Installing…".into(), rgb(entry.app.color)),
|
||||
JobPhase::Removing => (None, "Removing…".into(), rgb(entry.app.color)),
|
||||
JobPhase::Verifying => (None, tl!("Verifying checksum…").into(), rgb(entry.app.color)),
|
||||
JobPhase::Waiting => (None, tl!("Waiting for another app…").into(), rgb(entry.app.color)),
|
||||
JobPhase::Installing => (None, tl!("Installing…").into(), rgb(entry.app.color)),
|
||||
JobPhase::Removing => (None, tl!("Removing…").into(), rgb(entry.app.color)),
|
||||
};
|
||||
let downloading = matches!(phase, JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. });
|
||||
// A job waiting for the installer hasn't started it yet, so it can still be called off.
|
||||
let waiting = phase == JobPhase::Waiting;
|
||||
// Waiting has one square button beside the bar, like the menu otherwise.
|
||||
widgets::progress(ui, fraction, &caption, color, if downloading { bar_w } else { main_w });
|
||||
if downloading && widgets::glyph_button(ui, widgets::Glyph::Pause, "Pause download").clicked() {
|
||||
if downloading && widgets::glyph_button(ui, widgets::Glyph::Pause, tl!("Pause download")).clicked() {
|
||||
self.send(Command::Pause { app: id.clone() });
|
||||
}
|
||||
if (downloading || waiting)
|
||||
&& widgets::glyph_button(ui, widgets::Glyph::Close, if waiting { "Cancel" } else { "Cancel download" }).clicked()
|
||||
&& widgets::glyph_button(ui, widgets::Glyph::Close, if waiting { tl!("Cancel") } else { tl!("Cancel download") }).clicked()
|
||||
{
|
||||
self.send(Command::Cancel { app: id });
|
||||
}
|
||||
@@ -664,22 +728,22 @@ impl LauncherUi {
|
||||
let pct = if p.total > 0 { format!(" · {:.0}%", p.done as f32 / p.total as f32 * 100.0) } else { String::new() };
|
||||
// Resume, then discard and the menu (two squares).
|
||||
let resume_w = (width - 2.0 * (theme::CONTROL_H + gap)).max(90.0);
|
||||
// Narrow (the table): just "Resume"; the tooltip says how far it got.
|
||||
// Narrow (the table): just tl!("Resume"); the tooltip says how far it got.
|
||||
let label = match resume_w {
|
||||
w if w < 150.0 => "Resume".to_string(),
|
||||
w if w < 220.0 => format!("Resume{pct}"),
|
||||
_ => format!("Resume download{pct}"),
|
||||
w if w < 150.0 => tl!("Resume").to_string(),
|
||||
w if w < 220.0 => format!("{}{pct}", tl!("Resume")),
|
||||
_ => format!("{}{pct}", tl!("Resume download")),
|
||||
};
|
||||
let resp = widgets::button(ui, &label, Kind::Primary, resume_w);
|
||||
let tip = if p.total > 0 {
|
||||
format!("{} of {} downloaded", widgets::human_bytes(p.done), widgets::human_bytes(p.total))
|
||||
fmt(tl!("{done} of {total} downloaded"), &[("done", &widgets::human_bytes(p.done)), ("total", &widgets::human_bytes(p.total))])
|
||||
} else {
|
||||
format!("{} downloaded", widgets::human_bytes(p.done))
|
||||
fmt(tl!("{done} downloaded"), &[("done", &widgets::human_bytes(p.done))])
|
||||
};
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
self.send(Command::Install { app: id.clone() });
|
||||
}
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Close, "Discard download").clicked() {
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Discard download")).clicked() {
|
||||
self.send(Command::Cancel { app: id.clone() });
|
||||
}
|
||||
self.menu(ui, entry, status);
|
||||
@@ -687,30 +751,30 @@ impl LauncherUi {
|
||||
}
|
||||
match self.primary_action(entry, status) {
|
||||
Primary::Install => {
|
||||
if widgets::button(ui, "Install", Kind::Primary, main_w).clicked() {
|
||||
if widgets::button(ui, tl!("Install"), Kind::Primary, main_w).clicked() {
|
||||
self.send(Command::Install { app: id.clone() });
|
||||
}
|
||||
}
|
||||
Primary::Retry(label) => {
|
||||
let tip = "The download is still here. It's checked again, then installed.";
|
||||
let tip = tl!("The download is still here. It's checked again, then installed.");
|
||||
if widgets::button(ui, &label, Kind::Primary, main_w).on_hover_text(tip).clicked() {
|
||||
self.send(Command::Install { app: id.clone() });
|
||||
}
|
||||
}
|
||||
Primary::Update(v) => {
|
||||
if widgets::button(ui, &format!("Update to {v}"), Kind::Primary, main_w).clicked() {
|
||||
if widgets::button(ui, &fmt(tl!("Update to {v}"), &[("v", &v)]), Kind::Primary, main_w).clicked() {
|
||||
self.send(Command::Install { app: id.clone() });
|
||||
}
|
||||
}
|
||||
Primary::Open => {
|
||||
if widgets::button(ui, "Open", Kind::Secondary, main_w).clicked() {
|
||||
if widgets::button(ui, tl!("Open"), Kind::Secondary, main_w).clicked() {
|
||||
self.send(Command::Launch { app: id.clone() });
|
||||
}
|
||||
}
|
||||
Primary::Download(url) => {
|
||||
let label = match self.platform {
|
||||
Some(p) => format!("Download for {}", p.os.label()),
|
||||
None => "Download".into(),
|
||||
Some(p) => fmt(tl!("Download for {os}"), &[("os", p.os.label())]),
|
||||
None => tl!("Download").into(),
|
||||
};
|
||||
if widgets::button(ui, &label, Kind::Primary, main_w).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
@@ -728,33 +792,33 @@ impl LauncherUi {
|
||||
let manage = self.caps.manage_apps;
|
||||
let reveal = self.caps.reveal_label;
|
||||
widgets::more_menu(ui, |ui| {
|
||||
if entry.release().is_some() && ui.button("Release notes").clicked() {
|
||||
if entry.release().is_some() && ui.button(tl!("Release notes")).clicked() {
|
||||
self.dialog = Some(Dialog::Notes(id.clone()));
|
||||
ui.close();
|
||||
}
|
||||
// Only where Install would download: an up-to-date app would be reinstalled.
|
||||
let can_download_again =
|
||||
manage && entry.ready && entry.paused.is_none() && matches!(status, AppStatus::Available | AppStatus::UpdateAvailable);
|
||||
if can_download_again && ui.button("Download again").clicked() {
|
||||
if can_download_again && ui.button(tl!("Download again")).clicked() {
|
||||
self.send(Command::Cancel { app: id.clone() });
|
||||
self.send(Command::Install { app: id.clone() });
|
||||
ui.close();
|
||||
}
|
||||
if manage && status.is_installed() {
|
||||
if ui.button("Open").clicked() {
|
||||
if ui.button(tl!("Open")).clicked() {
|
||||
self.send(Command::Launch { app: id.clone() });
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(reveal).clicked() {
|
||||
if ui.button(t(reveal)).clicked() {
|
||||
self.send(Command::Reveal { app: id.clone() });
|
||||
ui.close();
|
||||
}
|
||||
if *status == AppStatus::UpToDate && ui.button("Reinstall").clicked() {
|
||||
if *status == AppStatus::UpToDate && ui.button(tl!("Reinstall")).clicked() {
|
||||
self.send(Command::Install { app: id.clone() });
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(RichText::new("Uninstall…").color(Tokens::get(ui.ctx()).bad)).clicked() {
|
||||
if ui.button(RichText::new(tl!("Uninstall…")).color(Tokens::get(ui.ctx()).bad)).clicked() {
|
||||
self.dialog = Some(Dialog::ConfirmUninstall(id.clone()));
|
||||
ui.close();
|
||||
}
|
||||
@@ -765,7 +829,8 @@ impl LauncherUi {
|
||||
.download_kinds()
|
||||
.iter()
|
||||
.filter_map(|k| {
|
||||
pick_asset(r, &stems, p, &[*k]).map(|(a, _)| (format!("{} · {}", k.label(), widgets::human_bytes(a.size)), a.url.clone()))
|
||||
pick_asset(r, &stems, p, &[*k])
|
||||
.map(|(a, _)| (format!("{} · {}", crate::i18n::t(k.label()), widgets::human_bytes(a.size)), a.url.clone()))
|
||||
})
|
||||
.collect();
|
||||
if !options.is_empty() {
|
||||
@@ -786,40 +851,42 @@ impl LauncherUi {
|
||||
}
|
||||
};
|
||||
match entry.app.website() {
|
||||
Some(site) => link(ui, "Website", site),
|
||||
Some(site) => link(ui, tl!("Website"), site),
|
||||
None => {
|
||||
ui.add_enabled(false, egui::Button::new("Website (coming soon)"));
|
||||
ui.add_enabled(false, egui::Button::new(tl!("Website (coming soon)")));
|
||||
}
|
||||
}
|
||||
link(ui, "GitHub repository", entry.app.repo_url());
|
||||
link(ui, "All releases", entry.app.releases_url());
|
||||
link(ui, tl!("GitHub repository"), entry.app.repo_url());
|
||||
link(ui, tl!("All releases"), entry.app.releases_url());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_label(k: SortKey) -> &'static str {
|
||||
match k {
|
||||
SortKey::Suite => "Suite order",
|
||||
SortKey::Name => "Name",
|
||||
SortKey::InspiredBy => "Inspired by",
|
||||
SortKey::Status => "Status",
|
||||
SortKey::Installed => "Installed version",
|
||||
SortKey::Latest => "Latest version",
|
||||
SortKey::Released => "Newest release",
|
||||
SortKey::Suite => tl!("Suite order"),
|
||||
SortKey::Name => tl!("Name"),
|
||||
SortKey::InspiredBy => tl!("Inspired by"),
|
||||
SortKey::Status => tl!("Status"),
|
||||
SortKey::Installed => tl!("Installed version"),
|
||||
SortKey::Latest => tl!("Latest version"),
|
||||
SortKey::Released => tl!("Newest release"),
|
||||
}
|
||||
}
|
||||
|
||||
/// "0.4.1 installed → 0.5.0 released · Oct 8, 2026" and friends.
|
||||
fn version_line(e: &AppEntry, status: &AppStatus) -> String {
|
||||
let released = e.release().and_then(|r| r.published_at).map(|at| format!(" · {}", time::format_date(at))).unwrap_or_default();
|
||||
let released = e.release().and_then(|r| r.published_at).map(|at| format!(" · {}", date_text(at))).unwrap_or_default();
|
||||
match (status, e.installed_label(), e.latest_label()) {
|
||||
(AppStatus::UpdateAvailable, Some(i), Some(l)) => format!("{i} installed → {l} released{released}"),
|
||||
(AppStatus::UpToDate, Some(i), _) => format!("Version {i}{released}"),
|
||||
(_, Some(i), Some(l)) => format!("{i} installed · latest {l}"),
|
||||
(_, Some(i), None) => format!("Version {i} installed"),
|
||||
(_, None, Some(l)) => format!("Version {l}{released}"),
|
||||
(AppStatus::NoRelease, None, None) => "No release published yet".into(),
|
||||
_ => " ".into(),
|
||||
(AppStatus::UpdateAvailable, Some(i), Some(l)) => {
|
||||
format!("{}{released}", fmt(tl!("{version} installed → {latest} released"), &[("version", i.as_str()), ("latest", l.as_str())]))
|
||||
}
|
||||
(AppStatus::UpToDate, Some(i), _) => format!("{}{released}", fmt(tl!("Version {version}"), &[("version", i.as_str())])),
|
||||
(_, Some(i), Some(l)) => fmt(tl!("{installed} installed · latest {latest}"), &[("installed", i.as_str()), ("latest", l.as_str())]),
|
||||
(_, Some(i), None) => fmt(tl!("Version {version} installed"), &[("version", i.as_str())]),
|
||||
(_, None, Some(l)) => format!("{}{released}", fmt(tl!("Version {latest}"), &[("latest", l.as_str())])),
|
||||
(AppStatus::NoRelease, None, None) => tl!("No release published yet").to_string(),
|
||||
_ => " ".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,10 +178,10 @@ fn window_button(ui: &mut Ui, button: WindowButton, height: f32) -> Response {
|
||||
let c = rect.center();
|
||||
let stroke = Stroke::new(1.0, ink);
|
||||
let label = match button {
|
||||
WindowButton::Minimize => "Minimize",
|
||||
WindowButton::Maximize => "Maximize",
|
||||
WindowButton::Restore => "Restore",
|
||||
WindowButton::Close => "Close",
|
||||
WindowButton::Minimize => tl!("Minimize"),
|
||||
WindowButton::Maximize => tl!("Maximize"),
|
||||
WindowButton::Restore => tl!("Restore"),
|
||||
WindowButton::Close => tl!("Close"),
|
||||
};
|
||||
// Half the glyph's size: 10 px, like Windows' own caption buttons.
|
||||
let s = 5.0;
|
||||
@@ -481,7 +481,7 @@ pub fn social_icons(ui: &mut Ui, size: f32) {
|
||||
let color = if resp.hovered() { t.ink_strong } else { t.faint };
|
||||
egui::Image::new(icon).tint(color).paint_at(ui, rect.shrink(pad));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, *name));
|
||||
let resp = resp.on_hover_text(format!("ArtCraft on {name}")).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let resp = resp.on_hover_text(crate::i18n::fmt(tl!("ArtCraft on {name}"), &[("name", name)])).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if resp.clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(*url));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! The Russian UI, in its own test binary: the UI language is process-wide, so it can't share one
|
||||
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
|
||||
//! `-- --test-threads=1`, see `ui.rs`).
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, Source};
|
||||
use artcraft_launcher_core::release::{Asset, Release};
|
||||
use artcraft_launcher_core::status::InstallSource;
|
||||
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
|
||||
use artcraft_launcher_ui_egui::settings::ViewMode;
|
||||
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
const NOW: i64 = 1_791_500_000;
|
||||
|
||||
struct Fake {
|
||||
events: Vec<Event>,
|
||||
clock: Rc<Cell<i64>>,
|
||||
}
|
||||
|
||||
impl Backend for Fake {
|
||||
fn send(&mut self, _command: Command) {}
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
|
||||
}
|
||||
fn platform(&self) -> Option<Platform> {
|
||||
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
|
||||
}
|
||||
fn now(&self) -> i64 {
|
||||
self.clock.get()
|
||||
}
|
||||
}
|
||||
|
||||
struct NoHost;
|
||||
impl Host for NoHost {}
|
||||
|
||||
fn release(id: &str, v: &str, at: i64) -> Release {
|
||||
let name = format!("{id}-{v}-macos-universal.dmg");
|
||||
Release {
|
||||
tag: format!("v{v}"),
|
||||
version: version::parse(v),
|
||||
name: format!("v{v}"),
|
||||
published_at: Some(at),
|
||||
html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),
|
||||
notes: String::new(),
|
||||
prerelease: false,
|
||||
assets: vec![Asset {
|
||||
name: name.clone(),
|
||||
size: 150 << 20,
|
||||
url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),
|
||||
sha256: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn events() -> Vec<Event> {
|
||||
let mut ev = Vec::new();
|
||||
for (i, app) in SUITE.iter().enumerate() {
|
||||
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
|
||||
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
|
||||
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
|
||||
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
|
||||
source: InstallSource::MacApp,
|
||||
location: format!("/Applications/{}.app", app.name),
|
||||
package: None,
|
||||
});
|
||||
ev.push(Event::Detected { app: app.id.into(), installed });
|
||||
}
|
||||
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
|
||||
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
|
||||
// A backend message, translated through its `@msg` template where it's shown.
|
||||
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
|
||||
ev
|
||||
}
|
||||
|
||||
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
|
||||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||||
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let settings =
|
||||
Settings { view, notify: false, language: "ru".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
|
||||
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
{
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
|
||||
}
|
||||
if let Some(l) = state.as_mut() {
|
||||
l.logic(&ui.ctx().clone(), &mut NoHost);
|
||||
l.ui(ui, &mut NoHost);
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
|
||||
let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
|
||||
let img = h.render().unwrap();
|
||||
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
|
||||
}
|
||||
|
||||
// One test: the UI language is process-wide.
|
||||
#[test]
|
||||
fn russian_ui_translates_labels_dates_and_messages() {
|
||||
let mut h = harness(ViewMode::Grid);
|
||||
h.run_steps(8);
|
||||
snapshot(&mut h, "grid-ru-dark");
|
||||
// Product names stay in English.
|
||||
h.get_by_label("Launcher");
|
||||
h.get_by_label("Craft App Suite");
|
||||
h.get_by_label("Редактирование изображений");
|
||||
h.get_by_label("Обновить до 0.5.0");
|
||||
h.get_by_label_contains("Проверка контрольной суммы…");
|
||||
h.get_by_label_contains("Проверено 3 минуты назад · через ArtCraft API");
|
||||
h.get_by_label("Приложение LightCraft обновлено до 0.5.0");
|
||||
|
||||
let mut table = harness(ViewMode::Table);
|
||||
table.run_steps(8);
|
||||
snapshot(&mut table, "table-ru-dark");
|
||||
table.get_by_label("Текущая");
|
||||
assert!(table.query_all_by_label_contains("авг. 2026").count() >= 1);
|
||||
}
|
||||
@@ -123,6 +123,9 @@ fn harness_with(
|
||||
manage: bool,
|
||||
scale: f32,
|
||||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||||
// Integration tests compile without cfg!(test), so system_lang() picks up the
|
||||
// developer's locale. Force English so labels match the assertions.
|
||||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let sent2 = sent.clone();
|
||||
let mut backend = Some(Fake { events, sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) });
|
||||
|
||||
Reference in New Issue
Block a user