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.
1059 lines
38 KiB
Rust
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<>k::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: >k::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: >k::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: >k::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: >k::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: >k::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: >k::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: >k::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: >k::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();
|
|
}
|
|
}
|