M5.6: inline expression editor, expression pick-whip, parent pick-whip

- Properties with an expression get an editor row in the timeline: Expression label,
  enable (=) switch, pick whip, syntax-error warning, and a multiline code editor in the
  time-graph area (commits on blur or Cmd/Ctrl+Enter via prop.setExpression).
- Expression pick whip: drag onto another property row -> prop.pickWhip (AE reference
  expression). Parent pick whip in Parent & Link: drag onto a layer -> layer.setParent.
- Alt-click on a stopwatch starts with the property's own reference (transform.opacity).
- Timeline rows have variable heights; the empty-graph box-select area sits under the rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 21:58:42 -04:00
parent 1cba07bdfe
commit 941e087446
3 changed files with 181 additions and 21 deletions

View File

@ -148,6 +148,8 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
if error.is_some() {
enabled = Some(false);
}
// Alt-click on a stopwatch starts with the property's own reference (`transform.opacity`).
let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid))).unwrap_or_else(|| "value".into());
let r = with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| {
match (&text, enabled) {
(Some(t), _) if t.trim().is_empty() => pr.expr = None,
@ -156,11 +158,11 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(x) = &mut pr.expr {
x.enabled = e;
} else if e {
pr.expr = Some(effectcraft_project::Expression { text: default_expr(pr), enabled: true });
pr.expr = Some(effectcraft_project::Expression { text: def.clone(), enabled: true });
}
}
(None, None) => {
pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: default_expr(pr), enabled: true }) };
pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: def.clone(), enabled: true }) };
}
}
Ok(json!(pr.expr.as_ref().map(|e| e.text.clone())))
@ -172,11 +174,6 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
fn default_expr(pr: &Property) -> String {
let _ = pr;
"value".to_string()
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.reset")?;
let def = p.get("default").cloned();

View File

@ -240,6 +240,20 @@ fn time_reverse_keyframes() {
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].value, KV::Scalar(0.0)));
}
#[test]
fn alt_click_stopwatch_toggles_a_self_reference_expression() {
let (mut s, l) = setup();
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity"})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").expr.unwrap().text, "transform.opacity");
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "enabled": false})).unwrap();
assert!(!prop(&s, l, "transform/opacity").expr.unwrap().enabled);
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "enabled": true})).unwrap();
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity"})).unwrap();
assert!(prop(&s, l, "transform/opacity").expr.is_none());
s.execute("edit.undo", json!({})).unwrap();
assert!(prop(&s, l, "transform/opacity").expr.unwrap().enabled);
}
#[test]
fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() {
let mut s = Session::default();

View File

@ -21,8 +21,54 @@ use crate::{EffectcraftApp, widgets};
#[derive(Clone, Debug)]
enum RowKind {
Layer,
Group { uid: u64, name: String, open: bool, has_children: bool, fx: Option<bool> },
Prop { uid: u64 },
Group {
uid: u64,
name: String,
open: bool,
has_children: bool,
fx: Option<bool>,
},
Prop {
uid: u64,
},
/// Inline expression editor under a property with an expression.
Expr {
uid: u64,
lines: usize,
},
}
/// Row height (expression editors grow with their text).
fn row_height(row: &Row, rh: f32) -> f32 {
match row.kind {
RowKind::Expr { lines, .. } => rh * lines.clamp(1, 8) as f32 + 6.0,
_ => rh,
}
}
/// Insert an expression editor row after every property that has an expression.
fn with_expr_rows(rows: Vec<Row>, comp: &Comp, closed: &std::collections::BTreeSet<u64>) -> Vec<Row> {
let mut out = Vec::with_capacity(rows.len());
for r in rows {
let extra = match r.kind {
RowKind::Prop { uid } if !closed.contains(&uid) => comp.layer(r.layer).and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| Row {
layer: r.layer,
depth: r.depth,
kind: RowKind::Expr { uid, lines: e.text.lines().count().max(1) },
}),
_ => None,
};
out.push(r);
out.extend(extra);
}
out
}
/// A pick-whip drag in progress: (parent = 0 / property = 1, layer, prop uid, start point).
type PickWhip = (u8, u64, u64, Pos2);
fn pick_whip_id() -> egui::Id {
egui::Id::new("tl-pickwhip")
}
#[derive(Clone, Debug)]
@ -536,9 +582,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let _ = cw.end;
// ---- rows.
let rows = build_rows(app, &comp);
let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline.expr_closed);
let rh = t.row_h;
let total_h = rows.len() as f32 * rh;
let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum();
let max_scroll = (total_h - rows_rect.height() + rh).max(0.0);
if ui.rect_contains_pointer(rows_rect) {
let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt));
@ -567,18 +613,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let full_clip = ui.clip_rect();
let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y)));
let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
// Registered before the rows so keys, bars and editors on top of it get the pointer first.
let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
let empty = ui.interact(empty_rect, egui::Id::new("tl-graph-bg"), Sense::click_and_drag());
let mut hit_rows: Vec<(Rect, Row)> = vec![];
for (ri, row) in rows.iter().enumerate() {
// Outline widgets never spill into the time graph (narrow timelines).
ui.set_clip_rect(left_clip);
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), rh));
y += rh;
let hh = row_height(row, rh);
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), hh));
y += hh;
if r.max.y < rows_rect.min.y || r.min.y > rows_rect.max.y {
continue;
}
hit_rows.push((r, row.clone()));
let Some(layer) = comp.layer(row.layer) else { continue };
let is_sel = selected.contains(&layer.id);
let left = Rect::from_min_max(r.min, pos2(graph_x0 - 1.0, r.max.y));
let cy = r.center().y;
let cy = r.min.y + rh / 2.0;
match &row.kind {
RowKind::Layer => {
lp.rect_filled(
@ -760,7 +812,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
// Parent.
let pr_rect = Rect::from_min_size(pos2(cw.parent + 2.0, cy - 9.0), vec2(110.0, 18.0));
let pw_rect = Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0));
let pw = ui.interact(pw_rect, egui::Id::new(("parent-whip", layer.id.0)), Sense::drag()).on_hover_text("Parent pick whip: drag onto a layer");
icons::paint(&lp, pw_rect.shrink(1.0), Icon::PickWhip, if pw.hovered() || pw.dragged() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.layer.{}.pickWhip", layer.id.0), pw_rect, "Parent pick whip");
if pw.drag_started() {
let pw_state: PickWhip = (0, layer.id.0, 0, pw_rect.center());
ctx.data_mut(|d| d.insert_temp(pick_whip_id(), pw_state));
}
let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0));
let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", idx_of(l.id), l.name)).unwrap_or_else(|| "None".into());
let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::new(("parent", layer.id.0)));
app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent");
@ -895,6 +955,75 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui_actions.push(UiAct::ToggleGroup(*uid));
}
}
RowKind::Expr { uid, lines } => {
let Some(prop) = layer.props.find(*uid) else { continue };
let Some(ex) = prop.expr.clone() else { continue };
lp.rect_filled(left, 0.0, t.row_alt);
let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, "Expression:", Tokens::ui(11.5), t.text_dim);
lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text);
// "=" enable switch and the property pick whip (AE's expression controls).
let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0));
let en = ui.interact(en_r, egui::Id::new(("expr-en", uid)), Sense::click()).on_hover_text("Enable Expression");
lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
let expr_col = Color32::from_rgb(0xe8, 0x7c, 0x5c);
lp.text(
en_r.center(),
Align2::CENTER_CENTER,
if ex.enabled { "=" } else { "≠" },
Tokens::semibold(13.0),
if ex.enabled { expr_col } else { t.text_dim },
);
app.auto.add(&format!("timeline.prop.{uid}.exprEnable"), en_r, "Enable Expression");
if en.clicked() {
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "enabled": !ex.enabled})));
}
let pw_rect = Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0));
let pw = ui.interact(pw_rect, egui::Id::new(("expr-whip", uid)), Sense::drag()).on_hover_text("Expression pick whip: drag onto a property");
icons::paint(&lp, pw_rect.shrink(1.0), Icon::PickWhip, if pw.hovered() || pw.dragged() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.prop.{uid}.pickWhip"), pw_rect, "Expression pick whip");
if pw.drag_started() {
let pw_state: PickWhip = (1, layer.id.0, *uid, pw_rect.center());
ctx.data_mut(|d| d.insert_temp(pick_whip_id(), pw_state));
}
// Syntax errors: warning in the outline (the expression is disabled).
if let Some(check) = app.session.expr_check
&& let Err(e) = check(&ex.text)
{
let wr = Rect::from_center_size(pos2(cw.switches + 50.0, cy), vec2(14.0, 14.0));
lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
let _ = ui.interact(wr, egui::Id::new(("expr-err", uid)), Sense::hover()).on_hover_text(e);
}
// The editor in the time-graph area.
ui.set_clip_rect(right_clip);
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a));
let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0));
let buf_id = egui::Id::new(("expr-buf", uid));
let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er));
let resp = child.add(
egui::TextEdit::multiline(&mut buf)
.id(egui::Id::new(("expr-edit", uid)))
.font(Tokens::mono(11.5))
.text_color(if ex.enabled { expr_col } else { t.text_dim })
.desired_width(er.width())
.desired_rows((*lines).clamp(1, 8))
.frame(egui::Frame::NONE),
);
app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl));
if commit {
resp.surrender_focus();
}
if resp.has_focus() && !commit {
ctx.data_mut(|d| d.insert_temp(buf_id, buf));
} else {
if (resp.lost_focus() || commit) && buf.trim_end() != ex.text {
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": buf.trim_end()})));
}
ctx.data_mut(|d| d.remove::<String>(buf_id));
}
}
RowKind::Prop { uid } => {
let Some(prop) = layer.props.find(*uid) else { continue };
let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
@ -983,7 +1112,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions);
ui.set_clip_rect(right_clip);
// Expression text row hint.
if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled) {
if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) {
gp.text(
pos2(graph_x0 + 8.0, cy),
Align2::LEFT_CENTER,
@ -1078,13 +1207,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
ui.set_clip_rect(full_clip);
// Pick whips: a line follows the pointer; releasing over a row links to it.
if let Some((kind, src_layer, src_prop, start)) = ctx.data(|d| d.get_temp::<PickWhip>(pick_whip_id())) {
let ptr = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(start);
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("tl-pickwhip-line")));
fg.line_segment([start, ptr], Stroke::new(1.5, t.accent));
fg.circle_stroke(ptr, 4.0, Stroke::new(1.5, t.accent));
let target = hit_rows.iter().find(|(r, _)| r.contains(ptr)).map(|(r, row)| (*r, row.clone()));
if let Some((tr, _)) = &target {
fg.rect_stroke(Rect::from_min_max(tr.min, pos2(graph_x0 - 1.0, tr.max.y)), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
}
if ctx.input(|i| !i.pointer.any_down()) {
ctx.data_mut(|d| d.remove::<PickWhip>(pick_whip_id()));
match (kind, target) {
(0, Some((_, row))) if row.layer.0 != src_layer => {
actions.push(("layer.setParent".into(), json!({"layers": [src_layer], "parent": row.layer.0})));
}
(1, Some((_, Row { layer, kind: RowKind::Prop { uid }, .. }))) if uid != src_prop => {
actions.push(("prop.pickWhip".into(), json!({"layer": src_layer, "prop": src_prop, "target": {"layer": layer.0, "prop": uid}})));
}
_ => {}
}
}
}
// Graph editor.
if graph_on {
super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
}
// Empty-area click in the graph: deselect keys; drag: box-select keys.
let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
let empty = ui.interact(empty_rect, egui::Id::new("tl-graph-bg"), Sense::click_and_drag());
if empty.clicked() {
actions.push(("keys.select".into(), json!({"keys": []})));
}
@ -1104,7 +1254,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut yy = rows_rect.min.y - app.ui.timeline.scroll_y;
for row in &rows {
let cy = yy + rh / 2.0;
yy += rh;
yy += row_height(row, rh);
if let RowKind::Prop { uid } = row.kind
&& cy >= br.min.y
&& cy <= br.max.y
@ -1180,8 +1330,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
if ctx.input(|i| i.pointer.any_released()) {
let hover_y = ctx.input(|i| i.pointer.hover_pos()).map(|p| p.y).unwrap_or(0.0);
let row_i = ((hover_y - rows_rect.min.y + app.ui.timeline.scroll_y) / rh).floor() as usize;
let target = rows.get(row_i).map(|r| r.layer);
let target = hit_rows.iter().find(|(r, _)| hover_y >= r.min.y && hover_y < r.max.y).map(|(_, row)| row.layer);
match payload.as_ref() {
crate::panels::DragPayload::Item(id) => actions.push(("layer.addItem".into(), json!({"item": id}))),
crate::panels::DragPayload::Effect(e) => {