Merge pull request #197 from storytold/fix/192-output-to-dialog

Render Queue: clicking the Output To file name opens a save dialog (#192)
This commit is contained in:
BFlat 2026-10-07 20:30:13 +07:00 committed by GitHub
commit 608d1b077b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 90 additions and 13 deletions

View File

@ -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")]
{

View File

@ -102,7 +102,8 @@ pub struct Hooks {
pub audio_devices: Option<Box<dyn Fn() -> Vec<String>>>,
/// Picks a folder (Settings paths).
pub pick_folder: Option<Box<dyn Fn() -> Option<String>>>,
/// 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<Box<dyn Fn(&str, &str) -> Option<String>>>,
/// The system clipboard's text (native menu Edit ▸ Paste into a text field).
pub clipboard_text: Option<Box<dyn Fn() -> Option<String>>>,

View File

@ -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<Option<String>> {
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<String> = 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);
}
}

View File

@ -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<RefCell<Vec<(String, String)>>> = 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::<u64>)));
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");
}