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 <noreply@anthropic.com>
This commit is contained in:
parent
2bd6eeb647
commit
f2b2fa811c
|
|
@ -145,7 +145,14 @@ fn main() -> eframe::Result {
|
||||||
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
|
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_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| {
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,8 @@ pub struct Hooks {
|
||||||
pub audio_devices: Option<Box<dyn Fn() -> Vec<String>>>,
|
pub audio_devices: Option<Box<dyn Fn() -> Vec<String>>>,
|
||||||
/// Picks a folder (Settings paths).
|
/// Picks a folder (Settings paths).
|
||||||
pub pick_folder: Option<Box<dyn Fn() -> Option<String>>>,
|
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>>>,
|
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).
|
/// The system clipboard's text (native menu Edit ▸ Paste into a text field).
|
||||||
pub clipboard_text: Option<Box<dyn Fn() -> Option<String>>>,
|
pub clipboard_text: Option<Box<dyn Fn() -> Option<String>>>,
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,15 @@ fn output_to_menu(it: &RenderQueueItem) -> Vec<(String, Value)> {
|
||||||
v
|
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) {
|
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||||
let t = app.tokens;
|
let t = app.tokens;
|
||||||
let p = ui.painter().with_clip_rect(rect);
|
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();
|
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 let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
|
||||||
if omenu[i].1.get("choose").is_some() {
|
if omenu[i].1.get("choose").is_some() {
|
||||||
let default = app.session.resolve_output(it).unwrap_or_default();
|
// The web has no folders to choose from: its save "dialog" only names the file.
|
||||||
let picked = app
|
let picked = pick_output(app, it).or_else(|| {
|
||||||
.hooks
|
let default = app.session.resolve_output(it).unwrap_or_default();
|
||||||
.pick_save
|
let name = std::path::Path::new(&default).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||||
.as_ref()
|
app.hooks.pick_save.as_ref().map(|f| f(&name))
|
||||||
.and_then(|f| f(std::path::Path::new(&default).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default().as_str()));
|
});
|
||||||
match picked {
|
match picked {
|
||||||
Some(path) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
|
Some(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())),
|
Some(None) => {}
|
||||||
None => {}
|
None => editing = Some((it.id, it.output.output.clone())),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let mut params = omenu[i].1.clone();
|
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 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 };
|
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);
|
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 {
|
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);
|
app.auto.add(&aid("outputPath"), path_rect, &shown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ use egui::{Event, Modifiers, Pos2, pos2};
|
||||||
use egui_kittest::Harness;
|
use egui_kittest::Harness;
|
||||||
use egui_kittest::kittest::Queryable;
|
use egui_kittest::kittest::Queryable;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
|
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
|
||||||
h.input_mut().events.push(Event::PointerMoved(p));
|
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_eq!(it.settings.resolution, 0.5);
|
||||||
assert!(h.query_by_label_contains("Quality: Draft").is_none(), "the menu closed after the choice");
|
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");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue