collective

Browse / github.com/storytold/craft-launcher / patch

feat(i18n): add Ukrainian (Українська) localization

feat(i18n): add Ukrainian (Українська) localization Ukrainian system/browser locales currently fall back to English. This adds **Українська** to Settings → Language and automatic detection for `uk`, `uk-UA`, and POSIX locale forms. The 325-entry Ukrainian catalog covers the current interface, app descriptions, tray text, dates, package labels, and status/error messages, including the new installation-folder flow. It uses the existing three-form plural rule and keeps English fallback behavior for unknown strings. Adds coverage for locale resolution, saved preferences, plural boundaries, dates, and Ukrainian glyphs in all bundled UI font famili

Status
open
Score
0.00
Adopters
0 other operators
Author
CrioNIK (imported from pull request #535, open)
Base
307efcf45a080af9643e750d43034eb25540cef8
Size
+654 −10 in 5 files
Review
pending
Shared
2026-10-28
Id
3de009742eec6a47a2f50c04

How it went

No other operator has reported using it yet.

Diff

raw · README.md crates/ui-egui/src/i18n/lang.rs crates/ui-egui/src/i18n/tests.rs crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/tests/ukrainian.rs

diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -86,15 +86,16 @@ The web version shows the same catalog in the browser, with the latest download  The interface is in English, Brazilian Portuguese (Português (Brasil)), Czech (Čeština), French (Français), German (Deutsch), Hungarian (Magyar), Japanese (日本語), Polish (Polski), Russian-(Русский), Simplified Chinese (简体中文) or Spanish (Español). **Auto**, the default, follows the-system or browser language; **Settings → Language** picks one and switches at once, and the choice-is kept between runs. Czech locales (`cs-CZ`, `cs_CZ.UTF-8`…) select Czech, German ones (`de-DE`,-`de-AT`, `de-CH`, `de_DE.UTF-8`…) German, French ones (`fr-FR`, `fr-CA`…) French, Hungarian ones+(Русский), Simplified Chinese (简体中文), Spanish (Español) or Ukrainian (Українська). **Auto**, the+default, follows the system or browser language; **Settings → Language** picks one and switches at+once, and the choice is kept between runs. Czech locales (`cs-CZ`, `cs_CZ.UTF-8`…) select Czech,+German ones (`de-DE`, `de-AT`, `de-CH`, `de_DE.UTF-8`…) German, French ones (`fr-FR`, `fr-CA`…) French, Hungarian ones (`hu`, `hu-HU`, `hu_HU.UTF-8`…) Hungarian, Japanese ones (`ja`, `ja-JP`, `ja_JP.UTF-8`…) Japanese, Polish ones (`pl`, `pl-PL`, `pl_PL.UTF-8`) Polish, Spanish ones (`es-ES`, `es-MX`, `es-419`…)-Spanish, and Portuguese ones (`pt-BR`, and `pt-PT` too, for now) Brazilian Portuguese. Simplified-Chinese locales (`zh-CN`, `zh-SG`, `zh-Hans`, and POSIX forms such as `zh_CN.UTF-8`) select-Chinese; Traditional Chinese ones don't. To try a language without changing the setting, start the+Spanish, and Portuguese ones (`pt-BR`, and `pt-PT` too, for now) Brazilian Portuguese.+Ukrainian locales (`uk`, `uk-UA`, `uk_UA.UTF-8`…) select Ukrainian, with one/few/many forms for+integer counts. Simplified Chinese locales (`zh-CN`, `zh-SG`, `zh-Hans`, and POSIX forms such as+`zh_CN.UTF-8`) select Chinese; Traditional Chinese ones don't. To try a language without changing the setting, start the desktop app with `ARTCRAFT_LAUNCHER_LOCALE=pt-BR`, or add `?lang=ru` to the web address.  The CJK languages are drawn with the system's own CJK font (Hiragino Sans or PingFang on macOS,diff --git a/crates/ui-egui/src/i18n/lang.rs b/crates/ui-egui/src/i18n/lang.rs--- a/crates/ui-egui/src/i18n/lang.rs+++ b/crates/ui-egui/src/i18n/lang.rs@@ -22,7 +22,7 @@ impl Script {  /// One supported UI language. pub struct LangInfo {-    /// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`).+    /// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`, `uk`).     /// Also the `interfaceLanguage` value.     pub code: &'static str,     /// The language's name in itself, shown in the Preferences dropdown.@@ -34,7 +34,7 @@ pub struct LangInfo {     /// Catalog file contents (empty for the built-in English).     pub source: &'static str,     /// Plural form index for a count (English, German, Spanish and Hungarian: 0 = one, 1 = other;-    /// Russian: 0 = one, 1 = few, 2 = many; Czech: 0 = one, 1 = few, 2 = other; Polish: 0 = one only+    /// Russian and Ukrainian: 0 = one, 1 = few, 2 = many; Czech: 0 = one, 1 = few, 2 = other; Polish: 0 = one only     /// for 1, 1 = few, 2 = many; French: 0 = one for 0 and 1; a language whose nouns don't inflect     /// for number: always 0).     pub plural: fn(u64) -> usize,@@ -94,7 +94,7 @@ pub(super) fn plural_polish(n: u64) -> usize { }  /// The registry. English first: it is the fallback and the source language.-pub static LANGUAGES: [LangInfo; 11] = [+pub static LANGUAGES: [LangInfo; 12] = [     LangInfo {         code: "en",         name: "English",@@ -216,6 +216,17 @@ pub static LANGUAGES: [LangInfo; 11] = [         complete_menus: true,         catalog: OnceLock::new(),     },+    LangInfo {+        code: "uk",+        name: "Українська",+        english_name: "Ukrainian",+        script: None,+        source: include_str!("uk.tsv"),+        plural: plural_east_slavic,+        plural_forms: 3,+        complete_menus: true,+        catalog: OnceLock::new(),+    }, ];  impl LangInfo {@@ -254,6 +265,7 @@ impl Lang {     pub const PL: Lang = Lang(&LANGUAGES[8]);     pub const HU: Lang = Lang(&LANGUAGES[9]);     pub const JA: Lang = Lang(&LANGUAGES[10]);+    pub const UK: Lang = Lang(&LANGUAGES[11]);      pub fn code(self) -> &'static str {         self.0.codediff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs--- a/crates/ui-egui/src/i18n/tests.rs+++ b/crates/ui-egui/src/i18n/tests.rs@@ -129,6 +129,7 @@ fn complete_catalogs_cover_every_ui_literal() {         include_str!("../model.rs"),         include_str!("../notes.rs"),         include_str!("../settings.rs"),+        include_str!("../theme.rs"),         include_str!("../views.rs"),         include_str!("../widgets.rs"),         // The desktop shell's tray menu and tooltip.@@ -250,6 +251,79 @@ fn untranslated_plurals_fall_back_to_english_by_count() {     assert_eq!(trn(Lang::EN, 0, "{n} app", "{n} apps"), "0 apps"); } +#[test]+fn ukrainian_locales_and_saved_preference_resolve() {+    for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK_ua", "uk-UA@euro"] {+        assert_eq!(lang_from_tag(tag), Some(Lang::UK), "{tag}");+    }+    assert_eq!(super::detect::first_supported(["xx-XX", "uk-UA", "en-US"]), Some(Lang::UK));+    assert_eq!(Lang::from_pref("uk"), Lang::UK);+    assert_eq!(Lang::from_code("UK"), Some(Lang::UK));+    assert!(!Lang::UK.needs_cjk_font());+    let settings = crate::settings::Settings { language: "uk".into(), ..Default::default() };+    let saved = serde_json::to_string(&settings).unwrap();+    let restored: crate::settings::Settings = serde_json::from_str(&saved).unwrap();+    assert_eq!(Lang::from_pref(&restored.language), Lang::UK);+    assert_eq!(crate::settings::Settings::language_label(&restored.language, None), "Українська");+}++#[test]+fn ukrainian_counts_dates_and_fallback_work() {+    for (n, app, day) in [+        (0, "застосунків", "днів"),+        (1, "застосунок", "день"),+        (2, "застосунки", "дні"),+        (4, "застосунки", "дні"),+        (5, "застосунків", "днів"),+        (11, "застосунків", "днів"),+        (12, "застосунків", "днів"),+        (14, "застосунків", "днів"),+        (21, "застосунок", "день"),+        (22, "застосунки", "дні"),+        (25, "застосунків", "днів"),+        (101, "застосунок", "день"),+        (111, "застосунків", "днів"),+        (112, "застосунків", "днів"),+    ] {+        assert_eq!(trn(Lang::UK, n, "{n} app", "{n} apps"), format!("{n} {app}"));+        assert_eq!(trn(Lang::UK, n, "{n} day ago", "{n} days ago"), format!("{n} {day} тому"));+    }+    assert_eq!(tr(Lang::UK, "Settings"), "Налаштування");+    assert_eq!(super::tr_ctx(Lang::UK, "month", "Mar"), "бер.");+    assert_eq!(super::tr_ctx(Lang::UK, "month", "Oct"), "жовт.");+    assert_eq!(super::fmt(tr(Lang::UK, "{month} {day}, {year}"), &[("month", "жовт."), ("day", "16"), ("year", "2026")]), "16 жовт. 2026");+    // Ukrainian's singular at 21 must not leak into an untranslated English plural.+    assert_eq!(trn(Lang::UK, 21, "{n} unknown item", "{n} unknown items"), "21 unknown items");+    assert_eq!(tr(Lang::UK, "Untranslated diagnostic"), "Untranslated diagnostic");+    assert_eq!(message(Lang::UK, "Untranslated diagnostic"), "Untranslated diagnostic");+}++#[test]+fn bundled_fonts_cover_ukrainian_in_every_ui_family() {+    use egui::{FontFamily, FontId};+    let ctx = egui::Context::default();+    crate::theme::install_fonts(&ctx, None);+    let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});+    output.textures_delta.clear();+    // Include the whole alphabet, especially letters absent from Russian and the UI's uppercase labels.+    let mut text = String::from("АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя’");+    for (_, _, _, translation) in rows(Lang::UK.0) {+        text.extend(translation.chars().filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)));+    }+    text.push_str(&text.to_uppercase());+    for family in [+        FontFamily::Proportional,+        FontFamily::Monospace,+        crate::theme::medium(),+        crate::theme::semibold(),+        crate::theme::display(),+        crate::theme::mono(),+        crate::theme::mono_bold(),+    ] {+        ctx.fonts_mut(|fonts| assert!(fonts.has_glyphs(&FontId::new(14.0, family.clone()), &text), "missing Ukrainian glyph in {family:?}"));+    }+}+ #[test] fn locale_tags_resolve() {     for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "CS_cz"] {diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsvnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/i18n/uk.tsv@@ -0,0 +1,362 @@+# Ukrainian (uk) 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|few|many (1, 2–4, 0/5–20; repeated by the last digits).+# @msg = status/error message template (message, msg).+# Other context = disambiguation for tr_ctx.+#+# Clean-room translation from the meaning of the English source text.+# Coverage: the whole interface (complete_menus = true), including the union of catalog keys.+# Terminology: app = застосунок; install = встановити; update = оновлення;+# settings = налаштування; download = завантажити; folder = тека.+# Product names ArtCraft Launcher, Launcher, Craft App Suite, ArtCraft and all app names,+# as well as GitHub, Discord and package format names, remain in English.+# Preserve placeholders, escaped newlines and trailing ellipses (U+2026).++	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	Оновити й перезапустити+	A new version is available. Would you like to relaunch with the updated version?	Доступна нова версія. Перезапустити застосунок, щоб перейти на оновлену версію?+	Later	Пізніше+	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	Лише за моїм запитом+	Automatically download and install updates	Автоматично завантажувати й встановлювати оновлення+	Notify me when an installed app has an update	Сповіщати про оновлення встановлених застосунків+	Include pre-releases (betas and release candidates)	Включати попередні випуски (бета-версії та кандидати на випуск)+	Installing on Linux	Встановлення в Linux+	Installing on Windows	Встановлення у Windows+	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	Застосунки, які можна встановити+	Hide App	Приховати застосунок+	Unhide App	Показати застосунок+	Hidden Apps	Приховані застосунки+	Apps you hid	Застосунки, які ви приховали+	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)++# 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	Запис, редагування й зведення звуку+figcraft	Interface design and prototyping	Дизайн інтерфейсів і прототипування++# Month abbreviations for dates in day-month-year order.+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} хв+	GitHub limit hit; back 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}+	Installed · latest {latest}	Встановлено · найновіша {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}+	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.	Якщо ввімкнено, відкриття ArtCraft Launcher зі смуги меню переносить його вікно на поточний робочий стіл. Якщо вимкнено, macOS перемикає вас на робочий стіл, де розташоване вікно.+	Couldn't change the login item: {e}	Не вдалося змінити налаштування автозапуску: {e}++# Table column headers and installed-app filter.+column	App	Застосунок+column	Installed	Наявна+column	Latest	Найновіша+column	Released	Дата випуску+filter	Installed	Встановлені+	{name} details	Відомості про {name}++# Ukrainian plural forms: 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} днів тому++# Backend status and error message templates.+@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	app.install	Встановити+@id	app.update	Оновити+@id	app.open	Відкрити+@id	app.download	Завантажити+@id	app.reveal	Показати в теці+@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	Включати попередні випуски (бета-версії та кандидати на випуск)+@id	linux.preferred_kind	Формат встановлення нових застосунків+@id	tray.close_to_tray	Після закриття вікна продовжувати роботу в системному треї+@id	tray.show_on_current_desktop	Показувати вікно на поточному робочому столі+@id	tray.login_item	Запускати ArtCraft Launcher під час входу в систему+@id	appearance.theme	Вигляд++# Tray (menu bar), details and picture viewer.+	Show ArtCraft Launcher	Показати ArtCraft Launcher+	Check for Updates	Перевірити оновлення+	Visit getartcraft.com	Відвідати getartcraft.com+	ArtCraft on GitHub	ArtCraft на GitHub+	Join the ArtCraft Discord	Приєднатися до Discord ArtCraft+	App details	Відомості про застосунок+	Close (Esc)	Закрити (Esc)+	Features	Можливості+	Latest release	Найновіший випуск+	Version	Версія+	Platforms	Платформи+	Releases	Випуски+	Enlarge picture	Збільшити зображення+	Click to enlarge	Натисніть, щоб збільшити+	Previous picture (←)	Попереднє зображення (←)+	Next picture (→)	Наступне зображення (→)+	Loading pictures…	Завантаження зображень…+	Pictures couldn't be loaded. Reopen this page to try again.	Не вдалося завантажити зображення. Відкрийте цю сторінку знову, щоб повторити спробу.+@plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · доступне {n} оновлення|ArtCraft Launcher · доступні {n} оновлення|ArtCraft Launcher · доступно {n} оновлень++# Install folders and replacement confirmation.+	Install folder	Тека встановлення+	Default install folder	Типова тека встановлення+	System default	Типова для системи+	Browse…	Огляд…+	Use default	Використати типову+	New installs use this folder. Updates stay in their current location.	Нові застосунки встановлюються в цю теку. Під час оновлення їхнє розташування не змінюється.+	Custom folders use AppImage on Linux.	У Linux для встановлення у власну теку використовується AppImage.+	The system manages install locations here.	Розташуванням встановлених застосунків тут керує система.+@msg	couldn't use install folder {path}: {reason}	не вдалося використати теку встановлення {path}: {reason}+@msg	this release has no AppImage for a custom install folder	цей випуск не містить AppImage для встановлення у власну теку+@msg	custom install folders aren't supported on this system	ця система не підтримує вибір власної теки встановлення+@msg	{path} already exists and wasn't installed by ArtCraft Launcher	{path} уже існує, і цей об’єкт не було встановлено через ArtCraft Launcher+@msg	couldn't move {path} to Trash ({reason}). Remove it in Finder instead.	не вдалося перемістити {path} у Смітник ({reason}). Видаліть його у Finder.+	Apps are placed inside this folder.	Застосунки розміщуються в цій теці.+	Replace what's there?	Замінити наявні файли?+	{path} already exists. Installing {name} will overwrite it.	{path} уже існує. Встановлення {name} перезапише його.+	Replace	Замінити++# Additional English keys retained by existing catalogs.+	Classic	Класика+	Website (coming soon)	Вебсайт (незабаром)diff --git a/crates/ui-egui/tests/ukrainian.rs b/crates/ui-egui/tests/ukrainian.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/ukrainian.rs@@ -0,0 +1,195 @@+//! Ukrainian UI flow in a separate binary because the UI language is process-wide.+//! The backend records commands and simulates replies; it never downloads or installs apps.+//! Set ARTCRAFT_LAUNCHER_SNAPSHOTS to save screenshots; run with --test-threads=1.++use std::cell::RefCell;+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;++// 16 October 2026, 12:00 UTC; releases are dated 14 October.+const NOW: i64 = 1_792_152_000;++struct Fake {+    events: Vec<Event>,+    sent: Rc<RefCell<Vec<Command>>>,+}++impl Backend for Fake {+    fn send(&mut self, command: Command) {+        if let Command::Install { app } = &command+            && let Some(craft) = SUITE.iter().find(|craft| craft.id == app)+        {+            // Simulate the reply after the user action, so its toast is still visible.+            self.events.push(Event::JobFinished { app: app.clone(), result: Ok(format!("{} installed: 0.5.0", craft.name)) });+        }+        self.sent.borrow_mut().push(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 Explorer", self_update: true }+    }+    fn platform(&self) -> Option<Platform> {+        Some(Platform { os: Os::Windows, arch: Arch::X64 })+    }+    fn now(&self) -> i64 {+        NOW+    }+}++struct NoHost;+impl Host for NoHost {}++fn events() -> Vec<Event> {+    let mut events = Vec::new();+    for app in &SUITE {+        let name = format!("{}-0.5.0-windows-x64.msi", app.id);+        let release = Release {+            tag: "v0.5.0".into(),+            version: version::parse("0.5.0"),+            name: "v0.5.0".into(),+            published_at: Some(NOW - 2 * 86_400),+            html_url: format!("https://github.com/storytold/{}/releases/tag/v0.5.0", app.id),+            notes: String::new(),+            prerelease: false,+            assets: vec![Asset {+                name: name.clone(),+                size: 150 << 20,+                url: format!("https://github.com/storytold/{}/releases/download/v0.5.0/{name}", app.id),+                sha256: None,+            }],+        };+        events.push(Event::Latest { app: app.id.into(), latest: LatestRelease::Found(release), from: Origin::ArtCraftApi });+        // One fresh install and one update make their actions unambiguous to query.+        let installed = (app.id != "vectorcraft").then(|| Installed {+            version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),+            source: InstallSource::Msi,+            location: format!(r"C:\Apps\{}\{}.exe", app.name, app.id),+            package: None,+        });+        events.push(Event::Detected { app: app.id.into(), installed });+    }+    events.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });+    events.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });+    events+}++fn harness(settings: Settings) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {+    artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);+    let sent = Rc::new(RefCell::new(Vec::new()));+    let mut backend = Some(Fake { events: events(), sent: sent.clone() });+    let h = Harness::builder().with_size(egui::vec2(1120.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(+        move |ui, state: &mut Option<LauncherUi>| {+            if let Some(backend) = backend.take() {+                *state = Some(LauncherUi::new(ui.ctx(), Box::new(backend), settings.clone()));+            }+            if let Some(launcher) = state.as_mut() {+                launcher.logic(ui.ctx(), &mut NoHost);+                launcher.ui(ui, &mut NoHost);+            }+        },+        None,+    );+    (h, sent)+}++fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {+    let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };+    std::fs::create_dir_all(&dir).unwrap();+    h.render().unwrap().save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();+}++#[test]+fn ukrainian_switches_persists_and_keeps_windows_install_actions() {+    let settings =+        Settings { language: "en".into(), notify: false, theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };+    let (mut h, sent) = harness(settings);+    h.run_steps(8);+    h.get_by_label("Settings").click();+    h.run_steps(3);+    h.get_by_value("English").click();+    h.run_steps(2);+    // Ukrainian is the last item, below the ComboBox's visible scroll area.+    // kittest's pointer click does not scroll a queried accessibility node into view.+    h.get_by_label("Українська").scroll_to_me();+    h.run_steps(16);+    snapshot(&mut h, "language-picker-uk-windows-dark");+    h.get_by_label("Українська").click();+    h.run_steps(3);+    assert_eq!(h.state().as_ref().unwrap().settings.language, "uk");+    h.get_by_value("Українська");+    h.get_by_label("Автоматично завантажувати й встановлювати оновлення");+    h.get_by_label("Сповіщати про оновлення встановлених застосунків");+    h.get_by_label("Встановлення у Windows");+    snapshot(&mut h, "settings-uk-windows-dark");+    h.get_by_label("Готово").click();+    h.run_steps(3);+    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");+    assert!(h.query_all_by_label_contains("14 жовт. 2026").count() >= 1);+    snapshot(&mut h, "grid-uk-windows-dark");++    sent.borrow_mut().clear();+    h.get_by_label("Встановити").click();+    h.run_steps(3);+    assert!(!sent.borrow().iter().any(|c| matches!(c, Command::Install { .. })));+    h.get_by_label("Застосунки розміщуються в цій теці.");+    let folder = r"C:\Craft test apps";+    h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Тека встановлення").click();+    h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Тека встановлення").type_text(folder);+    h.run_steps(3);+    assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Тека встановлення").value().as_deref(), Some(folder));+    assert!(h.state().as_ref().unwrap().settings.install_locations.per_app.is_empty());+    snapshot(&mut h, "install-uk-windows-dark");+    h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Встановити").last().unwrap().click();+    h.run_steps(3);+    let actions: Vec<_> = sent.borrow().iter().filter(|c| matches!(c, Command::Configure(_) | Command::Install { .. })).cloned().collect();+    assert!(matches!(actions.as_slice(), [Command::Configure(options), Command::Install { app }]+        if options.install_locations.folder("vectorcraft") == Some(folder) && app == "vectorcraft"));+    h.get_by_label("VectorCraft встановлено: 0.5.0");++    h.get_by_label("Таблиця").click();+    h.run_steps(4);+    assert_eq!(h.state().as_ref().unwrap().settings.view, ViewMode::Table);+    h.get_by_label("Наявна");+    assert!(h.query_all_by_label_contains("14 жовт. 2026").count() >= 1);+    snapshot(&mut h, "table-uk-windows-dark");++    // Recreate the UI from serialized preferences, then switch back through the real picker.+    let saved = serde_json::to_string(&h.state().as_ref().unwrap().settings).unwrap();+    let restored: Settings = serde_json::from_str(&saved).unwrap();+    assert_eq!(restored.language, "uk");+    drop(h);+    let (mut reopened, _) = harness(restored);+    reopened.run_steps(8);+    reopened.get_by_label("Наявна");+    reopened.get_by_label("Налаштування").click();+    reopened.run_steps(3);+    reopened.get_by_value("Українська").click();+    reopened.run_steps(2);+    reopened.get_by_label("English").scroll_to_me();+    reopened.run_steps(16);+    reopened.get_by_label("English").click();+    reopened.run_steps(3);+    assert_eq!(reopened.state().as_ref().unwrap().settings.language, "en");+    reopened.get_by_label("Automatically download and install updates");+    reopened.get_by_label("Installing on Windows");+    reopened.get_by_label("Done").click();+    reopened.run_steps(3);+    reopened.get_by_label("Settings");+    assert!(reopened.query_by_label("Налаштування").is_none());+    assert!(reopened.query_all_by_label_contains("Oct 14, 2026").count() >= 1);+}