Merge #1842: i18n: tab titles, workspaces and Project/Timeline column headers reach the catalogs; web language follows ?lang= and the browser (@devius)

This commit is contained in:
aiongg
2026-10-23 21:42:49 +00:00
11 changed files with 123 additions and 22 deletions
+1
View File
@@ -50,6 +50,7 @@ web-sys = { version = "0.3", features = [
"HtmlElement",
"HtmlInputElement",
"Location",
"Navigator",
"Url",
"UrlSearchParams",
"Window",
+5
View File
@@ -184,6 +184,11 @@ fn install_hooks(app: &mut EffectcraftApp) {
#[wasm_bindgen]
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
// Match System follows `?lang=` in the address, then the browser's languages; a language
// chosen in Settings still wins.
let navigator = web_sys::window().map(|w| w.navigator());
let browser = navigator.iter().flat_map(|n| n.languages().iter().filter_map(|v| v.as_string()).collect::<Vec<_>>());
effectcraft_ui_egui::i18n::set_system_locales(query_value("lang").into_iter().chain(browser).collect());
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
let empty = query_flag("empty");
+6 -4
View File
@@ -41,8 +41,8 @@ impl EffectcraftApp {
}
let Some(comp) = self.session.active_comp() else { return out };
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
out.push((PanelKind::EffectControls, format!("{} {}", crate::i18n::command_label("window.panel", "Effect Controls"), l.name)));
out.push((PanelKind::Properties, format!("{}: {}", crate::i18n::command_label("window.panel", "Properties"), l.name)));
}
out
}
@@ -206,7 +206,9 @@ impl EffectcraftApp {
let titles = self.tab_titles();
let decos = self.tab_decos();
let docs = self.tab_docs();
let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string());
let title = |p: PanelKind| {
titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| crate::i18n::command_label("window.panel", p.title()).to_string())
};
let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs };
// Where each group's tabs are (tab drags show where a tab will land between them).
let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![];
@@ -570,7 +572,7 @@ impl EffectcraftApp {
}
}
// Ghost of the dragged tab.
let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE);
let g = painter.layout_no_wrap(crate::i18n::command_label("window.panel", p.title()).to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE);
let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0));
painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85));
painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE);
+18 -3
View File
@@ -259,7 +259,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if mresp.clicked() {
widgets::open_popup(ui, egui::Id::new("ws-pop"));
}
let names: Vec<String> = crate::dock::WORKSPACES.iter().map(|s| s.to_string()).collect();
let names: Vec<String> = crate::dock::WORKSPACES.iter().map(|s| crate::i18n::command_label("window.workspace", s).to_string()).collect();
if let Some(i) = widgets::popup_menu(
ui,
egui::Id::new("ws-pop"),
@@ -274,7 +274,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Small Screen, Standard.
let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"];
for name in shown {
let g = p.layout_no_wrap(name.to_string(), Tokens::ui(12.0), t.text);
let g = p.layout_no_wrap(crate::i18n::command_label("window.workspace", name).to_string(), Tokens::ui(12.0), t.text);
let w = g.size().x + 16.0;
let r = Rect::from_min_max(pos2(rx - w, cy - 13.0), pos2(rx, cy + 13.0));
if r.min.x < x + 120.0 {
@@ -639,9 +639,15 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
r.max.x + 16.0
}
/// A workspace's drawn name: built-ins read the menu bar's Window ▸ Workspace row; names the user
/// saved stay verbatim, even when one matches a catalog source such as "Composition".
fn workspace_label(name: &str) -> &str {
if crate::dock::WORKSPACES.contains(&name) { crate::i18n::command_label("window.workspace", name) } else { name }
}
fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
for w in app.workspace_names() {
if ui.selectable_label(app.ui.workspace == w, &w).clicked() {
if ui.selectable_label(app.ui.workspace == w, workspace_label(&w)).clicked() {
app.set_workspace(&w);
ui.close();
}
@@ -692,6 +698,15 @@ mod tests {
out.textures_delta.clear();
}
#[test]
fn saved_workspace_names_stay_verbatim() {
crate::i18n::set_current("zh-hans");
assert_ne!(workspace_label("Default"), "Default");
assert_ne!(crate::i18n::tr("Composition"), "Composition");
assert_eq!(workspace_label("Composition"), "Composition");
crate::i18n::set_current("en");
}
#[test]
fn long_press_type_keeps_the_tool_until_vertical_is_selected() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
+74 -3
View File
@@ -68,16 +68,35 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool {
/// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language
/// where EffectCraft has it, else English (#229), as After Effects installs in the system's
/// language. The browser build stays in English because it has no system-locale probe.
/// language. The browser build follows the locales its page passes in ([`set_system_locales`]).
fn system_language() -> &'static str {
#[cfg(not(target_arch = "wasm32"))]
{
static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
LANGUAGE.get_or_init(|| supported(sys_locale::get_locale().as_deref()))
}
// No system locale in the browser build: English.
#[cfg(target_arch = "wasm32")]
supported(None)
first_supported(SYSTEM_LOCALES.get().map(Vec::as_slice).unwrap_or_default())
}
/// The browser's preferred languages, most preferred first (`?lang=` in the address, then
/// `navigator.languages`), for Match System in the web build.
static SYSTEM_LOCALES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
/// Tell Match System the system's preferred languages (the web build has no locale probe of its
/// own). Set before the first frame; only the first call counts.
pub fn set_system_locales(tags: Vec<String>) {
let _ = SYSTEM_LOCALES.set(tags);
}
/// The first of `tags` EffectCraft has a catalog for (English counts), else English: someone who
/// prefers Dutch, then Czech, gets Czech.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn first_supported(tags: &[String]) -> &'static str {
tags.iter()
.map(|t| (t, supported(Some(t))))
.find(|(t, code)| *code != "en" || t.split(['-', '_']).next().is_some_and(|p| p.eq_ignore_ascii_case("en")))
.map_or("en", |(_, code)| code)
}
/// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`,
@@ -147,6 +166,23 @@ fn menu_catalog(language: &str) -> Option<&'static [(&'static str, &'static str,
})
}
/// A command's label drawn outside the menu bar (the dock's tab titles, the workspace bar), in the
/// language the panels draw in: the menu bar's row for (`command`, `source`), else the panel
/// catalog's row for `source`, else `source`. `…` also finds a menu bar row written `...`.
pub(crate) fn command_label<'a>(command: &str, source: &'a str) -> &'a str {
let dotted = source.strip_suffix('…').map(|stem| format!("{stem}..."));
let row = |key: &str| menu_catalog(ui::current())?.iter().find(|(id, en, _)| *id == command && *en == key).map(|(_, _, translated)| *translated);
row(source).or_else(|| row(dotted.as_deref()?)).unwrap_or_else(|| tr(source))
}
/// A Settings page's title as the menu bar's Settings submenu draws it, without the ellipsis; the
/// panel catalog's row for pages that have no menu row. The panel catalog's "Type" is the kind
/// (Camera Settings, the Project panel's column), while the Type page is about text.
pub(crate) fn settings_page_title(title: &str) -> &str {
let menu = menu_catalog(ui::current()).and_then(|c| c.iter().find(|(id, en, _)| *id == "app.settings" && en.strip_suffix("...") == Some(title)));
menu.and_then(|(_, _, translated)| translated.strip_suffix("...")).unwrap_or_else(|| tr(title))
}
/// `source` looked up in `catalog` by (command, English label); the source itself when the pair is
/// absent, so computed labels and user names stay verbatim.
fn lookup<'a>(catalog: &[(&str, &str, &'a str)], command: &str, source: &'a str) -> &'a str {
@@ -6651,6 +6687,41 @@ mod tests {
use serde_json::json;
use std::collections::BTreeSet;
/// Labels drawn outside the menu bar take the menu bar's row for their command (with `…` for
/// `...`), else the panel catalog's row, else stay as they are.
#[test]
fn command_label_reads_the_menu_row_then_the_panel_row() {
set_current("zh-hans");
assert_eq!(command_label("window.panel", "Project"), lookup(SIMPLIFIED_CHINESE, "window.panel", "Project"));
let (command, en, translated) = SIMPLIFIED_CHINESE.iter().find(|(c, en, _)| !c.is_empty() && en.ends_with("...")).expect("a menu row ending in ...");
assert_eq!(command_label(command, &format!("{}…", en.trim_end_matches("..."))), *translated);
assert_eq!(command_label("no.such.command", "Cancel"), tr("Cancel"));
assert_eq!(command_label("window.panel", "Comp 1"), "Comp 1");
set_current("en");
assert_eq!(command_label("window.panel", "Project"), "Project");
}
#[test]
fn settings_page_titles_read_the_menu_row() {
set_current("zh-hans");
let menu_row = lookup(SIMPLIFIED_CHINESE, "app.settings", "Type...");
assert_eq!(settings_page_title("Type"), menu_row.trim_end_matches("..."));
assert_ne!(settings_page_title("Type"), tr("Type"), "the page is about text, the panel row is the kind");
assert_eq!(settings_page_title("Roto Brush"), tr("Roto Brush"));
set_current("en");
assert_eq!(settings_page_title("Type"), "Type");
}
#[test]
fn match_system_takes_the_first_browser_language_with_a_catalog() {
let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(super::first_supported(&tags(&["cs-CZ", "cs", "en"])), "cs");
assert_eq!(super::first_supported(&tags(&["nl-NL", "cs"])), "cs");
assert_eq!(super::first_supported(&tags(&["en-US", "cs"])), "en");
assert_eq!(super::first_supported(&tags(&["nl-NL"])), "en");
assert_eq!(super::first_supported(&tags(&[])), "en");
}
fn keys(nodes: &[MenuNode], out: &mut BTreeSet<(String, String)>) {
for node in nodes {
match node {
+3 -2
View File
@@ -1285,7 +1285,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Transparency grid", "透明网格"),
("Tutorial complete", "教程完成"),
("Two-Node Camera", "双节点摄像机"),
("Type", "文字"),
("Type", "类型"),
("Type a command or effect…", "输入命令或效果…"),
("URL:", "URL:"),
("Undock Panel", "取消停靠面板"),
@@ -2025,7 +2025,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Transparency grid", "透明格點"),
("Tutorial complete", "教學完成"),
("Two-Node Camera", "雙節點攝影機"),
("Type", "文字"),
("Type", "類型"),
("Type a command or effect…", "輸入命令或效果…"),
("URL:", "URL:"),
("Undock Panel", "取消停靠面板"),
@@ -4429,6 +4429,7 @@ mod tests {
const CONVERTED: &[(&str, &str)] = &[
("color_picker.rs", include_str!("../color_picker.rs")),
("credits.rs", include_str!("../credits.rs")),
("dock_ui.rs", include_str!("../dock_ui.rs")),
("header.rs", include_str!("../header.rs")),
("menus.rs", include_str!("../menus.rs")),
("widgets.rs", include_str!("../widgets.rs")),
+10 -4
View File
@@ -469,13 +469,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
} else {
let hint = "Select an item to see its details";
let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.0), head.width() - 20.0, t.text_faint);
let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, crate::i18n::tr(hint), Tokens::ui(11.0), head.width() - 20.0, t.text_faint);
app.auto.add("project.details.hint", r, hint);
}
// Search.
let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0));
let mut q = app.ui.project_search.clone();
widgets::search_field(ui, sr, &mut q, "Search project", &t);
widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t);
app.ui.project_search = q;
app.auto.add("project.search", sr, "Search");
// Column header: Name, Label, then the visible optional columns.
@@ -507,7 +507,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
continue;
}
let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click());
hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim });
hp.with_clip_rect(hr).text(
pos2(*x, hdr.center().y),
Align2::LEFT_CENTER,
crate::i18n::tr(label),
Tokens::ui(11.0),
if resp.hovered() { t.text } else { t.text_dim },
);
if *key == "label" {
icons::paint(&hp, Rect::from_center_size(pos2(label_x, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim);
}
@@ -1138,7 +1144,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) {
ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak());
for (key, label, _) in COLUMNS {
let mut on = app.ui.project_columns.iter().any(|c| c == key);
if ui.checkbox(&mut on, label).changed() {
if ui.checkbox(&mut on, crate::i18n::tr(label)).changed() {
set_column(&mut app.ui.project_columns, key, on);
}
}
+2 -2
View File
@@ -677,7 +677,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.painter().text(
r.left_center() + vec2(10.0, 0.0),
egui::Align2::LEFT_CENTER,
tr(p.title),
crate::i18n::settings_page_title(p.title),
Tokens::ui(12.5),
if sel { Color32::WHITE } else { t.text },
);
@@ -694,7 +694,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
// dialog scrolled sideways a little (#284).
ui.set_width(ui.available_width());
let p = &all[idx];
ui.label(RichText::new(tr(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active));
ui.label(RichText::new(crate::i18n::settings_page_title(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active));
ui.add_space(6.0);
egui::ScrollArea::vertical().max_height((body_h - 30.0).max(0.0)).auto_shrink([false, false]).show(ui, |ui| {
page_ui(app, ui, p, &cur, t, &mut acts);
+2 -2
View File
@@ -1866,7 +1866,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Column headers scroll with the outline and never spill into the time ruler.
let hp = p.with_clip_rect(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)));
let hic = |icon: Icon, x: f32| icons::paint(&hp, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim);
let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim);
let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, crate::i18n::tr(s), Tokens::ui(11.0), t.text_dim);
if vis.av {
hic(Icon::Eye, cw.av + 10.0);
hic(Icon::Speaker, cw.av + 28.0);
@@ -1950,7 +1950,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (id, label) in COLUMNS {
let label = if id == "name" { if app.ui.timeline.source_name { "Source Name" } else { "Layer Name" } } else { label };
let on = column_visible(&app.ui.timeline, id);
if ui.add_enabled(id != "name", egui::Button::new(label).selected(on)).clicked() {
if ui.add_enabled(id != "name", egui::Button::new(crate::i18n::tr(label)).selected(on)).clicked() {
actions.push(("timeline.column".into(), json!({"column": id, "visible": !on})));
ui.close();
}
+1 -1
View File
@@ -297,5 +297,5 @@ pub fn on_close(app: &mut EffectcraftApp, p: PanelKind) {
/// The tab label of viewer `id` ("Composition Intro").
pub fn title(app: &EffectcraftApp, id: u32) -> Option<String> {
let c = comp_of(app, id)?;
Some(format!("Composition {}", app.session.project.item(c)?.name))
Some(format!("{} {}", crate::i18n::command_label("window.panel", "Composition"), app.session.project.item(c)?.name))
}
+1 -1
View File
@@ -30,7 +30,7 @@ Commands (CLI, MCP, control channel):
### Settings that change behaviour
- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build stays in English with it. Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the `missing_translations_report` test lists what each language lacks, and the tests in `ui.rs` fail on an unused row.
- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build follows `?lang=` in the address, then the browser's languages (`navigator.languages`). Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the `missing_translations_report` test lists what each language lacks, and the tests in `ui.rs` fail on an unused row.
- `general.undoLevels`: Levels of Undo
- `general.pathPointSize`: Path Point and Handle Size
- `general.recentItems`: Recent Projects Shown