mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-23 12:13:04 -05:00
Problem: with the Project panel focused the arrow keys did nothing (After Effects selects the previous / next item, extends with Shift and opens / closes folders with Right / Left). The custom-drawn rows, sort headers, twirls, label swatches, proxy switches and footer buttons reached AccessKit with no name or role, the empty list area and each row's name cell were unnamed extra Tab stops, a Tab-focused row showed no focus ring, and the arrows then moved egui's spatial focus instead. Fix: Up / Down select the previous / next row (Shift extends from the anchor), Right opens and Left closes the selected folder or selects the parent; the moved-to row scrolls into view. A row with keyboard focus keeps the arrows, focus follows the selection and the panel takes focus. Rows are AccessKit tree items named "<name>, <type>" with selected, level and expanded state; headers and row / footer buttons are named buttons; one Tab stop per row and a focus ring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
11 KiB
Rust
217 lines
11 KiB
Rust
//! 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");
|
||
}
|
||
}
|