From 4133f47e2f0c3eb9f3820a6b1c6b4934aca99026 Mon Sep 17 00:00:00 2001 From: FeiLiuEM Date: Thu, 15 Oct 2026 23:13:12 +0800 Subject: [PATCH 1/4] i18n: the panel tabs, and the labels the call-shape scan cannot see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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"). --- crates/ui-egui/src/dock_ui.rs | 19 ++- crates/ui-egui/src/i18n.rs | 29 +++++ crates/ui-egui/src/i18n/ui.rs | 113 ++++++++++++++++++ crates/ui-egui/src/panels/anim_tools.rs | 8 +- .../ui-egui/src/panels/camera_tracker_ui.rs | 6 +- crates/ui-egui/src/panels/effect_controls.rs | 4 +- crates/ui-egui/src/panels/effects_presets.rs | 2 +- crates/ui-egui/src/panels/flowchart.rs | 2 +- crates/ui-egui/src/panels/fx_editors.rs | 6 +- crates/ui-egui/src/panels/learn.rs | 2 +- crates/ui-egui/src/panels/misc.rs | 3 +- crates/ui-egui/src/panels/mod.rs | 2 +- crates/ui-egui/src/panels/project.rs | 2 +- crates/ui-egui/src/panels/render_queue.rs | 4 +- crates/ui-egui/src/panels/templates.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 6 +- crates/ui-egui/src/panels/viewer.rs | 12 +- 17 files changed, 192 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index a92d376e9..7e7a3f787 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -39,11 +39,24 @@ impl EffectcraftApp { out.push((panels::viewers::panel(id), t)); } } - 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)) { + if let Some(comp) = self.session.active_comp() + && 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))); } + // The panels named for themselves, in the interface language. The Window menu has carried a + // (`window.panel`, title) row for these in every catalog that exists, so the dock reads the + // same pair instead of drawing `title()`'s English; a panel the menu does not list (`History`, + // `ScriptUI Panel`) keeps its English name, and a tab that names a comp or a layer is listed + // above and wins, being found first. + for p in PanelKind::ALL { + // The Window menu lists most of these; `Markers` is only in the panel catalog, and the + // two panels in neither (`History`, `ScriptUI Panel`) keep their English names. + let listed = crate::i18n::label(self, "window.panel", p.title()); + let label = if listed == p.title() { crate::i18n::tr(p.title()) } else { listed }; + out.push((p, label.to_string())); + } out } @@ -570,7 +583,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::label(self, "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); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4caae8dde..061a62601 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -6901,6 +6901,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::label(self, "window.panel", p.title())"#), "dock_ui.rs no longer asks the catalog for the panel titles"); + } + #[test] fn zh_hans_translates_labels_computed_titles_and_keeps_names_verbatim() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..186d272e2 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1366,6 +1366,21 @@ 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", "步"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -2093,6 +2108,21 @@ 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", "步驟"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -4464,6 +4494,89 @@ mod tests { } } + /// Labels drawn through a helper's *argument* or through `Painter::text` are invisible to the + /// scan above, which only looks at a literal right after the call token: a `widgets::` helper + /// takes its label as its second or third argument, and `Painter::text` takes the string third. + /// Both shapes are checked here, so an English literal there is caught like any other. + #[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 `(`. + fn args_of(s: &str) -> Vec<&str> { + let (mut out, mut depth, mut start, mut instr) = (Vec::new(), 0i32, None, false); + for (i, c) in s.char_indices() { + match c { + '"' if !instr => { + instr = true; + if start.is_none() { + start = Some(i); + } + } + '"' => instr = false, + '(' | '[' | '{' if !instr => depth += 1, + ')' | ']' | '}' if !instr => { + if depth == 0 { + break; + } + depth -= 1; + } + ',' if !instr && depth == 0 => { + if let Some(st) = start { + out.push(s[st..i].trim()); + } + start = None; + } + c if !instr && !c.is_whitespace() && start.is_none() => start = Some(i), + _ => {} + } + } + if let Some(st) = start { + out.push(s[st..].trim()); + } + out + } + + for (path, source) in CONVERTED { + // The test modules of these files draw nothing; they hold sample strings. + let source = source.split("#[cfg(test)]").next().unwrap_or(source); + for (n, line) in source.lines().enumerate() { + let code = line.split("//").next().unwrap_or(line); + let check = |args: Vec<&str>, index: usize, what: &str| { + let Some(arg) = args.get(index) else { return }; + // A single character is a symbol or a glyph, not interface text - the same + // exemption the call-shape scan above makes. + let inner = arg.strip_prefix('"').and_then(|a| a.strip_suffix('"')).filter(|i| !i.contains('"')); + match inner { + Some(text) if text.chars().count() >= 2 && !VERBATIM.contains(&text) => { + panic!("{path}:{}: the interface text {arg} that {what} draws is not in the catalog", n + 1); + } + _ => {} + } + }; + for (call, index) in HELPER_LABELS { + let marker = format!("widgets::{call}("); + let mut from = 0; + while let Some(at) = code[from..].find(&marker) { + let at = from + at + marker.len(); + check(args_of(&code[at..]), *index, &format!("widgets::{call}")); + from = at; + } + } + let mut from = 0; + while let Some(at) = code[from..].find(".text(") { + from = at + 6; + check(args_of(&code[from..]), 2, "Painter::text"); + } + } + } + } + /// The Settings pages come from the engine as data, so the literal scan above cannot see them: /// every title, section, label, choice, button, note and unit they draw needs a Chinese row /// (#1178). Language names are written in their own language, and a few labels read the same diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index 15a4f4f89..3f7c0ca46 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -109,7 +109,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::new("wg-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::new("wg-apply")).clicked() { let params = json!({ "apply": if st.wiggle_spatial { "spatial" } else { "temporal" }, "noise": if st.wiggle_smooth { "smooth" } else { "jagged" }, @@ -150,7 +150,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::new("sm-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::new("sm-apply")).clicked() { apply(app, "keys.smooth", json!({"tolerance": tol})); } app.auto.add("smoother.apply", b, "Apply"); @@ -321,7 +321,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| { @@ -392,7 +392,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::new("mi-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::new("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(), diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index 2bccfae07..1fe187672 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -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::new(("ct-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::new(("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::new(("ct-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::new(("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(¶ms); if solve.is_some() { - if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::new(("ct-camera", g.uid))).clicked() { + if widgets::text_button(ui, b3, crate::i18n::tr("Create Camera"), false, &t, egui::Id::new(("ct-camera", g.uid))).clicked() { actions.push(("camera.create".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera"); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..24c096977 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -756,12 +756,12 @@ fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l 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::new(("warp-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::new(("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::new(("warp-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::new(("warp-cancel", g.uid))).clicked() { actions.push(("warp.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel"); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index d3921cd85..e2544442c 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -276,7 +276,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"); // Contents menu. diff --git a/crates/ui-egui/src/panels/flowchart.rs b/crates/ui-egui/src/panels/flowchart.rs index f59d35982..f36fa86f5 100644 --- a/crates/ui-egui/src/panels/flowchart.rs +++ b/crates/ui-egui/src/panels/flowchart.rs @@ -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. diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 1ae74caa2..21d56deba 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -786,19 +786,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::new(("reshape-add", euid))).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("Add Point"), false, &t, egui::Id::new(("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::new(("reshape-remove", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b2, crate::i18n::tr("Remove Point"), false, &t, egui::Id::new(("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::new(("reshape-clear", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b3, crate::i18n::tr("Clear"), false, &t, egui::Id::new(("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"); diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index b613791fd..1e52b5c25 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -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); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index cd0ab6897..c1b7d9ee5 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -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 diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1b6ef8c33..21f009912 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -240,7 +240,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()); } } }); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 46187fa8c..11fc5c15b 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -431,7 +431,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // 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. diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index e07efe69e..6f9a539d0 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -456,7 +456,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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::new("rq-render")).clicked() && can_render { + if widgets::text_button(ui, render_b, crate::i18n::tr("Render"), can_render, &t, egui::Id::new("rq-render")).clicked() && can_render { actions.push(("renderQueue.render", json!({"wait": false}))); } app.auto.add("renderQueue.render", render_b, "Render"); @@ -469,7 +469,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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::new("rq-stop")).clicked() { + if widgets::text_button(ui, stop_b, crate::i18n::tr("Stop"), false, &t, egui::Id::new("rq-stop")).clicked() { actions.push(("renderQueue.stop", json!({}))); } app.auto.add("renderQueue.stop", stop_b, "Stop"); diff --git a/crates/ui-egui/src/panels/templates.rs b/crates/ui-egui/src/panels/templates.rs index ec65ee6d7..a0db75d92 100644 --- a/crates/ui-egui/src/panels/templates.rs +++ b/crates/ui-egui/src/panels/templates.rs @@ -69,7 +69,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { // 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::new("home-templates-save")).clicked(); + let save = widgets::text_button(ui, sr, crate::i18n::tr("Save Current as Template…"), false, &t, egui::Id::new("home-templates-save")).clicked(); app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…"); if save { if can_save { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..7661ca3cb 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1386,7 +1386,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); let Some(cid) = app.session.active_comp_id() else { - 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::(&ctx).as_deref() @@ -1517,7 +1517,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } 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; @@ -2501,7 +2501,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { RowKind::Waveform { item } => { lp.rect_filled(left, 0.0, if ri % 2 == 0 { 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"); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a881..9c77d62a8 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -745,7 +745,13 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo // Close the locked viewer. let close = Rect::from_min_size(pos2(r.max.x - 26.0, r.min.y + 4.0), vec2(20.0, 20.0)); let resp = ui.interact(close, egui::Id::new("viewer-locked-close"), Sense::click()); - p.text(close.center(), Align2::CENTER_CENTER, "\u{00d7}", Tokens::ui(14.0), if resp.hovered() { Color32::WHITE } else { Color32::from_white_alpha(170) }); + p.text( + close.center(), + Align2::CENTER_CENTER, + crate::i18n::tr("\u{00d7}"), + Tokens::ui(14.0), + if resp.hovered() { Color32::WHITE } else { Color32::from_white_alpha(170) }, + ); app.auto.add("viewer.locked.close", close, "Close Locked Viewer"); if resp.clicked() { let _ = app.session.execute("view.closeLockedViewer", serde_json::json!({})); @@ -2504,12 +2510,12 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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::new("empty-newcomp")).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("New Composition"), true, &t, egui::Id::new("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::new("empty-fromfootage")).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("New Composition From Footage"), false, &t, egui::Id::new("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"); From 71a9ab085228a8f2df6f1910b65c1d358a6030fe Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sun, 18 Oct 2026 14:06:19 +0900 Subject: [PATCH 2/4] G9: translate the new panel labels into Japanese --- crates/ui-egui/src/i18n/ui.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 206111892..86a286123 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -636,6 +636,21 @@ const JAPANESE: &[(&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", "ステップ"), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ From c7234061e8de6d4b8e414e4724241122f8434565 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:32:32 +0200 Subject: [PATCH 3/4] Viewer: motion path follows X/Y Position when dimensions are separated (#2242) --- crates/ui-egui/src/panels/viewer.rs | 15 +- crates/ui-egui/src/panels/viewer_overlays.rs | 152 +++++++++++++++---- 2 files changed, 133 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 984ad696f..4ca1c2425 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1962,8 +1962,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index})); } else if let Some(k) = ov::motion_key_at(&key_hits, pos) { // A key showing direction handles becomes Linear; one without, Auto Bezier. + // Separated X / Y Position have no spatial interpolation to convert. let curved = key_hits.iter().any(|h| h.tangent.is_some() && h.layer == k.layer && h.prop == k.prop && h.time == k.time); - if select_motion_key(app, &k) { + if k.axes.is_none() && select_motion_key(app, &k) { let spatial = if curved { "linear" } else { "autoBezier" }; let _ = app.session.execute("keys.interpolation", json!({"spatial": spatial})); } @@ -2224,14 +2225,18 @@ fn draw_rigs( /// layer, or a new shape layer when nothing is selected. The placed point snaps. /// Add a Position keyframe at a motion path's frame, keeping the path where it is. fn add_motion_key(app: &mut EffectcraftApp, f: ov::PathFrame) { - if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": f.prop, "time": f.time.seconds()})) { - app.ui.status = e.to_string(); + // Separated dimensions: the key goes on X and Y Position. + for prop in f.axes.map_or_else(|| vec![f.prop], Vec::from) { + if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": prop, "time": f.time.seconds()})) { + app.ui.status = e.to_string(); + } } } -/// Select only the motion path's keyframe `k` (what the vertex tools act on). +/// Select only the motion path's keyframe `k` (what the vertex tools act on; with separated +/// dimensions, the X and Y Position keys at its time). fn select_motion_key(app: &mut EffectcraftApp, k: &ov::KeyHit) -> bool { - app.session.execute("keys.select", json!({"keys": [{"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()}]})).is_ok() + app.session.execute("keys.select", json!({"keys": k.select_entries()})).is_ok() } /// The pointer for a Pen slot tool: the tool's own pen, the Pen showing Add Vertex's over a path diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 9117103d5..c09dcbec0 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -7,7 +7,7 @@ use effectcraft_engine::geom::{Mat3, vec2 as gv2}; use effectcraft_engine::keyframe::ShapePath; -use effectcraft_engine::project::{GroupKind, Layer, LayerId}; +use effectcraft_engine::project::{GroupKind, Layer, LayerId, Property}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::time::Tick; use effectcraft_engine::{KeyRef, VertexRef}; @@ -274,6 +274,23 @@ pub(crate) struct KeyHit { pub value: [f64; 3], /// Parent space → comp. pub p2c: Mat3, + /// Separated dimensions: X and Y Position's uids, and whether each has a key at `time`. + pub axes: Option<[(u64, bool); 2]>, +} + +impl KeyHit { + /// The keys this dot stands for: Position's, or the X and Y Position keys at its time. + pub(crate) fn key_refs(&self) -> Vec { + match self.axes { + Some(axes) => axes.iter().filter(|(_, has)| *has).map(|&(prop, _)| KeyRef { layer: self.layer, prop, time: self.time }).collect(), + None => vec![KeyRef { layer: self.layer, prop: self.prop, time: self.time }], + } + } + + /// [`Self::key_refs`] as `keys.select` entries. + pub(crate) fn select_entries(&self) -> Vec { + self.key_refs().iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect() + } } /// A frame on a motion path (one of its dots): where the Pen and Add Vertex tools add a @@ -285,6 +302,21 @@ pub(crate) struct PathFrame { /// Layer time of the frame. pub time: Tick, pub pos: Pos2, + /// Separated dimensions: X and Y Position's uids, which get the key instead of `prop`. + pub axes: Option<[u64; 2]>, +} + +/// Layer times of the keys of separated X and Y Position together, in order. +fn separated_key_times(x: &Property, y: &Property) -> Vec { + let mut times: Vec = x.keys.iter().chain(y.keys.iter()).map(|k| k.time).collect(); + times.sort(); + times.dedup(); + times +} + +/// Separated X and Y Position at layer time `t` (z 0: motion paths are for 2D layers). +fn separated_value(x: &Property, y: &Property, t: Tick) -> [f64; 3] { + [x.value_at(t).as_f64(), y.value_at(t).as_f64(), 0.0] } /// The keyframe dot on a motion path under `pos`. @@ -326,49 +358,62 @@ pub(crate) fn motion_path( hits: &mut Vec, frames: &mut Vec, ) { - let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return }; - if pos.keys.len() < 2 || l.is_3d() { + let Some(tr) = l.transform() else { return }; + let Some(pos) = tr.get("position") else { return }; + // Separate Dimensions: the layer follows X / Y Position, so the path is drawn from their keys. + let sep = tr.get("positionX").zip(tr.get("positionY")); + let key_times: Vec = match sep { + Some((x, y)) => separated_key_times(x, y), + None => pos.keys.iter().map(|k| k.time).collect(), + }; + if key_times.len() < 2 || l.is_3d() { return; } + let prop = sep.map_or(pos.uid, |(x, _)| x.uid); + let frame_axes = sep.map(|(x, y)| [x.uid, y.uid]); + let value_at = |t: Tick| match sep { + Some((x, y)) => separated_value(x, y, t), + None => pos.value_at(t).as_vec3(), + }; let p2c = parent_to_comp(ectx, l); let to_scr = |v: [f64; 3]| { let c = p2c.apply(gv2(v[0], v[1])); map.to_screen([c.x, c.y]) }; // Settings ▸ Composition ▸ Motion Path: all keyframes, none, or a window around the CTI. - let times: Vec = pos.keys.iter().map(|k| k.time).collect(); - let Some((lt0, lt1)) = app.session.prefs.motion_path_span(×, l.layer_time(ectx.time)) else { return }; + let Some((lt0, lt1)) = app.session.prefs.motion_path_span(&key_times, l.layer_time(ectx.time)) else { return }; let fd = ectx.comp.frame_duration(); let mut pts = vec![]; let mut lt = lt0; while lt <= lt1 && pts.len() < 20_000 { - let q = to_scr(pos.value_at(lt).as_vec3()); - frames.push(PathFrame { layer: l.id, prop: pos.uid, time: lt, pos: q }); + let q = to_scr(value_at(lt)); + frames.push(PathFrame { layer: l.id, prop, time: lt, pos: q, axes: frame_axes }); pts.push(q); lt += fd; } - pts.push(to_scr(pos.value_at(lt1).as_vec3())); + pts.push(to_scr(value_at(lt1))); let faint = col.gamma_multiply(0.8); painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, faint))); for q in &pts { painter.circle_filled(*q, 1.2, faint); } let sel = &app.session.state.selected_keys; - for (i, k) in pos.keys.iter().enumerate() { - if k.time < lt0 || k.time > lt1 { + for (i, kt) in key_times.iter().enumerate() { + if *kt < lt0 || *kt > lt1 { continue; } - let v = k.value.as_vec3(); + let v = value_at(*kt); let s = to_scr(v); // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's // ends), to drag it into a curve; Linear keys (no tangents) show none. - let (tin, tout) = if k.spatial_auto { - effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) - } else { - effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) + // Separated X / Y have no spatial tangents. + let (tin, tout) = match pos.keys.get(i).filter(|_| sep.is_none()) { + Some(k) if k.spatial_auto => effectcraft_engine::keyframe::spatial_handles(&pos.keys, i), + Some(_) => effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i), + None => ([0.0; 3], [0.0; 3]), }; for (out, tg) in [(false, tin), (true, tout)] { - let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); + let has = (out && i + 1 < key_times.len()) || (!out && i > 0); if !has || (tg[0] == 0.0 && tg[1] == 0.0) { continue; } @@ -377,17 +422,19 @@ pub(crate) fn motion_path( painter.circle_filled(h, 3.0, col); let side = if out { "out" } else { "in" }; app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); - hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); + hits.push(KeyHit { layer: l.id, prop, time: *kt, pos: h, tangent: Some(out), value: v, p2c, axes: None }); } + let axes = sep.map(|(x, y)| [x, y].map(|a| (a.uid, a.keys.iter().any(|k| k.time == *kt)))); + let hit = KeyHit { layer: l.id, prop, time: *kt, pos: s, tangent: None, value: v, p2c, axes }; let r = Rect::from_center_size(s, vec2(7.0, 7.0)); - if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) { + if hit.key_refs().iter().any(|k| sel.contains(k)) { painter.rect_filled(r, 0.0, col); } else { painter.rect_filled(r, 0.0, Color32::from_black_alpha(140)); painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); } app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe"); - hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c }); + hits.push(hit); } } @@ -503,7 +550,7 @@ fn point_in_quad(p: Pos2, q: &[Pos2; 4]) -> bool { #[derive(Clone, Debug)] pub(crate) enum Drag { /// A motion-path keyframe: its value moves with the pointer (parent space). - Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3 }, + Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3, axes: Option<[u64; 2]> }, /// A spatial tangent handle of a motion-path key. KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 }, /// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`. @@ -528,11 +575,17 @@ pub(crate) fn begin_key_drag(app: &mut EffectcraftApp, hits: &[KeyHit], press: P match h.tangent { Some(out) => Some(Drag::KeyTangent { layer: h.layer, prop: h.prop, time: h.time, out, key: h.value, inv }), None => { - let k = KeyRef { layer: h.layer, prop: h.prop, time: h.time }; - if !app.session.state.selected_keys.contains(&k) || shift { - let _ = app.session.execute("keys.select", json!({"keys": [{"layer": h.layer.0, "prop": h.prop, "time": h.time.seconds()}], "add": shift})); + // Separated dimensions: both X and Y need a key here before the drag moves them. + let mut h = *h; + for (uid, has) in h.axes.iter_mut().flatten().filter(|(_, has)| !*has) { + let _ = app.session.execute("prop.addKey", json!({"layer": h.layer.0, "prop": *uid, "time": h.time.seconds()})); + *has = true; } - Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv }) + if !h.key_refs().iter().all(|k| app.session.state.selected_keys.contains(k)) || shift { + let _ = app.session.execute("keys.select", json!({"keys": h.select_entries(), "add": shift})); + } + let axes = h.axes.map(|a| a.map(|(uid, _)| uid)); + Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv, axes }) } } } @@ -598,17 +651,22 @@ pub(crate) fn update( snap: &mut dyn FnMut(&EffectcraftApp, [f64; 2], LayerId) -> [f64; 2], ) -> Option { match d.clone() { - Drag::Key { layer, prop, time, start, press, inv } => { + Drag::Key { layer, prop, time, start, press, inv, axes } => { // Snap the key's on-screen position like a layer drag. let start_c = inv.inverse().map(|m| m.apply(gv2(start[0], start[1]))).map(|v| [v.x, v.y]).unwrap_or([start[0], start[1]]); let mut target = [start_c[0] + cpt[0] - press[0], start_c[1] + cpt[1] - press[1]]; let s = snap(app, target, layer); target = [target[0] + s[0], target[1] + s[1]]; let p = inv.apply(gv2(target[0], target[1])); - let v = json!([p.x, p.y, start[2]]); - let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "value": v, "merge": merge})); - if let Err(e) = r { - app.ui.status = e.to_string(); + let edits = match axes { + Some([ux, uy]) => vec![(ux, json!(p.x)), (uy, json!(p.y))], + None => vec![(prop, json!([p.x, p.y, start[2]]))], + }; + for (uid, v) in edits { + let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": uid, "time": time.seconds(), "value": v, "merge": merge})); + if let Err(e) = r { + app.ui.status = e.to_string(); + } } None } @@ -673,6 +731,42 @@ pub(crate) fn update( mod tests { use super::*; + fn scalar_prop(uid: u64, keys: &[(i64, f64)]) -> Property { + use effectcraft_engine::keyframe::{Keyframe, Value}; + let mut p = Property::new(uid, "positionX", "X Position", Value::Scalar(0.0)); + p.keys = keys.iter().map(|&(t, v)| Keyframe::new(Tick(t), Value::Scalar(v))).collect(); + p + } + + /// #2242: with separated dimensions the motion path follows the keys of X and Y Position. + #[test] + fn separated_motion_path_uses_x_and_y_keys() { + let x = scalar_prop(1, &[(0, 10.0), (100, 30.0)]); + let y = scalar_prop(2, &[(0, 0.0), (50, 20.0), (100, 0.0)]); + assert_eq!(separated_key_times(&x, &y), vec![Tick(0), Tick(50), Tick(100)]); + let mid = separated_value(&x, &y, Tick(50)); + assert!((mid[0] - 20.0).abs() < 1e-9 && (mid[1] - 20.0).abs() < 1e-9 && mid[2] == 0.0, "{mid:?}"); + assert_eq!(separated_value(&x, &y, Tick(100)), [30.0, 0.0, 0.0]); + } + + /// #2242: a separated dot selects the X and Y keys at its time, not the nearest X key. + #[test] + fn separated_key_dot_refers_to_its_axis_keys() { + let mut h = KeyHit { + layer: LayerId(7), + prop: 1, + time: Tick(50), + pos: Pos2::ZERO, + tangent: None, + value: [0.0; 3], + p2c: Mat3::IDENTITY, + axes: Some([(1, false), (2, true)]), + }; + assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 2, time: Tick(50) }]); + h.axes = None; + assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 1, time: Tick(50) }]); + } + fn info(vertices: Vec<[f64; 2]>, closed: bool) -> PathInfo { let n = vertices.len(); let sp = ShapePath { vertices, in_tangents: vec![[0.0; 2]; n], out_tangents: vec![[0.0; 2]; n], closed, feather: Vec::new() }; From 74303466ef2a603ab8c7507b5addfd894f4a4e40 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 11:31:29 +0700 Subject: [PATCH 4/4] =?UTF-8?q?Layer=20Style=20dialog:=20Edit=20Gradient?= =?UTF-8?q?=E2=80=A6=20opens=20the=20Gradient=20Editor=20and=20comes=20bac?= =?UTF-8?q?k=20(#2057)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gradient Overlay's gradient showed "Edit in the Timeline" text in the Layer Style dialog. It is now an Edit Gradient… button that runs prop.editGradient on that property; OK or Cancel in the Gradient Editor returns to the Layer Style dialog, as in After Effects. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ui.rs | 9 ------- crates/ui-egui/src/panels/gradient_editor.rs | 9 +++++-- .../ui-egui/src/panels/layer_styles_dialog.rs | 22 +++++++++++++---- crates/ui-egui/tests/it/ui_tail.rs | 24 +++++++++++++++++++ 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 1b7900bcb..4e010c469 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -316,7 +316,6 @@ const JAPANESE: &[(&str, &str)] = &[ ("Duration:", "デュレーション:"), ("Edit Gradient…", "グラデーションを編集…"), ("Edit Target: {}", "編集対象:{}"), - ("Edit in the Timeline", "タイムラインで編集"), ("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"), ("Effects", "エフェクト"), ("Effects & Presets options", "エフェクト&プリセットのオプション"), @@ -1360,7 +1359,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Duration:", "持续时间:"), ("Edit Gradient…", "编辑渐变…"), ("Edit Target: {}", "编辑目标:{}"), - ("Edit in the Timeline", "在时间轴中编辑"), ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"), ("Effects", "效果"), ("Effects & Presets options", "效果和预设选项"), @@ -2469,7 +2467,6 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Duration:", "持續時間:"), ("Edit Gradient…", "編輯漸變…"), ("Edit Target: {}", "編輯目標:{}"), - ("Edit in the Timeline", "在時間軸中編輯"), ("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"), ("Effects", "效果"), ("Effects & Presets options", "效果和預設集選項"), @@ -3531,7 +3528,6 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Duration:", "Тривалість:"), ("Edit Gradient…", "Редагувати градієнт…"), ("Edit Target: {}", "Ціль редагування: {}"), - ("Edit in the Timeline", "Редагувати на шкалі часу"), ("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."), ("Effects", "Ефекти"), ("Effects & Presets options", "Параметри ефектів і наборів"), @@ -4095,7 +4091,6 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Duration:", "Duração:"), ("Edit Gradient…", "Editar gradiente…"), ("Edit Target: {}", "Editar destino: {}"), - ("Edit in the Timeline", "Editar na linha do tempo"), ("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."), ("Effects", "Efeitos"), ("Effects & Presets options", "Opções de Efeitos e predefinições"), @@ -4858,7 +4853,6 @@ const POLISH: &[(&str, &str)] = &[ ("Duration:", "Czas trwania:"), ("Edit Gradient…", "Edytuj gradient…"), ("Edit Target: {}", "Cel edycji: {}"), - ("Edit in the Timeline", "Edytuj na osi czasu"), ("EffectCraft didn't quit normally last time.", "EffectCraft nie zakończył się poprawnie ostatnim razem."), ("Effects & Presets options", "Opcje efektów i ustawień"), ("Copy Presets Folder Path", "Kopiuj ścieżkę folderu ustawień"), @@ -5603,7 +5597,6 @@ const CZECH: &[(&str, &str)] = &[ ("Duration:", "Délka trvání:"), ("Edit Gradient…", "Upravit přechod…"), ("Edit Target: {}", "Upravit cíl: {}"), - ("Edit in the Timeline", "Upravit na časové ose"), ("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."), ("Effects", "Efekty"), ("Effects & Presets options", "Možnosti efektů a přednastavení"), @@ -6171,7 +6164,6 @@ const SPANISH: &[(&str, &str)] = &[ ("Duration:", "Duración:"), ("Edit Gradient…", "Editar degradado…"), ("Edit Target: {}", "Destino de la edición: {}"), - ("Edit in the Timeline", "Editar en la cronología"), ("EffectCraft didn't quit normally last time.", "EffectCraft no se cerró normalmente la última vez."), ("Effects", "Efectos"), ("Effects & Presets options", "Opciones de Efectos y ajustes preestablecidos"), @@ -6723,7 +6715,6 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Duration:", "ხანგრძლივობა:"), ("Edit Gradient…", "გრადიენტის რედაქტირება…"), ("Edit Target: {}", "რედაქტირების სამიზნე: {}"), - ("Edit in the Timeline", "რედაქტირება დროის ხაზზე"), ("EffectCraft didn't quit normally last time.", "EffectCraft ბოლოს ნორმალურად არ დახურულა."), ("Effects", "ეფექტები"), ("Effects & Presets options", "ეფექტებისა და პრესეტების პარამეტრები"), diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index 25e3d1d44..0f0bbe412 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -245,7 +245,7 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { /// Set the property to the draft: one `prop.set` at the time the editor opened, in composition /// time, or nothing when the draft is unchanged (so OK adds no keyframe). fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { - app.dialog = None; + close(app); let d = std::mem::take(&mut app.dialog_state.gradient); let g = d.gradient(); // The preview already set the property to this draft, in one merged undo step: OK keeps it. @@ -279,7 +279,12 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) { preview_merge::rollback(app, cp, d.applied_at.as_ref()); } app.session.history.merge_key = None; - app.dialog = None; + close(app); +} + +/// Close the editor: back to the Layer Style dialog when it opened from there (#2057). +fn close(app: &mut EffectcraftApp) { + app.dialog = std::mem::take(&mut app.dialog_state.layer_style.gradient_open).then_some(Dialog::LayerStyles); } /// Keep the property in step with the draft while Preview is on: apply a changed draft with one diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 5b415187d..09b775e30 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -23,6 +23,8 @@ pub struct LayerStyleState { pub selected: String, pub undo_mark: usize, checkpoint: Option, + /// The Gradient Editor opened from this dialog: closing it comes back here (#2057). + pub gradient_open: bool, } /// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back. @@ -58,7 +60,8 @@ pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> { return Err(format!("unknown layer style `{style}`")); } let checkpoint = checkpoint(app); - app.dialog_state.layer_style = LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len(), checkpoint: Some(checkpoint) }; + app.dialog_state.layer_style = + LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len(), checkpoint: Some(checkpoint), gradient_open: false }; app.dialog = Some(Dialog::LayerStyles); Ok(()) } @@ -118,10 +121,6 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { let changed = crate::widgets::srgb_color_button(ui, &mut rgb).changed(); changed.then(|| json!(rgb)) } - (KV::Gradient(_), _) => { - ui.label(egui::RichText::new(tr("Edit in the Timeline")).weak()); - None - } _ => { ui.label(egui::RichText::new(format!("{v:?}").chars().take(24).collect::()).weak()); None @@ -228,6 +227,19 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } let lbl = ui.label(&p.name); let v = p.value_at(lt); + if matches!(v, KV::Gradient(_)) { + // The Gradient Editor, over this dialog (a style the dialog + // only shows has no property to edit yet). + let path = g.match_path_of(p.uid).unwrap_or_default(); + let r = ui.add_enabled(missing.is_none(), egui::Button::new(tr("Edit Gradient…"))); + if r.clicked() { + actions.push(("prop.editGradient".into(), json!({"layer": lid, "prop": p.uid}))); + app.dialog_state.layer_style.gradient_open = true; + } + app.auto.add(&format!("dialog.layerStyle.value.{sel}/{path}"), r.rect, &p.name); + ui.end_row(); + return; + } let edit = ui.scope(|ui| control(ui, p, &v)); let path = g.match_path_of(p.uid).unwrap_or_default(); if let Some(nv) = edit.inner { diff --git a/crates/ui-egui/tests/it/ui_tail.rs b/crates/ui-egui/tests/it/ui_tail.rs index bd87a01a7..a824e065c 100644 --- a/crates/ui-egui/tests/it/ui_tail.rs +++ b/crates/ui-egui/tests/it/ui_tail.rs @@ -175,6 +175,30 @@ fn layer_style_buttons_stay_in_view() { } } +/// #2057: Gradient Overlay's gradient has an Edit Gradient… button in the Layer Style dialog. It +/// opens the Gradient Editor on that property, and closing the editor goes back to the dialog. +#[test] +fn layer_style_gradient_opens_the_gradient_editor() { + for ok in [true, false] { + let mut h = harness(1400.0); + let lid = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.gradientOverlay", json!({})).unwrap(); + h.run_steps(5); + let edit = in_view(&h, "dialog.layerStyle.value.gradientOverlay/colors"); + click(&mut h, edit); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::GradientEditor), "Edit Gradient… opened the editor"); + effectcraft_ui_egui::panels::gradient_editor::finish(h.state_mut(), ok, &ctx); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles), "closing the editor (OK {ok}) went back to the dialog"); + // The dialog closes as before once the editor is gone. + effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + } +} + /// #1062: with the buttons kept at the bottom, the style's controls still scroll both ways, so /// every one of them can be reached in the smallest window (960×600) at 200 % UI Scale, and in a /// dialog opened in a large window that then narrows: here Noise, near the end of Drop Shadow,