Add Escape key shortcut and subfolder prompt for folder drops

Escape now goes to previous wizard step (cancel/go back). When
a folder with subfolders is dropped on the images step, an alert
dialog asks whether to include subfolder images. The choice is
remembered for the rest of the session.
This commit is contained in:
2026-03-06 13:45:06 +02:00
parent fa7a8f54bb
commit 0ca15536ae
2 changed files with 139 additions and 7 deletions

View File

@@ -119,7 +119,7 @@ pub fn build_app() -> adw::Application {
fn setup_shortcuts(app: &adw::Application) { fn setup_shortcuts(app: &adw::Application) {
app.set_accels_for_action("win.next-step", &["<Alt>Right"]); app.set_accels_for_action("win.next-step", &["<Alt>Right"]);
app.set_accels_for_action("win.prev-step", &["<Alt>Left"]); app.set_accels_for_action("win.prev-step", &["<Alt>Left", "Escape"]);
app.set_accels_for_action("win.process", &["<Control>Return"]); app.set_accels_for_action("win.process", &["<Control>Return"]);
for i in 1..=9 { for i in 1..=9 {
app.set_accels_for_action( app.set_accels_for_action(

View File

@@ -16,6 +16,10 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
stack.set_visible_child_name("empty"); stack.set_visible_child_name("empty");
// Session-level remembered subfolder choice (None = not yet asked)
let subfolder_choice: std::rc::Rc<std::cell::RefCell<Option<bool>>> =
std::rc::Rc::new(std::cell::RefCell::new(None));
// Set up drag-and-drop on the entire page // Set up drag-and-drop on the entire page
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY); let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
drop_target.set_types(&[gtk::gio::File::static_type()]); drop_target.set_types(&[gtk::gio::File::static_type()]);
@@ -24,16 +28,69 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
let loaded_files = state.loaded_files.clone(); let loaded_files = state.loaded_files.clone();
let excluded = state.excluded_files.clone(); let excluded = state.excluded_files.clone();
let stack_ref = stack.clone(); let stack_ref = stack.clone();
drop_target.connect_drop(move |_target, value, _x, _y| { let subfolder_choice = subfolder_choice.clone();
drop_target.connect_drop(move |target, value, _x, _y| {
if let Ok(file) = value.get::<gtk::gio::File>() if let Ok(file) = value.get::<gtk::gio::File>()
&& let Some(path) = file.path() && let Some(path) = file.path()
{ {
if path.is_dir() { if path.is_dir() {
let has_subdirs = has_subfolders(&path);
if !has_subdirs {
// No subfolders - just load top-level images
let mut files = loaded_files.borrow_mut();
add_images_flat(&path, &mut files);
let count = files.len();
drop(files);
update_loaded_ui(&stack_ref, &loaded_files, &excluded, count);
} else {
let choice = *subfolder_choice.borrow();
match choice {
Some(true) => {
// Remembered: include subfolders
let mut files = loaded_files.borrow_mut(); let mut files = loaded_files.borrow_mut();
add_images_from_dir(&path, &mut files); add_images_from_dir(&path, &mut files);
let count = files.len(); let count = files.len();
drop(files); drop(files);
update_loaded_ui(&stack_ref, &loaded_files, &excluded, count); update_loaded_ui(&stack_ref, &loaded_files, &excluded, count);
}
Some(false) => {
// Remembered: top-level only
let mut files = loaded_files.borrow_mut();
add_images_flat(&path, &mut files);
let count = files.len();
drop(files);
update_loaded_ui(&stack_ref, &loaded_files, &excluded, count);
}
None => {
// Not yet asked - add top-level now, then prompt
let mut files = loaded_files.borrow_mut();
add_images_flat(&path, &mut files);
let count = files.len();
drop(files);
update_loaded_ui(&stack_ref, &loaded_files, &excluded, count);
// Show dialog asynchronously
let loaded_files = loaded_files.clone();
let excluded = excluded.clone();
let stack_ref = stack_ref.clone();
let subfolder_choice = subfolder_choice.clone();
let dir_path = path.clone();
let window = target.widget()
.and_then(|w| w.root())
.and_then(|r| r.downcast::<gtk::Window>().ok());
gtk::glib::idle_add_local_once(move || {
show_subfolder_prompt(
window.as_ref(),
&dir_path,
&loaded_files,
&excluded,
&stack_ref,
&subfolder_choice,
);
});
}
}
}
return true; return true;
} else if is_image_file(&path) { } else if is_image_file(&path) {
let mut files = loaded_files.borrow_mut(); let mut files = loaded_files.borrow_mut();
@@ -79,6 +136,81 @@ fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf
} }
} }
/// Add only top-level images from a directory (no recursion into subfolders)
fn add_images_flat(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && is_image_file(&path) && !files.contains(&path) {
files.push(path);
}
}
}
}
/// Check if a directory contains any subdirectories
fn has_subfolders(dir: &std::path::Path) -> bool {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
return true;
}
}
}
false
}
/// Add only the images from subfolders (not top-level, since those were already added)
fn add_images_from_subdirs(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
add_images_from_dir(&path, files);
}
}
}
}
fn show_subfolder_prompt(
window: Option<&gtk::Window>,
dir: &std::path::Path,
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::PathBuf>>>,
stack: &gtk::Stack,
subfolder_choice: &std::rc::Rc<std::cell::RefCell<Option<bool>>>,
) {
let dialog = adw::AlertDialog::builder()
.heading("Include subfolders?")
.body("This folder contains subfolders. Would you like to include images from subfolders too?")
.build();
dialog.add_response("no", "Top-level Only");
dialog.add_response("yes", "Include Subfolders");
dialog.set_default_response(Some("yes"));
dialog.set_response_appearance("yes", adw::ResponseAppearance::Suggested);
let loaded_files = loaded_files.clone();
let excluded = excluded.clone();
let stack = stack.clone();
let subfolder_choice = subfolder_choice.clone();
let dir = dir.to_path_buf();
dialog.connect_response(None, move |_dialog, response| {
let include_subdirs = response == "yes";
*subfolder_choice.borrow_mut() = Some(include_subdirs);
if include_subdirs {
let mut files = loaded_files.borrow_mut();
add_images_from_subdirs(&dir, &mut files);
let count = files.len();
drop(files);
update_loaded_ui(&stack, &loaded_files, &excluded, count);
}
});
if let Some(win) = window {
dialog.present(Some(win));
}
}
fn update_loaded_ui( fn update_loaded_ui(
stack: &gtk::Stack, stack: &gtk::Stack,
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>, loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,