Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ATTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| Asset | Author | Source | Licence |
|---|---|---|---|
| `crates/ui-egui/src/i18n/it.tsv` | FilmCraft contributors | Original work: clean-room Italian interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/tr.tsv` | FilmCraft contributors | Original work: clean-room Turkish interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/id.tsv` | FilmCraft contributors | Original work: clean-room Indonesian interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/ko.tsv` | FilmCraft contributors | Original work: clean-room Korean interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 |
Expand Down
2 changes: 2 additions & 0 deletions apps/filmcraft/src/native_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
filmcraft_ui_egui::i18n::Language::It
} else if checked("app.language.korean") {
filmcraft_ui_egui::i18n::Language::Ko
} else if checked("app.language.turkish") {
filmcraft_ui_egui::i18n::Language::Tr
} else {
filmcraft_ui_egui::i18n::Language::En
};
Expand Down
3 changes: 2 additions & 1 deletion crates/engine/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub const PREFS_VERSION: u32 = 2;
#[serde(rename_all = "camelCase", default)]
pub struct GeneralPrefs {
/// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, `uk`,
/// `zh-cn`, `de`, `ru`, `fr`, `id`, `it`, or `system` (the default: the operating system's preferred language when the
/// `zh-cn`, `de`, `ru`, `fr`, `id`, `it`, `ko`, `tr`, or `system` (the default: the operating system's preferred language when the
/// interface has it, otherwise English).
pub interface_language: String,
/// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`.
Expand Down Expand Up @@ -1013,6 +1013,7 @@ static CATEGORIES: &[Category] = &[
("id", "Bahasa Indonesia"),
("it", "Italiano"),
("ko", "한국어"),
("tr", "Türkçe"),
]),
true,
),
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/settings_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ fn interface_language_persists_and_rejects_hostile_values() {
}
set(&mut s, "general.interfaceLanguage", json!("it"));
assert_eq!(Preferences::load(&path).general.interface_language, "it");
// Turkish is stored as `tr`, not a locale tag
for value in [json!("tr-TR"), json!("TR")] {
assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err());
}
set(&mut s, "general.interfaceLanguage", json!("tr"));
assert_eq!(Preferences::load(&path).general.interface_language, "tr");
// back to following the operating system (#218)
set(&mut s, "general.interfaceLanguage", json!("system"));
assert_eq!(Preferences::load(&path).general.interface_language, "system");
Expand Down
2 changes: 1 addition & 1 deletion crates/ui-egui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho
## Localisation

Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified
Chinese, German, Russian, French, Indonesian, Korean and Italian (all but Ukrainian and Russian cover
Chinese, German, Russian, French, Indonesian, Korean, Italian and Turkish (all but Ukrainian and Russian cover
the whole interface; those two cover the core menus). The language is stored in the engine's `general.interfaceLanguage` preference,
restored on startup, and also available in Settings > General.
Its default, System Language (`system`), follows the operating system: the first of the user's
Expand Down
14 changes: 13 additions & 1 deletion crates/ui-egui/src/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let ws_resp = btn(ui, Icon::Workspaces, "workspaces", tl!("Workspaces"), app);
// workspace name (caps)
let ws = crate::i18n::t(&app.ui.workspace).to_uppercase();
let ws = crate::i18n::to_uppercase(crate::i18n::t(&app.ui.workspace));
let wg = p.layout_no_wrap(ws.clone(), Tokens::ui(11.0), t.text_dim);
let wr = Rect::from_min_size(pos2(rx - wg.size().x + 10.0, rect.center().y - 10.0), vec2(wg.size().x + 8.0, 20.0));
let wresp = ui.interact(wr, egui::Id::new("hdr-ws-name"), Sense::click());
Expand Down Expand Up @@ -182,3 +182,15 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}

#[cfg(test)]
mod tests {
#[test]
fn workspace_name_uppercasing_is_turkish_aware() {
crate::i18n::set_current(crate::i18n::Language::Tr);
assert_eq!(crate::i18n::to_uppercase("Birleştirme"), "BİRLEŞTİRME");
assert_eq!(crate::i18n::to_uppercase("Altyazılar ve Grafikler"), "ALTYAZILAR VE GRAFİKLER");
crate::i18n::set_current(crate::i18n::Language::En);
assert_eq!(crate::i18n::to_uppercase("Birleştirme"), "BIRLEŞTIRME");
}
}
168 changes: 161 additions & 7 deletions crates/ui-egui/src/i18n/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub enum Language {
It,
/// Korean; Hangul needs an installed system font ([`korean_font_available`]).
Ko,
Tr,
}

static JAPANESE: OnceLock<Catalog> = OnceLock::new();
Expand All @@ -62,9 +63,11 @@ static FRENCH: OnceLock<Catalog> = OnceLock::new();
static KOREAN: OnceLock<Catalog> = OnceLock::new();
static INDONESIAN: OnceLock<Catalog> = OnceLock::new();
static ITALIAN: OnceLock<Catalog> = OnceLock::new();
static TURKISH: OnceLock<Catalog> = OnceLock::new();

impl Language {
pub const ALL: [Self; 12] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr, Self::Id, Self::Ko, Self::It];
pub const ALL: [Self; 13] =
[Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr, Self::Id, Self::Ko, Self::It, Self::Tr];

pub fn name(self) -> &'static str {
match self {
Expand All @@ -80,6 +83,7 @@ impl Language {
Self::Id => "Bahasa Indonesia",
Self::It => "Italiano",
Self::Ko => "한국어",
Self::Tr => "Türkçe",
}
}

Expand All @@ -97,6 +101,7 @@ impl Language {
"id" => Some(Self::Id),
"it" => Some(Self::It),
"ko" => Some(Self::Ko),
"tr" => Some(Self::Tr),
_ => None,
}
}
Expand All @@ -107,11 +112,11 @@ impl Language {
/// any German (`de-AT`, `de_CH.UTF-8`…) gets the German one and any French (`fr-CA`, `fr_BE.UTF-8`…)
/// the French one; any Indonesian (`id-ID`, `id_ID.UTF-8`, and the legacy `in-ID` that older Java
/// and Android runtimes report) the Indonesian one; any Italian (`it-IT`, `it-CH`, `it_IT.UTF-8`…)
/// the Italian one. Chinese gets the Simplified catalog
/// the Italian one; any Turkish (`tr`, `tr-TR`, `tr_TR.UTF-8`…) the Turkish one. Chinese gets the Simplified catalog
/// only for Simplified locales (`zh`, `zh-CN`, `zh-SG`, `zh-Hans…`); Traditional ones (`zh-TW`,
/// `zh-HK`, `zh-MO`, `zh-Hant…`) are skipped.
pub fn from_locales(tags: &[String]) -> Self {
const PRIMARY: [(&str, Language); 12] = [
const PRIMARY: [(&str, Language); 13] = [
("en", Language::En),
("ja", Language::Ja),
("es", Language::Es),
Expand All @@ -124,6 +129,7 @@ impl Language {
("in", Language::Id),
("it", Language::It),
("ko", Language::Ko),
("tr", Language::Tr),
];
tags.iter()
.find_map(|tag| {
Expand Down Expand Up @@ -156,6 +162,7 @@ impl Language {
Self::Id => "id",
Self::It => "it",
Self::Ko => "ko",
Self::Tr => "tr",
}
}

Expand All @@ -174,6 +181,7 @@ impl Language {
Self::Id => Some(INDONESIAN.get_or_init(|| Catalog::parse(include_str!("id.tsv")))),
Self::It => Some(ITALIAN.get_or_init(|| Catalog::parse(include_str!("it.tsv")))),
Self::Ko => Some(KOREAN.get_or_init(|| Catalog::parse(include_str!("ko.tsv")))),
Self::Tr => Some(TURKISH.get_or_init(|| Catalog::parse(include_str!("tr.tsv")))),
}
}

Expand All @@ -186,6 +194,43 @@ impl Language {
pub fn has(self, text: &str) -> bool {
self.catalog().and_then(|c| c.plain(text)).is_some()
}

/// Uppercase `text`, using Turkish-aware rules (`i` → `İ`, `ı` → `I`) when the language is
/// Turkish.
pub fn to_uppercase(self, text: &str) -> String {
if self == Self::Tr {
let mapped: String = text
.chars()
.map(|c| match c {
'i' => 'İ',
'ı' => 'I',
other => other,
})
.collect();
mapped.to_uppercase()
} else {
text.to_uppercase()
}
}

/// Lowercase `text`, using Turkish-aware rules (`İ` → `i`, `I` → `ı`) when the language is
/// Turkish. Decomposed `I` followed by `\u{0307}` is normalized to `i`.
pub fn to_lowercase(self, text: &str) -> String {
if self == Self::Tr {
let normalized = text.replace("I\u{0307}", "i");
let mapped: String = normalized
.chars()
.map(|c| match c {
'İ' => 'i',
'I' => 'ı',
other => other,
})
.collect();
mapped.to_lowercase()
} else {
text.to_lowercase()
}
}
}

thread_local! {
Expand All @@ -210,9 +255,43 @@ pub fn t(text: &str) -> &str {
current().tr(text)
}

/// Match a lowercased search query against both the source and the displayed label.
/// Uppercase `text` in the current interface language (with Turkish-aware casing when the
/// language is Turkish).
pub fn to_uppercase(text: &str) -> String {
current().to_uppercase(text)
}

/// Lowercase `text` in the current interface language (with Turkish-aware casing when the
/// language is Turkish).
pub fn to_lowercase(text: &str) -> String {
current().to_lowercase(text)
}

fn fold_query(s: &str) -> String {
s.replace('İ', "i").replace('\u{0307}', "").to_lowercase()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

fn fold_turkish(s: &str) -> String {
let normalized = s.replace("I\u{0307}", "i");
let mapped: String = normalized
.chars()
.map(|c| match c {
'İ' => 'i',
'I' => 'ı',
other => other,
})
.collect();
mapped.replace('\u{0307}', "").to_lowercase()
}

/// Match a search query against both the source and the displayed label.
pub fn matches_query(text: &str, query: &str) -> bool {
text.to_lowercase().contains(query) || t(text).to_lowercase().contains(query)
let q = fold_query(query);
if fold_query(text).contains(&q) || fold_query(t(text)).contains(&q) {
return true;
}
let q_tr = fold_turkish(query);
fold_turkish(t(text)).contains(&q_tr) || fold_turkish(text).contains(&q_tr)
}

/// Like [`t`], for an English string that needs a disambiguating `context` (a catalog row whose
Expand Down Expand Up @@ -355,6 +434,7 @@ mod tests {
Language::Id => Some(include_str!("id.tsv")),
Language::It => Some(include_str!("it.tsv")),
Language::Ko => Some(include_str!("ko.tsv")),
Language::Tr => Some(include_str!("tr.tsv")),
}
}

Expand All @@ -379,7 +459,7 @@ mod tests {
#[test]
fn unknown_strings_pass_through_and_languages_round_trip() {
for l in Language::ALL {
for text in ["An untranslated label", "mi video.mp4", "日本語の文書.pdf", "Größe ändern.mp4", "Montage d'été.mp4"] {
for text in ["An untranslated label", "mi video.mp4", "日本語の文書.pdf", "Größe ändern.mp4", "Montage d'été.mp4", "Tatil videosu.mp4"] {
assert_eq!(l.tr(text), text, "{}", l.code());
}
assert_eq!(Language::parse(l.code()), Some(l));
Expand All @@ -389,7 +469,7 @@ mod tests {
}
assert_eq!(Language::En.tr("File"), "File");
assert_eq!(Language::parse("xx"), None);
for code in ["de-DE", "fr-FR", "id-ID", "it-IT"] {
for code in ["de-DE", "fr-FR", "id-ID", "it-IT", "tr-TR"] {
assert_eq!(Language::parse(code), None, "the preference stores the bare code");
}
// language names are always shown in their own language
Expand All @@ -403,6 +483,7 @@ mod tests {
(Language::Id, "Bahasa Indonesia"),
(Language::It, "Italiano"),
(Language::Ko, "한국어"),
(Language::Tr, "Türkçe"),
] {
assert_eq!(l.name(), name);
}
Expand Down Expand Up @@ -884,6 +965,24 @@ mod tests {
let mut restarted = filmcraft_engine::Session::default();
restarted.prefs = serde_json::from_str(&prefs).unwrap();
assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::It);
let result = crate::menus::invoke(&mut app, &ctx, "app.language.turkish", serde_json::json!({})).unwrap();
assert_eq!(result, serde_json::json!("tr"));
assert_eq!((app.ui.language, current()), (Language::Tr, Language::Tr));
assert_eq!(app.session.prefs.general.interface_language, "tr");
for it in crate::menus::menu_items(&app).iter().filter(|it| it.id.starts_with("app.language.")) {
assert_eq!(it.checked, Some(it.id == "app.language.turkish"), "{}", it.id);
}
let items = crate::menus::menu_items(&app);
let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str());
assert_eq!(label("file.saveAs"), Some("Farklı Kaydet…"));
assert_eq!(label("app.language.italian"), Some("Italiano"));
assert_eq!(label("app.language.turkish"), Some("Türkçe"));
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains("\"language\":\"tr\""), "{saved}");
let prefs = serde_json::to_string(&app.session.prefs).unwrap();
let mut restarted = filmcraft_engine::Session::default();
restarted.prefs = serde_json::from_str(&prefs).unwrap();
assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::Tr);
crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Language::En);
set_current(Language::En);
Expand Down Expand Up @@ -932,6 +1031,13 @@ mod tests {
for none in [&["ita"][..], &["i"], &["-it"]] {
assert_eq!(l(none), Language::En, "{none:?}");
}
for tag in ["tr", "TR", "tr-TR", "tr_TR.UTF-8", "tr_TR.UTF-8@euro", "tr_TR"] {
assert_eq!(l(&[tag]), Language::Tr, "{tag}");
}
assert_eq!(l(&["tr-TR", "de-CH", "fr-CH", "en"]), Language::Tr);
for none in [&["tur"][..], &["t"], &["-tr"]] {
assert_eq!(l(none), Language::En, "{none:?}");
}
assert_eq!(l(&["sv-SE", "nl", "es-MX", "ja"]), Language::Es, "the first one the interface has");
assert_eq!(l(&["rm-CH", "de-CH", "fr-CH", "en"]), Language::De);
assert_eq!(l(&["rm-CH", "fr-CH", "de-CH", "en"]), Language::Fr);
Expand Down Expand Up @@ -979,6 +1085,44 @@ mod tests {
set_current(Language::En);
}

#[test]
fn turkish_casing_and_search_folding() {
for (en, tr) in [
("File", "Dosya"),
("Save As…", "Farklı Kaydet…"),
("Settings", "Ayarlar"),
("Gaussian Blur", "Gauss Bulanıklığı"),
("Audio Track Mixer", "Ses İzi Karıştırıcısı"),
("Sequence", "Sekans"),
("Playhead", "Oynatma Kafası"),
] {
assert_eq!(Language::Tr.tr(en), tr);
}
set_current(Language::Tr);
assert!(matches_query("Gaussian Blur", "bulanıklığı"));
assert!(matches_query("Gaussian Blur", "gaussian"));
assert!(matches_query("Review", "inceleme"));
assert!(matches_query("İnceleme", "inceleme"));
assert!(matches_query("Işık", "ışık"));
assert!(matches_query("Işık", "IŞIK"));
assert!(matches_query("Import", "import"));
assert_eq!(to_uppercase("Birleştirme"), "BİRLEŞTİRME");
assert_eq!(to_uppercase("Altyazılar ve Grafikler"), "ALTYAZILAR VE GRAFİKLER");
assert_eq!(Language::Tr.to_uppercase("Birleştirme"), "BİRLEŞTİRME");
assert_eq!(Language::Tr.to_uppercase("Altyazılar ve Grafikler"), "ALTYAZILAR VE GRAFİKLER");
assert_eq!(to_lowercase("IŞIK"), "ışık");
assert_eq!(to_lowercase("İNCELEME"), "inceleme");
assert_eq!(to_lowercase("I\u{0307}NCELEME"), "inceleme");
assert_eq!(Language::Tr.to_lowercase("IŞIK"), "ışık");
assert_eq!(Language::Tr.to_lowercase("İNCELEME"), "inceleme");
assert_eq!(Language::Tr.to_lowercase("I\u{0307}NCELEME"), "inceleme");
set_current(Language::En);
assert_eq!(to_uppercase("Birleştirme"), "BIRLEŞTIRME");
assert_eq!(to_lowercase("IŞIK"), "i\u{015f}ik");
assert!(matches_query("Işık", "ışık"));
assert!(matches_query("Import", "import"));
}

/// Every non-ASCII character of `text` renders with the bundled interface fonts, in every family.
fn assert_bundled_fonts_cover(text: impl Iterator<Item = char>) {
let ctx = egui::Context::default();
Expand Down Expand Up @@ -1061,6 +1205,16 @@ mod tests {
assert_bundled_fonts_cover(alphabet.chars().chain(Language::It.name().chars()).chain(letters));
}

#[test]
fn turkish_renders_with_bundled_fonts() {
let (entries, _) = catalog::parse_entries(include_str!("tr.tsv"));
// every Turkish letter (ç, ğ, ı, ö, ş, ü, Ç, Ğ, İ, Ö, Ş, Ü), even ones no entry uses yet,
// and every letter, curly quote, dash and ellipsis the entries use (symbols such as ✔ come from the English sources)
let alphabet = "ÇĞİÖŞÜçğıöşü";
let letters = entries.iter().flat_map(|(_, _, tr)| tr.chars()).filter(|ch| ch.is_alphabetic() || "“”‘’–—…".contains(*ch));
assert_bundled_fonts_cover(alphabet.chars().chain(Language::Tr.name().chars()).chain(letters));
}

#[test]
fn japanese_needs_an_installed_font() {
let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
Expand Down
Loading