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();
|
|
}
|
|
}
|