Files
pixstrip/pixstrip-gtk/src/steps/step_images.rs

816 lines
28 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;
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
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
drop_target.set_types(&[gtk::gio::File::static_type()]);
{
let loaded_files = state.loaded_files.clone();
let excluded = state.excluded_files.clone();
let stack_ref = stack.clone();
let subfolder_choice = subfolder_choice.clone();
drop_target.connect_drop(move |target, value, _x, _y| {
if let Ok(file) = value.get::<gtk::gio::File>()
&& let Some(path) = file.path()
{
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);
let count = files.len();
drop(files);
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
} else {
let choice = *subfolder_choice.borrow();
match choice {
Some(true) => {
let mut files = loaded_files.borrow_mut();
add_images_from_dir(&path, &mut files);
let count = files.len();
drop(files);
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
}
Some(false) => {
let mut files = loaded_files.borrow_mut();
add_images_flat(&path, &mut files);
let count = files.len();
drop(files);
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
}
None => {
let mut files = loaded_files.borrow_mut();
add_images_flat(&path, &mut files);
let count = files.len();
drop(files);
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
let loaded_files = loaded_files.clone();
let excluded = excluded.clone();
let stack_ref = stack_ref.clone();
let subfolder_choice = subfolder_choice.clone();
let dir_path = path.clone();
let window = target.widget()
.and_then(|w| w.root())
.and_then(|r| r.downcast::<gtk::Window>().ok());
glib::idle_add_local_once(move || {
show_subfolder_prompt(
window.as_ref(),
&dir_path,
&loaded_files,
&excluded,
&stack_ref,
&subfolder_choice,
);
});
}
}
}
return true;
} else if is_image_file(&path) {
let mut files = loaded_files.borrow_mut();
if !files.contains(&path) {
files.push(path);
}
let count = files.len();
drop(files);
refresh_grid(&stack_ref, &loaded_files, &excluded, count);
return true;
}
}
false
});
}
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"),
None => false,
}
}
fn add_images_from_dir(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);
} else if is_image_file(&path) && !files.contains(&path) {
files.push(path);
}
}
}
}
fn add_images_flat(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_file() && is_image_file(&path) && !files.contains(&path) {
files.push(path);
}
}
}
}
fn has_subfolders(dir: &std::path::Path) -> bool {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
return true;
}
}
}
false
}
fn add_images_from_subdirs(dir: &std::path::Path, files: &mut Vec<PathBuf>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
add_images_from_dir(&path, files);
}
}
}
}
fn show_subfolder_prompt(
window: Option<&gtk::Window>,
dir: &std::path::Path,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
stack: &gtk::Stack,
subfolder_choice: &Rc<RefCell<Option<bool>>>,
) {
let dialog = adw::AlertDialog::builder()
.heading("Include subfolders?")
.body("This folder contains subfolders. Would you like to include images from subfolders too?")
.build();
dialog.add_response("no", "Top-level Only");
dialog.add_response("yes", "Include Subfolders");
dialog.set_default_response(Some("yes"));
dialog.set_response_appearance("yes", adw::ResponseAppearance::Suggested);
let loaded_files = loaded_files.clone();
let excluded = excluded.clone();
let 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, count);
}
});
if let Some(win) = window {
dialog.present(Some(win));
}
}
// ------------------------------------------------------------------
// Thumbnail grid
// ------------------------------------------------------------------
/// Refresh the grid view and count label from current loaded_files state
fn refresh_grid(
stack: &gtk::Stack,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
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);
}
}
/// Walk the widget tree to find our ListStore and count label, then rebuild
fn rebuild_grid_model(
widget: &gtk::Widget,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
) {
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();
let total_size: u64 = files.iter()
.filter(|p| !excluded_set.contains(*p))
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.sum();
let size_str = format_size(total_size);
// Update the count label
update_heading_label(widget, count, included_count, &size_str);
// Find the GridView and rebuild its model
if let Some(grid_view) = find_grid_view(widget) {
if let Some(model) = grid_view.model()
&& let Some(sel) = model.downcast_ref::<gtk::NoSelection>()
&& let Some(store) = sel.model()
&& let Some(store) = store.downcast_ref::<gtk::gio::ListStore>()
{
store.remove_all();
for path in files.iter() {
let item = ImageItem::new(path);
store.append(&item);
}
}
}
}
fn find_grid_view(widget: &gtk::Widget) -> Option<gtk::GridView> {
if let Some(gv) = widget.downcast_ref::<gtk::GridView>() {
return Some(gv.clone());
}
let mut child = widget.first_child();
while let Some(c) = child {
if let Some(gv) = find_grid_view(&c) {
return Some(gv);
}
child = c.next_sibling();
}
None
}
fn update_heading_label(widget: &gtk::Widget, count: usize, included_count: usize, size_str: &str) {
if let Some(label) = widget.downcast_ref::<gtk::Label>()
&& label.css_classes().iter().any(|c| c == "heading")
{
if included_count == count {
label.set_label(&format!("{} images ({})", count, size_str));
} else {
label.set_label(&format!("{}/{} images selected ({})", included_count, count, size_str));
}
}
let mut child = widget.first_child();
while let Some(c) = child {
update_heading_label(&c, count, included_count, size_str);
child = c.next_sibling();
}
}
/// Update only the count label without rebuilding the grid
fn update_count_label(
widget: &gtk::Widget,
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
) {
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();
let total_size: u64 = files.iter()
.filter(|p| !excluded_set.contains(*p))
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.sum();
let size_str = format_size(total_size);
update_heading_label(widget, count, included_count, &size_str);
}
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
// ------------------------------------------------------------------
// 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();
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("Supported: JPEG, PNG, WebP, AVIF, GIF, TIFF, BMP")
.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");
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();
// 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)")
.build();
select_all_button.add_css_class("flat");
let deselect_all_button = gtk::Button::builder()
.icon_name("edit-clear-symbolic")
.tooltip_text("Deselect all images (Ctrl+Shift+A)")
.build();
deselect_all_button.add_css_class("flat");
let clear_button = gtk::Button::builder()
.icon_name("edit-clear-all-symbolic")
.tooltip_text("Remove all images")
.build();
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");
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();
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")
{
set_all_checkboxes_in(&loaded_widget, true);
update_count_label(&loaded_widget, &loaded, &excl);
}
});
}
// Wire Deselect All
{
let excl = state.excluded_files.clone();
let loaded = state.loaded_files.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")
{
set_all_checkboxes_in(&loaded_widget, false);
update_count_label(&loaded_widget, &loaded, &excl);
}
});
}
toolbar.append(&count_label);
toolbar.append(&select_all_button);
toolbar.append(&deselect_all_button);
toolbar.append(&add_button);
toolbar.append(&clear_button);
toolbar.update_property(&[
gtk::accessible::Property::Label("Image toolbar with count, selection, and add controls"),
]);
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
container.append(&toolbar);
container.append(&separator);
container.append(&scrolled);
container
}
fn find_check_button(widget: &gtk::Widget) -> Option<gtk::CheckButton> {
if let Some(cb) = widget.downcast_ref::<gtk::CheckButton>() {
return Some(cb.clone());
}
let mut child = widget.first_child();
while let Some(c) = child {
if let Some(cb) = find_check_button(&c) {
return Some(cb);
}
child = c.next_sibling();
}
None
}
/// Load a thumbnail for the given path into the Picture widget
fn load_thumbnail(path: &std::path::Path, picture: &gtk::Picture, stack: &gtk::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
pub fn set_all_checkboxes_in(widget: &gtk::Widget, active: bool) {
if let Some(check) = widget.downcast_ref::<gtk::CheckButton>() {
check.set_active(active);
return;
}
let mut child = widget.first_child();
while let Some(c) = child {
set_all_checkboxes_in(&c, active);
child = c.next_sibling();
}
}