From b03af6a9798fc50e96672130c5bb4dd524c69130 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:48:33 -0400 Subject: [PATCH] M13.5: timeline/project column scrolling, keyframe label groups Interface: Timeline outline columns wider than the pane scroll horizontally (Shift+wheel/trackpad, scroll bar above the footer; headers clipped); the Project panel's optional columns scroll under the frozen Name/Label columns. ANM-1: keyframe colour labels (keys.setLabel, Label submenu, tinted key icons) and Edit > Select Keyframe Label Group on selected/all layers and visible keyframes (keys.selectLabelGroup replaces the stub). Alt-drag time scaling of key groups already existed. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/key_labels.rs | 85 ++++++++++++++++++++++++ crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/stubs.rs | 1 - crates/engine/src/tests_tail.rs | 40 +++++++++++ crates/keyframe/src/lib.rs | 8 +++ crates/ui-egui/src/panels/project.rs | 42 +++++++++++- crates/ui-egui/src/panels/timeline.rs | 73 +++++++++++++++++--- crates/ui-egui/src/state.rs | 8 +++ crates/ui-egui/tests/ui_tail.rs | 79 ++++++++++++++++++++++ 9 files changed, 327 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/commands/key_labels.rs create mode 100644 crates/ui-egui/tests/ui_tail.rs diff --git a/crates/engine/src/commands/key_labels.rs b/crates/engine/src/commands/key_labels.rs new file mode 100644 index 00000000..4fc9e964 --- /dev/null +++ b/crates/engine/src/commands/key_labels.rs @@ -0,0 +1,85 @@ +//! Keyframe color labels: Label a keyframe (Keyframe ▸ Label ▸ colour) and Edit ▸ Select Keyframe +//! Label Group (On Selected Layers / On All Layers / Visible Keyframes on …). + +use effectcraft_color::Label; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, has_comp, has_keys, str_p}; +use crate::{EngineError, KeyRef, Result, Session, cmd}; + +fn label_p(p: &Value, c: &str) -> Result { + match p.get("label") { + Some(Value::String(s)) => { + let l = if s.eq_ignore_ascii_case("none") { Some(Label::None) } else { Label::from_name(s) }; + let l = l.ok_or_else(|| bad(c, format!("unknown label `{s}`")))?; + Ok(Label::ALL.iter().position(|x| *x == l).unwrap_or(0) as u8) + } + Some(Value::Number(n)) => Ok(n.as_u64().filter(|i| (*i as usize) < Label::ALL.len()).ok_or_else(|| bad(c, "label index 0–16"))? as u8), + _ => Err(bad(c, "missing `label` (name or 0–16)")), + } +} + +fn set_label(s: &mut Session, p: &Value) -> Result { + let c = "keys.setLabel"; + let label = label_p(p, c)?; + let n = super::prop::edit_keys(s, "Keyframe Label", None, |keys, i, _| { + keys[i].label = label; + true + })?; + Ok(json!({"label": Label::ALL[label as usize].name(), "keys": n})) +} + +/// Select every keyframe that shares a label with a selected keyframe, on the selected or all +/// layers. `visible` (property uids revealed in the Timeline) limits the "Visible Keyframes" +/// scopes; without it every property counts as visible. +fn select_label_group(s: &mut Session, p: &Value) -> Result { + let c = "keys.selectLabelGroup"; + let scope = str_p(p, "scope").unwrap_or("selected"); + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let labels: Vec = + s.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer)?.props.find(k.prop)?.keys.iter().find(|x| x.time == k.time).map(|x| x.label)).collect(); + if labels.is_empty() { + return Err(bad(c, "select a keyframe first")); + } + let only_selected = match scope { + "selected" | "visibleSelected" => true, + "all" | "visibleAll" => false, + _ => return Err(bad(c, "scope: selected|all|visibleSelected|visibleAll")), + }; + let visible: Option> = + (scope.starts_with("visible")).then(|| p.get("visible").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect())).flatten(); + let key_layers: Vec<_> = s.state.selected_keys.iter().map(|k| k.layer).collect(); + let mut sel = Vec::new(); + for l in &comp.layers { + if only_selected && !s.state.selected_layers.contains(&l.id) && !key_layers.contains(&l.id) { + continue; + } + l.props.walk("", &mut |_, pr| { + if visible.as_ref().is_some_and(|v| !v.contains(&pr.uid)) { + return; + } + for k in &pr.keys { + if labels.contains(&k.label) { + sel.push(KeyRef { layer: l.id, prop: pr.uid, time: k.time }); + } + } + }); + } + s.state.selected_keys = sel; + Ok(json!({"keys": s.state.selected_keys.len()})) +} + +pub fn specs() -> Vec { + vec![ + cmd!("keys.setLabel", "Keyframe Label", [], None, "{label: name|none|0–16}", has_keys, set_label), + cmd!( + "keys.selectLabelGroup", + "Select Keyframe Label Group", + [], + None, + "{scope: selected|all|visibleSelected|visibleAll, visible?: [prop uid]}", + has_comp, + select_label_group + ), + ] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index ca16122a..930a794b 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -13,6 +13,7 @@ mod file_more; mod focus; mod frontend; mod help; +mod key_labels; mod key_transform; mod layer; mod layer_menu; @@ -131,6 +132,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(viewer_cmds::specs()); v.extend(shape_stroke::specs()); v.extend(focus::specs()); + v.extend(key_labels::specs()); v.extend(key_transform::specs()); v.extend(file_more::specs()); v.extend(lottie::specs()); diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 6ae25090..49160762 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -23,7 +23,6 @@ pub fn specs() -> Vec { stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"), stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"), stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"), - stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"), // Layer ▸ New (3D / content-aware fill). stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"), // Mask options not in the mask model yet; pen / vertex editing (timeline milestone). diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index 9b3eb407..7da7319f 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -220,6 +220,46 @@ fn adaptive_sample_limit_drives_layer_samples() { assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128); } +#[test] +fn keyframe_label_groups() { + let mut s = comp(200, 100); + let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap(); + for l in [a, b] { + for t in [0.0, 0.5, 1.0] { + s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t})).unwrap(); + } + } + let uid = |s: &Session, l: u64| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid; + let (ua, ub) = (uid(&s, a), uid(&s, b)); + // Label a's first key and b's last key Blue. + s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}, {"layer": b, "prop": ub, "time": 1.0}]})).unwrap(); + s.execute("keys.setLabel", json!({"label": "Blue"})).unwrap(); + s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.5}]})).unwrap(); + s.execute("keys.setLabel", json!({"label": "Green"})).unwrap(); + // On selected layers: from a's first key, only a's Blue key. + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap(); + assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap()["keys"], json!(1)); + // On all layers: both Blue keys. + assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(2)); + assert!(s.state.selected_keys.iter().any(|k| k.layer == LayerId(b) && (k.time.seconds() - 1.0).abs() < 1e-6)); + // Visible keyframes: only the revealed properties count. + s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap(); + assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "visible": [ua]})).unwrap()["keys"], json!(1)); + // Unlabelled keys group together too (label None). + s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 1.0}]})).unwrap(); + assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(3)); + // Undo, serde. + let lab = |s: &Session| s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label; + assert_eq!(lab(&s), 9); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(lab(&s), 0); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label, 9); + assert!(s.execute("keys.setLabel", json!({"label": "Mauve"})).is_err()); +} + #[test] fn camera_focus_commands_undo_and_serde() { let mut s = comp(400, 300); diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 7d9d35ee..ec2b4754 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -84,6 +84,13 @@ pub struct Keyframe { pub spatial_continuous: bool, #[serde(default)] pub roving: bool, + /// Keyframe color label: index into the label list (0 = None). + #[serde(default, skip_serializing_if = "is_zero")] + pub label: u8, +} + +fn is_zero(v: &u8) -> bool { + *v == 0 } fn yes() -> bool { @@ -106,6 +113,7 @@ impl Keyframe { spatial_auto: true, spatial_continuous: false, roving: false, + label: 0, } } pub fn hold(mut self) -> Keyframe { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 3254a878..ed185087 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -210,16 +210,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let name_w = (rect.width() * 0.40).clamp(120.0, 260.0); let label_x = rect.min.x + name_w + 14.0; let mut cols: Vec<(&'static str, &'static str, f32, f32)> = vec![("name", "Name", rect.min.x + 26.0, name_w - 26.0), ("label", "", label_x - 8.0, 18.0)]; - let mut x = label_x + 16.0; + // Optional columns wider than the panel scroll horizontally under the frozen Name and Label + // columns (Shift+wheel / trackpad over the list, or the scroll bar under it). + let opt_x0 = label_x + 16.0; + let natural_end = opt_x0 + app.ui.project_columns.iter().filter_map(|k| COLUMNS.iter().find(|c| c.0 == k)).map(|c| c.2 + 4.0).sum::(); + let overflow = (natural_end - rect.max.x + 4.0).max(0.0); + app.ui.project_hscroll = app.ui.project_hscroll.clamp(0.0, overflow); + let hscroll = app.ui.project_hscroll; + let mut x = opt_x0 - hscroll; for key in app.ui.project_columns.clone() { if let Some((k, l, w)) = COLUMNS.iter().find(|c| c.0 == key) { cols.push((k, l, x, *w)); x += w + 4.0; } } + let opt_clip = |r: Rect| Rect::from_min_max(pos2(r.min.x.max(opt_x0 - 4.0), r.min.y), r.max); let hp = p.with_clip_rect(hdr); for (key, label, x, w) in &cols { let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(*w, hdr.height())); + let hr = if matches!(*key, "name" | "label") { hr } else { opt_clip(hr) }; + if hr.width() <= 0.0 { + continue; + } let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click()); hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim }); if *key == "label" { @@ -252,6 +264,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let footer_h = 28.0; let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h)); let lp = p.with_clip_rect(list); + if overflow > 0.0 && ui.rect_contains_pointer(list) { + let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift)); + let d = if dx.abs() > 0.0 { + dx + } else if shift { + dy + } else { + 0.0 + }; + app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow); + } + if overflow > 0.0 { + let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0)); + let tw = (track.width() * track.width() / (track.width() + overflow)).max(16.0); + let thumb = Rect::from_min_size(pos2(track.min.x + (track.width() - tw) * (hscroll / overflow), track.min.y), vec2(tw, track.height())); + let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("proj-hscroll"), Sense::drag()); + p.rect_filled(track, 2.0, t.field_bg); + p.rect_filled(thumb, 2.0, if sresp.hovered() || sresp.dragged() { t.text_dim } else { t.text_faint }); + app.auto.add("project.hscroll", track, &format!("{hscroll}/{overflow}")); + if sresp.dragged() { + app.ui.project_hscroll = (hscroll + sresp.drag_delta().x * overflow / (track.width() - tw).max(1.0)).clamp(0.0, overflow); + } + } let mut y = list.min.y; let query = app.ui.project_search.to_lowercase(); let mut rows: Vec<(ItemId, usize)> = vec![]; @@ -372,7 +407,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Optional columns. for (key, _, cx, w) in cols.iter().skip(2) { - let cell = Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list); + let cell = opt_clip(Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list)); + if cell.width() <= 0.0 { + continue; + } let cp = lp.with_clip_rect(cell); let txt = match *key { "type" => it.type_name().to_string(), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ae43c4ac..f9f6cd9c 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -714,7 +714,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let footer_h = 24.0; let vis = Vis::of(&app.ui.timeline); let left_w = (vis.fixed() + 190.0).clamp(420.0, (rect.width() * 0.72).max(420.0)); - let cw = cols(rect.min.x, left_w, vis); + // Columns wider than the pane scroll horizontally (Shift+wheel / trackpad over the outline, + // or the scroll bar above the footer); the name column keeps its minimum width. + let natural_w = vis.fixed() + 190.0; + let overflow = (natural_w - left_w).max(0.0); + app.ui.timeline.outline_scroll = app.ui.timeline.outline_scroll.clamp(0.0, overflow); + let oscroll = app.ui.timeline.outline_scroll; + let cw = cols(rect.min.x - oscroll, left_w + overflow, vis); 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))); @@ -898,8 +904,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)), 0.0, t.panel_bg); p.line_segment([pos2(rect.min.x, ch.max.y), pos2(rect.max.x, ch.max.y)], Stroke::new(1.0, t.separator)); let hy = ch.center().y; - let hic = |icon: Icon, x: f32| icons::paint(&p, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim); - let htext = |x: f32, s: &str| p.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim); + // Column headers scroll with the outline and never spill into the time ruler. + let hp = p.with_clip_rect(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y))); + let hic = |icon: Icon, x: f32| icons::paint(&hp, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim); + let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim); if vis.av { hic(Icon::Eye, cw.av + 10.0); hic(Icon::Speaker, cw.av + 28.0); @@ -910,7 +918,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { htext(cw.keys + 6.0, "Keys"); } if vis.label { - icons::paint(&p, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); + icons::paint(&hp, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); } if vis.num { htext(cw.num + 6.0, "#"); @@ -933,7 +941,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { htext(cw.trkmat + 4.0, "Track Matte"); } if vis.parent { - icons::paint(&p, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim); + icons::paint(&hp, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim); htext(cw.parent + 20.0, "Parent & Link"); } for (on, x, s) in [(vis.in_, cw.in_, "In"), (vis.out, cw.out, "Out"), (vis.duration, cw.duration, "Duration"), (vis.stretch, cw.stretch, "Stretch")] { @@ -1006,8 +1014,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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)); } else { - app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll); - if dx.abs() > 0.0 && app.ui.timeline.pps.is_some() { + 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); + if over_outline && overflow > 0.0 && (dx.abs() > 0.0 || (shift && dy.abs() > 0.0)) { + let d = if dx.abs() > 0.0 { dx } else { dy }; + app.ui.timeline.outline_scroll = (app.ui.timeline.outline_scroll - d).clamp(0.0, overflow); + } else { + app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll); + } + if dx.abs() > 0.0 && app.ui.timeline.pps.is_some() && !over_outline { app.ui.timeline.start = (app.ui.timeline.start - dx as f64 / pps).max(0.0); } } @@ -1623,7 +1638,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ks = sel_keys.contains(&kref); let icon = k.icon(); let c = pos2(x, cy); - icons::keyframe(&gp, c, 11.0, icon.left, icon.right, if ks { t.keyframe_selected } else { t.keyframe }, Color32::from_black_alpha(200)); + // Labelled keys take their label colour (brighter when selected). + let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) { + Some(l) => { + let c = t.label(*l); + if ks { c } else { c.gamma_multiply(0.75) } + } + None if ks => t.keyframe_selected, + None => t.keyframe, + }; + icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200)); let kr = Rect::from_center_size(c, vec2(12.0, 14.0)); let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag()); app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name)); @@ -1660,6 +1684,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ("Select All Keyframes", "keys.selectAll", json!({})), ("Delete", "keys.delete", json!({})), ] { + if lbl == "Select All Keyframes" { + // Keyframe colour labels. + ui.menu_button("Label", |ui| { + for (i, l) in effectcraft_engine::color::Label::ALL.iter().enumerate() { + if ui.add(egui::Button::new(l.name()).selected(k.label as usize == i)).clicked() { + if !ks { + actions.push(( + "keys.select".into(), + json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}), + )); + } + actions.push(("keys.setLabel".into(), json!({"label": i}))); + ui.close(); + } + } + }); + } if lbl == "-" { ui.separator(); continue; @@ -1794,6 +1835,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let footer = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max); p.rect_filled(footer, 0.0, t.panel_bg); p.line_segment([footer.left_top(), footer.right_top()], Stroke::new(1.0, t.separator)); + if overflow > 0.0 { + // Outline scroll bar along the top of the footer, under the columns. + let track = Rect::from_min_max(pos2(rect.min.x + 2.0, footer.min.y + 1.0), pos2(graph_x0 - 3.0, footer.min.y + 5.0)); + let frac = left_w / natural_w; + let tw = (track.width() * frac).max(20.0); + let tx = track.min.x + (track.width() - tw) * (oscroll / overflow); + let thumb = Rect::from_min_size(pos2(tx, track.min.y), vec2(tw, track.height())); + p.rect_filled(track, 2.0, t.field_bg); + let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("tl-outline-scroll"), Sense::drag()); + p.rect_filled(thumb, 2.0, if sresp.dragged() || sresp.hovered() { t.text_dim } else { t.text_faint }); + app.auto.add("timeline.outlineScroll", track, &format!("{oscroll}/{overflow}")); + if sresp.dragged() { + let k = overflow / (track.width() - tw).max(1.0); + app.ui.timeline.outline_scroll = (oscroll + sresp.drag_delta().x * k).clamp(0.0, overflow); + } + } let tgl = Rect::from_min_size(pos2(footer.min.x + 10.0, footer.min.y + 3.0), vec2(150.0, 18.0)); let tresp = ui.interact(tgl, egui::Id::new("tl-toggle-modes"), Sense::click()); p.text(tgl.left_center(), Align2::LEFT_CENTER, "Toggle Switches / Modes", Tokens::ui(11.0), if tresp.hovered() { t.text } else { t.text_dim }); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2ead3c6a..adffbbeb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -340,6 +340,9 @@ pub struct TimelineState { pub scroll_y: f32, /// Left column area width. pub columns_w: f32, + /// Horizontal scroll of the outline columns (pixels) when they are wider than the pane. + #[serde(default)] + pub outline_scroll: f32, pub show_modes: bool, pub graph_editor: bool, pub search: String, @@ -403,6 +406,7 @@ impl Default for TimelineState { pps: None, scroll_y: 0.0, columns_w: 560.0, + outline_scroll: 0.0, show_modes: false, graph_editor: false, search: String::new(), @@ -545,6 +549,9 @@ pub struct UiState { /// `duration`, `path`, `comment`); toggled from the column header's context menu. #[serde(default = "default_project_columns")] pub project_columns: Vec, + /// Horizontal scroll of the Project panel's optional columns (Name and Label stay put). + #[serde(default)] + pub project_hscroll: f32, /// Effect Controls twirl state (group uids that are collapsed). pub fx_closed: BTreeSet, /// Slider params whose slider row is twirled open (AE hides sliders by default). @@ -631,6 +638,7 @@ impl Default for UiState { project_open_folders: BTreeSet::new(), project_sort: default_project_sort(), project_sort_desc: false, + project_hscroll: 0.0, project_columns: default_project_columns(), fx_closed: BTreeSet::new(), fx_slider_open: BTreeSet::new(), diff --git a/crates/ui-egui/tests/ui_tail.rs b/crates/ui-egui/tests/ui_tail.rs new file mode 100644 index 00000000..b113f115 --- /dev/null +++ b/crates/ui-egui/tests/ui_tail.rs @@ -0,0 +1,79 @@ +//! Headless checks for the M13.5 long-tail UI: horizontal scrolling of the Timeline outline and +//! Project panel columns (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness(w: f32) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Tail", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(w, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + 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.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +#[test] +fn timeline_outline_columns_scroll_horizontally() { + let mut h = harness(1100.0); + // Every column on: wider than the outline pane. + let ctx = h.ctx.clone(); + for c in ["keys", "comment", "modes", "parent", "in", "out", "duration", "stretch"] { + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.column", json!({"column": c, "visible": true})).unwrap(); + } + h.run_steps(3); + let bar = rect(&h, "timeline.outlineScroll"); + let name0 = rect(&h, "timeline.header.name").min.x; + assert_eq!(h.state().ui.timeline.outline_scroll, 0.0); + drag(&mut h, pos2(bar.min.x + 8.0, bar.center().y), pos2(bar.max.x + 50.0, bar.center().y)); + h.run_steps(2); + let s = h.state().ui.timeline.outline_scroll; + assert!(s > 50.0, "{s}"); + let name1 = rect(&h, "timeline.header.name").min.x; + assert!((name0 - name1 - s).abs() < 1.0, "the columns moved by the scroll: {name0} → {name1} ({s})"); + // The scroll is clamped to the overflow and resets when the columns fit again. + for c in ["keys", "comment", "modes", "parent", "in", "out", "duration", "stretch"] { + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.column", json!({"column": c, "visible": false})).unwrap(); + } + h.run_steps(3); + assert_eq!(h.state().ui.timeline.outline_scroll, 0.0); + assert!(h.state().auto.find("timeline.outlineScroll").is_none()); +} + +#[test] +fn project_panel_columns_scroll_horizontally() { + let mut h = harness(1400.0); + for c in ["type", "size", "duration", "fps", "path", "comment"] { + effectcraft_ui_egui::panels::project::set_column(&mut h.state_mut().ui.project_columns, c, true); + } + h.run_steps(3); + let bar = rect(&h, "project.hscroll"); + let x0 = rect(&h, "project.sort.comment").min.x; + let name0 = rect(&h, "project.sort.name").min.x; + drag(&mut h, pos2(bar.min.x + 4.0, bar.center().y), pos2(bar.max.x + 40.0, bar.center().y)); + h.run_steps(2); + let s = h.state().ui.project_hscroll; + assert!(s > 20.0, "{s}"); + assert!(rect(&h, "project.sort.comment").min.x < x0 - 20.0); + assert_eq!(rect(&h, "project.sort.name").min.x, name0, "Name stays frozen"); +}