Files
effectcraft/crates/ui-egui/tests/ui_timeline_switches.rs
T
bflatasticandClaude Opus 5.5 2f3a507f8e Labels: label menus show each label's colour (#290)
The label menus listed the labels' names only, so a label couldn't be
picked by its colour. Every place a label is chosen now shows its
colour swatch before its name, as After Effects does: the Timeline's
label swatch (which now opens its menu on a click too, not only a
right-click), the Project panel's label swatch and a new Label submenu
in its item menu, Edit > Label, keyframe Label menus and the marker
dialog. One shared widget (widgets::label_entry / label_popup) draws
them; keyframe labels now show the names set in Settings > Labels.

Tests: label_swatch_opens_the_label_menu (ui_project_rows),
the_label_swatch_menu_shows_the_label_colours (ui_timeline_switches),
edit_label_menu_shows_the_label_colours (ui_panels).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:21:12 +07:00

203 lines
9.9 KiB
Rust

//! Timeline layer switches with real pointer input (egui_kittest): pressing a switch and
//! dragging over other layers gives them all the state the first one got, in one undo step, as
//! in After Effects (#227); stopwatches the same way (#284). Also the label swatch's menu and the
//! layer bar past its source (#290).
use effectcraft_engine::Session;
use effectcraft_engine::color::Label;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Color32, Event, Modifiers, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
/// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom.
fn harness() -> (Harness<'static, EffectcraftApp>, Vec<u64>) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let mut ids: Vec<u64> = ["D", "C", "B", "A"]
.iter()
.map(|name| s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap())
.collect();
ids.reverse();
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
(h, ids)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
/// Press on `from`, move to `to` in `steps`, release there.
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) {
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..=steps {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// Each layer's switch, top to bottom.
fn states(h: &Harness<'_, EffectcraftApp>, ids: &[u64], f: fn(&effectcraft_engine::project::Layer) -> bool) -> Vec<bool> {
let comp = h.state().session.active_comp().unwrap();
ids.iter().map(|id| f(comp.layer(LayerId(*id)).unwrap())).collect()
}
fn video(l: &effectcraft_engine::project::Layer) -> bool {
l.switches.video
}
fn motion_blur(l: &effectcraft_engine::project::Layer) -> bool {
l.switches.motion_blur
}
#[test]
fn dragging_over_layer_switches_sets_them_all() {
let (mut h, ids) = harness();
let eye = |h: &Harness<'_, EffectcraftApp>, i: usize| rect(h, &format!("timeline.layer.{}.video", ids[i])).center();
let steps = h.state().session.history.undo.len();
// From A's eye down to D's: all hidden, one undo step.
let (a, d) = (eye(&h, 0), eye(&h, 3));
drag(&mut h, a, d, 10);
assert_eq!(states(&h, &ids, video), [false; 4]);
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(states(&h, &ids, video), [true; 4]);
// Every layer gets the first one's new state, whatever its own: B hidden, then from C (shown
// → hidden) up to A, quickly (two moves): B stays hidden, A is hidden, D untouched.
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [ids[1]], "switch": "video", "value": false})).unwrap();
h.run_steps(2);
let (c, a) = (eye(&h, 2), eye(&h, 0));
drag(&mut h, c, a, 2);
assert_eq!(states(&h, &ids, video), [false, false, false, true]);
// From B (hidden → shown) down to D: B, C and D shown.
let (b, d) = (eye(&h, 1), eye(&h, 3));
drag(&mut h, b, d + vec2(0.0, 40.0), 6);
assert_eq!(states(&h, &ids, video), [false, true, true, true]);
// A click still toggles only that layer.
let c = eye(&h, 2);
drag(&mut h, c, c, 1);
assert_eq!(states(&h, &ids, video), [false, true, false, true]);
// The Switches column works the same (Motion Blur, from D up to B).
let mb = |h: &Harness<'_, EffectcraftApp>, i: usize| rect(h, &format!("timeline.layer.{}.switch.motionBlur", ids[i])).center();
let (d, b) = (mb(&h, 3), mb(&h, 1));
drag(&mut h, d, b, 8);
assert_eq!(states(&h, &ids, motion_blur), [false, true, true, true]);
assert_eq!(states(&h, &ids, video), [false, true, false, true], "other switches untouched");
}
/// #284: pressing a stopwatch and dragging over the stopwatches below gives every property the
/// state the first one got (animated or not), in one undo step; Alt+click still adds an
/// expression and a click toggles one property.
#[test]
fn dragging_over_stopwatches_sets_them_all() {
let (mut h, ids) = harness();
// Layer A twirled open to its Transform properties.
let (layer, uids) = {
let comp = h.state().session.active_comp().unwrap();
let l = comp.layer(LayerId(ids[0])).unwrap();
let tr = l.props.sub("transform").unwrap();
let uids: Vec<u64> = ["anchor", "position", "scale", "rotation", "opacity"].iter().map(|m| tr.get(m).unwrap().uid).collect();
(l.id, (tr.uid, uids))
};
let (group, uids) = uids;
h.state_mut().ui.timeline.open_layers.insert(layer.0);
h.state_mut().ui.timeline.open_groups.insert(group);
h.run_steps(3);
let animated = |h: &Harness<'_, EffectcraftApp>| -> Vec<bool> {
let comp = h.state().session.active_comp().unwrap();
let l = comp.layer(layer).unwrap();
uids.iter().map(|u| l.props.find(*u).unwrap().is_animated()).collect()
};
let sw = |h: &Harness<'_, EffectcraftApp>, i: usize| rect(h, &format!("timeline.prop.{}.stopwatch", uids[i])).center();
let steps = h.state().session.history.undo.len();
// Scale animated first; from Anchor Point down to Opacity: all animated, one undo step.
h.state_mut().session.execute("prop.toggleAnimation", json!({"layer": layer.0, "prop": uids[2]})).unwrap();
h.run_steps(2);
let steps = steps + 1;
let (a, o) = (sw(&h, 0), sw(&h, 4));
drag(&mut h, a, o, 10);
assert_eq!(animated(&h), [true; 5]);
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
// From Rotation (animated → not) up to Position, quickly: those three stop animating.
let (r, p) = (sw(&h, 3), sw(&h, 1));
drag(&mut h, r, p, 2);
assert_eq!(animated(&h), [true, false, false, false, true]);
// A click toggles only that property.
let s = sw(&h, 2);
drag(&mut h, s, s, 1);
assert_eq!(animated(&h), [true, false, true, false, true]);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(animated(&h), [true; 5], "each drag was one step");
}
/// A swatch (a small rect filled with `col`) painted this frame in the left half of `row`.
fn swatch_in(h: &Harness<'_, EffectcraftApp>, row: Rect, col: Color32) -> bool {
fn walk(s: &egui::Shape, row: Rect, col: Color32) -> bool {
match s {
egui::Shape::Rect(r) => r.fill == col && r.rect.width() < 16.0 && row.contains(r.rect.center()) && r.rect.center().x < row.center().x,
egui::Shape::Vec(v) => v.iter().any(|s| walk(s, row, col)),
_ => false,
}
}
h.output().shapes.iter().any(|c| walk(&c.shape, row, col))
}
/// Clicking a layer's label swatch opens the label menu, which shows each label's colour before
/// its name, so a label is picked by its colour (#290).
#[test]
fn the_label_swatch_menu_shows_the_label_colours() {
let (mut h, ids) = harness();
let green = h.state().session.prefs.label_name(Label::Green);
let at = rect(&h, &format!("timeline.layer.{}.label", ids[1])).center();
drag(&mut h, at, at, 1);
let entry = h.query_by_label(&green).expect("the label menu is open").rect();
for l in [Label::Green, Label::SeaFoam, Label::Purple] {
let e = h.query_by_label(&h.state().session.prefs.label_name(l)).unwrap().rect();
assert!(swatch_in(&h, e, h.state().tokens.label(l)), "no {l:?} swatch");
}
let undo = h.state().session.history.undo.len();
drag(&mut h, entry.center(), entry.center(), 1);
assert_eq!(h.state().session.active_comp().unwrap().layer(LayerId(ids[1])).unwrap().label, Label::Green);
assert_eq!(h.state().session.history.undo.len(), undo + 1);
assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice");
}
/// A layer extended past its source's end (its last frame held) has that part of its bar
/// striped, so the source's own span shows (#290); with time remapping it isn't.
#[test]
fn the_bar_past_the_source_end_is_striped() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap();
let l = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let past = format!("timeline.layer.{l}.bar.afterSource");
assert!(h.state().auto.find(&past).is_none(), "the source fills the whole bar");
h.state_mut().session.execute("layer.timing", json!({"layers": [l], "out": 4.0})).unwrap();
h.run_steps(2);
let (bar, after) = (rect(&h, &format!("timeline.layer.{l}.bar")), rect(&h, &past));
assert!((after.min.x - bar.center().x).abs() < 2.0 && (after.max.x - bar.max.x).abs() < 1.0, "{bar:?} {after:?}");
h.state_mut().session.execute("layer.enableTimeRemap", json!({"layers": [l]})).unwrap();
h.run_steps(2);
assert!(h.state().auto.find(&past).is_none(), "time remapping decides what shows");
}