Timeline: Quick Offset layers and keyframes in selection order (#1012)

Ctrl/Cmd+Alt dragging a selected layer bar or a selected key now runs a
Quick Offset (layer.quickOffset / keys.quickOffset with fromStart, one
undo step). The mode is chosen when the drag starts and needs at least
two unlocked layers; the last selected layer moves by whole frames with
the pointer. A readout by the pointer shows the Total Offset and the
offset Per Layer, translated.

A plain drag still moves the selection together, Alt alone still scales
a key group in time (it now leaves Ctrl/Cmd+Alt to Quick Offset), and
Ctrl/Cmd+Alt+click still toggles Hold. The key drag's readout is drawn
by a shared drag_tip. Keys also register timeline.keyAt.<uid>.<ticks>,
exact where keys on subframes can share a frame.

Tests (ui_quick_offset): staggering layer bars in selection order and
keys per layer, one undo each; plain drags and Alt scaling unchanged; the
readout while dragging.
This commit is contained in:
tylerjenningsw
2026-10-12 17:37:34 -04:00
parent 28e9e5229e
commit 94b4d0ca34
4 changed files with 270 additions and 12 deletions
+12
View File
@@ -525,6 +525,8 @@ const JAPANESE: &[(&str, &str)] = &[
("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+ドラッグでピンの動きを記録"),
("▣ {}", "▣ {}"),
("⚠ {} is used by:", "⚠ {}は次のコマンドで使用中:"),
("Total Offset", "合計オフセット"),
("Per Layer", "レイヤーごと"),
];
const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
@@ -1060,6 +1062,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("After Effects project", "After Effects 项目"),
("Lottie animation", "Lottie 动画"),
("Project", "项目"),
("Total Offset", "总偏移"),
("Per Layer", "每个图层"),
];
const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
@@ -1590,6 +1594,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("After Effects project", "After Effects 專案"),
("Lottie animation", "Lottie 動畫"),
("Project", "專案"),
("Total Offset", "總偏移"),
("Per Layer", "每個圖層"),
];
/// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs.
@@ -2146,6 +2152,8 @@ const UKRAINIAN: &[(&str, &str)] = &[
("After Effects project", "Проєкт After Effects"),
("Lottie animation", "Анімація Lottie"),
("Project", "Проєкт"),
("Total Offset", "Загальний зсув"),
("Per Layer", "На шар"),
];
// Brazilian Portuguese.
@@ -2580,6 +2588,8 @@ const PORTUGUESE: &[(&str, &str)] = &[
("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+arraste um pino para gravar seu movimento"),
("▣ {}", "▣ {}"),
("⚠ {} is used by:", "⚠ {} é usado por:"),
("Total Offset", "Deslocamento total"),
("Per Layer", "Por camada"),
];
/// Czech (cs): original translations covering every row in the Chinese panel catalogs.
@@ -3115,6 +3125,8 @@ const CZECH: &[(&str, &str)] = &[
"Tyto sousední klíčové snímky nelze interpolovat, protože mají různý počet bodů přechodu.",
),
("Variable fonts only", "Jen proměnná písma"),
("Total Offset", "Celkový posun"),
("Per Layer", "Na vrstvu"),
];
#[cfg(test)]
+94 -11
View File
@@ -1127,18 +1127,61 @@ fn layer_drag_id() -> egui::Id {
}
/// A keyframe drag in progress: where the pointer started, the grabbed key's comp time
/// (seconds) and how many frames the selected keys have moved so far.
/// (seconds), how many frames the selected keys have moved so far and, for a Quick Offset
/// (Ctrl/Cmd+Alt), how many layers it staggers.
#[derive(Clone, Copy, Debug)]
struct KeyDrag {
origin_x: f32,
anchor: f64,
applied: i64,
quick: Option<usize>,
}
fn key_drag_id() -> egui::Id {
egui::Id::new("tl-key-drag")
}
/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): where the pointer started,
/// how many layers it staggers and how many frames the last one has moved so far.
#[derive(Clone, Copy, Debug)]
struct QuickBar {
origin_x: f32,
layers: usize,
applied: i64,
}
fn quick_bar_id(layer: LayerId) -> egui::Id {
egui::Id::new(("tl-quick-bar", layer.0))
}
/// How many layers a Quick Offset of `layers` staggers (#1012): the unlocked ones, each once, if
/// there are at least two.
fn quick_offset_layers(comp: &Comp, layers: impl IntoIterator<Item = LayerId>) -> Option<usize> {
let mut seen: Vec<LayerId> = vec![];
for id in layers {
if !seen.contains(&id) && comp.layer(id).is_some_and(|l| !l.switches.locked) {
seen.push(id);
}
}
(seen.len() >= 2).then_some(seen.len())
}
/// A Quick Offset's feedback: the last layer's move and the step from one layer to the next.
fn quick_offset_note(frames: i64, layers: usize) -> String {
let per = frames as f64 / layers.saturating_sub(1).max(1) as f64;
format!("{}: {:+} f {}: {:+.2} f", tr("Total Offset"), frames, tr("Per Layer"), per)
}
/// A Timeline drag's readout by the pointer (After Effects shows it in the Info panel).
fn drag_tip(ctx: &egui::Context, t: &Tokens, ptr: Pos2, note: String) {
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip")));
let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text);
let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0));
fg.rect_filled(r, 3.0, t.panel_bg);
fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
fg.galley(r.min + vec2(5.0, 3.0), g, t.text);
}
/// A switch pressed and dragged over other rows: the switch, the state the press gave it, and
/// the undo step the drag folds into.
#[derive(Clone, Debug)]
@@ -2126,7 +2169,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
layer_context_menu(&body, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions);
let drag_key = format!("bar-{}", layer.id.0);
// Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the
// drag starts. The last selected layer moves by whole frames with the pointer.
let mods = ui.input(|i| i.modifiers);
if body.drag_started()
&& is_sel
&& mods.command
&& mods.alt
&& let Some(n) = quick_offset_layers(&comp, selected.iter().copied())
&& let Some(p) = ctx.input(|i| i.pointer.press_origin())
{
ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), QuickBar { origin_x: p.x, layers: n, applied: 0 }));
}
let quick = ctx.data(|d| d.get_temp::<QuickBar>(quick_bar_id(layer.id)));
if let Some(mut q) = quick {
if body.dragged()
&& let Some(ptr) = ctx.input(|i| i.pointer.latest_pos())
{
let frames = (((ptr.x - q.origin_x) as f64 / pps) / fd).round() as i64;
if frames != q.applied {
q.applied = frames;
ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), q));
let merge = format!("bar-quick-{}", layer.id.0);
actions.push(("layer.quickOffset".into(), json!({"delta": frames as f64 * fd, "merge": merge, "fromStart": true})));
}
drag_tip(&ctx, &t, ptr, quick_offset_note(q.applied, q.layers));
}
if body.drag_stopped() {
ctx.data_mut(|d| d.remove::<QuickBar>(quick_bar_id(layer.id)));
ui_actions.push(UiAct::EndMerge);
}
}
for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
if kind == "move" && quick.is_some() {
continue;
}
if resp.dragged() {
let acc_id = egui::Id::new(("bar-acc", layer.id.0, kind));
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
@@ -2564,6 +2641,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag());
app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
// Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame.
app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name));
let mods = ui.input(|i| i.modifiers);
let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true});
if kresp.clicked() && mods.command {
@@ -2658,7 +2737,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
});
// Alt-drag the first or last key of a selected group: scale the group in time.
if kresp.drag_started()
&& ui.input(|i| i.modifiers.alt)
&& mods.alt
&& !mods.command
&& let Some(c) = app.session.active_comp_arc()
{
super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
@@ -2671,7 +2751,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& !super::graph_tools::alt_scale_active(&ctx)
&& let Some(p) = ctx.input(|i| i.pointer.press_origin())
{
ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0 }));
// Ctrl/Cmd+Alt: Quick Offset of the selected keys' layers (#1012).
let quick = (mods.command && mods.alt && ks).then(|| quick_offset_layers(&comp, sel_keys.iter().map(|k| k.layer))).flatten();
ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0, quick }));
}
}
}
@@ -2875,7 +2957,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let total = ((target - kd.anchor) / fd).round() as i64;
if total != kd.applied {
actions.push(("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"})));
actions.push(match kd.quick {
Some(_) => ("keys.quickOffset".into(), json!({"delta": total as f64 * fd, "merge": "key-quick-offset", "fromStart": true})),
None => ("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"})),
});
kd.applied = total;
ctx.data_mut(|d| d.insert_temp(key_drag_id(), kd));
}
@@ -2883,13 +2968,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Where the grabbed key is and how far the keys moved (After Effects shows this
// in the Info panel).
let at = Tick::from_seconds_f64(kd.anchor + kd.applied as f64 * fd);
let note = format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied);
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip")));
let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text);
let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0));
fg.rect_filled(r, 3.0, t.panel_bg);
fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
fg.galley(r.min + vec2(5.0, 3.0), g, t.text);
let note = match kd.quick {
Some(n) => quick_offset_note(kd.applied, n),
None => format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied),
};
drag_tip(&ctx, &t, ptr, note);
app.ui.status = format!("Keyframe at {}", super::timecode(&app.session, &comp, at));
}
None => {
+162
View File
@@ -0,0 +1,162 @@
//! Quick Offset in the Timeline (#1012): Ctrl/Cmd+Alt dragging selected layer bars or keys
//! staggers the layers in selection order, onto subframes, as one undo step; a plain drag still
//! moves them together and Alt alone still scales a key group.
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_engine::time::Tick;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, Pos2, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
/// One frame at 30 fps.
const F: i64 = 8_467_200_000;
/// A 4 s, 30 fps comp with three solids, each with Opacity keys at 0 s and 1 s, revealed.
fn harness() -> (Harness<'static, EffectcraftApp>, Vec<u64>) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Stagger", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let mut ids = vec![];
for i in 0..3 {
let id =
s.execute("layer.newSolid", json!({"name": format!("L{i}"), "color": "#e04020", "width": 80, "height": 80})).unwrap()["layer"].as_u64().unwrap();
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
ids.push(id);
}
s.execute("layer.select", json!({"layers": ids})).unwrap();
let mut h = Harness::builder().with_size(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.opacity", json!({})).unwrap();
h.run_steps(4);
(h, ids)
}
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)
}
/// Press at `from`, move to `to` in small steps with `modifiers` held (a real drag), release.
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
for i in 1..=24 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 24.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.step();
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default()));
h.run_steps(2);
}
fn ctrl_alt() -> Modifiers {
Modifiers { alt: true, ..Modifiers::COMMAND }
}
fn start(h: &Harness<'_, EffectcraftApp>, id: u64) -> i64 {
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().start_time.0
}
/// The comp times (ticks) of a layer's Opacity keys.
fn key_times(h: &Harness<'_, EffectcraftApp>, id: u64) -> Vec<i64> {
let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
l.props.prop("transform/opacity").unwrap().keys.iter().map(|k| l.comp_time(k.time).0).collect()
}
fn opacity_uid(h: &Harness<'_, EffectcraftApp>, id: u64) -> u64 {
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/opacity").unwrap().uid
}
#[test]
fn ctrl_alt_dragging_layer_bars_staggers_them_in_selection_order() {
let (mut h, ids) = harness();
let order = [ids[2], ids[0], ids[1]];
for (i, id) in order.iter().enumerate() {
h.state_mut().session.execute("layer.select", json!({"layers": [id], "add": i > 0})).unwrap();
}
h.run_steps(2);
let before = h.state().session.project.clone();
// Drag the middle layer's bar: the drag moves the last selected layer.
let from = center(&h, &format!("timeline.layer.{}.bar", ids[0]));
drag(&mut h, from, from + vec2(160.0, 0.0), ctrl_alt());
let d = start(&h, order[2]);
assert!(d > 0 && d % F == 0, "the last selected layer moves by whole frames: {d}");
assert_eq!([start(&h, order[0]), start(&h, order[1])], [0, d / 2], "the first stays, the middle one moves half");
assert_eq!(key_times(&h, order[1]), vec![d / 2, Tick::from_seconds_f64(1.0).0 + d / 2], "its keys come along");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(h.state().session.project.same_content(&before), "one undo step");
// A plain drag still moves every selected layer together.
h.run_steps(2);
drag(&mut h, from, from + vec2(160.0, 0.0), Modifiers::default());
let moved: Vec<i64> = order.iter().map(|id| start(&h, *id)).collect();
assert!(moved[0] > 0 && moved.iter().all(|m| *m == moved[0]), "{moved:?}");
}
#[test]
fn ctrl_alt_dragging_keys_staggers_each_layers_keys_and_alt_still_scales() {
let (mut h, ids) = harness();
// Layer 1's keys first, then layer 0's, then layer 2's.
let mut picks = vec![];
for id in [ids[1], ids[0], ids[2]] {
for t in [0.0, 1.0] {
picks.push(json!({"layer": id, "path": "transform/opacity", "time": t}));
}
}
for (i, k) in picks.iter().enumerate() {
h.state_mut().session.execute("keys.select", json!({"keys": [k], "add": i > 0})).unwrap();
}
h.run_steps(2);
let before = h.state().session.project.clone();
let one = Tick::from_seconds_f64(1.0).0;
let grab = center(&h, &format!("timeline.keyAt.{}.{one}", opacity_uid(&h, ids[0])));
drag(&mut h, grab, grab + vec2(160.0, 0.0), ctrl_alt());
let d = key_times(&h, ids[2])[0];
assert!(d > 0 && d % F == 0, "the last layer's keys move by whole frames: {d}");
assert_eq!(key_times(&h, ids[1]), vec![0, one], "the first layer's keys stay");
assert_eq!(key_times(&h, ids[0]), vec![d / 2, one + d / 2], "the middle layer's keys move half, together");
assert_eq!(key_times(&h, ids[2]), vec![d, one + d]);
let layers: Vec<u64> = h.state().session.state.selected_keys.iter().map(|k| k.layer.0).collect();
assert_eq!(layers, vec![ids[1], ids[1], ids[0], ids[0], ids[2], ids[2]], "the selection keeps its order");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(h.state().session.project.same_content(&before), "one undo step");
// Alt alone still scales a key group in time: the first key stays, the last follows.
h.state_mut().session.execute("keys.select", json!({"keys": [picks[2], picks[3]]})).unwrap();
h.run_steps(2);
let last = center(&h, &format!("timeline.keyAt.{}.{one}", opacity_uid(&h, ids[0])));
drag(&mut h, last, last + vec2(160.0, 0.0), Modifiers { alt: true, ..Default::default() });
let scaled = key_times(&h, ids[0]);
assert!(scaled[0] == 0 && scaled[1] > one, "Alt scales: {scaled:?}");
assert_eq!(key_times(&h, ids[1]), vec![0, one], "and staggers nothing");
}
#[test]
fn a_quick_offset_drag_shows_the_total_and_per_layer_offset() {
let (mut h, ids) = harness();
let from = center(&h, &format!("timeline.layer.{}.bar", ids[1]));
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(ctrl_alt()));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: ctrl_alt() });
h.step();
for i in 1..=12 {
h.input_mut().events.push(Event::PointerMoved(from + vec2(10.0 * i as f32, 0.0)));
h.step();
}
let shapes = h.output().shapes.clone();
let text: Vec<String> = shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some(t.galley.text().to_string()),
_ => None,
})
.collect();
assert!(text.iter().any(|t| t.starts_with("Total Offset: +") && t.contains("Per Layer: +")), "{text:?}");
}
+2 -1
View File
@@ -118,7 +118,8 @@ one), `panel.tab.Viewer(<n>)` and `viewers.<n>` (View ▸ New Viewer's other Com
renames it), `viewer.comp`, `viewer.handle.<layer>.<i>`,
`timeline.layer.<id>.bar`, `timeline.layer.<id>.nestedMarker.<i>` (a precomp's comp markers), `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
Enter or a double-click renames, drag reorders), `timeline.prop.<uid>.stopwatch`,
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` (an angle's
`timeline.key.<uid>.<frame>` (and `timeline.keyAt.<uid>.<ticks>`, exact in layer-time ticks, for
keys on subframes), `timeline.cti`, `effectControls.prop.<uid>.value` (an angle's
revolutions are `timeline.prop.<uid>.revolutions`, `effectControls.prop.<uid>.revolutions` and
`properties.prop.<uid>.revolutions`) and `effects.item.<name>`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`,
`viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`,