Merge pull request #774 from vangel76/gap-proportional-scrubbing

#770: Proportional Scrubbing of Timeline values (Ctrl+Alt / Cmd+Opt)

# Conflicts:
#	docs/ui-parity.md
This commit is contained in:
Brandon Thomas committed 2026-10-12 03:20:51 -04:00
commit be1d9928e0
7 files changed
+166 -16

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ pub mod panels_cmds;
pub(crate) mod path_nulls;
mod paths;
mod project_items;
mod prop;
pub(crate) mod prop;
mod prop_groups;
mod proxy;
pub mod puppet;
+47 -10
View File
@@ -230,8 +230,20 @@ fn json_numbers(v: &Value) -> Option<Vec<f64>> {
}
}
/// The share of a Proportional Scrubbing change (After Effects 26.2, Ctrl+Alt / Cmd+Opt scrub)
/// that the layer at `index` of `count` selected layers (in selection order) takes: the first
/// none, the last all of it, the ones between even steps.
pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
match count.checked_sub(1) {
Some(last) if last > 0 => index.min(last) as f64 / last as f64,
_ => 1.0,
}
}
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
/// `offset` (a scrub) the same change from each layer's own value. One undo step (with `merge`,
/// `offset` (a scrub) the same change from each layer's own value. With `offset` and
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
/// one that a continuing scrub keeps folding into).
fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
let twins = selected_twins(s, p, "prop.set")?;
@@ -244,16 +256,41 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
(Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::<Vec<f64>>()),
_ => None,
};
// Proportional Scrubbing: the edited layer and its twins in selection order.
let order: Option<Vec<LayerId>> = (delta.is_some() && b_p(p, "proportional") == Some(true))
.then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect());
let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len())));
let scalar = p.get("value").is_some_and(Value::is_number);
// `p` aimed at (layer, prop) with its value moved by `k` × the change.
let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| {
if let Some(d) = &delta
&& let Some(c) = current_components(s, cid, olid, ouid)
{
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + k * d.get(i).copied().unwrap_or(0.0)).collect();
q["value"] = if scalar { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
}
};
let run = |s: &mut Session| -> Result<Value> {
let out = set(s, p)?;
for (olid, ouid) in twins {
let mut q = twin_params(p, olid, ouid);
if let Some(d) = &delta
&& let Some(c) = current_components(s, cid, olid, ouid)
{
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + d.get(i).copied().unwrap_or(0.0)).collect();
q["value"] = if p.get("value").is_some_and(Value::is_number) { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
let out = if order.is_some() {
let k = share(lid);
if k <= 0.0 {
// (the first selected layer keeps its value: no key is added to it)
current_components(s, cid, lid, uid).map_or(Value::Null, |c| if scalar { json!(c.first().copied().unwrap_or(0.0)) } else { json!(c) })
} else {
let mut q = p.clone();
shifted(s, &mut q, lid, uid, k);
set(s, &q)?
}
} else {
set(s, p)?
};
for (olid, ouid) in twins {
let k = share(olid);
if k <= 0.0 {
continue;
}
let mut q = twin_params(p, olid, ouid);
shifted(s, &mut q, olid, ouid, k);
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
let _ = set(s, &q);
}
@@ -1140,7 +1177,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Property Value",
[],
None,
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change)}",
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}",
has_layers,
set_selected
),
+63
View File
@@ -883,3 +883,66 @@ fn property_edits_apply_to_every_selected_layer() {
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap();
assert_eq!(prop(&s, b, "transform/rotation").value.as_f64(), 0.0);
}
/// #770: Proportional Scrubbing (After Effects 26.2, Ctrl+Alt / Cmd+Opt scrub). The change is
/// spread over the selected layers by selection order (not stacking order): the first keeps its
/// value, the last takes all of it, the one between half; whichever layer is scrubbed. One undo
/// step; a keyframed property gets a key at the current time, the unchanged first layer none.
#[test]
fn proportional_scrub_spreads_the_change_by_selection_order() {
let (mut s, a) = setup();
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": b, "path": "transform/rotation", "value": 20})).unwrap();
// Selection order c, a, b.
s.execute("layer.select", json!({"layers": [c]})).unwrap();
s.execute("layer.select", json!({"layers": [a], "add": true})).unwrap();
s.execute("layer.select", json!({"layers": [b], "toggle": true})).unwrap();
let rot = |s: &Session, l: u64| prop(s, l, "transform/rotation").value.as_f64();
let uid = prop(&s, a, "transform/rotation").uid;
// Scrub layer a (the middle one) by +10 a frame, three frames: the UI sends a's shown value
// plus the drag.
let undo = s.history.undo.len();
for _ in 0..3 {
let v = rot(&s, a) + 10.0;
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v, "selected": true, "offset": true, "proportional": true, "merge": "scrub-r"}))
.unwrap();
}
assert_eq!(rot(&s, c), 0.0, "the first selected layer keeps its value");
assert_eq!(rot(&s, a), 15.0, "the middle one moves by half");
assert_eq!(rot(&s, b), 50.0, "the last one moves by the whole change");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step for the scrub");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!((rot(&s, c), rot(&s, a), rot(&s, b)), (0.0, 0.0, 20.0));
// Vectors and keyframes: Position animated on every layer, scrubbed on the last layer at 1 s.
for l in [a, b, c] {
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [100, 100], "time": 0.0})).unwrap();
s.execute("prop.toggleAnimation", json!({"layer": l, "path": "transform/position"})).unwrap();
}
s.execute("time.set", json!({"time": 1.0})).unwrap();
let pos_uid = prop(&s, b, "transform/position").uid;
s.execute("prop.set", json!({"layer": b, "prop": pos_uid, "value": [140, 80], "selected": true, "offset": true, "proportional": true})).unwrap();
let at1 = |s: &Session, l: u64| {
let p = prop(s, l, "transform/position");
(p.keys.len(), p.value_at(effectcraft_time::Tick::from_seconds_f64(1.0)).components()[..2].to_vec())
};
assert_eq!(at1(&s, c), (1, vec![100.0, 100.0]), "no key on the first selected layer");
assert_eq!(at1(&s, a), (2, vec![120.0, 90.0]));
assert_eq!(at1(&s, b), (2, vec![140.0, 80.0]));
// Without `proportional` a scrub still moves them all by the same change.
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": 5, "selected": true, "offset": true})).unwrap();
assert_eq!((rot(&s, c), rot(&s, a), rot(&s, b)), (5.0, 5.0, 25.0));
}
#[test]
fn proportional_share_is_linear_in_selection_order() {
use crate::commands::prop::proportional_share;
assert_eq!((0..5).map(|i| proportional_share(i, 5)).collect::<Vec<_>>(), [0.0, 0.25, 0.5, 0.75, 1.0]);
// One layer (or a bad count) takes the whole change; an index past the end the last share.
assert_eq!(proportional_share(0, 1), 1.0);
assert_eq!(proportional_share(0, 0), 1.0);
assert_eq!(proportional_share(usize::MAX, 3), 1.0);
}
+9 -3
View File
@@ -3546,8 +3546,14 @@ fn value_editor(
// With several layers selected the edit applies to all of them (#491): a scrub by the same
// change, a typed value as it is.
let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging());
// Proportional Scrubbing (After Effects 26.2): Ctrl+Alt (Cmd+Opt) spreads the change over the
// selected layers by selection order, the first unchanged and the last by all of it.
let proportional = scrub && ui.input(|i| widgets::proportional_scrub(i.modifiers));
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, "selected": true, "offset": scrub})))
actions.push((
"prop.set".into(),
json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub, "proportional": proportional}),
))
};
let is_3d = layer.is_3d();
let mut x = at.x;
@@ -3602,8 +3608,8 @@ fn value_editor(
dims.push(r);
if let Some(nv) = nv {
let mut nc = c.clone();
// Alt edits one value of a linked pair.
let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 };
// Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub).
let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 };
let from = if shown == 1 { d } else { 0 };
for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
if let Some(slot) = nc.get_mut(from + e) {
+8 -1
View File
@@ -121,7 +121,7 @@ pub fn hot_number_at(
let mult = ui.input(|i| {
if i.modifiers.shift {
10.0
} else if i.modifiers.command {
} else if i.modifiers.command && !proportional_scrub(i.modifiers) {
0.1
} else {
1.0
@@ -138,6 +138,13 @@ pub fn hot_number_at(
(rect, out, resp.drag_stopped())
}
/// Ctrl+Alt (Cmd+Opt on macOS) held while scrubbing hot text: After Effects' Proportional
/// Scrubbing, which spreads the change over the selected layers by selection order. Ctrl alone
/// still scrubs finely and Shift by tens.
pub fn proportional_scrub(m: egui::Modifiers) -> bool {
m.command && m.alt
}
/// [`hot_number_at`] for a whole number (an angle's revolutions): a drag steps it once every
/// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames),
/// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished).
+37
View File
@@ -398,3 +398,40 @@ fn properties_panel_edits_every_selected_layer() {
assert!((a - b - d).abs() < 1e-6, "same change on every layer: {before:?} → {after:?}");
}
}
/// #770: Proportional Scrubbing (After Effects 26.2). With three layers selected, a Ctrl+Alt
/// (Cmd+Opt) scrub of Position X in the Timeline leaves the first selected layer where it is,
/// moves the last by the whole change and the middle one by half, whichever layer is scrubbed.
#[test]
fn ctrl_alt_scrub_in_the_timeline_is_proportional() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Three", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let mut ids = vec![];
for c in ["#e04020", "#20e040", "#2040e0"] {
let l = s.execute("layer.newSolid", json!({"color": c, "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [100.0, 90.0]})).unwrap();
ids.push(l);
}
// Selection order: the first created, then the second, then the third.
for (i, l) in ids.iter().enumerate() {
s.execute("layer.select", json!({"layers": [l], "add": i > 0})).unwrap();
}
let middle = ids[1];
let uid = s.active_comp().unwrap().layer(LayerId(middle)).unwrap().props.prop("transform/position").unwrap().uid;
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.position", json!({})).unwrap();
h.run_steps(4);
let x = |h: &Harness<'_, EffectcraftApp>, l: u64| {
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().value.components()[0]
};
let e = h.state().auto.find(&format!("timeline.prop.{uid}.value.0")).cloned().expect("the middle layer's Position X shows");
let from = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
let ctrl_alt = egui::Modifiers { alt: true, ctrl: true, command: true, ..Default::default() };
drag(&mut h, from, from + egui::vec2(40.0, 0.0), ctrl_alt);
let moved: Vec<f64> = ids.iter().map(|l| x(&h, *l) - 100.0).collect();
assert!(moved[2] > 1.0, "the last selected layer moved: {moved:?}");
assert!(moved[0].abs() < 1e-9, "the first selected layer stays: {moved:?}");
assert!((moved[1] - moved[2] / 2.0).abs() < 1e-6, "the middle one moves by half: {moved:?}");
}
+1 -1
View File
@@ -17,7 +17,7 @@ feel and platform differences, and the 26.x interface changes are not in yet.
|---|---|---:|---|
| Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) |
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; dropping a panel on a stacked panel doesn't make a tab (#302); stacked-panel headers (#301); saved workspaces don't persist (#320); no panels on a second monitor (#486) |
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; Graph Editor jitter and hidden buttons (#456, #457); rotation can't go negative by dragging (#569); bulk keyframing (#355); expand precomps in place (#601); Proportional Scrubbing (26.2) missing |
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; Graph Editor jitter and hidden buttons (#456, #457); rotation can't go negative by dragging (#569); bulk keyframing (#355); expand precomps in place (#601); Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties |
| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu |
| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; pen and vertex tools on motion paths (#553) |
| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; Linux multi-layer edits (#491) |