Shapes: edit gradient color and opacity stops (#612)

The Timeline's "Edit Gradient…" on a gradient property (Gradient Fill /
Gradient Stroke ▸ Colors, Gradient Overlay) only painted its label. It now
opens a Gradient Editor (prop.editGradient): opacity stops above a preview
bar and colour stops below. Click to add a stop (it takes that track's
own value there), drag or type a Location to move it, set its opacity or
colour (egui's colour picker), Delete removes it (never the last of a
track, never while typing). The tracks stay in order while editing and the
selection follows its stop.

The editor works on a draft. OK sets the property once with prop.set at
the composition time it opened, snapped to the frame, with
timeBase: "comp" (offset and stretched layers get the key where it was
opened): one undo step, and nothing at all when the draft is unchanged, so
OK adds no keyframe. 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; when its stop counts no longer match a
neighbouring keyframe's (gradients only blend with equal counts) the
editor says so. The draft is saved in the typed form, so one colour stop
and each stop's RGBA survive.

prop.editGradient checks its target is a gradient property before the UI
opens anything, so agents get an error for a wrong one. New Gradient
color_at / opacity_at sample one track each (sample() multiplies opacity
in, which would apply it twice to a new stop).

Tests: ui_gradient_value (fails on main: the value had no automation id),
ui_gradient_editor (11), gradient_editor and keyframe unit tests.
This commit is contained in:
tylerjenningsw committed 2026-10-10 22:22:13 -04:00
1 parent 9a7d3e7699
commit ec1bc8cc99
13 files changed
+1133 -3

No files matched your search

+27 -2
View File
@@ -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}))
}
+32
View File
@@ -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))];
+18
View File
@@ -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> {
+26
View File
@@ -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")),
+13
View File
@@ -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) {
+2
View File
@@ -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));
+3
View File
@@ -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,521 @@
//! 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);
// egui's colour button: its picker is a popup that works inside this modal, and
// Escape closes the picker first (dialogs ignore Escape while a popup is open).
// sRGB like the swatches; the stop keeps its alpha.
let mut col = to_color32([c[0], c[1], c[2], 1.0]);
let swatch = egui::color_picker::color_edit_button_srgba(ui, &mut col, egui::color_picker::Alpha::Opaque);
app.auto.add("dialog.gradient.color", swatch.rect, "Color");
if swatch.changed() {
app.dialog_state.gradient.set_rgb(id, [col.r() as f32 / 255.0, col.g() as f32 / 255.0, col.b() as f32 / 255.0]);
}
}
}
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");
}
}
+1
View File
@@ -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;
+10 -1
View File
@@ -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);
+418
View File
@@ -0,0 +1,418 @@
//! 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::{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 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 colour picker (a new layer over the modal); a click in its
// saturation/value square, below the current-colour strip, picks a new colour.
let layers = h.ctx.memory(|m| m.areas().visible_layer_ids());
click(&mut h, "dialog.gradient.color");
assert!(egui::Popup::is_any_open(&h.ctx), "the colour picker opened");
let popup = h.ctx.memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| !layers.contains(l)).filter_map(|l| m.area_rect(l.id)).next());
let popup = popup.expect("the picker's layer");
let sp = h.ctx.global_style().spacing.clone();
let p = pos2(popup.min.x + 8.0 + sp.slider_width * 0.8, popup.min.y + 8.0 + sp.interact_size.y + sp.item_spacing.y + sp.slider_width * 0.3);
click_at(&mut h, p);
// Escape closes the picker, not the editor (its edits stay).
key(&mut h, Key::Escape);
assert!(!egui::Popup::is_any_open(&h.ctx), "the picker closed");
assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "Escape closed only the picker");
let picked = stop_color(&h, id);
assert_ne!(picked, sampled, "the picker changed the stop's colour");
assert_eq!(picked[3], sampled[3], "and kept its alpha");
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));
}
+49
View File
@@ -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");
}
+13
View File
@@ -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