# Conflicts:
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Claude 2026-10-08 18:25:01 +00:00
commit 69384ad430
No known key found for this signature in database
37 changed files with 2865 additions and 261 deletions

View File

@ -353,7 +353,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, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), 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

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 and Japanese 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 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 |
### By kind of user
@ -93,7 +93,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 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/German localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)

View File

@ -0,0 +1,176 @@
{
" ({unsure} with several look-alike files: use Locate)": " ({unsure} mit mehreren ähnlichen Dateien: „Fehlende Datei suchen“ verwenden)",
" ({} in Recently Deleted)": " ({} in „Zuletzt gelöscht“)",
" · {:.0}% clipped": " · {:.0}% beschnitten",
" · {failed} couldn't be rendered": " · nicht gerenderte Fotos: {failed}",
" · {skipped} skipped (file exists)": " · {skipped} übersprungen (Datei vorhanden)",
" · {} duplicate{} skipped": " · {} übersprungen (Duplikate){:.0}",
" · {} failed: {}": " · {} fehlgeschlagen: {}",
" · {} moved": " · {} verschoben",
" · {} not readable": " · {} nicht lesbar",
" · {} original{} left at the source": " · Originale an der Quelle verblieben: {}{:.0}",
" — not carried over: {}{}": " — nicht übernommen: {}{}",
"+{} more": "+{} weitere",
", {failed} file{} not readable": ", nicht lesbare Dateien: {failed}{:.0}",
"; {kept} already had a location": "; bereits verortet: {kept}",
"; {outside} outside its time range": "; {outside} außerhalb des Zeitbereichs",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} Commits, +{} / −{} Zeilen (Δ {}), +{} / −{} Binärdateien, {} – {}",
"Add {local_n} to My Photos": "{local_n} zu „Meine Fotos“ hinzufügen",
"Add {} Photo{}": "{} Foto{} hinzufügen",
"Add “{}” to Favorites": "„{}“ zu Favoriten hinzufügen",
"Added {n} photo{} to My Photos": "{n} Foto{} zu „Meine Fotos“ hinzugefügt",
"Added {n} photo{} to “{}”": "{n} Foto{0} zu „{1}“ hinzugefügt",
"Added {what} to {name}": "{what} zu {name} hinzugefügt",
"Adding photos… {} of {}": "Fotos werden hinzugefügt… {} von {}",
"All {n} selected photos move by the same amount.": "Alle {n} ausgewählten Fotos werden um denselben Betrag verschoben.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Bereits in der Bibliothek unter „Zuletzt gelöscht“: {} Foto{} ausgewählt · Rechtsklick → Wiederherstellen, oder endgültig löschen, um neu zu importieren",
"Already in the library: {} photo{} selected in All Photos": "Bereits in der Bibliothek: {} Foto{} in „Alle Fotos“ ausgewählt",
"Auto ({})": "Automatisch ({})",
"Auto Import: added {} photo{}": "Automatischer Import: {} Foto{} hinzugefügt",
"Auto Sync: edits apply to {n} photos": "Automatische Synchronisierung: Bearbeitungen gelten für {n} Fotos",
"Automatic ({} MB)": "Automatisch ({} MB)",
"Build 1:1 Previews ({scope})": "1:1-Vorschauen erstellen ({scope})",
"Build Smart Previews ({scope})": "Smart-Vorschauen erstellen ({scope})",
"Build Standard-Sized Previews ({scope})": "Standardvorschauen erstellen ({scope})",
"Building previews for {} photos…": "Vorschauen für {} Fotos werden erstellt…",
"Click {}:1": "Klick {}:1",
"Couldn't open the editor: {e}": "Editor konnte nicht geöffnet werden: {e}",
"Create {n} Virtual Copies": "{n} virtuelle Kopien erstellen",
"Creates {} stacks from {} of {scope}": "Erstellt {} Stapel aus {} von {scope}",
"Decoder: {reason}": "Decoder: {reason}",
"Delete \"{label}\"": "„{label}“ löschen",
"Delete \"{label}\" (and the mask)": "„{label}“ löschen (und die Maske)",
"Delete {n} Photos": "{n} Fotos löschen",
"Delete “{current}”": "„{current}“ löschen",
"Discard Smart Previews ({scope})": "Smart-Vorschauen verwerfen ({scope})",
"Done ({what}): {done} photo{plural}": "Fertig ({what}): {done} Foto{plural}",
"Duplicate \"{label}\"": "„{label}“ duplizieren",
"Example: {example}": "Beispiel: {example}",
"Exported {n} photo{}": "{n} Foto{} exportiert",
"Exported {ok} of {total} photo{}": "{ok} von {total} Foto{} exportiert",
"Exported {} point curve preset{}": "Exportierte Punktkurvenvorgaben: {}{:.0}",
"Exported {} preset{}": "Exportierte Vorgaben: {}{:.0}",
"Exporting {} of {total}": "{} von {total} wird exportiert",
"Filter bar — {active} active filter{}": "Filterleiste — aktive Filter: {active}{:.0}",
"Focus {:.0}": "Schärfe {:.0}",
"Folder “{}” (and its subfolders)": "Ordner „{}“ (und seine Unterordner)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Gefundene fehlende Fotos: {n}{:.0}; weiterhin fehlend: {left}{unsure}",
"Healed {n} dust spot{}": "Reparierte Staubflecken: {n}{:.0}",
"Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt",
"Import failed: {e}": "Import fehlgeschlagen: {e}",
"Imported {n} point curve preset{}": "Importierte Punktkurvenvorgaben: {n}{:.0}",
"Imported {n} preset{}": "Importierte Vorgaben: {n}{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "Importierte Vorgaben: {n}{0:.0}, nicht lesbare Dateien: {f}{1:.0}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Importierte Profile: {profiles}{:.0} (Profilbrowser → jeweilige Gruppen)",
"Imported {} photo{}": "{} Foto{} importiert",
"Insert at the cursor (also written {})": "An Cursorposition einfügen (auch als {} geschrieben)",
"Label Set: {name}": "Farbmarkierungssatz: {name}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT ODER Apache-2.0. Schriften: {} (OFL). Symbole: eigene Gestaltung.",
"Matches: {}": "Treffer: {}",
"Merging… {stage} {:.0}%": "Wird zusammengefügt… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "{what} nach „Zuletzt gelöscht“ verschieben?",
"Named ({named_n})": "Benannt ({named_n})",
"Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“ auftragen: Fotos anklicken, um es hinzuzufügen oder zu entfernen · Esc beendet",
"Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen — nur kopierte Einstellungen werden eingefügt",
"Preset: {name}": "Vorgabe: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau — das eingebettete Kamera-JPEG wird bearbeitet ({})",
"Preview only — {}": "Nur Vorschau — {}",
"Previews ready for {done} photo{plural}": "Vorschauen für {done} Foto{plural} fertig",
"Profile: {}": "Profil: {}",
"Proof Copy ({})": "Softproof-Kopie ({})",
"Proof Preview · {}": "Softproof-Vorschau · {}",
"Quick Develop · {n} photos": "Schnellentwicklung · {n} Fotos",
"Quick Develop: {name}": "Schnellentwicklung: {name}",
"Rated {}": "Bewertet mit {}",
"Reading photos… {done} of {total}": "Fotos werden gelesen… {done} von {total}",
"Reading photos… {} of {}": "Fotos werden gelesen… {} von {}",
"Redo {}": "Wiederholen: {}",
"Remove “{}” from Favorites": "„{}“ aus Favoriten entfernen",
"Removed {what} from {name}": "{what} aus {name} entfernt",
"Rename {n} Photos…": "{n} Fotos umbenennen…",
"Rename “{name}”": "„{name}“ umbenennen",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Benennt „{from}“ bei {n} Foto{} um (auch untergeordnete Stichwörter). Verwende | für Ebenen, z. B. Reise|Italien. Bei vorhandenem Namen werden beide zusammengeführt.",
"Rendering on the CPU: {why}": "Rendering auf der CPU: {why}",
"Replace {} with:": "{} ersetzen durch:",
"Reset {name}": "{name} zurücksetzen",
"Reset {} Channel": "Kanal {} zurücksetzen",
"Saved export preset “{}”": "Exportvorgabe „{}“ gespeichert",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bewertet {scope} nach Schärfe und Belichtung und findet ähnliche Aufnahmen im Abstand weniger Sekunden (Serien).",
"Show {hidden} hidden location{}": "Ausgeblendete Orte anzeigen: {hidden}{:.0}",
"Showing {n} of {}": "{n} von {} werden angezeigt",
"Smart album: {}": "Smart-Album: {}",
"Tagged {n} photo{} from the tracklog": "{n} Foto{} anhand des GPS-Tracks verortet",
"The SAM 3 download failed: {e}": "SAM-3-Download fehlgeschlagen: {e}",
"Undo {}": "Rückgängig: {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Unbekannte Platzhalter{s:.0} {} bleiben unverändert{:.0} (siehe „Platzhalter“)",
"Updated “{name}”": "„{name}“ aktualisiert",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} — eine unabhängig entwickelte Fotobibliothek und RAW-Entwicklung in reinem Rust.",
"Version: {}": "Version: {}",
"Virtual copy “{name}” of {of}": "Virtuelle Kopie „{name}“ von {of}",
"Working on {what} for {} photos…": "{what} für {} Fotos wird verarbeitet…",
"none (CPU rendering): {why}": "keine (CPU-Rendering): {why}",
"the {n} selected photos": "die {n} ausgewählten Fotos",
"the {} photos in view": "die {} Fotos in der Ansicht",
"these {count} photos": "diese {count} Fotos",
"{count} smart preview{} · {:.1} MB": "Smart-Vorschauen: {count}{0:.0} · {1:.1} MB",
"{f} folders (and their subfolders)": "{f} Ordner (und ihre Unterordner)",
"{label} Grading": "Farbtonung: {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} für jedes ausgewählte Foto",
"{label} {index} of {n}": "{label} {index} von {n}",
"{label}, {n} photos": "{label}, Fotos: {n}",
"{matching} of {t} photos": "Fotos: {matching} von {t}",
"{matching} photos": "Fotos: {matching}",
"{mode} spot {} of {}": "{mode}: Stelle {} von {}",
"{month}": "{month:02}",
"{month} {year}": "{month:02}/{year}",
"{msg} ({failed} failed)": "{msg} ({failed} fehlgeschlagen)",
"{name} Label": "Farbmarkierung {name}",
"{name} Off": "{name} aus",
"{name} On": "{name} ein",
"{name} opened for editing; it is stacked with the original": "{name} zum Bearbeiten geöffnet; mit dem Original gestapelt",
"{name} — double-click to reset it": "{name} — zum Zurücksetzen doppelklicken",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nicht auf den Datenträger geschriebene Änderungen: {n}{0:.0}: {e}\nWenn du jetzt beendest, gehen diese Änderungen verloren.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Ungespeicherte Änderungen: {n}{0:.0} im Arbeitsspeicher, aber nicht auf dem Datenträger: {e}\nLightCraft versucht es automatisch erneut; beim Beenden gingen diese Änderungen verloren.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Änderungen nur im Arbeitsspeicher gespeichert: {n}{:.0}: {e} — LightCraft versucht es erneut",
"{n} found · {sel} selected": "{n} gefunden · {sel} ausgewählt",
"{n} photos": "Fotos: {n}",
"{n} photos in {} are hidden by the filters above": "Ausgeblendete Fotos in {}: {n} (durch die obigen Filter)",
"{n} photo{}": "{n} Foto{}",
"{n} photo{} match · updates automatically as photos change": "Passende Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert",
"{n} photo{} now · updates automatically as photos change": "Aktuelle Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert",
"{pid} (missing)": "{pid} (fehlt)",
"{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{verb} {n} Photo{}": "{verb}: {n} Foto{}",
"{weekday}, {day}": "{weekday}, {day}.",
"{weekday}, {day} {month} {year}": "{weekday}, {day}.{month:02}.{year}",
"{what} merge added": "{what}-Zusammenfügung hinzugefügt",
"{what} merge failed: {e}": "{what}-Zusammenfügung fehlgeschlagen: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "{{date:…}}-Formatangaben: {}",
"{} and higher": "{} und höher",
"{} contributors · {} commits": "{} Mitwirkende · {} Commits",
"{} failed": "{} fehlgeschlagen",
"{} files": "{} Dateien",
"{} files and {f} folder{}": "{} Dateien und {f} Ordner{:.0}",
"{} item{}": "Elemente: {}{:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} von 4 Hilfslinien — entlang von Linien ziehen, die senkrecht oder waagerecht sein sollen.",
"{} photos": "Fotos: {}",
"{} photos · exposures {} EV": "{} Fotos · Belichtungen {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} Fotos · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "{} Foto{} nicht lesbar",
"{} sample{}": "Farbproben: {}{:.0}",
"{} sec": "{} s",
"{} spot(s) on this photo": "{} Retuschierstelle(n) in diesem Foto",
"{} — show their photos": "{} — Fotos dieser Person anzeigen",
"· {dups} already in the library": "· {dups} bereits in der Bibliothek",
"· {n} photo{}": "· {n} Foto{}",
"… and {more} more": "… und {more} weitere",
"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 kann diese RAW-Variante noch nicht dekodieren ({}). Du bearbeitest die eingebettete JPEG-Vorschau der Kamera. Sie enthält bereits deren Bildstil (z. B. Monochrom) und Weißabgleich.",
"Filtered: {label}": "Gefiltert: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Ähnliche Fotos: {n}{:.0} · Ansicht ▸ Filter löschen zeigt alle Fotos",
"Moved; {} photo(s) relinked": "Verschoben; {} Fotos neu verknüpft",
"{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)",
", {} star{}": ", Sterne: {}{:.0}",
", {} label": ", Markierung: {}"
}

File diff suppressed because it is too large Load Diff

View File

@ -122,5 +122,59 @@
"{weekday}, {day}": "{day}日({weekday})",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日({weekday})",
"{year}": "{year}年",
"Remove “{name}” and its {count} photo{} from the library?": "「{name}」と写真{count}枚をライブラリから除去しますか?{:.0}"
"Remove “{name}” and its {count} photo{} from the library?": "「{name}」と写真{count}枚をライブラリから除去しますか?{:.0}",
" ({unsure} with several look-alike files: use Locate)": " ({unsure} with several look-alike files: use Locate)",
" · {:.0}% clipped": " · {:.0}% clipped",
" · {failed} couldn't be rendered": " · {failed} couldn't be rendered",
" · {} original{} left at the source": " · {} original{} left at the source",
" — not carried over: {}{}": " — not carried over: {}{}",
"+{} more": "+{} more",
"; {kept} already had a location": "; {kept} already had a location",
"; {outside} outside its time range": "; {outside} outside its time range",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
"Added {what} to {name}": "Added {what} to {name}",
"Auto Import: added {} photo{}": "Auto Import: added {} photo{}",
"Building previews for {} photos…": "Building previews for {} photos…",
"Couldn't open the editor: {e}": "Couldn't open the editor: {e}",
"Creates {} stacks from {} of {scope}": "Creates {} stacks from {} of {scope}",
"Done ({what}): {done} photo{plural}": "Done ({what}): {done} photo{plural}",
"Focus {:.0}": "Focus {:.0}",
"Folder “{}” (and its subfolders)": "Folder “{}” (and its subfolders)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Found {n} missing photo{}; {left} still missing{unsure}",
"Import failed: {e}": "Import failed: {e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Imported {profiles} profile{} (Profile browser ▸ their groups)",
"Insert at the cursor (also written {})": "Insert at the cursor (also written {})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Painting “{k}”: click photos to add or remove it · Esc stops",
"Previews ready for {done} photo{plural}": "Previews ready for {done} photo{plural}",
"Removed {what} from {name}": "Removed {what} from {name}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.",
"Replace {} with:": "Replace {} with:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).",
"Tagged {n} photo{} from the tracklog": "Tagged {n} photo{} from the tracklog",
"The SAM 3 download failed: {e}": "The SAM 3 download failed: {e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Unknown tag{s} {} stay{} as typed (see Tags)",
"Virtual copy “{name}” of {of}": "Virtual copy “{name}” of {of}",
"Working on {what} for {} photos…": "Working on {what} for {} photos…",
"{label} {index} of {n}": "{label} {index} of {n}",
"{name} opened for editing; it is stacked with the original": "{name} opened for editing; it is stacked with the original",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry",
"{pid} (missing)": "{pid} (missing)",
"{{date:…}} directives: {}": "{{date:…}} directives: {}",
"{} contributors · {} commits": "{} contributors · {} commits",
"{} failed": "{} failed",
"{} item{}": "{} item{}",
"{} photos · exposures {} EV": "{} photos · exposures {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} photos · {} · {:.0}° × {:.0}°",
"{} sample{}": "{} sample{}",
"{} sec": "{} sec",
"{} — show their photos": "{} — show their photos",
"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 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.",
"Filtered: {label}": "Filtered: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} similar photo{} · View ▸ Clear Filters to see all",
"Moved; {} photo(s) relinked": "Moved; {} photo(s) relinked",
"{} label (click more labels to show any of them)": "{} label (click more labels to show any of them)",
", {} star{}": ", {} star{}",
", {} label": ", {} label"
}

View File

@ -122,5 +122,59 @@
"{} spot(s) on this photo": "{} ponto(s) nesta foto",
"· {dups} already in the library": "· {dups} já na biblioteca",
"· {n} photo{}": "· {n} foto{:.0}",
"… and {more} more": "… e mais {more}"
"… and {more} more": "… e mais {more}",
" ({unsure} with several look-alike files: use Locate)": " ({unsure} with several look-alike files: use Locate)",
" · {:.0}% clipped": " · {:.0}% clipped",
" · {failed} couldn't be rendered": " · {failed} couldn't be rendered",
" · {} original{} left at the source": " · {} original{} left at the source",
" — not carried over: {}{}": " — not carried over: {}{}",
"+{} more": "+{} more",
"; {kept} already had a location": "; {kept} already had a location",
"; {outside} outside its time range": "; {outside} outside its time range",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
"Added {what} to {name}": "Added {what} to {name}",
"Auto Import: added {} photo{}": "Auto Import: added {} photo{}",
"Building previews for {} photos…": "Building previews for {} photos…",
"Couldn't open the editor: {e}": "Couldn't open the editor: {e}",
"Creates {} stacks from {} of {scope}": "Creates {} stacks from {} of {scope}",
"Done ({what}): {done} photo{plural}": "Done ({what}): {done} photo{plural}",
"Focus {:.0}": "Focus {:.0}",
"Folder “{}” (and its subfolders)": "Folder “{}” (and its subfolders)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Found {n} missing photo{}; {left} still missing{unsure}",
"Import failed: {e}": "Import failed: {e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Imported {profiles} profile{} (Profile browser ▸ their groups)",
"Insert at the cursor (also written {})": "Insert at the cursor (also written {})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Painting “{k}”: click photos to add or remove it · Esc stops",
"Previews ready for {done} photo{plural}": "Previews ready for {done} photo{plural}",
"Removed {what} from {name}": "Removed {what} from {name}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.",
"Replace {} with:": "Replace {} with:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).",
"Tagged {n} photo{} from the tracklog": "Tagged {n} photo{} from the tracklog",
"The SAM 3 download failed: {e}": "The SAM 3 download failed: {e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Unknown tag{s} {} stay{} as typed (see Tags)",
"Virtual copy “{name}” of {of}": "Virtual copy “{name}” of {of}",
"Working on {what} for {} photos…": "Working on {what} for {} photos…",
"{label} {index} of {n}": "{label} {index} of {n}",
"{name} opened for editing; it is stacked with the original": "{name} opened for editing; it is stacked with the original",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry",
"{pid} (missing)": "{pid} (missing)",
"{{date:…}} directives: {}": "{{date:…}} directives: {}",
"{} contributors · {} commits": "{} contributors · {} commits",
"{} failed": "{} failed",
"{} item{}": "{} item{}",
"{} photos · exposures {} EV": "{} photos · exposures {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} photos · {} · {:.0}° × {:.0}°",
"{} sample{}": "{} sample{}",
"{} sec": "{} sec",
"{} — show their photos": "{} — show their photos",
"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 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.",
"Filtered: {label}": "Filtered: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} similar photo{} · View ▸ Clear Filters to see all",
"Moved; {} photo(s) relinked": "Moved; {} photo(s) relinked",
"{} label (click more labels to show any of them)": "{} label (click more labels to show any of them)",
", {} star{}": ", {} star{}",
", {} label": ", {} label"
}

View File

@ -122,5 +122,59 @@
"{month} {year}": "{year}年{month}月",
"{weekday}, {day}": "{day}日 {weekday}",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}",
"Remove “{name}” and its {count} photo{} from the library?": "要将“{name}”及其 {count} 张照片从图库中移除吗?{:.0}"
"Remove “{name}” and its {count} photo{} from the library?": "要将“{name}”及其 {count} 张照片从图库中移除吗?{:.0}",
" ({unsure} with several look-alike files: use Locate)": " ({unsure} with several look-alike files: use Locate)",
" · {:.0}% clipped": " · {:.0}% clipped",
" · {failed} couldn't be rendered": " · {failed} couldn't be rendered",
" · {} original{} left at the source": " · {} original{} left at the source",
" — not carried over: {}{}": " — not carried over: {}{}",
"+{} more": "+{} more",
"; {kept} already had a location": "; {kept} already had a location",
"; {outside} outside its time range": "; {outside} outside its time range",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
"Added {what} to {name}": "Added {what} to {name}",
"Auto Import: added {} photo{}": "Auto Import: added {} photo{}",
"Building previews for {} photos…": "Building previews for {} photos…",
"Couldn't open the editor: {e}": "Couldn't open the editor: {e}",
"Creates {} stacks from {} of {scope}": "Creates {} stacks from {} of {scope}",
"Done ({what}): {done} photo{plural}": "Done ({what}): {done} photo{plural}",
"Focus {:.0}": "Focus {:.0}",
"Folder “{}” (and its subfolders)": "Folder “{}” (and its subfolders)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Found {n} missing photo{}; {left} still missing{unsure}",
"Import failed: {e}": "Import failed: {e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Imported {profiles} profile{} (Profile browser ▸ their groups)",
"Insert at the cursor (also written {})": "Insert at the cursor (also written {})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Painting “{k}”: click photos to add or remove it · Esc stops",
"Previews ready for {done} photo{plural}": "Previews ready for {done} photo{plural}",
"Removed {what} from {name}": "Removed {what} from {name}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.",
"Replace {} with:": "Replace {} with:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).",
"Tagged {n} photo{} from the tracklog": "Tagged {n} photo{} from the tracklog",
"The SAM 3 download failed: {e}": "The SAM 3 download failed: {e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Unknown tag{s} {} stay{} as typed (see Tags)",
"Virtual copy “{name}” of {of}": "Virtual copy “{name}” of {of}",
"Working on {what} for {} photos…": "Working on {what} for {} photos…",
"{label} {index} of {n}": "{label} {index} of {n}",
"{name} opened for editing; it is stacked with the original": "{name} opened for editing; it is stacked with the original",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry",
"{pid} (missing)": "{pid} (missing)",
"{{date:…}} directives: {}": "{{date:…}} directives: {}",
"{} contributors · {} commits": "{} contributors · {} commits",
"{} failed": "{} failed",
"{} item{}": "{} item{}",
"{} photos · exposures {} EV": "{} photos · exposures {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} photos · {} · {:.0}° × {:.0}°",
"{} sample{}": "{} sample{}",
"{} sec": "{} sec",
"{} — show their photos": "{} — show their photos",
"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 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.",
"Filtered: {label}": "Filtered: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} similar photo{} · View ▸ Clear Filters to see all",
"Moved; {} photo(s) relinked": "Moved; {} photo(s) relinked",
"{} label (click more labels to show any of them)": "{} label (click more labels to show any of them)",
", {} star{}": ", {} star{}",
", {} label": ", {} label"
}

View File

@ -122,5 +122,59 @@
" ({} in Recently Deleted)": "(其中 {} 張在「最近刪除」中)",
"Already in the library: {} photo{} selected in All Photos": "已在照片庫中:已在「所有照片」中選取 {} 張照片{:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 ▸ 還原,或永久刪除後重新匯入",
"Remove “{name}” and its {count} photo{} from the library?": "要將「{name}」及其 {count} 張照片從照片庫移除嗎?{:.0}"
"Remove “{name}” and its {count} photo{} from the library?": "要將「{name}」及其 {count} 張照片從照片庫移除嗎?{:.0}",
" ({unsure} with several look-alike files: use Locate)": " ({unsure} with several look-alike files: use Locate)",
" · {:.0}% clipped": " · {:.0}% clipped",
" · {failed} couldn't be rendered": " · {failed} couldn't be rendered",
" · {} original{} left at the source": " · {} original{} left at the source",
" — not carried over: {}{}": " — not carried over: {}{}",
"+{} more": "+{} more",
"; {kept} already had a location": "; {kept} already had a location",
"; {outside} outside its time range": "; {outside} outside its time range",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
"Added {what} to {name}": "Added {what} to {name}",
"Auto Import: added {} photo{}": "Auto Import: added {} photo{}",
"Building previews for {} photos…": "Building previews for {} photos…",
"Couldn't open the editor: {e}": "Couldn't open the editor: {e}",
"Creates {} stacks from {} of {scope}": "Creates {} stacks from {} of {scope}",
"Done ({what}): {done} photo{plural}": "Done ({what}): {done} photo{plural}",
"Focus {:.0}": "Focus {:.0}",
"Folder “{}” (and its subfolders)": "Folder “{}” (and its subfolders)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Found {n} missing photo{}; {left} still missing{unsure}",
"Import failed: {e}": "Import failed: {e}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Imported {profiles} profile{} (Profile browser ▸ their groups)",
"Insert at the cursor (also written {})": "Insert at the cursor (also written {})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Painting “{k}”: click photos to add or remove it · Esc stops",
"Previews ready for {done} photo{plural}": "Previews ready for {done} photo{plural}",
"Removed {what} from {name}": "Removed {what} from {name}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.",
"Replace {} with:": "Replace {} with:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).",
"Tagged {n} photo{} from the tracklog": "Tagged {n} photo{} from the tracklog",
"The SAM 3 download failed: {e}": "The SAM 3 download failed: {e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Unknown tag{s} {} stay{} as typed (see Tags)",
"Virtual copy “{name}” of {of}": "Virtual copy “{name}” of {of}",
"Working on {what} for {} photos…": "Working on {what} for {} photos…",
"{label} {index} of {n}": "{label} {index} of {n}",
"{name} opened for editing; it is stacked with the original": "{name} opened for editing; it is stacked with the original",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry",
"{pid} (missing)": "{pid} (missing)",
"{{date:…}} directives: {}": "{{date:…}} directives: {}",
"{} contributors · {} commits": "{} contributors · {} commits",
"{} failed": "{} failed",
"{} item{}": "{} item{}",
"{} photos · exposures {} EV": "{} photos · exposures {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} photos · {} · {:.0}° × {:.0}°",
"{} sample{}": "{} sample{}",
"{} sec": "{} sec",
"{} — show their photos": "{} — show their photos",
"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 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.",
"Filtered: {label}": "Filtered: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} similar photo{} · View ▸ Clear Filters to see all",
"Moved; {} photo(s) relinked": "Moved; {} photo(s) relinked",
"{} label (click more labels to show any of them)": "{} label (click more labels to show any of them)",
", {} star{}": ", {} star{}",
", {} label": ", {} label"
}

View File

@ -125,7 +125,7 @@ impl Contributor {
/// One line with everything we know, for tooltips.
pub fn summary(&self) -> String {
format!(
crate::i18n::tr_format!(
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}",
self.login,
self.prs,
@ -136,7 +136,7 @@ impl Contributor {
self.binary_added,
self.binary_deleted,
day(self.first_commit),
day(self.last_commit),
day(self.last_commit)
)
}
}
@ -232,43 +232,43 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label("Show");
ui.label(crate::i18n::tr("Show"));
for m in NameMode::ALL {
if ui.selectable_label(v.names == m, m.label()).clicked() {
if ui.selectable_label(v.names == m, crate::i18n::tr(m.label())).clicked() {
v.names = m;
}
}
ui.separator();
ui.label("Sort");
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
ui.label(crate::i18n::tr("Sort"));
egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, k.label().0).clicked() {
if ui.selectable_label(v.key == k, crate::i18n::tr(k.label().0)).clicked() {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(crate::i18n::tr("Reverse the order")).clicked() {
v.ascending = !v.ascending;
}
ui.separator();
let r = ui.selectable_label(!v.table, "Grab bag");
let r = ui.selectable_label(!v.table, crate::i18n::tr("Grab bag"));
crate::widgets::register(ui.ctx(), "button:creditsGrabBag", r.rect);
if r.clicked() {
v.table = false;
}
let r = ui.selectable_label(v.table, "Table");
let r = ui.selectable_label(v.table, crate::i18n::tr("Table"));
crate::widgets::register(ui.ctx(), "button:creditsTable", r.rect);
if r.clicked() {
v.table = true;
}
});
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_dim));
ui.label(RichText::new(crate::i18n::tr_format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_dim));
ui.separator();
egui::ScrollArea::both().id_salt("credits_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
ui.label(crate::i18n::tr("No contributor data was built into this copy."));
} else if v.table {
table(app, ui, &list, &mut v);
} else {
@ -289,7 +289,11 @@ fn table(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if ui
.button(RichText::new(format!("{}{arrow}", crate::i18n::tr(k.label().1))).strong())
.on_hover_text(crate::i18n::tr(k.label().0))
.clicked()
{
if v.key == k {
v.ascending = !v.ascending;
} else {
@ -318,14 +322,14 @@ fn table(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
pub fn models_ui(ui: &mut egui::Ui) {
if MODELS.is_empty() {
ui.label("No model credits were built into this copy.");
ui.label(crate::i18n::tr("No model credits were built into this copy."));
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::ScrollArea::both().id_salt("credits_models_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(h).strong());
ui.label(RichText::new(crate::i18n::tr(h)).strong());
}
ui.end_row();
for m in MODELS {

View File

@ -122,7 +122,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
app.export = None;
app.toast(ctx, "Export stopped unexpectedly");
app.toast(ctx, crate::i18n::tr("Export stopped unexpectedly"));
}
}
}

View File

@ -1107,6 +1107,26 @@ mod tests {
let r = h.request("ui.widgets", json!({}), t);
assert!(r.to_string().contains("label:importSource"), "source shown");
assert!(h.app.ui.local_roots.is_empty(), "no Local shortcut saved");
// The source facts were cached by a worker with the source-language default. They must
// still be rendered in German after switching, with the folder name kept verbatim.
assert_eq!(h.request("engine.execute", json!({"command": "app.language.german"}), t)["ok"], true);
h.settle(SETTLE);
fn texts(shape: &egui::epaint::Shape, out: &mut Vec<String>) {
match shape {
egui::epaint::Shape::Text(shape) => out.push(shape.galley.job.text.clone()),
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| texts(shape, out)),
_ => {}
}
}
let mut painted = Vec::new();
for shape in &h.view.shapes {
texts(&shape.shape, &mut painted);
}
assert!(painted.iter().any(|text| text == &format!("Ordner „{name}“ (und seine Unterordner)")), "{painted:?}");
for label in ["Quelle", "Übertragen", "Stichwörter", "Vorgabe"] {
assert!(painted.iter().any(|text| text == label), "{label}: {painted:?}");
}
assert_eq!(h.request("engine.execute", json!({"command": "app.language.english"}), t)["ok"], true);
// a camera / card folder: copied into the library by default
h.app.ui.dialog = None;
let r = h.request("engine.execute", json!({"command": "file.addFromDevice", "params": {"path": sub.to_string_lossy()}}), t);

View File

@ -121,6 +121,7 @@ language_table! {
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json");
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
}
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
@ -287,6 +288,68 @@ pub fn display_time(iso: &str) -> String {
}
}
/// Built-in colour names are translated; custom label names are user data.
pub fn color_label(catalog: &lightcraft_catalog::Catalog, label: lightcraft_catalog::ColorLabel) -> String {
catalog.custom_label_name(label).map_or_else(|| tr(&catalog.label_name(label)).to_string(), str::to_string)
}
/// A legacy smart-album filter summary, using the same display labels as filter chips.
pub fn filter_label(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String {
if language() == Locale::En {
filter.describe()
} else {
lightcraft_engine::filter_chips(filter, catalog)
.iter()
.map(|chip| crate::panels::chips::display_label(chip, filter, catalog))
.collect::<Vec<_>>()
.join(" · ")
}
}
/// A rule summary for display, keeping free-text rule values verbatim.
pub fn rules_label(rules: &lightcraft_catalog::RuleSet) -> String {
fn describe(rules: &lightcraft_catalog::RuleSet, depth: usize) -> String {
use lightcraft_catalog::{
Match, Rule,
rules::{FIELDS, Kind, ops_for},
};
if depth >= 16 {
return tr("Nested group").to_string();
}
let parts: Vec<String> = rules
.rules
.iter()
.map(|rule| match rule {
Rule::Group { group } => format!("({})", describe(group, depth + 1)),
Rule::Field { field, op, value } => {
let Some((_, label, kind)) = FIELDS.iter().find(|entry| entry.0 == field) else { return field.clone() };
let operator = ops_for(*kind).iter().find(|entry| entry.0 == op).map_or(op.as_str(), |entry| entry.1);
let text = match kind {
Kind::Choice(_) | Kind::Bool => {
tr(value.as_str().unwrap_or(if value.as_bool() == Some(true) { "true" } else { "false" })).to_string()
}
_ if matches!(op.as_str(), "inLast" | "notInLast") => format!(
"{} {}",
value.get("n").unwrap_or(&serde_json::Value::Null),
tr(value.get("unit").and_then(serde_json::Value::as_str).unwrap_or("days"))
),
_ => value.as_str().map(str::to_string).unwrap_or_else(|| if value.is_null() { String::new() } else { value.to_string() }),
};
format!("{} {} {text}", tr(label), tr(operator)).trim().to_string()
}
})
.collect();
let join = match rules.mode {
Match::All => tr("and"),
Match::Any | Match::None => tr("or"),
};
let text = parts.join(&format!(" {join} "));
if rules.mode == Match::None { format!("{} ({text})", tr("none of")) } else { text }
}
// Preserve the established source-language summary.
if language() == Locale::En { rules.describe() } else { describe(rules, 0) }
}
#[cfg(test)]
mod tests {
use super::*;
@ -359,7 +422,10 @@ mod tests {
assert_eq!(Locale::parse_tag("pt-BR"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("pt_BR.UTF-8"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("pt"), Some(Locale::PtBr));
assert_eq!(Locale::parse_tag("de"), None);
for tag in ["de", "de-DE", "de_AT.UTF-8", "de-CH"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::De), "{tag}");
}
assert_eq!(Locale::parse_tag("xx"), None);
}
#[test]
@ -481,6 +547,7 @@ mod tests {
("app.language.simplifiedChinese", Locale::ZhHans),
("app.language.japanese", Locale::Ja),
("app.language.portuguese", Locale::PtBr),
("app.language.german", Locale::De),
];
// One command per language, and every command reachable from the menu table.
assert_eq!(commands.len(), Locale::ALL.len());
@ -515,6 +582,153 @@ mod tests {
}
}
#[test]
fn german_catalog_covers_catalogs_commands_controls_and_rule_labels() {
let catalog = Locale::De.catalog();
for language in Locale::ALL {
for key in language.catalog().keys() {
assert!(catalog.contains_key(key), "German lacks {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));
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);
for line in crate::panels::dialogs::WHATS_NEW.lines().map(str::trim) {
if line.is_empty() || line.starts_with("# ") {
continue;
}
let label = line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line);
labels.push(label);
}
for label in labels {
assert!(catalog.contains_key(label), "German lacks display label {label:?}");
}
}
#[test]
fn german_switches_via_menu_and_control_persists_and_formats_dates() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.german", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::De);
assert_eq!(tr("File"), "Datei");
assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true));
assert_eq!(date_group_label("2026-09-20", false), "Sonntag, 20.09.2026");
assert_eq!(date_group_label("2026-09-20", true), "Sonntag, 20.");
assert_eq!(date_group_label("2026-09", false), "09/2026");
assert_eq!(display_time("2026-09-20T16:04:05"), "Sonntag, 20.09.2026 16:04:05");
assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 Foto importiert");
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 Fotos importiert");
assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Color", n = 12), "12 Fotos zu „Color“ hinzugefügt");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"de""#));
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::De);
let ctx = egui::Context::default();
for code in ["de-DE", "de_AT.UTF-8", "de-CH"] {
let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") };
assert_eq!(reply["ok"], true);
assert_eq!(language(), Locale::De);
}
assert_eq!(builtin_label("Warm Glow", false), "Warm Glow");
assert_eq!(builtin_label("Warm Glow", true), "Warmer Glanz");
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
let rules = lightcraft_catalog::RuleSet {
mode: lightcraft_catalog::Match::All,
rules: vec![lightcraft_catalog::Rule::Field { field: "keywords".into(), op: "contains".into(), value: serde_json::json!("Color") }],
};
assert_eq!(rules_label(&rules), "Stichwörter enthält Color");
let filter = lightcraft_catalog::Filter {
labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue],
..Default::default()
};
assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Rot oder Blau");
app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap();
assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Color oder Blau");
set_language(Locale::En);
}
#[test]
fn german_panels_dialogs_and_glyphs_are_painted_without_extra_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::De);
assert!(text.contains("Meine Fotos") && text.contains("Alle Fotos"), "{text}");
app.ui.view = crate::state::ViewMode::People;
let text = painted_text(&ctx, &mut app, Locale::De);
assert!(text.contains("Benannte Personen"), "{text}");
for (command, title) in [("app.about", "Über LightCraft"), ("app.shortcuts", "Tastenkürzel"), ("app.settings", "Einstellungen")] {
app.ui.dialog = None;
app.run(command, serde_json::json!({})).unwrap();
let text = painted_text(&ctx, &mut app, Locale::De);
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() {
assert!(fonts.has_glyph(&font, ch), "missing German glyph {ch}");
}
}
});
set_language(Locale::En);
}
#[test]
fn german_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) => {
bounds.push((shape.galley.job.text.clone(), egui::Rect::from_min_size(shape.pos, shape.galley.size())))
}
egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, bounds)),
_ => {}
}
}
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);
}
}
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 == "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);
}
/// User-named menu items (presets, albums, label sets) are never translated; built-in labels are.
#[test]
fn menu_labels_translate_but_user_names_survive() {

View File

@ -444,7 +444,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(_) => {
app.scan = None;
app.toast(ctx, "Scan failed");
app.toast(ctx, crate::i18n::tr("Scan failed"));
return;
}
};
@ -466,7 +466,7 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
return;
}
if out.candidates.is_empty() {
app.toast(ctx, "No photos found");
app.toast(ctx, crate::i18n::tr("No photos found"));
return;
}
app.renderer.forget_imports();
@ -602,7 +602,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
Ok(run) => task.run = Some(run),
Err(e) => {
log::warn!("import: {e}");
app.toast(ctx, format!("Import failed: {e}"));
app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e));
return;
}
}
@ -680,7 +680,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
app.session.merge_undo(steps, &label);
if task.auto {
if task.imported > 0 {
app.toast(ctx, format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }));
app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }));
}
return;
}
@ -710,7 +710,7 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
if task.params["mode"] == "move" {
msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved));
if task.kept > 0 {
msg.push_str(&format!(" · {} original{} left at the source", task.kept, plural(task.kept)));
msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, plural(task.kept)));
}
}
if task.duplicates > 0 {
@ -854,9 +854,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
// (whether each source is a folder is checked off the UI thread, once)
let joined = d.sources.join("\n");
let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| {
source_summary(&s.split('\n').map(str::to_string).collect::<Vec<_>>())
inspect_sources(&s.split('\n').map(str::to_string).collect::<Vec<_>>())
})
.unwrap_or_else(|| format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" }));
.map(|summary| summary.display())
.unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" }));
let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined);
register(ui.ctx(), "label:importSource", r.rect);
});
@ -892,7 +893,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
}
});
let r = ui.label(
egui::RichText::new(match (d.copy, d.move_files) {
egui::RichText::new(crate::i18n::tr(match (d.copy, d.move_files) {
(true, false) => {
"Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are."
}
@ -900,7 +901,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are."
}
_ => "Add in place: the library references the files where they are; nothing is copied or moved.",
})
}))
.color(if d.copy && d.move_files { t.caution } else { t.text_dim })
.small(),
);
@ -908,7 +909,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
if d.copy {
field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let shown = if d.destination.trim().is_empty() { "Library Originals".to_string() } else { d.destination.clone() };
let shown = if d.destination.trim().is_empty() { crate::i18n::tr("Library Originals").to_string() } else { d.destination.clone() };
ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label));
if app.services.pick_folder.is_some()
&& crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked()
@ -929,9 +930,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
];
let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() };
let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1);
let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(cur).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
for (k, label) in opts {
let r = ui.selectable_label(key == k, label);
let r = ui.selectable_label(key == k, crate::i18n::tr(label));
register(ui.ctx(), format!("button:importOrganize-{k}"), r.rect);
if r.clicked() {
d.organize = k.to_string();
@ -947,7 +948,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
let folders_id = egui::Id::new("import-folder-template");
let tags_open = field(ui, "Template", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let w = tag_field_width(ui);
let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w));
register(ui.ctx(), "field:importFolderTemplate", r.rect);
tag_toggle(ui, "importFolders")
@ -979,8 +980,13 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
let rename_id = egui::Id::new("import-rename");
let tags_open = field(ui, "Rename", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let r = ui.add(egui::TextEdit::singleline(&mut d.rename).id(rename_id).hint_text("keep names — or e.g. {date}_{seq:3}").desired_width(w));
let w = tag_field_width(ui);
let r = ui.add(
egui::TextEdit::singleline(&mut d.rename)
.id(rename_id)
.hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}"))
.desired_width(w),
);
register(ui.ctx(), "field:importRename", r.rect);
tag_toggle(ui, "importRename")
});
@ -1034,8 +1040,8 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
field(ui, "Album", |ui| {
let cur = match d.album {
Some(a) => albums.iter().find(|x| x.0 == a).map(|x| x.1.clone()).unwrap_or_default(),
None if !d.new_album.is_empty() => "New album".into(),
None => "None".into(),
None if !d.new_album.is_empty() => crate::i18n::tr("New album").into(),
None => crate::i18n::tr("None").into(),
};
egui::ComboBox::from_id_salt("import-album").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() {
@ -1045,7 +1051,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")).clicked() {
d.album = None;
if d.new_album.is_empty() {
d.new_album = "Imported Photos".into();
d.new_album = crate::i18n::tr("Imported Photos").into();
}
}
for (id, name) in &albums {
@ -1061,13 +1067,25 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
}
});
field(ui, "Preset", |ui| {
let cur = app.session.presets.iter().find(|p| p.id == d.preset).map(|p| p.name.clone()).unwrap_or_else(|| "None".into());
let cur = app
.session
.presets
.iter()
.find(|p| p.id == d.preset)
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
.unwrap_or_else(|| crate::i18n::tr("None").into());
egui::ComboBox::from_id_salt("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| {
if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() {
d.preset.clear();
}
for p in &app.session.presets {
if ui.selectable_label(d.preset == p.id, format!("{} — {}", p.group, p.name)).clicked() {
if ui
.selectable_label(
d.preset == p.id,
format!("{} — {}", crate::i18n::builtin_label(&p.group, p.builtin), crate::i18n::builtin_label(&p.name, p.builtin)),
)
.clicked()
{
d.preset = p.id.clone();
}
}
@ -1075,7 +1093,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
});
if !app.session.metadata_presets.is_empty() {
field(ui, "Metadata", |ui| {
let cur = if d.metadata_preset.is_empty() { "None".to_string() } else { d.metadata_preset.clone() };
let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() };
egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() {
d.metadata_preset.clear();
@ -1161,6 +1179,13 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
}
}
/// Reserve the actual translated tag button width beside a template field.
pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(crate::i18n::tr("Tags").to_string(), t.semibold(11.5), t.text);
(ui.available_width() - (galley.size().x + 18.0).max(37.0) - ui.spacing().item_spacing.x).max(80.0)
}
/// The "Tags" toggle beside a template field (`button:<key>Tags`); returns whether the tag help
/// ([`tag_help`]) is open.
pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool {
@ -1180,7 +1205,12 @@ pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) {
let unknown = lightcraft_engine::rename::unknown_tokens(template);
if !unknown.is_empty() {
let s = if unknown.len() == 1 { "" } else { "s" };
let text = format!("Unknown tag{s} {} stay{} as typed (see Tags)", unknown.join(" "), if unknown.len() == 1 { "s" } else { "" });
let text = crate::i18n::tr_format!(
"Unknown tag{s} {} stay{} as typed (see Tags)",
unknown.join(" "),
if unknown.len() == 1 { "s" } else { "" },
s = s
);
ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution));
}
}
@ -1222,22 +1252,22 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id:
if tok.aliases.is_empty() {
"Insert at the cursor".to_string()
} else {
format!("Insert at the cursor (also written {})", tok.aliases.join(", "))
crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", "))
},
);
register(ui.ctx(), format!("button:{key}Tag-{i}"), r.rect);
if r.clicked() {
insert_at_cursor(ui.ctx(), edit_id, text, tok.tag);
}
ui.label(egui::RichText::new(tok.meaning).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr(tok.meaning)).color(t.text_label));
ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim));
ui.end_row();
}
});
let directives: Vec<String> = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {m}")).collect();
ui.label(egui::RichText::new(format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small());
let directives: Vec<String> = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {}", crate::i18n::tr(m))).collect();
ui.label(egui::RichText::new(crate::i18n::tr_format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small());
for n in TEMPLATE_NOTES {
ui.label(egui::RichText::new(format!("• {n}")).color(t.text_dim).small());
ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(n))).color(t.text_dim).small());
}
});
});
@ -1284,17 +1314,41 @@ pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option<Stri
/// The import source in a few words: a folder's name, a file's name, or "N files and folders".
pub fn source_summary(sources: &[String]) -> String {
inspect_sources(sources).display()
}
/// Facts cached by the filesystem worker, formatted in the UI thread's active language.
/// Caching a translated String on the worker would always use its default language (English)
/// and also retain that language after the user changes the UI language.
#[derive(Clone)]
enum SourceSummary {
Folder(String),
File(String),
Many { files: usize, folders: usize },
}
impl SourceSummary {
fn display(&self) -> String {
match self {
Self::Folder(name) => crate::i18n::tr_format!("Folder “{}” (and its subfolders)", name),
Self::File(name) => name.clone(),
Self::Many { files, folders } => match (files, folders) {
(_, 0) => crate::i18n::tr_format!("{} files", files),
(0, f) => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f),
(_, f) => crate::i18n::tr_format!("{} files and {f} folder{}", files, if *f == 1 { "" } else { "s" }, f = f),
},
}
}
}
fn inspect_sources(sources: &[String]) -> SourceSummary {
let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string());
match sources {
[one] if std::path::Path::new(one).is_dir() => format!("Folder “{}” (and its subfolders)", name(one)),
[one] => name(one),
[one] if std::path::Path::new(one).is_dir() => SourceSummary::Folder(name(one)),
[one] => SourceSummary::File(name(one)),
many => {
let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count();
match folders {
0 => crate::i18n::tr_format!("{} files", many.len()),
f if f == many.len() => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f),
f => crate::i18n::tr_format!("{} files and {f} folder{}", many.len() - f, if f == 1 { "" } else { "s" }, f = f),
}
SourceSummary::Many { files: many.len().saturating_sub(folders), folders }
}
}
}
@ -1305,7 +1359,7 @@ fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(78.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(78.0);
ui.label(egui::RichText::new(label).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label));
});
add(ui)
})

View File

@ -332,11 +332,13 @@ impl LightcraftApp {
let plural = if done == 1 { "" } else { "s" };
let mut msg = match (b.error(), b.what) {
(Some(e), what) => format!("{}: {e}", if what.is_empty() { "previews" } else { what }),
(None, "") => format!("Previews ready for {done} photo{plural}"),
(None, what) => format!("Done ({what}): {done} photo{plural}"),
(None, "") => crate::i18n::tr_format!("Previews ready for {done} photo{plural}", done = done, plural = plural),
(None, what) => {
crate::i18n::tr_format!("Done ({what}): {done} photo{plural}", done = done, plural = plural, what = crate::i18n::tr(what))
}
};
if failed > 0 {
msg.push_str(&format!(" · {failed} couldn't be rendered"));
msg.push_str(&crate::i18n::tr_format!(" · {failed} couldn't be rendered", failed = failed));
}
self.toast(ctx, msg);
}
@ -344,8 +346,8 @@ impl LightcraftApp {
if self.ui.preview_build_seen != Some((key, false)) {
self.ui.preview_build_seen = Some((key, false));
let msg = match b.what {
"" => format!("Building previews for {} photos…", b.total),
what => format!("Working on {what} for {} photos…", b.total),
"" => crate::i18n::tr_format!("Building previews for {} photos…", b.total),
what => crate::i18n::tr_format!("Working on {what} for {} photos…", b.total, what = crate::i18n::tr(what)),
};
self.toast(ctx, msg);
}
@ -361,8 +363,16 @@ impl LightcraftApp {
(Some((n, e)), false) => {
self.ui.unsaved_seen = true;
let t = ctx.input(|i| i.time);
let what = if n == 1 { "1 change".to_string() } else { format!("{n} changes") };
self.ui.toast = Some((format!("{what} saved in memory but not written to disk: {e} — LightCraft will retry"), t + 6.0, None));
self.ui.toast = Some((
crate::i18n::tr_format!(
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry",
if n == 1 { "" } else { "s" },
n = n,
e = e
),
t + 6.0,
None,
));
}
(None, true) => {
self.ui.unsaved_seen = false;
@ -415,7 +425,7 @@ impl LightcraftApp {
(Some(e), false) if e.contains("cancelled") => {
self.toast(ctx, crate::i18n::tr("SAM 3 download stopped: it resumes where it left off next time."))
}
(Some(e), false) => self.toast_error(ctx, format!("The SAM 3 download failed: {e}")),
(Some(e), false) => self.toast_error(ctx, crate::i18n::tr_format!("The SAM 3 download failed: {e}", e = e)),
(None, false) if download.finished => {
self.toast_error(ctx, crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready."))
}

View File

@ -361,19 +361,26 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
item(
"library.buildPreviews",
json!({"size": "standard", "edge": app.ui.settings.preview_edge}),
format!("Build Standard-Sized Previews ({scope})"),
crate::i18n::tr_format!("Build Standard-Sized Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
!running,
None,
),
item(
"library.buildPreviews",
json!({"size": "full"}),
crate::i18n::tr_format!("Build 1:1 Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
!running,
None,
),
item("library.buildPreviews", json!({"size": "full"}), format!("Build 1:1 Previews ({scope})"), None, !running, None),
item("library.cancelPreviews", Value::Null, "Stop Building Previews", None, running, None),
MenuNode::Separator,
// (read and written on a worker thread: the originals may be on a slow drive)
item(
"library.smartPreviews",
json!({"background": true}),
format!("Build Smart Previews ({scope})"),
crate::i18n::tr_format!("Build Smart Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
app.session.media.smart_dir.is_some() && !running,
None,
@ -381,7 +388,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
item(
"library.smartPreviews",
json!({"discard": true, "background": true}),
format!("Discard Smart Previews ({scope})"),
crate::i18n::tr_format!("Discard Smart Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
app.session.media.smart_dir.is_some() && !running,
None,
@ -414,7 +421,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
.map(|(l, sc)| {
let name = format!("{l:?}");
let label = match app.session.catalog.custom_label_name(*l) {
Some(custom) => format!("{custom} ({name})"),
Some(custom) => format!("{custom} ({})", crate::i18n::tr(&name)),
None => name.clone(),
};
item(
@ -438,7 +445,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
v.push(item(
"label.applySet",
json!({"name": name}),
format!("Label Set: {name}"),
format!("{}: {name}", crate::i18n::tr("Label Set")),
None,
true,
Some(current.as_deref() == Some(name)),

View File

@ -18,6 +18,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"),
("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"),
];
/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from
@ -35,6 +36,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
"app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant),
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
"app.language.portuguese" => Some(crate::i18n::Locale::PtBr),
"app.language.german" => Some(crate::i18n::Locale::De),
_ => None,
}
}
@ -181,10 +183,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[
fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) {
if app.ui.right == p {
app.ui.right = RightPanel::None;
app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = name));
app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name)));
} else {
app.ui.right = p;
app.toast(ctx, crate::i18n::tr_format!("{name} On", name = name));
app.toast(ctx, crate::i18n::tr_format!("{name} On", name = crate::i18n::tr(name)));
if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) {
app.ui.view = ViewMode::Detail;
}
@ -275,7 +277,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.view = ViewMode::PhotoGrid;
}
if let Some(k) = app.ui.keyword_painter.clone() {
app.toast(&ctx, format!("Painting “{k}”: click photos to add or remove it · Esc stops"));
app.toast(&ctx, crate::i18n::tr_format!("Painting “{k}”: click photos to add or remove it · Esc stops", k = k));
}
Ok(json!({"keyword": app.ui.keyword_painter}))
}
@ -374,7 +376,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.zoom = Zoom::Fit;
app.ui.tool.clear();
let _ = app.session.end_interaction();
app.toast(&ctx, "Slideshow · Space pauses · Esc ends");
app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends"));
Ok(json!({"interval": interval}))
}
"view.fullScreenPreview" => {
@ -915,10 +917,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
if let Some(f) = app.services.open_with.as_mut()
&& let Err(e) = f(&path, &editor)
{
app.toast(&ctx, format!("Couldn't open the editor: {e}"));
app.toast(&ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e));
}
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
app.toast(&ctx, format!("{name} opened for editing; it is stacked with the original"));
app.toast(&ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name));
Ok(r)
}
"dialog.cull" => {
@ -1020,8 +1022,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
let n = v["found"].as_array().map_or(0, Vec::len);
let left = v["missing"].as_u64().unwrap_or(0);
let unsure = v["ambiguous"].as_array().map_or(0, Vec::len);
let unsure = if unsure > 0 { format!(" ({unsure} with several look-alike files: use Locate)") } else { String::new() };
app.toast(ctx, format!("Found {n} missing photo{}; {left} still missing{unsure}", if n == 1 { "" } else { "s" }));
let unsure = if unsure > 0 {
crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure)
} else {
String::new()
};
app.toast(
ctx,
crate::i18n::tr_format!(
"Found {n} missing photo{}; {left} still missing{unsure}",
if n == 1 { "" } else { "s" },
left = left,
n = n,
unsure = unsure
),
);
app.ui.last_find_missing = Some(v);
}
Err(e) => app.toast(ctx, e),
@ -1066,14 +1081,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
if let Ok(v) = &r {
let n = v["tagged"].as_u64().unwrap_or(0);
let sk = &v["skipped"];
let mut msg = format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" });
let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n);
let outside = sk["outside"].as_u64().unwrap_or(0);
if outside > 0 {
msg += &format!("; {outside} outside its time range");
msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside);
}
let kept = sk["hasGps"].as_u64().unwrap_or(0);
if kept > 0 {
msg += &format!("; {kept} already had a location");
msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept);
}
app.toast(&egui::Context::default(), msg);
}
@ -1154,7 +1169,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string())
};
if profiles > 0 {
app.toast(&ctx, format!("Imported {profiles} profile{} (Profile browser ▸ their groups)", if profiles == 1 { "" } else { "s" }));
app.toast(
&ctx,
crate::i18n::tr_format!(
"Imported {profiles} profile{} (Profile browser ▸ their groups)",
if profiles == 1 { "" } else { "s" },
profiles = profiles
),
);
}
if let Ok(v) = &r {
let n = v["imported"].as_array().map_or(0, Vec::len);
@ -1182,7 +1204,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
skipped.dedup();
if !skipped.is_empty() {
let names: Vec<&str> = skipped.iter().take(3).copied().collect();
msg += &format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" });
msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" });
}
app.toast(&ctx, msg);
if n > 0 {

View File

@ -320,7 +320,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog)
} else {
let ev: Vec<String> =
info["ev"].as_array().map(|a| a.iter().filter_map(Value::as_f64).map(|v| format!("{v:+.1}")).collect()).unwrap_or_default();
format!("{} photos · exposures {} EV", ev.len(), ev.join(" / "))
crate::i18n::tr_format!("{} photos · exposures {} EV", ev.len(), ev.join(" / "))
};
ui.label(egui::RichText::new(txt).color(t.text_dim));
}

View File

@ -49,18 +49,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
/// Width of the centre group: the rating/flag pill, then Copy/Paste Edit Settings and its gear.
const PILL_W: f32 = 196.0;
const CENTRE_W: f32 = PILL_W + 10.0 + 136.0 + 4.0 + 30.0;
/// The rating/flag pill and copy/paste settings, between `from` and `to` (the side groups): the
/// copy buttons go first when there is no room (they are in the Edit menu too), then the pill.
fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: f32) {
let t = Tokens::get(ui.ctx());
let has_clip = app.session.clipboard.is_some();
let label = crate::i18n::tr(if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" });
let copy_width = (ui.painter().layout_no_wrap(label.to_string(), t.font(13.0), t.text_label).size().x + 24.0).max(136.0);
let centre_width = PILL_W + 10.0 + copy_width + 4.0 + 30.0;
let room = to - from;
if room < PILL_W {
return;
}
let with_copy = room >= CENTRE_W;
let w = if with_copy { CENTRE_W } else { PILL_W };
let with_copy = room >= centre_width;
let w = if with_copy { centre_width } else { PILL_W };
// where it sits with room to spare (slightly left of centre), kept between the side groups
let left = (full.center().x - 60.0 - PILL_W / 2.0).clamp(from, to - w);
let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned());
@ -99,23 +102,15 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to:
if !with_copy {
return;
}
let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(136.0, 30.0));
let has_clip = app.session.clipboard.is_some();
let label = if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" };
let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(copy_width, 30.0));
let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click());
register(ui.ctx(), "button:copySettings", copy_r);
ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas });
ui.painter().text(
copy_r.center(),
Align2::CENTER_CENTER,
crate::i18n::tr(label),
t.font(13.0),
if active.is_some() { t.text_label } else { t.text_disabled },
);
ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled });
if cresp.clicked() && active.is_some() {
let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) };
let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" };
app.toast(ui.ctx(), msg);
app.toast(ui.ctx(), crate::i18n::tr(msg));
}
let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0));
let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click());

View File

@ -28,6 +28,52 @@ fn chip_width(ui: &egui::Ui, label: &str, font: egui::FontId) -> f32 {
ui.painter().layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x + 12.0 + 22.0
}
/// Localise generated chip labels without rewriting search text or metadata values.
pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String {
use crate::i18n::{date_group_label, rules_label, tr};
if crate::i18n::language() == crate::i18n::Locale::En {
return chip.label.clone();
}
if chip.clear.get("ruleSet").is_some()
&& let Some(rules) = &filter.rule_set
{
return format!("{}: {}", tr("Rules"), rules_label(rules));
}
if chip.clear.get("label").is_some() {
let mut labels = filter.labels.clone();
labels.extend(filter.label.filter(|label| !labels.contains(label)));
let names = labels.iter().map(|label| crate::i18n::color_label(catalog, *label)).collect::<Vec<_>>();
return format!("{}: {}", tr("Label"), names.join(&format!(" {} ", tr("or"))));
}
if chip.clear.get("dateFrom").is_some() || chip.clear.get("dateTo").is_some() {
return match (&filter.date_from, &filter.date_to) {
(Some(from), Some(to)) => format!("{} {} – {}", tr("Captured"), date_group_label(from, false), date_group_label(to, false)),
(Some(from), None) => format!("{} {}", tr("Captured from"), date_group_label(from, false)),
(None, Some(to)) => format!("{} {}", tr("Captured until"), date_group_label(to, false)),
_ => chip.label.clone(),
};
}
if chip.clear.get("only").is_some() {
return format!("{} {}", tr("Only"), crate::i18n::tr_format!("{n} photos", n = filter.only.len()));
}
if let Some((prefix, value)) = chip.label.split_once(": ") {
let value = if matches!(prefix, "Flag" | "Type") {
tr(value).to_string()
} else if prefix == "Date" {
filter.date.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false))
} else if prefix == "Imported" {
filter.imported.as_ref().map_or_else(|| value.to_string(), |date| date_group_label(date, false))
} else {
value.to_string()
};
return format!("{}: {value}", tr(prefix));
}
if let Some(value) = chip.label.strip_prefix("Rating ") {
return format!("{} {value}", tr("Rating"));
}
tr(&chip.label).to_string()
}
/// Draws the strip; does nothing without chips.
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
if chips.is_empty() {
@ -42,7 +88,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
let mut x = bar.left() + 20.0;
let mut shown = 0;
for (i, c) in chips.iter().enumerate() {
let w = chip_width(ui, &c.label, font.clone());
let label = display_label(c, &app.session.filter, &app.session.catalog);
let w = chip_width(ui, &label, font.clone());
// always show the first chip; keep room for the tail unless this is the last one that fits
let room = right - x - if i + 1 == chips.len() { 90.0 } else { TAIL };
if i > 0 && w > room {
@ -51,7 +98,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
let r = Rect::from_min_size(pos2(x, bar.center().y - 11.0), vec2(w.min(right - x), 22.0));
ui.painter().rect(r, 11.0, t.field, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), StrokeKind::Inside);
let text_r = Rect::from_min_max(r.min, pos2(r.right() - 22.0, r.max.y));
ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &c.label, font.clone(), t.text);
ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &label, font.clone(), t.text);
let xr = Rect::from_center_size(pos2(r.right() - 12.0, r.center().y), vec2(18.0, 18.0));
let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text(crate::i18n::tr("Remove this filter"));
register(ui.ctx(), format!("chip:{i}"), xr);
@ -77,10 +124,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) {
child.spacing_mut().item_spacing.x = 8.0;
let hidden = &chips[shown..];
if !hidden.is_empty() {
let more = crate::widgets::text_button(&mut child, "chipsMore", &format!("+{} more", hidden.len()), false);
let more = crate::widgets::text_button(&mut child, "chipsMore", &crate::i18n::tr_format!("+{} more", hidden.len()), false);
egui::Popup::menu(&more).show(|ui| {
for c in hidden {
if ui.button(format!("{} ✕", c.label)).on_hover_text(crate::i18n::tr("Remove this filter")).clicked() {
if ui
.button(format!("{} ✕", display_label(c, &app.session.filter, &app.session.catalog)))
.on_hover_text(crate::i18n::tr("Remove this filter"))
.clicked()
{
let _ = app.run("library.filter", c.clear.clone());
if c.clear.get("text").is_some() {
app.ui.search.clear();

View File

@ -631,7 +631,7 @@ fn region_overlay(
let cross = Stroke::new(1.4, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(210) });
p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross);
p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross);
if resp.on_hover_text("Remove this face box (undo with Edit ▸ Undo)").clicked() {
if resp.on_hover_text(crate::i18n::tr("Remove this face box (undo with Edit ▸ Undo)")).clicked() {
edit = Some(RegionEdit::Remove(index));
}
}
@ -668,8 +668,9 @@ fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) {
}
let t = Tokens::get(ui.ctx());
let p = ui.painter_at(canvas);
let label = chips.iter().map(|c| c.label.as_str()).collect::<Vec<_>>().join(" · ");
let g = p.layout_no_wrap(format!("Filtered: {label}"), t.font(12.0), Color32::from_gray(225));
let label =
chips.iter().map(|c| crate::panels::chips::display_label(c, &app.session.filter, &app.session.catalog)).collect::<Vec<_>>().join(" · ");
let g = p.layout_no_wrap(crate::i18n::tr_format!("Filtered: {label}", label = label), t.font(12.0), Color32::from_gray(225));
let w = (g.size().x + 40.0).min((canvas.width() - 24.0).max(60.0));
let r = Rect::from_min_size(canvas.min + vec2(12.0, 3.0), vec2(w, 20.0));
let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click());
@ -681,7 +682,7 @@ fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) {
let cross = Stroke::new(1.3, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(190) });
p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross);
p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross);
if resp.on_hover_text("Click to clear these filters").clicked() {
if resp.on_hover_text(crate::i18n::tr("Click to clear these filters")).clicked() {
let _ = app.run("library.clearFilter", json!({}));
}
}

View File

@ -138,17 +138,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default();
let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" };
ui.label(
egui::RichText::new(format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"])).color(t.text_dim),
egui::RichText::new(crate::i18n::tr_format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"], scope = crate::i18n::tr(scope))).color(t.text_dim),
);
}
Dialog::Cull { reject_below, pick_best } => {
let n = app.session.selection.ids.len();
let scope = if n > 1 { crate::i18n::tr_format!("the {n} selected photos", n = n) } else { crate::i18n::tr_format!("the {} photos in view", app.session.visible_cloned().len()) };
ui.label(egui::RichText::new(format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).")).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr_format!("Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).", scope = scope)).color(t.text_label));
ui.add_space(6.0);
let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text("Reject below focus").step_by(1.0));
let r = ui.add(egui::Slider::new(reject_below, 0.0..=80.0).text(crate::i18n::tr("Reject below focus")).step_by(1.0));
crate::widgets::register(ui.ctx(), "field:cullReject", r.rect);
ui.label(egui::RichText::new(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." }).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr(if *reject_below > 0.0 { "Blurry photos below the score are flagged as rejects." } else { "0: nothing is rejected." })).color(t.text_dim));
let r = ui.checkbox(pick_best, crate::i18n::tr("Pick the sharpest photo of each burst"));
crate::widgets::register(ui.ctx(), "check:cullPick", r.rect);
ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim));
@ -159,15 +159,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let l = line.trim_end();
if let Some(h) = l.strip_prefix("### ") {
ui.add_space(6.0);
ui.label(egui::RichText::new(h).font(t.semibold(12.5)).color(t.text));
ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(12.5)).color(t.text));
} else if let Some(h) = l.strip_prefix("## ") {
ui.add_space(8.0);
ui.label(egui::RichText::new(h).font(t.semibold(14.0)).color(t.text));
ui.label(egui::RichText::new(crate::i18n::tr(h)).font(t.semibold(14.0)).color(t.text));
} else if l.starts_with("# ") || l.is_empty() {
} else if let Some(b) = l.strip_prefix("- ") {
ui.label(egui::RichText::new(format!("• {}", b.replace('`', ""))).color(t.text_label));
ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(b).replace('`', ""))).color(t.text_label));
} else {
ui.label(egui::RichText::new(l.trim().replace('`', "")).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr(l.trim()).replace('`', "")).color(t.text_label));
}
}
});
@ -175,7 +175,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::SystemInfo { rows } => {
egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| {
for (k, v) in rows.iter() {
ui.label(egui::RichText::new(k).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr(k)).color(t.text_dim));
ui.add(egui::Label::new(egui::RichText::new(v).color(t.text_label)).wrap());
ui.end_row();
}
@ -214,7 +214,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
egui::ScrollArea::vertical().max_height(440.0).auto_shrink([false, true]).show(ui, |ui| {
if groups.is_empty() {
ui.label(egui::RichText::new(rows["note"].as_str().unwrap_or("No metadata found")).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr(rows["note"].as_str().unwrap_or("No metadata found"))).color(t.text_dim));
}
for (g, list) in &groups {
ui.add_space(6.0);
@ -258,7 +258,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
let rules = app.session.view_rules();
let n = app.session.catalog.query(&rules, &Default::default()).len();
ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", rules.describe())).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label));
ui.label(
egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n))
.color(t.text_dim),
@ -354,7 +354,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let template_id = egui::Id::new("rename-template");
let tags_open = field(ui, "Template", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let w = crate::import::tag_field_width(ui);
let r = ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w));
crate::widgets::register(ui.ctx(), "field:renameTemplate", r.rect);
crate::import::tag_toggle(ui, "renameTemplate")
@ -391,7 +391,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
}
None => {
ui.label(egui::RichText::new("Checking names…").color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim));
}
}
let more = total.saturating_sub(RENAME_PREVIEW_ROWS);
@ -408,15 +408,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
confirm = true;
}
ui.label(
egui::RichText::new(format!(
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.",
if n == 1 { "" } else { "s" }
))
egui::RichText::new(crate::i18n::tr_format!("Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.",
if n == 1 { "" } else { "s" }, from = from, n = n))
.color(t.text_dim),
);
}
Dialog::MergeKeywords { from, into } => {
ui.label(egui::RichText::new(format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::<Vec<_>>().join(", "))).color(t.text_label));
ui.label(egui::RichText::new(crate::i18n::tr_format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::<Vec<_>>().join(", "))).color(t.text_label));
let r = ui.add(egui::TextEdit::singleline(into).hint_text(crate::i18n::tr("Keyword")).desired_width(f32::INFINITY));
crate::widgets::register(ui.ctx(), "field:keywordInto", r.rect);
r.request_focus();
@ -439,7 +437,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
}
Dialog::TextPrompt { value, hint, .. } => {
let r = ui.add(egui::TextEdit::singleline(value).hint_text(hint.as_str()).desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(value).hint_text(crate::i18n::tr(hint.as_str())).desired_width(f32::INFINITY));
r.request_focus();
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
confirm = true;
@ -622,7 +620,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
} else {
choices(ui, app.ui.language.tr("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &mut wm.vertical);
field(ui, "Text", |ui| {
ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text("© Your Name").desired_width(f32::INFINITY))
ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text(crate::i18n::tr("© Your Name")).desired_width(f32::INFINITY))
});
}
choices(
@ -657,7 +655,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let naming_id = egui::Id::new("export-naming");
let tags_open = field(ui, "File name", |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let w = (ui.available_width() - 50.0).max(80.0);
let w = crate::import::tag_field_width(ui);
ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w));
crate::import::tag_toggle(ui, "exportNaming")
});
@ -756,7 +754,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
_ => {
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.label(crate::i18n::tr_format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.add_space(10.0);
let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
.fill(t.accent)
@ -917,7 +915,7 @@ pub fn fmt_gap(v: f64) -> String {
60..3600 => format!("{} min {} s", v / 60, v % 60),
3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600),
3600..86400 => format!("{} h {} min", v / 3600, v % 3600 / 60),
_ => "1 day".into(),
_ => crate::i18n::tr("1 day").into(),
}
}
@ -1106,7 +1104,7 @@ fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec<String>) {
for (i, g) in SettingsGroup::ALL.iter().enumerate() {
let key = key_of(g);
let mut on = groups.contains(&key);
let r = cols[i % 2].checkbox(&mut on, g.label());
let r = cols[i % 2].checkbox(&mut on, crate::i18n::tr(g.label()));
crate::widgets::register(&r.ctx, format!("{tag}:{key}"), r.rect);
if r.changed() {
if on {
@ -1118,12 +1116,12 @@ fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec<String>) {
}
});
ui.horizontal(|ui| {
let all = ui.small_button("All");
let all = ui.small_button(crate::i18n::tr("All"));
crate::widgets::register(ui.ctx(), format!("{tag}:all"), all.rect);
if all.clicked() {
*groups = SettingsGroup::ALL.iter().map(key_of).collect();
}
let none = ui.small_button("None");
let none = ui.small_button(crate::i18n::tr("None"));
crate::widgets::register(ui.ctx(), format!("{tag}:none"), none.rect);
if none.clicked() {
groups.clear();

View File

@ -99,7 +99,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.spacing_mut().item_spacing.x = 8.0;
if text_button(ui, "auto", crate::i18n::tr("Auto"), false).clicked() {
let _ = app.run("develop.auto", json!({}));
app.toast(ui.ctx(), "Auto settings applied");
app.toast(ui.ctx(), crate::i18n::tr("Auto settings applied"));
}
let bw = crate::is_bw(&d);
if text_button(ui, "bw", crate::i18n::tr("B&W"), bw).clicked() {
@ -588,7 +588,7 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if r.clicked() {
let name = crate::i18n::tr_format!("Proof Copy ({})", app.ui.proof.space.label());
if app.run("photo.virtualCopy", json!({"ids": [id.0], "name": name})).is_ok() {
app.toast(ui.ctx(), "Proof copy created");
app.toast(ui.ctx(), crate::i18n::tr("Proof copy created"));
}
}
});
@ -1144,7 +1144,9 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) {
for (txt, d) in [("◀◀", -big), ("◀", -small), ("▶", small), ("▶▶", big)] {
let r = ui.add(egui::Button::new(egui::RichText::new(txt).size(10.0)).min_size(egui::vec2(28.0, 18.0)));
crate::widgets::register(ui.ctx(), format!("button:quick-{ctl}-{txt}"), r.rect);
if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = label)).clicked() {
if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label)))
.clicked()
{
let _ = app.run("develop.quickAdjust", json!({"control": ctl, "delta": d}));
}
}

View File

@ -57,7 +57,13 @@ fn toggle(ui: &mut egui::Ui, id: &str, on: bool, tip: &str, draw: impl FnOnce(&e
/// A dropdown showing `current`; `items` are (label, filter patch, selected).
fn picker(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, current: &str, active: bool, items: Vec<(String, Value, bool)>) {
let t = Tokens::get(ui.ctx());
let r = crate::widgets::dropdown(ui, &format!("filter-{id}"), crate::i18n::tr(current), t.font(12.5), if active { t.text } else { t.text_label });
let r = crate::widgets::dropdown(
ui,
&format!("filter-{id}"),
if active && matches!(id, "camera" | "lens" | "keyword") { current } else { crate::i18n::tr(current) },
t.font(12.5),
if active { t.text } else { t.text_label },
);
if active {
ui.painter().rect_stroke(r.rect.expand(2.0), 4.0, Stroke::new(1.0, t.accent.gamma_multiply(0.7)), StrokeKind::Outside);
}
@ -65,7 +71,17 @@ fn picker(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, current: &str, a
egui::Popup::menu(&r).show(|ui| {
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (label, patch, sel) in &items {
if ui.selectable_label(*sel, crate::i18n::tr(label)).clicked() {
if ui
.selectable_label(
*sel,
if matches!(id, "camera" | "lens" | "keyword") && patch.get(id).is_some_and(|value| !value.is_null()) {
label.as_str()
} else {
crate::i18n::tr(label)
},
)
.clicked()
{
chosen = Some(patch.clone());
}
}
@ -163,7 +179,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
for l in ColorLabel::ALL {
let on = chosen.contains(&l);
let name = format!("{l:?}").to_lowercase();
let tip = format!("{} label (click more labels to show any of them)", app.session.catalog.label_name(l));
let tip = crate::i18n::tr_format!("{} label (click more labels to show any of them)", crate::i18n::color_label(&app.session.catalog, l));
let resp = toggle(ui, &format!("label-{name}"), on, &tip, |p, r, _| {
p.circle_filled(r.center(), 5.5, label_color(l));
});

View File

@ -482,15 +482,15 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
// screen readers: the file, then rating / flag / label
let mut spoken = photo.file_name.clone();
if photo.rating > 0 {
spoken.push_str(&format!(", {} star{}", photo.rating, if photo.rating == 1 { "" } else { "s" }));
spoken.push_str(&crate::i18n::tr_format!(", {} star{}", photo.rating, if photo.rating == 1 { "" } else { "s" }));
}
match photo.flag {
lightcraft_catalog::Flag::Pick => spoken.push_str(", picked"),
lightcraft_catalog::Flag::Reject => spoken.push_str(", rejected"),
lightcraft_catalog::Flag::Pick => spoken.push_str(crate::i18n::tr(", picked")),
lightcraft_catalog::Flag::Reject => spoken.push_str(crate::i18n::tr(", rejected")),
lightcraft_catalog::Flag::None => {}
}
if let Some(l) = photo.label {
spoken.push_str(&format!(", {} label", app.session.catalog.label_name(l)));
spoken.push_str(&crate::i18n::tr_format!(", {} label", crate::i18n::color_label(&app.session.catalog, l)));
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &spoken));
let active = state == lightcraft_engine::SelectionState::Active;
@ -746,7 +746,7 @@ pub use super::filterbar::label_color;
pub fn label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let current = app.session.active().and_then(|id| app.session.catalog.photo(id)).and_then(|p| p.label);
for l in ColorLabel::ALL {
let name = app.session.catalog.label_name(l);
let name = crate::i18n::color_label(&app.session.catalog, l);
let resp = ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover());
ui.painter().circle_filled(r.center(), 5.0, label_color(l));
@ -808,7 +808,10 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
Ok(r) => {
let n = r["photos"].as_array().map_or(0, Vec::len);
app.ui.view = crate::state::ViewMode::PhotoGrid;
app.toast(ui.ctx(), format!("{n} similar photo{} · View ▸ Clear Filters to see all", if n == 1 { "" } else { "s" }));
app.toast(
ui.ctx(),
crate::i18n::tr_format!("{n} similar photo{} · View ▸ Clear Filters to see all", if n == 1 { "" } else { "s" }, n = n),
);
}
Err(e) => app.toast(ui.ctx(), e),
}

View File

@ -295,7 +295,7 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() };
// (checked off the UI thread: a home folder can be on a network share)
if fs_cached(ui, "is-dir", &p, 5.0, |p| std::path::Path::new(p).is_dir()) == Some(true) {
builtin.push((name.to_string(), p));
builtin.push((crate::i18n::tr(name).to_string(), p));
}
}
}
@ -314,7 +314,8 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let reveal = local.owner == Some(i);
folder_tree(app, ui, name, path, 0.0, current.as_deref(), reveal, transient);
}
if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, "Browse Folder…", None, false, 0.0).clicked() {
if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked()
{
let picked = app.services.pick_folder.as_mut().and_then(|f| f());
if let Some(path) = picked {
match app.run("library.browse", json!({"path": path})) {
@ -585,7 +586,7 @@ fn folder_tree(
let into = app.services.pick_folder.as_mut().and_then(|f| f());
if let Some(into) = into {
match app.run("folder.move", json!({"path": path, "into": into})) {
Ok(r) => app.toast(ui.ctx(), format!("Moved; {} photo(s) relinked", r["relinked"])),
Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])),
Err(e) => app.toast(ui.ctx(), e),
}
}
@ -665,7 +666,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent
drop_target(app, ui, &resp, a);
}
if let Some(rules) = &a.smart {
resp = resp.on_hover_text(crate::i18n::tr_format!("Smart album: {}", rules.describe()));
resp = resp.on_hover_text(crate::i18n::tr_format!("Smart album: {}", crate::i18n::filter_label(rules, &app.session.catalog)));
}
if resp.clicked() {
let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0}));

View File

@ -103,7 +103,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// an empty colour range; clicking the photo samples it
let _ = app.run("mask.add", json!({"kind": "colorRange"}));
app.ui.tool = "colorRange".into();
app.toast(ui.ctx(), "Click the photo to pick a colour · ⇧-click adds more");
app.toast(ui.ctx(), crate::i18n::tr("Click the photo to pick a colour · ⇧-click adds more"));
}
"object" => start_object(app, ui.ctx(), "new"),
"prompt" => start_describe(app, "new"),
@ -292,7 +292,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
return;
}
// a long name is cut short (with …) before the options button, not past the panel
let text = format!("{op}{label}{}", if c.invert { " (inverted)" } else { "" });
let text = format!("{op}{label}{}", if c.invert { crate::i18n::tr(" (inverted)") } else { "" });
let room = (ui.available_width() - 30.0).max(0.0);
let resp = ui.scope(|ui| {
ui.set_max_width(room);
@ -564,7 +564,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape
MaskShape::ColorRange { samples, refine } => {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("{} sample{}", samples.len(), if samples.len() == 1 { "" } else { "s" }))
egui::RichText::new(crate::i18n::tr_format!("{} sample{}", samples.len(), if samples.len() == 1 { "" } else { "s" }))
.color(t.text_dim)
.size(11.5),
);
@ -657,7 +657,7 @@ pub(crate) fn start_object(app: &mut LightcraftApp, ctx: &egui::Context, op: &st
match r {
Ok(_) => {
app.ui.tool = "object".into();
app.toast(ctx, "Click the object to select it · ⌥-click leaves a part out");
app.toast(ctx, crate::i18n::tr("Click the object to select it · ⌥-click leaves a part out"));
}
Err(e) => app.ai_error(ctx, e, Some(("object", op))),
}
@ -707,8 +707,11 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
ui.label(crate::i18n::tr(prompt));
let mut submit = false;
ui.horizontal(|ui| {
let r =
ui.add(egui::TextEdit::singleline(&mut text).hint_text("e.g. sky · the red car · car, road").desired_width(ui.available_width() - 64.0));
let r = ui.add(
egui::TextEdit::singleline(&mut text)
.hint_text(crate::i18n::tr("e.g. sky · the red car · car, road"))
.desired_width(ui.available_width() - 64.0),
);
register(ui.ctx(), "maskDescribe", r.rect);
if !r.has_focus() && !r.lost_focus() && text.is_empty() {
r.request_focus();

View File

@ -29,10 +29,12 @@ pub fn may_close(app: &mut LightcraftApp) -> bool {
match app.session.unsaved() {
None => true,
Some((n, e)) => {
app.quit_prompt = Some(format!(
"{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
if n == 1 { "1 change".to_string() } else { format!("{n} changes") },
if n == 1 { "it is" } else { "they are" }
app.quit_prompt = Some(crate::i18n::tr_format!(
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
if n == 1 { "" } else { "s" },
if n == 1 { "it is" } else { "they are" },
n = n,
e = e
));
false
}
@ -55,7 +57,7 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s
});
let mut chosen = None;
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14));
egui::Window::new(title)
egui::Window::new(crate::i18n::tr(title))
.id(egui::Id::new(id))
.order(egui::Order::Foreground)
.collapsible(false)
@ -71,7 +73,7 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s
ui.add_space(4.0);
ui.horizontal(|ui| {
for (i, (bid, label, _)) in buttons.iter().enumerate() {
let r = ui.add(egui::Button::new(*label).min_size(vec2(0.0, 26.0)));
let r = ui.add(egui::Button::new(crate::i18n::tr(label)).min_size(vec2(0.0, 26.0)));
register(ui.ctx(), format!("button:{bid}"), r.rect);
if r.clicked() {
chosen = Some(i);

View File

@ -22,7 +22,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let people = app.caches.people(&app.session.catalog, &app.session.filter);
let (head, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER_H), Sense::hover());
ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, "Named People", t.semibold(15.0), t.text);
ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Named People"), t.semibold(15.0), t.text);
ui.painter().text(pos2(head.right() - PAD, head.center().y), Align2::RIGHT_CENTER, people.len().to_string(), t.font(13.0), t.text_dim);
// the filters narrowing the list (a date, a keyword…), removable here
let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);
@ -78,9 +78,9 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, pp
let name =
if person.name.chars().count() > 19 { format!("{}…", person.name.chars().take(18).collect::<String>()) } else { person.name.clone() };
p.text(pos2(r.left() + 2.0, face.bottom() + 14.0), Align2::LEFT_CENTER, name, t.semibold(13.0), t.text);
let photos = if person.count == 1 { "1 photo".to_string() } else { format!("{} photos", person.count) };
let photos = crate::i18n::tr_format!("{n} photo{}", if person.count == 1 { "" } else { "s" }, n = person.count);
p.text(pos2(r.left() + 2.0, face.bottom() + 32.0), Align2::LEFT_CENTER, photos, t.font(12.0), t.text_dim);
if resp.on_hover_text(format!("{} — show their photos", person.name)).clicked() {
if resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name)).clicked() {
let _ = app.run("library.filter", json!({"person": person.name}));
let _ = app.run("view.photoGrid", json!({}));
}

View File

@ -437,7 +437,15 @@ fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let i = app.ui.eye;
let eye = d.red_eye[i];
divider(ui);
super::edit::sub_title(ui, &format!("{} {} of {n}", if eye.pet { "Pet Eye" } else { "Red Eye" }, i + 1));
super::edit::sub_title(
ui,
&crate::i18n::tr_format!(
"{label} {index} of {n}",
label = crate::i18n::tr(if eye.pet { "Pet Eye" } else { "Red Eye" }),
index = i + 1,
n = n
),
);
for k in ["pupilSize", "darken"] {
let cid = format!("redEye.{i}.{k}");
let Some(spec) = lightcraft_develop::controls::find(&cid) else { continue };
@ -478,8 +486,12 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.add_space(6.0);
}
if let Some(name) = &p.copy_name {
let of = p.copy_of.and_then(|m| app.session.catalog.photo(m)).map(|m| m.file_name.clone()).unwrap_or_else(|| "a removed photo".into());
ui.label(egui::RichText::new(format!("Virtual copy “{name}” of {of}")).color(t.text_label));
let of = p
.copy_of
.and_then(|m| app.session.catalog.photo(m))
.map(|m| m.file_name.clone())
.unwrap_or_else(|| crate::i18n::tr("a removed photo").into());
ui.label(egui::RichText::new(crate::i18n::tr_format!("Virtual copy “{name}” of {of}", name = name, of = of)).color(t.text_label));
}
ui.add_space(8.0);
if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner {
@ -497,7 +509,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if on {
ui.painter().circle_stroke(r.center(), 10.0, egui::Stroke::new(1.5, t.text));
}
let resp = resp.on_hover_text(app.session.catalog.label_name(l));
let resp = resp.on_hover_text(crate::i18n::color_label(&app.session.catalog, l));
if resp.clicked() {
let _ = app.run("photo.label", json!({"label": if on { "none".to_string() } else { key }}));
}
@ -528,7 +540,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
meta_field(app, ui, label, key, value, lines);
}
// file: name (rename), path (reveal), capture time (edit)
let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).size(11.5).color(t.text_dim));
let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(crate::i18n::tr(text)).size(11.5).color(t.text_dim));
small(ui, "File Name");
ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, "infoRename", Icon::Pencil, vec2(20.0, 20.0), false, true, "Rename…").clicked() {
@ -543,7 +555,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.horizontal(|ui| {
let path = match &p.source {
lightcraft_catalog::Source::File { path } => path.clone(),
lightcraft_catalog::Source::Demo { .. } => "Generated demo photo".into(),
lightcraft_catalog::Source::Demo { .. } => crate::i18n::tr("Generated demo photo").into(),
};
ui.add(egui::Label::new(egui::RichText::new(path).size(12.0).color(t.text_label)).truncate());
if crate::menus::ui_enabled(app, "app.showInFinder") {
@ -581,7 +593,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let gid = egui::Id::new("info-gps");
ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim));
let mut text: String = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| gps.clone());
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text("latitude, longitude").desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("latitude, longitude")).desired_width(f32::INFINITY));
register(ui.ctx(), "field:gps", r.rect);
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(gid, text.clone()));
@ -610,9 +622,9 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
}
if let Some(a) = p.analysis {
let mut line = format!("Focus {:.0}", a.sharpness);
let mut line = crate::i18n::tr_format!("Focus {:.0}", a.sharpness);
if a.clipped > 0.02 {
line.push_str(&format!(" · {:.0}% clipped", a.clipped * 100.0));
line.push_str(&crate::i18n::tr_format!(" · {:.0}% clipped", a.clipped * 100.0));
}
if a.group.is_some() {
line.push_str(if a.best { " · best of its burst" } else { " · in a burst" });
@ -638,7 +650,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
_ => "Smart preview available",
};
ui.add_space(6.0);
ui.label(egui::RichText::new(text).color(if online { t.text_dim } else { t.accent }));
ui.label(egui::RichText::new(crate::i18n::tr(text)).color(if online { t.text_dim } else { t.accent }));
}
}
ui.add_space(10.0);
@ -675,7 +687,7 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) {
});
let rows = [
("Focal length", m.focal_mm.map(|f| format!("{f:.1} mm").replace(".0 mm", " mm"))),
("Shutter speed", (!m.shutter.is_empty()).then(|| format!("{} sec", m.shutter))),
("Shutter speed", (!m.shutter.is_empty()).then(|| crate::i18n::tr_format!("{} sec", m.shutter))),
("Aperture", m.aperture.map(|f| format!("f / {f:.1}").replace(".0", ""))),
("ISO", m.iso.map(|i| i.to_string())),
];
@ -683,7 +695,7 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) {
ui.add_space(6.0);
egui::Grid::new("info-capture").num_columns(2).spacing([24.0, 3.0]).show(ui, |ui| {
for (k, v) in rows {
ui.label(dim(k));
ui.label(dim(crate::i18n::tr(k)));
ui.label(dim(&v.unwrap_or_else(|| "—".into())));
ui.end_row();
}
@ -832,30 +844,35 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui, have: &[String]) {
let current = sets["current"].as_str().unwrap_or_default().to_string();
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim));
egui::ComboBox::from_id_salt("kw-set").selected_text(&current).show_ui(ui, |ui| {
for set in sets["sets"].as_array().into_iter().flatten() {
let name = set["name"].as_str().unwrap_or_default();
if ui.selectable_label(name == current, name).clicked() {
let _ = app.run("keyword.useSet", json!({"name": name}));
egui::ComboBox::from_id_salt("kw-set")
.selected_text(crate::i18n::builtin_label(&current, current == lightcraft_engine::cmd::keywords::RECENT))
.show_ui(ui, |ui| {
for set in sets["sets"].as_array().into_iter().flatten() {
let name = set["name"].as_str().unwrap_or_default();
if ui
.selectable_label(name == current, crate::i18n::builtin_label(name, name == lightcraft_engine::cmd::keywords::RECENT))
.clicked()
{
let _ = app.run("keyword.useSet", json!({"name": name}));
}
}
}
ui.separator();
if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
title: "Save Keyword Set".into(),
hint: "Set name".into(),
value: String::new(),
command: "keyword.saveSet".into(),
params: json!({}),
key: "name".into(),
});
}
if current != lightcraft_engine::cmd::keywords::RECENT
&& ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked()
{
let _ = app.run("keyword.deleteSet", json!({"name": current}));
}
});
ui.separator();
if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
title: "Save Keyword Set".into(),
hint: "Set name".into(),
value: String::new(),
command: "keyword.saveSet".into(),
params: json!({}),
key: "name".into(),
});
}
if current != lightcraft_engine::cmd::keywords::RECENT
&& ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked()
{
let _ = app.run("keyword.deleteSet", json!({"name": current}));
}
});
});
let kws: Vec<String> = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect();
if kws.is_empty() {
@ -950,8 +967,8 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
} else if resp.clicked() && !current {
let _ = app.run("version.restore", json!({"index": i}));
}
resp.on_hover_text(if current { "The photo has these settings" } else { "Click to restore · double-click to rename" }).context_menu(
|ui| {
resp.on_hover_text(crate::i18n::tr(if current { "The photo has these settings" } else { "Click to restore · double-click to rename" }))
.context_menu(|ui| {
if ui.button(crate::i18n::tr("Restore")).clicked() {
let _ = app.run("version.restore", json!({"index": i}));
}
@ -968,8 +985,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if ui.button(crate::i18n::tr("Delete")).clicked() {
let _ = app.run("version.delete", json!({"index": i}));
}
},
);
});
}
});
}

View File

@ -34,9 +34,9 @@ fn match_combo(ui: &mut egui::Ui, salt: &str, m: &mut Match) {
Match::Any => "any",
Match::None => "none",
};
egui::ComboBox::from_id_salt(format!("{salt}-match")).width(64.0).selected_text(label(*m)).show_ui(ui, |ui| {
egui::ComboBox::from_id_salt(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| {
for x in [Match::All, Match::Any, Match::None] {
ui.selectable_value(m, x, label(x));
ui.selectable_value(m, x, crate::i18n::tr(label(x)));
}
});
}
@ -47,7 +47,7 @@ fn text_value(ui: &mut egui::Ui, v: &mut Value, width: f32, hint: &str, salt: &s
Value::Null => String::new(),
other => other.to_string(),
};
let r = ui.add(egui::TextEdit::singleline(&mut s).hint_text(hint).desired_width(width));
let r = ui.add(egui::TextEdit::singleline(&mut s).hint_text(crate::i18n::tr(hint)).desired_width(width));
register(ui.ctx(), format!("field:{salt}"), r.rect);
if r.changed() {
*v = json!(s);
@ -83,9 +83,9 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
v["n"] = json!(n.round());
}
let unit = v["unit"].as_str().unwrap_or("days").to_string();
egui::ComboBox::from_id_salt(format!("{salt}-unit")).width(70.0).selected_text(&unit).show_ui(ui, |ui| {
egui::ComboBox::from_id_salt(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| {
for u in ["hours", "days", "weeks", "months", "years"] {
if ui.selectable_label(unit == u, u).clicked() {
if ui.selectable_label(unit == u, crate::i18n::tr(u)).clicked() {
v["unit"] = json!(u);
}
}
@ -109,9 +109,9 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
(Some(Kind::Number), _) => number_value(ui, v, field),
(Some(Kind::Choice(c)), _) => {
let cur = v.as_str().unwrap_or("").to_string();
egui::ComboBox::from_id_salt(format!("{salt}-choice")).width(90.0).selected_text(&cur).show_ui(ui, |ui| {
egui::ComboBox::from_id_salt(format!("{salt}-choice")).width(90.0).selected_text(crate::i18n::tr(&cur)).show_ui(ui, |ui| {
for x in c.iter() {
if ui.selectable_label(cur == *x, *x).clicked() {
if ui.selectable_label(cur == *x, crate::i18n::tr(x)).clicked() {
*v = json!(x);
}
}
@ -119,10 +119,13 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
}
(Some(Kind::Bool), _) => {
let mut b = v.as_bool().unwrap_or(true);
egui::ComboBox::from_id_salt(format!("{salt}-bool")).width(60.0).selected_text(if b { "true" } else { "false" }).show_ui(ui, |ui| {
ui.selectable_value(&mut b, true, "true");
ui.selectable_value(&mut b, false, "false");
});
egui::ComboBox::from_id_salt(format!("{salt}-bool"))
.width(60.0)
.selected_text(crate::i18n::tr(if b { "true" } else { "false" }))
.show_ui(ui, |ui| {
ui.selectable_value(&mut b, true, crate::i18n::tr("true"));
ui.selectable_value(&mut b, false, crate::i18n::tr("false"));
});
*v = json!(b);
}
(Some(Kind::Date), _) => text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt),
@ -156,22 +159,25 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) {
Rule::Field { field, op, value } => {
ui.horizontal(|ui| {
let label = FIELDS.iter().find(|f| f.0 == field.as_str()).map_or(field.as_str(), |f| f.1).to_string();
egui::ComboBox::from_id_salt(format!("{rsalt}-field")).width(150.0).height(400.0).selected_text(label).show_ui(ui, |ui| {
for (id, l, k) in FIELDS {
if ui.selectable_label(field == id, *l).clicked() && field != id {
*field = id.to_string();
if !ops_for(*k).iter().any(|o| o.0 == op.as_str()) {
*op = ops_for(*k)[0].0.to_string();
egui::ComboBox::from_id_salt(format!("{rsalt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui(
ui,
|ui| {
for (id, l, k) in FIELDS {
if ui.selectable_label(field == id, crate::i18n::tr(l)).clicked() && field != id {
*field = id.to_string();
if !ops_for(*k).iter().any(|o| o.0 == op.as_str()) {
*op = ops_for(*k)[0].0.to_string();
}
*value = default_value(field, op);
}
*value = default_value(field, op);
}
}
});
},
);
let kind = field_kind(field).unwrap_or(Kind::Text);
let op_label = ops_for(kind).iter().find(|o| o.0 == op.as_str()).map_or(op.as_str(), |o| o.1).to_string();
egui::ComboBox::from_id_salt(format!("{rsalt}-op")).width(110.0).selected_text(op_label).show_ui(ui, |ui| {
egui::ComboBox::from_id_salt(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| {
for (id, l) in ops_for(kind) {
if ui.selectable_label(op == id, *l).clicked() && op != id {
if ui.selectable_label(op == id, crate::i18n::tr(l)).clicked() && op != id {
let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty");
*op = id.to_string();
if was_special || matches!(*id, "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty") {
@ -209,12 +215,12 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) {
}
if rs.rules.is_empty() || depth == 0 {
ui.horizontal(|ui| {
let add = ui.small_button("+ Rule");
let add = ui.small_button(crate::i18n::tr("+ Rule"));
register(ui.ctx(), format!("button:ruleAddEnd-{salt}"), add.rect);
if add.clicked() {
rs.rules.push(new_rule());
}
if depth < 3 && ui.small_button("+ Group").clicked() {
if depth < 3 && ui.small_button(crate::i18n::tr("+ Group")).clicked() {
rs.rules.push(Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } });
}
});

View File

@ -145,20 +145,27 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
/// A preset picker: `None` = `none_label`. Returns the new choice when it changed.
fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Option<&str>, none_label: &str) -> Option<Option<String>> {
let name = |pid: &str| app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| format!("{pid} (missing)"));
let text = current.map(name).unwrap_or_else(|| none_label.to_string());
let name = |pid: &str| {
app.session
.presets
.iter()
.find(|p| p.id == pid)
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
.unwrap_or_else(|| crate::i18n::tr_format!("{pid} (missing)", pid = pid))
};
let text = current.map(name).unwrap_or_else(|| crate::i18n::tr(none_label).to_string());
let mut out = None;
let r = egui::ComboBox::from_id_salt(id).width(240.0).selected_text(text).show_ui(ui, |ui| {
if ui.selectable_label(current.is_none(), none_label).clicked() {
if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() {
out = Some(None);
}
let mut group = "";
for p in &app.session.presets {
if p.group != group {
group = &p.group;
ui.label(RichText::new(group).size(10.5).weak());
ui.label(RichText::new(crate::i18n::builtin_label(group, p.builtin)).size(10.5).weak());
}
if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() {
if ui.selectable_label(current == Some(p.id.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)).clicked() {
out = Some(Some(p.id.clone()));
}
}
@ -214,9 +221,15 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
};
let mut pick = None;
let label = match current {
Some(RAW_DEFAULT) => "Same as raw default".to_string(),
None => "LightCraft Default".to_string(),
Some(pid) => app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| pid.to_string()),
Some(RAW_DEFAULT) => crate::i18n::tr("Same as raw default").to_string(),
None => crate::i18n::tr("LightCraft Default").to_string(),
Some(pid) => app
.session
.presets
.iter()
.find(|p| p.id == pid)
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
.unwrap_or_else(|| pid.to_string()),
};
let id = format!("settingsCamera-{i}");
let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| {
@ -227,7 +240,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
pick = Some(json!({"camera": cam, "preset": null}));
}
for p in &app.session.presets {
if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() {
if ui.selectable_label(current == Some(p.id.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)).clicked() {
pick = Some(json!({"camera": cam, "preset": p.id}));
}
}
@ -272,7 +285,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let mut pick = None;
let r = egui::ComboBox::from_id_salt("settingsMetaPreset")
.width(240.0)
.selected_text(cur.clone().unwrap_or_else(|| "None".into()))
.selected_text(cur.clone().unwrap_or_else(|| crate::i18n::tr("None").into()))
.show_ui(ui, |ui| {
if ui.selectable_label(cur.is_none(), crate::i18n::tr("None")).clicked() {
pick = Some(String::new());
@ -306,7 +319,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
heading(ui, t, crate::i18n::tr("Auto Import"));
hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete."));
row(ui, t, crate::i18n::tr("Watched folder"), |ui| {
ui.label(RichText::new(d.auto_folder.clone().unwrap_or_else(|| "Off".into())).color(t.text));
ui.label(RichText::new(d.auto_folder.clone().unwrap_or_else(|| crate::i18n::tr("Off").into())).color(t.text));
let can = app.services.pick_folder.is_some();
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose…")));
register(ui.ctx(), "button:settingsAutoFolder", r.rect);

View File

@ -182,8 +182,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(rest) = f.strip_prefix("rate:") {
let (n, adv) = rest.split_once(':').unwrap_or(("0", "0"));
cull(app, "photo.rate", json!({"rating": n.parse::<u8>().unwrap_or(0)}), adv == "1");
let label =
if n == "0" { "Rating cleared".to_string() } else { crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) };
let label = if n == "0" {
crate::i18n::tr("Rating cleared").to_string()
} else {
crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0)))
};
app.toast(ctx, label);
} else if let Some(l) = f.strip_prefix("label:") {
cull(app, "photo.label", json!({"label": l}), false);
@ -199,9 +202,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
} else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop {
cull(app, &f, json!({}), false);
match f.as_str() {
"photo.pick" => app.toast(ctx, "Flagged as Pick"),
"photo.reject" => app.toast(ctx, "Flagged as Reject"),
_ => app.toast(ctx, "Unflagged"),
"photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")),
"photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")),
_ => app.toast(ctx, crate::i18n::tr("Unflagged")),
}
} else {
// in the full-screen preview (no panels) I cycles the info overlay instead
@ -237,9 +240,16 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) {
if let Ok(r) = app.run("album.toggleTarget", json!({})) {
let n = app.session.targets(&json!({})).len();
let what = if n == 1 { "photo".to_string() } else { crate::i18n::tr_format!("{n} photos", n = n) };
let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n);
let name = r["name"].as_str().unwrap_or("Quick Collection").to_string();
app.toast(ctx, if r["added"] == true { format!("Added {what} to {name}") } else { format!("Removed {what} from {name}") });
app.toast(
ctx,
if r["added"] == true {
crate::i18n::tr_format!("Added {what} to {name}", name = name, what = what)
} else {
crate::i18n::tr_format!("Removed {what} from {name}", name = name, what = what)
},
);
}
continue;
}
@ -281,11 +291,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
app.toast(ctx, e);
}
match f.as_str() {
"photo.pick" => app.toast(ctx, "Flagged as Pick"),
"photo.reject" => app.toast(ctx, "Flagged as Reject"),
"photo.unflag" => app.toast(ctx, "Unflagged"),
"edit.undo" => app.toast(ctx, "Undo"),
"edit.redo" => app.toast(ctx, "Redo"),
"photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")),
"photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")),
"photo.unflag" => app.toast(ctx, crate::i18n::tr("Unflagged")),
"edit.undo" => app.toast(ctx, crate::i18n::tr("Undo")),
"edit.redo" => app.toast(ctx, crate::i18n::tr("Redo")),
_ => {}
}
}

View File

@ -77,7 +77,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
Err(TryRecvError::Disconnected) => {
// the worker died (a panic, already logged)
let t = app.tasks.running.remove(i);
app.toast(ctx, format!("{} failed", t.label));
app.toast(ctx, crate::i18n::tr_format!("{} failed", t.label));
}
}
}

View File

@ -33,9 +33,8 @@ pub fn preview_only_variant(reason: &str) -> &str {
/// What a preview-only raw means for the user (see `Photo::preview_only`).
pub fn preview_only_explanation(reason: &str) -> String {
format!(
"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.",
crate::i18n::tr_format!(
"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.",
preview_only_variant(reason)
)
}
@ -61,7 +60,7 @@ pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) {
})
.response;
let r = r.on_hover_text(crate::i18n::tr_format!("Decoder: {reason}", reason = reason));
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, "Preview only: editing the camera's embedded JPEG"));
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, crate::i18n::tr("Preview only: editing the camera's embedded JPEG")));
register(ui.ctx(), format!("notice:previewOnly:{key}"), r.rect);
}

36
docs/localization-de.md Normal file
View File

@ -0,0 +1,36 @@
# LightCraft auf Deutsch
Wähle **Bearbeiten → Sprache → Deutsch** oder **Einstellungen → Allgemein → Sprache**.
Die Auswahl gilt sofort und wird für den nächsten Start gespeichert (`language: "de"` in `ui.json`).
Die deutschen Kataloge decken alle Schlüssel der bisherigen Übersetzungen sowie sämtliche
Befehls- und Reglerbezeichnungen ab: Menüs, Tastenkürzel, Fotoverwaltung, Bearbeitung,
Zuschnitt, Masken, Personen, Import/Export, Einstellungen, eingebaute Vorgaben und Profile,
Verlauf, Versionen, Datumsüberschriften und Statusmeldungen. Auch der Regel-Editor für
Smart-Alben, Platzhalterhilfen und der Dialog bei ungespeicherten Änderungen sind angebunden.
Die eingebetteten Versionshinweise sind ebenfalls übersetzt. Technische Fehler aus tieferen
Schichten bleiben wie bei den bestehenden Sprachen auf Englisch.
Dateinamen, Befehls-IDs, Vorlagenplatzhalter und selbst benannte Alben, Vorgaben oder Metadaten
werden nicht übersetzt. Die vorhandenen Inter-Schriften enthalten die deutschen Zeichen;
für Deutsch ist kein zusätzlicher Font-Download nötig. Beschreibungen für SAM 3 werden
unverändert an das Modell übergeben; die Beispiele verwenden dessen englische Begriffe.
## Wartung und Prüfung
Feste Texte: `crates/ui-egui/locales/de.json`. Meldungen mit Werten:
`crates/ui-egui/locales/de-formats.json`. Die englischen Schlüssel bleiben unverändert;
Platzhalter werden beim Build durch Rust geprüft. Englische Pluralendungen werden dort,
wo sie nicht passen, mit `{:.0}` ausgeblendet; mengenabhängige Meldungen verwenden neutrale
Formulierungen. Das Datum erscheint beispielsweise als `Sonntag, 20.09.2026`.
```sh
cargo test -p lightcraft-ui-egui i18n::tests
LIGHTCRAFT_LANGUAGE=de lightcraft-cli snapshot --demo -o deutsch.png --size 1600x1000
```
Die Tests prüfen Katalogabdeckung, Platzhalter, Sprachwechsel per Menü und Steuerkanal,
Speicherung, Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute
und ß. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft
auch den Sprachwechsel nach der Hintergrundprüfung der Quellen. Akzeptierte Sprachangaben umfassen `de`, `de-DE`, `de_AT.UTF-8` und `de-CH`;
gespeichert wird stets `de`. Weitere Hinweise stehen in [localization.md](localization.md).

View File

@ -2,9 +2,10 @@
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`) and Brazilian Portuguese (`pt-br`). 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`) and German (`de`). 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) and [`localization-pt-br.md`](localization-pt-br.md).
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md) and
[`localization-de.md`](localization-de.md).
## Adding a language
@ -25,6 +26,7 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json");
De, "de", "Deutsch", "Latn", include_str!("../locales/de.json");
}
```

View File

@ -84,6 +84,8 @@ Take the first one nobody is working on.
Settings → Performance → Preview size (2,560 px by default). Render visible regions at native resolution so
100% can reliably show original-pixel detail on larger photos.
German UI is available (M16, LR-BEHAV-LOCALIZE); German also translates release notes; remaining localisation gaps are technical errors and other locales.
## Shortcuts: conflicts and missing bindings
Compared `plan/lightroom/06-shortcuts.md` (desktop part) with our bindings: command specs in `crates/engine/src/cmd/`,
@ -516,8 +518,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
| 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` | 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` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese and Brazilian Portuguese 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 Traditional Chinese catalog is the most complete, the others fall back to English for messages they lack. Technical errors/release notes remain English; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
| 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.german` | 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-de.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese and German 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 only the Japanese face. |
| 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 | ⬜ | | |