Replace image list with GtkGridView thumbnail grid
Upgrade the Add Images step from a plain ListBox to a GtkGridView with thumbnail rendering. Each item shows a scaled thumbnail with a checkbox overlay for include/exclude, and filename label below. Thumbnails load asynchronously using Pixbuf at reduced size. Uses GObject subclass (ImageItem) for the list model.
This commit is contained in:
@@ -1,6 +1,15 @@
|
|||||||
use adw::prelude::*;
|
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::app::AppState;
|
||||||
|
|
||||||
|
const THUMB_SIZE: i32 = 120;
|
||||||
|
|
||||||
pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
||||||
let stack = gtk::Stack::builder()
|
let stack = gtk::Stack::builder()
|
||||||
.transition_type(gtk::StackTransitionType::Crossfade)
|
.transition_type(gtk::StackTransitionType::Crossfade)
|
||||||
@@ -10,15 +19,14 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
let empty_state = build_empty_state();
|
let empty_state = build_empty_state();
|
||||||
stack.add_named(&empty_state, Some("empty"));
|
stack.add_named(&empty_state, Some("empty"));
|
||||||
|
|
||||||
// Loaded state - file list
|
// Loaded state - thumbnail grid
|
||||||
let loaded_state = build_loaded_state(state);
|
let loaded_state = build_loaded_state(state);
|
||||||
stack.add_named(&loaded_state, Some("loaded"));
|
stack.add_named(&loaded_state, Some("loaded"));
|
||||||
|
|
||||||
stack.set_visible_child_name("empty");
|
stack.set_visible_child_name("empty");
|
||||||
|
|
||||||
// Session-level remembered subfolder choice (None = not yet asked)
|
// Session-level remembered subfolder choice (None = not yet asked)
|
||||||
let subfolder_choice: std::rc::Rc<std::cell::RefCell<Option<bool>>> =
|
let subfolder_choice: Rc<RefCell<Option<bool>>> = Rc::new(RefCell::new(None));
|
||||||
std::rc::Rc::new(std::cell::RefCell::new(None));
|
|
||||||
|
|
||||||
// Set up drag-and-drop on the entire page
|
// Set up drag-and-drop on the entire page
|
||||||
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
|
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
|
||||||
@@ -36,40 +44,35 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
let has_subdirs = has_subfolders(&path);
|
let has_subdirs = has_subfolders(&path);
|
||||||
if !has_subdirs {
|
if !has_subdirs {
|
||||||
// No subfolders - just load top-level images
|
|
||||||
let mut files = loaded_files.borrow_mut();
|
let mut files = loaded_files.borrow_mut();
|
||||||
add_images_flat(&path, &mut files);
|
add_images_flat(&path, &mut files);
|
||||||
let count = files.len();
|
let count = files.len();
|
||||||
drop(files);
|
drop(files);
|
||||||
update_loaded_ui(&stack_ref, &loaded_files, &excluded, count);
|
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
|
||||||
} else {
|
} else {
|
||||||
let choice = *subfolder_choice.borrow();
|
let choice = *subfolder_choice.borrow();
|
||||||
match choice {
|
match choice {
|
||||||
Some(true) => {
|
Some(true) => {
|
||||||
// Remembered: include subfolders
|
|
||||||
let mut files = loaded_files.borrow_mut();
|
let mut files = loaded_files.borrow_mut();
|
||||||
add_images_from_dir(&path, &mut files);
|
add_images_from_dir(&path, &mut files);
|
||||||
let count = files.len();
|
let count = files.len();
|
||||||
drop(files);
|
drop(files);
|
||||||
update_loaded_ui(&stack_ref, &loaded_files, &excluded, count);
|
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
|
||||||
}
|
}
|
||||||
Some(false) => {
|
Some(false) => {
|
||||||
// Remembered: top-level only
|
|
||||||
let mut files = loaded_files.borrow_mut();
|
let mut files = loaded_files.borrow_mut();
|
||||||
add_images_flat(&path, &mut files);
|
add_images_flat(&path, &mut files);
|
||||||
let count = files.len();
|
let count = files.len();
|
||||||
drop(files);
|
drop(files);
|
||||||
update_loaded_ui(&stack_ref, &loaded_files, &excluded, count);
|
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Not yet asked - add top-level now, then prompt
|
|
||||||
let mut files = loaded_files.borrow_mut();
|
let mut files = loaded_files.borrow_mut();
|
||||||
add_images_flat(&path, &mut files);
|
add_images_flat(&path, &mut files);
|
||||||
let count = files.len();
|
let count = files.len();
|
||||||
drop(files);
|
drop(files);
|
||||||
update_loaded_ui(&stack_ref, &loaded_files, &excluded, count);
|
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
|
||||||
|
|
||||||
// Show dialog asynchronously
|
|
||||||
let loaded_files = loaded_files.clone();
|
let loaded_files = loaded_files.clone();
|
||||||
let excluded = excluded.clone();
|
let excluded = excluded.clone();
|
||||||
let stack_ref = stack_ref.clone();
|
let stack_ref = stack_ref.clone();
|
||||||
@@ -78,7 +81,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
let window = target.widget()
|
let window = target.widget()
|
||||||
.and_then(|w| w.root())
|
.and_then(|w| w.root())
|
||||||
.and_then(|r| r.downcast::<gtk::Window>().ok());
|
.and_then(|r| r.downcast::<gtk::Window>().ok());
|
||||||
gtk::glib::idle_add_local_once(move || {
|
glib::idle_add_local_once(move || {
|
||||||
show_subfolder_prompt(
|
show_subfolder_prompt(
|
||||||
window.as_ref(),
|
window.as_ref(),
|
||||||
&dir_path,
|
&dir_path,
|
||||||
@@ -99,7 +102,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
}
|
}
|
||||||
let count = files.len();
|
let count = files.len();
|
||||||
drop(files);
|
drop(files);
|
||||||
update_loaded_ui(&stack_ref, &loaded_files, &excluded, count);
|
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +126,7 @@ fn is_image_file(path: &std::path::Path) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
|
fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec<PathBuf>) {
|
||||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
@@ -136,8 +139,7 @@ fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add only top-level images from a directory (no recursion into subfolders)
|
fn add_images_flat(dir: &std::path::Path, files: &mut Vec<PathBuf>) {
|
||||||
fn add_images_flat(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
|
|
||||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
@@ -148,7 +150,6 @@ fn add_images_flat(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a directory contains any subdirectories
|
|
||||||
fn has_subfolders(dir: &std::path::Path) -> bool {
|
fn has_subfolders(dir: &std::path::Path) -> bool {
|
||||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
@@ -160,8 +161,7 @@ fn has_subfolders(dir: &std::path::Path) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add only the images from subfolders (not top-level, since those were already added)
|
fn add_images_from_subdirs(dir: &std::path::Path, files: &mut Vec<PathBuf>) {
|
||||||
fn add_images_from_subdirs(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
|
|
||||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
@@ -175,10 +175,10 @@ fn add_images_from_subdirs(dir: &std::path::Path, files: &mut Vec<std::path::Pat
|
|||||||
fn show_subfolder_prompt(
|
fn show_subfolder_prompt(
|
||||||
window: Option<>k::Window>,
|
window: Option<>k::Window>,
|
||||||
dir: &std::path::Path,
|
dir: &std::path::Path,
|
||||||
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
|
||||||
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::PathBuf>>>,
|
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
|
||||||
stack: >k::Stack,
|
stack: >k::Stack,
|
||||||
subfolder_choice: &std::rc::Rc<std::cell::RefCell<Option<bool>>>,
|
subfolder_choice: &Rc<RefCell<Option<bool>>>,
|
||||||
) {
|
) {
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Include subfolders?")
|
.heading("Include subfolders?")
|
||||||
@@ -202,7 +202,7 @@ fn show_subfolder_prompt(
|
|||||||
add_images_from_subdirs(&dir, &mut files);
|
add_images_from_subdirs(&dir, &mut files);
|
||||||
let count = files.len();
|
let count = files.len();
|
||||||
drop(files);
|
drop(files);
|
||||||
update_loaded_ui(&stack, &loaded_files, &excluded, count);
|
refresh_grid(&stack, &loaded_files, &excluded, count);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,10 +211,15 @@ fn show_subfolder_prompt(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_loaded_ui(
|
// ------------------------------------------------------------------
|
||||||
|
// Thumbnail grid
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Refresh the grid view and count label from current loaded_files state
|
||||||
|
fn refresh_grid(
|
||||||
stack: >k::Stack,
|
stack: >k::Stack,
|
||||||
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
|
||||||
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::PathBuf>>>,
|
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
|
||||||
count: usize,
|
count: usize,
|
||||||
) {
|
) {
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
@@ -223,14 +228,15 @@ fn update_loaded_ui(
|
|||||||
stack.set_visible_child_name("empty");
|
stack.set_visible_child_name("empty");
|
||||||
}
|
}
|
||||||
if let Some(loaded_widget) = stack.child_by_name("loaded") {
|
if let Some(loaded_widget) = stack.child_by_name("loaded") {
|
||||||
update_count_and_list(&loaded_widget, loaded_files, excluded);
|
rebuild_grid_model(&loaded_widget, loaded_files, excluded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_count_and_list(
|
/// Walk the widget tree to find our ListStore and count label, then rebuild
|
||||||
|
fn rebuild_grid_model(
|
||||||
widget: >k::Widget,
|
widget: >k::Widget,
|
||||||
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
|
||||||
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::PathBuf>>>,
|
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
|
||||||
) {
|
) {
|
||||||
let files = loaded_files.borrow();
|
let files = loaded_files.borrow();
|
||||||
let excluded_set = excluded.borrow();
|
let excluded_set = excluded.borrow();
|
||||||
@@ -243,18 +249,40 @@ fn update_count_and_list(
|
|||||||
.sum();
|
.sum();
|
||||||
let size_str = format_size(total_size);
|
let size_str = format_size(total_size);
|
||||||
|
|
||||||
walk_loaded_widgets(widget, count, included_count, &size_str, &files, loaded_files, excluded);
|
// 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 walk_loaded_widgets(
|
fn find_grid_view(widget: >k::Widget) -> Option<gtk::GridView> {
|
||||||
widget: >k::Widget,
|
if let Some(gv) = widget.downcast_ref::<gtk::GridView>() {
|
||||||
count: usize,
|
return Some(gv.clone());
|
||||||
included_count: usize,
|
}
|
||||||
size_str: &str,
|
let mut child = widget.first_child();
|
||||||
files: &[std::path::PathBuf],
|
while let Some(c) = child {
|
||||||
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
if let Some(gv) = find_grid_view(&c) {
|
||||||
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::PathBuf>>>,
|
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>()
|
if let Some(label) = widget.downcast_ref::<gtk::Label>()
|
||||||
&& label.css_classes().iter().any(|c| c == "heading")
|
&& label.css_classes().iter().any(|c| c == "heading")
|
||||||
{
|
{
|
||||||
@@ -264,117 +292,18 @@ fn walk_loaded_widgets(
|
|||||||
label.set_label(&format!("{}/{} images selected ({})", included_count, count, size_str));
|
label.set_label(&format!("{}/{} images selected ({})", included_count, count, size_str));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(list_box) = widget.downcast_ref::<gtk::ListBox>()
|
|
||||||
&& list_box.css_classes().iter().any(|c| c == "boxed-list")
|
|
||||||
{
|
|
||||||
// Clear existing rows
|
|
||||||
while let Some(row) = list_box.first_child() {
|
|
||||||
list_box.remove(&row);
|
|
||||||
}
|
|
||||||
let excluded_set = excluded.borrow();
|
|
||||||
// Add rows for each file with checkbox and remove button
|
|
||||||
for (idx, path) in files.iter().enumerate() {
|
|
||||||
let name = path.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let size = std::fs::metadata(path)
|
|
||||||
.map(|m| format_size(m.len()))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let ext = path.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_uppercase();
|
|
||||||
let row = adw::ActionRow::builder()
|
|
||||||
.title(name)
|
|
||||||
.subtitle(format!("{} - {}", ext, size))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Include/exclude checkbox
|
|
||||||
let check = gtk::CheckButton::builder()
|
|
||||||
.active(!excluded_set.contains(path))
|
|
||||||
.tooltip_text("Include in processing")
|
|
||||||
.valign(gtk::Align::Center)
|
|
||||||
.build();
|
|
||||||
{
|
|
||||||
let excl = excluded.clone();
|
|
||||||
let file_path = path.clone();
|
|
||||||
let list = list_box.clone();
|
|
||||||
let loaded = loaded_files.clone();
|
|
||||||
let excluded_ref = excluded.clone();
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Update the count label
|
|
||||||
if let Some(parent) = list.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, &excluded_ref);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
row.add_prefix(&check);
|
|
||||||
row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic"));
|
|
||||||
|
|
||||||
// Per-image remove button
|
|
||||||
let remove_btn = gtk::Button::builder()
|
|
||||||
.icon_name("list-remove-symbolic")
|
|
||||||
.tooltip_text("Remove this image from batch")
|
|
||||||
.valign(gtk::Align::Center)
|
|
||||||
.build();
|
|
||||||
remove_btn.add_css_class("flat");
|
|
||||||
{
|
|
||||||
let loaded = loaded_files.clone();
|
|
||||||
let excl = excluded.clone();
|
|
||||||
let list = list_box.clone();
|
|
||||||
let file_idx = idx;
|
|
||||||
remove_btn.connect_clicked(move |_btn| {
|
|
||||||
let removed_path;
|
|
||||||
{
|
|
||||||
let mut files = loaded.borrow_mut();
|
|
||||||
if file_idx < files.len() {
|
|
||||||
removed_path = files.remove(file_idx);
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
excl.borrow_mut().remove(&removed_path);
|
|
||||||
let count = loaded.borrow().len();
|
|
||||||
// Refresh by finding the parent stack
|
|
||||||
if let Some(parent) = list.ancestor(gtk::Stack::static_type())
|
|
||||||
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
|
|
||||||
{
|
|
||||||
if count == 0 {
|
|
||||||
stack.set_visible_child_name("empty");
|
|
||||||
} else if let Some(loaded_widget) = stack.child_by_name("loaded") {
|
|
||||||
update_count_and_list(&loaded_widget, &loaded, &excl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
row.add_suffix(&remove_btn);
|
|
||||||
list_box.append(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Recurse
|
|
||||||
let mut child = widget.first_child();
|
let mut child = widget.first_child();
|
||||||
while let Some(c) = child {
|
while let Some(c) = child {
|
||||||
walk_loaded_widgets(&c, count, included_count, size_str, files, loaded_files, excluded);
|
update_heading_label(&c, count, included_count, size_str);
|
||||||
child = c.next_sibling();
|
child = c.next_sibling();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update only the count label without rebuilding the list
|
/// Update only the count label without rebuilding the grid
|
||||||
fn update_count_label(
|
fn update_count_label(
|
||||||
widget: >k::Widget,
|
widget: >k::Widget,
|
||||||
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
|
||||||
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::PathBuf>>>,
|
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
|
||||||
) {
|
) {
|
||||||
let files = loaded_files.borrow();
|
let files = loaded_files.borrow();
|
||||||
let excluded_set = excluded.borrow();
|
let excluded_set = excluded.borrow();
|
||||||
@@ -386,25 +315,7 @@ fn update_count_label(
|
|||||||
.map(|m| m.len())
|
.map(|m| m.len())
|
||||||
.sum();
|
.sum();
|
||||||
let size_str = format_size(total_size);
|
let size_str = format_size(total_size);
|
||||||
|
update_heading_label(widget, count, included_count, &size_str);
|
||||||
update_count_label_walk(widget, count, included_count, &size_str);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_count_label_walk(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_count_label_walk(&c, count, included_count, size_str);
|
|
||||||
child = c.next_sibling();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_size(bytes: u64) -> String {
|
fn format_size(bytes: u64) -> String {
|
||||||
@@ -419,6 +330,49 @@ fn format_size(bytes: u64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 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 {
|
fn build_empty_state() -> gtk::Box {
|
||||||
let container = gtk::Box::builder()
|
let container = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
@@ -497,6 +451,10 @@ fn build_empty_state() -> gtk::Box {
|
|||||||
container
|
container
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Loaded state with thumbnail GridView
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
fn build_loaded_state(state: &AppState) -> gtk::Box {
|
fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||||
let container = gtk::Box::builder()
|
let container = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
@@ -545,14 +503,205 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
|||||||
.build();
|
.build();
|
||||||
clear_button.add_css_class("flat");
|
clear_button.add_css_class("flat");
|
||||||
|
|
||||||
|
// 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 list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||||
|
|
||||||
|
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();
|
||||||
|
factory.connect_bind(move |_factory, list_item| {
|
||||||
|
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||||
|
let item = list_item.item().and_downcast::<ImageItem>().unwrap();
|
||||||
|
let path = item.path().to_path_buf();
|
||||||
|
|
||||||
|
let vbox = list_item.child().and_downcast::<gtk::Box>().unwrap();
|
||||||
|
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap();
|
||||||
|
let name_label = overlay.next_sibling().and_downcast::<gtk::Label>().unwrap();
|
||||||
|
|
||||||
|
// 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 frame = overlay.child().and_downcast::<gtk::Frame>().unwrap();
|
||||||
|
let thumb_stack = frame.child().and_downcast::<gtk::Stack>().unwrap();
|
||||||
|
let picture = thumb_stack.child_by_name("picture")
|
||||||
|
.and_downcast::<gtk::Picture>().unwrap();
|
||||||
|
|
||||||
|
// Reset to placeholder
|
||||||
|
thumb_stack.set_visible_child_name("placeholder");
|
||||||
|
|
||||||
|
// Load thumbnail asynchronously
|
||||||
|
let thumb_stack_c = thumb_stack.clone();
|
||||||
|
let picture_c = picture.clone();
|
||||||
|
let path_c = path.clone();
|
||||||
|
glib::idle_add_local_once(move || {
|
||||||
|
load_thumbnail(&path_c, &picture_c, &thumb_stack_c);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Wire checkbox toggle
|
||||||
|
let excl = excluded.clone();
|
||||||
|
let loaded_ref = loaded.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 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 list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||||
|
let vbox = list_item.child().and_downcast::<gtk::Box>().unwrap();
|
||||||
|
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap();
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&grid_view)
|
||||||
|
.build();
|
||||||
|
|
||||||
// Wire clear button
|
// Wire clear button
|
||||||
{
|
{
|
||||||
let files = state.loaded_files.clone();
|
let files = state.loaded_files.clone();
|
||||||
let excl = state.excluded_files.clone();
|
let excl = state.excluded_files.clone();
|
||||||
let count_label_c = count_label.clone();
|
let count_label_c = count_label.clone();
|
||||||
|
let store_c = store.clone();
|
||||||
clear_button.connect_clicked(move |btn| {
|
clear_button.connect_clicked(move |btn| {
|
||||||
files.borrow_mut().clear();
|
files.borrow_mut().clear();
|
||||||
excl.borrow_mut().clear();
|
excl.borrow_mut().clear();
|
||||||
|
store_c.remove_all();
|
||||||
count_label_c.set_label("0 images");
|
count_label_c.set_label("0 images");
|
||||||
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
|
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
|
||||||
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
|
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
|
||||||
@@ -562,7 +711,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire Select All - clears exclusion set, re-checks all checkboxes
|
// Wire Select All
|
||||||
{
|
{
|
||||||
let excl = state.excluded_files.clone();
|
let excl = state.excluded_files.clone();
|
||||||
let loaded = state.loaded_files.clone();
|
let loaded = state.loaded_files.clone();
|
||||||
@@ -578,7 +727,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire Deselect All - adds all files to exclusion set, unchecks all
|
// Wire Deselect All
|
||||||
{
|
{
|
||||||
let excl = state.excluded_files.clone();
|
let excl = state.excluded_files.clone();
|
||||||
let loaded = state.loaded_files.clone();
|
let loaded = state.loaded_files.clone();
|
||||||
@@ -608,35 +757,48 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
|||||||
|
|
||||||
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
|
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
|
||||||
|
|
||||||
// File list
|
|
||||||
let list_scrolled = gtk::ScrolledWindow::builder()
|
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
||||||
.vexpand(true)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let list_box = gtk::ListBox::builder()
|
|
||||||
.selection_mode(gtk::SelectionMode::None)
|
|
||||||
.css_classes(["boxed-list"])
|
|
||||||
.margin_start(12)
|
|
||||||
.margin_end(12)
|
|
||||||
.margin_top(8)
|
|
||||||
.margin_bottom(8)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
list_scrolled.set_child(Some(&list_box));
|
|
||||||
|
|
||||||
container.append(&toolbar);
|
container.append(&toolbar);
|
||||||
container.append(&separator);
|
container.append(&separator);
|
||||||
container.append(&list_scrolled);
|
container.append(&scrolled);
|
||||||
|
|
||||||
container
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a thumbnail for the given path into the Picture widget
|
||||||
|
fn load_thumbnail(path: &std::path::Path, picture: >k::Picture, stack: >k::Stack) {
|
||||||
|
// Use GdkPixbuf to load at reduced size for speed
|
||||||
|
match gtk::gdk_pixbuf::Pixbuf::from_file_at_scale(path, THUMB_SIZE * 2, THUMB_SIZE * 2, true) {
|
||||||
|
Ok(pixbuf) => {
|
||||||
|
#[allow(deprecated)]
|
||||||
|
let texture = gtk::gdk::Texture::for_pixbuf(&pixbuf);
|
||||||
|
picture.set_paintable(Some(&texture));
|
||||||
|
stack.set_visible_child_name("picture");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Leave placeholder visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Set all CheckButton widgets within a container to a given state
|
/// Set all CheckButton widgets within a container to a given state
|
||||||
pub fn set_all_checkboxes_in(widget: >k::Widget, active: bool) {
|
pub fn set_all_checkboxes_in(widget: >k::Widget, active: bool) {
|
||||||
if let Some(check) = widget.downcast_ref::<gtk::CheckButton>() {
|
if let Some(check) = widget.downcast_ref::<gtk::CheckButton>() {
|
||||||
check.set_active(active);
|
check.set_active(active);
|
||||||
return; // Don't recurse into CheckButton children
|
return;
|
||||||
}
|
}
|
||||||
let mut child = widget.first_child();
|
let mut child = widget.first_child();
|
||||||
while let Some(c) = child {
|
while let Some(c) = child {
|
||||||
|
|||||||
Reference in New Issue
Block a user