Timeline: the pick whip picks the dimension it is dropped on (#363)

Dropping the expression pick whip on one of a property's values (the X
of Position) referenced the whole property. As in After Effects, a
drop on one value now picks that dimension: `transform.position[0]`;
a drop on the name still picks the whole property.

`prop.pickWhip` takes `target.dimension`.

The rest of #363 is unchanged: inserting at the cursor landed with
#284, and After Effects' property pick whip in the Parent & Link
column is left for later.

Tests: pick_whip_picks_one_dimension_of_the_target (engine),
the_expression_pick_whip_picks_the_dimension_it_is_dropped_on
(ui_inline_edit).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-09 22:52:56 +07:00
1 parent 639342a5e7
commit 6fadae2818
6 files changed
+82 -5

No files matched your search

+7 -1
View File
@@ -164,6 +164,12 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
let (dm, dt) = (dims(from, me), dims(target, them));
// Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects.
let (r, dt) = match t.get("dimension").and_then(Value::as_u64) {
Some(d) if d < dt as u64 => (format!("{r}[{d}]"), 1),
Some(d) => return Err(bad("prop.pickWhip", format!("the target has {dt} dimension(s), no dimension {d}"))),
None => (r, dt),
};
// While the expression is being edited the reference goes in at the cursor, replacing the
// selected text (After Effects): `expression` is the text being edited, `range` the
// selection [start, end] in characters.
@@ -200,7 +206,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pick Whip (Link Property)",
[],
None,
"{layer?, path|prop, target: {layer, path|prop}, expression?, range?: [start, end]} → sets an AE reference expression (with `expression`: inserts it there, replacing the characters in `range`, default the end)",
"{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)",
has_layers,
pick_whip
),
+17
View File
@@ -482,6 +482,23 @@ fn pick_whip_generates_ae_reference_expressions() {
assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err());
}
/// #363: dropped on one of the target's values the pick whip picks that dimension
/// (`position[0]`), as in After Effects; a multi-dimension property follows it on every axis.
#[test]
fn pick_whip_picks_one_dimension_of_the_target() {
let (mut s, l) = setup();
let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
let whip = |s: &mut Session, path: &str, d: u64| {
s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": {"layer": o, "path": "transform/position", "dimension": d}}))
};
let r = "thisComp.layer(\"Other\").transform.position";
assert_eq!(whip(&mut s, "transform/rotation", 0).unwrap(), json!(format!("{r}[0]")));
assert_eq!(whip(&mut s, "transform/scale", 1).unwrap(), json!(format!("temp = {r}[1];\n[temp, temp]")));
assert_eq!(prop(&s, l, "transform/scale").expr.unwrap().text, format!("temp = {r}[1];\n[temp, temp]"));
// A 2D layer's Position has two.
assert!(whip(&mut s, "transform/rotation", 2).is_err());
}
/// #284: with the expression being edited, the pick whip puts the reference at the cursor,
/// replacing the selected characters (After Effects), in one undo step.
#[test]
+16 -3
View File
@@ -1599,6 +1599,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let outline_empty = ui.interact(outline_rect, egui::Id::new("tl-layer-bg"), Sense::click_and_drag());
app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
let mut hit_rows: Vec<(Rect, Row)> = vec![];
// The value fields of multi-dimension properties (per dimension), for the pick whip.
let mut dim_hits: Vec<(u64, Vec<Rect>)> = vec![];
for (ri, row) in rows.iter().enumerate() {
// Outline widgets never spill into the time graph (narrow timelines).
ui.set_clip_rect(left_clip);
@@ -2335,7 +2337,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Value editors.
let value = ectx.value(layer, prop);
let vx = (cw.switches + 4.0).max(name_x + 120.0);
value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions);
let dims = value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions);
if !dims.is_empty() {
dim_hits.push((*uid, dims));
}
ui.set_clip_rect(right_clip);
// Expression text row hint.
if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) {
@@ -2567,6 +2572,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
(1, Some((_, Row { layer, kind: RowKind::Prop { uid }, .. }))) if uid != src_prop => {
let mut params = json!({"layer": src_layer, "prop": src_prop, "target": {"layer": layer.0, "prop": uid}});
// On one of its values: that dimension (`position[0]`).
if let Some(d) = dim_hits.iter().find(|(u, _)| *u == uid).and_then(|(_, rs)| rs.iter().position(|r| r.contains(ptr))) {
params["target"]["dimension"] = json!(d);
}
if let Some((text, [a, b])) = into {
// Into the expression being edited, which goes on being edited.
params["expression"] = json!(text);
@@ -3301,7 +3310,8 @@ fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
.collect()
}
/// Inline value editor for a property row; pushes `prop.set` actions.
/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a
/// multi-dimension value's fields, one per dimension (none for other values).
fn value_editor(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
@@ -3311,9 +3321,10 @@ fn value_editor(
value: &Value,
at: Pos2,
actions: &mut Vec<(String, serde_json::Value)>,
) {
) -> Vec<Rect> {
let t = app.tokens;
let uid = prop.uid;
let mut dims = vec![];
let merge = format!("scrub-{uid}");
let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| {
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge})))
@@ -3368,6 +3379,7 @@ fn value_editor(
let suffix = if pct && d + 1 == n { "%" } else { "" };
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t);
app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
dims.push(r);
if let Some(nv) = nv {
let mut nc = c.clone();
// Alt edits one value of a linked pair.
@@ -3450,6 +3462,7 @@ fn value_editor(
p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::<String>(), Tokens::ui(12.0), t.text_dim);
}
}
dims
}
#[cfg(test)]
+38
View File
@@ -128,6 +128,44 @@ fn the_expression_pick_whip_inserts_at_the_cursor_while_editing() {
assert_eq!(expr(&h), "transform.rotation");
}
/// #363: dropped on one of a property's values the expression pick whip picks that dimension
/// (`position[1]`), as in After Effects; on its name, the whole property.
#[test]
fn the_expression_pick_whip_picks_the_dimension_it_is_dropped_on() {
let (mut h, _) = harness();
let (layer, opacity, position, transform) = {
let s = &mut h.state_mut().session;
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap();
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone();
(l, tr.get("opacity").unwrap().uid, tr.get("position").unwrap().uid, tr.uid)
};
h.state_mut().ui.timeline.open_layers.insert(layer);
h.state_mut().ui.timeline.open_groups.insert(transform);
h.run_steps(3);
let expr = |h: &Harness<'_, EffectcraftApp>| {
let comp = h.state().session.active_comp().unwrap();
comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(opacity).unwrap().expr.as_ref().unwrap().text.clone()
};
let whip_to = |h: &mut Harness<'_, EffectcraftApp>, target: &str| {
let (from, to) = (center(h, &format!("timeline.prop.{opacity}.pickWhip")), center(h, target));
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..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
};
whip_to(&mut h, &format!("timeline.prop.{position}.value.1"));
assert_eq!(expr(&h), "transform.position[1]");
whip_to(&mut h, &format!("timeline.prop.{position}.name"));
assert_eq!(expr(&h), "transform.position[0]", "the whole Position into one value: its first, as before");
}
/// #362: an expression pick whip held near the bottom or top of the Timeline's layers scrolls
/// them (After Effects), so a property out of view can be picked.
#[test]
+3
View File
@@ -484,6 +484,9 @@ Extension points and the bundled extensions: [plugins.md](plugins.md).
click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly.
* Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time
Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`.
* `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its
expression pick whip does; `dimension` picks one of the target's values (`position[0]`), as a
drop on that value field does.
### History, puppet recording, plug-ins
+1 -1
View File
@@ -166,7 +166,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: concave / self-intersecting `onDraw` fills, real images in `drawImage`, image controls and icon buttons landed in M13.11; ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) |
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
| Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates; nested comp markers on the precomp layer bar (Preserve frame rate / resolution when nested and nested comps showing nothing past their end landed in M4.9–M4.10; Pre-compose keeping the comp's settings and New Comp from Selection's dialog in M3.12; CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: the expression pick whip scrolls the Timeline when held at its top or bottom, #362; an angle's revolutions scrub and take typing in Effect Controls, the Timeline and Properties, #93; puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: the expression pick whip scrolls the Timeline when held at its top or bottom, #362; dropped on one of a property's values it picks that dimension (`position[0]`), #363; an angle's revolutions scrub and take typing in Effect Controls, the Timeline and Properties, #93; puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
| Text | ≈ 96% | 0.3 | no extruded strokes (G2: the font menus list every installed family with its own styles — before, only a project asking for a font read the system fonts — and `text.fonts` / MCP `list_fonts` list them for agents; M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) |
| 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |