Fix: video frames to layers working using folder dialog request (#3513) (#3718)

This commit is contained in:
Giuseppe 2026-10-12 10:33:48 +02:00 committed by GitHub
parent 986f68f969
commit 986f977a01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 56 additions and 2 deletions

View File

@ -382,6 +382,8 @@ fn services(inbox: Inbox) -> Services {
};
reply.send(answer);
}),
// Browser folder handles cannot be represented as filesystem paths for engine commands.
FileDialogRequest::Folder { .. } => reply.send(None),
// No save dialog on the web: the suggested name becomes the download name.
FileDialogRequest::Save { suggested } => {
let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string());

View File

@ -115,6 +115,13 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
}
}
FileDialogRequest::Folder { initial_dir } => {
if let Some(dir) = initial_dir {
dialog = dialog.set_directory(dir);
}
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);

View File

@ -21,12 +21,14 @@ use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::file_open::display_name;
/// What a file dialog asks the user for.
/// What a file or folder dialog asks the user for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileDialogRequest {
/// Files to read: several for File › Open, one for a command that reads a file (Place,
/// scripts, notes, presets). Starts in `initial_dir` (the last-used folder, UI-217-3).
Open { multiple: bool, initial_dir: Option<String>, extensions: Option<Vec<String>> },
/// Which folder to read from. Starts in `initial_dir` when available.
Folder { initial_dir: Option<String> },
/// Where to write, starting from `suggested` (a file name, or the document's own path).
Save { suggested: String },
}
@ -42,6 +44,8 @@ pub enum FileDialogAnswer {
ContentsMany(Vec<(String, Vec<u8>)>),
/// The path to save to (desktop), or the download's name (web).
SaveTo(String),
/// The selected folder's path (desktop).
FolderTo(String),
/// No dialog could be shown (on Linux: no desktop portal and no zenity, #3354); the message
/// says what to install. Reported as an error rather than taken for Cancel.
Unavailable(String),
@ -88,7 +92,7 @@ impl PhotocraftApp {
}
/// Show a file dialog; `then` continues with the user's choice on a later frame (see the
/// module docs). Returns at once with `{"fileDialog": "open" | "save"}`; `Err` without a
/// module docs). Returns at once with `{"fileDialog": "open" | "folder" | "save"}`; `Err` without a
/// dialog service ("cancelled", as before there were async dialogs) or while one is open.
pub(crate) fn ask_file(
&mut self,
@ -103,6 +107,7 @@ impl PhotocraftApp {
}
let kind = match request {
FileDialogRequest::Open { .. } => "open",
FileDialogRequest::Folder { .. } => "folder",
FileDialogRequest::Save { .. } => "save",
};
let then: Then = Box::new(move |app, answer| match answer {
@ -114,6 +119,16 @@ impl PhotocraftApp {
Ok(json!({ "fileDialog": kind }))
}
/// Ask the user to choose a folder, starting in the folder of the most recent file when known.
/// `then` receives the selected path when the dialog is answered.
pub(crate) fn ask_folder(&mut self, then: impl FnOnce(&mut Self, String) -> Result<Value, String> + 'static) -> Result<Value, String> {
let initial_dir = last_used_dir(&self.ui.recent_files);
self.ask_file(FileDialogRequest::Folder { initial_dir }, move |app, answer| match answer {
FileDialogAnswer::FolderTo(path) => then(app, path),
_ => Err(UNEXPECTED.into()),
})
}
/// Ask where to save: `then` gets the chosen path, with a lowercase extension when
/// Preferences ▸ File Handling › Lowercase Extension is on (its default).
///
@ -193,6 +208,7 @@ impl PhotocraftApp {
}
FileDialogAnswer::Contents(name, bytes) => app.open_contents(vec![(name, bytes)]),
FileDialogAnswer::ContentsMany(files) => app.open_contents(files),
FileDialogAnswer::FolderTo(_) => return Err(UNEXPECTED.into()),
FileDialogAnswer::SaveTo(_) | FileDialogAnswer::Unavailable(_) => return Err(UNEXPECTED.into()),
}
Ok(Value::Null)
@ -290,6 +306,7 @@ fn read_picked(answer: FileDialogAnswer) -> Result<(String, Vec<u8>), String> {
let bytes = photocraft_format::read_file(std::path::Path::new(&path)).map_err(|e| format!("{}: {e}", display_name(&path)))?;
Ok((path, bytes))
}
FileDialogAnswer::FolderTo(_) => Err(UNEXPECTED.into()),
FileDialogAnswer::SaveTo(_) | FileDialogAnswer::Unavailable(_) => Err(UNEXPECTED.into()),
}
}

View File

@ -175,6 +175,30 @@ fn open_starts_in_the_last_used_folder() {
);
}
#[test]
fn ask_folder_uses_the_async_dialog_and_returns_the_selected_path() {
let (mut app, open, _) = app();
let ctx = egui::Context::default();
app.ui.recent_files = vec!["/pics/cat.psd".into()];
let selected = Rc::new(RefCell::new(None));
let result = selected.clone();
assert_eq!(
app.ask_folder(move |_, path| {
*result.borrow_mut() = Some(path);
Ok(Value::Null)
})
.unwrap(),
json!({"fileDialog": "folder"})
);
app.poll_file_dialog(&ctx, None);
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Folder { initial_dir: Some(dir) }, _)] if dir == "/pics"));
answer(&open, Some(FileDialogAnswer::FolderTo("/pics/frames".into())));
app.poll_file_dialog(&ctx, None);
assert_eq!(selected.borrow().as_deref(), Some("/pics/frames"));
}
#[test]
fn filtered_picker_requests_only_the_given_extensions() {
let (mut app, open, _) = app();

View File

@ -292,6 +292,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
app.open_dialog_file()
}
}
"file.import.videoFramesToLayers" if params.get("path").and_then(Value::as_str).is_none() => {
app.ask_folder(|app, path| app.run("file.import.videoFramesToLayers", json!({"path": path})).map_err(|e| e.to_string()))
}
"file.save" => {
// Writes back only to a layered file; a flat one goes through Save As.
let tga_bits = crate::targa_options_ui::bits_param(&params, id)?;

View File

@ -24,6 +24,7 @@ fn harness() -> Option<(Harness, Writes)> {
file_dialog: Some(Box::new(|request, _parent, reply| {
reply.send(match request {
FileDialogRequest::Save { suggested } => Some(FileDialogAnswer::SaveTo(suggested)),
FileDialogRequest::Folder { .. } => None,
FileDialogRequest::Open { .. } => None,
})
})),