export: editable quick export file name and a clickable folder link
The Quick Export path field was refilled from the stored path every frame, so typing into it was overwritten. It is now a file name field that keeps what is typed while it has focus and applies each non-empty value; a typed or pasted full path replaces the folder too. Under it the folder is shown as an accent-coloured, underlined link with a folder icon. Clicking it opens the save dialog in the current folder with the current file name; a chosen path is applied and shown, cancelling changes nothing, and Export writes there. A preset change now swaps only the extension, so a chosen name is kept. Export mode's File Name and Location fields had the same refill problem when emptied and use the same draft handling. The desktop save dialog hook accepts a full path and starts in its folder.
This commit is contained in:
1 parent
383fafea8e
commit
dd833b4eab
5 files changed
+166
-19
No files matched your search
@@ -263,7 +263,10 @@ pub fn install_hooks(app: &mut FilmcraftApp) {
|
||||
}));
|
||||
// Saving writes the file in memory and offers it as a download (`fs::WebServices`).
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| Some(format!("/projects/{name}"))));
|
||||
app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| Some(format!("/exports/{name}"))));
|
||||
app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| {
|
||||
let file = std::path::Path::new(name).file_name().map_or(name.into(), |n| n.to_string_lossy());
|
||||
Some(format!("/exports/{file}"))
|
||||
}));
|
||||
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: the picker is async, so the
|
||||
// hint's command runs once the user has chosen (#111).
|
||||
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], hint: Option<RelinkHint>| {
|
||||
|
||||
@@ -285,11 +285,15 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<A
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
// a full path (Quick Export, Export mode) opens the dialog in its folder
|
||||
let path = std::path::Path::new(name);
|
||||
let mut dialog = rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.set_file_name(path.file_name().map_or(name.into(), |n| n.to_string_lossy()));
|
||||
if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
dialog.save_file().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_folder_at =
|
||||
|
||||
@@ -123,7 +123,8 @@ pub struct HostHooks {
|
||||
pub cursor_screen_position: Option<Box<dyn Fn() -> Option<(i32, i32)>>>,
|
||||
pub pick_save: Option<Box<dyn FnMut(&str) -> Option<String>>>,
|
||||
pub pick_open_project: Option<Box<dyn FnMut() -> Option<String>>>,
|
||||
/// Save dialog with a filter: (filter name, extensions, suggested file name) → path.
|
||||
/// Save dialog with a filter: (filter name, extensions, suggested file name) → path. The
|
||||
/// suggestion may be a full path, and the dialog then starts in its folder.
|
||||
pub pick_save_as: Option<Box<dyn FnMut(&str, &[&str], &str) -> Option<String>>>,
|
||||
/// The active keyboard shortcuts changed: update native menu key equivalents.
|
||||
pub shortcuts_changed: Option<Box<dyn FnMut(&[menus::MenuItem])>>,
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
//! Preset Manager: `presetManager.search`, `presetManager.favoritesOnly`,
|
||||
//! `presetManager.item.<name>`, `presetManager.favorite.<name>`, `presetManager.saveName`,
|
||||
//! `presetManager.save`, `presetManager.delete`, `presetManager.import`, `presetManager.export`,
|
||||
//! `presetManager.ok`, `presetManager.cancel`; Quick Export: `quickExport.path`,
|
||||
//! `quickExport.preset.<name>`, `quickExport.go`, `quickExport.close`.
|
||||
//! `presetManager.ok`, `presetManager.cancel`; Quick Export: `quickExport.path` (the file name),
|
||||
//! `quickExport.location` (the folder; opens the save dialog), `quickExport.preset.<name>`,
|
||||
//! `quickExport.go`, `quickExport.close`.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||
use filmcraft_engine::export::presets::{DEFAULT_PRESET, preset_key};
|
||||
@@ -371,6 +372,44 @@ fn text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut String, hint: &str)
|
||||
reg.add(id, r.rect, hint);
|
||||
}
|
||||
|
||||
/// A text field over a value the caller fills in again every frame (a file name taken from a path,
|
||||
/// a folder that falls back to the default one). While it has focus it shows what is typed, even
|
||||
/// when that is not usable yet (emptied to retype, a half-typed path); each non-empty value is
|
||||
/// returned as it comes. Leaving shows the stored value again.
|
||||
fn draft_text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, stored: &str, hint: &str) -> Option<String> {
|
||||
let draft_id = egui::Id::new(("export-draft", id));
|
||||
let mut v = ui.data(|m| m.get_temp::<String>(draft_id)).unwrap_or_else(|| stored.to_string());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(f32::INFINITY));
|
||||
reg.add(id, r.rect, hint);
|
||||
if r.has_focus() {
|
||||
ui.data_mut(|m| m.insert_temp(draft_id, v.clone()));
|
||||
} else {
|
||||
ui.data_mut(|m| m.remove::<String>(draft_id));
|
||||
}
|
||||
(r.changed() && !v.trim().is_empty()).then_some(v)
|
||||
}
|
||||
|
||||
/// The output folder as a link (folder icon, accent colour, underlined); the caller opens the save
|
||||
/// dialog on a click. A path too long for the row loses its start, so the folder's name stays.
|
||||
fn location_link(ui: &mut egui::Ui, t: &Tokens, folder: &str) -> egui::Response {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
let col = if resp.hovered() { t.accent_hover } else { t.accent };
|
||||
crate::icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 8.0, r.center().y), vec2(14.0, 14.0)), crate::icons::Icon::Folder, col);
|
||||
let room = (r.width() - 22.0).max(0.0);
|
||||
let mut galley = ui.painter().layout_no_wrap(folder.to_string(), Tokens::ui(12.5), col);
|
||||
for (i, _) in folder.char_indices().skip(1) {
|
||||
if galley.size().x <= room {
|
||||
break;
|
||||
}
|
||||
galley = ui.painter().layout_no_wrap(format!("…{}", folder.get(i..).unwrap_or("")), Tokens::ui(12.5), col);
|
||||
}
|
||||
let at = pos2(r.min.x + 20.0, r.center().y - galley.size().y / 2.0);
|
||||
let underline = at.y + galley.size().y;
|
||||
ui.painter().hline(at.x..=at.x + galley.size().x, underline, egui::Stroke::new(1.0, col));
|
||||
ui.painter().galley(at, galley, col);
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(folder)
|
||||
}
|
||||
|
||||
const RATES: [(&str, FrameRate); 11] = [
|
||||
("10", FrameRate { num: 10, den: 1 }),
|
||||
("12", FrameRate { num: 12, den: 1 }),
|
||||
@@ -416,12 +455,18 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
egui::ScrollArea::vertical().id_salt("export-settings-scroll").auto_shrink([false, false]).show(&mut child, |ui| {
|
||||
ui.label(egui::RichText::new(tl!("Media File")).strong().size(14.0));
|
||||
ui.add_space(6.0);
|
||||
row(ui, &t, tl!("File Name"), |ui| text(ui, &mut reg, "export.fileName", &mut ex.file_name, tl!("Sequence name")));
|
||||
row(ui, &t, tl!("File Name"), |ui| {
|
||||
if let Some(v) = draft_text(ui, &mut reg, "export.fileName", &ex.file_name, tl!("Sequence name")) {
|
||||
ex.file_name = v;
|
||||
}
|
||||
});
|
||||
row(ui, &t, tl!("Location"), |ui| {
|
||||
let r = ui.small_button("…");
|
||||
reg.add("export.location.browse", r.rect, "Choose folder");
|
||||
pick_folder = r.clicked();
|
||||
text(ui, &mut reg, "export.location", &mut ex.location, tl!("Folder"));
|
||||
if let Some(v) = draft_text(ui, &mut reg, "export.location", &ex.location, tl!("Folder")) {
|
||||
ex.location = v;
|
||||
}
|
||||
});
|
||||
// Preset: favourites, then every preset by category, then More presets…
|
||||
row(ui, &t, tl!("Preset"), |ui| {
|
||||
@@ -1257,6 +1302,30 @@ fn quick_presets(app: &FilmcraftApp) -> Vec<String> {
|
||||
v
|
||||
}
|
||||
|
||||
/// The Quick Export path for the preset's extension `ext`, or None when `path` already fits. No
|
||||
/// folder means the default one and no file name the sequence's; another format's extension is
|
||||
/// swapped and any other kept, so a typed or chosen name survives a preset change.
|
||||
fn quick_file(app: &FilmcraftApp, path: &str, seq_name: &str, ext: &str) -> Option<String> {
|
||||
let p = std::path::Path::new(path);
|
||||
let folder = path.ends_with(['/', '\\']);
|
||||
let dir = if folder { Some(p) } else { p.parent() }.filter(|d| !d.as_os_str().is_empty());
|
||||
let name = if folder { None } else { p.file_name().and_then(|n| n.to_str()) }.filter(|n| !n.trim().is_empty());
|
||||
let cur = name.and_then(|n| std::path::Path::new(n).extension()).and_then(|e| e.to_str()).unwrap_or("");
|
||||
if dir.is_some() && name.is_some() && cur.eq_ignore_ascii_case(ext) {
|
||||
return None;
|
||||
}
|
||||
let file = match name {
|
||||
None => format!("{seq_name}.{ext}"),
|
||||
Some(n) if cur.eq_ignore_ascii_case(ext) => n.to_string(),
|
||||
Some(n) if !cur.is_empty() && app.session.export_presets.all().iter().any(|x| x.settings.extension().eq_ignore_ascii_case(cur)) => {
|
||||
std::path::Path::new(n).with_extension(ext).to_string_lossy().to_string()
|
||||
}
|
||||
Some(n) => format!("{n}.{ext}"),
|
||||
};
|
||||
let dir = dir.map(std::path::Path::to_path_buf).unwrap_or_else(|| filmcraft_engine::export_tools::default_export_dir(&app.session));
|
||||
Some(dir.join(file).to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// The Quick Export popup under the header button at `anchor`.
|
||||
pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::Pos2) {
|
||||
if !app.ui.export.quick_open {
|
||||
@@ -1273,14 +1342,14 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P
|
||||
}
|
||||
let preset = app.session.export_presets.find(&app.ui.export.quick_preset);
|
||||
let ext = preset.as_ref().map(|p| p.settings.extension()).unwrap_or("mp4");
|
||||
if app.ui.export.quick_path.is_empty() || !app.ui.export.quick_path.ends_with(&format!(".{ext}")) {
|
||||
let name = app.session.project.item(seq).map(|i| i.name.clone()).unwrap_or_else(|| "Sequence".into());
|
||||
let dir = match app.ui.export.quick_path.rsplit_once(['/', '\\']) {
|
||||
Some((d, _)) if !d.is_empty() => d.to_string(),
|
||||
_ => filmcraft_engine::export_tools::default_export_dir(&app.session).to_string_lossy().to_string(),
|
||||
};
|
||||
app.ui.export.quick_path = std::path::Path::new(&dir).join(format!("{name}.{ext}")).to_string_lossy().to_string();
|
||||
let format = preset.as_ref().map(|p| p.settings.format.label()).unwrap_or("MP4");
|
||||
let seq_name = app.session.project.item(seq).map(|i| i.name.clone()).unwrap_or_else(|| "Sequence".into());
|
||||
if let Some(p) = quick_file(app, &app.ui.export.quick_path, &seq_name, ext) {
|
||||
app.ui.export.quick_path = p;
|
||||
}
|
||||
let path = std::path::Path::new(&app.ui.export.quick_path);
|
||||
let file_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let folder = path.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let est = app
|
||||
.session
|
||||
.execute("export.resolve", json!({"preset": app.ui.export.quick_preset}))
|
||||
@@ -1290,6 +1359,8 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P
|
||||
let mut reg = Reg::default();
|
||||
let mut go = false;
|
||||
let mut close = false;
|
||||
let mut browse = false;
|
||||
let mut typed: Option<String> = None;
|
||||
let q = &mut app.ui.export;
|
||||
let area = egui::Area::new(egui::Id::new("quick-export")).order(egui::Order::Foreground).fixed_pos(anchor).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
@@ -1303,8 +1374,10 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P
|
||||
});
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("File Name & Location")).color(t.text_dim).size(11.5));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut q.quick_path).desired_width(f32::INFINITY));
|
||||
reg.add("quickExport.path", r.rect, "File Name & Location");
|
||||
typed = draft_text(ui, &mut reg, "quickExport.path", &file_name, tl!("File Name"));
|
||||
let r = location_link(ui, &t, &folder);
|
||||
reg.add("quickExport.location", r.rect, folder.clone());
|
||||
browse = r.clicked();
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim).size(11.5));
|
||||
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
|
||||
@@ -1331,6 +1404,13 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P
|
||||
// the header click that opened the popup is not a click elsewhere
|
||||
let toggled = ctx.data_mut(|d| d.remove_temp::<bool>(egui::Id::new("quick-export-toggled")).unwrap_or(false));
|
||||
reg.flush(app);
|
||||
if let Some(v) = typed {
|
||||
// a file name goes in the shown folder; a typed or pasted path replaces both
|
||||
app.ui.export.quick_path = if v.contains(['/', '\\']) { v } else { std::path::Path::new(&folder).join(v.trim()).to_string_lossy().to_string() };
|
||||
}
|
||||
if browse && let Some(p) = app.hooks.pick_save_as.as_mut().and_then(|f| f(format, &[ext], &app.ui.export.quick_path)) {
|
||||
app.ui.export.quick_path = p;
|
||||
}
|
||||
if go {
|
||||
let p = json!({"preset": app.ui.export.quick_preset, "path": app.ui.export.quick_path});
|
||||
match app.session.execute("export.quick", p) {
|
||||
|
||||
@@ -382,3 +382,62 @@ fn export_button_and_quick_export_popup() {
|
||||
d.click("quickExport.close");
|
||||
assert!(!d.app().ui.export.quick_open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_export_file_name_can_be_typed_and_the_folder_link_opens_the_save_dialog() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let mut d = Driver::new("quick-location");
|
||||
d.exec("markers.markIn", json!({"seconds": 0.0}));
|
||||
d.exec("markers.markOut", json!({"seconds": 0.2}));
|
||||
d.click("header.quickExport");
|
||||
d.ok("ui.set", json!({"export": {"quickPreset": "Waveform Audio 48 kHz 16-bit"}}));
|
||||
d.frames(2);
|
||||
let start = PathBuf::from(&d.app().ui.export.quick_path);
|
||||
assert_eq!(start.extension().unwrap(), "wav", "{start:?}");
|
||||
// emptying the file name to retype it keeps what is typed instead of refilling the old name
|
||||
d.click("quickExport.path");
|
||||
d.ok("ui.key", json!({"key": "Cmd+A"}));
|
||||
d.ok("ui.key", json!({"key": "Backspace"}));
|
||||
d.frames(2);
|
||||
d.ok("ui.type", json!({"text": "renamed"}));
|
||||
d.frames(2);
|
||||
let typed = PathBuf::from(&d.app().ui.export.quick_path);
|
||||
assert_eq!(typed, start.parent().unwrap().join("renamed.wav"));
|
||||
// the folder link asks the save dialog, seeded with the whole path; cancelling changes nothing
|
||||
let asked: Arc<Mutex<Vec<(Vec<String>, String)>>> = Arc::default();
|
||||
let answer: Arc<Mutex<Option<String>>> = Arc::default();
|
||||
let (log, reply) = (asked.clone(), answer.clone());
|
||||
d.app().hooks.pick_save_as = Some(Box::new(move |_, exts, suggested| {
|
||||
log.lock().unwrap().push((exts.iter().map(|e| e.to_string()).collect(), suggested.to_string()));
|
||||
reply.lock().unwrap().clone()
|
||||
}));
|
||||
d.click("quickExport.location");
|
||||
assert_eq!(asked.lock().unwrap().clone(), [(vec!["wav".to_string()], typed.to_string_lossy().to_string())]);
|
||||
assert_eq!(PathBuf::from(&d.app().ui.export.quick_path), typed);
|
||||
assert!(d.app().ui.export.quick_open, "the popup stays open");
|
||||
// a chosen path is applied and shown; another format's extension follows the preset
|
||||
*answer.lock().unwrap() = Some(d.path("picked.mp4"));
|
||||
d.click("quickExport.location");
|
||||
let picked = d.path("picked.wav");
|
||||
assert_eq!(d.app().ui.export.quick_path, picked);
|
||||
let shown = d.ok("ui.elements", json!({"prefix": "quickExport.location"}))[0]["label"].as_str().unwrap().to_string();
|
||||
assert_eq!(shown, d.dir.to_string_lossy());
|
||||
d.snapshot("export-quick-location");
|
||||
d.click("quickExport.go");
|
||||
d.wait_until("quick export", |d| d.app().session.jobs.iter().all(|j| j.progress.finished.load(std::sync::atomic::Ordering::Relaxed)));
|
||||
assert!(Path::new(&d.path("picked.wav")).exists(), "{}", d.app().ui.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_mode_file_name_can_be_emptied_and_retyped() {
|
||||
let mut d = Driver::new("file-name");
|
||||
d.ok("ui.set", json!({"mode": "export"}));
|
||||
d.frames(3);
|
||||
d.click("export.fileName");
|
||||
d.ok("ui.key", json!({"key": "Cmd+A"}));
|
||||
d.ok("ui.key", json!({"key": "Backspace"}));
|
||||
d.frames(2);
|
||||
d.ok("ui.type", json!({"text": "Final"}));
|
||||
d.frames(2);
|
||||
assert_eq!(d.app().ui.export.file_name, "Final");
|
||||
}
|
||||
Reference in new issue
Block a user