Merge branch 'fix/f3': community issue fixes

# Conflicts:
#	crates/ui-egui/src/widgets.rs
This commit is contained in:
Claude committed 2026-10-10 19:48:24 +00:00
commit 09b3dbaad9
11 files changed
+436 -71

No files matched your search

+63 -1
View File
@@ -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.
+31
View File
@@ -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)));
+100 -21
View File
@@ -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<String>,
}
/// 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::<String>(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).
+40 -24
View File
@@ -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();
+8 -23
View File
@@ -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<f64> {
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;
}
+16 -2
View File
@@ -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();
+20
View File
@@ -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<f64> {
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::*;
+31
View File
@@ -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());
+55
View File
@@ -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");
}
+44
View File
@@ -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:?}");
}
+28
View File
@@ -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]))