512 lines
18 KiB
Rust
512 lines
18 KiB
Rust
use adw::prelude::*;
|
|
use crate::app::AppState;
|
|
|
|
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 - file list
|
|
let loaded_state = build_loaded_state(state);
|
|
stack.add_named(&loaded_state, Some("loaded"));
|
|
|
|
stack.set_visible_child_name("empty");
|
|
|
|
// 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();
|
|
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 mut files = loaded_files.borrow_mut();
|
|
add_images_from_dir(&path, &mut files);
|
|
let count = files.len();
|
|
drop(files);
|
|
update_loaded_ui(&stack_ref, &loaded_files, &excluded, count);
|
|
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);
|
|
update_loaded_ui(&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<std::path::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 update_loaded_ui(
|
|
stack: >k::Stack,
|
|
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
|
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::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") {
|
|
update_count_and_list(&loaded_widget, loaded_files, excluded);
|
|
}
|
|
}
|
|
|
|
fn update_count_and_list(
|
|
widget: >k::Widget,
|
|
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
|
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::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);
|
|
|
|
walk_loaded_widgets(widget, count, included_count, &size_str, &files, loaded_files, excluded);
|
|
}
|
|
|
|
fn walk_loaded_widgets(
|
|
widget: >k::Widget,
|
|
count: usize,
|
|
included_count: usize,
|
|
size_str: &str,
|
|
files: &[std::path::PathBuf],
|
|
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
|
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::PathBuf>>>,
|
|
) {
|
|
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));
|
|
}
|
|
}
|
|
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();
|
|
while let Some(c) = child {
|
|
walk_loaded_widgets(&c, count, included_count, size_str, files, loaded_files, excluded);
|
|
child = c.next_sibling();
|
|
}
|
|
}
|
|
|
|
/// Update only the count label without rebuilding the list
|
|
fn update_count_label(
|
|
widget: >k::Widget,
|
|
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
|
excluded: &std::rc::Rc<std::cell::RefCell<std::collections::HashSet<std::path::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_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 {
|
|
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))
|
|
}
|
|
}
|
|
|
|
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");
|
|
|
|
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
|
|
}
|
|
|
|
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");
|
|
|
|
// Wire clear button
|
|
{
|
|
let files = state.loaded_files.clone();
|
|
let excl = state.excluded_files.clone();
|
|
let count_label_c = count_label.clone();
|
|
clear_button.connect_clicked(move |btn| {
|
|
files.borrow_mut().clear();
|
|
excl.borrow_mut().clear();
|
|
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 - clears exclusion set, re-checks all checkboxes
|
|
{
|
|
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 - adds all files to exclusion set, unchecks 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);
|
|
|
|
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(&separator);
|
|
container.append(&list_scrolled);
|
|
|
|
container
|
|
}
|
|
|
|
/// 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; // Don't recurse into CheckButton children
|
|
}
|
|
let mut child = widget.first_child();
|
|
while let Some(c) = child {
|
|
set_all_checkboxes_in(&c, active);
|
|
child = c.next_sibling();
|
|
}
|
|
}
|