mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 12:37:08 -06:00
Merge pull request #694 from tylerjenningsw/enhance/gradient-editor
Shapes: edit gradient color and opacity stops (#612)
This commit is contained in:
@@ -3,8 +3,10 @@
|
||||
//! control channel and MCP all see them); running one emits [`crate::Event::Frontend`] and the UI
|
||||
//! performs it. Headless sessions accept and ignore them.
|
||||
|
||||
use super::{CommandSpec, always, frontend, has_comp, has_layers};
|
||||
use crate::cmd;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, frontend, has_comp, has_layers};
|
||||
use crate::{Result, Session, cmd};
|
||||
|
||||
macro_rules! fe {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr) => {
|
||||
@@ -116,5 +118,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{} — the Project panel's selection, with the layers that use it; one undo step",
|
||||
always
|
||||
),
|
||||
// A gradient property's "Edit Gradient…" (the Timeline's Colors value): opens the
|
||||
// Gradient Editor on it at the current time. OK sets it with prop.set (one undo step).
|
||||
cmd!(
|
||||
"prop.editGradient",
|
||||
"Edit Gradient...",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, comp?} — a gradient property; opens the Gradient Editor at the current time",
|
||||
has_layers,
|
||||
edit_gradient
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// `prop.editGradient`: check the target is a gradient property before the UI opens the editor,
|
||||
/// so agents get an error (not success) for a wrong one, and hand the UI the resolved ids.
|
||||
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.editGradient";
|
||||
let (cid, lid, uid) = super::prop::prop_ref(s, p, c)?;
|
||||
let pr = s.project.comp(cid).and_then(|comp| comp.layer(lid)).and_then(|l| l.props.find(uid)).ok_or_else(|| bad(c, format!("no property @{uid}")))?;
|
||||
if !matches!(pr.value, effectcraft_keyframe::Value::Gradient(_)) {
|
||||
return Err(bad(c, format!("{} is not a gradient property", pr.name)));
|
||||
}
|
||||
frontend(s, c, &json!({"comp": cid.0, "layer": lid.0, "prop": uid}))
|
||||
}
|
||||
|
||||
@@ -751,6 +751,38 @@ mod tests {
|
||||
assert_eq!(g.coerce_json(&serde_json::json!(5)), None);
|
||||
}
|
||||
|
||||
/// #612: the gradient editor samples a new stop from its own track only. `sample` multiplies
|
||||
/// in the opacity stops, so feeding its alpha into a colour stop would apply 50% twice (25%).
|
||||
#[test]
|
||||
fn gradient_tracks_sample_separately() {
|
||||
let g = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 0.8]), (1.0, [0.0, 0.0, 1.0, 0.4])], opacities: vec![(0.0, 0.5), (1.0, 0.5)] };
|
||||
let c = g.color_at(0.5);
|
||||
assert!((c[0] - 0.5).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6, "{c:?}");
|
||||
assert!((c[3] - 0.6).abs() < 1e-6, "the colour track's own alpha, without opacity: {c:?}");
|
||||
assert!((g.opacity_at(0.5) - 0.5).abs() < 1e-6);
|
||||
assert!((g.sample(0.5)[3] - 0.3).abs() < 1e-6, "sample still applies opacity once");
|
||||
// A stop added at 50% from the opacity track keeps 50%: sampled again, it is still 50%.
|
||||
let mut with_stop = g.clone();
|
||||
with_stop.opacities.insert(1, (0.5, g.opacity_at(0.5)));
|
||||
assert!((with_stop.opacity_at(0.5) - 0.5).abs() < 1e-6);
|
||||
assert!((with_stop.sample(0.5)[3] - 0.3).abs() < 1e-6, "adding a stop doesn't change the look");
|
||||
// Outside the stops, and with none: the ends hold, and an empty track is opaque white.
|
||||
assert_eq!(g.color_at(-1.0), [1.0, 0.0, 0.0, 0.8]);
|
||||
assert_eq!(g.color_at(2.0), [0.0, 0.0, 1.0, 0.4]);
|
||||
let empty = Gradient { colors: vec![], opacities: vec![] };
|
||||
assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0));
|
||||
}
|
||||
|
||||
/// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser
|
||||
/// rejects) and each stop's RGBA come back exactly.
|
||||
#[test]
|
||||
fn typed_gradient_round_trips_one_stop_and_rgba() {
|
||||
let one = Value::Gradient(Gradient { colors: vec![(0.25, [0.1, 0.2, 0.3, 0.7])], opacities: vec![(0.0, 1.0), (0.6, 0.35)] });
|
||||
let cur = Value::Gradient(Gradient::default());
|
||||
assert_eq!(cur.coerce_json(&one.to_json()), Some(one.clone()));
|
||||
assert_eq!(cur.coerce_json(&serde_json::json!({"colors": [[0.25, [0.1, 0.2, 0.3, 0.7]]]})), None, "plain JSON needs two colours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_and_hold() {
|
||||
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0))];
|
||||
|
||||
@@ -169,6 +169,24 @@ impl Gradient {
|
||||
let a = sample_stops(&self.opacities, t, |a, b, f| a + (b - a) * f as f32).unwrap_or(1.0);
|
||||
[col[0], col[1], col[2], col[3] * a]
|
||||
}
|
||||
|
||||
/// The colour stops alone at `t` (0..1), RGBA as stored: unlike [`Gradient::sample`], the
|
||||
/// opacity stops are not applied. A new colour stop takes this value.
|
||||
pub fn color_at(&self, t: f64) -> [f32; 4] {
|
||||
sample_stops(&self.colors, t.clamp(0.0, 1.0), |a, b, f| {
|
||||
let mut o = [0.0; 4];
|
||||
for i in 0..4 {
|
||||
o[i] = a[i] + (b[i] - a[i]) * f as f32;
|
||||
}
|
||||
o
|
||||
})
|
||||
.unwrap_or([1.0; 4])
|
||||
}
|
||||
|
||||
/// The opacity stops alone at `t` (0..1). A new opacity stop takes this value.
|
||||
pub fn opacity_at(&self, t: f64) -> f32 {
|
||||
sample_stops(&self.opacities, t.clamp(0.0, 1.0), |a, b, f| a + (b - a) * f as f32).unwrap_or(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_stops<T: Copy>(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option<T> {
|
||||
|
||||
@@ -41,7 +41,8 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
|
||||
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| out = color_picker(ui, id, c, original));
|
||||
});
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
// Escape is the picker's: consume it, so nothing later this pass takes it too.
|
||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
out.close = Some(false);
|
||||
}
|
||||
if pressed_outside(ui.ctx(), &area.response) {
|
||||
@@ -55,11 +56,30 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(id.with("open"), false);
|
||||
d.remove::<[f32; 3]>(orig_id);
|
||||
// Closed: the next Escape is the dialog's again.
|
||||
d.remove::<u64>(open_pass_id());
|
||||
});
|
||||
} else {
|
||||
// Still open after this pass: dialogs leave Escape to the picker (see `open_recently`).
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
ui.data_mut(|d| d.insert_temp(open_pass_id(), pass));
|
||||
}
|
||||
out.changed
|
||||
}
|
||||
|
||||
fn open_pass_id() -> egui::Id {
|
||||
egui::Id::new("color-picker-open-pass")
|
||||
}
|
||||
|
||||
/// A colour picker is open: it was drawn, and stayed open, in this pass or the last one (so one
|
||||
/// that stops being drawn without closing, its dialog gone, doesn't count for long). Escape then
|
||||
/// belongs to the picker (it goes back to the original colour and closes), not to the dialog it
|
||||
/// was opened from. Closing clears this at once, so the next Escape reaches the dialog.
|
||||
pub fn open_recently(ctx: &egui::Context) -> bool {
|
||||
let pass: Option<u64> = ctx.data(|d| d.get_temp(open_pass_id()));
|
||||
pass.is_some_and(|p| p.saturating_add(1) >= ctx.cumulative_pass_nr())
|
||||
}
|
||||
|
||||
/// What the picker did this frame.
|
||||
#[derive(Default)]
|
||||
struct Outcome {
|
||||
|
||||
@@ -145,6 +145,10 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Clear", "消去"),
|
||||
("Clear list", "リストを消去"),
|
||||
("Click a key to see its commands.", "キーをクリックすると、割り当てられたコマンドが表示されます。"),
|
||||
(
|
||||
"Click above the bar to add an opacity stop, below it to add a color stop.",
|
||||
"バーの上をクリックすると不透明度の分岐点、下をクリックするとカラーの分岐点を追加します。",
|
||||
),
|
||||
("Click adds a point (½ height = no change)", "クリックでポイントを追加(高さの½=変化なし)"),
|
||||
("Click adds a point; drag one off to remove", "クリックでポイントを追加、外へドラッグで削除"),
|
||||
("Click to make this viewer active", "クリックしてこのビューアーをアクティブ化"),
|
||||
@@ -238,6 +242,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Frame Render Time {}ms", "フレームのレンダリング時間 {}ms"),
|
||||
("Frame Target:", "フレームターゲット:"),
|
||||
("Grab bag", "いろいろ"),
|
||||
("Gradient Editor", "グラデーションエディター"),
|
||||
("Grid", "グリッド"),
|
||||
("Guide Layers: {}", "ガイドレイヤー:{}"),
|
||||
("Guides", "ガイド"),
|
||||
@@ -274,6 +279,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Light Type", "ライトの種類"),
|
||||
("Lighter", "明るく"),
|
||||
("Link Selected Property", "選択したプロパティをリンク"),
|
||||
("Location:", "位置:"),
|
||||
("Lock Aspect Ratio to {}", "アスペクト比を{}に固定"),
|
||||
("Lock to Zoom", "ズームにロック"),
|
||||
("Locked: {} — {}", "ロック済み:{} — {}"),
|
||||
@@ -461,6 +467,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("The Media Browser is not available here (use File ▸ Import)", "ここではメディアブラウザーを使用できません(「ファイル ▸ 読み込み」を使ってください)"),
|
||||
("The OpenEXR layer shown in red, green, blue and alpha", "赤・緑・青・アルファとして表示するOpenEXRレイヤー"),
|
||||
("The render queue is empty.", "レンダーキューは空です。"),
|
||||
("These adjacent keyframes cannot interpolate because their stop counts differ.", "これらの隣接するキーフレームは分岐点の数が異なるため、補間できません。"),
|
||||
("This ScriptUI panel has closed", "このScriptUIパネルは閉じられました"),
|
||||
("This change will create a new solid for this layer.", "この変更により、このレイヤー用に新しい平面が作成されます。"),
|
||||
("Time", "時間"),
|
||||
@@ -577,6 +584,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Clear", "清除"),
|
||||
("Clear list", "清空列表"),
|
||||
("Click a key to see its commands.", "单击某个按键以查看其命令。"),
|
||||
("Click above the bar to add an opacity stop, below it to add a color stop.", "点击渐变条上方添加不透明度色标,点击下方添加颜色色标。"),
|
||||
("Click adds a point (½ height = no change)", "单击可添加点(上下滑动高度的一半 = 无变化)"),
|
||||
("Click adds a point; drag one off to remove", "单击可添加点;拖出一个即可删除"),
|
||||
("Click to make this viewer active", "单击可激活此视图"),
|
||||
@@ -673,6 +681,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Frame Target:", "帧目标:"),
|
||||
("Freeze", "冻结"),
|
||||
("Grab bag", "列表"),
|
||||
("Gradient Editor", "渐变编辑器"),
|
||||
("Grid", "网格"),
|
||||
("Guide Layers: {}", "参考图层:{}"),
|
||||
("Guides", "参考线"),
|
||||
@@ -711,6 +720,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Light Type", "灯光类型"),
|
||||
("Lighter", "更浅"),
|
||||
("Link Selected Property", "链接所选属性"),
|
||||
("Location:", "位置:"),
|
||||
("Lock Aspect Ratio to {}", "锁定长宽比为 {}"),
|
||||
("Lock to Zoom", "锁定缩放"),
|
||||
("Locked: {} — {}", "已锁定:{} — {}"),
|
||||
@@ -899,6 +909,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("The Media Browser is not available here (use File ▸ Import)", "此处不支持媒体浏览器(请使用 文件 ▸ 导入)"),
|
||||
("The OpenEXR layer shown in red, green, blue and alpha", "以红、绿、蓝和 Alpha 显示的 OpenEXR 图层"),
|
||||
("The render queue is empty.", "渲染队列为空。"),
|
||||
("These adjacent keyframes cannot interpolate because their stop counts differ.", "这些相邻关键帧的色标数量不同,无法插值。"),
|
||||
("This ScriptUI panel has closed", "此 ScriptUI 面板已关闭"),
|
||||
("This change will create a new solid for this layer.", "此更改将为该图层创建新的纯色。"),
|
||||
("Time", "时间"),
|
||||
@@ -1102,6 +1113,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Clear", "清除"),
|
||||
("Clear list", "清空清單"),
|
||||
("Click a key to see its commands.", "按一下某個按鍵以查看其命令。"),
|
||||
("Click above the bar to add an opacity stop, below it to add a color stop.", "按一下漸變條上方以新增不透明度色標,按一下下方以新增顏色色標。"),
|
||||
("Click adds a point (½ height = no change)", "按一下可添加點(上下滑動高度的一半 = 無變化)"),
|
||||
("Click adds a point; drag one off to remove", "按一下可添加點;拖出一個即可刪除"),
|
||||
("Click to make this viewer active", "按一下可激活此檢視"),
|
||||
@@ -1198,6 +1210,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Frame Target:", "幀目標:"),
|
||||
("Freeze", "凍結"),
|
||||
("Grab bag", "清單"),
|
||||
("Gradient Editor", "漸變編輯器"),
|
||||
("Grid", "格點"),
|
||||
("Guide Layers: {}", "參考圖層:{}"),
|
||||
("Guides", "參考線"),
|
||||
@@ -1236,6 +1249,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Light Type", "燈光類型"),
|
||||
("Lighter", "更淺"),
|
||||
("Link Selected Property", "鏈接所選屬性"),
|
||||
("Location:", "位置:"),
|
||||
("Lock Aspect Ratio to {}", "鎖定長寬比例為 {}"),
|
||||
("Lock to Zoom", "鎖定縮放"),
|
||||
("Locked: {} — {}", "已鎖定:{} — {}"),
|
||||
@@ -1424,6 +1438,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("The Media Browser is not available here (use File ▸ Import)", "此處不支援媒體瀏覽器(請使用 文件 ▸ 匯入)"),
|
||||
("The OpenEXR layer shown in red, green, blue and alpha", "以紅、綠、藍和 Alpha 顯示的 OpenEXR 圖層"),
|
||||
("The render queue is empty.", "算圖佇列為空。"),
|
||||
("These adjacent keyframes cannot interpolate because their stop counts differ.", "這些相鄰關鍵影格的色標數量不同,無法內插。"),
|
||||
("This ScriptUI panel has closed", "此 ScriptUI 面板已關閉"),
|
||||
("This change will create a new solid for this layer.", "此更改將為該圖層創建新的純色。"),
|
||||
("Time", "時間"),
|
||||
@@ -1622,6 +1637,10 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Clear", "Очистити"),
|
||||
("Clear list", "Очистити список"),
|
||||
("Click a key to see its commands.", "Клацніть клавішу, щоб переглянути її команди."),
|
||||
(
|
||||
"Click above the bar to add an opacity stop, below it to add a color stop.",
|
||||
"Клацніть над смугою, щоб додати точку непрозорості, під нею — щоб додати точку кольору.",
|
||||
),
|
||||
("Click adds a point (½ height = no change)", "Клацніть, щоб додати точку (½ висоти = без змін)"),
|
||||
("Click adds a point; drag one off to remove", "Клацніть, щоб додати точку; перетягніть її за межі, щоб вилучити"),
|
||||
("Click to make this viewer active", "Клацніть, щоб зробити цю область перегляду активною"),
|
||||
@@ -1715,6 +1734,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Frame Render Time {}ms", "Час рендерингу кадру {} мс"),
|
||||
("Frame Target:", "Цільовий фрейм:"),
|
||||
("Grab bag", "Список"),
|
||||
("Gradient Editor", "Редактор градієнта"),
|
||||
("Grid", "Сітка"),
|
||||
("Guide Layers: {}", "Допоміжні шари: {}"),
|
||||
("Guides", "Напрямні"),
|
||||
@@ -1751,6 +1771,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Light Type", "Тип світла"),
|
||||
("Lighter", "Світліше"),
|
||||
("Link Selected Property", "Зв’язати виділену властивість"),
|
||||
("Location:", "Розташування:"),
|
||||
("Lock Aspect Ratio to {}", "Зафіксувати співвідношення сторін на {}"),
|
||||
("Lock to Zoom", "Прив’язати до масштабу"),
|
||||
("Locked: {} — {}", "Заблоковано: {} — {}"),
|
||||
@@ -1941,6 +1962,10 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("The Media Browser is not available here (use File ▸ Import)", "Браузер матеріалів тут недоступний (скористайтеся меню «Файл ▸ Імпорт»)"),
|
||||
("The OpenEXR layer shown in red, green, blue and alpha", "Шар OpenEXR, показаний у червоному, зеленому, синьому та альфа-каналі"),
|
||||
("The render queue is empty.", "Черга рендерингу порожня."),
|
||||
(
|
||||
"These adjacent keyframes cannot interpolate because their stop counts differ.",
|
||||
"Ці сусідні ключові кадри не можуть інтерполюватися, бо кількість точок у них різна.",
|
||||
),
|
||||
("This ScriptUI panel has closed", "Цю панель ScriptUI закрито"),
|
||||
("This change will create a new solid for this layer.", "Ця зміна створить новий матеріал суцільного кольору для цього шару."),
|
||||
("Time", "Час"),
|
||||
@@ -2007,6 +2032,7 @@ mod tests {
|
||||
("panels/comp_settings.rs", include_str!("../panels/comp_settings.rs")),
|
||||
("panels/content_fill_panel.rs", include_str!("../panels/content_fill_panel.rs")),
|
||||
("panels/delete_items.rs", include_str!("../panels/delete_items.rs")),
|
||||
("panels/gradient_editor.rs", include_str!("../panels/gradient_editor.rs")),
|
||||
("panels/dialogs.rs", include_str!("../panels/dialogs.rs")),
|
||||
("panels/dialogs_3d.rs", include_str!("../panels/dialogs_3d.rs")),
|
||||
("panels/effect_controls.rs", include_str!("../panels/effect_controls.rs")),
|
||||
|
||||
@@ -94,6 +94,8 @@ pub enum Dialog {
|
||||
UnsavedChanges,
|
||||
/// Delete Project items that compositions use? (`panels::delete_items`).
|
||||
DeleteItems,
|
||||
/// A gradient property's Gradient Editor (`panels::gradient_editor`, `prop.editGradient`).
|
||||
GradientEditor,
|
||||
}
|
||||
|
||||
/// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send
|
||||
@@ -544,6 +546,17 @@ impl EffectcraftApp {
|
||||
self.ui.saved_workspaces.keys().filter(|k| !dock::WORKSPACES.contains(&k.as_str())).cloned().collect()
|
||||
}
|
||||
|
||||
/// The open Gradient Editor's draft (`panels::gradient_editor`): what OK would set.
|
||||
pub fn gradient_draft(&self) -> &panels::gradient_editor::Draft {
|
||||
&self.dialog_state.gradient
|
||||
}
|
||||
|
||||
/// The open Gradient Editor's draft, to edit (tests and automation; the editor's own fields
|
||||
/// do the same).
|
||||
pub fn gradient_draft_mut(&mut self) -> &mut panels::gradient_editor::Draft {
|
||||
&mut self.dialog_state.gradient
|
||||
}
|
||||
|
||||
/// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and
|
||||
/// give it the focus: Window ▸ <panel>.
|
||||
pub fn show_panel(&mut self, p: PanelKind) {
|
||||
|
||||
@@ -758,6 +758,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
"view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}),
|
||||
// Run by id (control channel, MCP): the Project panel's selection, whatever has focus.
|
||||
"project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?,
|
||||
// A gradient property's "Edit Gradient…" (the Timeline's value): the Gradient Editor.
|
||||
"prop.editGradient" => crate::panels::gradient_editor::open(app, &p)?,
|
||||
"window.scriptPanel" => {
|
||||
let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32;
|
||||
app.show_panel(PanelKind::ScriptPanel(id));
|
||||
@@ -1606,7 +1608,8 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
let start = modifiers_at_start(ctx);
|
||||
// Dialog cancellation owns Escape even when a text field has keyboard focus.
|
||||
if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) {
|
||||
// A popup or colour picker takes Escape first (the picker goes back to its original colour).
|
||||
if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) || crate::color_picker::open_recently(ctx) {
|
||||
return;
|
||||
}
|
||||
ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct DialogState {
|
||||
pub unsaved: super::unsaved::Pending,
|
||||
/// Deleting Project items in use: the deletion waiting on the prompt.
|
||||
pub delete_items: super::delete_items::Pending,
|
||||
/// The Gradient Editor's draft and the property it edits.
|
||||
pub gradient: super::gradient_editor::Draft,
|
||||
}
|
||||
|
||||
pub fn open_new_comp(app: &mut EffectcraftApp) {
|
||||
@@ -193,6 +195,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
Dialog::RenderTemplates => super::rq_templates::show(app, ctx, &t),
|
||||
Dialog::UnsavedChanges => super::unsaved::show(app, ctx, &t),
|
||||
Dialog::DeleteItems => super::delete_items::show(app, ctx, &t),
|
||||
Dialog::GradientEditor => super::gradient_editor::show(app, ctx, &t),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
//! The Gradient Editor (#612), opened by a gradient property's "Edit Gradient…" in the Timeline
|
||||
//! (Gradient Fill / Gradient Stroke ▸ Colors, Gradient Overlay ▸ Gradient): `prop.editGradient`.
|
||||
//! Opacity stops sit above the preview bar and colour stops below it. Click above or below the
|
||||
//! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the
|
||||
//! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a
|
||||
//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it
|
||||
//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it
|
||||
//! was.
|
||||
//!
|
||||
//! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`,
|
||||
//! `dialog.gradient.opacityStop.<i>`, `dialog.gradient.colorStop.<i>` (in order along the bar),
|
||||
//! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`,
|
||||
//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`.
|
||||
|
||||
use effectcraft_engine::keyframe::{Gradient, Value};
|
||||
use effectcraft_engine::project::{ItemId, LayerId};
|
||||
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
/// Which row of stops: opacity (above the bar) or colour (below it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Track {
|
||||
Opacity,
|
||||
Color,
|
||||
}
|
||||
|
||||
/// A stop in the draft. `id` stays with the stop when it moves past another one (the tracks are
|
||||
/// kept in order along the bar, so its index changes).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Stop<T> {
|
||||
pub id: u64,
|
||||
pub pos: f64,
|
||||
pub value: T,
|
||||
}
|
||||
|
||||
/// The property being edited, as it was when the editor opened.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Target {
|
||||
pub comp: u64,
|
||||
pub layer: u64,
|
||||
pub prop: u64,
|
||||
/// Composition seconds, snapped to the nearest frame: the value is read and set there.
|
||||
pub time: f64,
|
||||
}
|
||||
|
||||
/// The editor's draft gradient and selection.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Draft {
|
||||
pub target: Option<Target>,
|
||||
pub colors: Vec<Stop<[f32; 4]>>,
|
||||
pub opacities: Vec<Stop<f32>>,
|
||||
pub selected: Option<(Track, u64)>,
|
||||
/// The gradient as it opened, stops in order: OK with an equal draft changes nothing.
|
||||
pub baseline: Gradient,
|
||||
/// Stop counts (colour, opacity) of the keyframes just before and after the edit time.
|
||||
pub neighbors: Vec<(usize, usize)>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
fn by_pos<T>(v: &mut [Stop<T>]) {
|
||||
// Stable: stops at the same location keep their order.
|
||||
v.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
}
|
||||
|
||||
fn clamp_pos(p: f64) -> f64 {
|
||||
if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
/// A draft of `g` (stops put in order, locations clamped to 0..1).
|
||||
pub fn new(g: &Gradient, target: Option<Target>, neighbors: Vec<(usize, usize)>) -> Draft {
|
||||
let mut d = Draft { target, neighbors, ..Default::default() };
|
||||
for &(pos, value) in &g.colors {
|
||||
let id = d.fresh();
|
||||
d.colors.push(Stop { id, pos: clamp_pos(pos), value });
|
||||
}
|
||||
for &(pos, value) in &g.opacities {
|
||||
let id = d.fresh();
|
||||
d.opacities.push(Stop { id, pos: clamp_pos(pos), value });
|
||||
}
|
||||
by_pos(&mut d.colors);
|
||||
by_pos(&mut d.opacities);
|
||||
d.baseline = d.gradient();
|
||||
d
|
||||
}
|
||||
|
||||
fn fresh(&mut self) -> u64 {
|
||||
self.next_id = self.next_id.saturating_add(1);
|
||||
self.next_id
|
||||
}
|
||||
|
||||
/// The draft as a gradient (the tracks are always in order).
|
||||
pub fn gradient(&self) -> Gradient {
|
||||
Gradient { colors: self.colors.iter().map(|s| (s.pos, s.value)).collect(), opacities: self.opacities.iter().map(|s| (s.pos, s.value)).collect() }
|
||||
}
|
||||
|
||||
pub fn len(&self, track: Track) -> usize {
|
||||
match track {
|
||||
Track::Color => self.colors.len(),
|
||||
Track::Opacity => self.opacities.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The id of the stop `i`-th along the bar.
|
||||
pub fn id_at(&self, track: Track, i: usize) -> Option<u64> {
|
||||
match track {
|
||||
Track::Color => self.colors.get(i).map(|s| s.id),
|
||||
Track::Opacity => self.opacities.get(i).map(|s| s.id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where stop `id` is along the bar (its index), if it exists.
|
||||
pub fn index_of(&self, track: Track, id: u64) -> Option<usize> {
|
||||
match track {
|
||||
Track::Color => self.colors.iter().position(|s| s.id == id),
|
||||
Track::Opacity => self.opacities.iter().position(|s| s.id == id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a stop at `pos` with that track's current value there, select it, return its id.
|
||||
pub fn add(&mut self, track: Track, pos: f64) -> u64 {
|
||||
let pos = clamp_pos(pos);
|
||||
let g = self.gradient();
|
||||
let id = self.fresh();
|
||||
match track {
|
||||
Track::Color => self.colors.push(Stop { id, pos, value: g.color_at(pos) }),
|
||||
Track::Opacity => self.opacities.push(Stop { id, pos, value: g.opacity_at(pos) }),
|
||||
}
|
||||
self.sort();
|
||||
self.selected = Some((track, id));
|
||||
id
|
||||
}
|
||||
|
||||
/// Move stop `id` to `pos` (clamped) and keep the track in order.
|
||||
pub fn move_to(&mut self, track: Track, id: u64, pos: f64) {
|
||||
let pos = clamp_pos(pos);
|
||||
match track {
|
||||
Track::Color => {
|
||||
if let Some(s) = self.colors.iter_mut().find(|s| s.id == id) {
|
||||
s.pos = pos;
|
||||
}
|
||||
}
|
||||
Track::Opacity => {
|
||||
if let Some(s) = self.opacities.iter_mut().find(|s| s.id == id) {
|
||||
s.pos = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sort();
|
||||
}
|
||||
|
||||
/// Remove stop `id` unless it is the last of its track. Returns whether it went.
|
||||
pub fn remove(&mut self, track: Track, id: u64) -> bool {
|
||||
if self.len(track) <= 1 {
|
||||
return false;
|
||||
}
|
||||
let before = self.len(track);
|
||||
match track {
|
||||
Track::Color => self.colors.retain(|s| s.id != id),
|
||||
Track::Opacity => self.opacities.retain(|s| s.id != id),
|
||||
}
|
||||
if self.selected == Some((track, id)) {
|
||||
self.selected = None;
|
||||
}
|
||||
self.len(track) < before
|
||||
}
|
||||
|
||||
/// Set colour stop `id`'s RGB, keeping its alpha.
|
||||
pub fn set_rgb(&mut self, id: u64, rgb: [f32; 3]) {
|
||||
if let Some(s) = self.colors.iter_mut().find(|s| s.id == id) {
|
||||
s.value = [rgb[0], rgb[1], rgb[2], s.value[3]];
|
||||
}
|
||||
}
|
||||
|
||||
/// Set opacity stop `id` (0..1).
|
||||
pub fn set_opacity(&mut self, id: u64, a: f32) {
|
||||
if let Some(s) = self.opacities.iter_mut().find(|s| s.id == id) {
|
||||
s.value = if a.is_finite() { a.clamp(0.0, 1.0) } else { s.value };
|
||||
}
|
||||
}
|
||||
|
||||
fn sort(&mut self) {
|
||||
by_pos(&mut self.colors);
|
||||
by_pos(&mut self.opacities);
|
||||
}
|
||||
|
||||
/// The draft's stop counts differ from a neighbouring keyframe's, so the two can't
|
||||
/// interpolate (gradient keyframes blend only with the same number of stops).
|
||||
pub fn mismatched(&self) -> bool {
|
||||
self.neighbors.iter().any(|&(c, o)| c != self.colors.len() || o != self.opacities.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// `prop.editGradient {layer, prop, comp?}`: open the editor on that gradient property at the
|
||||
/// current time (snapped to the nearest frame, as `prop.set` snaps it).
|
||||
pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result<Json, String> {
|
||||
let cid = match p.get("comp").and_then(Json::as_u64) {
|
||||
Some(c) => ItemId(c),
|
||||
None => app.session.active_comp_id().ok_or("no composition is open")?,
|
||||
};
|
||||
let comp = app.session.project.comp(cid).ok_or_else(|| format!("no composition {}", cid.0))?;
|
||||
let lid = p.get("layer").and_then(Json::as_u64).ok_or("missing `layer`")?;
|
||||
let layer = comp.layer(LayerId(lid)).ok_or_else(|| format!("no layer {lid}"))?;
|
||||
let uid = p.get("prop").and_then(Json::as_u64).ok_or("missing `prop`")?;
|
||||
let prop = layer.props.find(uid).ok_or_else(|| format!("no property @{uid}"))?;
|
||||
let ct = comp.frame_rate.snap_nearest(app.session.time_of(cid));
|
||||
let lt = layer.layer_time(ct);
|
||||
let Value::Gradient(g) = prop.value_at(lt) else {
|
||||
return Err(format!("{} is not a gradient", prop.name));
|
||||
};
|
||||
// The keyframes either side of the edit time (one exactly there is the one being edited).
|
||||
let counts = |v: &Value| match v {
|
||||
Value::Gradient(g) => Some((g.colors.len(), g.opacities.len())),
|
||||
_ => None,
|
||||
};
|
||||
let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value));
|
||||
let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value));
|
||||
let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() };
|
||||
app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect());
|
||||
app.dialog = Some(Dialog::GradientEditor);
|
||||
Ok(json!({"dialog": "gradientEditor"}))
|
||||
}
|
||||
|
||||
/// Set the property to the draft: one `prop.set` at the time the editor opened, in composition
|
||||
/// time, or nothing when the draft is unchanged (so OK adds no keyframe).
|
||||
fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
app.dialog = None;
|
||||
let d = std::mem::take(&mut app.dialog_state.gradient);
|
||||
let g = d.gradient();
|
||||
let Some(tg) = d.target else { return };
|
||||
if g == d.baseline {
|
||||
return;
|
||||
}
|
||||
let params = json!({"comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp", "value": Value::Gradient(g).to_json()});
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, "prop.set", params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
fn to_color32(c: [f32; 4]) -> Color32 {
|
||||
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3]))
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
if app.dialog_state.gradient.target.is_none() {
|
||||
app.dialog = None;
|
||||
return;
|
||||
}
|
||||
let mut answer: Option<bool> = None;
|
||||
super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| {
|
||||
let w = ui.available_width().max(200.0);
|
||||
let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover());
|
||||
let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0));
|
||||
let opacity_row = Rect::from_min_max(pos2(bar.min.x, area.min.y), pos2(bar.max.x, bar.min.y - 2.0));
|
||||
let color_row = Rect::from_min_max(pos2(bar.min.x, bar.max.y + 2.0), pos2(bar.max.x, area.max.y));
|
||||
let pos_of = |x: f32| ((x - bar.min.x) / bar.width().max(1.0)) as f64;
|
||||
let x_of = |p: f64| bar.min.x + bar.width() * p as f32;
|
||||
let painter = ui.painter().clone();
|
||||
|
||||
// The bar: a checkerboard under the gradient, so opacity shows.
|
||||
let cell = 6.0;
|
||||
let mut y = bar.min.y;
|
||||
let mut row = 0;
|
||||
while y < bar.max.y {
|
||||
let mut x = bar.min.x;
|
||||
let mut col = row;
|
||||
while x < bar.max.x {
|
||||
let c = if col % 2 == 0 { Color32::from_gray(200) } else { Color32::from_gray(150) };
|
||||
painter.rect_filled(Rect::from_min_max(pos2(x, y), pos2((x + cell).min(bar.max.x), (y + cell).min(bar.max.y))), 0.0, c);
|
||||
x += cell;
|
||||
col += 1;
|
||||
}
|
||||
y += cell;
|
||||
row += 1;
|
||||
}
|
||||
let g = app.dialog_state.gradient.gradient();
|
||||
let n = 96;
|
||||
for k in 0..n {
|
||||
let x0 = bar.min.x + bar.width() * k as f32 / n as f32;
|
||||
let x1 = bar.min.x + bar.width() * (k + 1) as f32 / n as f32;
|
||||
let c = g.sample((k as f64 + 0.5) / n as f64);
|
||||
painter.rect_filled(Rect::from_min_max(pos2(x0, bar.min.y), pos2(x1, bar.max.y)), 0.0, to_color32(c));
|
||||
}
|
||||
painter.rect_stroke(bar, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
app.auto.add("dialog.gradient.bar", bar, "Gradient");
|
||||
|
||||
// Clicking a row's empty space adds a stop there.
|
||||
for (track, rowr, id) in [(Track::Opacity, opacity_row, "dialog.gradient.addOpacity"), (Track::Color, color_row, "dialog.gradient.addColor")] {
|
||||
let r = ui.interact(rowr, egui::Id::new(("gradient-add", id)), Sense::click());
|
||||
app.auto.add(id, rowr, id);
|
||||
if r.clicked()
|
||||
&& let Some(p) = r.interact_pointer_pos()
|
||||
{
|
||||
app.dialog_state.gradient.add(track, pos_of(p.x));
|
||||
}
|
||||
}
|
||||
|
||||
// The stops (drawn and hit-tested over the rows, so they take the clicks).
|
||||
for track in [Track::Opacity, Track::Color] {
|
||||
for i in 0..app.dialog_state.gradient.len(track) {
|
||||
let d = &app.dialog_state.gradient;
|
||||
let Some(id) = d.id_at(track, i) else { continue };
|
||||
let (pos, fill) = match track {
|
||||
Track::Color => d.colors.get(i).map(|s| (s.pos, to_color32([s.value[0], s.value[1], s.value[2], 1.0]))),
|
||||
Track::Opacity => d.opacities.get(i).map(|s| (s.pos, Color32::from_gray((s.value.clamp(0.0, 1.0) * 255.0) as u8))),
|
||||
}
|
||||
.unwrap_or((0.0, Color32::WHITE));
|
||||
let selected = d.selected == Some((track, id));
|
||||
let x = x_of(pos);
|
||||
let r = match track {
|
||||
Track::Opacity => Rect::from_min_max(pos2(x - 5.0, opacity_row.max.y - 15.0), pos2(x + 5.0, opacity_row.max.y)),
|
||||
Track::Color => Rect::from_min_max(pos2(x - 5.0, color_row.min.y), pos2(x + 5.0, color_row.min.y + 15.0)),
|
||||
};
|
||||
painter.rect_filled(r, 1.0, fill);
|
||||
painter.rect_stroke(
|
||||
r,
|
||||
1.0,
|
||||
Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.field_border }),
|
||||
StrokeKind::Outside,
|
||||
);
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new(("gradient-stop", track == Track::Color, id)), Sense::click_and_drag());
|
||||
let name = match track {
|
||||
Track::Opacity => format!("dialog.gradient.opacityStop.{i}"),
|
||||
Track::Color => format!("dialog.gradient.colorStop.{i}"),
|
||||
};
|
||||
app.auto.add(&name, r, &name);
|
||||
if resp.clicked() || resp.drag_started() {
|
||||
app.dialog_state.gradient.selected = Some((track, id));
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
app.dialog_state.gradient.move_to(track, id, pos_of(p.x));
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
|
||||
// The selected stop's fields.
|
||||
let selected = app.dialog_state.gradient.selected;
|
||||
let selected = selected.and_then(|(track, id)| app.dialog_state.gradient.index_of(track, id).map(|i| (track, id, i)));
|
||||
ui.horizontal(|ui| {
|
||||
let Some((track, id, i)) = selected else {
|
||||
ui.label(egui::RichText::new(tr("Click above the bar to add an opacity stop, below it to add a color stop.")).color(t.text_dim));
|
||||
return;
|
||||
};
|
||||
ui.label(tr("Location:"));
|
||||
let d = &app.dialog_state.gradient;
|
||||
let pos = match track {
|
||||
Track::Color => d.colors.get(i).map(|s| s.pos),
|
||||
Track::Opacity => d.opacities.get(i).map(|s| s.pos),
|
||||
}
|
||||
.unwrap_or(0.0);
|
||||
let mut pct = pos * 100.0;
|
||||
let r = ui.add(egui::DragValue::new(&mut pct).range(0.0..=100.0).speed(0.5).max_decimals(1).suffix("%"));
|
||||
app.auto.add("dialog.gradient.location", r.rect, "Location");
|
||||
if r.changed() {
|
||||
app.dialog_state.gradient.move_to(track, id, pct / 100.0);
|
||||
}
|
||||
// Moving can reorder the track: find the stop again by its id, so the fields below
|
||||
// show this stop and not the one now at its old index.
|
||||
let Some(i) = app.dialog_state.gradient.index_of(track, id) else { return };
|
||||
match track {
|
||||
Track::Opacity => {
|
||||
ui.label(tr("Opacity:"));
|
||||
let mut a = app.dialog_state.gradient.opacities.get(i).map_or(1.0, |s| s.value) as f64 * 100.0;
|
||||
let r = ui.add(egui::DragValue::new(&mut a).range(0.0..=100.0).speed(0.5).max_decimals(1).suffix("%"));
|
||||
app.auto.add("dialog.gradient.opacity", r.rect, "Opacity");
|
||||
if r.changed() {
|
||||
app.dialog_state.gradient.set_opacity(id, (a / 100.0) as f32);
|
||||
}
|
||||
}
|
||||
Track::Color => {
|
||||
ui.label(tr("Color:"));
|
||||
let c = app.dialog_state.gradient.colors.get(i).map_or([1.0; 4], |s| s.value);
|
||||
// The shared colour picker (sRGB, hex field, OK / Cancel); the stop keeps its alpha.
|
||||
let mut rgb = [c[0], c[1], c[2]];
|
||||
let swatch = crate::widgets::srgb_color_button(ui, &mut rgb);
|
||||
app.auto.add("dialog.gradient.color", swatch.rect, "Color");
|
||||
if swatch.changed() {
|
||||
app.dialog_state.gradient.set_rgb(id, rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
let can_delete = app.dialog_state.gradient.len(track) > 1;
|
||||
let r = ui.add_enabled(can_delete, egui::Button::new(tr("Delete")));
|
||||
app.auto.add("dialog.gradient.delete", r.rect, "Delete");
|
||||
if r.clicked() {
|
||||
app.dialog_state.gradient.remove(track, id);
|
||||
}
|
||||
});
|
||||
if app.dialog_state.gradient.mismatched() {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tr("These adjacent keyframes cannot interpolate because their stop counts differ.")).color(t.danger).size(11.0));
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.button(tr("Cancel"));
|
||||
app.auto.add("dialog.gradient.cancel", r.rect, "Cancel");
|
||||
if r.clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
let r = ui.button(tr("OK"));
|
||||
app.auto.add("dialog.gradient.ok", r.rect, "OK");
|
||||
if r.clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Delete / Backspace remove the selected stop, but not while typing in a field.
|
||||
if answer.is_none()
|
||||
&& !ctx.egui_wants_keyboard_input()
|
||||
&& ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
|
||||
&& let Some((track, id)) = app.dialog_state.gradient.selected
|
||||
{
|
||||
app.dialog_state.gradient.remove(track, id);
|
||||
}
|
||||
match answer {
|
||||
Some(true) => commit(app, ctx),
|
||||
Some(false) => {
|
||||
app.dialog = None;
|
||||
app.dialog_state.gradient = Draft::default();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn draft() -> Draft {
|
||||
Draft::new(&Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 0.5)] }, None, vec![])
|
||||
}
|
||||
|
||||
/// Dragging the first stop past the second keeps the track in order and the same stop
|
||||
/// selected; a stop added afterwards samples the reordered track.
|
||||
#[test]
|
||||
fn stops_stay_in_order_and_selected_while_moving() {
|
||||
for track in [Track::Color, Track::Opacity] {
|
||||
let g = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (0.6, [0.0, 0.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (0.6, 0.5)] };
|
||||
let mut d = Draft::new(&g, None, vec![]);
|
||||
let first = d.id_at(track, 0).unwrap();
|
||||
d.selected = Some((track, first));
|
||||
d.move_to(track, first, 0.3);
|
||||
d.move_to(track, first, 0.8);
|
||||
assert_eq!(d.index_of(track, first), Some(1), "{track:?}: dragged past the stop at 0.6, it is now second");
|
||||
assert_eq!(d.selected, Some((track, first)), "{track:?}: the selection followed it");
|
||||
let g = d.gradient();
|
||||
let positions: Vec<f64> = match track {
|
||||
Track::Color => g.colors.iter().map(|c| c.0).collect(),
|
||||
Track::Opacity => g.opacities.iter().map(|c| c.0).collect(),
|
||||
};
|
||||
assert!(positions.windows(2).all(|w| w[0] <= w[1]), "{track:?}: {positions:?}");
|
||||
let added = d.add(track, 0.5);
|
||||
assert_eq!(d.index_of(track, added), Some(0), "{track:?}: the new stop sorts before the stops at 0.6 and 0.8");
|
||||
assert_eq!(d.selected, Some((track, added)));
|
||||
}
|
||||
// After the swap the colour track runs blue (at 0) to red (at 1): a stop added at 0.5 is
|
||||
// their mix in the swapped order, not the original red-to-blue.
|
||||
let mut d = draft();
|
||||
let red = d.id_at(Track::Color, 0).unwrap();
|
||||
let blue = d.id_at(Track::Color, 1).unwrap();
|
||||
d.move_to(Track::Color, red, 1.0);
|
||||
d.move_to(Track::Color, blue, 0.0);
|
||||
let id = d.add(Track::Color, 0.25);
|
||||
let i = d.index_of(Track::Color, id).unwrap();
|
||||
let c = d.colors[i].value;
|
||||
assert!(c[2] > c[0], "blue dominates near 0 after the swap: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_stop_stays_and_new_stops_keep_their_tracks_values() {
|
||||
let mut d = draft();
|
||||
let a = d.id_at(Track::Opacity, 0).unwrap();
|
||||
let b = d.id_at(Track::Opacity, 1).unwrap();
|
||||
assert!(d.remove(Track::Opacity, a));
|
||||
assert!(!d.remove(Track::Opacity, b), "the last opacity stop stays");
|
||||
let mut d = draft();
|
||||
let id = d.add(Track::Opacity, 0.5);
|
||||
let i = d.index_of(Track::Opacity, id).unwrap();
|
||||
assert!((d.opacities[i].value - 0.75).abs() < 1e-6, "the opacity track's own value");
|
||||
let id = d.add(Track::Color, 0.5);
|
||||
let i = d.index_of(Track::Color, id).unwrap();
|
||||
assert!((d.colors[i].value[3] - 1.0).abs() < 1e-6, "colour stops keep their own alpha, not times opacity");
|
||||
d.set_rgb(id, [0.0, 1.0, 0.0]);
|
||||
assert_eq!(d.colors[i].value, [0.0, 1.0, 0.0, 1.0]);
|
||||
d.set_opacity(d.id_at(Track::Opacity, 0).unwrap(), f32::NAN);
|
||||
assert!(d.opacities.iter().all(|s| s.value.is_finite()));
|
||||
d.move_to(Track::Color, id, f64::INFINITY);
|
||||
assert!(d.colors.iter().all(|s| (0.0..=1.0).contains(&s.pos)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_drafts_equal_their_baseline() {
|
||||
// Out-of-order and out-of-range stops come in sorted and clamped: still "unchanged".
|
||||
let g = Gradient { colors: vec![(1.2, [0.0, 0.0, 1.0, 1.0]), (0.0, [1.0, 0.0, 0.0, 1.0])], opacities: vec![(0.5, 1.0)] };
|
||||
let d = Draft::new(&g, None, vec![]);
|
||||
assert_eq!(d.gradient(), d.baseline);
|
||||
assert_eq!(d.gradient().colors.iter().map(|c| c.0).collect::<Vec<_>>(), vec![0.0, 1.0]);
|
||||
let mut e = d.clone();
|
||||
e.add(Track::Opacity, 0.2);
|
||||
assert_ne!(e.gradient(), e.baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_warning_follows_the_neighbours_stop_counts() {
|
||||
let mut d = Draft::new(&Gradient::default(), None, vec![(2, 2), (2, 2)]);
|
||||
assert!(!d.mismatched(), "same counts as both neighbours");
|
||||
d.add(Track::Color, 0.5);
|
||||
assert!(d.mismatched(), "three colour stops next to keyframes with two");
|
||||
assert!(!Draft::new(&Gradient::default(), None, vec![]).mismatched(), "not animated: no neighbours");
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ pub mod footage_panel;
|
||||
pub mod forms;
|
||||
pub mod fx_editors;
|
||||
pub mod fx_widgets;
|
||||
pub mod gradient_editor;
|
||||
pub mod graph;
|
||||
pub mod graph_tools;
|
||||
pub mod home;
|
||||
|
||||
@@ -3671,7 +3671,16 @@ fn value_editor(
|
||||
);
|
||||
}
|
||||
Value::Gradient(_) => {
|
||||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
|
||||
// Opens the Gradient Editor at the current time (#612).
|
||||
let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new(("gradient-edit", uid)), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
if resp.clicked() {
|
||||
actions.push(("prop.editGradient".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||
}
|
||||
}
|
||||
Value::Str(s) => {
|
||||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::<String>(), Tokens::ui(12.0), t.text_dim);
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
//! The Gradient Editor (#612): the Timeline's "Edit Gradient…" opens it on a gradient property
|
||||
//! (here a shape layer's Gradient Fill ▸ Colors). OK sets the edited gradient with one undo step
|
||||
//! at the composition time it opened at (offset and stretched layers too); OK with no change, Cancel
|
||||
//! and Escape change nothing; on an animated gradient only the keyframe at that time changes, with
|
||||
//! a warning when its stop counts no longer match a neighbour's.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::keyframe::{Gradient, Value};
|
||||
use effectcraft_engine::project::{LayerId, PropGroup};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_ui_egui::panels::gradient_editor::Track;
|
||||
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
const WARNING: &str = "These adjacent keyframes cannot interpolate because their stop counts differ.";
|
||||
|
||||
/// The uids from the layer's root down to the Gradient Fill group, and its Colors property.
|
||||
fn gradient_fill(g: &PropGroup) -> Option<(Vec<u64>, u64)> {
|
||||
for x in g.groups() {
|
||||
if x.match_id == "gfill" {
|
||||
return Some((vec![x.uid], x.get("colors")?.uid));
|
||||
}
|
||||
if let Some((mut path, colors)) = gradient_fill(x) {
|
||||
path.insert(0, x.uid);
|
||||
return Some((path, colors));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A 20 s comp with a rectangle shape layer holding a Gradient Fill, its rows open in the
|
||||
/// Timeline. `timing` runs on the layer first (start, stretch). Returns (harness, layer, Colors uid).
|
||||
fn setup(timing: &[(&str, serde_json::Value)]) -> (Harness<'static, EffectcraftApp>, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 20})).unwrap();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.addShapeItem", json!({"layer": l, "kind": "gfill"})).unwrap();
|
||||
for (cmd, p) in timing {
|
||||
let mut p = p.clone();
|
||||
p["layers"] = json!([l]);
|
||||
s.execute(cmd, p).unwrap();
|
||||
}
|
||||
let (groups, colors) = gradient_fill(&s.active_comp().unwrap().layer(LayerId(l)).unwrap().props).expect("a Gradient Fill");
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.state_mut().ui.timeline.open_layers.insert(l);
|
||||
h.state_mut().ui.timeline.open_groups.extend(groups);
|
||||
h.run_steps(3);
|
||||
(h, l, colors)
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> [f32; 4] {
|
||||
h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect
|
||||
}
|
||||
|
||||
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
|
||||
let r = rect(h, id);
|
||||
pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
|
||||
}
|
||||
|
||||
/// The point at `f` (0..1) along the bar, at the height of automation id `row`.
|
||||
fn along(h: &Harness<'_, EffectcraftApp>, row: &str, f: f32) -> Pos2 {
|
||||
let bar = rect(h, "dialog.gradient.bar");
|
||||
pos2(bar[0] + bar[2] * f, center(h, row).y)
|
||||
}
|
||||
|
||||
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let p = center(h, id);
|
||||
click_at(h, p);
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=6 {
|
||||
let f = k as f32 / 6.0;
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * f));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness<'_, EffectcraftApp>, k: Key) {
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn colors_prop(h: &Harness<'_, EffectcraftApp>, l: u64, uid: u64) -> effectcraft_engine::project::Property {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find(uid).unwrap().clone()
|
||||
}
|
||||
|
||||
fn gradient(v: &Value) -> Gradient {
|
||||
match v {
|
||||
Value::Gradient(g) => g.clone(),
|
||||
other => panic!("not a gradient: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_editor(h: &mut Harness<'_, EffectcraftApp>, colors: u64) {
|
||||
let id = format!("timeline.prop.{colors}.value");
|
||||
assert!(h.state().auto.find(&id).is_some(), "no automation id {id}: the Timeline's gradient value isn't clickable");
|
||||
click(h, &id);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn set_time(h: &mut Harness<'_, EffectcraftApp>, t: f64) {
|
||||
h.state_mut().session.execute("time.set", json!({"time": t})).unwrap();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn add_key(h: &mut Harness<'_, EffectcraftApp>, l: u64, colors: u64, comp_t: f64, g: &Gradient) {
|
||||
h.state_mut()
|
||||
.session
|
||||
.execute("prop.addKey", json!({"layer": l, "prop": colors, "time": comp_t, "timeBase": "comp", "value": Value::Gradient(g.clone()).to_json()}))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4)
|
||||
}
|
||||
|
||||
/// The shared colour picker is showing (its colour field is in the accessibility tree).
|
||||
fn picker_open(h: &Harness<'_, EffectcraftApp>) -> bool {
|
||||
h.query_by_label("Color field").is_some()
|
||||
}
|
||||
|
||||
/// Type `hex` into the open picker's Hex field, over the code it shows.
|
||||
fn type_hex(h: &mut Harness<'_, EffectcraftApp>, hex: &str) {
|
||||
let at = h.get_by_role_and_label(Role::TextInput, "#").rect().center();
|
||||
click_at(h, at);
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND });
|
||||
}
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::Text(hex.into()));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn stop_color(h: &Harness<'_, EffectcraftApp>, id: u64) -> [f32; 4] {
|
||||
h.state().gradient_draft().colors.iter().find(|s| s.id == id).expect("the stop").value
|
||||
}
|
||||
|
||||
/// The numbers the editor's fields show (their accessibility values).
|
||||
fn shown_numbers(h: &Harness<'_, EffectcraftApp>) -> Vec<f64> {
|
||||
h.query_all_by(|n| n.numeric_value().is_some()).filter_map(|n| n.accesskit_node().numeric_value()).collect()
|
||||
}
|
||||
|
||||
fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize {
|
||||
h.state().session.history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_edits_ok_sets_the_gradient_in_one_undo_step() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
let before = colors_prop(&h, l, colors).value.clone();
|
||||
let undo = undo_len(&h);
|
||||
open_editor(&mut h, colors);
|
||||
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor));
|
||||
// Add a colour stop a quarter along, recolour it, and drag the right opacity stop to the middle.
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.25);
|
||||
click_at(&mut h, p);
|
||||
let (track, id) = h.state().gradient_draft().selected.expect("the new stop is selected");
|
||||
assert_eq!(track, Track::Color);
|
||||
let sampled = stop_color(&h, id);
|
||||
// The swatch opens the shared colour picker over the modal editor. A click in its colour field
|
||||
// reaches it (a modal blocks the layers below it), and so does typing a hex code.
|
||||
click(&mut h, "dialog.gradient.color");
|
||||
assert!(picker_open(&h), "the colour picker opened");
|
||||
let field = h.get_by_label("Color field").rect().center();
|
||||
click_at(&mut h, field + egui::vec2(20.0, -20.0));
|
||||
assert_ne!(stop_color(&h, id), sampled, "a click in the picker's colour field changed the stop");
|
||||
type_hex(&mut h, "336699");
|
||||
let picked = stop_color(&h, id);
|
||||
assert!(near(picked, [0.2, 0.4, 0.6, sampled[3]]), "the hex code's colour, and the stop kept its alpha: {picked:?}");
|
||||
// Clicking outside the picker keeps the colour and closes it; the editor stays.
|
||||
let title = h.get_by_label("Gradient Editor").rect().center();
|
||||
click_at(&mut h, title);
|
||||
assert!(!picker_open(&h), "the picker closed");
|
||||
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor));
|
||||
assert_eq!(stop_color(&h, id), picked);
|
||||
let from = center(&h, "dialog.gradient.opacityStop.1");
|
||||
let to = along(&h, "dialog.gradient.opacityStop.1", 0.5);
|
||||
drag(&mut h, from, to);
|
||||
assert_eq!(undo_len(&h), undo, "nothing is set while editing");
|
||||
click(&mut h, "dialog.gradient.ok");
|
||||
assert_eq!(h.state().dialog, None);
|
||||
let g = gradient(&colors_prop(&h, l, colors).value);
|
||||
assert_eq!(g.colors.len(), 3, "{g:?}");
|
||||
assert!((g.colors[1].0 - 0.25).abs() < 0.02 && g.colors[1].1 == picked, "{g:?}");
|
||||
assert!((g.opacities[1].0 - 0.5).abs() < 0.02, "{g:?}");
|
||||
assert_eq!(undo_len(&h), undo + 1, "one undo step");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(colors_prop(&h, l, colors).value, before, "one undo restores it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_and_escape_change_nothing() {
|
||||
for cancel in ["button", "escape"] {
|
||||
let (mut h, _, colors) = setup(&[]);
|
||||
let project = h.state().session.project.clone();
|
||||
let undo = undo_len(&h);
|
||||
open_editor(&mut h, colors);
|
||||
let p = along(&h, "dialog.gradient.addOpacity", 0.4);
|
||||
click_at(&mut h, p);
|
||||
assert_eq!(h.state().gradient_draft().opacities.len(), 3);
|
||||
if cancel == "button" {
|
||||
click(&mut h, "dialog.gradient.cancel");
|
||||
} else {
|
||||
key(&mut h, Key::Escape);
|
||||
}
|
||||
assert_eq!(h.state().dialog, None, "{cancel}");
|
||||
assert_eq!(h.state().session.project, project, "{cancel} leaves the project as it was");
|
||||
assert_eq!(undo_len(&h), undo);
|
||||
}
|
||||
}
|
||||
|
||||
/// OK without a change adds no keyframe between two (prop.set would add one).
|
||||
#[test]
|
||||
fn ok_without_changes_adds_no_keyframe() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
add_key(&mut h, l, colors, 1.0, &Gradient::default());
|
||||
add_key(&mut h, l, colors, 3.0, &Gradient::default());
|
||||
set_time(&mut h, 2.0);
|
||||
let undo = undo_len(&h);
|
||||
open_editor(&mut h, colors);
|
||||
click(&mut h, "dialog.gradient.ok");
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert_eq!(colors_prop(&h, l, colors).keys.len(), 2, "still the two keyframes");
|
||||
assert_eq!(undo_len(&h), undo, "no undo step");
|
||||
}
|
||||
|
||||
/// On an animated gradient OK sets the keyframe at the edit time (adding one between keys) and no
|
||||
/// other.
|
||||
#[test]
|
||||
fn animated_edits_change_only_the_keyframe_at_the_edit_time() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
let red = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], ..Gradient::default() };
|
||||
add_key(&mut h, l, colors, 1.0, &Gradient::default());
|
||||
add_key(&mut h, l, colors, 3.0, &red);
|
||||
let first = colors_prop(&h, l, colors).keys[0].clone();
|
||||
for (t, keys) in [(3.0, 2), (2.0, 3)] {
|
||||
set_time(&mut h, t);
|
||||
open_editor(&mut h, colors);
|
||||
let d = &h.state().gradient_draft();
|
||||
let id = d.id_at(Track::Color, 1).unwrap();
|
||||
h.state_mut().gradient_draft_mut().set_rgb(id, [0.0, 0.0, 1.0]);
|
||||
click(&mut h, "dialog.gradient.ok");
|
||||
let pr = colors_prop(&h, l, colors);
|
||||
assert_eq!(pr.keys.len(), keys, "at {t} s");
|
||||
assert_eq!(pr.keys[0], first, "the keyframe at 1 s is untouched");
|
||||
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
|
||||
// The edit time snapped to the nearest frame (29.97 fps), as prop.set snaps it.
|
||||
let at = h.state().session.active_comp().unwrap().frame_rate.snap_nearest(Tick::from_seconds_f64(t)).seconds();
|
||||
let k = pr.keys.iter().find(|k| (layer.comp_time(k.time).seconds() - at).abs() < 1e-6).expect("a keyframe at the edit time");
|
||||
assert_eq!(gradient(&k.value).colors[1].1, [0.0, 0.0, 1.0, 1.0], "at {t} s");
|
||||
}
|
||||
}
|
||||
|
||||
/// The key goes at the composition time the editor opened at, on a layer that starts late or is
|
||||
/// stretched (layer time differs from composition time).
|
||||
#[test]
|
||||
fn offset_and_stretched_layers_get_the_keyframe_at_the_comp_time() {
|
||||
for timing in
|
||||
[vec![("layer.timing", json!({"start": 5.0}))], vec![("layer.timing", json!({"start": 5.0})), ("layer.timeStretch", json!({"percent": 200.0}))]]
|
||||
{
|
||||
let (mut h, l, colors) = setup(&timing);
|
||||
set_time(&mut h, 5.5);
|
||||
h.state_mut().session.execute("prop.toggleAnimation", json!({"layer": l, "prop": colors})).unwrap();
|
||||
set_time(&mut h, 6.0);
|
||||
open_editor(&mut h, colors);
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.5);
|
||||
click_at(&mut h, p);
|
||||
click(&mut h, "dialog.gradient.ok");
|
||||
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
|
||||
let pr = layer.props.find(colors).unwrap();
|
||||
let comp_times: Vec<f64> = pr.keys.iter().map(|k| layer.comp_time(k.time).seconds()).collect();
|
||||
assert_eq!(pr.keys.len(), 2, "{timing:?}: {comp_times:?}");
|
||||
// Composition time 6 s snapped to the nearest frame (29.97 fps: 6.006 s), as prop.set snaps
|
||||
// it: the layer's start and stretch don't move it.
|
||||
let at = h.state().session.active_comp().unwrap().frame_rate.snap_nearest(Tick::from_seconds_f64(6.0)).seconds();
|
||||
let k = pr.keys.iter().find(|k| (layer.comp_time(k.time).seconds() - at).abs() < 1e-6);
|
||||
let k = k.unwrap_or_else(|| panic!("{timing:?}: no keyframe at comp time {at} s: {comp_times:?}"));
|
||||
assert_eq!(gradient(&k.value).colors.len(), 3, "{timing:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Adding a stop next to keyframes with fewer stops warns that they can't interpolate; it still
|
||||
/// saves, the neighbour keeps its stops, and the animation holds until the edited keyframe.
|
||||
#[test]
|
||||
fn mismatched_stop_counts_warn_and_neighbours_stay() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
add_key(&mut h, l, colors, 1.0, &Gradient::default());
|
||||
add_key(&mut h, l, colors, 3.0, &Gradient::default());
|
||||
set_time(&mut h, 3.0);
|
||||
open_editor(&mut h, colors);
|
||||
assert!(h.query_by_label(WARNING).is_none(), "same counts: no warning");
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.5);
|
||||
click_at(&mut h, p);
|
||||
assert!(h.query_by_label(WARNING).is_some(), "three colour stops next to a keyframe with two");
|
||||
click(&mut h, "dialog.gradient.ok");
|
||||
let pr = colors_prop(&h, l, colors);
|
||||
assert_eq!(pr.keys.len(), 2);
|
||||
assert_eq!(gradient(&pr.keys[0].value), Gradient::default(), "the neighbour keeps its stops");
|
||||
assert_eq!(gradient(&pr.keys[1].value).colors.len(), 3);
|
||||
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
|
||||
let mid = pr.value_at(layer.layer_time(Tick::from_seconds_f64(2.0)));
|
||||
assert_eq!(gradient(&mid), Gradient::default(), "it holds the first keyframe instead of blending");
|
||||
}
|
||||
|
||||
/// Delete removes the selected stop (never a layer, and never the last stop of a track), but not
|
||||
/// while typing in a field.
|
||||
#[test]
|
||||
fn delete_removes_the_selected_stop_but_not_the_last_or_while_typing() {
|
||||
let (mut h, _, colors) = setup(&[]);
|
||||
let layers = h.state().session.active_comp().unwrap().layers.len();
|
||||
open_editor(&mut h, colors);
|
||||
click(&mut h, "dialog.gradient.colorStop.0");
|
||||
key(&mut h, Key::Delete);
|
||||
assert_eq!(h.state().gradient_draft().colors.len(), 1, "Delete removed the selected stop");
|
||||
click(&mut h, "dialog.gradient.colorStop.0");
|
||||
key(&mut h, Key::Delete);
|
||||
click(&mut h, "dialog.gradient.delete");
|
||||
assert_eq!(h.state().gradient_draft().colors.len(), 1, "the last colour stop stays");
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), layers, "Delete never reaches the layer");
|
||||
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor));
|
||||
// Typing in Location: Delete edits the text, not the stops.
|
||||
click(&mut h, "dialog.gradient.opacityStop.0");
|
||||
click(&mut h, "dialog.gradient.location");
|
||||
assert!(h.ctx.egui_wants_keyboard_input(), "the Location field takes the keyboard");
|
||||
key(&mut h, Key::Delete);
|
||||
assert_eq!(h.state().gradient_draft().opacities.len(), 2, "typing doesn't delete a stop");
|
||||
}
|
||||
|
||||
/// One colour stop saves (the typed form; plain JSON needs two) and one undo brings both back.
|
||||
#[test]
|
||||
fn a_single_colour_stop_saves_and_undoes() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
let before = colors_prop(&h, l, colors).value.clone();
|
||||
let undo = undo_len(&h);
|
||||
open_editor(&mut h, colors);
|
||||
click(&mut h, "dialog.gradient.colorStop.1");
|
||||
click(&mut h, "dialog.gradient.delete");
|
||||
assert_eq!(h.state().gradient_draft().colors.len(), 1);
|
||||
click(&mut h, "dialog.gradient.ok");
|
||||
let g = gradient(&colors_prop(&h, l, colors).value);
|
||||
assert_eq!(g.colors, gradient(&before).colors[..1].to_vec(), "{g:?}");
|
||||
assert_eq!(g.opacities, gradient(&before).opacities, "opacity stops unchanged");
|
||||
assert_eq!(undo_len(&h), undo + 1);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(colors_prop(&h, l, colors).value, before);
|
||||
}
|
||||
|
||||
/// Typing a Location that moves a stop past its neighbour keeps the selection on that stop, and
|
||||
/// in the same frame the Opacity field shows its value, not the neighbour's (now at its old index).
|
||||
#[test]
|
||||
fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() {
|
||||
let (mut h, _, colors) = setup(&[]);
|
||||
open_editor(&mut h, colors);
|
||||
// Opacity 100% at 0 and 40% at 1, plus a stop at 0.5 sampled from them: 70%.
|
||||
let right = h.state().gradient_draft().opacities[1].id;
|
||||
h.state_mut().gradient_draft_mut().set_opacity(right, 0.4);
|
||||
let p = along(&h, "dialog.gradient.addOpacity", 0.5);
|
||||
click_at(&mut h, p);
|
||||
let middle = h.state().gradient_draft().selected.expect("the new stop").1;
|
||||
let i = h.state().gradient_draft().index_of(Track::Opacity, middle).unwrap();
|
||||
assert!((h.state().gradient_draft().opacities[i].value - 0.7).abs() < 0.02, "it sampled 70%");
|
||||
// Select the 100% stop at 0 and type 80 into Location: it passes the stop at 0.5.
|
||||
let first = h.state().gradient_draft().opacities[0].id;
|
||||
click(&mut h, "dialog.gradient.opacityStop.0");
|
||||
click(&mut h, "dialog.gradient.location");
|
||||
assert!(h.ctx.egui_wants_keyboard_input(), "the Location field is being typed in");
|
||||
h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
|
||||
h.input_mut().events.push(Event::Text("80".into()));
|
||||
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
let d = h.state().gradient_draft();
|
||||
assert_eq!(d.selected, Some((Track::Opacity, first)), "the moved stop stays selected");
|
||||
assert_eq!(d.index_of(Track::Opacity, first), Some(1), "it is now after the stop at 0.5");
|
||||
// The frame that moved it: Opacity already shows this stop's 100%, never the neighbour's 70%
|
||||
// that is now at its old index. (Location itself was drawn before the move: 0 until the next.)
|
||||
let shown = shown_numbers(&h);
|
||||
assert!(shown.contains(&100.0), "this stop's 100%: {shown:?}");
|
||||
assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "never the neighbour's 70%: {shown:?}");
|
||||
h.run_steps(2);
|
||||
let shown = shown_numbers(&h);
|
||||
assert!(shown.contains(&80.0) && shown.contains(&100.0), "Location 80 and Opacity 100%: {shown:?}");
|
||||
assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}");
|
||||
}
|
||||
|
||||
/// A layer deleted while the editor is open: OK reports it, without a crash.
|
||||
#[test]
|
||||
fn a_layer_deleted_while_editing_is_reported() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
open_editor(&mut h, colors);
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.5);
|
||||
click_at(&mut h, p);
|
||||
h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap();
|
||||
h.run_steps(2);
|
||||
click(&mut h, "dialog.gradient.ok");
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert!(!h.state().ui.status.is_empty(), "the failed set is reported");
|
||||
}
|
||||
|
||||
/// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that
|
||||
/// isn't a gradient property, from the engine and through the UI, and opens nothing.
|
||||
#[test]
|
||||
fn edit_gradient_by_id_rejects_bad_targets() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
let ctx = h.ctx.clone();
|
||||
let opacity = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
for (p, err) in [(json!({"layer": 9999, "prop": colors}), "no layer"), (json!({"layer": l, "prop": opacity}), "not a gradient")] {
|
||||
let e = h.state_mut().session.execute_checked("prop.editGradient", p.clone()).expect_err("the engine refuses it");
|
||||
assert!(e.to_string().contains(err), "{p}: {e}");
|
||||
let e = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "prop.editGradient", p.clone()).expect_err("so does the UI");
|
||||
assert!(e.contains(err), "{p}: {e}");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, None, "{p}");
|
||||
}
|
||||
let r = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "prop.editGradient", json!({"layer": l, "prop": colors}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor));
|
||||
}
|
||||
|
||||
/// In the editor, the colour picker's Escape and Cancel go back to the stop's colour and close only
|
||||
/// the picker: the editor and its other edits stay (dialogs leave Escape to an open picker).
|
||||
#[test]
|
||||
fn picker_escape_and_cancel_restore_the_stop_and_keep_the_editor() {
|
||||
let (mut h, _, colors) = setup(&[]);
|
||||
open_editor(&mut h, colors);
|
||||
let added = along(&h, "dialog.gradient.addOpacity", 0.3);
|
||||
click_at(&mut h, added);
|
||||
click(&mut h, "dialog.gradient.colorStop.0");
|
||||
let id = h.state().gradient_draft().selected.expect("a selected stop").1;
|
||||
let original = stop_color(&h, id);
|
||||
for how in ["escape", "cancel"] {
|
||||
click(&mut h, "dialog.gradient.color");
|
||||
assert!(picker_open(&h), "{how}: the picker opened");
|
||||
type_hex(&mut h, "ff8000");
|
||||
assert!(near(stop_color(&h, id), [1.0, 128.0 / 255.0, 0.0, original[3]]), "{how}: the picker previews as it goes");
|
||||
if how == "escape" {
|
||||
key(&mut h, Key::Escape);
|
||||
} else {
|
||||
// The picker's Cancel, not the editor's.
|
||||
let editor_cancel = rect(&h, "dialog.gradient.cancel");
|
||||
let cancel =
|
||||
h.query_all_by_label("Cancel").map(|n| n.rect()).find(|r| (r.min.x - editor_cancel[0]).abs() > 1.0 || (r.min.y - editor_cancel[1]).abs() > 1.0);
|
||||
click_at(&mut h, cancel.expect("the picker's Cancel").center());
|
||||
}
|
||||
assert!(!picker_open(&h), "{how}: the picker closed");
|
||||
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "{how}: the editor stays open");
|
||||
assert_eq!(stop_color(&h, id), original, "{how}: the stop's colour is back");
|
||||
assert_eq!(h.state().gradient_draft().opacities.len(), 3, "{how}: the editor's other edits stay");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Escape that closes the picker is its own: the next Escape, even in the very next pass,
|
||||
/// cancels the editor (a closed picker doesn't keep the dialog from it).
|
||||
#[test]
|
||||
fn the_escape_after_the_picker_closes_cancels_the_editor() {
|
||||
let (mut h, _, colors) = setup(&[]);
|
||||
let project = h.state().session.project.clone();
|
||||
open_editor(&mut h, colors);
|
||||
click(&mut h, "dialog.gradient.colorStop.0");
|
||||
click(&mut h, "dialog.gradient.color");
|
||||
assert!(picker_open(&h), "the picker opened");
|
||||
let escape = |h: &mut Harness<'_, EffectcraftApp>| {
|
||||
h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
};
|
||||
escape(&mut h);
|
||||
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "the first Escape closed only the picker");
|
||||
escape(&mut h);
|
||||
assert_eq!(h.state().dialog, None, "the next pass's Escape cancels the editor");
|
||||
assert_eq!(h.state().session.project, project, "and changes nothing");
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! #612: a gradient property's "Edit Gradient…" in the Timeline opens an editor. Before the fix
|
||||
//! the value only painted its label: no automation id, nothing to click. This test uses only APIs
|
||||
//! that existed before the Gradient Editor, so it runs (and fails) on the code without it; the
|
||||
//! editor itself is tested in `ui_gradient_editor`.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::{LayerId, PropGroup};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Modifiers, PointerButton, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// The uids from the layer's root down to the Gradient Fill group, and its Colors property.
|
||||
fn gradient_fill(g: &PropGroup) -> Option<(Vec<u64>, u64)> {
|
||||
for x in g.groups() {
|
||||
if x.match_id == "gfill" {
|
||||
return Some((vec![x.uid], x.get("colors")?.uid));
|
||||
}
|
||||
if let Some((mut path, colors)) = gradient_fill(x) {
|
||||
path.insert(0, x.uid);
|
||||
return Some((path, colors));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_timeline_gradient_value_opens_an_editor() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.addShapeItem", json!({"layer": l, "kind": "gfill"})).unwrap();
|
||||
let (groups, colors) = gradient_fill(&s.active_comp().unwrap().layer(LayerId(l)).unwrap().props).expect("a Gradient Fill");
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.state_mut().ui.timeline.open_layers.insert(l);
|
||||
h.state_mut().ui.timeline.open_groups.extend(groups);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let id = format!("timeline.prop.{colors}.value");
|
||||
let r = h.state().auto.find(&id).unwrap_or_else(|| panic!("no automation id {id}: the Timeline's Edit Gradient… isn't clickable")).rect;
|
||||
let p = pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(4);
|
||||
assert!(h.state().dialog.is_some(), "clicking Edit Gradient… opens an editor");
|
||||
}
|
||||
@@ -129,6 +129,19 @@ The most important missing piece: it turns every other estimate here into a meas
|
||||
the prompt, with their layers, in one undo step, for single items, multiple selections and
|
||||
folder contents. Nothing happens with an empty selection, in other panels, while typing or with
|
||||
a dialog open, and the next Delete still asks.
|
||||
- Regression evidence for [#612](https://github.com/storytold/effectcraft/issues/612): the
|
||||
`ui_gradient_value` and `ui_gradient_editor` input tests, checked headlessly on Windows, open
|
||||
the Gradient Editor from the Timeline's "Edit Gradient…" on a shape layer's Gradient Fill. They
|
||||
check that OK sets the edited stops (added, dragged, recoloured in the picker, down to a single
|
||||
colour stop) in one undo step, at the composition time the editor opened (also on offset and
|
||||
stretched layers), and that OK without a change, Cancel and Escape change nothing; Escape with
|
||||
the picker open closes only the picker. On an animated gradient only the keyframe at that time
|
||||
changes, with a warning when its stop counts no longer match a neighbour's. Delete never
|
||||
removes the last stop, a layer or a stop while typing; a stop moved past another by its
|
||||
Location keeps its selection and fields; a layer deleted while editing is reported; and
|
||||
`prop.editGradient` is an error for a target that isn't a gradient. Unit tests in `keyframe`
|
||||
and `gradient_editor` cover per-track sampling (no double opacity), the typed one-stop round
|
||||
trip, and stops staying in order, and selected, while they move.
|
||||
- Regression evidence for the reports of 6 October, checked headlessly on Windows: real pointer
|
||||
drags in `ui_drag_drop` (Project items into the Timeline between layers and at a time, and onto
|
||||
the viewer; an effect onto a layer in the viewer: #85, #88, #89), `ui_render_queue` (a template
|
||||
|
||||
Reference in New Issue
Block a user