Render Queue: clicking the Output To file name opens a save dialog (#192)

Clicking an item's Output To file name opened an inline editor on the
name template (`[compName].[fileExtension]`), so there was no way to
pick a folder from there. Choose File… in the menu beside it did open
a dialog, but the project save dialog: filtered to .ecproj and opening
wherever the OS chose.

As After Effects' Output Movie To, clicking the file name now opens a
save dialog at the current output (its folder and name) for the output
format's extension, and the chosen file becomes the output. Choose
File… opens the same dialog. Alt-click still edits the template in
place, and hosts without a save dialog (the web) keep the inline
editor. The desktop `pick_save_file` hook opens in the folder of a
default path.

Test: clicking_the_output_file_name_opens_the_save_dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
BFlatandClaude Opus 5.5 committed 2026-10-07 20:29:29 +07:00
1 parent 2bd6eeb647
commit f2b2fa811c
4 files changed
+90 -13

No files matched your search

+8 -1
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")]
{
+2 -1
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>>>,
+29 -11
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);
}
}
+51
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");
}