Files
effectcraft/crates/ui-egui/tests/ui_project_rows.rs
T
bflatasticandClaude Opus 5.5 0f44588bbd Project panel: footage items' menu has Interpret Footage, Replace and Reload Footage (#290)
Right-clicking imported footage offered no way to change its
interpretation. As in After Effects, right-clicking an item now selects
it, and footage items' menu has the File menu's footage commands:
Interpret Footage (Main, Proxy, Remember / Apply Interpretation),
Replace Footage, Reload Footage and Reveal in Explorer / Finder, with
the menu bar's labels, shortcuts and enabled state
(menus::submenu_at / entry_for). The panel's Interpret Footage button,
which only ever said to select footage, opens the dialog for selected
footage.

Test: footage_context_menu_interprets_the_footage (ui_project_rows).

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

186 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Project panel rows: a folder's twirl and an item's label swatch take their own clicks, over
//! the row's click / drag area (#152).
use effectcraft_engine::Session;
use effectcraft_engine::color::Label;
use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind};
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui::{Color32, Event, Modifiers, Pos2, Rect, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
/// A closed folder holding a comp, and a solid at the project root: (harness, folder, comp, solid).
fn harness() -> (Harness<'static, EffectcraftApp>, ItemId, ItemId, ItemId) {
let mut s = Session::default();
let comp = ItemId(s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap());
s.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap();
let solid = s.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id;
let folder = ItemId(s.execute("project.newFolder", json!({"name": "Folder"})).unwrap()["item"].as_u64().unwrap());
s.execute("project.move", json!({"items": [comp.0], "folder": folder.0})).unwrap();
s.execute("project.move", json!({"items": [solid.0], "folder": null})).unwrap();
s.execute("project.select", json!({"items": []})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
(h, folder, comp, solid)
}
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
fn button(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, pressed: bool) {
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
}
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
button(h, p, true);
h.step();
button(h, p, false);
h.run_steps(3);
}
/// Both clicks in one frame: the harness' frame time is longer than a double-click's.
fn double_click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
for _ in 0..2 {
button(h, p, true);
button(h, p, false);
}
h.run_steps(3);
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = center(h, id);
click_at(h, p);
}
#[test]
fn folder_twirl_opens_and_closes_the_folder_on_one_click() {
let (mut h, folder, comp, _) = harness();
let twirl = format!("project.item.{}.twirl", folder.0);
let comp_row = format!("project.item.{}", comp.0);
assert!(!h.state().ui.project_open_folders.contains(&folder.0));
assert!(h.state().auto.find(&comp_row).is_none(), "the closed folder hides its comp");
click(&mut h, &twirl);
assert!(h.state().ui.project_open_folders.contains(&folder.0), "one click on the twirl opens the folder");
assert!(h.state().auto.find(&comp_row).is_some(), "the open folder shows its comp");
assert!(h.state().session.state.project_selection.is_empty(), "the twirl does not select the folder");
click(&mut h, &twirl);
assert!(!h.state().ui.project_open_folders.contains(&folder.0), "a second click closes it");
// The row under the twirl still selects on a click and twirls on a double-click.
let name = center(&h, &format!("project.item.{}.name", folder.0));
click_at(&mut h, name);
assert_eq!(h.state().session.state.project_selection, vec![folder]);
double_click_at(&mut h, name);
assert!(h.state().ui.project_open_folders.contains(&folder.0), "a double-click on the row opens the folder");
}
/// 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))
}
#[test]
fn label_swatch_opens_the_label_menu() {
let (mut h, _, _, solid) = harness();
assert_ne!(h.state().session.project.item(solid).unwrap().label, Label::Green);
let green = h.state().session.prefs.label_name(Label::Green);
assert!(h.query_by_label(&green).is_none());
click(&mut h, &format!("project.item.{}.label", solid.0));
assert!(h.state().session.state.project_selection.is_empty(), "the swatch does not select the row");
let entry = h.query_by_label(&green).expect("the label menu is open").rect();
// Each entry shows its colour before its name (#290).
assert!(swatch_in(&h, entry, h.state().tokens.label(Label::Green)), "no {green} swatch");
let undo = h.state().session.history.undo.len();
click_at(&mut h, entry.center());
assert_eq!(h.state().session.project.item(solid).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");
}
/// Right-clicking a footage item selects it and offers the File menu's footage commands:
/// Interpret Footage ▸ Main... opens the Interpret Footage dialog on it (#290). A solid has none.
#[test]
fn footage_context_menu_interprets_the_footage() {
let (mut h, _, _, solid) = harness();
let footage = Footage { kind: FootageKind::Video, path: "clip.mp4".into(), width: 64, height: 36, has_video: true, ..Default::default() };
let clip = std::sync::Arc::make_mut(&mut h.state_mut().session.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage));
h.run_steps(2);
let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
};
let at = center(&h, &format!("project.item.{}.name", solid.0));
right_click(&mut h, at);
assert_eq!(h.state().session.state.project_selection, vec![solid]);
assert!(h.query_by_label_contains("Interpret Footage").is_none(), "a solid isn't footage");
h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let at = center(&h, &format!("project.item.{}.name", clip.0));
right_click(&mut h, at);
assert_eq!(h.state().session.state.project_selection, vec![clip], "right-click selects the item");
let sub = h.query_by_label_contains("Interpret Footage").expect("Interpret Footage in the menu").rect();
h.input_mut().events.push(Event::PointerMoved(sub.center()));
h.run_steps(3);
let main = h.query_by_label_contains("Main...").expect("Interpret Footage ▸ Main...").rect();
for k in 0..=6 {
h.input_mut().events.push(Event::PointerMoved(pos2(main.center().x, sub.center().y + (main.center().y - sub.center().y) * k as f32 / 6.0)));
h.step();
}
click_at(&mut h, main.center());
assert_eq!(h.state().dialog, Some(Dialog::Form), "the Interpret Footage dialog opened");
}
/// Dragging from the Project panel's empty area draws a selection box that selects the rows it
/// touches; a click there deselects (#203).
#[test]
fn dragging_in_the_empty_area_box_selects_items() {
let (mut h, folder, _, solid) = harness();
let list = h.state().auto.find("project.empty").unwrap().rect;
let from = pos2(list[0] + 40.0, list[1] + list[3] - 6.0);
let to = center(&h, &format!("project.item.{}.name", folder.0));
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
button(&mut h, from, true);
h.step();
for k in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0)));
h.step();
}
button(&mut h, to, false);
h.run_steps(2);
let sel = h.state().session.state.project_selection.clone();
assert!(sel.contains(&folder) && sel.contains(&solid), "{sel:?}");
click_at(&mut h, from);
assert!(h.state().session.state.project_selection.is_empty());
}
/// A Project panel shorter than its scroll bar's thumb draws instead of panicking: an empty
/// project in a 1280 × 800 browser window at device pixel ratio 2 (640 × 400 points) left the
/// list too short for the thumb's 16-point minimum (#231).
#[test]
fn a_short_project_panel_draws() {
for height in [400.0, 300.0] {
let mut h = Harness::builder().with_size(egui::vec2(640.0, height)).build_eframe(|_| EffectcraftApp::new(Session::default()));
h.run_steps(3);
assert!(h.state().auto.find("project.empty").is_some(), "the Project panel drew at {height} points");
}
}