Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions crates/engine/src/commands/frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 changes: 32 additions & 0 deletions crates/keyframe/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))];
Expand Down
18 changes: 18 additions & 0 deletions crates/keyframe/src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
14 changes: 14 additions & 0 deletions crates/ui-egui/src/color_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ 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));
});
// Open 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));
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
out.close = Some(false);
}
Expand All @@ -60,6 +63,17 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
out.changed
}

fn open_pass_id() -> egui::Id {
egui::Id::new("color-picker-open-pass")
}

/// A colour picker was open in this pass or the last one. Escape then belongs to the picker (it
/// goes back to the original colour and closes), not to the dialog it was opened from.
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 {
Expand Down
26 changes: 26 additions & 0 deletions crates/ui-egui/src/i18n/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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", "クリックしてこのビューアーをアクティブ化"),
Expand Down Expand Up @@ -238,6 +242,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Frame Render Time {}ms", "フレームのレンダリング時間 {}ms"),
("Frame Target:", "フレームターゲット:"),
("Grab bag", "いろいろ"),
("Gradient Editor", "グラデーションエディター"),
("Grid", "グリッド"),
("Guide Layers: {}", "ガイドレイヤー:{}"),
("Guides", "ガイド"),
Expand Down Expand Up @@ -274,6 +279,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Light Type", "ライトの種類"),
("Lighter", "明るく"),
("Link Selected Property", "選択したプロパティをリンク"),
("Location:", "位置:"),
("Lock Aspect Ratio to {}", "アスペクト比を{}に固定"),
("Lock to Zoom", "ズームにロック"),
("Locked: {} — {}", "ロック済み:{} — {}"),
Expand Down Expand Up @@ -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", "時間"),
Expand Down Expand Up @@ -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", "单击可激活此视图"),
Expand Down Expand Up @@ -673,6 +681,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Frame Target:", "帧目标:"),
("Freeze", "冻结"),
("Grab bag", "列表"),
("Gradient Editor", "渐变编辑器"),
("Grid", "网格"),
("Guide Layers: {}", "参考图层:{}"),
("Guides", "参考线"),
Expand Down Expand Up @@ -711,6 +720,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Light Type", "灯光类型"),
("Lighter", "更浅"),
("Link Selected Property", "链接所选属性"),
("Location:", "位置:"),
("Lock Aspect Ratio to {}", "锁定长宽比为 {}"),
("Lock to Zoom", "锁定缩放"),
("Locked: {} — {}", "已锁定:{} — {}"),
Expand Down Expand Up @@ -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", "时间"),
Expand Down Expand Up @@ -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", "按一下可激活此檢視"),
Expand Down Expand Up @@ -1198,6 +1210,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Frame Target:", "幀目標:"),
("Freeze", "凍結"),
("Grab bag", "清單"),
("Gradient Editor", "漸變編輯器"),
("Grid", "格點"),
("Guide Layers: {}", "參考圖層:{}"),
("Guides", "參考線"),
Expand Down Expand Up @@ -1236,6 +1249,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Light Type", "燈光類型"),
("Lighter", "更淺"),
("Link Selected Property", "鏈接所選屬性"),
("Location:", "位置:"),
("Lock Aspect Ratio to {}", "鎖定長寬比例為 {}"),
("Lock to Zoom", "鎖定縮放"),
("Locked: {} — {}", "已鎖定:{} — {}"),
Expand Down Expand Up @@ -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", "時間"),
Expand Down Expand Up @@ -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", "Клацніть, щоб зробити цю область перегляду активною"),
Expand Down Expand Up @@ -1715,6 +1734,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Frame Render Time {}ms", "Час рендерингу кадру {} мс"),
("Frame Target:", "Цільовий фрейм:"),
("Grab bag", "Список"),
("Gradient Editor", "Редактор градієнта"),
("Grid", "Сітка"),
("Guide Layers: {}", "Допоміжні шари: {}"),
("Guides", "Напрямні"),
Expand Down Expand Up @@ -1751,6 +1771,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Light Type", "Тип світла"),
("Lighter", "Світліше"),
("Link Selected Property", "Зв’язати виділену властивість"),
("Location:", "Розташування:"),
("Lock Aspect Ratio to {}", "Зафіксувати співвідношення сторін на {}"),
("Lock to Zoom", "Прив’язати до масштабу"),
("Locked: {} — {}", "Заблоковано: {} — {}"),
Expand Down Expand Up @@ -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", "Час"),
Expand Down Expand Up @@ -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")),
Expand Down
13 changes: 13 additions & 0 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion crates/ui-egui/src/menus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down
Loading