Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion crates/engine/src/settings/prefs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use super::*;
#[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
1 change: 1 addition & 0 deletions crates/engine/src/settings/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ pub(super) 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 @@ -28,7 +28,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 @@ -118,7 +118,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::unique("hdr-ws-name"), Sense::click());
Expand Down Expand Up @@ -186,3 +186,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");
}
}
90 changes: 85 additions & 5 deletions crates/ui-egui/src/i18n.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];

/// BCP 47 tag for AccessKit and the web IME's hidden input. Keep the persisted spelling
/// (`pt-br`, `zh-cn`) out of the protocol-facing value.
Expand All @@ -82,6 +85,7 @@ impl Language {
Self::Id => "id",
Self::It => "it",
Self::Ko => "ko",
Self::Tr => "tr",
}
}

Expand All @@ -99,6 +103,7 @@ impl Language {
Self::Id => "Bahasa Indonesia",
Self::It => "Italiano",
Self::Ko => "한국어",
Self::Tr => "Türkçe",
}
}

Expand All @@ -116,6 +121,7 @@ impl Language {
"id" => Some(Self::Id),
"it" => Some(Self::It),
"ko" => Some(Self::Ko),
"tr" => Some(Self::Tr),
_ => None,
}
}
Expand All @@ -126,11 +132,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 @@ -143,6 +149,7 @@ impl Language {
("in", Language::Id),
("it", Language::It),
("ko", Language::Ko),
("tr", Language::Tr),
];
tags.iter()
.find_map(|tag| {
Expand Down Expand Up @@ -175,6 +182,7 @@ impl Language {
Self::Id => "id",
Self::It => "it",
Self::Ko => "ko",
Self::Tr => "tr",
}
}

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

Expand All @@ -205,6 +214,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 @@ -229,9 +275,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()
}

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
52 changes: 52 additions & 0 deletions crates/ui-egui/src/i18n/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ fn catalog_text(l: Language) -> Option<&'static str> {
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 Down Expand Up @@ -63,6 +64,7 @@ fn unknown_strings_pass_through_and_languages_round_trip() {
(Language::Id, "Bahasa Indonesia"),
(Language::It, "Italiano"),
(Language::Ko, "한국어"),
(Language::Tr, "Türkçe"),
] {
assert_eq!(l.name(), name);
}
Expand Down Expand Up @@ -544,6 +546,23 @@ fn language_commands_switch_and_persist_without_a_document() {
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(Language::Tr.tr("Save As…")));
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 @@ -589,6 +608,9 @@ fn system_language_follows_the_preferred_locales() {
for tag in ["ko", "ko-KR", "ko_KR.UTF-8"] {
assert_eq!(l(&[tag]), Language::Ko, "{tag}");
}
for tag in ["tr", "TR", "tr-TR", "tr_TR.UTF-8", "tr_TR"] {
assert_eq!(l(&[tag]), Language::Tr, "{tag}");
}
for none in [&["ita"][..], &["i"], &["-it"]] {
assert_eq!(l(none), Language::En, "{none:?}");
}
Expand Down Expand Up @@ -763,3 +785,33 @@ fn japanese_needs_an_installed_font() {
}
});
}

#[test]
fn turkish_casing_and_search_folding() {
assert_eq!(Language::Tr.to_uppercase("i"), "İ");
assert_eq!(Language::Tr.to_uppercase("ı"), "I");
assert_eq!(Language::Tr.to_lowercase("İ"), "i");
assert_eq!(Language::Tr.to_lowercase("I"), "ı");
assert_eq!(Language::Tr.to_lowercase("I\u{0307}"), "i");
assert_eq!(to_uppercase("file"), "FILE");
set_current(Language::Tr);
assert_eq!(to_uppercase("i"), "İ");
assert_eq!(to_lowercase("I"), "ı");
assert_eq!(to_lowercase("I\u{0307}"), "i");
assert!(matches_query("Gaussian Blur", "gaussian"));
assert!(matches_query("Gaussian Blur", "GAUSSIAN"));
assert!(matches_query("Inceleme", "inceleme"));
assert!(matches_query("İnceleme", "inceleme"));
assert!(matches_query("inceleme", "İnceleme"));
assert!(matches_query("Işık", "ışık"));
assert!(matches_query("ışık", "IŞIK"));
set_current(Language::En);
}

#[test]
fn turkish_renders_with_bundled_fonts() {
let (entries, _) = catalog::parse_entries(include_str!("tr.tsv"));
let alphabet = "ABCÇDEFGĞHIİJKLMNOÖPRSŞTUÜVYZabcçdefgğhıijklmnoöprsştuüvyz";
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));
}
Loading