Merge pull request #181 from storytold/fix/157-160-timeline

Timeline: per-layer reveals, Time Remap reveal, full zoom-out, navigator handles (#157–#160)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-07 13:30:32 +07:00
commit 6cac35a144
6 files changed
+368 -56

No files matched your search

+2 -1
View File
@@ -338,7 +338,8 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
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<u64> = layers.iter().map(|l| l.0).collect();
frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind, "layers": layers}))?;
Ok(json!({"props": out}))
}
+78 -29
View File
@@ -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<u64> = 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<u64> = 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<u64> = 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<u64> {
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<u64> = 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, &params);
}
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<u64> { 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<u64> = 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<u64>, Vec<u64>) = 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")),
})
+137 -24
View File
@@ -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<Row> {
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<Row> {
/// 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<Row>) {
fn reveal_rows(app: &EffectcraftApp, l: &Layer, kinds: &[String], rows: &mut Vec<Row>) {
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<Row>, 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<Row>) {
}
// 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);
+24 -1
View File
@@ -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<String>) {
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<u64>,
pub open_groups: BTreeSet<u64>,
/// "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<String>,
/// 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<u64, Vec<String>>,
/// Properties / groups (uids) shown by the `props` reveal (Animation ▸ Reveal Properties…).
#[serde(default)]
pub reveal_props: BTreeSet<u64>,
@@ -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,
+1 -1
View File
@@ -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);
+126
View File
@@ -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::<f64>().unwrap(), b.parse::<f64>().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);
}