From 6b8200ff786f9c68b3b01811b74bc10140b17b80 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:02:01 +0700 Subject: [PATCH 1/5] Timeline: reveal shortcuts leave unselected layers alone (#160) U (and P, S, R, T, A, E, M, F, L) kept one reveal filter for the whole Timeline and replaced the set of open layers with the selected ones, so revealing on one layer closed every other layer and dropped their reveals. Each layer now has its own filter (TimelineState::layer_reveal; an open layer without one shows its whole tree). A shortcut applies to the selected layers (all layers with none selected) through apply_reveal and leaves the others as they are; `reveal` stays the last shortcut's filter for toggling and Shift-adding. U's results replace only the target layers' earlier picks, so U after UU shows the keyframed properties again. anim.reveal tells the Timeline which layers it looked at, so a layer with nothing keyframed closes. Twirling a layer by hand, Ctrl+` and Collapse All clear the filters they cover. Test: reveal_shortcuts_leave_unselected_layers_alone. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/animation.rs | 3 +- crates/ui-egui/src/menus.rs | 81 ++++++++++++++++--------- crates/ui-egui/src/panels/timeline.rs | 56 +++++++++++++++-- crates/ui-egui/src/state.rs | 25 +++++++- 4 files changed, 130 insertions(+), 35 deletions(-) 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..175a1b8f7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -156,33 +156,32 @@ 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); +} + +/// 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 +315,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})); } _ => {} @@ -451,14 +450,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); @@ -987,11 +990,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..99d336d61 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -562,8 +562,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 +612,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 +668,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), @@ -2366,7 +2366,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); @@ -2969,6 +2971,50 @@ 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:?}"); + } + #[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, From 76761bd2ef4a3c64f6c4c7fe6a770908e6d1971d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:04:12 +0700 Subject: [PATCH 2/5] Timeline: enabling time remapping reveals Time Remap (#157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer ▸ Time ▸ Enable Time Remapping (and the layer menu's entry) added the property and its two keyframes but left the layer twirled closed, so nothing visibly changed. When the command turns remapping on, the layers that have it now twirl open on Time Remap, through the same per-layer reveal as RR. Turning it off reveals nothing. Test: enabling_time_remapping_reveals_time_remap. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 23 +++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 175a1b8f7..d94586bfa 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -175,6 +175,20 @@ pub fn reveal(app: &mut EffectcraftApp, kind: &str, now: f64, add: bool) { 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() { @@ -541,6 +555,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); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 99d336d61..48fc72ec1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3015,6 +3015,29 @@ mod tests { 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); From f936b1ff9cb5e4df4231dfb4c6afeac24c39a22e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:09:18 +0700 Subject: [PATCH 3/5] Timeline: Alt+wheel zooms out to the whole comp (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alt+wheel's zoom took its lower limit from the current zoom rather than the comp's fit and kept the ruler's start where the pointer had put it, so on long comps it stopped zoomed out on a slice from the middle of the comp (0:05:02–0:10:27 of a 10-minute comp). The = / - zoom used a different limit again. Both now go through set_zoom: the limit is the zoom that fits the comp (fit_pps, the same formula the ruler and the zoom slider use), and reaching it switches the ruler back to fitting the whole comp from 0. Test: alt_wheel_zooms_out_until_the_whole_comp_shows (real Alt+wheel events on a 10-minute comp); it failed before (Some(1.135) px/s, start still offset). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 50 ++++++++++++-------- crates/ui-egui/tests/ui_timeline_zoom.rs | 58 ++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/tests/ui_timeline_zoom.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 48fc72ec1..ae1f7bbd5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -341,19 +341,37 @@ 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); +} + /// 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). @@ -850,7 +868,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; @@ -1184,7 +1202,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 +2306,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); @@ -2644,14 +2664,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)); } 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..9a7d3897f --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -0,0 +1,58 @@ +//! Zooming the Timeline's time ruler with real input: Alt+wheel zooms out until the whole comp +//! shows (#158). + +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()); +} From bfd1e974ea426d286dfaf5edd76fdd0b054a1025 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:13:26 +0700 Subject: [PATCH 4/5] Timeline: the Time Navigator's ends drag to zoom; ; toggles frame level (#159) The navigator drew handles on both ends but the whole bar was one drag area that scrolled. Each end is now a handle (timeline.navigator.start / .end) that moves that side of the visible span while the other stays: pulled in, the ruler zooms in; stretched to the track's ends, it shows the whole comp again. The middle still scrolls at the current zoom. ; (timeline.zoomFrameToggle) zooms in to frame level around the current time and, pressed again, back out to the whole comp. timeline.zoomFit stays as the plain fit command (no shortcut). Both go through set_zoom. Tests: navigator_ends_drag_to_zoom and semicolon_toggles_frame_level_and_the_whole_comp (real pointer drags). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/panels/timeline.rs | 32 +++++++++++ crates/ui-egui/tests/ui_timeline_zoom.rs | 70 +++++++++++++++++++++++- 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d94586bfa..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")), @@ -440,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)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ae1f7bbd5..9fb77eabb 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -363,6 +363,19 @@ fn set_zoom(tl: &mut crate::state::TimelineState, comp: &Comp, pps: f64, fit: f6 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((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; @@ -953,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() { @@ -962,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 diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 9a7d3897f..460375a59 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -1,5 +1,6 @@ //! Zooming the Timeline's time ruler with real input: Alt+wheel zooms out until the whole comp -//! shows (#158). +//! 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; @@ -56,3 +57,70 @@ fn alt_wheel_zooms_out_until_the_whole_comp_shows() { 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); +} From 72e70f53605c56ac36da6f1ba0ce5fdcb2f4f7f7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:21:08 +0700 Subject: [PATCH 5/5] Tests: ui_angles reveals Rotation through the per-layer filter (#160) Its setup wrote the Timeline's old global reveal field, which no longer filters on its own since reveals are kept per layer. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/ui_angles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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);