diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 77750b24..397543ce 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -53,6 +53,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("timeline.zoomIn", "Zoom In Time", [], Some("=")), uic!("timeline.zoomOut", "Zoom Out Time", [], Some("-")), uic!("timeline.zoomFit", "Zoom to Fit Comp", [], None), + uic!("timeline.scrollToTime", "Scroll to Current Time", [], Some("D")), + uic!("timeline.scrollToLayer", "Scroll Topmost Selected Layer to Top", [], Some("X")), + uic!("timeline.parentColumn", "Show/Hide Parent Column", [], Some("Shift+F4")), uic!("timeline.zoomFrameToggle", "Zoom In to Frame Level / Out to the Whole Comp", [], Some(";")), uic!("timeline.graphEditor", "Graph Editor", [], Some("Shift+F3")), uic!("timeline.switchesModes", "Toggle Switches / Modes", [], Some("F4")), @@ -565,6 +568,13 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V app.ui.viewer.pan = [0.0, 0.0]; return Ok(Value::Null); } + "timeline.scrollToTime" => app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Time), + "timeline.scrollToLayer" => app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Layer), + "timeline.parentColumn" => { + let on = !crate::panels::timeline::column_visible(&app.ui.timeline, "parent"); + crate::panels::timeline::set_column(&mut app.ui.timeline, "parent", on)?; + return Ok(json!({"parent": on})); + } "view.actualSize" if app.ui.focused == PanelKind::Layer => { app.ui.layer_zoom = Some(1.0 / ctx.pixels_per_point()); app.ui.layer_pan = [0.0, 0.0]; @@ -2140,11 +2150,32 @@ mod tests { ("layer.mask.selectNext", "Alt+`"), ("layer.mask.selectPrevious", "Alt+Shift+`"), ("layer.castsShadows", "Alt+Shift+C"), + ("timeline.scrollToTime", "D"), + ("timeline.scrollToLayer", "X"), + ("timeline.parentColumn", "Shift+F4"), ] { assert!(bindings(&app.session).into_iter().any(|x| x.2 == id && Some((x.0, x.1)) == parse_shortcut(keys)), "{id} on {keys}"); } } + #[test] + fn timeline_keys_scroll_and_toggle_the_parent_column() { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let run = |app: &mut EffectcraftApp, id: &str| invoke(app, &ctx, id, json!({})).unwrap(); + // D and X leave a scroll for the Timeline to do when it is drawn. + run(&mut app, "timeline.scrollToTime"); + assert_eq!(app.ui.timeline.scroll_to, Some(crate::state::ScrollTo::Time)); + run(&mut app, "timeline.scrollToLayer"); + assert_eq!(app.ui.timeline.scroll_to, Some(crate::state::ScrollTo::Layer)); + // Shift+F4 shows and hides the Parent column. + let parent = crate::panels::timeline::column_visible(&app.ui.timeline, "parent"); + run(&mut app, "timeline.parentColumn"); + assert_eq!(crate::panels::timeline::column_visible(&app.ui.timeline, "parent"), !parent); + run(&mut app, "timeline.parentColumn"); + assert_eq!(crate::panels::timeline::column_visible(&app.ui.timeline, "parent"), parent); + } + #[test] fn dispatch_uses_the_active_custom_preset() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f10429..e9a92698 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -230,6 +230,14 @@ fn page_to_cti(start: f64, span: f64, duration: f64, cti: f64) -> f64 { cti.min(duration - span).max(0.0) } +/// The visible span's start (seconds) that puts `cti` in its middle, kept inside the comp. +fn centered_on(span: f64, duration: f64, cti: f64) -> f64 { + if !(span.is_finite() && duration.is_finite() && cti.is_finite()) { + return 0.0; + } + (cti - span / 2.0).min(duration - span).max(0.0) +} + /// The text of an expression being edited when its pick whip was pressed, and the selection in /// it (characters): the pick whip's reference goes in there. type WhipInto = (String, [usize; 2]); @@ -1302,6 +1310,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0); app.ui.timeline.start = page_to_cti(app.ui.timeline.start, span, comp.duration.seconds(), app.session.time().seconds()); } + let scroll_to = app.ui.timeline.scroll_to.take(); + if scroll_to == Some(crate::state::ScrollTo::Time) && app.ui.timeline.pps.is_some() { + let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0); + app.ui.timeline.start = centered_on(span, comp.duration.seconds(), app.session.time().seconds()); + } let tm = TMap { x0: graph_x0 + 6.0, start: app.ui.timeline.start, pps }; ctx.data_mut(|d| d.insert_temp(tl_map_id(), (tm.x0, tm.start, tm.pps))); let top = rect.min.y; @@ -1709,6 +1722,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let rh = t.row_h; 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 scroll_to == Some(crate::state::ScrollTo::Layer) { + // The topmost selected layer, in stack order. + let sel = &app.session.state.selected_layers; + let top = comp.layers.iter().find(|l| sel.contains(&l.id)).map(|l| l.id); + if let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Layer) && Some(r.layer) == top) { + app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum(); + } + } 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)); // Ctrl/Cmd+wheel and a trackpad pinch reach egui as a zoom factor rather than a scroll @@ -3984,6 +4005,17 @@ mod tests { assert!(app.ui.timeline.reveal_props.len() > n_keys, "UU reveals modified properties too"); } + #[test] + fn scroll_to_time_centres_the_current_time_inside_the_comp() { + assert_eq!(centered_on(4.0, 10.0, 5.0), 3.0); + // Near the ends the view stops at the comp's start and end. + assert_eq!(centered_on(4.0, 10.0, 1.0), 0.0); + assert_eq!(centered_on(4.0, 10.0, 9.5), 6.0); + // A span longer than the comp, and bad numbers, start at zero. + assert_eq!(centered_on(20.0, 10.0, 5.0), 0.0); + assert_eq!(centered_on(f64::NAN, 10.0, 5.0), 0.0); + } + /// U and the other reveal shortcuts act on the selected layers only: other layers keep their /// twirl state and their own reveal (#160). #[test] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d0a23279..b0e901ee 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -317,6 +317,15 @@ impl TimelineState { } } +/// Where a Timeline shortcut scrolls to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ScrollTo { + /// D: the current time, centred in the time graph. + Time, + /// X: the topmost selected layer, at the top of the layers. + Layer, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TimelineState { /// Visible span start (seconds) and pixels per second; None = fit comp. @@ -385,6 +394,9 @@ pub struct TimelineState { /// The name column shows Source Name instead of Layer Name (click its header). #[serde(default)] pub source_name: bool, + /// A scroll asked for by a shortcut (D, X), done when the Timeline is next drawn. + #[serde(skip)] + pub scroll_to: Option, } pub fn default_tl_columns() -> BTreeSet { @@ -426,6 +438,7 @@ impl Default for TimelineState { unlinked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, + scroll_to: None, } } }