diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index aa74c3f2f..a1060c85e 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -338,7 +338,8 @@ fn reveal(s: &mut Session, p: &Value) -> Result { out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u}))); } } - frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind}))?; + let layers: Vec = layers.iter().map(|l| l.0).collect(); + frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind, "layers": layers}))?; Ok(json!({"props": out})) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2b5b58509..28e28835c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -43,7 +43,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("view.theme.light", "Theme: Light", [], None), uic!("timeline.zoomIn", "Zoom In Time", [], Some("=")), uic!("timeline.zoomOut", "Zoom Out Time", [], Some("-")), - uic!("timeline.zoomFit", "Zoom to Fit Comp", [], Some(";")), + uic!("timeline.zoomFit", "Zoom to Fit Comp", [], None), + 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")), uic!("timeline.workAreaBegin", "Set Work Area Begin", [], Some("B")), @@ -156,33 +157,46 @@ pub fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64, add: bool) { _ if pressed == "masks" => "maskPath", _ => pressed, }; + let targets = reveal_targets(app); let tl = &mut app.ui.timeline; let present = tl.reveal.iter().any(|k| k == kind); - let sel: Vec = if app.session.state.selected_layers.is_empty() { + let mut kinds = tl.reveal.clone(); + if add { + if present { + kinds.retain(|k| k != kind); + } else { + kinds.retain(|k| k != "props" || !tl.reveal_props.is_empty()); + kinds.push(kind.to_string()); + } + } else if present && kinds.len() == 1 { + kinds.clear(); + } else { + kinds = vec![kind.to_string()]; + } + tl.apply_reveal(&targets, kinds); +} + +/// Show Time Remap on the layers of a `layer.enableTimeRemap` (`layers` by id, else the +/// selected ones) that have it now. +fn reveal_time_remap(app: &mut EffectcraftApp, params: &Value) { + let asked: Vec = match params.get("layers").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_u64).collect(), + None => app.session.state.selected_layers.iter().map(|l| l.0).collect(), + }; + let Some(comp) = app.session.active_comp() else { return }; + let layers: Vec = comp.layers.iter().filter(|l| asked.contains(&l.id.0) && l.props.get("timeRemap").is_some()).map(|l| l.id.0).collect(); + if !layers.is_empty() { + app.ui.timeline.apply_reveal(&layers, vec!["timeRemap".into()]); + } +} + +/// The layers a reveal shortcut acts on: the selected ones, else every layer of the active comp. +fn reveal_targets(app: &EffectcraftApp) -> Vec { + if app.session.state.selected_layers.is_empty() { app.session.active_comp().map(|c| c.layers.iter().map(|l| l.id.0).collect()).unwrap_or_default() } else { app.session.state.selected_layers.iter().map(|l| l.0).collect() - }; - if add { - if present { - tl.reveal.retain(|k| k != kind); - if tl.reveal.is_empty() { - tl.open_layers.clear(); - } - } else { - tl.reveal.retain(|k| k != "props" || !tl.reveal_props.is_empty()); - tl.reveal.push(kind.to_string()); - tl.open_layers.extend(sel); - } - return; } - if present && tl.reveal.len() == 1 { - tl.reveal.clear(); - tl.open_layers.clear(); - return; - } - tl.reveal = vec![kind.to_string()]; - tl.open_layers = sel.into_iter().collect(); } fn no_params(p: &Value) -> bool { @@ -316,8 +330,8 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return run_engine(app, ctx, "anim.reveal", json!({"kind": "modified"})); } Some((k, _)) if k == "keyframes" && app.ui.timeline.reveal == ["props"] => { - app.ui.timeline.reveal.clear(); - app.ui.timeline.open_layers.clear(); + let targets = reveal_targets(app); + app.ui.timeline.apply_reveal(&targets, vec![]); return Ok(json!({"revealed": 0})); } _ => {} @@ -427,6 +441,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return Ok(Value::Null); } "timeline.zoomFit" => app.ui.timeline.pps = None, + "timeline.zoomFrameToggle" => crate::panels::timeline::toggle_frame_zoom(app, ctx), "timeline.column" => { let col = params.get("column").and_then(Value::as_str).ok_or("timeline.column: need `column`")?; let on = params.get("visible").and_then(Value::as_bool).unwrap_or(!crate::panels::timeline::column_visible(&app.ui.timeline, col)); @@ -451,14 +466,18 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V app.ui.timeline.open_layers.clear(); app.ui.timeline.open_groups.clear(); app.ui.timeline.reveal.clear(); + app.ui.timeline.layer_reveal.clear(); } // Ctrl+`: twirl the selected layers open (all closed ones) or closed (all open). From a // reveal shortcut's view it opens their full property trees. "timeline.twirlSelected" => { let sel: Vec = app.session.state.selected_layers.iter().map(|l| l.0).collect(); let tl = &mut app.ui.timeline; - let revealing = !tl.reveal.is_empty(); + let revealing = sel.iter().any(|l| tl.layer_reveal.contains_key(l)); tl.reveal.clear(); + for l in &sel { + tl.layer_reveal.remove(l); + } if !revealing && sel.iter().all(|l| tl.open_layers.contains(l)) { for l in &sel { tl.open_layers.remove(l); @@ -538,6 +557,14 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V } return r; } + // Enabling time remapping twirls its layers open on Time Remap and its two keys. + if id == "layer.enableTimeRemap" { + let r = run_engine(app, ctx, id, params.clone()); + if r.as_ref().is_ok_and(|on| on.as_bool() == Some(true)) { + reveal_time_remap(app, ¶ms); + } + return r; + } if id == "layer.rename" && params.get("name").is_none() { crate::panels::timeline::begin_rename(app, ctx); return Ok(Value::Null); @@ -987,11 +1014,33 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } "timeline.revealProps" => { let props = p.get("props").and_then(Value::as_array).cloned().unwrap_or_default(); + let uids = |k: &str| -> Vec { props.iter().filter_map(|x| x.get(k).and_then(Value::as_u64)).collect() }; + let (found, with_props) = (uids("prop"), uids("layer")); + // The layers it looked at (`layers`), else those it found something on. Layers that + // have nothing to show close; the others' reveals stay. + let targets: Vec = match p.get("layers").and_then(Value::as_array) { + Some(l) => l.iter().filter_map(Value::as_u64).collect(), + None => with_props.clone(), + }; + // The targets' earlier picks go (U after UU shows the keyframed ones only). + let comp = app.session.active_comp_arc(); + let theirs = |u: u64| { + comp.as_ref().is_some_and(|c| { + targets + .iter() + .filter_map(|l| c.layer(effectcraft_engine::project::LayerId(*l))) + .any(|l| l.props.find(u).is_some() || l.props.find_group(u).is_some()) + }) + }; let tl = &mut app.ui.timeline; - tl.reveal_props = props.iter().filter_map(|x| x.get("prop").and_then(Value::as_u64)).collect(); - tl.open_layers = props.iter().filter_map(|x| x.get("layer").and_then(Value::as_u64)).collect(); - tl.reveal = if tl.reveal_props.is_empty() { vec![] } else { vec!["props".into()] }; - json!({"revealed": tl.reveal_props.len()}) + tl.reveal_props.retain(|u| !theirs(*u)); + tl.reveal_props.extend(found.iter().copied()); + let (shown, hidden): (Vec, Vec) = targets.into_iter().partition(|l| with_props.contains(l)); + tl.apply_reveal(&hidden, vec![]); + if !shown.is_empty() { + tl.apply_reveal(&shown, vec!["props".into()]); + } + json!({"revealed": found.len()}) } _ => return Err(format!("`{id}` is not a frontend command")), }) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 3fbea99ab..9fb77eabb 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -341,19 +341,50 @@ fn tl_map_id() -> egui::Id { egui::Id::new("timeline-map") } +/// The most a time ruler zooms in (pixels per second). +const MAX_PPS: f64 = 4000.0; + +/// Pixels per second that fit `comp` in a time graph `width` points wide. +fn fit_pps(width: f32, comp: &Comp) -> f64 { + (width - 10.0).max(1.0) as f64 / comp.duration.seconds().max(0.01) +} + +/// Zoom the time ruler to `pps` pixels per second, keeping comp time `at_t` at `at_px` pixels +/// from the ruler's start. Zooming out stops at `fit`, where the whole comp shows (and the +/// ruler keeps fitting it). +fn set_zoom(tl: &mut crate::state::TimelineState, comp: &Comp, pps: f64, fit: f64, at_t: f64, at_px: f64) { + let npps = pps.min(MAX_PPS.max(fit)).max(fit); + if npps <= fit { + tl.pps = None; + tl.start = 0.0; + return; + } + tl.start = (at_t - at_px / npps).clamp(0.0, comp.duration.seconds()); + tl.pps = Some(npps); +} + +/// `;`: from the whole comp, zoom in to frame level around the CTI; otherwise show the whole comp. +pub fn toggle_frame_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) { + let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; + let Some(comp) = app.session.active_comp_arc() else { return }; + let fit = fit_pps(w, &comp); + if app.ui.timeline.pps.is_some_and(|p| p > fit) { + set_zoom(&mut app.ui.timeline, &comp, fit, fit, 0.0, 0.0); + return; + } + let cti = app.session.time().seconds(); + set_zoom(&mut app.ui.timeline, &comp, MAX_PPS, fit, cti, w as f64 / 2.0); +} + /// Zoom the time ruler around the CTI. pub fn zoom(app: &mut EffectcraftApp, ctx: &egui::Context, k: f64) { - let Some((x0, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; - let comp = app.session.active_comp_arc(); - let Some(comp) = comp else { return }; - let fit = (w as f64 - 20.0) / comp.duration.seconds().max(0.01); + let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; + let Some(comp) = app.session.active_comp_arc() else { return }; + let fit = fit_pps(w, &comp); let pps = app.ui.timeline.pps.unwrap_or(fit); - let npps = (pps * k).clamp(fit.min(1.0), 4000.0); let cti = app.session.time().seconds(); let rel = (cti - app.ui.timeline.start) * pps; - app.ui.timeline.start = (cti - rel / npps).max(0.0); - app.ui.timeline.pps = Some(npps); - let _ = x0; + set_zoom(&mut app.ui.timeline, &comp, pps * k, fit, cti, rel); } /// Screen point of a layer bar (centre, or at a comp time). @@ -562,8 +593,8 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { if !tl.open_layers.contains(&l.id.0) { continue; } - if !tl.reveal.is_empty() { - reveal_rows(app, l, &mut rows); + if let Some(kinds) = tl.layer_reveal.get(&l.id.0).filter(|k| !k.is_empty()) { + reveal_rows(app, l, kinds, &mut rows); continue; } for c in &l.props.children { @@ -612,9 +643,9 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { /// Rows of a twirled-open layer under the reveal shortcuts (one, or several added with Shift): /// masks, effects, the revealed properties in property-tree order, the Reveal Properties /// selection, then the waveform. -fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec) { +fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec) { let tl = &app.ui.timeline; - let has = |k: &str| tl.reveal.iter().any(|r| r == k); + let has = |k: &str| kinds.iter().any(|r| r == k); let group_rows = |rows: &mut Vec, g: &PropGroup, fx: bool| { for g in g.groups() { rows.push(Row { @@ -668,7 +699,7 @@ fn reveal_rows(app: &EffectcraftApp, l: &Layer, rows: &mut Vec) { } // P/S/R/T/A/F/L/animated: matching properties, in tree order, each once. let mut wanted = std::collections::BTreeSet::new(); - for kind in &tl.reveal { + for kind in kinds { let Some((group, ids)) = reveal_targets(kind) else { continue }; let root = match group { "" => Some(&l.props), @@ -850,7 +881,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let graph_x0 = rect.min.x + left_w + 1.0; let graph_x1 = rect.max.x - 10.0; ctx.data_mut(|d| d.insert_temp(egui::Id::new("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0))); - let fit_pps = (graph_x1 - graph_x0 - 10.0) as f64 / comp.duration.seconds().max(0.01); + let fit_pps = fit_pps(graph_x1 - graph_x0, &comp); let pps = app.ui.timeline.pps.unwrap_or(fit_pps); if app.ui.timeline.pps.is_none() { app.ui.timeline.start = 0.0; @@ -935,6 +966,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for x in [nav_vis.min.x, nav_vis.max.x] { p.rect_filled(Rect::from_min_max(pos2(x - 3.0, nav.min.y - 1.0), pos2(x + 3.0, nav.max.y + 1.0)), 3.0, t.accent); } + // The middle scrolls at the current zoom; each end is a handle that moves that side of the + // visible span (pulled in: zoomed in; stretched to the whole track: the whole comp). let nresp = ui.interact(nav, egui::Id::new("tl-nav"), Sense::drag()); app.auto.add("timeline.navigator", nav, "Time navigator"); if nresp.dragged() { @@ -944,6 +977,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.timeline.pps = Some(pps); } } + let span_px = (graph_x1 - tm.x0) as f64; + let (t0, t1) = (tm.start, tm.t(graph_x1)); + for (end, x) in [("start", nav_vis.min.x), ("end", nav_vis.max.x)] { + let hr = Rect::from_center_size(pos2(x, nav.center().y), vec2(10.0, nav.height() + 6.0)); + let hresp = ui.interact(hr, egui::Id::new(("tl-nav", end)), Sense::drag()); + app.auto.add(&format!("timeline.navigator.{end}"), hr, if end == "start" { "Time navigator start" } else { "Time navigator end" }); + if hresp.hovered() || hresp.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + if let Some(px) = hresp.interact_pointer_pos().filter(|_| hresp.dragged()) { + let t = ((px.x - nav.min.x) / nav.width()) as f64 * comp.duration.seconds(); + // The other end stays; the span is at least what the most zoomed-in ruler shows. + let min_span = span_px / MAX_PPS; + let (keep_t, keep_px, span) = if end == "start" { (t1, span_px, t1 - t.min(t1 - min_span)) } else { (t0, 0.0, t.max(t0 + min_span) - t0) }; + set_zoom(&mut app.ui.timeline, &comp, span_px / span.max(min_span), fit_pps, keep_t, keep_px); + } + } p.rect_filled(ruler, 0.0, t.tl_ruler_bg); app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps)); // Work area bar: below the ruler, beside the column headers (After Effects' layout), with @@ -1184,7 +1234,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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)); if zoom && dy.abs() > 0.0 { - zoom_at(app, &comp, tm, (dy as f64 / 200.0).exp(), ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0)); + // Alt+wheel zooms around the pointer, out until the whole comp shows. + let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0); + set_zoom(&mut app.ui.timeline, &comp, tm.pps * (dy as f64 / 200.0).exp(), fit_pps, tm.t(at_x), (at_x - tm.x0) as f64); } else { let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0); let shift = ui.input(|i| i.modifiers.shift); @@ -2286,7 +2338,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { icons::paint(&p, Rect::from_center_size(pos2(zs.min.x - 14.0, footer.center().y), vec2(14.0, 14.0)), Icon::MountainSmall, t.text_dim); icons::paint(&p, Rect::from_center_size(pos2(zs.max.x + 14.0, footer.center().y), vec2(16.0, 16.0)), Icon::MountainLarge, t.text_dim); p.rect_filled(zs, 2.0, t.field_bg); - let maxpps = 4000.0f64; + let maxpps = MAX_PPS; let frac = ((pps / fit_pps).ln() / (maxpps / fit_pps).ln()).clamp(0.0, 1.0) as f32; let knob = pos2(zs.min.x + zs.width() * frac, zs.center().y); p.circle_filled(knob, 6.0, t.text); @@ -2366,7 +2418,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { match a { UiAct::ToggleLayer(id, all) => { let tl = &mut app.ui.timeline; + // Twirled by hand, the layer shows its whole tree again. tl.reveal.clear(); + tl.layer_reveal.remove(&id); let open = !tl.open_layers.contains(&id); if open { tl.open_layers.insert(id); @@ -2642,14 +2696,6 @@ fn kind_name(k: MatteKind) -> &'static str { } } -fn zoom_at(app: &mut EffectcraftApp, comp: &Comp, tm: TMap, k: f64, at_x: f32) { - let fit = tm.pps / app.ui.timeline.pps.map(|p| p / tm.pps).unwrap_or(1.0); - let npps = (tm.pps * k).clamp(fit.min(5.0), 4000.0); - let at_t = tm.t(at_x); - app.ui.timeline.start = (at_t - (at_x - tm.x0) as f64 / npps).clamp(0.0, comp.duration.seconds()); - app.ui.timeline.pps = Some(npps); -} - fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(String, serde_json::Value)>) { resp.context_menu(|ui| layer_menu(ui, &[layer.id.0], actions)); } @@ -2969,6 +3015,73 @@ mod tests { assert!(app.ui.timeline.reveal_props.len() > n_keys, "UU reveals modified properties too"); } + /// U and the other reveal shortcuts act on the selected layers only: other layers keep their + /// twirl state and their own reveal (#160). + #[test] + fn reveal_shortcuts_leave_unselected_layers_alone() { + let mut app = app(); + let ctx = egui::Context::default(); + let comp = app.session.active_comp().unwrap().clone(); + let id = |n: &str| comp.layers.iter().find(|l| l.name == n).unwrap().id.0; + let (plate, bx) = (id("Plate"), id("Box")); + let select = |app: &mut EffectcraftApp, l: u64| app.session.execute("layer.select", json!({"layers": [l]})).unwrap(); + // Plate shows Scale (S); then U on Box. + select(&mut app, plate); + crate::menus::invoke(&mut app, &ctx, "timeline.reveal.scale", json!({})).unwrap(); + select(&mut app, bx); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + let tl = &app.ui.timeline; + assert!(tl.open_layers.contains(&plate) && tl.open_layers.contains(&bx), "{:?}", tl.open_layers); + assert_eq!(tl.layer_reveal.get(&plate), Some(&vec!["scale".to_string()]), "Plate keeps its reveal"); + assert_eq!(tl.layer_reveal.get(&bx), Some(&vec!["props".to_string()])); + let rows = labels(&app); + let at = |n: &str| rows.iter().position(|r| r == n).unwrap(); + assert_eq!(rows[at("Plate") + 1].trim(), "Scale", "{rows:?}"); + assert_eq!(rows[at("Box") + 1].trim(), "Position", "{rows:?}"); + // UU (modified), then U later: Box shows its keyframed properties only again. + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + assert!(labels(&app).iter().any(|r| r.trim() == ">Gaussian Blur"), "UU shows the effect"); + app.last_reveal = Some(("keyframes".into(), -10.0)); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + app.last_reveal = Some(("keyframes".into(), -10.0)); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + let rows = labels(&app); + assert!(!rows.iter().any(|r| r.trim() == ">Gaussian Blur") && rows.iter().any(|r| r.trim() == "Position"), "{rows:?}"); + // U again on Box (later than a double press) hides only Box's. + app.last_reveal = Some(("keyframes".into(), -10.0)); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + let tl = &app.ui.timeline; + assert!(!tl.open_layers.contains(&bx) && tl.open_layers.contains(&plate), "{:?}", tl.open_layers); + // A layer twirled open by hand shows its whole tree, whatever was revealed elsewhere. + app.session.execute("layer.select", json!({"layers": []})).unwrap(); + app.ui.timeline.open_layers.insert(bx); + let rows = labels(&app); + assert!(rows.iter().any(|r| r.trim() == ">Transform"), "{rows:?}"); + } + + /// Enabling time remapping twirls the layer open on Time Remap (#157); disabling it reveals + /// nothing. + #[test] + fn enabling_time_remapping_reveals_time_remap() { + let mut s = effectcraft_engine::Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Other"})).unwrap(); + let nested = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "layer.enableTimeRemap", json!({"layers": [nested]})).unwrap(); + assert_eq!(app.ui.timeline.layer_reveal.get(&nested), Some(&vec!["timeRemap".to_string()])); + let rows = labels(&app); + let at = rows.iter().position(|r| r == "Inner").unwrap(); + assert_eq!(rows[at + 1].trim(), "Time Remap", "{rows:?}"); + // Through the selection (the Layer menu), turning it off again: nothing new is revealed. + app.session.execute("layer.select", json!({"layers": [nested]})).unwrap(); + app.ui.timeline.layer_reveal.clear(); + crate::menus::invoke(&mut app, &ctx, "layer.enableTimeRemap", json!({})).unwrap(); + assert!(app.ui.timeline.layer_reveal.is_empty()); + } + #[test] fn cti_snaps_to_the_nearest_candidate() { assert_eq!(snap_time(&[0.0, 1.0, 2.0], 1.04, 0.1), 1.0); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index be8ef3853..041ce5931 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -344,6 +344,23 @@ impl Default for ViewerState { } } +impl TimelineState { + /// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last + /// reveal. Other layers keep theirs. + pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec) { + for id in layers { + if kinds.is_empty() { + self.open_layers.remove(id); + self.layer_reveal.remove(id); + } else { + self.open_layers.insert(*id); + self.layer_reveal.insert(*id, kinds.clone()); + } + } + self.reveal = kinds; + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TimelineState { /// Visible span start (seconds) and pixels per second; None = fit comp. @@ -361,8 +378,13 @@ pub struct TimelineState { /// Twirled-open layers and groups (by layer id / group uid). pub open_layers: BTreeSet, pub open_groups: BTreeSet, - /// "Reveal" filter: only show these property match ids (P/S/R/T/A…) — empty = normal. + /// The last reveal shortcut's filter (P/S/R/T/A…): what pressing it again toggles or adds to. pub reveal: Vec, + /// Each revealed layer's filter: an open layer without one shows its whole property tree. A + /// reveal shortcut sets it on the selected layers (all layers with none selected) and leaves + /// the others as they are. + #[serde(default)] + pub layer_reveal: BTreeMap>, /// Properties / groups (uids) shown by the `props` reveal (Animation ▸ Reveal Properties…). #[serde(default)] pub reveal_props: BTreeSet, @@ -426,6 +448,7 @@ impl Default for TimelineState { open_layers: BTreeSet::new(), open_groups: BTreeSet::new(), reveal: vec![], + layer_reveal: BTreeMap::new(), reveal_props: BTreeSet::new(), graph_mode: auto_graph(), graph_show_selected: true, diff --git a/crates/ui-egui/tests/ui_angles.rs b/crates/ui-egui/tests/ui_angles.rs index 3be2ee5ed..7d5a888f7 100644 --- a/crates/ui-egui/tests/ui_angles.rs +++ b/crates/ui-egui/tests/ui_angles.rs @@ -47,7 +47,7 @@ fn harness(panel: Panel, angle: f64) -> (Harness<'static, EffectcraftApp>, u64, let mut app = EffectcraftApp::new(s); app.show_panel(panel.kind()); app.toggle_maximize(panel.kind()); - app.ui.timeline.reveal = vec!["rotation".into()]; + app.ui.timeline.layer_reveal.insert(layer, vec!["rotation".into()]); app.ui.timeline.open_layers.insert(layer); let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); h.run_steps(4); diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs new file mode 100644 index 000000000..460375a59 --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -0,0 +1,126 @@ +//! Zooming the Timeline's time ruler with real input: Alt+wheel zooms out until the whole comp +//! shows (#158); the Time Navigator's ends drag to zoom and `;` toggles frame level / the whole +//! comp (#159). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, MouseWheelUnit, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// A 10-minute comp with one solid, zoomed in on its start. +fn harness() -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Long", "width": 320, "height": 180, "frameRate": 30, "duration": 600})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let mut app = EffectcraftApp::new(s); + app.ui.timeline.pps = Some(200.0); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(3); + (h, layer) +} + +/// A point over the layer's bar in the time graph. +fn over_bar(h: &Harness<'_, EffectcraftApp>, layer: u64) -> Pos2 { + let e = h.state().auto.find(&format!("timeline.layer.{layer}.bar")).expect("layer bar").clone(); + pos2(e.rect[0] + 40.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn alt_wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) { + h.event(Event::PointerMoved(at)); + h.event(Event::ModifiersChanged(Modifiers::ALT)); + h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, dy), modifiers: Modifiers::ALT, phase: egui::TouchPhase::Move }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); +} + +/// #158: on a long comp, Alt+wheel zooms out all the way to the whole comp (and no further). +#[test] +fn alt_wheel_zooms_out_until_the_whole_comp_shows() { + let (mut h, layer) = harness(); + for _ in 0..80 { + let at = over_bar(&h, layer); + alt_wheel(&mut h, at, -120.0); + if h.state().ui.timeline.pps.is_none() { + break; + } + } + let tl = &h.state().ui.timeline; + assert_eq!((tl.pps, tl.start), (None, 0.0), "fits the whole comp: {:?}", tl.pps); + // The bar spans (nearly) the whole time graph. + let bar = h.state().auto.find(&format!("timeline.layer.{layer}.bar")).unwrap().rect; + let ruler = h.state().auto.find("timeline.ruler").unwrap().rect; + assert!(bar[2] > ruler[2] * 0.95, "bar {bar:?} ruler {ruler:?}"); + // Zooming in again works from there. + let at = over_bar(&h, layer); + alt_wheel(&mut h, at, 240.0); + assert!(h.state().ui.timeline.pps.is_some()); +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// The visible span (seconds) from the ruler's automation label ("start,pps") and width. +fn visible(h: &Harness<'_, EffectcraftApp>) -> (f64, f64) { + let e = h.state().auto.find("timeline.ruler").unwrap().clone(); + let (start, pps) = e.label.split_once(',').map(|(a, b)| (a.parse::().unwrap(), b.parse::().unwrap())).unwrap(); + (start, start + (e.rect[2] - 16.0) as f64 / pps) +} + +/// #159: dragging the navigator's end handles changes that side of the visible span; pulling +/// them out to the track's ends shows the whole comp. +#[test] +fn navigator_ends_drag_to_zoom() { + let (mut h, _) = harness(); + h.state_mut().ui.timeline.pps = None; + h.run_steps(3); + let track = rect(&h, "timeline.navigator"); + // The end handle pulled to the middle: the span ends near half the comp, starts at 0. + let end = rect(&h, "timeline.navigator.end").center(); + drag(&mut h, end, pos2(track.center().x, end.y)); + let (a, b) = visible(&h); + assert!(a.abs() < 1e-6 && (b - 300.0).abs() < 15.0, "{a}..{b}"); + // The start handle pulled to a quarter: the span starts there, its end stays. + let start = rect(&h, "timeline.navigator.start").center(); + drag(&mut h, start, pos2(track.min.x + track.width() * 0.25, start.y)); + let (a2, b2) = visible(&h); + assert!((a2 - 150.0).abs() < 15.0 && (b2 - b).abs() < 2.0, "{a2}..{b2} (end was {b})"); + // Both ends back out to the track's ends: the whole comp. + let start = rect(&h, "timeline.navigator.start").center(); + drag(&mut h, start, pos2(track.min.x - 20.0, start.y)); + let end = rect(&h, "timeline.navigator.end").center(); + drag(&mut h, end, pos2(track.max.x + 20.0, end.y)); + assert_eq!(h.state().ui.timeline.pps, None, "the whole comp"); +} + +/// #159: `;` zooms in to frame level around the current time, and back out to the whole comp. +#[test] +fn semicolon_toggles_frame_level_and_the_whole_comp() { + let (mut h, _) = harness(); + h.state_mut().ui.timeline.pps = None; + h.state_mut().session.execute("time.set", json!({"time": 120.0})).unwrap(); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFrameToggle", json!({})).unwrap(); + h.run_steps(2); + let (a, b) = visible(&h); + assert!(a < 120.0 && 120.0 < b && b - a < 1.0, "frame level around the CTI: {a}..{b}"); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFrameToggle", json!({})).unwrap(); + assert_eq!(h.state().ui.timeline.pps, None); +}