effectcraft/crates/ui-egui/tests/ui_project_rows.rs

217 lines
11 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);
};
// The menu's Interpret Footage entry, not the panel's footer button of the same name.
let menu_entry = |h: &Harness<'_, EffectcraftApp>| {
let footer = center(h, "project.interpret");
h.query_all_by_label_contains("Interpret Footage").find(|n| !n.rect().contains(footer)).map(|n| n.rect())
};
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!(menu_entry(&h).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 = menu_entry(&h).expect("Interpret Footage in the menu");
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");
}
/// #360: the row menu's New Comp from Selection works on the whole selection, as File ▸ New
/// Comp from Selection does: with several items selected it asks how in After Effects' New
/// Composition from Selection dialog, and Multiple Compositions makes one per item.
#[test]
fn context_menu_new_comp_from_selection_uses_the_selection() {
let (mut h, _, comp, solid) = harness();
h.state_mut().session.state.project_selection = vec![comp, solid];
h.run_steps(2);
let at = center(&h, &format!("project.item.{}.name", solid.0));
h.input_mut().events.push(Event::PointerMoved(at));
h.step();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
assert_eq!(h.state().session.state.project_selection, vec![comp, solid], "the selection stays");
let item = h.query_by_label_contains("New Comp from Selection").expect("in the menu").rect();
click_at(&mut h, item.center());
assert_eq!(h.state().dialog, Some(Dialog::Form));
assert!(h.state().auto.find("form.field.single").is_some(), "the New Composition from Selection dialog");
let comps = h.state().session.project.comps().count();
h.state_mut().session.execute_checked("file.newCompFromSelection", json!({"single": false})).unwrap();
assert_eq!(h.state().session.project.comps().count(), comps + 2, "one per item");
}
/// 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");
}
}