diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f31d0951..ce2c9a43 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -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)] diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f10429..802913c4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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, } 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) -> Option { + let mut seen: Vec = 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::(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::(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 => { diff --git a/crates/ui-egui/tests/ui_quick_offset.rs b/crates/ui-egui/tests/ui_quick_offset.rs new file mode 100644 index 00000000..d8e16433 --- /dev/null +++ b/crates/ui-egui/tests/ui_quick_offset.rs @@ -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) { + 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 { + 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 = 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 = 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 = 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:?}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f5487981..61f01443 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -118,7 +118,8 @@ one), `panel.tab.Viewer()` and `viewers.` (View ▸ New Viewer's other Com renames it), `viewer.comp`, `viewer.handle..`, `timeline.layer..bar`, `timeline.layer..nestedMarker.` (a precomp's comp markers), `timeline.layer..twirl`, `timeline.layer..row` (click selects, Enter or a double-click renames, drag reorders), `timeline.prop..stopwatch`, -`timeline.key..`, `timeline.cti`, `effectControls.prop..value` (an angle's +`timeline.key..` (and `timeline.keyAt..`, exact in layer-time ticks, for +keys on subframes), `timeline.cti`, `effectControls.prop..value` (an angle's revolutions are `timeline.prop..revolutions`, `effectControls.prop..revolutions` and `properties.prop..revolutions`) and `effects.item.`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`, `viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`,