diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index c0385aa4..d1e81f1e 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -998,6 +998,37 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Use In-Window Menu Bar on macOS", "在 macOS 上使用窗口内菜单栏"), ("Wireframe", "线框"), ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1(默认)"), + // The Home screen (#574). + ("New Project", "新建项目"), + ("Open Project…", "打开项目…"), + ("Templates", "模板"), + ("EffectCraft home page", "EffectCraft 主页"), + ("EffectCraft on GitHub", "GitHub 上的 EffectCraft"), + ("Motion graphics and visual effects · version {}", "动态图形和视觉效果 · 版本 {}"), + ("New Composition", "新建合成"), + ("Open Demo Project", "打开示例项目"), + ("Import Footage…", "导入素材…"), + ("Filter recent files", "筛选最近文件"), + ("NAME", "名称"), + ("OPENED", "打开时间"), + ("SIZE", "大小"), + ("KIND", "类型"), + ("Your recent files will show here", "你最近使用的文件会显示在这里"), + ("Create a new project, or open the demo project to look around.", "新建一个项目,或打开示例项目先熟悉一下界面。"), + ("No recent files match the filter", "没有与筛选条件匹配的最近文件"), + ("Try another name or folder.", "请尝试其他名称或文件夹。"), + ("Just now", "刚刚"), + ("Yesterday", "昨天"), + ("{} minute ago", "{} 分钟前"), + ("{} minutes ago", "{} 分钟前"), + ("{} hour ago", "{} 小时前"), + ("{} hours ago", "{} 小时前"), + ("{} day ago", "{} 天前"), + ("{} days ago", "{} 天前"), + ("EffectCraft project", "EffectCraft 项目"), + ("After Effects project", "After Effects 项目"), + ("Lottie animation", "Lottie 动画"), + ("Project", "项目"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1472,7 +1503,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Import PDF / Illustrator File", "Import PDF / Illustrator File"), ("Import Photoshop File", "Import Photoshop File"), ("Interpret Unlabeled Alpha As", "Interpret Unlabeled Alpha As"), - ("Join the ArtCraft Discord", "Join the ArtCraft Discord"), + ("Join the ArtCraft Discord", "加入 ArtCraft Discord"), ("Mercury GPU Acceleration ({})", "Mercury GPU Acceleration ({})"), ("Mercury Software Only", "Mercury Software Only"), ("OCIO (built-in ACES config)", "OCIO (built-in ACES config)"), @@ -1485,6 +1516,37 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Use In-Window Menu Bar on macOS", "Use In-Window Menu Bar on macOS"), ("Wireframe", "Wireframe"), ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (default)"), + // The Home screen (#574). + ("New Project", "新增專案"), + ("Open Project…", "開啟專案…"), + ("Templates", "範本"), + ("EffectCraft home page", "EffectCraft 首頁"), + ("EffectCraft on GitHub", "GitHub 上的 EffectCraft"), + ("Motion graphics and visual effects · version {}", "動態圖形與視覺效果 · 版本 {}"), + ("New Composition", "新增合成"), + ("Open Demo Project", "開啟範例專案"), + ("Import Footage…", "匯入素材…"), + ("Filter recent files", "篩選最近的檔案"), + ("NAME", "名稱"), + ("OPENED", "開啟時間"), + ("SIZE", "大小"), + ("KIND", "類型"), + ("Your recent files will show here", "你最近使用的檔案會顯示在這裡"), + ("Create a new project, or open the demo project to look around.", "新增一個專案,或開啟範例專案先熟悉一下介面。"), + ("No recent files match the filter", "沒有符合篩選條件的最近檔案"), + ("Try another name or folder.", "請嘗試其他名稱或資料夾。"), + ("Just now", "剛剛"), + ("Yesterday", "昨天"), + ("{} minute ago", "{} 分鐘前"), + ("{} minutes ago", "{} 分鐘前"), + ("{} hour ago", "{} 小時前"), + ("{} hours ago", "{} 小時前"), + ("{} day ago", "{} 天前"), + ("{} days ago", "{} 天前"), + ("EffectCraft project", "EffectCraft 專案"), + ("After Effects project", "After Effects 專案"), + ("Lottie animation", "Lottie 動畫"), + ("Project", "專案"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index ce6121a7..048b0b58 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -374,6 +374,37 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt }); r.response.rect } + // Scale keeps its Constrain Proportions link, on by default, as in After Effects (#338). + // The link is the property's own, shared with the Timeline's chain link. + ControlType::Point if p.match_id == "scale" && matches!(p.ui, ParamUi::Percent) => { + let comps = v.components(); + let linked = !app.ui.timeline.unlinked.contains(&p.uid); + let (lr, link) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); + let t = app.tokens; + crate::icons::paint(ui.painter(), lr, crate::icons::Icon::Link, if linked { t.accent } else { t.text_faint }); + let link = link.on_hover_text(tr("Constrain Proportions")); + app.auto.add(&format!("{auto}.link"), lr, "Constrain Proportions"); + if link.clicked() && !app.ui.timeline.unlinked.remove(&p.uid) { + app.ui.timeline.unlinked.insert(p.uid); + } + let mut rect = lr; + for (d, &old) in comps.iter().enumerate() { + let mut x = old; + let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix(if d + 1 == comps.len() { "%" } else { "" }).max_decimals(1)); + app.auto.add(&format!("{auto}.{d}"), r.rect, &p.name); + rect = rect.union(r.rect); + if r.changed() { + // Alt edits one value of a linked pair, as in the Timeline. + let next = if linked && !ui.input(|i| i.modifiers.alt) { + widgets::constrained(&comps, d, x) + } else { + comps.iter().enumerate().map(|(e, &c)| if e == d { x } else { c }).collect() + }; + out = Some(json!(next)); + } + } + rect + } ControlType::Point => { let mut a = v.as_vec2(); let r1 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1))); diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 7f19d91b..2dbb9826 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -14,8 +14,8 @@ use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; use super::timeline::TMap; -use crate::EffectcraftApp; use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; type Actions = Vec<(String, serde_json::Value)>; @@ -162,10 +162,88 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl out } -fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: &mut f32, y: f32, label: &str, on: bool, id: &str, tip: &str) -> bool { +/// The Graph Editor's button bar, laid out left to right. Buttons that would run past the right +/// edge (a Timeline narrower than the whole row, #457) go to the "»" menu at its end instead of +/// being clipped, so every one stays reachable. +struct ButtonBar { + x: f32, + /// Where the next button must end to stay on the bar (the "»" button's room is kept free). + limit: f32, + /// Buttons moved to the "»" menu: (automation id, label, on, tooltip). + hidden: Vec<(String, String, bool, String)>, + /// The id picked from the "»" menu last frame, run by its button this frame. + chosen: Option, +} + +/// The "»" menu's popup and the id it picked (read by the next frame's buttons). +const MORE_POPUP: &str = "graph-more"; + +impl ButtonBar { + const MORE_W: f32 = 24.0; + + fn new(ctx: &egui::Context, bar: Rect) -> Self { + let chosen = ctx.data_mut(|d| d.remove_temp::(egui::Id::new(MORE_POPUP).with("chosen"))); + ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen } + } + + /// The "»" button and its menu, when buttons did not fit. + fn more(&mut self, app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect) { + if self.hidden.is_empty() { + return; + } + let t = app.tokens; + let r = Rect::from_min_size(pos2(bar.max.x - 8.0 - Self::MORE_W, bar.center().y - 9.0), vec2(Self::MORE_W, 18.0)); + let pid = egui::Id::new(MORE_POPUP); + let resp = ui.interact(r, pid.with("button"), Sense::click()).on_hover_text(crate::i18n::tr("More")); + p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x2c, 0x2c, 0x2c) }); + p.text(r.center(), Align2::CENTER_CENTER, "»", Tokens::ui(12.0), t.text); + app.auto.add("timeline.graph.more", r, "More"); + if resp.clicked() { + let open = widgets::popup_is_open(ui, pid); + ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); + } + if !widgets::popup_is_open(ui, pid) { + return; + } + let rows = self.hidden.len(); + let pos = r.left_top() - vec2(0.0, rows as f32 * 22.0 + 18.0); + let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { + let mut chosen = None; + for (id, label, on, tip) in &self.hidden { + let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); + if item.clicked() { + chosen = Some(id.clone()); + } + } + chosen + }); + if let Some(id) = chosen { + ui.data_mut(|d| d.insert_temp(pid.with("chosen"), id)); + ui.ctx().request_repaint(); + } + } +} + +fn text_button( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + bar: &mut ButtonBar, + y: f32, + label: &str, + on: bool, + id: &str, + tip: &str, +) -> bool { let t = app.tokens; let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; - let r = Rect::from_min_size(pos2(*x, y - 9.0), vec2(w, 18.0)); + // Once one button overflows, the rest follow it into the menu (they keep their order). + if !bar.hidden.is_empty() || bar.x + w > bar.limit { + bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); + return bar.chosen.as_deref() == Some(id); + } + let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip); p.rect_filled( r, @@ -180,7 +258,7 @@ fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x ); p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); - *x += w + 4.0; + bar.x += w + 4.0; resp.clicked() } @@ -446,14 +524,14 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte p.rect_filled(bar, 0.0, t.panel_bg); p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); let y = bar.center().y; - let mut x = bar.min.x + 8.0; + let mut buttons = ButtonBar::new(&ctx, bar); let tl = &app.ui.timeline; let (auto_type, show_sel, auto_on) = (tl.graph_mode == "auto", tl.graph_show_selected, tl.graph_auto_zoom); if text_button( app, ui, p, - &mut x, + &mut buttons, y, "Auto", auto_type, @@ -463,45 +541,45 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into(); } // Choosing a graph type turns Auto-Select off, as in After Effects. - if text_button(app, ui, p, &mut x, y, "Value", !speed, "valueGraph", "Edit Value Graph") { + if text_button(app, ui, p, &mut buttons, y, "Value", !speed, "valueGraph", "Edit Value Graph") { app.ui.timeline.graph_mode = "value".into(); } - if text_button(app, ui, p, &mut x, y, "Speed", speed, "speedGraph", "Edit Speed Graph") { + if text_button(app, ui, p, &mut buttons, y, "Speed", speed, "speedGraph", "Edit Speed Graph") { app.ui.timeline.graph_mode = "speed".into(); } - x += 6.0; - if text_button(app, ui, p, &mut x, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { + buttons.x += 6.0; + if text_button(app, ui, p, &mut buttons, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { app.ui.timeline.graph_show_selected = !show_sel; } - if text_button(app, ui, p, &mut x, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { + if text_button(app, ui, p, &mut buttons, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { app.ui.timeline.graph_auto_zoom = !auto_on; } - if text_button(app, ui, p, &mut x, y, "Fit", false, "fit", "Fit all graphs to view") { + if text_button(app, ui, p, &mut buttons, y, "Fit", false, "fit", "Fit all graphs to view") { app.ui.timeline.graph_auto_zoom = true; app.ui.timeline.pps = None; } - if text_button(app, ui, p, &mut x, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { + if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { fit_selection(app, &cs, tm, plot); } - x += 6.0; + buttons.x += 6.0; let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box); - if text_button(app, ui, p, &mut x, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { + if text_button(app, ui, p, &mut buttons, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { app.ui.timeline.graph_snap = !snap; } - if text_button(app, ui, p, &mut x, y, "Reference", reference, "reference", "Show Reference Graph") { + if text_button(app, ui, p, &mut buttons, y, "Reference", reference, "reference", "Show Reference Graph") { app.ui.timeline.graph_reference = !reference; } - if text_button(app, ui, p, &mut x, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { + if text_button(app, ui, p, &mut buttons, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { app.ui.timeline.graph_transform_box = !tbox; } - x += 6.0; + buttons.x += 6.0; let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0)); - if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") + if text_button(app, ui, p, &mut buttons, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0)) { actions.push(("prop.separateDimensions".into(), json!({"layer": l}))); } - x += 6.0; + buttons.x += 6.0; for (label, cmd, params, tip) in [ ("Hold", "keys.interpolation", json!({"interpolation": "hold"}), "Convert selected keyframes to Hold"), ("Linear", "keys.interpolation", json!({"interpolation": "linear"}), "Convert selected keyframes to Linear"), @@ -511,10 +589,11 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte ("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"), ] { let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or(""); - if text_button(app, ui, p, &mut x, y, label, false, &id, tip) { + if text_button(app, ui, p, &mut buttons, y, label, false, &id, tip) { actions.push((cmd.into(), params)); } } + buttons.more(app, ui, p, bar); } /// Zoom time and value to the selected keys (and their handles). diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 861d7d49..46bbb44f 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -15,6 +15,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde::Serialize; use serde_json::json; +use crate::i18n::{tr, tr_args}; use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -87,13 +88,13 @@ pub fn format_utc(secs: i64) -> String { /// date after a week). pub fn format_ago(now: i64, then: i64) -> String { let d = now.saturating_sub(then); - let plural = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }); + let plural = |n: i64, one: &str, many: &str| tr_args(if n == 1 { one } else { many }, &[&n]); match d { - i64::MIN..60 => "Just now".to_string(), - 60..3_600 => plural(d / 60, "minute"), - 3_600..86_400 => plural(d / 3_600, "hour"), - 86_400..172_800 => "Yesterday".to_string(), - 172_800..604_800 => plural(d / 86_400, "day"), + i64::MIN..60 => tr("Just now").to_string(), + 60..3_600 => plural(d / 60, "{} minute ago", "{} minutes ago"), + 3_600..86_400 => plural(d / 3_600, "{} hour ago", "{} hours ago"), + 86_400..172_800 => tr("Yesterday").to_string(), + 172_800..604_800 => plural(d / 86_400, "{} day ago", "{} days ago"), _ => format_utc(then).get(..10).unwrap_or_default().to_string(), } } @@ -112,14 +113,14 @@ pub fn format_size(bytes: u64) -> String { } } -/// The Kind column: what a recent file is, by its extension. +/// The Kind column: what a recent file is, by its extension, in the interface language. fn kind_of(path: &str) -> &'static str { - match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { + tr(match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { Some("ecproj") => "EffectCraft project", Some("aep" | "aepx") => "After Effects project", Some("json" | "lottie") => "Lottie animation", _ => "Project", - } + }) } /// The config-store name of a project's thumbnail. @@ -237,7 +238,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: let mut y = rail.min.y + 20.0; for (label, id, primary) in [("New Project", "file.newProject", true), ("Open Project…", "file.open", false)] { let r = Rect::from_min_size(pos2(x0, y), vec2(w, 34.0)); - if widgets::text_button(ui, r, label, primary, &t, egui::Id::new(("home", id))).clicked() { + if widgets::text_button(ui, r, tr(label), primary, &t, egui::Id::new(("home", id))).clicked() { actions.push((id, json!({}))); } app.auto.add(&format!("home.{id}"), r, label); @@ -261,7 +262,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: } let col = if on { t.tab_text_active } else { t.text_dim }; icons::paint(p, Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(16.0, 16.0)), icon, col); - p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(13.0), col); + p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(13.0), col); app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label); if resp.clicked() { app.ui.home_learn = k == 2; @@ -281,7 +282,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: let foot_h = 22.0 + links.len() as f32 * 30.0 + 22.0 + sib_rows * 26.0; let mut cy = (rail.max.y - 16.0 - foot_h).max(y + 16.0); p.line_segment([pos2(x0, cy - 10.0), pos2(x0 + w, cy - 10.0)], Stroke::new(1.0, t.separator)); - p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, crate::i18n::tr("Community"), Tokens::semibold(12.0), t.text_dim); + p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, tr("Community"), Tokens::semibold(12.0), t.text_dim); cy += 22.0; for (icon, label, cmd) in links { let r = Rect::from_min_size(pos2(x0, cy), vec2(w, 26.0)); @@ -294,14 +295,14 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: } let col = if discord { Color32::WHITE } else { t.text }; icons::paint(p, Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(13.0, 13.0)), icon, col); - p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.0), col); + p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.0), col); app.auto.add(&format!("home.{cmd}"), r, label); if resp.clicked() { let _ = app.session.execute(cmd, json!({})); } cy += 30.0; } - p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, crate::i18n::tr("More ArtCraft apps"), Tokens::ui(11.0), t.text_faint); + p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, tr("More ArtCraft apps"), Tokens::ui(11.0), t.text_faint); cy += 20.0; let sw = (w - 6.0) / 2.0; for (i, (name, slug)) in sib.iter().enumerate() { @@ -328,12 +329,12 @@ fn home_page( let t = app.tokens; let (x0, w) = (area.min.x, area.width()); let mut y = area.min.y; - p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Welcome to EffectCraft"), Tokens::semibold(26.0), t.tab_text_active); + p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, tr("Welcome to EffectCraft"), Tokens::semibold(26.0), t.tab_text_active); y += 40.0; p.text( pos2(x0, y + 8.0), Align2::LEFT_CENTER, - format!("Motion graphics and visual effects · version {}", env!("CARGO_PKG_VERSION")), + tr_args("Motion graphics and visual effects · version {}", &[&env!("CARGO_PKG_VERSION")]), Tokens::ui(12.5), t.text_dim, ); @@ -355,7 +356,7 @@ fn home_page( let ir = Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(32.0, 32.0)); p.rect_filled(ir, 7.0, t.accent.gamma_multiply(0.18)); icons::paint(p, ir.shrink(8.0), icon, t.accent); - p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), t.text); + p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.5), t.text); app.auto.add(&format!("home.{id}"), r, label); if resp.clicked() { if id == "templates" { @@ -369,18 +370,18 @@ fn home_page( y += 64.0 + 36.0; // Recent: heading, filter, clear. - p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, crate::i18n::tr("Recent"), Tokens::semibold(17.0), t.tab_text_active); + p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, tr("Recent"), Tokens::semibold(17.0), t.tab_text_active); let entries = recent_entries(&app.session.prefs); let fid = egui::Id::new("home-filter"); let mut filter: String = ctx.data(|d| d.get_temp(fid)).unwrap_or_default(); let fr = Rect::from_min_size(pos2(x0 + w - 240.0, y), vec2(240.0, 26.0)); - widgets::search_field(ui, fr, &mut filter, "Filter recent files", &t); + widgets::search_field(ui, fr, &mut filter, tr("Filter recent files"), &t); app.auto.add("home.filter", fr, "Filter recent files"); ctx.data_mut(|d| d.insert_temp(fid, filter.clone())); if !entries.is_empty() { let cr = Rect::from_min_size(pos2(fr.min.x - 96.0, y + 3.0), vec2(84.0, 20.0)); let resp = ui.interact(cr, egui::Id::new("home-clear-recent"), Sense::click()); - p.text(cr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint }); + p.text(cr.right_center(), Align2::RIGHT_CENTER, tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint }); app.auto.add("home.clearRecent", cr, "Clear Recent Projects"); if resp.clicked() { actions.push(("file.clearRecent", json!({}))); @@ -390,7 +391,7 @@ fn home_page( // Column headings (Name / Opened / Size / Kind, as in After Effects). let (opened_x, size_x, kind_x) = (x0 + w * 0.52, x0 + w * 0.68, x0 + w * 0.80); for (label, x) in [("NAME", x0 + 76.0), ("OPENED", opened_x), ("SIZE", size_x), ("KIND", kind_x)] { - p.text(pos2(x, y), Align2::LEFT_CENTER, label, Tokens::medium(10.5), t.text_faint); + p.text(pos2(x, y), Align2::LEFT_CENTER, tr(label), Tokens::medium(10.5), t.text_faint); } y += 12.0; p.line_segment([pos2(x0, y), pos2(x0 + w, y)], Stroke::new(1.0, t.separator)); @@ -406,8 +407,8 @@ fn home_page( } else { ("No recent files match the filter", "Try another name or folder.") }; - p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, head, Tokens::semibold(14.0), t.text); - p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, sub, Tokens::ui(12.0), t.text_faint); + p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, tr(head), Tokens::semibold(14.0), t.text); + p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, tr(sub), Tokens::ui(12.0), t.text_faint); } let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)); let row_h = 48.0; @@ -439,7 +440,7 @@ fn home_page( Tokens::medium(13.0), if e.exists { t.tab_text_active } else { t.text_dim }, ); - let sub = if e.exists { e.folder.clone() } else { format!("{} (missing)", e.folder) }; + let sub = if e.exists { e.folder.clone() } else { format!("{} {}", e.folder, crate::i18n::tr("(missing)")) }; name_clip.text(pos2(tr.max.x + 6.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub, Tokens::ui(11.0), t.text_faint); let cell = |x: f32, x1: f32, text: String| { p.with_clip_rect(Rect::from_min_max(pos2(x, r.min.y), pos2(x1 - 8.0, r.max.y))).text( @@ -534,6 +535,21 @@ mod tests { assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project")); } + /// The Home screen's own text goes through the interface catalog (#574): the Opened and Kind + /// columns read in Chinese, and English stays English. + #[test] + fn home_text_follows_the_interface_language() { + let now = 1_790_000_000; + crate::i18n::set_current("zh-hans"); + let zh = (format_ago(now, now - 5), format_ago(now, now - 3 * 3_600), format_ago(now, now - 90_000), kind_of("/a/B.ecproj")); + crate::i18n::set_current("zh-hant"); + let tw = (format_ago(now, now - 60), kind_of("x")); + crate::i18n::set_current("en"); + assert_eq!(zh, ("刚刚".into(), "3 小时前".into(), "昨天".into(), "EffectCraft 项目")); + assert_eq!(tw, ("1 分鐘前".into(), "專案")); + assert_eq!(tr("Filter recent files"), "Filter recent files"); + } + #[test] fn home_screen_registers_its_controls() { let mut s = effectcraft_engine::Session::default(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index f91987c0..4104871f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1614,9 +1614,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); if ui.rect_contains_pointer(rows_rect) { let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt)); - if zoom && dy.abs() > 0.0 { + // Ctrl/Cmd+wheel and a trackpad pinch reach egui as a zoom factor rather than a scroll + // (#428); like Alt+wheel they zoom time. + let pinch = ui.input(|i| i.zoom_delta()) as f64; + let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0); + if (pinch - 1.0).abs() > 1e-4 { + set_zoom(&mut app.ui.timeline, &comp, tm.pps * pinch, fit_pps, tm.t(at_x), (at_x - tm.x0) as f64); + } else if zoom && dy.abs() > 0.0 { // Alt+wheel zooms around the pointer, out until the whole comp shows. - let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0); set_zoom(&mut app.ui.timeline, &comp, tm.pps * (dy as f64 / 200.0).exp(), fit_pps, tm.t(at_x), (at_x - tm.x0) as f64); } else { let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0); @@ -3445,26 +3450,6 @@ fn fmt_num(v: f64, d: usize) -> String { format!("{v:.d$}") } -/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio -/// (Constrain Proportions); from 0 the others that are 0 too follow the value. -fn constrained(c: &[f64], d: usize, v: f64) -> Vec { - let Some(&old) = c.get(d) else { return c.to_vec() }; - c.iter() - .enumerate() - .map(|(e, &x)| { - if e == d { - v - } else if old.abs() > 1e-9 { - x * v / old - } else if x.abs() <= 1e-9 { - v - } else { - x - } - }) - .collect() -} - /// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a /// multi-dimension value's fields, one per dimension (none for other values). fn value_editor( @@ -3540,7 +3525,7 @@ fn value_editor( // Alt edits one value of a linked pair. let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 }; let from = if shown == 1 { d } else { 0 }; - for (e, v) in constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { + for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { if let Some(slot) = nc.get_mut(from + e) { *slot = v; } diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index c94f94f5..72cb0806 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -726,9 +726,23 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, x += 4.0; // 3D renderer + view. if comp.has_3d() { + // The comp's 3D renderer, as After Effects' Composition panel shows it: the menu switches + // it through Composition Settings (undoable), and the automation label is the renderer. + use effectcraft_engine::project::Renderer; + let renderers = [(Renderer::Classic3D, "Classic 3D", "classic3D"), (Renderer::Advanced3D, "Advanced 3D", "advanced3D")]; + let current = renderers.iter().find(|(r, ..)| *r == comp.renderer).map_or("Classic 3D", |(_, label, _)| *label); let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0)); - let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d")); - app.auto.add("viewer.renderer3d", r, "3D Renderer"); + if widgets::dropdown(ui, r, crate::i18n::tr(current), &t, egui::Id::new("vw-3d")).clicked() { + toggle_popup(ui, "vw-3d-pop"); + } + app.auto.add("viewer.renderer3d", r, current); + let items: Vec<(String, bool)> = renderers.iter().map(|(r, label, _)| (label.to_string(), *r == comp.renderer)).collect(); + if let Some((renderer, _, id)) = popup(app, ui, "vw-3d-pop", r, &items, "renderer3dItem").and_then(|i| renderers.get(i)) + && *renderer != comp.renderer + && let Err(e) = app.session.execute("comp.settings", json!({"renderer": id})) + { + app.ui.status = e.to_string(); + } x = r.max.x + 4.0; let cid = app.session.active_comp_id(); let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ea76f06b..364a2472 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -568,6 +568,26 @@ pub fn open_popup(ui: &Ui, id: egui::Id) { ui.data_mut(|d| d.insert_temp(id.with("open"), true)); } +/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio +/// (Constrain Proportions); from 0 the others that are 0 too follow the value. +pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { + let Some(&old) = c.get(d) else { return c.to_vec() }; + c.iter() + .enumerate() + .map(|(e, &x)| { + if e == d { + v + } else if old.abs() > 1e-9 { + x * v / old + } else if x.abs() <= 1e-9 { + v + } else { + x + } + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/ui_3d.rs index 385bf7ac..0c65084b 100644 --- a/crates/ui-egui/tests/ui_3d.rs +++ b/crates/ui-egui/tests/ui_3d.rs @@ -47,6 +47,37 @@ fn viewer_registers_3d_controls() { assert!(ids.iter().any(|i| i.starts_with("viewer.camera.")), "camera wireframe registered"); } +/// Click the centre of the automation element `id`. +fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let [x, y, w, hh] = h.state().auto.find(id).unwrap_or_else(|| panic!("{id} is registered")).rect; + let p = egui::pos2(x + w / 2.0, y + hh / 2.0); + h.input_mut().events.push(egui::Event::PointerMoved(p)); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +/// #307: the viewer's 3D Renderer menu shows the comp's renderer (it always read "Classic 3D") +/// and switches it, as After Effects' Composition panel does. +#[test] +fn renderer_menu_shows_and_switches_the_comp_renderer() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.state_mut().session.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); + h.run_steps(3); + let label = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find("viewer.renderer3d").map(|e| e.label.clone()); + assert_eq!(label(&h).as_deref(), Some("Classic 3D")); + click_id(&mut h, "viewer.renderer3d"); + click_id(&mut h, "viewer.renderer3dItem.1"); + let renderer = h.state().session.active_comp().unwrap().renderer; + assert_eq!(renderer, effectcraft_engine::project::Renderer::Advanced3D); + h.run_steps(2); + assert_eq!(label(&h).as_deref(), Some("Advanced 3D")); + // One undoable Composition Settings step. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.active_comp().unwrap().renderer, effectcraft_engine::project::Renderer::Classic3D); +} + #[test] fn camera_and_light_dialogs_create_layers() { let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 50ccf1ef..3ca85f8f 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -293,3 +293,58 @@ fn a_graph_handle_drag_moves_the_handles_of_every_selected_key() { assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step"); assert!((handle(&h, 0).x - k0.x - px_per_s * 0.2).abs() < 2.0, "key 0's handle moved on screen"); } + +/// #457: on a Timeline narrower than the Graph Editor's whole button row, the buttons that don't +/// fit go to a "»" menu at the end of the bar instead of being cut off; each stays reachable and +/// works from there. +#[test] +fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() { + let (mut h, _, _) = harness(); + h.set_size(egui::vec2(1280.0, 800.0)); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + let ids = [ + "autoSelectGraphType", + "valueGraph", + "speedGraph", + "showSelected", + "autoZoom", + "fit", + "fitSelection", + "snap", + "reference", + "transformBox", + "separateDimensions", + "interpolationHold", + "interpolationLinear", + "interpolationAuto", + "easyEaseEasy", + "easyEaseInEase", + "easyEaseOutEase", + ]; + let graph = h.state().auto.find("timeline.graph").expect("the Graph Editor is shown").rect; + let right = graph[0] + graph[2]; + let shown: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.{id}")).is_some()).collect(); + for id in &shown { + let r = h.state().auto.find(&format!("timeline.graph.{id}")).unwrap().rect; + assert!(r[0] + r[2] <= right, "{id} is drawn past the bar's right edge"); + } + let more = h.state().auto.find("timeline.graph.more").expect("the buttons that don't fit are in a menu").rect; + assert!(more[0] + more[2] <= right); + let p = pos2(more[0] + more[2] / 2.0, more[1] + more[3] / 2.0); + let click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + }; + click(&mut h, p); + let hidden: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.more.{id}")).is_some()).collect(); + assert_eq!(shown.len() + hidden.len(), ids.len(), "every button is on the bar or in the menu: {shown:?} + {hidden:?}"); + assert!(hidden.contains(&"transformBox"), "{hidden:?}"); + let before = h.state().ui.timeline.graph_transform_box; + let item = h.state().auto.find("timeline.graph.more.transformBox").unwrap().rect; + click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0)); + assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button"); +} diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index ac84a550..7235b75d 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -318,3 +318,47 @@ fn panels_snapshot() { img.save(format!("{dir}/{panel}{scope}.png")).unwrap(); } } + +/// Drag the automation element `id` horizontally by `dx` points. +fn drag_x(h: &mut Harness<'_, EffectcraftApp>, id: &str, dx: f32) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + let from: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(dx * k as f32 / 10.0, 0.0))); + h.step(); + } + let to = from + egui::vec2(dx, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +/// #338: an Essential Graphics Scale control keeps After Effects' Constrain Proportions link, on +/// by default: dragging X scales Y with it; unlinked, X moves alone. +#[test] +fn essential_graphics_scale_keeps_constrain_proportions() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/scale", "value": [100.0, 50.0]})).unwrap(); + let c = + h.state_mut().session.execute("essential.addProperty", json!({"layer": layer, "path": "transform/scale"})).unwrap()["controls"][0].as_u64().unwrap(); + open(&mut h, "essentialGraphics"); + let value = format!("essential.control.{c}.value"); + let scale = |h: &Harness<'_, EffectcraftApp>| { + let comp = h.state().session.active_comp().unwrap(); + let l = comp.layers.iter().find(|l| l.id.0 == layer).unwrap(); + l.props.prop("transform/scale").unwrap().value.components() + }; + assert!(h.state().auto.find(&format!("{value}.link")).is_some(), "the chain link is drawn"); + drag_x(&mut h, &format!("{value}.0"), 40.0); + let linked = scale(&h); + assert!(linked[0] > 100.0, "{linked:?}"); + assert!((linked[1] / linked[0] - 0.5).abs() < 1e-6, "Y follows X in proportion: {linked:?}"); + click(&mut h, &format!("{value}.link")); + drag_x(&mut h, &format!("{value}.0"), 40.0); + let unlinked = scale(&h); + assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}"); +} diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 48b19cb8..5fb16943 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -59,6 +59,34 @@ fn alt_wheel_zooms_out_until_the_whole_comp_shows() { assert!(h.state().ui.timeline.pps.is_some()); } +/// #428: Ctrl/Cmd+wheel and a trackpad pinch zoom time over the layers, as Alt+wheel does (egui +/// hands them over as a zoom factor, which the Timeline ignored). +#[test] +fn ctrl_wheel_and_pinch_zoom_time() { + let (mut h, layer) = harness(); + h.state_mut().ui.timeline.pps = Some(200.0); + h.run_steps(2); + let pps = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.pps.unwrap_or(0.0); + let start = pps(&h); + assert!(start > 0.0); + let at = over_bar(&h, layer); + h.event(Event::PointerMoved(at)); + h.event(Event::Zoom(0.5)); + h.step(); + let pinched = pps(&h); + assert!(pinched > 0.0 && pinched < start * 0.75, "a pinch in zooms out: {start} -> {pinched}"); + let at = over_bar(&h, layer); + h.event(Event::PointerMoved(at)); + h.event(Event::ModifiersChanged(Modifiers::COMMAND)); + h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 240.0), modifiers: Modifiers::COMMAND, phase: egui::TouchPhase::Move }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + assert!(pps(&h) > pinched, "Ctrl/Cmd+wheel up zooms in: {pinched} -> {}", pps(&h)); + // Over the layers, so the rows did not scroll. + assert_eq!(h.state().ui.timeline.scroll_y, 0.0); +} + fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))