M16: add Ukrainian interface translation (#558)

Add Ukrainian catalogs, locale selection, context-aware labels and regression coverage.
Record permissive sibling-catalog provenance in the existing attribution index.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Dmytro Matviichuk
2026-10-09 20:07:44 +02:00
co-authored by Codex
parent 88ea1a804b
commit b08f80700f
10 changed files with 2292 additions and 44 deletions
+1 -1
View File
@@ -354,7 +354,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Українська) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
+5 -3
View File
@@ -29,7 +29,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
### By kind of user
@@ -95,7 +95,7 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02)
@@ -226,7 +226,9 @@ database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-cat
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
translated; untranslated text retains English. Ukrainian covers all existing catalog keys, command/control/rule labels
and format messages, with count-neutral labels and numeric dates; technical errors from lower layers remain English.
CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
+1
View File
@@ -22,6 +22,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file |
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
+210
View File
@@ -0,0 +1,210 @@
{
" ({unsure} with several look-alike files: use Locate)": " ({unsure} із кількома схожими файлами: використайте «Знайти»)",
" ({} in Recently Deleted)": " ({} у нещодавно видалених)",
" · {:.0}% clipped": " · {:.0}% відсічено",
" · {failed} couldn't be rendered": " · не вдалося відтворити: {failed}",
" · {skipped} skipped (file exists)": " · пропущено: {skipped} (файл існує)",
" · {} duplicate{} skipped": " · дублікатів пропущено: {}{:.0}",
" · {} failed: {}": " · помилок: {}: {}",
" · {} moved": " · переміщено: {}",
" · {} not readable": " · не читається: {}",
" · {} original{} left at the source": " · оригіналів залишено в джерелі: {}{:.0}",
" · {} restored from Recently Deleted": " · відновлено з нещодавно видалених: {}",
" — not carried over: {}{}": " — не перенесено: {}{}",
"+{} more": "+{} ще",
", {failed} file{} not readable": ", файлів не читається: {failed}{:.0}",
"; {kept} already had a location": "; уже мали розташування: {kept}",
"; {outside} outside its time range": "; поза часовим діапазоном маршруту: {outside}",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR: {}, комітів: {}, рядків: +{} / −{} (Δ {}), двійкових ресурсів: +{} / −{}, {} – {}",
"Add {local_n} to My Photos": "Додати до моїх фото: {local_n}",
"Add {} Photo{}": "Додати фото: {}{:.0}",
"Add {} Photo{}, restore {}": "Додати фото: {}{:.0}, відновити: {}",
"Add “{}” to Favorites": "Додати «{}» до улюблених",
"Added {n} photo{} to My Photos": "До моїх фото додано: {n}{:.0}",
"Added {n} photo{} to “{}”": "До «{1}» додано фото: {n}{0:.0}",
"Added {what} to {name}": "{what}: додано до {name}",
"Adding photos… {} of {}": "Додавання фото… {} з {}",
"All {n} selected photos move by the same amount.": "Усі вибрані фото ({n}) зміщуються на однакове значення.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Уже в бібліотеці, у нещодавно видалених: вибрано фото: {}{:.0} · права кнопка → Відновити або Видалити назавжди, щоб імпортувати заново",
"Already in the library: {} photo{} selected in All Photos": "Уже в бібліотеці: вибрано фото в усіх фото: {}{:.0}",
"Auto ({})": "Автоматично ({})",
"Auto Import: added {} photo{}": "Автоімпорт: додано фото: {}{:.0}",
"Auto Sync: edits apply to {n} photos": "Автосинхронізація: зміни застосовуються до фото: {n}",
"Automatic ({} MB)": "Автоматично ({} МБ)",
"Build 1:1 Previews ({scope})": "Створити перегляди 1:1 ({scope})",
"Build Smart Previews ({scope})": "Створити смарт-перегляди ({scope})",
"Build Standard-Sized Previews ({scope})": "Створити перегляди стандартного розміру ({scope})",
"Building previews for {} photos…": "Створення переглядів для фото: {}…",
"Click {}:1": "Клацання {}:1",
"Couldn't open the editor: {e}": "Не вдалося відкрити редактор: {e}",
"Create {n} Virtual Copies": "Створити віртуальні копії: {n}",
"Creates {} stacks from {} of {scope}": "Буде створено стосів: {} із {} ({scope})",
"Decoder: {reason}": "Декодер: {reason}",
"Delete \"{label}\"": "Видалити «{label}»",
"Delete \"{label}\" (and the mask)": "Видалити «{label}» (і маску)",
"Delete {n} Photos": "Видалити фото: {n}",
"Delete “{current}”": "Видалити «{current}»",
"Discard Smart Previews ({scope})": "Видалити смарт-перегляди ({scope})",
"Done ({what}): {done} photo{plural}": "Готово ({what}): фото: {done}{plural:.0}",
"Duplicate \"{label}\"": "Дублювати «{label}»",
"Example: {example}": "Приклад: {example}",
"Exported {n} photo{}": "Експортовано фото: {n}{:.0}",
"Exported {ok} of {total} photo{}": "Експортовано фото: {ok} із {total}{:.0}",
"Exported {} point curve preset{}": "Експортовано наборів налаштувань точкової кривої: {}{:.0}",
"Exported {} preset{}": "Експортовано наборів налаштувань: {}{:.0}",
"Exporting {} of {total}": "Експорт: {} із {total}",
"Filter bar — {active} active filter{}": "Панель фільтрів — активних фільтрів: {active}{:.0}",
"Focus {:.0}": "Фокус {:.0}",
"Folder “{}” (and its subfolders)": "Тека «{}» (і вкладені теки)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Знайдено відсутніх фото: {n}{:.0}; усе ще відсутні: {left}{unsure}",
"Healed {n} dust spot{}": "Усунено пилових плям: {n}{:.0}",
"Import cancelled · {} photo{} added": "Імпорт скасовано · додано фото: {}{:.0}",
"Import failed: {e}": "Помилка імпорту: {e}",
"Imported {n} point curve preset{}": "Імпортовано наборів налаштувань точкової кривої: {n}{:.0}",
"Imported {n} preset{}": "Імпортовано наборів налаштувань: {n}{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "Імпортовано наборів налаштувань: {n}{:.0}, файлів не читається: {f}{:.0}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Імпортовано профілів: {profiles}{:.0} (Оглядач профілів → їхні групи)",
"Imported {} photo{}": "Імпортовано фото: {}{:.0}",
"Insert at the cursor (also written {})": "Вставити в позицію курсора (також записується як {})",
"Label Set: {name}": "Набір міток: {name}",
"LightCraft could not open its window: {e}": "LightCraft не вдалося відкрити вікно: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft не вдалося відкрити вікно: {e}\n\nПодробиці у файлі журналу: {path}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Каталог Lightroom перевірено: фото — {photos}, колекцій — {collections}, відсутні — {missing}",
"Lightroom import complete: {imported} photos added": "Імпорт Lightroom завершено: додано фото — {imported}",
"Lightroom import complete: {imported} photos added, {failed} failed": "Імпорт Lightroom завершено: додано фото — {imported}, помилок — {failed}",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Імпорт Lightroom завершено: додано фото — {imported}; попередження індексу: {warning}",
"Lightroom import failed: {e}{warning}": "Помилка імпорту Lightroom: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Помилка перевірки Lightroom: {e}{warning}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Шрифти: {} (OFL). Значки: оригінальні.",
"Matches: {}": "Збігів: {}",
"Merging… {stage} {:.0}%": "Об’єднання… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "Перемістити {what} до нещодавно видалених?",
"Named ({named_n})": "З іменами ({named_n})",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Призначення «{k}»: клацайте фото, щоб додати або вилучити його · Esc завершує",
"Paste into {n} photo{} — only settings that were copied are pasted": "Вставка у фото: {n}{:.0} — вставляються лише скопійовані налаштування",
"Preset: {name}": "Набір налаштувань: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Лише перегляд — редагування вбудованого JPEG камери ({})",
"Preview only — {}": "Лише перегляд — {}",
"Previews ready for {done} photo{plural}": "Перегляди готові для фото: {done}{plural:.0}",
"Profile: {}": "Профіль: {}",
"Proof Copy ({})": "Пробна копія ({})",
"Proof Preview · {}": "Пробний перегляд · {}",
"Quick Develop · {n} photos": "Швидка обробка · фото: {n}",
"Quick Develop: {name}": "Швидка обробка: {name}",
"Rated {}": "Оцінено: {}",
"Reading Lightroom catalog… {done} of {total}": "Читання каталогу Lightroom… {done} із {total}",
"Reading photos… {done} of {total}": "Читання фото… {done} із {total}",
"Reading photos… {} of {}": "Читання фото… {} із {}",
"Redo {}": "Повторити {}",
"Remove “{name}” and its {count} photo{} from the library?": "Вилучити «{name}» і фото ({count}){:.0} з бібліотеки?",
"Remove “{}” from Favorites": "Вилучити «{}» з улюблених",
"Removed from {}": "Вилучено з {}",
"Removed {what} from {name}": "{what}: вилучено з {name}",
"Rename {n} Photos…": "Перейменувати фото: {n}…",
"Rename “{name}”": "Перейменувати «{name}»",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Перейменовує «{from}» на фото ({n}){:.0} (також вкладені ключові слова). Використовуйте | для рівнів, наприклад Подорожі|Італія. Наявна назва об’єднує обидва слова.",
"Rendering on the CPU: {why}": "Рендеринг на ЦП: {why}",
"Replace {} with:": "Замінити {} на:",
"Reset {name}": "Скинути: {name}",
"Reset {} Channel": "Скинути канал: {}",
"Restore {}": "Відновити {}",
"Restore {} Photo{}": "Відновити фото: {}{:.0}",
"Saved export preset “{}”": "Набір налаштувань експорту «{}» збережено",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оцінює фокус і експозицію ({scope}) та знаходить подібні знімки з різницею в кілька секунд (серії).",
"Show {hidden} hidden location{}": "Показати приховані розташування: {hidden}{:.0}",
"Showing {n} of {}": "Показано {n} із {}",
"Smart album: {}": "Смарт-альбом: {}",
"Tagged {n} photo{} from the tracklog": "Позначено за журналом маршруту фото: {n}{:.0}",
"The SAM 3 download failed: {e}": "Не вдалося завантажити SAM 3: {e}",
"Undo {}": "Скасувати {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Невідомі теги{s:.0} {} залишаються як введено{:.0} (див. «Теги»)",
"Updated “{name}”": "«{name}» оновлено",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Версія {} — незалежно розроблена бібліотека фото й обробка RAW на чистому Rust.",
"Version: {}": "Версія: {}",
"Virtual copy “{name}” of {of}": "Віртуальна копія «{name}» з {of}",
"Working on {what} for {} photos…": "{what}: обробка фото ({})…",
"none (CPU rendering): {why}": "немає (рендеринг на ЦП): {why}",
"the {n} selected photos": "вибрані фото: {n}",
"the {} photos in view": "фото в поданні: {}",
"these {count} photos": "ці фото ({count})",
"{count} smart preview{} · {:.1} MB": "Смарт-переглядів: {count}{:.0} · {:.1} МБ",
"{f} folders (and their subfolders)": "Тек: {f} (і їхніх вкладених тек)",
"{label} Grading": "Колірне тонування: {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} на кожному вибраному фото",
"{label} {index} of {n}": "{label} {index} із {n}",
"{label}, {n} photos": "{label}, фото: {n}",
"{matching} of {t} photos": "Фото: {matching} із {t}",
"{matching} photos": "Фото: {matching}",
"{mode} spot {} of {}": "{mode}: ділянка {} із {}",
"{month}": "{month}",
"{month} {year}": "{month:02}.{year}",
"{msg} ({failed} failed)": "{msg} (помилок: {failed})",
"{name} Label": "Мітка: {name}",
"{name} Off": "{name}: вимкнено",
"{name} On": "{name}: увімкнено",
"{name} opened for editing; it is stacked with the original": "{name} відкрито для редагування й об’єднано в стос з оригіналом",
"{name} — double-click to reset it": "{name} — подвійне клацання для скидання",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Змін не вдалося записати на диск: {n}{0:.0}: {e}\nЯкщо вийти зараз, їх буде втрачено.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Змін збережено в пам’яті, але не записано на диск: {n}{:.0}: {e}\nLightCraft повторює спроби автоматично; вихід зараз призведе до їхньої втрати.{:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Змін збережено в пам’яті, але не записано на диск: {n}{:.0}: {e} — LightCraft повторить спробу",
"{n} found · {sel} selected": "Знайдено: {n} · вибрано: {sel}",
"{n} photos": "Фото: {n}",
"{n} photos in {} are hidden by the filters above": "Фільтри вище приховують фото в «{}»: {n}",
"{n} photo{}": "Фото: {n}{:.0}",
"{n} photo{} match · updates automatically as photos change": "Відповідних фото: {n}{:.0} · оновлюється автоматично зі зміною фото",
"{n} photo{} now · updates automatically as photos change": "Зараз фото: {n}{:.0} · оновлюється автоматично зі зміною фото",
"{pid} (missing)": "{pid} (відсутній)",
"{sel_n} selected · {counted}": "Вибрано: {sel_n} · {counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb} (фото: {n}){:.0}",
"{weekday}, {day}": "{weekday}, {day}",
"{weekday}, {day} {month} {year}": "{weekday}, {day:02}.{month:02}.{year}",
"{what} merge added": "{what}: об’єднання додано",
"{what} merge failed: {e}": "{what}: помилка об’єднання: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "Директиви {{date:…}}: {}",
"{} and higher": "{} і вище",
"{} contributors · {} commits": "Учасників: {} · комітів: {}",
"{} failed": "Помилок: {}",
"{} files": "Файлів: {}",
"{} files and {f} folder{}": "Файлів: {}, тек: {f}{:.0}",
"{} item{}": "Елементів: {}{:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "Напрямних: {} із 4 — проведіть уздовж ліній, які мають бути вертикальними або горизонтальними.",
"{} photos": "Фото: {}",
"{} photos · exposures {} EV": "Фото: {} · експозиції {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "Фото: {} · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "Не читається фото: {}{:.0}",
"{} sample{}": "Зразків: {}{:.0}",
"{} sec": "{} с",
"{} spot(s) on this photo": "Ділянок на цьому фото: {}",
"{} — show their photos": "{} — показати фото цієї особи",
"· {dups} already in the library": "· уже в бібліотеці: {dups}",
"· {n} photo{}": "· фото: {n}{:.0}",
"… and {more} more": "… і ще {more}",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft ще не може декодувати цей варіант RAW ({}). Ви редагуєте вбудований JPEG-перегляд камери, який уже містить її стиль зображення (наприклад, монохромний) і баланс білого.",
"Filtered: {label}": "Відфільтровано: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Подібних фото: {n}{:.0} · Вигляд → Скинути фільтри, щоб побачити всі",
"Moved; {} photo(s) relinked": "Переміщено; повторно пов’язано фото: {}",
"{} label (click more labels to show any of them)": "Мітка «{}» (клацніть інші мітки, щоб показати фото з будь-якою з них)",
", {} star{}": ", зірок: {}{:.0}",
", {} label": ", мітка: {}",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Завантажується з {host}. Після отримання й перевірки LightCraft установлює модель, {after}.",
"{:.1} MB": "{:.1} МБ",
"Running on the processor: {}.": "Працює на процесорі: {}.",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Зберігається в теці «denoise» бібліотеки; після досягнення ліміту першими видаляються найстаріші зображення, які не використовуються жодним фото.",
"Denoising {name}: {done} of {total} tiles{}": "Усунення шуму {name}: ділянки — {done} із {total}{}",
"{size} GB": "{size} ГБ",
"{} of {} from {host}": "{} із {} з {host}",
"{} KB": "{} КБ",
"{} MB": "{} МБ",
"Making the denoised picture… {}%": "Створення зображення з усуненим шумом… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{noun}: {n}",
"Trained on: {provenance}": "Навчено на: {provenance}",
" · {queued} waiting": " · очікують: {queued}",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Показує умови моделі, потім завантажує {} з {host}, установлює й починає використовувати її. Більше нічого не надсилається.",
"In line for AI Denoise ({ahead} ahead)…": "У черзі на усунення шуму ШІ (попереду: {ahead})…",
"Running on {}.": "Працює на {}.",
"{file} · {size}": "{file} · {size}",
"• {assumption}": "• {assumption}"
}
File diff suppressed because it is too large Load Diff
+131 -33
View File
@@ -3,8 +3,8 @@
//! A language is one entry in [`language_table!`] below: its BCP-47 code, the endonym shown in the
//! Language menus, the ISO 15924 script its text needs (which picks the CJK font fallback), and its
//! message catalog (`locales/<code>.json`, embedded at build time). Adding a language is that entry
//! plus its catalogs — every menu and settings surface is built from the table, so nothing else in
//! the UI changes.
//! plus its catalogs and the corresponding language command in `menus.rs`. Settings read the
//! locale table, while menus and the control channel share the command mapping.
use std::{cell::RefCell, collections::BTreeMap, sync::OnceLock};
@@ -124,6 +124,7 @@ language_table! {
Es, "es", "Español", "Latn", include_str!("../locales/es.json");
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json");
Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json");
}
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
@@ -593,6 +594,7 @@ mod tests {
("app.language.spanish", Locale::Es),
("app.language.german", Locale::De),
("app.language.russian", Locale::Ru),
("app.language.ukrainian", Locale::Uk),
];
// One command per language, and every command reachable from the menu table.
assert_eq!(commands.len(), Locale::ALL.len());
@@ -627,6 +629,100 @@ mod tests {
}
}
#[test]
fn ukrainian_covers_catalogs_commands_controls_and_rules() {
let catalog = Locale::Uk.catalog();
for key in Locale::ALL.iter().flat_map(|language| language.catalog().keys()) {
assert!(catalog.contains_key(key), "Ukrainian lacks catalog key {key:?}");
}
let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect();
labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label)));
labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label));
labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label));
for (_, label, kind) in lightcraft_catalog::rules::FIELDS {
labels.push(label);
labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label));
}
labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning));
labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES);
let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect();
assert!(missing.is_empty(), "Ukrainian lacks display labels: {missing:?}");
}
#[test]
fn ukrainian_switches_persists_formats_dates_and_preserves_values() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.ukrainian", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::Uk);
assert_eq!(crate::menubar::checked(&app, "app.language.ukrainian"), Some(true));
assert_eq!(tr("Light"), "Світло");
assert_eq!(tr("Landscape"), "Пейзаж");
assert_eq!(tr("Home"), "Домашня тека");
assert_eq!(date_group_label("2026-09-20", false), "Неділя, 20.09.2026");
assert_eq!(date_group_label("2026-09", false), "09.2026");
assert_eq!(display_time("2026-09-20T16:04:05"), "Неділя, 20.09.2026 16:04:05");
// Count-neutral labels work for every Ukrainian integer category, with no English suffix.
for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] {
assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Імпортовано фото: {n}"));
assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("Файлів: {n}, тек: {n}"));
let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" });
assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), format!("Кешовані зображення: {n}"));
}
// This trailing argument is queue progress, not an English plural suffix.
let queued = tr_format!(" · {queued} waiting", queued = 5);
assert_eq!(
tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4),
"Усунення шуму IMG.CR3: ділянки — 1 із 4 · очікують: 5"
);
let name = "Color {title} 日本語";
assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("До «{name}» додано фото: 22"));
assert_eq!(builtin_label("Warm Glow", false), "Warm Glow");
assert_eq!(builtin_label("Warm Glow", true), "Тепле сяйво");
assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg");
assert_eq!(tr("develop.set"), "develop.set");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"uk""#));
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Uk);
let ctx = egui::Context::default();
for code in ["uk", "uk-UA", "uk_UA.UTF-8", "uk-Cyrl-UA"] {
assert_eq!(Locale::parse_tag(code), Some(Locale::Uk));
let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") };
assert_eq!(reply["ok"], true);
assert_eq!(language(), Locale::Uk);
}
set_language(Locale::En);
}
#[test]
fn ukrainian_panels_dialogs_and_letters_paint_with_bundled_fonts() {
let ctx = fonts_ctx(&[]);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.left_panel = true;
let text = painted_text(&ctx, &mut app, Locale::Uk);
assert!(text.contains("Мої фото") && text.contains("Усі фото"), "{text}");
for (command, title) in [("app.about", "Про LightCraft"), ("app.shortcuts", "Клавіатурні скорочення"), ("app.settings", "Налаштування")]
{
app.ui.dialog = None;
app.run(command, serde_json::json!({})).unwrap();
let text = painted_text(&ctx, &mut app, Locale::Uk);
assert!(text.contains(title), "{command}: {text}");
}
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
for ch in "ҐґЄєІіЇї’«»"
.chars()
.chain(Locale::Uk.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{0400}'..='\u{04ff}').contains(ch)))
{
assert!(fonts.has_glyph(&font, ch), "missing Ukrainian glyph {ch}");
}
}
});
set_language(Locale::En);
}
/// Every command, control, rule and rename label, and every line of What's New, is a message a
/// catalog can translate. What a language lacks is reported, not failed (like
/// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every
@@ -742,7 +838,7 @@ mod tests {
}
#[test]
fn german_buttons_fit_the_bottom_bar_and_export_dialog() {
fn german_and_ukrainian_buttons_fit_the_bottom_bar_and_export_dialog() {
fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) {
match shape {
egui::epaint::Shape::Text(shape) => {
@@ -752,38 +848,40 @@ mod tests {
_ => {}
}
}
let ctx = fonts_ctx(&[]);
let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() };
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
app.ui.language = Locale::De;
let mut bounds = Vec::new();
for export in [false, true] {
if export {
app.run("dialog.export", serde_json::json!({})).unwrap();
}
for frame in 0..4 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
bounds.clear();
for shape in out.shapes {
collect(&shape.shape, &mut bounds);
for language in [Locale::De, Locale::Uk] {
let ctx = fonts_ctx(&[]);
let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() };
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
app.ui.language = language;
let mut bounds = Vec::new();
for export in [false, true] {
if export {
app.run("dialog.export", serde_json::json!({})).unwrap();
}
}
let rect = |id: &str| app.widgets.iter().find(|entry| entry.0 == id).unwrap().1;
if export {
let dialog = rect("dialog:window");
for id in ["button:exportNamingTags", "button:exportChooseFolder", "button:exportSavePreset"] {
assert!(dialog.contains_rect(rect(id)), "{id} spills outside {dialog:?}: {:?}", rect(id));
for frame in 0..4 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
bounds.clear();
for shape in out.shapes {
collect(&shape.shape, &mut bounds);
}
}
let rect = |id: &str| app.widgets.iter().find(|entry| entry.0 == id).unwrap().1;
if export {
let dialog = rect("dialog:window");
for id in ["button:exportNamingTags", "button:exportChooseFolder", "button:exportSavePreset"] {
assert!(dialog.contains_rect(rect(id)), "{id} spills outside {dialog:?}: {:?}", rect(id));
}
} else {
let button = rect("button:copySettings");
let (_, text) = bounds.iter().find(|entry| entry.0 == language.tr("Copy Edit Settings")).unwrap();
assert!(button.contains_rect(*text), "{language:?} copy caption overflows its button: {text:?} vs {button:?}");
assert!(!button.intersects(rect("icon:copyGear")));
}
} else {
let button = rect("button:copySettings");
let (_, text) = bounds.iter().find(|entry| entry.0 == "Bearbeitungseinstellungen kopieren").unwrap();
assert!(button.contains_rect(*text), "German copy caption overflows its button: {text:?} vs {button:?}");
assert!(!button.intersects(rect("icon:copyGear")));
}
}
set_language(Locale::En);
+3 -1
View File
@@ -12,7 +12,7 @@ use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom};
pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str);
/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language
/// added to the table shows up in the menu (and in the control channel's command list) by itself.
/// has a matching menu command (also listed through the control channel). Tests check coverage.
pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"),
("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"),
@@ -22,6 +22,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"),
("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"),
("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"),
("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"),
];
/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from
@@ -42,6 +43,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
"app.language.spanish" => Some(crate::i18n::Locale::Es),
"app.language.german" => Some(crate::i18n::Locale::De),
"app.language.russian" => Some(crate::i18n::Locale::Ru),
"app.language.ukrainian" => Some(crate::i18n::Locale::Uk),
_ => None,
}
}
+1 -1
View File
@@ -250,7 +250,7 @@ pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let area = canvas.shrink(18.0);
let half = (area.width() - 16.0) / 2.0;
let panes = [
(sel, Rect::from_min_size(area.min, vec2(half, area.height())), "Select", 0u8),
(sel, Rect::from_min_size(area.min, vec2(half, area.height())), "Selected", 0u8),
(cand, Rect::from_min_size(pos2(area.right() - half, area.top()), vec2(half, area.height())), "Candidate", 1u8),
];
let zoom = app.ui.zoom;
+6 -3
View File
@@ -2,7 +2,7 @@
The interface ships in the language it is written in (English) plus every language in the table in
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`) and Russian (`ru`). This file is the reference for **adding or maintaining a language**; the per-language notes
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`) and Ukrainian (`uk`). This file is the reference for **adding or maintaining a language**; the per-language notes
are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md),
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md),
[`localization-de.md`](localization-de.md) and [`localization-ru.md`](localization-ru.md).
@@ -29,14 +29,17 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json");
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json");
Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json");
}
```
The fields are the BCP-47 code (also the settings-file value), the **endonym** shown in the
Language menu, the ISO 15924 script (`Latn`, `Jpan`, `Hans`, `Hant`, `Kore`…), and the embedded
catalog. `build.rs` picks up `<code>-formats.json` by name, so the macro and the format lookup
follow automatically — nothing else in the UI needs to change. The menus, the settings row, the
keyboard-shortcut sheet and the control channel all read the table.
follow automatically. Add the corresponding `app.language.<name>` entry to
`LANGUAGE_COMMANDS` and `language_from_command` in `src/menus.rs`, and to the
`language_commands_cover_every_language` test. Settings read the locale table; menus and
the control channel share the language-command mapping.
3. **Make sure the glyphs exist.** The script drives which craft-fonts faces the UI installs: a
language whose script has no face in the build shows boxes (the test says so instead of failing).
Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never here; a
+2 -2
View File
@@ -527,8 +527,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
| LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported |
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. |
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |