mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
2bd6eeb647
commit
f2b2fa811c
4 files changed
+90
-13
No files matched your search
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -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>>>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user