Merge pull request #391 from txiki739/fix/export-menu-paths
#382: File > Export entries work from the menus
This commit is contained in:
commit
6a240ca701
|
|
@ -172,6 +172,17 @@ const SCREENSHOT_TIMEOUT_S: f64 = 10.0;
|
|||
/// timeline zoom animation finished) before capturing what is there.
|
||||
const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0;
|
||||
|
||||
/// File ▸ Export entries that run from the menus through the save panel: command, filter label,
|
||||
/// extension.
|
||||
pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 6] = [
|
||||
("file.exportEdl", "EDL", "edl"),
|
||||
("file.exportFcp7Xml", "Final Cut Pro XML", "xml"),
|
||||
("file.exportFcpxml", "FCPXML", "fcpxml"),
|
||||
("file.exportOtio", "OpenTimelineIO", "otio"),
|
||||
("file.exportAle", "Avid Log Exchange", "ale"),
|
||||
("file.exportSelectionProject", "FilmCraft Project", "fcproj"),
|
||||
];
|
||||
|
||||
pub struct FilmcraftApp {
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
|
|
@ -1007,10 +1018,40 @@ impl FilmcraftApp {
|
|||
p["path"] = json!(path);
|
||||
self.session.execute("captions.export", p).map_err(|e| e.to_string())
|
||||
}
|
||||
_ => Err(format!("no dialog for {id}")),
|
||||
_ => match EXPORT_SAVE_DIALOGS.iter().find(|(c, ..)| *c == id) {
|
||||
Some(&(_, filter, ext)) => self.export_save_dialog(id, filter, ext, params),
|
||||
None => Err(format!("no dialog for {id}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// File ▸ Export ▸ EDL…, Final Cut Pro XML… etc. from the menus (#382): ask where to save, then
|
||||
/// run the command with that `path` and the rest of `params`.
|
||||
fn export_save_dialog(&mut self, id: &str, filter: &str, ext: &str, params: &Value) -> Result<Value, String> {
|
||||
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&self.session))?;
|
||||
let Some(pick) = self.hooks.pick_save_as.as_mut() else {
|
||||
return Err("no save dialog available: run the command with a `path`".into());
|
||||
};
|
||||
// Timelines are named after the sequence; a selection must not suggest the open project's file.
|
||||
let sequence = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone());
|
||||
let stem = match id {
|
||||
"file.exportAle" => self.session.project.name.clone(),
|
||||
"file.exportSelectionProject" => format!("{} selection", self.session.project.name),
|
||||
_ => sequence.unwrap_or_else(|| self.session.project.name.clone()),
|
||||
};
|
||||
let stem: String = stem.chars().map(|c| if matches!(c, '/' | '\\') || c.is_control() { '-' } else { c }).collect();
|
||||
let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem };
|
||||
let Some(path) = pick(filter, &[ext], &format!("{stem}.{ext}")) else { return Ok(Value::Null) };
|
||||
let mut p = params.as_object().cloned().unwrap_or_default();
|
||||
p.insert("path".into(), json!(path));
|
||||
let r = self.session.execute(id, Value::Object(p)).map_err(|e| e.to_string());
|
||||
self.ui.status = match &r {
|
||||
Ok(_) => format!("Exported {path}"),
|
||||
Err(e) => e.clone(),
|
||||
};
|
||||
r
|
||||
}
|
||||
|
||||
/// Import dropped files.
|
||||
fn handle_drops(&mut self, ctx: &egui::Context) {
|
||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||||
|
|
|
|||
|
|
@ -310,6 +310,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
|
|||
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
|
||||
return crate::panels::interchange_export::open(app, ctx, id);
|
||||
}
|
||||
// File ▸ Export ▸ Media… opens the Export mode, like ⌘M (#382); with a path it exports directly.
|
||||
"file.exportMedia" if params.get("path").is_none() => {
|
||||
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
|
||||
app.ui.mode = Mode::Export;
|
||||
return Ok(json!({"mode": "export"}));
|
||||
}
|
||||
// Audio Gain from the menu or G opens the dialog; with params it applies directly.
|
||||
"clip.audioGain" if params.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
filmcraft_engine::find_command("clip.audioGain").map_or(Ok(()), |c| (c.enabled)(&app.session))?;
|
||||
|
|
@ -388,6 +394,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
|
|||
|| (id == "file.save" && params.get("path").is_none() && app.session.path.is_none())
|
||||
|| (matches!(id, "captions.import" | "captions.export") && params.get("path").is_none())
|
||||
|| (id == "graphics.newFromFile" && params.get("path").is_none())
|
||||
|| (crate::EXPORT_SAVE_DIALOGS.iter().any(|(c, ..)| *c == id) && params.get("path").is_none())
|
||||
{
|
||||
return app.file_dialog(id, ¶ms);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
//! Headless UI tests of File ▸ Export from the menus (#382): a menu entry carries no parameters, so
|
||||
//! the entries whose command needs a `path` ask the host's save panel for it, and Media… opens the
|
||||
//! Export mode like ⌘M, instead of failing with a missing `path`.
|
||||
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::control::ControlRequest;
|
||||
use filmcraft_ui_egui::state::Mode;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// What the save panel was asked: filter label, extensions, suggested name.
|
||||
type Asked = Arc<Mutex<Vec<(String, Vec<String>, String)>>>;
|
||||
|
||||
struct Driver {
|
||||
harness: Harness<'static, FilmcraftApp>,
|
||||
tx: Sender<ControlRequest>,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
/// `save_to: None` is a cancelled save panel.
|
||||
fn new(session: Session, save_to: Option<std::path::PathBuf>, asked: Asked) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let mut app = FilmcraftApp::new(session).with_control(rx);
|
||||
app.hooks.pick_save_as = Some(Box::new(move |filter, exts, suggested| {
|
||||
asked.lock().unwrap().push((filter.to_string(), exts.iter().map(|e| e.to_string()).collect(), suggested.to_string()));
|
||||
save_to.as_ref().map(|d| d.join(suggested).to_string_lossy().into_owned())
|
||||
}));
|
||||
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
|
||||
let mut d = Driver { harness, tx };
|
||||
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 v = self.call(method, params.clone());
|
||||
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
|
||||
v["result"].clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The demo project (sequences, clips) with one clip selected in the Project panel.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let clip = s.project.items.iter().find(|(_, it)| it.name.ends_with(".mp4")).map(|(id, _)| id.0).unwrap();
|
||||
s.execute("project.select", json!({"items": [clip]})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("fc-export-menu-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Every File ▸ Export entry whose command needs a path, and the extension it saves.
|
||||
const SAVED: [(&str, &str); 6] = [
|
||||
("file.exportEdl", "edl"),
|
||||
("file.exportFcp7Xml", "xml"),
|
||||
("file.exportFcpxml", "fcpxml"),
|
||||
("file.exportOtio", "otio"),
|
||||
("file.exportAle", "ale"),
|
||||
("file.exportSelectionProject", "fcproj"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn file_export_entries_ask_for_the_destination() {
|
||||
let dir = tmp("save");
|
||||
let asked = Asked::default();
|
||||
let mut d = Driver::new(session(), Some(dir.clone()), asked.clone());
|
||||
let menu = d.ok("ui.menu.list", json!({}));
|
||||
for (id, ext) in SAVED {
|
||||
let entry = menu.as_array().unwrap().iter().find(|i| i["id"] == id).unwrap_or_else(|| panic!("{id} missing from the menus"));
|
||||
assert_eq!(entry["path"], json!(["File", "Export"]), "{id}");
|
||||
let r = d.ok("ui.menu.invoke", json!({"id": id}));
|
||||
d.frames(2);
|
||||
let (_, exts, suggested) = asked.lock().unwrap().last().cloned().unwrap_or_else(|| panic!("{id} did not ask where to save"));
|
||||
assert_eq!(exts, [ext], "{id}");
|
||||
assert!(suggested.ends_with(&format!(".{ext}")) && suggested.len() > ext.len() + 1, "{id}: {suggested}");
|
||||
let written = dir.join(&suggested);
|
||||
assert!(written.metadata().is_ok_and(|m| m.len() > 0), "{id} wrote nothing at {} ({r})", written.display());
|
||||
}
|
||||
assert_eq!(asked.lock().unwrap().len(), SAVED.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_the_save_panel_writes_nothing_and_reports_no_error() {
|
||||
let asked = Asked::default();
|
||||
let mut d = Driver::new(session(), None, asked.clone());
|
||||
for (id, _) in SAVED {
|
||||
assert_eq!(d.ok("ui.menu.invoke", json!({"id": id})), Value::Null, "{id}");
|
||||
}
|
||||
assert_eq!(asked.lock().unwrap().len(), SAVED.len());
|
||||
assert!(!d.harness.state().ui.status.contains("path"), "{}", d.harness.state().ui.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_from_the_menu_opens_the_export_mode() {
|
||||
let mut d = Driver::new(session(), None, Asked::default());
|
||||
assert_eq!(d.harness.state().ui.mode, Mode::Edit);
|
||||
d.ok("ui.menu.invoke", json!({"id": "file.exportMedia"}));
|
||||
d.frames(2);
|
||||
assert_eq!(d.harness.state().ui.mode, Mode::Export);
|
||||
// with a path it still exports directly, as scripts and the CLI use it
|
||||
let out = tmp("media").join("direct.wav");
|
||||
let r = d.ok("ui.menu.invoke", json!({"id": "file.exportMedia", "params": {"path": out.to_string_lossy(), "format": "wav", "wait": true}}));
|
||||
assert!(out.metadata().is_ok_and(|m| m.len() > 0), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_export_entries_need_an_open_sequence() {
|
||||
let mut d = Driver::new(Session::default(), Some(tmp("noseq")), Asked::default());
|
||||
for id in ["file.exportMedia", "file.exportEdl", "file.exportFcp7Xml", "file.exportFcpxml", "file.exportOtio"] {
|
||||
assert_eq!(d.call("ui.menu.invoke", json!({"id": id}))["ok"], json!(false), "{id}");
|
||||
assert_eq!(d.harness.state().ui.mode, Mode::Edit, "{id}");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue