UI: Window > Ease Presets panel draws, edits and applies ease curves (#254)

A dockable panel (our own design) for the keys.easePreset commands. A
value graph shows the working curve with its two handles, which drag
to edit it; Apply eases the selected keyframes with it and From Keys
loads the curve between the first selected pair. Every preset is a
thumbnail drawn in code; a click loads it and applies it to the
selected keys. Save Current keeps the working curve under the name in
the field, and Rename and Delete act on the selected user preset.
Wide panels put the presets beside the graph; narrow ones below it,
the graph shrinking to leave a row of presets, scrolled with the wheel.

The panel state (working curve, selection, name) is serde in the UI
state, and every control registers an automation id (easePresets.*).
widgets::text_field is the search field without its magnifier.

Test: clicking_an_ease_preset_eases_the_selected_keys_and_handles_edit_the_curve
(ui_keyframes); every_panel_registers_automation_ids_for_its_widgets
covers the new panel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 17:25:00 +07:00
1 parent 0717f12246
commit 1c22615553
9 files changed
+319 -6

No files matched your search

+1 -1
View File
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Show Panel",
[],
None,
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor}",
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor|easePresets}",
always
),
fe!(
+1
View File
@@ -1200,6 +1200,7 @@ Window
@dynamic:openViewers
---
Create Nulls From Paths | window.panel {"panel":"createNullsFromPaths"}
Ease Presets | window.panel {"panel":"easePresets"}
VR Comp Editor | window.panel {"panel":"vrCompEditor"}
@dynamic:scriptPanels
Help
+6 -1
View File
@@ -50,10 +50,12 @@ pub enum PanelKind {
CreateNullsFromPaths,
/// Window ▸ VR Comp Editor (our own panel).
VrCompEditor,
/// Window ▸ Ease Presets (our own panel).
EasePresets,
}
impl PanelKind {
pub const ALL: [PanelKind; 34] = [
pub const ALL: [PanelKind; 35] = [
PanelKind::Project,
PanelKind::EffectControls,
PanelKind::Composition,
@@ -88,6 +90,7 @@ impl PanelKind {
PanelKind::ContentAwareFill,
PanelKind::CreateNullsFromPaths,
PanelKind::VrCompEditor,
PanelKind::EasePresets,
];
pub fn title(self) -> &'static str {
match self {
@@ -127,6 +130,7 @@ impl PanelKind {
PanelKind::ContentAwareFill => "Content-Aware Fill",
PanelKind::CreateNullsFromPaths => "Create Nulls From Paths",
PanelKind::VrCompEditor => "VR Comp Editor",
PanelKind::EasePresets => "Ease Presets",
}
}
pub fn id(self) -> String {
@@ -367,6 +371,7 @@ pub fn workspace(name: &str) -> DockNode {
ContentAwareFill,
CreateNullsFromPaths,
VrCompEditor,
EasePresets,
],
),
),
+1
View File
@@ -674,6 +674,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("window.panel", "Render Queue", "レンダーキュー"),
("window.panel", "Timeline", "タイムライン"),
("window.panel", "Create Nulls From Paths", "パスからヌルを作成"),
("window.panel", "Ease Presets", "イーズプリセット"),
("window.panel", "VR Comp Editor", "VRコンポジションエディター"),
("", "Help", "ヘルプ"),
("help.docs", "EffectCraft Help...", "EffectCraftヘルプ..."),
+249
View File
@@ -0,0 +1,249 @@
//! Ease Presets panel (Window ▸ Ease Presets): easing curves kept by name and applied to pairs of
//! keyframes (our own design and presets). The value graph at the top edits a working curve by
//! dragging its two handles, and Apply eases the selected keyframes with it; From Keys reads the
//! curve between the first selected pair. Clicking a preset's thumbnail loads it and applies it.
//! Save Current keeps the working curve under the name in the field; Rename and Delete act on the
//! selected user preset. Every action is a `keys.easePreset.*` command.
//!
//! Automation ids: `easePresets.graph`, `easePresets.handle.out` / `.in`, `easePresets.apply`,
//! `easePresets.fromKeys`, `easePresets.name`, `easePresets.save`, `easePresets.rename`,
//! `easePresets.delete`, `easePresets.preset.<n>` (1-based, labelled with the preset's name).
use effectcraft_engine::ease_presets;
use effectcraft_engine::keyframe::EaseCurve;
use egui::epaint::CubicBezierShape;
use egui::{Align2, Color32, CursorIcon, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde::{Deserialize, Serialize};
use serde_json::json;
use super::panel_kit as kit;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
/// Panel state (serde in the UI state).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct EasePanelState {
/// The curve the graph shows and Apply uses.
pub curve: EaseCurve,
/// The preset clicked last (Rename and Delete act on it).
pub selected: Option<String>,
/// Name for Save Current and Rename.
pub name: String,
}
impl Default for EasePanelState {
fn default() -> Self {
EasePanelState { curve: EaseCurve::LINEAR, selected: None, name: String::new() }
}
}
/// Value range of the graph: room for handles above and below the keys.
const V_MIN: f64 = -0.35;
const V_MAX: f64 = 1.35;
const TILE: egui::Vec2 = vec2(84.0, 64.0);
const GAP: f32 = 6.0;
const ROW: f32 = 30.0;
/// Height of the controls under the graph (numbers, three button rows, status, "Presets").
const CONTROLS: f32 = 154.0;
/// Panels at least this wide put the presets beside the graph and controls.
const SIDE_BY_SIDE: f32 = 560.0;
/// Screen point of normalised time `x` and value `v` in `r`, values `lo..hi` bottom to top.
fn to_screen(r: Rect, lo: f64, hi: f64, x: f64, v: f64) -> Pos2 {
pos2(r.min.x + r.width() * x as f32, r.max.y - r.height() * ((v - lo) / (hi - lo)) as f32)
}
/// The curve from (0, 0) to (1, 1) through its handles.
fn curve_shape(c: &EaseCurve, at: impl Fn(f64, f64) -> Pos2, stroke: Stroke) -> CubicBezierShape {
let [x1, y1, x2, y2] = c.handles();
CubicBezierShape::from_points_stroke([at(0.0, 0.0), at(x1, y1), at(x2, y2), at(1.0, 1.0)], false, Color32::TRANSPARENT, stroke)
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let mut st = app.ui.ease_presets.clone();
let pairs = ease_presets::selected_pairs(&app.session).len();
let x0 = rect.min.x + 10.0;
// Wide panels: graph and controls on the left, presets on the right; else presets below,
// with room for at least one row of them.
let side = rect.width() >= SIDE_BY_SIDE;
let w = if side { 300.0 } else { (rect.width() - 20.0).max(210.0) };
let room = rect.height() - 16.0 - CONTROLS - if side { 0.0 } else { TILE.y + GAP };
let g = Rect::from_min_size(pos2(x0, rect.min.y + 10.0), vec2(w, (w * 0.6).min(room).clamp(90.0, 220.0)));
graph(app, ui, g, &mut st.curve);
let c = st.curve;
let mut y = g.max.y + 4.0;
let numbers = format!("Out {:.1} % · {:.2} In {:.1} % · {:.2}", c.out_influence, c.out_speed, c.in_influence, c.in_speed);
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, numbers, Tokens::mono(11.0), t.text_faint);
y += 22.0;
let at = |x: f32, y: f32, width: f32| Rect::from_min_size(pos2(x, y), vec2(width, 24.0));
if kit::button(app, ui, at(x0, y, 90.0), "easePresets.apply", "Apply", pairs > 0) {
apply(app, json!({"curve": c}), pairs, "the curve");
}
if kit::button(app, ui, at(x0 + 96.0, y, 100.0), "easePresets.fromKeys", "From Keys", false)
&& let Some(v) = kit::exec(app, "keys.easePreset.capture", json!({}))
&& let Ok(curve) = serde_json::from_value(v)
{
st.curve = curve;
st.selected = None;
}
y += ROW;
let field = at(x0, y, (w - 116.0).max(80.0));
widgets::text_field(ui, field, &mut st.name, "Preset name", &t);
app.auto.add("easePresets.name", field, "Preset name");
let name = st.name.trim().to_string();
if kit::button(app, ui, at(field.max.x + 6.0, y, 110.0), "easePresets.save", "Save Current", false) {
if name.is_empty() {
app.ui.status = "Type a name for the preset first".into();
} else if let Some(v) = kit::exec(app, "keys.easePreset.save", json!({"name": name, "curve": c})) {
st.selected = v["name"].as_str().map(str::to_string);
app.ui.status = format!("Saved the ease preset “{}”", st.selected.as_deref().unwrap_or(&name));
}
}
y += ROW;
// Rename and Delete act on the selected user preset.
let user = st.selected.clone().filter(|n| app.session.ease_presets.iter().any(|q| &q.name == n));
let rename = kit::button(app, ui, at(x0, y, 90.0), "easePresets.rename", "Rename", false);
let delete = kit::button(app, ui, at(x0 + 96.0, y, 90.0), "easePresets.delete", "Delete", false);
match (&user, rename, delete) {
(None, true, _) | (None, _, true) => app.ui.status = "Select one of your own presets first".into(),
(Some(old), true, _) if name.is_empty() => app.ui.status = format!("Type a new name for “{old}” first"),
(Some(old), true, _) => {
if let Some(v) = kit::exec(app, "keys.easePreset.rename", json!({"name": old, "newName": name})) {
st.selected = v["name"].as_str().map(str::to_string);
}
}
(Some(old), _, true) => {
let deleted = kit::exec(app, "keys.easePreset.delete", json!({"name": old})).is_some();
if deleted {
st.selected = None;
}
}
_ => {}
}
y += ROW;
let msg = if pairs > 0 {
format!("{pairs} keyframe pair{} selected", if pairs == 1 { "" } else { "s" })
} else {
"Select two or more neighbouring keyframes".into()
};
p.text(pos2(x0, y + 4.0), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_faint);
y += 16.0;
let head = if side { pos2(x0 + w + 16.0, rect.min.y + 6.0) } else { pos2(x0, y) };
kit::label(ui, head, "Presets", &t);
let list = Rect::from_min_max(head + vec2(0.0, 22.0), pos2(rect.max.x - 10.0, rect.max.y - 6.0));
if let Some((name, curve)) = grid(app, ui, list, st.selected.as_deref()) {
st.curve = curve;
if pairs > 0 {
apply(app, json!({"preset": name}), pairs, &format!("“{name}”"));
}
st.selected = Some(name);
}
if st != app.ui.ease_presets {
app.ui.ease_presets = st;
}
}
fn apply(app: &mut EffectcraftApp, params: serde_json::Value, pairs: usize, what: &str) {
if pairs == 0 {
app.ui.status = "Select two or more neighbouring keyframes of a property".into();
} else if let Some(r) = kit::exec(app, "keys.easePreset.apply", params) {
app.ui.status = format!("Eased {} keyframe pair(s) with {what}", r["pairs"]);
}
}
/// The working curve's value graph: keys at (0, 0) and (1, 1), the linear ease for reference, and
/// the two handles, dragged to edit the curve.
fn graph(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Rect, curve: &mut EaseCurve) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(g);
p.rect_filled(g, 3.0, t.field_bg);
p.rect_stroke(g, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
app.auto.add("easePresets.graph", g, "Ease curve");
let plot = g.shrink2(vec2(16.0, 6.0));
let at = |x: f64, v: f64| to_screen(plot, V_MIN, V_MAX, x, v);
for v in [0.0, 1.0] {
p.line_segment([at(0.0, v), at(1.0, v)], Stroke::new(1.0, t.separator));
}
p.add(egui::Shape::dashed_line(&[at(0.0, 0.0), at(1.0, 1.0)], Stroke::new(1.0, t.separator), 4.0, 4.0));
p.add(curve_shape(curve, at, Stroke::new(2.0, t.accent)));
let [x1, y1, x2, y2] = curve.handles();
let mut next = None;
for (out, key, h) in [(true, at(0.0, 0.0), at(x1, y1)), (false, at(1.0, 1.0), at(x2, y2))] {
p.line_segment([key, h], Stroke::new(1.0, t.text_dim));
p.rect_filled(Rect::from_center_size(key, vec2(7.0, 7.0)), 1.0, t.keyframe);
let r = Rect::from_center_size(h, vec2(14.0, 14.0));
let resp = ui.interact(r, egui::Id::new(("easePresets.handle", out)), Sense::drag());
app.auto.add(if out { "easePresets.handle.out" } else { "easePresets.handle.in" }, r, if out { "Out handle" } else { "In handle" });
let hot = resp.hovered() || resp.dragged();
if hot {
ui.ctx().set_cursor_icon(if resp.dragged() { CursorIcon::Grabbing } else { CursorIcon::Grab });
}
p.circle_filled(h, if hot { 5.5 } else { 4.5 }, if hot { t.keyframe_selected } else { t.text });
if resp.dragged() {
let to = h + resp.drag_delta();
let x = f64::from((to.x - plot.min.x) / plot.width()).clamp(0.0, 1.0);
let v = (V_MIN + f64::from((plot.max.y - to.y) / plot.height()) * (V_MAX - V_MIN)).clamp(V_MIN, V_MAX);
next = EaseCurve::from_handles(if out { [x, v, x2, y2] } else { [x1, y1, x, v] });
}
}
if let Some(c) = next {
*curve = c;
}
}
/// Preset thumbnails in a wrapping grid that scrolls with the wheel; returns the clicked one.
fn grid(app: &mut EffectcraftApp, ui: &mut egui::Ui, list: Rect, selected: Option<&str>) -> Option<(String, EaseCurve)> {
let t = app.tokens;
let p = ui.painter().with_clip_rect(list);
let scroll_id = egui::Id::new("easePresets.scroll");
let mut scroll: f32 = ui.ctx().data(|d| d.get_temp(scroll_id).unwrap_or(0.0));
if ui.rect_contains_pointer(list) {
scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0);
}
let cols = (((list.width() + GAP) / (TILE.x + GAP)).floor() as usize).max(1);
let (mut clicked, mut count) = (None, 0);
for (n, (name, curve, built_in)) in ease_presets::presets(&app.session).enumerate() {
count = n + 1;
let r = Rect::from_min_size(list.min + vec2((n % cols) as f32 * (TILE.x + GAP), (n / cols) as f32 * (TILE.y + GAP) - scroll), TILE);
if !r.intersects(list) {
continue;
}
let resp = ui.interact(r.intersect(list), egui::Id::new(("easePresets.preset", n)), Sense::click());
app.auto.add(&format!("easePresets.preset.{count}"), r, name);
let on = selected == Some(name);
p.rect_filled(
r,
4.0,
if on {
t.row_selected
} else if resp.hovered() {
t.hover
} else {
t.field_bg
},
);
let thumb = Rect::from_min_size(r.min, vec2(TILE.x, TILE.y - 16.0)).shrink2(vec2(12.0, 7.0));
let [_, y1, _, y2] = curve.handles();
let (lo, hi) = (y1.min(y2).min(0.0), y1.max(y2).max(1.0));
p.add(curve_shape(&curve, |x, v| to_screen(thumb, lo, hi, x, v), Stroke::new(1.5, if on { t.text } else { t.text_dim })));
widgets::text_fit(
&p,
pos2(r.center().x, r.max.y - 9.0),
Align2::CENTER_CENTER,
name,
Tokens::ui(11.0),
TILE.x - 6.0,
if built_in { t.text_dim } else { t.text },
);
if resp.on_hover_text(format!("{name}: click to apply it to the selected keyframes")).clicked() {
clicked = Some((name.to_string(), curve));
}
}
let content = count.div_ceil(cols) as f32 * (TILE.y + GAP);
scroll = scroll.min((content - list.height()).max(0.0));
ui.ctx().data_mut(|d| d.insert_temp(scroll_id, scroll));
clicked
}
+2
View File
@@ -7,6 +7,7 @@ pub mod content_fill_panel;
pub mod delete_items;
pub mod dialogs;
pub mod dialogs_3d;
pub mod ease_presets;
pub mod effect_controls;
pub mod effects_presets;
pub mod essential;
@@ -127,6 +128,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
PanelKind::ContentAwareFill => content_fill_panel::show(app, ui, rect),
PanelKind::CreateNullsFromPaths => path_vr_panels::create_nulls(app, ui, rect),
PanelKind::VrCompEditor => path_vr_panels::vr_editor(app, ui, rect),
PanelKind::EasePresets => ease_presets::show(app, ui, rect),
}
}
+4
View File
@@ -667,6 +667,9 @@ pub struct UiState {
/// Lumetri Scopes panel options.
#[serde(default)]
pub scopes: crate::panels::scopes_panel::ScopesState,
/// Ease Presets panel: working curve, selected preset, name field.
#[serde(default)]
pub ease_presets: crate::panels::ease_presets::EasePanelState,
}
impl Default for UiState {
@@ -721,6 +724,7 @@ impl Default for UiState {
mini_flowchart: None,
flowchart: Default::default(),
scopes: Default::default(),
ease_presets: Default::default(),
}
}
}
+13 -2
View File
@@ -193,11 +193,22 @@ pub fn text_fit(p: &egui::Painter, pos: egui::Pos2, align: Align2, text: &str, f
/// Rounded search field with a magnifier icon.
pub fn search_field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens) -> Response {
field(ui, rect, text, hint, t, true)
}
/// Rounded text field.
pub fn text_field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens) -> Response {
field(ui, rect, text, hint, t, false)
}
fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, search: bool) -> Response {
let h = rect.height();
ui.painter().rect_filled(rect, h / 2.0, t.field_bg);
ui.painter().rect_stroke(rect, h / 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(12.0, 12.0)), Icon::Search, t.text_dim);
let inner = Rect::from_min_max(pos2(rect.min.x + 22.0, rect.min.y + 1.0), pos2(rect.max.x - 8.0, rect.max.y - 1.0));
if search {
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(12.0, 12.0)), Icon::Search, t.text_dim);
}
let inner = Rect::from_min_max(pos2(rect.min.x + if search { 22.0 } else { 9.0 }, rect.min.y + 1.0), pos2(rect.max.x - 8.0, rect.max.y - 1.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner));
child.add(
egui::TextEdit::singleline(text)
+42 -2
View File
@@ -1,6 +1,7 @@
//! Headless checks for Timeline keyframes as a person uses them: dragging keys over many frames
//! in one gesture (the drag used to end after the first frame), Shift-snapping, and Ctrl+C /
//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses.
//! in one gesture (the drag used to end after the first frame), Shift-snapping, Ctrl+C /
//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses, and
//! easing selected keys from the Ease Presets panel.
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
@@ -253,3 +254,42 @@ fn k_follows_the_revealed_properties_and_auto_select_picks_the_speed_graph_for_p
h.run_steps(3);
assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.1.0")).is_some(), "value graph: X and Y");
}
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
#[test]
fn clicking_an_ease_preset_eases_the_selected_keys_and_handles_edit_the_curve() {
use effectcraft_engine::keyframe::{Ease, Interp};
use effectcraft_ui_egui::dock::PanelKind;
let (mut h, id, _) = harness();
h.state_mut().session.execute("prop.select", json!({"layer": id.0, "path": "transform/opacity"})).unwrap();
h.state_mut().show_panel(PanelKind::EasePresets);
h.state_mut().ui.maximized = Some(PanelKind::EasePresets);
h.run_steps(4);
let opacity =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().clone();
let tile = h.state().auto.query("easePresets.preset.").into_iter().find(|e| e.label == "Ease In-Out").expect("the Ease In-Out thumbnail").id.clone();
let steps = h.state().session.history.undo.len();
let at = center(&h, &tile);
click_with(&mut h, at, Default::default());
let op = opacity(&h);
let eased = Ease { speed: 0.0, influence: 0.5 };
assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease.clone()), (Interp::Bezier, vec![eased]));
assert_eq!((op.keys[1].in_interp, op.keys[1].in_ease.clone()), (Interp::Bezier, vec![eased]));
assert_eq!(h.state().session.history.undo.len(), steps + 1);
assert_eq!(h.state().ui.ease_presets.selected.as_deref(), Some("Ease In-Out"));
// Dragging the out handle to the right lengthens its influence; Apply uses the edited curve.
let graph = h.state().auto.find("easePresets.graph").unwrap().rect;
let from = center(&h, "easePresets.handle.out");
drag(&mut h, from, from + egui::vec2(graph[2] * 0.2, 0.0), Default::default());
let out = h.state().ui.ease_presets.curve.out_influence;
assert!(out > 60.0 && out < 75.0, "{out}");
let apply = center(&h, "easePresets.apply");
click_with(&mut h, apply, Default::default());
let applied = opacity(&h).keys[0].out_ease[0].influence;
assert!((applied * 100.0 - out).abs() < 1e-9, "{applied} vs {out}");
assert_eq!(h.state().session.history.undo.len(), steps + 2);
}