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 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 23:48:33 -04:00
1 parent 85c645f337
commit b03af6a979
9 files changed
+327 -11

No files matched your search

+85
View File
@@ -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<u8> {
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<Value> {
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<Value> {
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<u8> =
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<Vec<u64>> =
(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<CommandSpec> {
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
),
]
}
+2
View File
@@ -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());
-1
View File
@@ -23,7 +23,6 @@ pub fn specs() -> Vec<CommandSpec> {
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).
+40
View File
@@ -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);
+8
View File
@@ -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 {
+40 -2
View File
@@ -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::<f32>();
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(),
+65 -8
View File
@@ -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 });
+8
View File
@@ -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<String>,
/// 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<u64>,
/// 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(),
+79
View File
@@ -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");
}