M16: add German localization using the existing catalogs
Add German language selection and persisted locale support, complete German label and format catalogs, and German release notes. Connect previously untranslated UI text, preserve user names, and size translated buttons to fit. Format cached import source facts in the active UI language. Validate locale coverage, placeholder formats, language persistence, German glyphs, button bounds and cached import language switching. Review native and headless screenshots; run all seven cargo xtask ci gates. Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
This commit is contained in:
1 parent
629e39380e
commit
35dd973d91
37 files changed
+2864
-261
No files matched your search
@@ -352,7 +352,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).
|
||||
|
||||
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -89,7 +89,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
|
||||
| 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)
|
||||
|
||||
|
||||
@@ -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
@@ -118,5 +118,59 @@
|
||||
"{verb} {n} Photo{}": "{n}枚を{verb}{:.0}",
|
||||
"{weekday}, {day}": "{day}日({weekday})",
|
||||
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日({weekday})",
|
||||
"{year}": "{year}年"
|
||||
"{year}": "{year}年",
|
||||
" ({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"
|
||||
}
|
||||
@@ -118,5 +118,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"
|
||||
}
|
||||
@@ -118,5 +118,59 @@
|
||||
"{month}": "{month}月",
|
||||
"{month} {year}": "{year}年{month}月",
|
||||
"{weekday}, {day}": "{day}日 {weekday}",
|
||||
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}"
|
||||
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}",
|
||||
" ({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"
|
||||
}
|
||||
@@ -118,5 +118,59 @@
|
||||
"… and {more} more": "…以及另外 {more} 個",
|
||||
" ({} 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} · 按右鍵 ▸ 還原,或永久刪除後重新匯入"
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+215
-1
@@ -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
|
||||
@@ -272,6 +273,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::*;
|
||||
@@ -344,7 +407,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]
|
||||
@@ -466,6 +532,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());
|
||||
@@ -500,6 +567,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() {
|
||||
|
||||
@@ -373,7 +373,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;
|
||||
}
|
||||
};
|
||||
@@ -395,7 +395,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();
|
||||
@@ -520,7 +520,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;
|
||||
}
|
||||
}
|
||||
@@ -590,7 +590,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;
|
||||
}
|
||||
@@ -617,7 +617,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 {
|
||||
@@ -750,9 +750,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);
|
||||
});
|
||||
@@ -788,7 +789,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."
|
||||
}
|
||||
@@ -796,7 +797,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(),
|
||||
);
|
||||
@@ -804,7 +805,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()
|
||||
@@ -825,9 +826,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();
|
||||
@@ -843,7 +844,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")
|
||||
@@ -875,8 +876,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")
|
||||
});
|
||||
@@ -904,8 +910,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() {
|
||||
@@ -915,7 +921,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 {
|
||||
@@ -931,13 +937,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();
|
||||
}
|
||||
}
|
||||
@@ -945,7 +963,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();
|
||||
@@ -1029,6 +1047,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 {
|
||||
@@ -1048,7 +1073,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));
|
||||
}
|
||||
}
|
||||
@@ -1090,22 +1120,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());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1152,17 +1182,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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1173,7 +1227,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)
|
||||
})
|
||||
|
||||
@@ -312,11 +312,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);
|
||||
}
|
||||
@@ -324,8 +326,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);
|
||||
}
|
||||
@@ -341,8 +343,15 @@ 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));
|
||||
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, true) => {
|
||||
self.ui.unsaved_seen = false;
|
||||
@@ -395,7 +404,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."))
|
||||
}
|
||||
|
||||
@@ -358,19 +358,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,
|
||||
@@ -378,7 +385,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,
|
||||
@@ -411,7 +418,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(
|
||||
@@ -435,7 +442,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)),
|
||||
|
||||
+35
-13
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -180,10 +182,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;
|
||||
}
|
||||
@@ -274,7 +276,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}))
|
||||
}
|
||||
@@ -373,7 +375,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" => {
|
||||
@@ -889,10 +891,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" => {
|
||||
@@ -994,8 +996,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),
|
||||
@@ -1040,14 +1055,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);
|
||||
}
|
||||
@@ -1128,7 +1143,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);
|
||||
@@ -1156,7 +1178,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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -537,7 +537,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));
|
||||
}
|
||||
}
|
||||
@@ -574,8 +574,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());
|
||||
@@ -587,7 +588,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!({}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,17 +129,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));
|
||||
@@ -150,15 +150,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));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -166,7 +166,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();
|
||||
}
|
||||
@@ -205,7 +205,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);
|
||||
@@ -247,7 +247,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),
|
||||
@@ -343,7 +343,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")
|
||||
@@ -380,7 +380,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);
|
||||
@@ -397,15 +397,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();
|
||||
@@ -428,7 +426,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;
|
||||
@@ -611,7 +609,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(
|
||||
@@ -646,7 +644,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")
|
||||
});
|
||||
@@ -730,7 +728,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)
|
||||
@@ -876,7 +874,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,7 +1062,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 {
|
||||
@@ -1076,12 +1074,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();
|
||||
|
||||
@@ -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() {
|
||||
@@ -575,7 +575,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"));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1131,7 +1131,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}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,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);
|
||||
}
|
||||
@@ -73,7 +79,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());
|
||||
}
|
||||
}
|
||||
@@ -171,7 +187,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));
|
||||
});
|
||||
|
||||
@@ -475,15 +475,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 = app.session.selection.active == Some(id);
|
||||
@@ -730,7 +730,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));
|
||||
@@ -792,7 +792,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),
|
||||
}
|
||||
|
||||
@@ -225,7 +225,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,7 +244,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})) {
|
||||
@@ -515,7 +516,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),
|
||||
}
|
||||
}
|
||||
@@ -595,7 +596,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}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -371,7 +371,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 };
|
||||
@@ -412,8 +420,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 {
|
||||
@@ -431,7 +443,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 }}));
|
||||
}
|
||||
@@ -462,7 +474,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() {
|
||||
@@ -477,7 +489,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") {
|
||||
@@ -513,7 +525,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()));
|
||||
@@ -542,9 +554,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" });
|
||||
@@ -570,7 +582,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);
|
||||
@@ -607,7 +619,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())),
|
||||
];
|
||||
@@ -615,7 +627,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();
|
||||
}
|
||||
@@ -764,30 +776,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(¤t).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(¤t, 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() {
|
||||
@@ -882,8 +899,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}));
|
||||
}
|
||||
@@ -900,8 +917,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}));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()] } });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -169,8 +169,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);
|
||||
@@ -183,9 +186,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
|
||||
@@ -221,9 +224,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;
|
||||
}
|
||||
@@ -265,11 +275,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")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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");
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+4
-2
@@ -76,6 +76,8 @@ Take the first one nobody is working on.
|
||||
7. **HDR** (Q. HDR, LR-EXP-HDR), **video** (R. Video), **Classic output modules** (Map view, Book, Slideshow module,
|
||||
Print, publish): large, well understood, lower priority than 1–5.
|
||||
|
||||
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/`,
|
||||
@@ -506,8 +508,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 | ⬜ | | |
|
||||
|
||||
Reference in new issue
Block a user