i18n: the panel tabs, and the labels the call-shape scan cannot see

Two English surfaces the translation work so far could not reach.

The dock's tab titles: `PanelKind::title()` is drawn by `dock_ui.rs`'s `tab_titles`, which listed only
the tabs that name a comp, a layer or a script and let every other one fall back to `title()`'s English
- so every panel tab (`Timeline`, `Preview`, `Properties`, `Effects & Presets`, ...) stayed English. The
Window menu already carries a (`window.panel`, title) row in every catalog, so the dock reads that pair
and falls back to the panel catalog for `Markers` (the one panel only it names); `History` and
`ScriptUI Panel` have no row anywhere and keep their English names. `app.auto`'s labels and the dock's
serialized state keep the English title, which the control channel needs.

The scan's blind shapes: `converted_panels_have_no_unwrapped_literals` only sees a literal that follows
a call token, so a label passed to a helper, the third argument of `Painter::text`, a `(key, label, ...)`
table, a `let` binding and a `match` arm were all invisible. The two that can be checked without
guessing are added (`helper_and_painter_labels_are_translated`), with the same single-character
exemption the call scan makes, and the 25 sites they find now ask the catalog (`Apply`, `Analyze`,
`Cancel`, `Create Camera`, `Add Point`, `Remove Point`, `Clear`, `Render`, `Stop`, `Save Current as
Template...`, `New Composition From Footage`, the three search fields, `(no composition)`,
`per second`, `Waveform`, `steps`), with 15 rows added to the Simplified and Traditional catalogs in
the conventions those tables already use (Render 算圖, Apply 套用, Add Point 添加點).

The `uic!` labels and the Keyboard Shortcuts dialog are a different shape and are still to decide
(#1451).

Verified on Linux: cargo fmt --all --check, cargo test -p effectcraft-ui-egui (every suite passes),
cargo clippy -p effectcraft-ui-egui --all-targets -- -D warnings clean. Reverting the dock's catalog
lookup fails `the_dock_draws_every_panel_it_can_in_the_interface_language`; unwrapping a helper or a
painter label fails `helper_and_painter_labels_are_translated` ("panels/project.rs:434: the interface
text \"Search project\" that widgets::search_field draws is not in the catalog").

Also answers #1451's question about languages whose rows have no Simplified row to be a subset
of: six labels the Composition Settings dialog draws from a `(label, value)` table, a tab array and
a `match` arm (`Square Pixels`, `Basic`, `Advanced`, `3D Renderer`, `Black`, `Composition Settings`,
plus `White` from the same `color_name`) had no row anywhere, so every interface drew them in
English; they have Simplified and Traditional rows now, in the existing menu wording. The seventh,
`Lock Aspect Ratio`, is only an automation name - the drawn label is `Lock Aspect Ratio to {}`,
which already had rows, and `comp_settings.rs` built it with `format!` where `dialogs.rs` already
used `tr_args`, so that one is wired now too.

Review fixes (CodeRabbit): the scan now runs over the whole file with line comments blanked, so a
`.text(` call split across lines is still one argument list - which found five more labels the
per-line loop could not see (`No effects. Drag one from Effects & Presets.`, the colour-ring hint in
`fx_editors.rs`, the Learn blurb, the Script Console hint, the Templates blurb, plus
`Building waveform…` from the review), all wired with rows. And the New Composition title is back to
the English source: `modal_fit` keys the modal id and the drag offset on the title (its own comment
says the id stays on the untranslated source) and translates the heading itself, so `tr(..)` there
would have moved the dialog when the interface language changed.

And the argument list is bounded by the call's closing parenthesis: `args_of` used to flush a
trailing argument at end-of-string, so a last argument without a trailing comma carried the `)` and
the source after it, which made the check skip it as "not a literal". Reverting that one line is a
two-line test (a literal last argument in `header.rs` is caught now and was silent before).

The label scan also understands the two string forms a label can be written in: a plain string ends
at the first unescaped quote, a raw string (`r"…"`, `r#"…"#`) at a quote followed by as many `#` as
opened it, and `literal_text` decodes the argument before the exemptions run - so an escaped or raw
label is checked like any other instead of being skipped.

Two more scan fixes from review: `without_comments` tracks the escape state rather than the previous
character, so a string ending in a backslash (`"a\\"`) closes where it should; and the scanned range
ends at the test *module* - the first `#[cfg(test)]` followed by a `mod` - rather than at the first
`#[cfg(test)]`, because `dialogs.rs` keeps a test-only helper above its test module and production
code between them was not scanned.

And call tokens are looked for in a code-only mask: comments blanked, the contents of plain and raw
strings blanked too, with the quotes kept and every byte offset preserved (a blanked character
becomes as many spaces as its UTF-8 length, and a debug assertion pins the mask length). The argument
list is read from the original source at that offset, so a sample string that quotes a call cannot be
mistaken for one. Building the mask from chars instead of bytes shifted every offset after the first
non-ASCII character, which the debug assertion now catches.

The dock's display path has its own test now: `tab_titles` is called with `general.language` set to
Chinese and the labels a window shows are asserted (`Timeline` 时间轴 ...), plus `History` keeping its
English tab and the English interface keeping all of them. Reverting the dock to `PanelKind::title()`
fails it with "Timeline is not drawn in Chinese", which the catalog-only test could not see.
This commit is contained in:
FeiLiuEM committed 2026-10-29 19:35:09 +08:00
1 parent 990fa668cd
commit 383ff2d487
20 files changed
+412 -37

No files matched your search

+5 -1
View File
@@ -791,7 +791,11 @@ impl DockContent<PanelKind> for EffectContent<'_> {
}
fn logical_label(&self, panel: &PanelKind) -> String {
crate::i18n::command_label("window.panel", panel.title()).to_string()
let title = panel.title();
// The Window menu names most panels; `Markers` is only in the panel catalog, and the two
// panels in neither (`History`, `ScriptUI Panel`) keep their English names.
let listed = crate::i18n::command_label("window.panel", title);
if listed == title { crate::i18n::tr(title).to_string() } else { listed.to_string() }
}
/// The tab names what the panel shows ("Composition Intro"), after the label swatch of its
+29
View File
@@ -7620,6 +7620,35 @@ mod tests {
}
}
/// The dock's tab titles are `PanelKind::title()`; the panels the Window menu lists are drawn in
/// the interface language through their `(window.panel, title)` rows. `History` and `ScriptUI
/// Panel` are in no menu and keep their English names, which is what this pins.
#[test]
fn the_dock_draws_every_panel_it_can_in_the_interface_language() {
use crate::dock::PanelKind;
const WITHOUT_A_ROW: &[&str] = &["History", "ScriptUI Panel"];
for (name, catalog, code) in [("ja", JAPANESE, "ja"), ("zh-hans", SIMPLIFIED_CHINESE, "zh-hans"), ("zh-hant", TRADITIONAL_CHINESE, "zh-hant")] {
// The dock asks both catalogs, so a title may be named by either.
ui::set_current(code);
for p in PanelKind::ALL {
let title = p.title();
let in_menu = catalog.iter().any(|(c, en, _)| *c == "window.panel" && *en == title);
let in_panel = ui::tr(title) != title;
assert_eq!(
in_menu || in_panel,
!WITHOUT_A_ROW.contains(&title),
"{name}: {title} is {}named by a catalog and {}in the exception list",
if in_menu || in_panel { "" } else { "not " },
if WITHOUT_A_ROW.contains(&title) { "" } else { "not " }
);
}
}
ui::set_current("en");
// The dock asks the catalog rather than drawing the English name.
let dock_ui = include_str!("dock_ui.rs");
assert!(dock_ui.contains(r#"crate::i18n::command_label("window.panel", title)"#), "dock_ui.rs no longer asks the catalog for the panel titles");
}
#[test]
fn ukrainian_catalog_matches_the_settings_registry() {
let codes: Vec<_> = effectcraft_engine::prefs::LANGUAGES.iter().map(|(_, code)| *code).filter(|code| !matches!(*code, "system" | "en")).collect();
+339
View File
@@ -1641,6 +1641,32 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Total Offset", "总偏移"),
("Per Layer", "每个图层"),
("Track", "跟踪"),
("Apply", "应用"),
("Analyze", "分析"),
("Create Camera", "创建摄像机"),
("Add Point", "添加点"),
("Remove Point", "移除点"),
("Render", "渲染"),
("Save Current as Template…", "将当前项存为模板…"),
("New Composition From Footage", "从素材新建合成"),
("Search effects and presets", "搜索效果和预设"),
("Search project", "搜索项目"),
("Search", "搜索"),
("(no composition)", "没有合成"),
("per second", "每秒"),
("Waveform", "波形"),
("steps", "步"),
("Building waveform…", "正在生成波形…"),
("No effects. Drag one from Effects & Presets.", "没有效果。从效果和预设中拖入一个。"),
("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "点击色环添加颜色;按住 Alt 点击色标(或将其拖出)可移除"),
("Start from a ready-made project. Opening a template makes an untitled copy.", "从现成项目开始。打开模板会创建一份未命名副本。"),
("Square Pixels", "方形像素"),
("Basic", "基本"),
("Advanced", "高级"),
("3D Renderer", "3D 渲染器"),
("Black", "黑色"),
("White", "白色"),
("Composition Settings", "合成设置"),
];
const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
@@ -2327,6 +2353,32 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Total Offset", "總偏移"),
("Per Layer", "每個圖層"),
("Track", "追蹤"),
("Apply", "套用"),
("Analyze", "分析"),
("Create Camera", "建立攝影機"),
("Add Point", "添加點"),
("Remove Point", "移除點"),
("Render", "算圖"),
("Save Current as Template…", "將目前項目存為範本…"),
("New Composition From Footage", "從素材新增合成"),
("Search effects and presets", "搜尋效果與預設"),
("Search project", "搜尋專案"),
("Search", "搜尋"),
("(no composition)", "沒有合成"),
("per second", "每秒"),
("Waveform", "波形"),
("steps", "步驟"),
("Building waveform…", "正在產生波形…"),
("No effects. Drag one from Effects & Presets.", "沒有效果。從效果與預設集中拖入一個。"),
("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "點擊色環新增顏色;按住 Alt 點擊色標(或將其拖出)即可移除"),
("Start from a ready-made project. Opening a template makes an untitled copy.", "從現成專案開始。開啟範本會建立一份未命名副本。"),
("Square Pixels", "方形像素"),
("Basic", "基本"),
("Advanced", "高級"),
("3D Renderer", "3D 算圖引擎"),
("Black", "黑色"),
("White", "白色"),
("Composition Settings", "合成設置"),
];
/// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs.
@@ -5579,6 +5631,293 @@ mod tests {
/// English source changed or went away. A string a helper draws for the panel (a toolbar
/// tooltip, a popup item, a checkbox label) is looked up inside that helper, so its English
/// source only appears as a literal there.
#[test]
fn helper_and_painter_labels_are_translated() {
/// The `widgets.rs` helpers that draw a label, and which argument holds it.
const HELPER_LABELS: &[(&str, usize)] =
&[("text_button", 2), ("search_field", 3), ("text_field", 3), ("dropdown", 2), ("dropdown_tip", 2), ("text_fit", 3), ("check_label", 1)];
/// Literals that draw as they are: a brand, a value, a separator, a unit, and the kerning
/// sample pair the Character panel shows.
const VERBATIM: &[&str] = &["EffectCraft", "32", "dB", "···", "×", "V/A"];
/// The top-level arguments of a call, given the text after its `(`. A call split over
/// several lines is still one argument list, so this walks past newlines until the
/// parenthesis closes. It understands the two string forms a label can be written in - a
/// plain one with escapes and a raw one (`r"…"`, `r#"…"#`) - so a comma or parenthesis
/// inside either is not mistaken for code.
fn args_of(s: &str) -> Vec<&str> {
#[derive(Clone, Copy)]
enum Mode {
Code,
Str(bool),
Raw(usize),
}
let cs: Vec<(usize, char)> = s.char_indices().collect();
let (mut out, mut depth, mut start, mut end) = (Vec::new(), 0i32, None, s.len());
let mut mode = Mode::Code;
let mut k = 0;
while k < cs.len() {
let (i, c) = cs[k];
match mode {
Mode::Raw(hashes) => {
if c == '"' {
let mut n = 0;
while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') {
n += 1;
}
if n >= hashes {
mode = Mode::Code;
k += n;
}
}
}
Mode::Str(escaped) => {
mode = if c == '"' && !escaped { Mode::Code } else { Mode::Str(c == '\\' && !escaped) };
}
Mode::Code => match c {
'"' => {
mode = Mode::Str(false);
if start.is_none() {
start = Some(i);
}
}
'r' if start.is_none() && cs.get(k + 1).is_some_and(|(_, n)| *n == '#' || *n == '"') => {
let mut n = 0;
while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') {
n += 1;
}
if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') {
mode = Mode::Raw(n);
start = Some(i);
k += n + 1;
}
}
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => {
if depth == 0 {
end = i;
break;
}
depth -= 1;
}
',' if depth == 0 => {
if let Some(st) = start {
out.push(s[st..i].trim());
}
start = None;
}
c if !c.is_whitespace() && start.is_none() => start = Some(i),
_ => {}
},
}
k += 1;
}
if let Some(st) = start {
out.push(s[st..end].trim());
}
out
}
/// The text a literal argument draws: a plain string with its escapes undone, or a raw
/// string with none. `None` when the argument is not one literal (a concatenation, a call).
fn literal_text(arg: &str) -> Option<String> {
if let Some(rest) = arg.strip_prefix('r') {
let hashes = rest.chars().take_while(|c| *c == '#').count();
// An identifier that starts with `r` (`result`) is not a raw string: the quote has
// to follow the `#`s.
if rest.chars().nth(hashes) != Some('"') {
return None;
}
let close = format!("\"{}", "#".repeat(hashes));
return arg.get(1 + hashes + 1..)?.strip_suffix(&close).map(str::to_string);
}
let body = arg.strip_prefix('"')?.strip_suffix('"')?;
// Decode the escapes that matter for the exemption: a literal written as `"\u{bb}"` is
// one character (`»`), not six.
let mut out = String::new();
let mut it = body.chars().peekable();
while let Some(c) = it.next() {
if c != '\\' {
if c == '"' {
return None; // two literals side by side
}
out.push(c);
continue;
}
match it.next()? {
'"' => out.push('"'),
'\\' => out.push('\\'),
'n' => out.push('\n'),
't' => out.push('\t'),
'x' => {
let hex: String = it.by_ref().take(2).collect();
let byte = u8::from_str_radix(&hex, 16).ok()?;
out.push(char::from(byte));
}
'u' => {
if it.next() != Some('{') {
return None;
}
let hex: String = it.by_ref().take_while(|c| *c != '}').collect();
out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?);
}
other => {
out.push('\\');
out.push(other);
}
}
}
Some(out)
}
/// `source` with comments blanked and the *contents* of every string literal blanked too,
/// with offsets, newlines and the quotes kept. Call tokens are looked for in this text, so a
/// `widgets::…(` or `.text(` that only appears inside a doc string or a sample is not
/// mistaken for a call; the argument list is then read from the original at that offset.
fn code_only(source: &str) -> String {
#[derive(Clone, Copy)]
enum Mode {
Code,
Str(bool),
Raw(usize),
Comment,
}
let cs: Vec<(usize, char)> = source.char_indices().collect();
// Byte-for-byte: a blanked character becomes as many spaces as its UTF-8 length, so
// every offset found here indexes the original source as well.
let mut out: Vec<u8> = Vec::with_capacity(source.len());
let keep = |out: &mut Vec<u8>, c: char| out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes());
let blank = |out: &mut Vec<u8>, c: char| out.extend(std::iter::repeat_n(b' ', c.len_utf8()));
let mut mode = Mode::Code;
let mut k = 0;
while k < cs.len() {
let (_, c) = cs[k];
match mode {
Mode::Comment => {
if c == '\n' {
mode = Mode::Code;
keep(&mut out, c);
} else {
blank(&mut out, c);
}
}
Mode::Raw(hashes) => {
if c == '"' {
let mut n = 0;
while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') {
n += 1;
}
if n >= hashes {
keep(&mut out, c);
for j in 1..=n {
keep(&mut out, cs[k + j].1);
}
mode = Mode::Code;
k += n;
} else {
blank(&mut out, c);
}
} else {
blank(&mut out, c);
}
}
Mode::Str(escaped) => {
if c == '"' && !escaped {
mode = Mode::Code;
keep(&mut out, c);
} else {
mode = Mode::Str(c == '\\' && !escaped);
blank(&mut out, c);
}
}
Mode::Code => match c {
'"' => {
mode = Mode::Str(false);
keep(&mut out, c);
}
'/' if out.last() == Some(&b'/') => {
// The first `/` stays; this one becomes a space. Both are one byte, so
// the mask keeps its length and every offset still indexes the source.
blank(&mut out, c);
mode = Mode::Comment;
}
'r' if matches!(cs.get(k + 1), Some((_, n)) if *n == '#' || *n == '"') => {
let mut n = 0;
while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') {
n += 1;
}
if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') {
keep(&mut out, c);
for j in 1..=n {
keep(&mut out, cs[k + j].1);
}
keep(&mut out, '"');
mode = Mode::Raw(n);
k += n + 1;
} else {
keep(&mut out, c);
}
}
_ => keep(&mut out, c),
},
}
k += 1;
}
debug_assert_eq!(out.len(), source.len(), "the mask must keep every byte offset");
String::from_utf8(out).expect("mask keeps UTF-8 boundaries")
}
for (path, source) in CONVERTED {
// The test module draws nothing; it holds sample strings. A test-only helper above it
// (`#[cfg(test)] pub(crate) fn press_key` in `dialogs.rs`) is kept in the scanned range,
// because production code follows it.
let cut = source
.match_indices("#[cfg(test)]")
.find(|(at, _)| source[at + "#[cfg(test)]".len()..].lines().take(3).any(|l| l.trim_start().starts_with("mod ")))
.map_or(source.len(), |(at, _)| at);
let source = &source[..cut];
let code = code_only(source);
let line_of = |at: usize| code[..at].matches('\n').count() + 1;
let check = |at: usize, arg: &str, what: &str| {
// A single character is a symbol or a glyph, not interface text - the same exemption
// the call-shape scan above makes. A call that already asks the catalog is not a
// literal here at all.
if let Some(text) = literal_text(arg)
&& text.chars().count() >= 2
&& !VERBATIM.contains(&text.as_str())
{
panic!("{path}:{}: the interface text {arg} that {what} draws is not in the catalog", line_of(at));
}
};
for (call, index) in HELPER_LABELS {
let marker = format!("widgets::{call}(");
for (at, _) in code.match_indices(&marker) {
if let Some(arg) = args_of(&source[at + marker.len()..]).get(*index) {
check(at, arg, &format!("widgets::{call}"));
}
}
}
for (at, _) in code.match_indices(".text(") {
if let Some(arg) = args_of(&source[at + ".text(".len()..]).get(2) {
check(at, arg, "Painter::text");
}
}
}
}
#[test]
fn the_composition_settings_labels_are_translated() {
// The Composition Settings dialog (and New Composition) draws these from a `(label, value)`
// table, a tab array and a `match` arm, which the call-shape scan cannot see (#1451). They
// used to have no row in any catalog, so every interface drew them in English.
for key in ["Square Pixels", "Basic", "Advanced", "3D Renderer", "Black", "White", "Composition Settings"] {
for (name, table) in [("Simplified", SIMPLIFIED_CHINESE), ("Traditional", TRADITIONAL_CHINESE)] {
let value = table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v);
assert!(value.is_some_and(|v| !v.is_empty()), "{key} has an empty {name} row");
}
}
}
#[test]
fn every_row_is_used_by_a_converted_panel() {
for (en, _) in catalogs() {
+1 -1
View File
@@ -281,7 +281,7 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
}
}
_ => {
ui.label(egui::RichText::new(panel.title()).weak());
ui.label(egui::RichText::new(crate::i18n::label(app, "window.panel", panel.title())).weak());
}
}
});
+4 -4
View File
@@ -127,7 +127,7 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.anim_tools = st.clone();
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
let enabled = app.session.state.selected_keys.len() >= 2;
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("wg-apply")).clicked() {
if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("wg-apply")).clicked() {
let params = json!({
"apply": if st.wiggle_spatial { "spatial" } else { "temporal" },
"noise": if st.wiggle_smooth { "smooth" } else { "jagged" },
@@ -175,7 +175,7 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
y += ROW + 8.0;
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
let enabled = app.session.state.selected_keys.len() >= 3;
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("sm-apply")).clicked() {
if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("sm-apply")).clicked() {
apply(app, "keys.smooth", json!({"tolerance": tol}));
}
app.auto.add("smoother.apply", b, "Apply");
@@ -375,7 +375,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
changes.insert("keyframeRate".into(), json!(nr));
}
} else {
p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, "per second", Tokens::ui(12.0), t.text_dim);
p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, crate::i18n::tr("per second"), Tokens::ui(12.0), t.text_dim);
}
y += ROW;
let mut check = |app: &mut EffectcraftApp, y: f32, key: &str, text: &str, on: bool| {
@@ -454,7 +454,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
}
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
let enabled = app.session.state.selected_keys.len() >= 2;
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("mi-apply")).clicked() {
if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("mi-apply")).clicked() {
match app.session.execute("mask.interpolate", json!({})) {
Ok(r) => app.ui.status = format!("Mask Interpolation: {} keyframes added ({} vertices)", r["keys"], r["vertices"]),
Err(e) => app.ui.status = e.to_string(),
@@ -413,12 +413,12 @@ pub fn editor(
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0));
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy {
if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy {
actions.push(("camera.analyze".into(), me.clone()));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze");
if running {
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() {
if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() {
actions.push(("camera.cancel".into(), json!({})));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel");
@@ -426,7 +426,7 @@ pub fn editor(
let params = effectcraft_engine::camera_track::static_params(g);
let solve = ct::solve(&params);
if solve.is_some() {
if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() {
if widgets::text_button(ui, b3, crate::i18n::tr("Create Camera"), false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() {
actions.push(("camera.create".into(), me.clone()));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera");
+5 -5
View File
@@ -266,7 +266,7 @@ pub fn show(
ui.add_space(8.0);
ui.horizontal(|ui| {
for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] {
let r = ui.selectable_label(d.tab == tab, label);
let r = ui.selectable_label(d.tab == tab, tr(label));
auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label);
if r.clicked() {
d.tab = tab;
@@ -327,7 +327,7 @@ pub fn show(
let (ow, oh) = (d.width, d.height);
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px")));
auto.add("dialog.comp.width", r.rect, "Width");
let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64));
let lbl = tr_args("Lock Aspect Ratio to {}", &[&aspect_label(ow as f64, oh as f64)]);
let r = ui.checkbox(&mut d.lock_aspect, lbl);
auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio");
if d.lock_aspect && ow != d.width && ow > 0 {
@@ -348,11 +348,11 @@ pub fn show(
let cur = PIXEL_ASPECTS
.iter()
.find(|(_, v)| (v - d.pixel_aspect).abs() < 1e-3)
.map(|p| p.0.to_string())
.map(|p| tr(p.0).to_string())
.unwrap_or_else(|| format!("{:.2}", d.pixel_aspect));
let r = craft_ui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| {
for (name, v) in PIXEL_ASPECTS {
if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, *name).clicked() {
if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, tr(name)).clicked() {
d.pixel_aspect = *v;
}
}
@@ -420,7 +420,7 @@ pub fn show(
ui.horizontal(|ui| {
let r = crate::widgets::srgb_color_button(ui, &mut d.bg);
auto.add("dialog.comp.background", r.rect, "Background Color");
ui.label(color_name(d.bg));
ui.label(tr(color_name(d.bg)));
});
ui.end_row();
});
+2
View File
@@ -403,6 +403,8 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
let mut result = None;
let custom = app.session.prefs.comp_presets.clone();
let mut cmds = Vec::new();
// The title keys the modal id and its drag offset; `modal_fit` translates the heading itself,
// so passing the source keeps the dialog in place when the interface language changes.
modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| {
result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds);
});
@@ -8,12 +8,12 @@ pub(super) fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui:
let busy = app.session.is_warp_analyzing();
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy {
if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy {
actions.push(("warp.analyze".into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.warp.{}.analyze", g.uid), b1, "Analyze");
if running {
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() {
if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() {
actions.push(("warp.cancel".into(), json!({})));
}
app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel");
@@ -183,7 +183,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
bp.text(
pos2(body.center().x, body.min.y + 40.0),
Align2::CENTER_CENTER,
"No effects. Drag one from Effects & Presets.",
crate::i18n::tr("No effects. Drag one from Effects & Presets."),
Tokens::ui(12.0),
t.text_faint,
);
+1 -1
View File
@@ -357,7 +357,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0));
let mut q = app.ui.effects_search.clone();
widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t);
widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search effects and presets"), &t);
app.ui.effects_search = q.clone();
app.auto.add("effects.search", sr, "Search");
let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t);
+1 -1
View File
@@ -250,7 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.tl_bg);
let Some(rid) = root(app) else {
p.text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint);
p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint);
return;
};
// ---- toolbar: switches and direction.
+4 -4
View File
@@ -600,7 +600,7 @@ fn colorama_wheel(
p.text(
pos2(sr.min.x, sr.max.y + 9.0),
Align2::LEFT_CENTER,
"Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it",
crate::i18n::tr("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it"),
Tokens::ui(10.5),
t.text_faint,
);
@@ -801,19 +801,19 @@ fn reshape_buttons(
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0));
let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0));
if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::unique(("reshape-add", euid))).clicked() {
if widgets::text_button(ui, b1, crate::i18n::tr("Add Point"), false, &t, egui::Id::unique(("reshape-add", euid))).clicked() {
let mut v = pairs.clone();
v.push(add_pair(&pairs));
set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None);
}
app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point");
if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() {
if widgets::text_button(ui, b2, crate::i18n::tr("Remove Point"), false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() {
let mut v = pairs.clone();
v.pop();
set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None);
}
app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point");
if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() {
if widgets::text_button(ui, b3, crate::i18n::tr("Clear"), false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() {
set_str(actions, layer, g, "correspondencePoints", String::new(), None);
}
app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear");
+2 -2
View File
@@ -18,7 +18,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
p.text(
pos2(x, y + 26.0),
Align2::LEFT_CENTER,
"Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me.",
crate::i18n::tr("Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me."),
Tokens::ui(11.5),
t.text_faint,
);
@@ -38,7 +38,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
p.circle_filled(badge.center(), 22.0, t.accent.gamma_multiply(0.25));
p.circle_stroke(badge.center(), 22.0, Stroke::new(1.5, t.accent));
p.text(badge.center() - vec2(0.0, 5.0), Align2::CENTER_CENTER, tut.steps.len().to_string(), Tokens::semibold(15.0), Color32::WHITE);
p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, "steps", Tokens::ui(9.0), t.text_dim);
p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, crate::i18n::tr("steps"), Tokens::ui(9.0), t.text_dim);
let tx = badge.max.x + 16.0;
p.text(pos2(tx, r.min.y + 22.0), Align2::LEFT_CENTER, &tut.title, Tokens::semibold(14.0), Color32::WHITE);
p.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, format!("{} min", tut.minutes), Tokens::ui(11.0), t.text_faint);
+2 -1
View File
@@ -11,7 +11,8 @@ use crate::{EffectcraftApp, widgets};
pub fn placeholder(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, p: PanelKind) {
let t = app.tokens;
ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&(p.title())]), Tokens::ui(12.0), t.text_faint);
let name = crate::i18n::label(app, "window.panel", p.title());
ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&name]), Tokens::ui(12.0), t.text_faint);
}
/// Preview panel: transport controls, the shortcut whose options are shown, and that
+2 -2
View File
@@ -474,7 +474,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P
let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0));
let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0));
let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty();
if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render {
if widgets::text_button(ui, render_b, crate::i18n::tr("Render"), can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render {
actions.push(("renderQueue.render", json!({"wait": false})));
}
app.auto.add("renderQueue.render", render_b, "Render");
@@ -487,7 +487,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P
p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim);
app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" });
if rendering {
if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::unique("rq-stop")).clicked() {
if widgets::text_button(ui, stop_b, crate::i18n::tr("Stop"), false, &t, egui::Id::unique("rq-stop")).clicked() {
actions.push(("renderQueue.stop", json!({})));
}
app.auto.add("renderQueue.stop", stop_b, "Stop");
+1 -1
View File
@@ -102,7 +102,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.text(
out_rect.center(),
Align2::CENTER_CENTER,
"JavaScript with the After Effects scripting object model — try app.project.numItems",
crate::i18n::tr("JavaScript with the After Effects scripting object model — try app.project.numItems"),
Tokens::ui(12.0),
t.text_faint,
);
+2 -2
View File
@@ -62,14 +62,14 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
p.text(
pos2(x, y + 26.0),
Align2::LEFT_CENTER,
"Start from a ready-made project. Opening a template makes an untitled copy.",
crate::i18n::tr("Start from a ready-made project. Opening a template makes an untitled copy."),
Tokens::ui(11.5),
t.text_faint,
);
// Save the open project as a user template.
let can_save = app.session.project.comps().next().is_some();
let sr = Rect::from_min_size(pos2(area.max.x - 210.0, y - 4.0), vec2(210.0, 28.0));
let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::unique("home-templates-save")).clicked();
let save = widgets::text_button(ui, sr, crate::i18n::tr("Save Current as Template…"), false, &t, egui::Id::unique("home-templates-save")).clicked();
app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…");
if save {
if can_save {
+4 -4
View File
@@ -1721,7 +1721,7 @@ fn layout(app: &mut EffectcraftApp, ui: &egui::Ui, rect: Rect, cid: ItemId, comp
fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint);
ui.painter().text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint);
// Dropping footage into an empty Timeline should create its first composition,
// just like dropping onto the Project panel's New Composition button (#649).
if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx).as_deref()
@@ -2152,7 +2152,7 @@ fn header_search_and_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &T
let comp = g.comp.clone();
let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0));
let mut search = app.ui.timeline.search.clone();
widgets::search_field(ui, sr, &mut search, "Search", &t);
widgets::search_field(ui, sr, &mut search, crate::i18n::tr("Search"), &t);
app.ui.timeline.search = search;
app.auto.add("timeline.search", sr, "Search");
let mut bx = rect.min.x + left_w - 10.0;
@@ -3536,7 +3536,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) {
let RowKind::Waveform { item, .. } = &rc.row.kind else { return };
lp.rect_filled(left, 0.0, if ri.is_multiple_of(2) { t.row } else { t.row_alt });
let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text);
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Waveform"), Tokens::ui(12.0), t.text);
let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max);
gp.rect_filled(wr, 0.0, t.tl_bg);
app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform");
@@ -3546,7 +3546,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) {
gp.text(
pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y),
Align2::LEFT_CENTER,
"Building waveform…",
crate::i18n::tr("Building waveform…"),
Tokens::ui(11.0),
t.text_faint,
);
+2 -2
View File
@@ -9,12 +9,12 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
let c = rect.center();
p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text);
let b1 = Rect::from_center_size(c + vec2(0.0, 0.0), vec2(220.0, 30.0));
if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::unique("empty-newcomp")).clicked() {
if widgets::text_button(ui, b1, crate::i18n::tr("New Composition"), true, &t, egui::Id::unique("empty-newcomp")).clicked() {
crate::panels::dialogs::open_new_comp(app);
}
app.auto.add("viewer.empty.newComp", b1, "New Composition");
let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0));
if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() {
if widgets::text_button(ui, b2, crate::i18n::tr("New Composition From Footage"), false, &t, egui::Id::unique("empty-fromfootage")).clicked() {
let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({}));
}
app.auto.add("viewer.empty.import", b2, "New Composition From Footage");