diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 057e6f12..0db5d065 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -145,7 +145,14 @@ fn main() -> eframe::Result { app.hooks.audio_devices = Some(Box::new(audio_out::devices)); app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| { - rfd::FileDialog::new().add_filter(ext, &[ext]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) + // A default with a folder that exists opens the dialog there. + let path = std::path::Path::new(name); + let mut d = rfd::FileDialog::new().add_filter(ext, &[ext]); + if let Some(dir) = path.parent().filter(|d| d.is_dir()) { + d = d.set_directory(dir); + } + let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string()) })); #[cfg(target_os = "macos")] { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 49b2e310..5c5bfc40 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,7 +102,8 @@ pub struct Hooks { pub audio_devices: Option Vec>>, /// Picks a folder (Settings paths). pub pick_folder: Option Option>>, - /// Save dialog for other file kinds: (default name, extension). + /// Save dialog for other file kinds: (default name or path, extension). A default path opens + /// the dialog in its folder. pub pick_save_file: Option Option>>, /// The system clipboard's text (native menu Edit ▸ Paste into a text field). pub clipboard_text: Option Option>>, diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 99f907ae..06c5c339 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -398,6 +398,15 @@ fn output_to_menu(it: &RenderQueueItem) -> Vec<(String, Value)> { v } +/// Output To's save dialog, as After Effects' Output Movie To: it opens at the current output +/// (folder and name) and offers the output format's extension. `None` when the host has no +/// save dialog for other file kinds; `Some(None)` when the dialog was cancelled. +fn pick_output(app: &EffectcraftApp, it: &RenderQueueItem) -> Option> { + let pick = app.hooks.pick_save_file.as_ref()?; + let default = app.session.resolve_output(it).unwrap_or_default(); + Some(pick(&default, it.output.format.extension())) +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().with_clip_rect(rect); @@ -687,16 +696,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let olabels: Vec = omenu.iter().map(|(l, _)| l.clone()).collect(); if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) { if omenu[i].1.get("choose").is_some() { - let default = app.session.resolve_output(it).unwrap_or_default(); - let picked = app - .hooks - .pick_save - .as_ref() - .and_then(|f| f(std::path::Path::new(&default).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default().as_str())); + // The web has no folders to choose from: its save "dialog" only names the file. + let picked = pick_output(app, it).or_else(|| { + let default = app.session.resolve_output(it).unwrap_or_default(); + let name = std::path::Path::new(&default).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + app.hooks.pick_save.as_ref().map(|f| f(&name)) + }); match picked { - Some(path) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))), - None if app.hooks.pick_save.is_none() => editing = Some((it.id, it.output.output.clone())), - None => {} + Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))), + Some(None) => {} + None => editing = Some((it.id, it.output.output.clone())), } } else { let mut params = omenu[i].1.clone(); @@ -723,10 +732,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let resp = ui.interact(path_rect, egui::Id::new(("rq-path", it.id)), Sense::click()); let col = if resp.hovered() { t.accent_hover } else { t.hot_text }; lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col); + // Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a + // host without one, edits the name template in place. + let dialog = app.hooks.pick_save_file.is_some(); if resp.clicked() && !rendering { - editing = Some((it.id, it.output.output.clone())); + let picked = if ui.input(|i| i.modifiers.alt) { None } else { pick_output(app, it) }; + match picked { + Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))), + Some(None) => {} + None => editing = Some((it.id, it.output.output.clone())), + } } - resp.on_hover_text(format!("Template: {}\nClick to edit", it.output.output)); + let how = if dialog { "Click to choose where to save; Alt-click to edit the template" } else { "Click to edit" }; + resp.on_hover_text(format!("Template: {}\n{how}", it.output.output)); app.auto.add(&aid("outputPath"), path_rect, &shown); } } diff --git a/crates/ui-egui/tests/ui_render_queue.rs b/crates/ui-egui/tests/ui_render_queue.rs index 1ec5d9e4..72e585eb 100644 --- a/crates/ui-egui/tests/ui_render_queue.rs +++ b/crates/ui-egui/tests/ui_render_queue.rs @@ -8,6 +8,8 @@ use egui::{Event, Modifiers, Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use serde_json::json; +use std::cell::RefCell; +use std::rc::Rc; fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.input_mut().events.push(Event::PointerMoved(p)); @@ -44,3 +46,52 @@ fn render_settings_template_applies_from_a_menu_moved_to_fit_the_window() { assert_eq!(it.settings.resolution, 0.5); assert!(h.query_by_label_contains("Quality: Draft").is_none(), "the menu closed after the choice"); } + +fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +/// Issue #192: clicking the Output To file name opens a save dialog (at the current output, for +/// the module's extension) and the chosen file becomes the output, as After Effects' Output +/// Movie To; Alt-click still edits the name template in place. +#[test] +fn clicking_the_output_file_name_opens_the_save_dialog() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("renderQueue.add", json!({})).unwrap(); + let id = s.project.render_queue[0].id; + let expected_default = s.resolve_output(&s.project.render_queue[0]).unwrap(); + let ext = s.project.render_queue[0].output.format.extension(); + let mut app = EffectcraftApp::new(s); + let asked: Rc>> = Rc::default(); + let log = asked.clone(); + let chosen = std::env::temp_dir().join(format!("chosen.{ext}")).to_string_lossy().to_string(); + let answer = chosen.clone(); + app.hooks.pick_save_file = Some(Box::new(move |name: &str, ext: &str| { + log.borrow_mut().push((name.to_string(), ext.to_string())); + Some(answer.clone()) + })); + let mut app = Some(app); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app.take().expect("app")); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("rq-ui"), (vec![id], None::))); + h.state_mut().show_panel(PanelKind::RenderQueue); + h.run_steps(4); + let path = h.state().auto.find(&format!("renderQueue.item.{id}.outputPath")).expect("the output file name").clone(); + let at = pos2(path.rect[0] + 20.0, path.rect[1] + path.rect[3] / 2.0); + click_with(&mut h, at, Modifiers::NONE); + assert_eq!(*asked.borrow(), [(expected_default, ext.to_string())], "the dialog opened at the current output"); + assert_eq!(h.state().session.project.render_queue[0].output.output, chosen); + let editing = |h: &Harness<'_, EffectcraftApp>| h.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("rq-edit-output"))); + assert_eq!(editing(&h), None, "no inline editor"); + // Alt-click edits the template text instead. + click_with(&mut h, at, Modifiers::ALT); + assert_eq!(asked.borrow().len(), 1, "Alt-click does not open the dialog"); + assert_eq!(editing(&h).map(|(item, _)| item), Some(id), "the template editor is open"); +}