mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge branch 'fix/f3': community issue fixes
# Conflicts: # crates/ui-egui/src/widgets.rs
This commit is contained in:
11 files changed
+436
-71
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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]))
|
||||
|
||||
Reference in new issue
Block a user