Files
pixstrip/pixstrip-gtk/src/steps/step_images.rs
lashman bcc53a0dc1 Fix drag-and-drop and file manager integration
Drag-and-drop from Nautilus on Wayland was broken by two issues:
- DropTarget only accepted COPY action, but Wayland compositor
  pre-selects MOVE, causing GTK4 to silently reject all drops
- Two competing DropTarget controllers on the same widget caused
  the gchararray target to match Nautilus formats first, swallowing
  the drop before the FileList target could receive it

Merged both drop targets into a single controller that tries
FileList first, then File, then falls back to URI text parsing.

File manager integration was broken by wrong command syntax
(non-existent --files flag) and wrong binary path resolution
inside AppImage. Split into separate GTK/CLI binary resolution
with APPIMAGE env var detection.
2026-03-08 16:53:30 +02:00

1059 lines
38 KiB
Rust

use adw::prelude::*;
use gtk::glib;
use gtk::subclass::prelude::ObjectSubclassIsExt;
use std::cell::RefCell;
use std::collections::HashSet;
use std::path::PathBuf;
use std::rc::Rc;
use crate::app::AppState;
use crate::utils::format_size;
const THUMB_SIZE: i32 = 120;
pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
let stack = gtk::Stack::builder()
.transition_type(gtk::StackTransitionType::Crossfade)
.build();
// Empty state - drop zone
let empty_state = build_empty_state();
stack.add_named(&empty_state, Some("empty"));
// Loaded state - thumbnail grid
let loaded_state = build_loaded_state(state);
stack.add_named(&loaded_state, Some("loaded"));
stack.set_visible_child_name("empty");
// Session-level remembered subfolder choice (None = not yet asked)
let subfolder_choice: Rc<RefCell<Option<bool>>> = Rc::new(RefCell::new(None));
// Set up drag-and-drop on the entire page
// Accept both FileList (from file managers) and single File
let drop_target = gtk::DropTarget::new(gtk::gdk::FileList::static_type(), gtk::gdk::DragAction::COPY | gtk::gdk::DragAction::MOVE);
drop_target.set_types(&[gtk::gdk::FileList::static_type(), gtk::gio::File::static_type(), glib::GString::static_type()]);
drop_target.set_preload(true);
{
let loaded_files = state.loaded_files.clone();
let excluded = state.excluded_files.clone();
let sizes = state.file_sizes.clone();
let stack_ref = stack.clone();
let subfolder_choice = subfolder_choice.clone();
drop_target.connect_drop(move |target, value, _x, _y| {
// Collect paths from FileList, single File, or URI text
let mut paths: Vec<PathBuf> = Vec::new();
if let Ok(file_list) = value.get::<gtk::gdk::FileList>() {
for file in file_list.files() {
if let Some(path) = file.path() {
paths.push(path);
}
}
} else if let Ok(file) = value.get::<gtk::gio::File>() {
if let Some(path) = file.path() {
paths.push(path);
}
} else if let Ok(text) = value.get::<glib::GString>() {
// Handle URI text drops (from web browsers)
let text = text.trim().to_string();
let lower = text.to_lowercase();
let is_image_url = (lower.starts_with("http://") || lower.starts_with("https://"))
&& (lower.ends_with(".jpg")
|| lower.ends_with(".jpeg")
|| lower.ends_with(".png")
|| lower.ends_with(".webp")
|| lower.ends_with(".gif")
|| lower.ends_with(".avif")
|| lower.ends_with(".tiff")
|| lower.ends_with(".bmp")
|| lower.contains(".jpg?")
|| lower.contains(".jpeg?")
|| lower.contains(".png?")
|| lower.contains(".webp?"));
if is_image_url {
let loaded = loaded_files.clone();
let excl = excluded.clone();
let sz = sizes.clone();
let sr = stack_ref.clone();
let (tx, rx) = std::sync::mpsc::channel::<Option<std::path::PathBuf>>();
let url = text.clone();
std::thread::spawn(move || {
let result = download_image_url(&url);
let _ = tx.send(result);
});
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
match rx.try_recv() {
Ok(Some(path)) => {
let mut files = loaded.borrow_mut();
if !files.contains(&path) {
files.push(path);
}
let count = files.len();
drop(files);
refresh_grid(&sr, &loaded, &excl, &sz, count);
glib::ControlFlow::Break
}
Ok(None) => glib::ControlFlow::Break,
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(_) => glib::ControlFlow::Break,
}
});
return true;
}
return false;
}
if paths.is_empty() {
return false;
}
for path in paths {
if path.is_dir() {
let has_subdirs = has_subfolders(&path);
if !has_subdirs {
let mut files = loaded_files.borrow_mut();
add_images_flat(&path, &mut files);
drop(files);
} else {
let choice = *subfolder_choice.borrow();
match choice {
Some(true) => {
let mut files = loaded_files.borrow_mut();
add_images_from_dir(&path, &mut files);
drop(files);
}
Some(false) => {
let mut files = loaded_files.borrow_mut();
add_images_flat(&path, &mut files);
drop(files);
}
None => {
let mut files = loaded_files.borrow_mut();
add_images_flat(&path, &mut files);
drop(files);
let loaded_files = loaded_files.clone();
let excluded = excluded.clone();
let sz = sizes.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());
glib::idle_add_local_once(move || {
show_subfolder_prompt(
window.as_ref(),
&dir_path,
&loaded_files,
&excluded,
&sz,
&stack_ref,
&subfolder_choice,
);
});
}
}
}
} else if is_image_file(&path) {
let mut files = loaded_files.borrow_mut();
if !files.contains(&path) {
files.push(path);
}
drop(files);
}
}
let count = loaded_files.borrow().len();
refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count);
true
});
}
stack.add_controller(drop_target);
adw::NavigationPage::builder()
.title("Add Images")
.tag("step-images")
.child(&stack)
.build()
}
fn is_image_file(path: &std::path::Path) -> bool {
match path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()) {
Some(ext) => matches!(ext.as_str(),
"jpg" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff" | "tif" | "bmp"
| "ico" | "hdr" | "exr" | "pnm" | "ppm" | "pgm" | "pbm" | "pam"
| "tga" | "dds" | "ff" | "farbfeld" | "qoi"
| "heic" | "heif" | "jxl"
| "svg" | "svgz"
| "raw" | "cr2" | "cr3" | "nef" | "nrw" | "arw" | "srf" | "sr2"
| "orf" | "rw2" | "raf" | "dng" | "pef" | "srw" | "x3f"
| "pcx" | "xpm" | "xbm" | "wbmp" | "jp2" | "j2k" | "jpf" | "jpx"
),
None => false,
}
}
fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec<PathBuf>) {
let existing: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();
add_images_from_dir_inner(dir, files, &existing);
}
fn add_images_from_dir_inner(
dir: &std::path::Path,
files: &mut Vec<PathBuf>,
existing: &std::collections::HashSet<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_inner(&path, files, existing);
} else if is_image_file(&path) && !existing.contains(&path) && !files.contains(&path) {
files.push(path);
}
}
}
}
fn add_images_flat(dir: &std::path::Path, files: &mut Vec<PathBuf>) {
let existing: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();
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) && !existing.contains(&path) {
files.push(path);
}
}
}
}
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
}
fn add_images_from_subdirs(dir: &std::path::Path, files: &mut Vec<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: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
file_sizes: &Rc<RefCell<std::collections::HashMap<PathBuf, u64>>>,
stack: &gtk::Stack,
subfolder_choice: &Rc<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 file_sizes = file_sizes.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);
refresh_grid(&stack, &loaded_files, &excluded, &file_sizes, count);
}
});
if let Some(win) = window {
dialog.present(Some(win));
}
}
// ------------------------------------------------------------------
// Thumbnail grid
// ------------------------------------------------------------------
/// Refresh the grid view and count label from current loaded_files state
fn refresh_grid(
stack: &gtk::Stack,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
file_sizes: &Rc<RefCell<std::collections::HashMap<PathBuf, u64>>>,
count: usize,
) {
if count > 0 {
stack.set_visible_child_name("loaded");
} else {
stack.set_visible_child_name("empty");
}
if let Some(loaded_widget) = stack.child_by_name("loaded") {
rebuild_grid_model(&loaded_widget, loaded_files, excluded, file_sizes);
}
}
/// Walk the widget tree to find our ListStore and count label, then rebuild
pub fn rebuild_grid_model(
widget: &gtk::Widget,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
file_sizes: &Rc<RefCell<std::collections::HashMap<PathBuf, u64>>>,
) {
let files = loaded_files.borrow();
let excluded_set = excluded.borrow();
let count = files.len();
let included_count = files.iter().filter(|p| !excluded_set.contains(*p)).count();
// Populate size cache for any new files
{
let mut sizes = file_sizes.borrow_mut();
for p in files.iter() {
sizes.entry(p.clone()).or_insert_with(|| {
std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)
});
}
}
let sizes = file_sizes.borrow();
let total_size: u64 = files.iter()
.filter(|p| !excluded_set.contains(*p))
.map(|p| sizes.get(p).copied().unwrap_or(0))
.sum();
let size_str = format_size(total_size);
// Update the count label
update_heading_label(widget, count, included_count, &size_str);
// Find the GridView and rebuild its model
if let Some(grid_view) = find_grid_view(widget) {
if let Some(model) = grid_view.model()
&& let Some(sel) = model.downcast_ref::<gtk::NoSelection>()
&& let Some(store) = sel.model()
&& let Some(store) = store.downcast_ref::<gtk::gio::ListStore>()
{
store.remove_all();
for path in files.iter() {
let item = ImageItem::new(path);
store.append(&item);
}
}
}
}
fn find_grid_view(widget: &gtk::Widget) -> Option<gtk::GridView> {
if let Some(gv) = widget.downcast_ref::<gtk::GridView>() {
return Some(gv.clone());
}
let mut child = widget.first_child();
while let Some(c) = child {
if let Some(gv) = find_grid_view(&c) {
return Some(gv);
}
child = c.next_sibling();
}
None
}
fn update_heading_label(widget: &gtk::Widget, count: usize, included_count: usize, size_str: &str) {
if let Some(label) = widget.downcast_ref::<gtk::Label>()
&& label.css_classes().iter().any(|c| c == "heading")
{
if included_count == count {
label.set_label(&format!("{} images ({})", count, size_str));
} else {
label.set_label(&format!("{}/{} images selected ({})", included_count, count, size_str));
}
}
let mut child = widget.first_child();
while let Some(c) = child {
update_heading_label(&c, count, included_count, size_str);
child = c.next_sibling();
}
}
/// Update only the count label without rebuilding the grid
fn update_count_label(
widget: &gtk::Widget,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
file_sizes: &Rc<RefCell<std::collections::HashMap<PathBuf, u64>>>,
) {
let files = loaded_files.borrow();
let excluded_set = excluded.borrow();
let sizes = file_sizes.borrow();
let count = files.len();
let included_count = files.iter().filter(|p| !excluded_set.contains(*p)).count();
let total_size: u64 = files.iter()
.filter(|p| !excluded_set.contains(*p))
.map(|p| sizes.get(p).copied().unwrap_or(0))
.sum();
let size_str = format_size(total_size);
update_heading_label(widget, count, included_count, &size_str);
}
// ------------------------------------------------------------------
// GObject wrapper for list store items
// ------------------------------------------------------------------
mod imp {
use super::*;
use std::cell::OnceCell;
#[derive(Default)]
pub struct ImageItem {
pub path: OnceCell<PathBuf>,
}
#[glib::object_subclass]
impl glib::subclass::types::ObjectSubclass for ImageItem {
const NAME: &'static str = "PixstripImageItem";
type Type = super::ImageItem;
type ParentType = glib::Object;
}
impl glib::subclass::object::ObjectImpl for ImageItem {}
}
glib::wrapper! {
pub struct ImageItem(ObjectSubclass<imp::ImageItem>);
}
impl ImageItem {
fn new(path: &std::path::Path) -> Self {
let obj: Self = glib::Object::builder().build();
let _ = obj.imp().path.set(path.to_path_buf());
obj
}
pub fn path(&self) -> &std::path::Path {
self.imp().path.get().map(|p: &PathBuf| p.as_path()).unwrap_or(std::path::Path::new(""))
}
}
// ------------------------------------------------------------------
// Empty state
// ------------------------------------------------------------------
fn build_empty_state() -> gtk::Box {
let container = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.vexpand(true)
.hexpand(true)
.build();
let drop_zone = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.vexpand(true)
.margin_top(48)
.margin_bottom(48)
.margin_start(48)
.margin_end(48)
.build();
drop_zone.add_css_class("card");
drop_zone.update_property(&[
gtk::accessible::Property::Label("Image drop zone. Drop images here or use the Browse Files button."),
]);
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.margin_top(48)
.margin_bottom(48)
.margin_start(64)
.margin_end(64)
.halign(gtk::Align::Center)
.build();
let icon = gtk::Image::builder()
.icon_name("folder-pictures-symbolic")
.pixel_size(64)
.css_classes(["dim-label"])
.build();
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
let title = gtk::Label::builder()
.label("Drop images here")
.css_classes(["title-2"])
.build();
let subtitle = gtk::Label::builder()
.label("or click Browse to select files.\nYou can also drop folders.")
.css_classes(["dim-label"])
.halign(gtk::Align::Center)
.justify(gtk::Justification::Center)
.build();
let formats_label = gtk::Label::builder()
.label("Supports all common image formats including RAW")
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Center)
.margin_top(8)
.build();
let browse_button = gtk::Button::builder()
.label("Browse Files")
.tooltip_text("Add image files (Ctrl+O)")
.halign(gtk::Align::Center)
.action_name("win.add-files")
.build();
browse_button.add_css_class("suggested-action");
browse_button.add_css_class("pill");
let hint = gtk::Label::builder()
.label("Start by adding your images below, then use the Next button to configure each processing step.")
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Center)
.justify(gtk::Justification::Center)
.wrap(true)
.margin_bottom(8)
.build();
inner.append(&hint);
inner.append(&icon);
inner.append(&title);
inner.append(&subtitle);
inner.append(&formats_label);
inner.append(&browse_button);
drop_zone.append(&inner);
container.append(&drop_zone);
container
}
// ------------------------------------------------------------------
// Loaded state with thumbnail GridView
// ------------------------------------------------------------------
fn build_loaded_state(state: &AppState) -> gtk::Box {
let container = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
.build();
// Use the shared batch_updating flag from AppState
let batch_updating = state.batch_updating.clone();
// Toolbar
let toolbar = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.margin_start(12)
.margin_end(12)
.margin_top(8)
.margin_bottom(8)
.build();
let count_label = gtk::Label::builder()
.label("0 images")
.hexpand(true)
.halign(gtk::Align::Start)
.css_classes(["heading"])
.build();
let add_button = gtk::Button::builder()
.icon_name("list-add-symbolic")
.tooltip_text("Add more images (Ctrl+O)")
.action_name("win.add-files")
.build();
add_button.add_css_class("flat");
let select_all_button = gtk::Button::builder()
.icon_name("edit-select-all-symbolic")
.tooltip_text("Select all images (Ctrl+A)")
.sensitive(false)
.build();
select_all_button.add_css_class("flat");
select_all_button.update_property(&[
gtk::accessible::Property::Label("Select all images for processing"),
]);
let deselect_all_button = gtk::Button::builder()
.icon_name("edit-clear-symbolic")
.tooltip_text("Deselect all images (Ctrl+Shift+A)")
.sensitive(false)
.build();
deselect_all_button.add_css_class("flat");
deselect_all_button.update_property(&[
gtk::accessible::Property::Label("Deselect all images from processing"),
]);
let clear_button = gtk::Button::builder()
.icon_name("edit-clear-all-symbolic")
.tooltip_text("Remove all images")
.sensitive(false)
.build();
clear_button.add_css_class("flat");
clear_button.update_property(&[
gtk::accessible::Property::Label("Remove all images from list"),
]);
// Build the grid view model
let store = gtk::gio::ListStore::new::<ImageItem>();
let selection = gtk::NoSelection::new(Some(store.clone()));
let factory = gtk::SignalListItemFactory::new();
// Factory: setup
{
factory.connect_setup(move |_factory, list_item| {
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
let overlay = gtk::Overlay::builder()
.width_request(THUMB_SIZE)
.height_request(THUMB_SIZE)
.build();
let picture = gtk::Picture::builder()
.content_fit(gtk::ContentFit::Cover)
.width_request(THUMB_SIZE)
.height_request(THUMB_SIZE)
.build();
picture.add_css_class("thumbnail-image");
// Placeholder icon shown while loading
let placeholder = gtk::Image::builder()
.icon_name("image-x-generic-symbolic")
.pixel_size(48)
.css_classes(["dim-label"])
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.build();
let thumb_stack = gtk::Stack::builder()
.transition_type(gtk::StackTransitionType::Crossfade)
.transition_duration(150)
.width_request(THUMB_SIZE)
.height_request(THUMB_SIZE)
.build();
thumb_stack.add_named(&placeholder, Some("placeholder"));
thumb_stack.add_named(&picture, Some("picture"));
thumb_stack.set_visible_child_name("placeholder");
// Frame to clip rounded corners
let frame = gtk::Frame::builder()
.child(&thumb_stack)
.overflow(gtk::Overflow::Hidden)
.build();
frame.add_css_class("thumbnail-frame");
overlay.set_child(Some(&frame));
// Checkbox overlay in top-left corner
let check = gtk::CheckButton::builder()
.active(true)
.tooltip_text("Include in processing")
.halign(gtk::Align::Start)
.valign(gtk::Align::Start)
.margin_start(4)
.margin_top(4)
.build();
check.add_css_class("thumbnail-check");
overlay.add_overlay(&check);
// File name label at the bottom
let name_label = gtk::Label::builder()
.css_classes(["caption"])
.halign(gtk::Align::Center)
.ellipsize(gtk::pango::EllipsizeMode::Middle)
.max_width_chars(14)
.build();
let vbox = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(4)
.halign(gtk::Align::Center)
.margin_top(4)
.margin_bottom(4)
.margin_start(4)
.margin_end(4)
.build();
vbox.append(&overlay);
vbox.append(&name_label);
list_item.set_child(Some(&vbox));
});
}
// Factory: bind
{
let excluded = state.excluded_files.clone();
let loaded = state.loaded_files.clone();
let cached_sizes = state.file_sizes.clone();
let batch_flag = batch_updating.clone();
factory.connect_bind(move |_factory, list_item| {
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
let Some(item) = list_item.item().and_downcast::<ImageItem>() else { return };
let path = item.path().to_path_buf();
let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
let Some(name_label) = overlay.next_sibling().and_downcast::<gtk::Label>() else { return };
// Set filename
let file_name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
name_label.set_label(file_name);
// Get the frame -> stack -> picture
let Some(frame) = overlay.child().and_downcast::<gtk::Frame>() else { return };
let Some(thumb_stack) = frame.child().and_downcast::<gtk::Stack>() else { return };
let Some(picture) = thumb_stack.child_by_name("picture")
.and_downcast::<gtk::Picture>() else { return };
// Reset to placeholder
thumb_stack.set_visible_child_name("placeholder");
// Bump bind generation so stale idle callbacks are ignored
let bind_gen: u32 = unsafe {
thumb_stack.data::<u32>("bind-gen")
.map(|p| *p.as_ref())
.unwrap_or(0)
.wrapping_add(1)
};
unsafe { thumb_stack.set_data("bind-gen", bind_gen); }
// Load thumbnail in background thread to avoid blocking the UI
let thumb_stack_c = thumb_stack.clone();
let picture_c = picture.clone();
let path_c = path.clone();
let (tx, rx) = std::sync::mpsc::channel::<Option<Vec<u8>>>();
std::thread::spawn(move || {
let result = (|| -> Option<Vec<u8>> {
let img = image::open(&path_c).ok()?;
let thumb = img.resize(
(THUMB_SIZE * 2) as u32,
(THUMB_SIZE * 2) as u32,
image::imageops::FilterType::Triangle,
);
let mut buf = Vec::new();
thumb.write_to(
&mut std::io::Cursor::new(&mut buf),
image::ImageFormat::Png,
).ok()?;
Some(buf)
})();
let _ = tx.send(result);
});
glib::timeout_add_local(std::time::Duration::from_millis(50), move || {
let current: u32 = unsafe {
thumb_stack_c.data::<u32>("bind-gen")
.map(|p| *p.as_ref())
.unwrap_or(0)
};
if current != bind_gen {
return glib::ControlFlow::Break;
}
match rx.try_recv() {
Ok(Some(bytes)) => {
let gbytes = glib::Bytes::from(&bytes);
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
picture_c.set_paintable(Some(&texture));
thumb_stack_c.set_visible_child_name("picture");
}
glib::ControlFlow::Break
}
Ok(None) => glib::ControlFlow::Break, // decode failed, leave placeholder
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(_) => glib::ControlFlow::Break,
}
});
// Set accessible label on thumbnail picture
picture.update_property(&[
gtk::accessible::Property::Label(&format!("Thumbnail of {}", file_name)),
]);
// Set checkbox state
let check = find_check_button(overlay.upcast_ref::<gtk::Widget>());
if let Some(ref check) = check {
let is_excluded = excluded.borrow().contains(&path);
check.set_active(!is_excluded);
check.update_property(&[
gtk::accessible::Property::Label(&format!("Include {} in processing", file_name)),
]);
// Wire checkbox toggle
let excl = excluded.clone();
let loaded_ref = loaded.clone();
let sizes_ref = cached_sizes.clone();
let batch_guard = batch_flag.clone();
let file_path = path.clone();
let handler_id = check.connect_toggled(move |btn| {
{
let mut excl = excl.borrow_mut();
if btn.is_active() {
excl.remove(&file_path);
} else {
excl.insert(file_path.clone());
}
}
// Skip per-toggle label update during batch select/deselect
if batch_guard.get() {
return;
}
// Update count label
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
&& let Some(loaded_widget) = stack.child_by_name("loaded")
{
update_count_label(&loaded_widget, &loaded_ref, &excl, &sizes_ref);
}
});
// Store handler id so we can disconnect in unbind
unsafe {
check.set_data("handler-id", handler_id);
}
}
});
}
// Factory: unbind - disconnect signal to avoid stale closures
{
factory.connect_unbind(move |_factory, list_item| {
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
if let Some(check) = find_check_button(overlay.upcast_ref::<gtk::Widget>()) {
let handler: Option<glib::SignalHandlerId> = unsafe {
check.steal_data("handler-id")
};
if let Some(id) = handler {
check.disconnect(id);
}
}
});
}
let grid_view = gtk::GridView::builder()
.model(&selection)
.factory(&factory)
.min_columns(2)
.max_columns(10)
.build();
grid_view.add_css_class("thumbnail-grid");
grid_view.update_property(&[
gtk::accessible::Property::Label("Image thumbnail grid. Use arrow keys to navigate."),
]);
let scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&grid_view)
.build();
// Wire clear button
{
let files = state.loaded_files.clone();
let excl = state.excluded_files.clone();
let count_label_c = count_label.clone();
let store_c = store.clone();
clear_button.connect_clicked(move |btn| {
files.borrow_mut().clear();
excl.borrow_mut().clear();
store_c.remove_all();
count_label_c.set_label("0 images");
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
{
stack.set_visible_child_name("empty");
}
});
}
// Wire Select All
{
let excl = state.excluded_files.clone();
let loaded = state.loaded_files.clone();
let sizes = state.file_sizes.clone();
let batch_flag = batch_updating.clone();
select_all_button.connect_clicked(move |btn| {
excl.borrow_mut().clear();
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
&& let Some(loaded_widget) = stack.child_by_name("loaded")
{
batch_flag.set(true);
set_all_checkboxes_in(&loaded_widget, true);
batch_flag.set(false);
update_count_label(&loaded_widget, &loaded, &excl, &sizes);
}
});
}
// Wire Deselect All
{
let excl = state.excluded_files.clone();
let loaded = state.loaded_files.clone();
let sizes = state.file_sizes.clone();
let batch_flag = batch_updating.clone();
deselect_all_button.connect_clicked(move |btn| {
{
let files = loaded.borrow();
let mut excl_set = excl.borrow_mut();
for f in files.iter() {
excl_set.insert(f.clone());
}
}
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
&& let Some(loaded_widget) = stack.child_by_name("loaded")
{
batch_flag.set(true);
set_all_checkboxes_in(&loaded_widget, false);
batch_flag.set(false);
update_count_label(&loaded_widget, &loaded, &excl, &sizes);
}
});
}
toolbar.append(&count_label);
toolbar.append(&select_all_button);
toolbar.append(&deselect_all_button);
toolbar.append(&add_button);
toolbar.append(&clear_button);
// Enable/disable toolbar buttons based on whether the store has items
{
let sa = select_all_button.clone();
let da = deselect_all_button.clone();
let cl = clear_button.clone();
store.connect_items_changed(move |store, _, _, _| {
let has_items = store.n_items() > 0;
sa.set_sensitive(has_items);
da.set_sensitive(has_items);
cl.set_sensitive(has_items);
});
}
toolbar.update_property(&[
gtk::accessible::Property::Label("Image toolbar with count, selection, and add controls"),
]);
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
container.append(&toolbar);
container.append(&separator);
container.append(&scrolled);
container
}
fn find_check_button(widget: &gtk::Widget) -> Option<gtk::CheckButton> {
if let Some(cb) = widget.downcast_ref::<gtk::CheckButton>() {
return Some(cb.clone());
}
let mut child = widget.first_child();
while let Some(c) = child {
if let Some(cb) = find_check_button(&c) {
return Some(cb);
}
child = c.next_sibling();
}
None
}
/// Download an image from a URL to a temporary file
fn download_image_url(url: &str) -> Option<std::path::PathBuf> {
let temp_dir = std::env::temp_dir().join("pixstrip-downloads");
std::fs::create_dir_all(&temp_dir).ok()?;
// Extract and sanitize filename from URL to prevent path traversal
let url_path = url.split('?').next().unwrap_or(url);
let raw_name = url_path
.rsplit('/')
.next()
.unwrap_or("downloaded.jpg");
let sanitized = std::path::Path::new(raw_name)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("downloaded.jpg");
let filename = if sanitized.is_empty() { "downloaded.jpg" } else { sanitized };
// Avoid overwriting previous downloads with the same filename
let dest = {
let base = temp_dir.join(filename);
if base.exists() {
let stem = std::path::Path::new(filename).file_stem()
.and_then(|s| s.to_str()).unwrap_or("downloaded");
let ext = std::path::Path::new(filename).extension()
.and_then(|e| e.to_str()).unwrap_or("jpg");
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis()).unwrap_or(0);
temp_dir.join(format!("{}_{}.{}", stem, ts, ext))
} else {
base
}
};
// Use GIO for the download (synchronous, runs in background thread)
let gfile = gtk::gio::File::for_uri(url);
let stream = gfile.read(gtk::gio::Cancellable::NONE).ok()?;
const MAX_DOWNLOAD_BYTES: usize = 100 * 1024 * 1024; // 100 MB
let mut buf = Vec::new();
loop {
let bytes = stream.read_bytes(8192, gtk::gio::Cancellable::NONE).ok()?;
if bytes.is_empty() {
break;
}
buf.extend_from_slice(&bytes);
if buf.len() > MAX_DOWNLOAD_BYTES {
return None;
}
}
if buf.is_empty() {
return None;
}
std::fs::write(&dest, &buf).ok()?;
// Verify it's actually an image
if is_image_file(&dest) {
Some(dest)
} else {
let _ = std::fs::remove_file(&dest);
None
}
}
/// Set all CheckButton widgets within a container to a given state
pub fn set_all_checkboxes_in(widget: &gtk::Widget, active: bool) {
if let Some(check) = widget.downcast_ref::<gtk::CheckButton>() {
check.set_active(active);
return;
}
let mut child = widget.first_child();
while let Some(c) = child {
set_all_checkboxes_in(&c, active);
child = c.next_sibling();
}
}