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:
ReReddo committed 2026-10-12 04:07:39 +03:00
1 parent 383fafea8e
commit dd833b4eab
5 files changed
+166 -19

No files matched your search

+4 -1
View File
@@ -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>| {
+8 -4
View File
@@ -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 =
+2 -1
View File
@@ -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])>>,
+93 -13
View File
@@ -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) {
+59
View File
@@ -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");
}