//! Headless UI tests of the M12.7 Project panel and Media Browser: List view (columns, sorting,
//! resizing, inline rename), Icon view (hover scrub sets In / Out, thumbnail size), Freeform view
//! (dragging places a card, stacks), the panel menu and Metadata Display… dialog, view presets,
//! bins opened in place / in a tab / in a window, and the Media Browser (navigation, Favorites,
//! Edit Columns…, import, drag to the Project panel, Open In Source Monitor).
//!
//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=
` to also render offscreen with wgpu and write
//! `project-*.png` / `media-browser-*.png`.
#![allow(dead_code)]
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_engine::project_panel::{FREEFORM_POS, FREEFORM_STACK, ViewMode};
use filmcraft_project::{BinEntry, ItemId};
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender,
snapshots: Option,
}
impl Driver {
fn with(session: Session) -> Self {
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::PathBuf::from);
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
if snapshots.is_some() {
b = b.wgpu();
}
let harness = b.build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, snapshots };
d.frames(4);
d
}
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
Self::with(session)
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn call(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let v = self.call(method, params.clone());
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
v["result"].clone()
}
fn exec(&mut self, command: &str, params: Value) -> Value {
self.ok("engine.execute", json!({"command": command, "params": params}))
}
fn click(&mut self, id: &str) {
self.ok("ui.click", json!({"id": id}));
self.frames(2);
}
fn ids(&mut self, prefix: &str) -> Vec {
let v = self.ok("ui.elements", json!({"prefix": prefix}));
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
}
fn has(&mut self, id: &str) -> bool {
self.ids(id).iter().any(|i| i == id)
}
fn rect(&mut self, id: &str) -> [f32; 4] {
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")).clone();
let r = &e["rect"];
[r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32, r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32]
}
fn label(&mut self, id: &str) -> String {
let v = self.ok("ui.elements", json!({"prefix": id}));
v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).and_then(|e| e["label"].as_str().map(str::to_string)).unwrap_or_default()
}
/// Step frames until `id` is registered.
fn wait_for(&mut self, id: &str) {
for _ in 0..400 {
if self.has(id) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
self.frames(1);
}
panic!("{id} never appeared: {:?}", self.ids(id.split('.').next().unwrap()));
}
fn key(&mut self, key: &str) {
self.ok("ui.key", json!({"key": key}));
self.frames(2);
}
fn app(&mut self) -> &mut FilmcraftApp {
self.harness.state_mut()
}
/// The demo's Footage bin and its media items.
fn footage(&mut self) -> (u64, Vec) {
let p = &self.app().session.project;
let bin = p
.root
.children
.iter()
.find_map(|e| match e {
BinEntry::Bin(b) if b.name == "Footage" => Some(b.clone()),
_ => None,
})
.expect("Footage bin");
let items = bin.children.iter().filter_map(|e| if let BinEntry::Item(i) = e { Some(i.0) } else { None }).collect();
(bin.id.0, items)
}
fn snapshot(&mut self, name: &str, prefix: Option<&str>) {
let Some(dir) = self.snapshots.clone() else { return };
let crop = prefix.map(|p| self.rect(p));
self.frames(2);
let img = match self.harness.render() {
Ok(i) => i,
Err(e) => {
eprintln!("snapshot {name} skipped: {e}");
return;
}
};
let ppp = img.width() as f32 / 1600.0;
let img = match crop {
Some([x, y, w, h]) => {
let (x0, y0) = ((x * ppp).max(0.0) as u32, (y * ppp).max(0.0) as u32);
let (x1, y1) = ((((x + w) * ppp) as u32).min(img.width()), (((y + h) * ppp) as u32).min(img.height()));
image::imageops::crop_imm(&img, x0, y0, x1 - x0, y1 - y0).to_image()
}
None => img,
};
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{name}.png"));
img.save(&path).unwrap();
eprintln!("snapshot: {}", path.display());
}
}
/// The demo project with the Project panel maximized (room for the List view's columns).
fn project_driver() -> Driver {
let mut d = Driver::demo();
d.ok("ui.panel.show", json!({"panel": "Project"}));
d.ok("ui.set", json!({"focused": "Project"}));
d.ok("ui.menu.invoke", json!({"id": "window.maximizeFrame"}));
d.frames(3);
d
}
/// Double-click a bin (synthetic input runs one event per frame, too slow for a double-click, so
/// this sends what the double-click does: open as Settings ▸ General ▸ Bins says).
fn double_click_bin(d: &mut Driver, bin: u64, alt: bool) {
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "alt": alt}}));
d.frames(3);
}
#[test]
fn list_view_columns_sort_resize_and_rename() {
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
let (bin, items) = d.footage();
d.frames(2);
// expand the Footage bin
d.click(&format!("project.bin.{bin}.toggle"));
assert!(d.app().ui.expanded_bins.contains(&bin));
d.wait_for(&format!("project.item.{}", items[0]));
for c in ["Name", "Frame Rate", "Media Start", "Media Duration"] {
assert!(d.has(&format!("project.list.header.{c}")), "{c} header");
}
assert!(d.has(&format!("project.item.{}.label", items[0])), "label chip");
let y = |d: &mut Driver, i: u64| d.rect(&format!("project.item.{i}"))[1];
// Name ascending: rows ordered by name
let mut by_name: Vec<(String, u64)> = items.iter().map(|i| (d.app().session.project.item(ItemId(*i)).unwrap().name.to_lowercase(), *i)).collect();
by_name.sort();
let (first, last) = (by_name[0].1, by_name.last().unwrap().1);
assert!(y(&mut d, first) < y(&mut d, last));
d.snapshot("project-list", Some("panel.Project"));
// clicking the Name header reverses the order
d.click("project.list.header.Name");
assert!(d.app().session.prefs.project_panel.view.sort.descending);
d.frames(2);
assert!(y(&mut d, first) > y(&mut d, last));
// drag the Frame Rate column wider (saved in preferences)
let w0 = d.app().session.prefs.project_panel.view.columns.iter().find(|c| c.name == "Frame Rate").unwrap().width;
let r = d.rect("project.list.resize.Frame Rate");
let (cx, cy) = (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": cx, "y": cy}, "to": {"x": cx + 60.0, "y": cy}}));
d.frames(3);
let w1 = d.app().session.prefs.project_panel.view.columns.iter().find(|c| c.name == "Frame Rate").unwrap().width;
assert!(w1 > w0 + 30.0, "{w0} → {w1}");
// inline rename: Rename, type, Enter (one undo step)
d.exec("project.select", json!({"items": [items[0]]}));
d.ok("ui.menu.invoke", json!({"id": "projectPanel.rename"}));
d.frames(3);
assert!(d.has("project.rename"));
d.key("Cmd+A");
d.ok("ui.type", json!({"text": "Hero shot"}));
d.frames(2);
d.key("Enter");
d.frames(2);
assert_eq!(d.app().session.project.item(ItemId(items[0])).unwrap().name, "Hero shot");
assert!(d.app().ui.project_panel.rename.is_none());
d.exec("edit.undo", json!({}));
assert_ne!(d.app().session.project.item(ItemId(items[0])).unwrap().name, "Hero shot");
}
#[test]
fn icon_view_hover_scrub_sets_in_and_out() {
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "icon"}));
let (bin, items) = d.footage();
// open the Footage bin in place (Settings ▸ Bins ▸ Double-click: Open in place)
d.click(&format!("project.bin.{bin}"));
double_click_bin(&mut d, bin, false);
assert_eq!(d.app().ui.project_panel.bin, Some(bin));
assert!(d.label("project.breadcrumb").ends_with("/ Footage"));
let card = format!("project.item.{}", items[0]);
d.wait_for(&card);
d.ok("ui.set", json!({"focused": "Project"}));
// hover a quarter in, press I; three quarters in, press O
d.ok("ui.move", json!({"id": card, "fx": 0.25, "fy": 0.5}));
d.frames(2);
let h = d.app().ui.project_panel.hover.expect("hover scrub");
assert_eq!(h.item, items[0]);
d.key("I");
d.ok("ui.move", json!({"id": card, "fx": 0.75, "fy": 0.5}));
d.frames(2);
d.key("O");
let it = d.app().session.project.item(ItemId(items[0])).unwrap().clone();
let m = it.as_media().unwrap();
let (i, o) = (m.mark_in.expect("In set"), m.mark_out.expect("Out set"));
let dur = it.duration().0 as f64;
assert!((i.0 as f64 / dur - 0.25).abs() < 0.05, "In at {}", i.0 as f64 / dur);
assert!((o.0 as f64 / dur - 0.75).abs() < 0.05, "Out at {}", o.0 as f64 / dur);
d.frames(2);
assert!(d.has(&format!("project.item.{}.inOut", items[0])), "In/Out bar on the card");
// the sequence's marks are untouched
assert!(d.app().session.active_sequence().unwrap().mark_in.is_none());
d.snapshot("project-icon", Some("panel.Project"));
// Shift+] / Shift+[ change the thumbnail size
let s0 = d.app().session.prefs.project_panel.view.icon_size;
d.key("Shift+]");
assert!(d.app().session.prefs.project_panel.view.icon_size > s0);
d.key("Shift+[");
assert!((d.app().session.prefs.project_panel.view.icon_size - s0).abs() < 1.0);
// Hover Scrub off: no hover, I marks the sequence again
d.key("Shift+H");
assert!(!d.app().session.prefs.project_panel.hover_scrub);
d.ok("ui.move", json!({"id": card, "fx": 0.5, "fy": 0.5}));
d.frames(4);
d.key("I");
assert!(d.app().session.active_sequence().unwrap().mark_in.is_some());
// up one level
d.click("project.up");
assert_eq!(d.app().ui.project_panel.bin, None);
}
#[test]
fn freeform_view_places_and_stacks_cards() {
let mut d = project_driver();
let (bin, items) = d.footage();
d.exec("project.view.set", json!({"view": "freeform"}));
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
let card = format!("project.item.{}", items[0]);
d.wait_for(&card);
assert!(d.has("project.freeform"));
let r = d.rect(&card);
let canvas = d.rect("project.freeform");
let from = (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
let to = (from.0 + 140.0, from.1 + 90.0);
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": to.0, "y": to.1}, "steps": 16}));
d.frames(4);
let pos = d.app().session.project.item(ItemId(items[0])).unwrap().metadata.get(FREEFORM_POS).cloned().expect("card placed");
let (x, y, _) = filmcraft_engine::project_panel::parse_pos(&pos).unwrap();
let (ex, ey) = (r[0] - canvas[0] + 140.0, r[1] - canvas[1] + 90.0);
assert!((x - ex).abs() < 4.0 && (y - ey).abs() < 4.0, "placed at {x},{y}, expected {ex},{ey}");
d.frames(2);
let r2 = d.rect(&card);
assert!((r2[0] - r[0] - 140.0).abs() < 4.0, "card drawn where it was dropped");
d.snapshot("project-freeform", Some("panel.Project"));
// the move is one undo step
d.exec("edit.undo", json!({}));
assert!(!d.app().session.project.item(ItemId(items[0])).unwrap().metadata.contains_key(FREEFORM_POS));
// stack two cards from the card menu
d.exec("project.select", json!({"items": [items[0], items[1]]}));
d.ok("ui.click", json!({"id": card, "button": "right"}));
d.frames(2);
d.click("project.cardMenu.stack");
assert_eq!(d.app().session.project.item(ItemId(items[1])).unwrap().metadata.get(FREEFORM_STACK).map(String::as_str), Some(items[0].to_string().as_str()));
d.frames(2);
assert!(d.has(&format!("project.stack.{}", items[0])), "×2 badge");
// Freeform View Options… from the panel menu
d.click("panel.menu.Project");
d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200}));
d.frames(4);
let item_rect = d.rect("project.menu.freeformOptions");
let ctx = d.harness.ctx.clone();
let at = egui::pos2(item_rect[0] + item_rect[2] / 2.0, item_rect[1] + item_rect[3] / 2.0);
assert!(ctx.content_rect().contains(at), "bottom menu action must be reachable: {item_rect:?}");
d.snapshot("project-freeform-menu", None);
d.click("project.menu.freeformOptions");
let app = d.app();
let diagnostic = format!(
"dialog={:?}, controls={:?}",
app.ui.project_panel.dialog,
app.auto.elements.iter().filter(|element| element.id.starts_with("projectDialog.")).map(|element| (&element.id, element.rect)).collect::>()
);
assert!(d.has("projectDialog.snap"), "{diagnostic}");
d.click("projectDialog.snap");
d.click("projectDialog.ok");
assert!(d.app().session.prefs.project_panel.freeform.snap);
assert!(d.app().ui.project_panel.dialog.is_none());
}
#[test]
fn metadata_display_dialog_and_view_presets() {
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
let (bin, _) = d.footage();
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
d.frames(2);
// the panel menu lists Premiere's items
d.click("panel.menu.Project");
for id in [
"project.menu.closeProject",
"project.menu.newBin",
"project.menu.newSearchBin",
"project.menu.rename",
"project.menu.automateToSequence",
"project.menu.view.List",
"project.menu.view.Freeform",
"project.menu.previewArea",
"project.menu.hoverScrub",
"project.menu.fontSize",
"project.menu.refreshSortOrder",
"project.menu.metadataDisplay",
"project.menu.saveViewPresetAs",
"project.menu.restoreViewPreset",
"project.menu.freeformOptions",
] {
assert!(d.has(id), "{id}");
}
d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200}));
d.frames(4);
d.click("project.menu.metadataDisplay");
d.frames(4);
assert!(matches!(d.app().ui.project_panel.dialog, Some(filmcraft_ui_egui::panels::project::ProjectDialog::MetadataDisplay { .. })));
d.click("projectDialog.field.Label");
// fields further down: narrow the list with the search box
d.click("projectDialog.filter");
d.ok("ui.type", json!({"text": "usage"}));
d.frames(2);
d.click("projectDialog.field.Video Usage");
d.click("projectDialog.filter");
d.key("Cmd+A");
d.ok("ui.type", json!({"text": "tape"}));
d.frames(2);
d.click("projectDialog.field.Tape Name");
// move Label up next to Name
for _ in 0..12 {
if !d.has("projectDialog.up.Label") {
break;
}
d.click("projectDialog.up.Label");
}
d.snapshot("project-metadata-display", None);
d.click("projectDialog.ok");
let cols: Vec = d.app().session.prefs.project_panel.view.columns.iter().map(|c| c.name.clone()).collect();
assert_eq!(cols[0], "Name");
assert_eq!(cols[1], "Label", "{cols:?}");
assert!(cols.contains(&"Video Usage".to_string()) && !cols.contains(&"Tape Name".to_string()));
d.frames(2);
assert!(d.has("project.list.header.Label") && d.has("project.list.header.Video Usage"));
// Save As New View Preset (named), change the view, restore it from Manage
d.click("panel.menu.Project");
d.click("project.menu.saveViewPresetAs");
d.ok("ui.set", json!({"projectPanel": {"dialog": {"savePresetAs": {"name": "Logging"}}}}));
d.frames(2);
d.click("projectDialog.ok");
assert_eq!(d.app().session.prefs.project_panel.presets[0].as_ref().unwrap().name, "Logging");
d.exec("project.view.set", json!({"view": "icon", "fontSize": "large"}));
d.click("panel.menu.Project");
d.click("project.menu.manageViewPresets");
d.click("projectDialog.preset.1");
d.click("projectDialog.restore");
assert_eq!(d.app().session.prefs.project_panel.view.mode, ViewMode::List);
d.click("projectDialog.ok");
assert!(d.app().ui.project_panel.dialog.is_none());
// Font Size and Preview Area
d.exec("project.view.set", json!({"fontSize": "extraLarge", "previewArea": true}));
let first = d.footage().1[0];
d.exec("project.select", json!({"items": [first]}));
d.frames(3);
assert!(d.has("project.previewArea"));
d.snapshot("project-list-preview-xl", Some("panel.Project"));
}
#[test]
fn bins_open_in_place_in_a_tab_or_in_a_window() {
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
let (bin, items) = d.footage();
d.app().session.prefs.general.bins_double_click = "openNewTab".into();
d.frames(2);
double_click_bin(&mut d, bin, false);
assert_eq!(d.app().ui.project_panel.tabs.len(), 1);
assert!(d.has("project.tab.0") && d.has("project.tab.project"));
// the tab shows the bin's items under its own ids
d.wait_for(&format!("projectBin.0.item.{}", items[0]));
d.click("project.tab.project");
assert_eq!(d.app().ui.project_panel.active_tab, None);
d.click("project.tab.0.close");
assert!(d.app().ui.project_panel.tabs.is_empty());
// Opt+double-click: Open in New Window (a floating bin panel)
double_click_bin(&mut d, bin, true);
assert!(d.app().ui.project_panel.tabs.first().is_some_and(|t| t.floating), "{:?}", d.app().ui.project_panel.tabs);
d.wait_for("projectBin.0.window");
// a second Project panel on the project
d.ok("ui.menu.invoke", json!({"id": "projectPanel.newPanel"}));
d.frames(3);
assert!(d.has("projectBin.1.window"));
d.snapshot("project-bin-windows", None);
// inline rename of a bin
d.ok("ui.menu.invoke", json!({"id": "projectPanel.rename", "params": {"bin": bin}}));
d.frames(3);
d.key("Cmd+A");
d.ok("ui.type", json!({"text": "Camera A"}));
d.key("Enter");
let name = d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).unwrap().name.clone();
assert_eq!(name, "Camera A");
}
#[test]
fn select_all_takes_only_the_shown_bin() {
// #456: Cmd+A selected every item in the project, closed bins and other tabs included
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
let (bin, items) = d.footage();
assert!(!items.is_empty());
let sorted = |v: &[ItemId]| {
let mut v: Vec = v.iter().map(|i| i.0).collect();
v.sort();
v
};
let mut footage = items.clone();
footage.sort();
// the root with the Footage bin closed: none of its items
d.app().ui.expanded_bins.clear();
d.frames(2);
d.key("Cmd+A");
let sel = d.app().session.state.project_selection.clone();
assert!(sel.iter().all(|i| !items.contains(&i.0)), "{sel:?}");
assert!(sel.iter().all(|i| d.app().session.project.root.children.contains(&BinEntry::Item(*i))), "{sel:?}");
// twirled open in List view, its items are shown and count
d.app().ui.expanded_bins.push(bin);
d.frames(2);
d.key("Cmd+A");
let sel = d.app().session.state.project_selection.clone();
assert!(items.iter().all(|i| sel.contains(&ItemId(*i))), "{sel:?}");
d.key("Cmd+Shift+A");
assert!(d.app().session.state.project_selection.is_empty());
// the bin opened in its own tab: exactly its items
d.app().ui.expanded_bins.clear();
d.app().session.prefs.general.bins_double_click = "openNewTab".into();
double_click_bin(&mut d, bin, false);
assert_eq!(d.app().ui.project_panel.active_tab, Some(0));
d.key("Cmd+A");
assert_eq!(sorted(&d.app().session.state.project_selection), footage);
}
#[test]
fn a_dragged_bin_nests_in_another_and_back_out() {
// #456: a bin stayed wherever it was created; dragging it onto another bin did nothing
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
let outer = d.exec("file.newBin", json!({"name": "Outer"}))["bin"].as_u64().unwrap();
let inner = d.exec("file.newBin", json!({"name": "Inner"}))["bin"].as_u64().unwrap();
d.app().ui.expanded_bins.clear();
d.frames(3);
let parent =
|d: &mut Driver, b: u64| filmcraft_ui_egui::panels::project::parent_bin(&d.app().session.project.root, filmcraft_project::BinId(b)).map(|p| p.0);
let root = d.app().session.project.root.id.0;
let drag = |d: &mut Driver, from: [f32; 4], to: (f32, f32)| {
let start = (from[0] + 80.0, from[1] + from[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": start.0, "y": start.1}, "to": {"x": to.0, "y": to.1}, "steps": 12}));
d.frames(3);
};
// onto the Outer row: Inner nests in it
let (from, to) = (d.rect(&format!("project.bin.{inner}")), d.rect(&format!("project.bin.{outer}")));
drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0));
assert_eq!(parent(&mut d, inner), Some(outer));
// a bin never goes into itself
d.app().ui.expanded_bins.push(outer);
d.frames(3);
let (from, to) = (d.rect(&format!("project.bin.{outer}")), d.rect(&format!("project.bin.{inner}")));
drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0));
assert_eq!(parent(&mut d, outer), Some(root));
assert_eq!(parent(&mut d, inner), Some(outer));
// onto empty space: back to the bin the view shows
let from = d.rect(&format!("project.bin.{inner}"));
let to = centre(d.rect("project.empty"));
drag(&mut d, from, to);
assert_eq!(parent(&mut d, inner), Some(root));
}
#[test]
fn footer_buttons_are_wired() {
let mut d = project_driver();
let bins0 = d.app().session.project.root.children.len();
d.click("project.button.file.newBin");
assert_eq!(d.app().session.project.root.children.len(), bins0 + 1);
d.click("project.button.find");
assert!(d.app().ui.extras.dialog.is_some(), "Find… dialog");
d.app().ui.extras.dialog = None;
d.click("project.button.new-item");
d.wait_for("project.newItem.file.newColorMatte");
let n0 = d.app().session.project.items.len();
d.click("project.newItem.file.newColorMatte");
// the New Color Matte dialog asks for the color first (#29)
assert!(d.app().ui.extras.dialog.is_some(), "New Color Matte dialog");
d.click("colorMatte.ok");
assert_eq!(d.app().session.project.items.len(), n0 + 1);
d.click("project.button.project.delete");
assert_eq!(d.app().session.project.items.len(), n0, "Clear removes the selected new matte");
for v in ["List", "Icon", "Freeform"] {
d.click(&format!("project.view.{v}"));
assert_eq!(format!("{:?}", d.app().session.prefs.project_panel.view.mode), v);
}
d.click("project.view.Icon");
assert!(d.has("project.sortIcons"));
d.click("project.sortIcons");
d.click("project.sortIcons.Name");
assert_eq!(d.app().session.prefs.project_panel.view.icon_sort.column, "Name");
}
#[test]
fn new_bin_asks_for_its_name() {
// #456: New Bin opens the name field of the new bin instead of leaving it "New Bin"
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
d.click("project.button.file.newBin");
d.frames(2);
let bin = d.app().ui.project_panel.rename.as_ref().and_then(|r| r.bin).expect("the new bin's name is being edited");
assert!(d.has("project.rename"), "the name field is shown");
d.key("Cmd+A");
d.ok("ui.type", json!({"text": "Interviews"}));
d.key("Enter");
let name = d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).unwrap().name.clone();
assert_eq!(name, "Interviews");
// with a name (agents) the bin is named directly and nothing is edited
let r = d.ok("ui.menu.invoke", json!({"id": "file.newBin", "params": {"name": "B-roll"}}));
assert!(r["bin"].as_u64().is_some(), "{r}");
assert!(d.app().ui.project_panel.rename.is_none());
}
#[test]
fn new_bin_then_clear_removes_the_bin() {
// leftover Project item selection must not keep Clear from removing the new bin
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
d.click("project.button.new-item");
d.wait_for("project.newItem.file.newColorMatte");
d.click("project.newItem.file.newColorMatte");
d.click("colorMatte.ok");
let items0 = d.app().session.project.items.len();
let bins0 = d.app().session.project.root.children.len();
assert!(!d.app().session.state.project_selection.is_empty());
d.click("project.button.file.newBin");
d.frames(2);
let bin = d.app().ui.project_panel.selected_bin.expect("the new bin is selected");
assert!(d.app().session.state.project_selection.is_empty(), "New Bin clears leftover item selection");
d.click("project.button.project.delete");
d.frames(2);
assert!(d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).is_none(), "Clear removes the new bin");
assert_eq!(d.app().session.project.root.children.len(), bins0);
assert_eq!(d.app().session.project.items.len(), items0, "clips already in the project stay");
}
// ------------------------------------------------------------------------------------ Media Browser
fn media_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("fc-mb-ui-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Selects")).unwrap();
let wav = filmcraft_media::wav::write_wav16(&[0.0; 9600], 2, 48_000);
for n in ["a-interview.wav", "b-roll.wav"] {
std::fs::write(dir.join(n), &wav).unwrap();
}
std::fs::write(dir.join("Selects").join("pick.wav"), &wav).unwrap();
std::fs::write(dir.join("readme.txt"), b"not media").unwrap();
dir
}
#[test]
fn file_dialog_import_uses_shown_bin_unless_explicit() {
let dir = media_dir("import-bin");
let path = dir.join("a-interview.wav").to_string_lossy().into_owned();
let mut app = FilmcraftApp::new(Session::default());
app.hooks.pick_files = Some(Box::new(move |_| vec![path.clone()]));
let parent = app.session.execute("file.newBin", json!({"name": "Footage"})).unwrap()["bin"].as_u64().unwrap();
let child = app.session.execute("file.newBin", json!({"name": "Selects", "parent": parent})).unwrap()["bin"].as_u64().unwrap();
let root = app.session.project.root.id.0;
app.ui.project_panel.tabs.push(filmcraft_ui_egui::panels::project::BinTab { bin: parent, ..Default::default() });
for (shown, tab, params, expected) in [
(None, None, json!({}), root),
(Some(child), None, json!({}), child),
(Some(child), Some(0), json!({}), parent),
(Some(child), None, json!({"bin": null}), root),
(Some(parent), None, json!({"bin": child}), child),
] {
app.ui.project_panel.bin = shown;
app.ui.project_panel.active_tab = tab;
let result = app.file_dialog("file.import", ¶ms).unwrap();
assert!(result["errors"].as_array().unwrap().is_empty(), "{result}");
let item = ItemId(result["items"][0].as_u64().unwrap());
let bin = app.session.project.root.find_bin(filmcraft_project::BinId(expected)).unwrap();
assert!(bin.children.contains(&BinEntry::Item(item)), "shown={shown:?}, tab={tab:?}, params={params}: expected bin {expected}");
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn media_browser_navigation_import_and_columns() {
let dir = media_dir("nav");
let d0 = dir.to_string_lossy().to_string();
let mut d = Driver::with(Session::default());
d.ok("ui.panel.show", json!({"panel": "MediaBrowser"}));
d.ok("ui.set", json!({"focused": "MediaBrowser"}));
d.ok("ui.menu.invoke", json!({"id": "window.maximizeFrame"}));
d.exec("mediaBrowser.navigate", json!({"path": d0}));
d.frames(3);
d.wait_for("mediaBrowser.entry.a-interview.wav");
assert!(d.has("mediaBrowser.entry.Selects") && !d.has("mediaBrowser.entry.readme.txt"));
for id in [
"mediaBrowser.back",
"mediaBrowser.forward",
"mediaBrowser.up",
"mediaBrowser.path",
"mediaBrowser.fileTypes",
"mediaBrowser.ingest",
"mediaBrowser.importAsImageSequence",
"mediaBrowser.tree.localDrives",
] {
assert!(d.has(id), "{id}");
}
// double-click a folder, then Back / Forward
// type a directory into the path field
d.click("mediaBrowser.path");
d.key("Cmd+A");
d.ok("ui.type", json!({"text": format!("{d0}/Selects")}));
d.key("Enter");
d.wait_for("mediaBrowser.entry.pick.wav");
d.click("mediaBrowser.back");
d.wait_for("mediaBrowser.entry.b-roll.wav");
d.click("mediaBrowser.forward");
d.wait_for("mediaBrowser.entry.pick.wav");
d.click("mediaBrowser.up");
d.wait_for("mediaBrowser.entry.b-roll.wav");
// Favorites and Recent Directories in the tree
d.click("panel.menu.MediaBrowser");
d.click("mediaBrowser.menu.favorite");
assert_eq!(d.app().session.prefs.media_browser.favorites, std::slice::from_ref(&d0));
d.frames(2);
let fav_name = dir.file_name().unwrap().to_string_lossy().to_string();
assert!(d.has(&format!("mediaBrowser.tree.favorites.{fav_name}")));
assert!(d.has("mediaBrowser.tree.recent"));
// select two files and Import from the panel menu
d.click("mediaBrowser.entry.a-interview.wav");
d.ok("ui.click", json!({"id": "mediaBrowser.entry.b-roll.wav", "command": true}));
d.frames(2);
assert_eq!(d.app().session.browser.selection.len(), 2);
assert!(d.label("mediaBrowser.count").starts_with("2 of 2"));
d.snapshot("media-browser-list", Some("panel.MediaBrowser"));
d.click("panel.menu.MediaBrowser");
d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200}));
d.frames(4);
d.click("mediaBrowser.menu.import");
assert_eq!(d.app().session.project.items.len(), 2);
// Shift+O opens the selection in the Source monitor (already imported: no duplicate)
d.click("mediaBrowser.entry.b-roll.wav");
d.ok("ui.set", json!({"focused": "MediaBrowser"}));
d.key("Shift+O");
assert!(d.app().session.state.source_item.is_some());
assert_eq!(d.app().session.project.items.len(), 2);
// Edit Columns…
d.click("panel.menu.MediaBrowser");
d.click("mediaBrowser.menu.editColumns");
d.click("mediaBrowser.columns.Audio Info");
d.click("mediaBrowser.columns.ok");
assert!(d.app().session.prefs.media_browser.columns.contains(&"Audio Info".to_string()));
d.frames(6);
assert!(d.has("mediaBrowser.header.Audio Info"));
// thumbnails view and the file-type filter
d.click("mediaBrowser.view.thumbnails");
assert_eq!(d.app().session.prefs.media_browser.view, "thumbnails");
d.wait_for("mediaBrowser.entry.b-roll.wav");
d.snapshot("media-browser-thumbnails", Some("panel.MediaBrowser"));
d.exec("mediaBrowser.settings", json!({"fileTypes": "image", "view": "list"}));
d.frames(3);
assert!(!d.has("mediaBrowser.entry.b-roll.wav") && d.has("mediaBrowser.entry.Selects"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn media_browser_drag_imports_into_the_project_panel() {
let dir = media_dir("drag");
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let mut d = Driver::with(session);
// the Editing workspace has Project and Media Browser in one group: put the browser beside it
d.ok("ui.set", json!({"workspace": "Editing"}));
d.ok("ui.panel.show", json!({"panel": "MediaBrowser"}));
d.exec("mediaBrowser.navigate", json!({"path": dir.to_string_lossy()}));
d.frames(3);
d.wait_for("mediaBrowser.entry.b-roll.wav");
let n0 = d.app().session.project.items.len();
// dropped on the Media Browser itself: the import is taken back
let e = d.rect("mediaBrowser.entry.b-roll.wav");
let from = (e[0] + 40.0, e[1] + e[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": from.0 + 30.0, "y": from.1 + 60.0}}));
d.frames(6);
assert_eq!(d.app().session.project.items.len(), n0, "dropped nowhere");
// dropped on the Timeline: imported and placed
let tl = d.rect("panel.Timeline");
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": tl[0] + tl[2] * 0.6, "y": tl[1] + tl[3] * 0.75}, "steps": 20}));
d.frames(6);
assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline");
let _ = std::fs::remove_dir_all(&dir);
}
/// Dragging several selected Media Browser files onto the Timeline (#1360) places every one of
/// them, whether it was already in the project or is imported by the drag.
#[test]
fn media_browser_drag_places_every_selected_file() {
let dir = media_dir("drag-many");
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let mut d = Driver::with(session);
d.ok("ui.set", json!({"workspace": "Editing"}));
d.ok("ui.panel.show", json!({"panel": "MediaBrowser"}));
d.exec("mediaBrowser.navigate", json!({"path": dir.to_string_lossy()}));
d.frames(3);
d.wait_for("mediaBrowser.entry.b-roll.wav");
let listed = d.exec("mediaBrowser.list", json!({}));
let path = |name: &str| listed["entries"].as_array().unwrap().iter().find(|e| e["name"] == json!(name)).unwrap()["path"].clone();
let (a, b) = (path("a-interview.wav"), path("b-roll.wav"));
// one file is already in the project, the other is imported by the drag
d.exec("mediaBrowser.import", json!({"paths": [a]}));
d.exec("mediaBrowser.select", json!({"paths": [a, b]}));
d.frames(2);
let n0 = d.app().session.project.items.len();
// clips named after each dragged file (a drop inside a long clip also splits that one)
let placed = |d: &mut Driver, name: &str| {
d.app().session.active_sequence().map_or(0, |q| q.video_tracks.iter().chain(&q.audio_tracks).flat_map(|t| &t.items).filter(|i| i.name == name).count())
};
assert_eq!((placed(&mut d, "a-interview.wav"), placed(&mut d, "b-roll.wav")), (0, 0));
let e = d.rect("mediaBrowser.entry.b-roll.wav");
let tl = d.rect("panel.Timeline");
d.ok("ui.drag", json!({"from": {"x": e[0] + 40.0, "y": e[1] + e[3] / 2.0}, "to": {"x": tl[0] + tl[2] * 0.6, "y": tl[1] + tl[3] * 0.75}, "steps": 20}));
d.frames(6);
assert_eq!(d.app().session.project.items.len(), n0 + 1, "only the new file is imported");
let status = d.app().ui.status.clone();
assert_eq!((placed(&mut d, "a-interview.wav"), placed(&mut d, "b-roll.wav")), (1, 1), "both files are placed; status: {status}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Dragging one of several selected Project items onto the Timeline places all of them, back to
/// back, as one undo step; an item outside the selection goes alone.
#[test]
fn dragging_a_selection_to_the_timeline_places_every_item() {
let mut d = Driver::demo();
let (bin, items) = d.footage();
assert!(items.len() >= 3);
d.exec("file.newSequence", json!({"name": "Drop", "video": 2, "audio": 2}));
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
let card = |i: u64| format!("project.item.{i}");
d.wait_for(&card(items[0]));
let clips = |d: &mut Driver| d.app().session.active_sequence().map(|q| q.video_tracks.iter().map(|t| t.items.len()).sum::()).unwrap_or(0);
let steps = d.app().session.history.undo.len();
let drop = json!({"id": "timeline.tracks", "fx": 0.3, "fy": 0.1});
// an item that is not selected: one clip
d.exec("project.select", json!({"items": [items[2]]}));
d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16}));
d.frames(4);
assert_eq!(clips(&mut d), 1, "an unselected item goes alone");
d.exec("edit.undo", json!({}));
// two of the selection: both, back to back, in one undo step
d.exec("project.select", json!({"items": [items[0], items[1]]}));
d.frames(2);
d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16}));
d.frames(4);
assert_eq!(clips(&mut d), 2, "the whole selection is placed");
let mut v1: Vec<(i64, i64)> =
d.app().session.active_sequence().unwrap().video_tracks.iter().flat_map(|t| t.items.iter()).map(|i| (i.start.0, i.end().0)).collect();
v1.sort();
assert_eq!(v1[0].1, v1[1].0, "back to back");
assert_eq!(d.app().session.history.undo.len(), steps + 1, "the drag is one undo step");
d.exec("edit.undo", json!({}));
assert_eq!(clips(&mut d), 0);
}
/// Double-clicking empty space in the Project panel opens Import, also between and beside the
/// Icon view's cards once the project has some. Only the strip under the last row used to answer,
/// so with a project loaded a double-click in the visible blank space did nothing. Real pointer
/// input at 60 frames per second, so the two clicks are a real double-click.
#[test]
fn double_click_empty_area_imports_with_a_project_loaded() {
fn driver(demo: bool) -> (Driver, std::sync::Arc) {
let mut session = Session::default();
if demo {
session.execute("file.openDemoProject", json!({})).expect("demo project");
}
let (tx, rx) = channel();
let mut app = FilmcraftApp::new(session).with_control(rx);
let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = picks.clone();
app.hooks.pick_files = Some(Box::new(move |_| {
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Vec::new()
}));
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, snapshots: None };
d.frames(10);
(d, picks)
}
fn double_click(d: &mut Driver, at: egui::Pos2) {
let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e);
// a pause first, as a person makes: clicks closer together continue the last gesture
// (a triple click) instead of starting a double-click
d.frames(60);
push(d, egui::Event::PointerMoved(at));
d.frames(1);
for _ in 0..2 {
for pressed in [true, false] {
push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
d.frames(1);
}
}
d.frames(3);
}
let count = |p: &std::sync::Arc| p.load(std::sync::atomic::Ordering::SeqCst);
// an empty project: the whole panel is the empty area
let (mut d, picks) = driver(false);
let r = d.rect("project.empty");
double_click(&mut d, egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0));
assert_eq!(count(&picks), 1, "empty project");
// the demo project, Icon view: the gap between the first two bins, and the space right of the
// last card in the first row
let (mut d, picks) = driver(true);
d.ok("ui.set", json!({}));
let (a, b) = (d.rect("project.bin.1"), d.rect("project.bin.2"));
assert!((a[1] - b[1]).abs() < 1.0 && b[0] > a[0] + a[2], "bins 1 and 2 share a row: {a:?} {b:?}");
double_click(&mut d, egui::pos2((a[0] + a[2] + b[0]) / 2.0, a[1] + a[3] / 2.0));
assert_eq!(count(&picks), 1, "between two bins");
let row: Vec<[f32; 4]> = d.ids("project.bin.").iter().map(|id| d.rect(id)).filter(|r| (r[1] - a[1]).abs() < 1.0).collect();
let last = row.iter().fold(a, |m, r| if r[0] > m[0] { *r } else { m });
let panel = d.rect("project.count");
if last[0] + last[2] + 30.0 < panel[0] + panel[2] {
double_click(&mut d, egui::pos2(last[0] + last[2] + 20.0, last[1] + last[3] / 2.0));
assert_eq!(count(&picks), 2, "right of the last card");
}
// a card still takes its own double-click: the bin opens, Import does not
double_click(&mut d, egui::pos2(a[0] + a[2] / 2.0, a[1] + a[3] / 2.0));
let picked = count(&picks);
assert!(picked <= 2, "double-clicking a bin must not open Import");
}
/// The report: with audio placed in the timeline, double-clicking the Project panel's empty space
/// no longer opened Import. A new sequence with a tone on A1 (so the panel shows the sequence and
/// the audio clip as cards); every empty spot opens Import: the strip under the cards and the space
/// beside them.
#[test]
fn double_click_imports_with_audio_in_the_timeline() {
let mut session = Session::default();
session.execute("file.newSequence", json!({"name": "Mix", "audio": 2, "video": 1})).unwrap();
let inter: Vec = (0..48_000).flat_map(|i| [(i as f32 * 0.0576).sin() * 0.5; 2]).collect();
let bytes: std::sync::Arc<[u8]> = filmcraft_engine::previews::write_wav_f32(&inter, 48_000).into();
let item = filmcraft_engine::commands::import_bytes(&mut session, "/tone.wav", bytes, None).unwrap();
session.execute("timeline.place", json!({"item": item.0, "audioTrack": "A1", "seconds": 0.0})).unwrap();
let (tx, rx) = channel();
let mut app = FilmcraftApp::new(session).with_control(rx);
let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = picks.clone();
app.hooks.pick_files = Some(Box::new(move |_| {
seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Vec::new()
}));
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, snapshots: None };
d.frames(10);
let double_click = |d: &mut Driver, at: egui::Pos2| {
let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e);
d.frames(60);
push(d, egui::Event::PointerMoved(at));
d.frames(1);
for _ in 0..2 {
for pressed in [true, false] {
push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
d.frames(1);
}
}
d.frames(3);
};
let count = || picks.load(std::sync::atomic::Ordering::SeqCst);
let cards: Vec<[f32; 4]> = d.ids("project.item.").iter().filter(|id| id.matches('.').count() == 2).map(|id| d.rect(id)).collect();
assert!(!cards.is_empty(), "the sequence and the tone show as cards");
let empty = d.rect("project.empty");
eprintln!("cards {cards:?}, empty strip {empty:?}");
double_click(&mut d, egui::pos2(empty[0] + empty[2] / 2.0, empty[1] + empty[3] / 2.0));
let strip = count();
let last = cards.iter().fold(cards[0], |m, r| if r[0] > m[0] { *r } else { m });
double_click(&mut d, egui::pos2(last[0] + last[2] + 40.0, last[1] + last[3] / 2.0));
let beside = count() - strip;
assert_eq!((strip, beside), (1, 1), "double-click on (the strip under the cards, the space beside them)");
}
// ---- marquee selection (#578)
/// The Footage bin open in place in `view`, with its items' rects (row by row, left to right).
fn footage_in(d: &mut Driver, view: &str) -> Vec<(u64, [f32; 4])> {
let (bin, items) = d.footage();
d.exec("project.view.set", json!({"view": view}));
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
d.wait_for(&format!("project.item.{}", items[0]));
let mut rects: Vec<(u64, [f32; 4])> = items.iter().map(|i| (*i, d.rect(&format!("project.item.{i}")))).collect();
rects.sort_by(|a, b| (a.1[1], a.1[0]).partial_cmp(&(b.1[1], b.1[0])).unwrap());
rects
}
fn selection(d: &mut Driver) -> Vec {
let mut s: Vec = d.app().session.state.project_selection.iter().map(|i| i.0).collect();
s.sort_unstable();
s
}
fn sorted(mut v: Vec) -> Vec {
v.sort_unstable();
v
}
fn centre(r: [f32; 4]) -> (f32, f32) {
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
}
fn marquee(d: &mut Driver, from: (f32, f32), to: (f32, f32), shift: bool) {
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": to.0, "y": to.1}, "steps": 12, "modifiers": {"shift": shift}}));
d.frames(3);
}
#[test]
fn marquee_selects_cards_in_icon_view() {
let mut d = project_driver();
let cards = footage_in(&mut d, "icon");
let [(i0, a), (i1, b), (i2, c)] = [cards[0], cards[1], cards[2]];
assert!((a[1] - c[1]).abs() < 1.0, "the first three cards share a row");
// from the gap above-left of the first card to the middle of the second
marquee(&mut d, (a[0] - 4.0, a[1] - 4.0), centre(b), false);
assert_eq!(selection(&mut d), sorted(vec![i0, i1]));
// Shift adds
marquee(&mut d, (c[0] + 4.0, c[1] - 4.0), centre(c), true);
assert_eq!(selection(&mut d), sorted(vec![i0, i1, i2]));
// a plain drag replaces
marquee(&mut d, (a[0] - 4.0, a[1] - 4.0), centre(a), false);
assert_eq!(selection(&mut d), vec![i0]);
// the gaps between cards are empty space: a click there deselects
d.ok("ui.click", json!({"x": a[0] - 4.0, "y": a[1] - 4.0}));
d.frames(2);
assert!(selection(&mut d).is_empty());
}
#[test]
fn escape_cancels_a_marquee_and_keeps_the_selection() {
let mut d = project_driver();
let cards = footage_in(&mut d, "icon");
let [(i0, a), (i1, b), (i2, c)] = [cards[0], cards[1], cards[2]];
d.exec("project.select", json!({"items": [i2]}));
let send = |d: &mut Driver, e: egui::Event| {
d.harness.input_mut().events.push(e);
d.frames(1);
};
let (from, to) = (egui::pos2(a[0] - 4.0, a[1] - 4.0), egui::pos2(centre(b).0, centre(b).1));
send(&mut d, egui::Event::PointerMoved(from));
send(&mut d, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
for k in 1..=10 {
send(&mut d, egui::Event::PointerMoved(from.lerp(to, k as f32 / 10.0)));
}
assert_eq!(selection(&mut d), sorted(vec![i0, i1]), "the selection follows the rectangle");
send(&mut d, egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
let end = egui::pos2(centre(c).0, centre(c).1);
send(&mut d, egui::Event::PointerMoved(end));
send(&mut d, egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
d.frames(2);
assert_eq!(selection(&mut d), vec![i2], "Escape restored the selection the drag began with");
}
#[test]
fn only_the_left_button_draws_a_marquee() {
let mut d = project_driver();
let cards = footage_in(&mut d, "icon");
let [(i0, a), (_, b)] = [cards[0], cards[1]];
d.exec("project.select", json!({"items": [i0]}));
let (from, to) = (egui::pos2(a[0] - 4.0, a[1] - 4.0), egui::pos2(centre(b).0, centre(b).1));
for button in [egui::PointerButton::Middle, egui::PointerButton::Secondary] {
let mut send = |e: egui::Event| {
d.harness.input_mut().events.push(e);
d.frames(1);
};
send(egui::Event::PointerMoved(from));
send(egui::Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() });
for k in 1..=10 {
send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 10.0)));
}
send(egui::Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() });
d.key("Escape");
assert_eq!(selection(&mut d), vec![i0], "{button:?} drag left the selection alone");
assert!(!d.has("project.marquee"));
}
}
#[test]
fn marquee_selects_rows_in_list_view() {
let mut d = project_driver();
let rows = footage_in(&mut d, "list");
let n = rows.len();
let (last, before_last) = (rows[n - 1], rows[n - 2]);
let empty = d.rect("project.empty");
// from the empty space under the rows up into the second-to-last row
marquee(&mut d, (empty[0] + 60.0, empty[1] + 20.0), (empty[0] + 60.0, centre(before_last.1).1), false);
assert_eq!(selection(&mut d), sorted(vec![last.0, before_last.0]));
}
#[test]
fn marquee_selects_cards_in_freeform_view() {
let mut d = project_driver();
let cards = footage_in(&mut d, "freeform");
let [(i0, a), (i1, b)] = [cards[0], cards[1]];
assert!((a[1] - b[1]).abs() < 1.0, "the first two cards share a row");
marquee(&mut d, (a[0] - 6.0, a[1] - 6.0), centre(b), false);
assert_eq!(selection(&mut d), sorted(vec![i0, i1]));
assert!(!d.app().session.project.item(ItemId(i0)).unwrap().metadata.contains_key(FREEFORM_POS), "the marquee moved no card");
}
/// The Project selection can hold a bin beside items (one id space). Dragging the items still
/// places them: the bin is left out instead of failing the whole drop.
#[test]
fn a_bin_in_the_selection_does_not_stop_the_items_being_placed() {
let mut d = Driver::demo();
let (bin, items) = d.footage();
d.exec("file.newSequence", json!({"name": "Drop", "video": 2, "audio": 2}));
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
d.wait_for(&format!("project.item.{}", items[0]));
d.exec("project.select", json!({"items": [items[0], bin, items[1]]}));
d.frames(2);
d.ok("ui.drag", json!({"from": {"id": format!("project.item.{}", items[0])}, "to": {"id": "timeline.tracks", "fx": 0.3, "fy": 0.1}, "steps": 16}));
d.frames(4);
let clips = d.app().session.active_sequence().map(|q| q.video_tracks.iter().map(|t| t.items.len()).sum::()).unwrap_or(0);
assert_eq!(clips, 2, "status: {}", d.app().ui.status);
}
/// Escape during a Media Browser drag (#580) takes the import back, even over the Timeline.
#[test]
fn escape_takes_back_a_media_browser_drag() {
let dir = media_dir("drag-escape");
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let mut d = Driver::with(session);
d.ok("ui.set", json!({"workspace": "Editing"}));
d.ok("ui.panel.show", json!({"panel": "MediaBrowser"}));
d.exec("mediaBrowser.navigate", json!({"path": dir.to_string_lossy()}));
d.frames(3);
d.wait_for("mediaBrowser.entry.b-roll.wav");
let (n0, before) = (d.app().session.project.items.len(), d.app().session.project.to_json());
let e = d.rect("mediaBrowser.entry.b-roll.wav");
let tl = d.rect("panel.Timeline");
let (from, to) = (egui::pos2(e[0] + 40.0, e[1] + e[3] / 2.0), egui::pos2(tl[0] + tl[2] * 0.6, tl[1] + tl[3] * 0.75));
let mut send = |e: egui::Event| {
d.harness.input_mut().events.push(e);
d.frames(1);
};
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
send(egui::Event::PointerMoved(from));
send(button(from, true));
for k in 1..=20 {
send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 20.0)));
if k == 10 {
send(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
}
}
send(button(to, false));
d.frames(6);
assert_eq!(d.app().session.project.items.len(), n0, "the import was taken back");
assert_eq!(d.app().session.project.to_json(), before, "nothing was placed on the Timeline");
let _ = std::fs::remove_dir_all(&dir);
}