404 lines
18 KiB
Rust
404 lines
18 KiB
Rust
//! The Settings dialog, driven headless through the control channel: opening categories from the
|
|
//! `app.settings.<category>` commands and Cmd+,, editing fields by automation id (checkboxes,
|
|
//! dropdowns, numbers, label names), OK / Cancel / Escape / Reset… semantics, and settings that
|
|
//! change the UI right away (theme, label names in the Edit ▸ Label menu, tooltips).
|
|
//!
|
|
//! With `FILMCRAFT_UI_SHOTS=<dir>` the test also renders the UI with wgpu and writes
|
|
//! `settings-*.png`.
|
|
|
|
use std::sync::mpsc::{Sender, channel};
|
|
|
|
use egui_kittest::Harness;
|
|
use filmcraft_engine::Session;
|
|
use filmcraft_ui_egui::FilmcraftApp;
|
|
use filmcraft_ui_egui::control::ControlRequest;
|
|
use serde_json::{Value, json};
|
|
|
|
struct Driver {
|
|
harness: Harness<'static, FilmcraftApp>,
|
|
tx: Sender<ControlRequest>,
|
|
shots: Option<std::path::PathBuf>,
|
|
}
|
|
|
|
impl Driver {
|
|
fn demo() -> Self {
|
|
let mut session = Session::default();
|
|
session.execute("file.openDemoProject", json!({})).expect("demo project");
|
|
let (tx, rx) = channel();
|
|
let app = FilmcraftApp::new(session).with_control(rx);
|
|
let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from);
|
|
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
|
|
if shots.is_some() {
|
|
b = b.wgpu().with_pixels_per_point(1.0);
|
|
}
|
|
let harness = b.build_eframe(move |_cc| app);
|
|
let mut d = Driver { harness, tx, shots };
|
|
d.frames(4);
|
|
d
|
|
}
|
|
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 r = self.call(method, params.clone());
|
|
assert_eq!(r["ok"], true, "{method} {params}: {r}");
|
|
r["result"].clone()
|
|
}
|
|
fn exec(&mut self, command: &str, params: Value) -> Value {
|
|
self.ok("engine.execute", json!({"command": command, "params": params}))
|
|
}
|
|
fn menu(&mut self, id: &str) -> Value {
|
|
let r = self.ok("ui.menu.invoke", json!({"id": id}));
|
|
self.frames(3);
|
|
r
|
|
}
|
|
fn click(&mut self, id: &str) {
|
|
self.ok("ui.click", json!({"id": id}));
|
|
self.frames(3);
|
|
}
|
|
fn has(&mut self, id: &str) -> bool {
|
|
let v = self.ok("ui.elements", json!({"prefix": id}));
|
|
v.as_array().unwrap().iter().any(|e| e["id"] == id)
|
|
}
|
|
fn inspect(&mut self) -> Value {
|
|
self.ok("ui.inspect", json!({}))
|
|
}
|
|
/// The open dialog's draft (`UiState::settings`), Null when closed.
|
|
fn draft(&mut self) -> Value {
|
|
self.inspect()["ui"]["settings"].clone()
|
|
}
|
|
fn pref(&mut self, key: &str) -> Value {
|
|
self.exec("prefs.get", json!({"key": key}))
|
|
}
|
|
fn shot(&mut self, name: &str) {
|
|
let Some(dir) = self.shots.clone() else { return };
|
|
self.frames(4);
|
|
let img = self.harness.render().expect("wgpu render");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
img.save(dir.join(format!("settings-{name}.png"))).unwrap();
|
|
}
|
|
}
|
|
|
|
const CATEGORIES: [&str; 16] = [
|
|
"general",
|
|
"appearance",
|
|
"audio",
|
|
"audioHardware",
|
|
"autoSave",
|
|
"color",
|
|
"graphics",
|
|
"labels",
|
|
"media",
|
|
"mediaAnalysis",
|
|
"mediaCache",
|
|
"memory",
|
|
"playback",
|
|
"plugins",
|
|
"timeline",
|
|
"trim",
|
|
];
|
|
|
|
#[test]
|
|
fn every_category_opens_from_its_command_and_menu() {
|
|
let mut d = Driver::demo();
|
|
let menu = d.ok("ui.menu.list", json!({}));
|
|
for c in CATEGORIES {
|
|
let id = format!("app.settings.{c}");
|
|
let item = menu.as_array().unwrap().iter().find(|m| m["id"] == id.as_str()).unwrap_or_else(|| panic!("no menu item {id}"));
|
|
assert_eq!(item["path"], json!(["Edit", "Preferences"]));
|
|
}
|
|
let general = menu.as_array().unwrap().iter().find(|m| m["id"] == "app.settings.general").unwrap();
|
|
assert_eq!(general["shortcut"], "Cmd+,");
|
|
for c in CATEGORIES {
|
|
let r = d.menu(&format!("app.settings.{c}"));
|
|
assert_eq!(r["page"], c);
|
|
let ui = d.inspect();
|
|
assert_eq!(ui["dialog"], "Preferences");
|
|
assert_eq!(ui["ui"]["settings"]["page"], c);
|
|
for other in CATEGORIES {
|
|
assert!(d.has(&format!("settings.category.{other}")), "category list row {other} on {c}");
|
|
}
|
|
assert!(d.has("settings.ok") && d.has("settings.cancel") && d.has("settings.reset") && d.has("settings.help"));
|
|
d.shot(c);
|
|
d.click("settings.cancel");
|
|
assert!(d.inspect()["dialog"].is_null(), "Cancel closes ({c})");
|
|
}
|
|
// the first field of a few pages, by automation id
|
|
for (c, field) in [
|
|
("general", "settings.general.atStartup"),
|
|
("timeline", "settings.timeline.stillImageDuration"),
|
|
("timeline", "settings.timeline.stillImageUnit"),
|
|
("trim", "settings.trim.largeTrimOffset"),
|
|
("labels", "settings.labels.colors.violet.name"),
|
|
("labels", "settings.labels.defaults.movie"),
|
|
("autoSave", "settings.autoSave.enabled"),
|
|
("mediaCache", "settings.mediaCache.clean"),
|
|
("audioHardware", "settings.audioHardware.defaultOutput"),
|
|
("appearance", "settings.appearance.highlightColor"),
|
|
] {
|
|
d.menu(&format!("app.settings.{c}"));
|
|
assert!(d.has(field), "{field} on {c}");
|
|
d.click("settings.cancel");
|
|
}
|
|
assert!(d.exec("prefs.get", json!({})).is_object());
|
|
// unknown category
|
|
let r = d.call("ui.menu.invoke", json!({"id": "app.settings.nope"}));
|
|
assert_eq!(r["ok"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn cmd_comma_opens_general_and_the_list_switches_pages() {
|
|
let mut d = Driver::demo();
|
|
d.ok("ui.key", json!({"key": "Cmd+,"}));
|
|
d.frames(3);
|
|
assert_eq!(d.draft()["page"], "general");
|
|
d.click("settings.category.playback");
|
|
assert_eq!(d.draft()["page"], "playback");
|
|
assert!(d.has("settings.playback.stepManyFrames"));
|
|
assert!(!d.has("settings.general.atStartup"), "only the shown page's fields are on screen");
|
|
// Escape cancels
|
|
d.ok("ui.key", json!({"key": "Escape"}));
|
|
d.frames(3);
|
|
assert!(d.draft().is_null());
|
|
}
|
|
|
|
#[test]
|
|
fn checkbox_edits_apply_on_ok_and_not_on_cancel() {
|
|
let mut d = Driver::demo();
|
|
d.menu("app.settings.trim");
|
|
assert_eq!(d.draft()["values"]["trim"]["selectionToolRollRipple"], false);
|
|
d.click("settings.trim.selectionToolRollRipple");
|
|
assert_eq!(d.draft()["values"]["trim"]["selectionToolRollRipple"], true, "the draft changes");
|
|
assert_eq!(d.pref("trim.selectionToolRollRipple"), false, "nothing applied before OK");
|
|
d.click("settings.cancel");
|
|
assert_eq!(d.pref("trim.selectionToolRollRipple"), false, "Cancel discards");
|
|
|
|
d.menu("app.settings.trim");
|
|
d.click("settings.trim.selectionToolRollRipple");
|
|
d.click("settings.category.timeline");
|
|
d.click("settings.timeline.snapPlayhead");
|
|
d.click("settings.ok");
|
|
assert!(d.draft().is_null(), "OK closes");
|
|
assert_eq!(d.pref("trim.selectionToolRollRipple"), true, "edits on several pages apply together");
|
|
assert_eq!(d.pref("timeline.snapPlayhead"), false, "on by default (as in Premiere), the click turns it off");
|
|
}
|
|
|
|
#[test]
|
|
fn dropdowns_numbers_and_reset() {
|
|
let mut d = Driver::demo();
|
|
d.menu("app.settings.timeline");
|
|
// dropdown: open it, pick an item by its automation id
|
|
d.click("settings.timeline.autoScroll");
|
|
assert!(d.has("settings.timeline.autoScroll.smoothScroll"), "items of the open dropdown carry ids");
|
|
d.click("settings.timeline.autoScroll.smoothScroll");
|
|
assert_eq!(d.draft()["values"]["timeline"]["autoScroll"], "smoothScroll");
|
|
// duration unit dropdown
|
|
d.click("settings.timeline.stillImageUnit");
|
|
d.click("settings.timeline.stillImageUnit.frames");
|
|
// numbers through ui.set on the draft (agents) …
|
|
d.ok("ui.set", json!({"settings": {"values": {"timeline.stillImageDuration": 48}}}));
|
|
d.click("settings.ok");
|
|
assert_eq!(d.pref("timeline.autoScroll"), "smoothScroll");
|
|
assert_eq!(d.pref("timeline.stillImageUnit"), "frames");
|
|
assert_eq!(d.pref("timeline.stillImageDuration"), 48.0);
|
|
// … or typed into the field
|
|
d.menu("app.settings.playback");
|
|
d.ok("ui.click", json!({"id": "settings.playback.stepManyFrames"}));
|
|
d.frames(3);
|
|
d.ok("ui.key", json!({"key": "Cmd+A"}));
|
|
d.ok("ui.type", json!({"text": "9"}));
|
|
d.ok("ui.key", json!({"key": "Enter"}));
|
|
d.frames(3);
|
|
assert_eq!(d.draft()["values"]["playback"]["stepManyFrames"], 9);
|
|
d.click("settings.ok");
|
|
assert_eq!(d.pref("playback.stepManyFrames"), 9);
|
|
// Reset… puts the page back to its defaults (applied with OK)
|
|
d.menu("app.settings.timeline");
|
|
d.click("settings.reset");
|
|
assert_eq!(d.draft()["values"]["timeline"]["autoScroll"], "pageScroll");
|
|
assert_eq!(d.pref("timeline.autoScroll"), "smoothScroll", "not yet applied");
|
|
d.click("settings.ok");
|
|
assert_eq!(d.pref("timeline.autoScroll"), "pageScroll");
|
|
assert_eq!(d.pref("playback.stepManyFrames"), 9, "other pages untouched");
|
|
// ui.set rejects unknown keys
|
|
d.menu("app.settings.trim");
|
|
let r = d.call("ui.set", json!({"settings": {"values": {"trim.nope": 1}}}));
|
|
assert_eq!(r["ok"], false);
|
|
d.ok("ui.set", json!({"settings": {"page": "memory"}}));
|
|
assert_eq!(d.draft()["page"], "memory");
|
|
}
|
|
|
|
#[test]
|
|
fn view_menu_and_settings_name_each_color_theme_alike() {
|
|
let mut d = Driver::demo();
|
|
let menu = d.ok("ui.menu.list", json!({}));
|
|
let menu_label = |id: &str| menu.as_array().unwrap().iter().find(|m| m["id"] == id).unwrap()["label"].as_str().unwrap().to_string();
|
|
d.menu("app.settings.appearance");
|
|
// the theme cards' choices (the single Color Theme is no longer drawn)
|
|
for (menu_id, id) in [
|
|
("view.theme.dark", "settings.appearance.darkTheme.darkest"),
|
|
("view.theme.medium", "settings.appearance.darkTheme.dark"),
|
|
("view.theme.light", "settings.appearance.lightTheme.light"),
|
|
] {
|
|
let found = d.ok("ui.elements", json!({"prefix": id}));
|
|
let choice = found.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap();
|
|
assert_eq!(menu_label(menu_id), choice["label"].as_str().unwrap(), "{menu_id} and {id} name the same theme");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn appearance_labels_and_tooltips_take_effect() {
|
|
let mut d = Driver::demo();
|
|
assert_eq!(d.inspect()["ui"]["dark"], true);
|
|
d.menu("app.settings.appearance");
|
|
// the Appearance Mode selector above the light and dark theme cards
|
|
assert!(d.has("settings.appearance.lightTheme.light") && d.has("settings.appearance.darkTheme.dark"));
|
|
assert!(!d.has("settings.appearance.colorTheme"), "the single Color Theme is not shown");
|
|
// Each radio belongs below its preview, even though the cards are laid out side by side.
|
|
let elements = d.ok("ui.elements", json!({"prefix": "settings.appearance."}));
|
|
let top = |id: &str| elements.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap()["rect"][1].as_f64().unwrap();
|
|
for (card, radio) in
|
|
[("settings.appearance.lightTheme", "settings.appearance.lightTheme.light"), ("settings.appearance.darkTheme", "settings.appearance.darkTheme.darkest")]
|
|
{
|
|
assert!(top(radio) > top(card) + 128.0, "{radio} sits below its preview");
|
|
}
|
|
d.click("settings.appearance.darkTheme.dark");
|
|
d.click("settings.appearance.appearanceMode");
|
|
d.click("settings.appearance.appearanceMode.light");
|
|
d.ok("ui.set", json!({"settings": {"values": {"appearance.highlightColor": "#e0457b"}}}));
|
|
d.click("settings.ok");
|
|
d.frames(2);
|
|
assert_eq!(d.inspect()["ui"]["dark"], false, "Light theme applied");
|
|
assert_eq!((d.pref("appearance.appearanceMode"), d.pref("appearance.darkTheme")), (json!("light"), json!("dark")));
|
|
// the header button cycles Auto, Light, Dark
|
|
d.click("header.appearance");
|
|
assert_eq!(d.pref("appearance.appearanceMode"), "dark");
|
|
assert_eq!(d.harness.state().tokens.kind, filmcraft_ui_egui::theme::ThemeKind::Medium, "the saved dark theme");
|
|
d.click("header.appearance");
|
|
assert_eq!(d.pref("appearance.appearanceMode"), "auto");
|
|
d.menu("view.appearanceMode.next");
|
|
assert_eq!(d.pref("appearance.appearanceMode"), "light");
|
|
assert_eq!(d.pref("appearance.highlightColor"), "#e0457b");
|
|
assert_eq!(d.harness.state().tokens.accent, egui::Color32::from_rgb(0xe0, 0x45, 0x7b));
|
|
// View ▸ Appearance writes the setting
|
|
d.menu("view.theme.dark");
|
|
assert_eq!(d.pref("appearance.colorTheme"), "darkest");
|
|
assert_eq!((d.pref("appearance.appearanceMode"), d.pref("appearance.darkTheme")), (json!("dark"), json!("darkest")));
|
|
assert_eq!(d.inspect()["ui"]["dark"], true);
|
|
|
|
// label names show in Edit ▸ Label
|
|
d.menu("app.settings.labels");
|
|
d.ok("ui.click", json!({"id": "settings.labels.colors.rose.name"}));
|
|
d.frames(2);
|
|
d.ok("ui.key", json!({"key": "Cmd+A"}));
|
|
d.ok("ui.type", json!({"text": "Interview"}));
|
|
d.frames(2);
|
|
assert_eq!(d.draft()["values"]["labels"]["colors"]["rose"]["name"], "Interview");
|
|
d.click("settings.category.labels");
|
|
d.click("settings.labels.defaults.sequence");
|
|
d.click("settings.labels.defaults.sequence.Teal");
|
|
d.click("settings.ok");
|
|
let menu = d.ok("ui.menu.list", json!({}));
|
|
let rose = menu.as_array().unwrap().iter().find(|m| m["id"] == "edit.label.rose").unwrap();
|
|
assert_eq!(rose["label"], "Interview");
|
|
let r = d.exec("file.newSequence", json!({"name": "Labelled"}));
|
|
let seq = r["sequence"].as_u64().unwrap();
|
|
assert_eq!(d.harness.state().session.project.item(filmcraft_project::ItemId(seq)).unwrap().label, filmcraft_project::Label::Teal);
|
|
|
|
// Show Tool Tips off → egui never shows them
|
|
d.menu("app.settings.general");
|
|
d.click("settings.general.showToolTips");
|
|
d.click("settings.ok");
|
|
let delay = d.harness.ctx.global_style().interaction.tooltip_delay;
|
|
assert!(delay > 1.0e6, "tooltips disabled: delay {delay}");
|
|
d.exec("prefs.set", json!({"key": "general.showToolTips", "value": true}));
|
|
d.frames(2);
|
|
assert!(d.harness.ctx.global_style().interaction.tooltip_delay < 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_and_media_cache_pages() {
|
|
let mut d = Driver::demo();
|
|
d.menu("app.settings.memory");
|
|
d.ok("ui.set", json!({"settings": {"values": {"memory.frameCacheMb": 256}}}));
|
|
d.click("settings.ok");
|
|
assert_eq!(d.harness.state().frames.cache_usage().1, 256 << 20, "frame cache budget follows Memory");
|
|
// Media Cache: no data directory in this session → the button reports it
|
|
d.menu("app.settings.mediaCache");
|
|
assert!(d.draft()["cache_info"].is_object());
|
|
d.click("settings.mediaCache.clean");
|
|
assert!(d.draft()["message"].as_str().unwrap().contains("no media cache"), "{}", d.draft());
|
|
d.click("settings.cancel");
|
|
// play after rendering previews follows Timeline
|
|
d.exec("prefs.set", json!({"key": "timeline.playAfterRendering", "value": false}));
|
|
d.frames(2);
|
|
assert_eq!(d.inspect()["ui"]["play_after_render"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn snap_playhead_and_return_to_beginning() {
|
|
let mut d = Driver::demo();
|
|
// At playback end, return to beginning
|
|
let dur = d.harness.state().session.active_sequence().unwrap().duration();
|
|
d.exec("playhead.set", json!({"time": dur.0}));
|
|
d.exec("prefs.set", json!({"key": "timeline.returnToBeginning", "value": false}));
|
|
d.frames(2);
|
|
d.ok("ui.playback", json!({"action": "play"}));
|
|
let ph = d.harness.state().session.playhead();
|
|
d.ok("ui.playback", json!({"action": "stop"}));
|
|
assert!(ph.0 > 0, "stays at the end");
|
|
d.exec("prefs.set", json!({"key": "timeline.returnToBeginning", "value": true}));
|
|
d.exec("playhead.set", json!({"time": dur.0}));
|
|
d.ok("ui.playback", json!({"action": "play"}));
|
|
let ph = d.harness.state().session.playhead();
|
|
d.ok("ui.playback", json!({"action": "stop"}));
|
|
assert!(ph.0 < dur.0 / 2, "restarts from the beginning: {ph:?}");
|
|
}
|
|
|
|
/// #164: dragging the playhead in the ruler snaps to a cut, and moves freely away from one
|
|
/// (it used to snap onto its own position and only move in jumps).
|
|
#[test]
|
|
fn playhead_drag_snaps_to_cuts() {
|
|
let mut d = Driver::demo();
|
|
d.frames(2);
|
|
let seq = d.harness.state().session.active_sequence().unwrap().clone();
|
|
let v1 = &seq.video_tracks[0];
|
|
let cut = v1.items[1].start;
|
|
let id = v1.items[1].id.0;
|
|
let rect = |d: &mut Driver, id: &str| -> [f64; 4] {
|
|
let v = d.ok("ui.elements", json!({"prefix": id}));
|
|
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")).clone();
|
|
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
|
[r[0], r[1], r[2], r[3]]
|
|
};
|
|
let clip = rect(&mut d, &format!("timeline.clip.{id}"));
|
|
let ruler = rect(&mut d, "timeline.ruler");
|
|
let y = ruler[1] + ruler[3] / 2.0;
|
|
let (from, near) = (clip[0] + 40.0, clip[0] + 3.0);
|
|
d.ok("ui.drag", json!({"from": {"x": from, "y": y}, "to": {"x": near, "y": y}, "steps": 8}));
|
|
d.frames(2);
|
|
assert_eq!(d.harness.state().session.playhead(), cut, "lands on the cut");
|
|
// a step at a time from the cut, it follows the mouse instead of sticking
|
|
d.ok("ui.drag", json!({"from": {"x": clip[0] + 30.0, "y": y}, "to": {"x": clip[0] + 22.0, "y": y}, "steps": 8}));
|
|
d.frames(2);
|
|
let ph = d.harness.state().session.playhead();
|
|
assert!(ph > cut, "moves off the cut freely: {ph:?} vs {cut:?}");
|
|
}
|