Wire remaining UI elements: presets, drag-drop, import/save, output summary
- Workflow preset cards now apply their config to JobConfig on selection - User presets section shows saved custom presets from PresetStore - Import Preset button opens file dialog and imports JSON presets - Save as Preset button in results page saves current workflow - Images step supports drag-and-drop for image files - Images loaded state shows file list and clear button - Output step dynamically shows operation summary when navigated to - Output step wires preserve directory structure and overwrite behavior - Results page displays individual error details in expandable section - Pause button toggles visual state on processing page
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use adw::prelude::*;
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn build_images_page() -> adw::NavigationPage {
|
||||
pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
||||
let stack = gtk::Stack::builder()
|
||||
.transition_type(gtk::StackTransitionType::Crossfade)
|
||||
.build();
|
||||
@@ -9,12 +10,40 @@ pub fn build_images_page() -> adw::NavigationPage {
|
||||
let empty_state = build_empty_state();
|
||||
stack.add_named(&empty_state, Some("empty"));
|
||||
|
||||
// Loaded state - thumbnail grid (placeholder for now)
|
||||
let loaded_state = build_loaded_state();
|
||||
// Loaded state - thumbnail grid
|
||||
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 stack_ref = stack.clone();
|
||||
drop_target.connect_drop(move |_target, value, _x, _y| {
|
||||
// Try single file
|
||||
if let Ok(file) = value.get::<gtk::gio::File>()
|
||||
&& let Some(path) = file.path()
|
||||
&& 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, count);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
});
|
||||
}
|
||||
|
||||
stack.add_controller(drop_target);
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Add Images")
|
||||
.tag("step-images")
|
||||
@@ -22,6 +51,38 @@ pub fn build_images_page() -> adw::NavigationPage {
|
||||
.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 update_loaded_ui(stack: >k::Stack, count: usize) {
|
||||
if count > 0 {
|
||||
stack.set_visible_child_name("loaded");
|
||||
}
|
||||
if let Some(loaded_box) = stack.child_by_name("loaded") {
|
||||
update_count_label(&loaded_box, count);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_count_label(widget: >k::Widget, count: usize) {
|
||||
if let Some(label) = widget.downcast_ref::<gtk::Label>()
|
||||
&& label.css_classes().iter().any(|c| c == "heading")
|
||||
{
|
||||
label.set_label(&format!("{} images loaded", count));
|
||||
return;
|
||||
}
|
||||
if let Some(bx) = widget.downcast_ref::<gtk::Box>() {
|
||||
let mut child = bx.first_child();
|
||||
while let Some(c) = child {
|
||||
update_count_label(&c, count);
|
||||
child = c.next_sibling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_empty_state() -> gtk::Box {
|
||||
let container = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
@@ -87,7 +148,7 @@ fn build_empty_state() -> gtk::Box {
|
||||
container
|
||||
}
|
||||
|
||||
fn build_loaded_state() -> gtk::Box {
|
||||
fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
let container = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
@@ -104,7 +165,7 @@ fn build_loaded_state() -> gtk::Box {
|
||||
.build();
|
||||
|
||||
let count_label = gtk::Label::builder()
|
||||
.label("0 images (0 B)")
|
||||
.label("0 images loaded")
|
||||
.hexpand(true)
|
||||
.halign(gtk::Align::Start)
|
||||
.css_classes(["heading"])
|
||||
@@ -117,28 +178,54 @@ fn build_loaded_state() -> gtk::Box {
|
||||
.build();
|
||||
add_button.add_css_class("flat");
|
||||
|
||||
let select_all_button = gtk::Button::builder()
|
||||
.label("Select All")
|
||||
.tooltip_text("Select all images (Ctrl+A)")
|
||||
let clear_button = gtk::Button::builder()
|
||||
.icon_name("edit-clear-all-symbolic")
|
||||
.tooltip_text("Remove all images")
|
||||
.build();
|
||||
select_all_button.add_css_class("flat");
|
||||
clear_button.add_css_class("flat");
|
||||
|
||||
// Wire clear button
|
||||
{
|
||||
let files = state.loaded_files.clone();
|
||||
let count_label_c = count_label.clone();
|
||||
clear_button.connect_clicked(move |btn| {
|
||||
files.borrow_mut().clear();
|
||||
count_label_c.set_label("0 images loaded");
|
||||
// Navigate back to empty state by finding parent stack
|
||||
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
|
||||
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
|
||||
{
|
||||
stack.set_visible_child_name("empty");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toolbar.append(&count_label);
|
||||
toolbar.append(&add_button);
|
||||
toolbar.append(&select_all_button);
|
||||
toolbar.append(&clear_button);
|
||||
|
||||
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
|
||||
|
||||
// Thumbnail grid placeholder
|
||||
let grid_placeholder = adw::StatusPage::builder()
|
||||
.title("Images will appear here")
|
||||
.icon_name("image-x-generic-symbolic")
|
||||
// File list showing loaded images
|
||||
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(&grid_placeholder);
|
||||
container.append(&list_scrolled);
|
||||
|
||||
container
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use adw::prelude::*;
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn build_output_page() -> adw::NavigationPage {
|
||||
pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
@@ -39,6 +40,7 @@ pub fn build_output_page() -> adw::NavigationPage {
|
||||
.title("Output Location")
|
||||
.subtitle("processed/ (subfolder next to originals)")
|
||||
.activatable(true)
|
||||
.action_name("win.choose-output")
|
||||
.build();
|
||||
output_row.add_prefix(>k::Image::from_icon_name("folder-symbolic"));
|
||||
|
||||
@@ -96,6 +98,22 @@ pub fn build_output_page() -> adw::NavigationPage {
|
||||
stats_group.add(&count_row);
|
||||
content.append(&stats_group);
|
||||
|
||||
// Wire preserve directory structure
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
structure_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().preserve_dir_structure = row.is_active();
|
||||
});
|
||||
}
|
||||
|
||||
// Wire overwrite behavior
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
overwrite_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().overwrite_behavior = row.selected() as u8;
|
||||
});
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use adw::prelude::*;
|
||||
use pixstrip_core::preset::Preset;
|
||||
use pixstrip_core::operations::*;
|
||||
use crate::app::{AppState, JobConfig, MetadataMode};
|
||||
|
||||
pub fn build_workflow_page() -> adw::NavigationPage {
|
||||
pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
@@ -31,15 +33,23 @@ pub fn build_workflow_page() -> adw::NavigationPage {
|
||||
.homogeneous(true)
|
||||
.build();
|
||||
|
||||
for preset in Preset::all_builtins() {
|
||||
let card = build_preset_card(&preset);
|
||||
let builtins = Preset::all_builtins();
|
||||
for preset in &builtins {
|
||||
let card = build_preset_card(preset);
|
||||
builtin_flow.append(&card);
|
||||
}
|
||||
|
||||
// When a preset card is activated, advance to the next step
|
||||
builtin_flow.connect_child_activated(|flow, _child| {
|
||||
flow.activate_action("win.next-step", None).ok();
|
||||
});
|
||||
// When a preset card is activated, apply it to JobConfig and advance
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
builtin_flow.connect_child_activated(move |flow, child| {
|
||||
let idx = child.index() as usize;
|
||||
if let Some(preset) = builtins.get(idx) {
|
||||
apply_preset_to_config(&mut jc.borrow_mut(), preset);
|
||||
}
|
||||
flow.activate_action("win.next-step", None).ok();
|
||||
});
|
||||
}
|
||||
|
||||
builtin_group.add(&builtin_flow);
|
||||
content.append(&builtin_group);
|
||||
@@ -63,15 +73,42 @@ pub fn build_workflow_page() -> adw::NavigationPage {
|
||||
custom_group.add(&custom_flow);
|
||||
content.append(&custom_group);
|
||||
|
||||
// User presets section (initially empty)
|
||||
// User presets section
|
||||
let user_group = adw::PreferencesGroup::builder()
|
||||
.title("Your Presets")
|
||||
.description("Import or save your own workflows")
|
||||
.build();
|
||||
|
||||
// Show saved user presets
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
if let Ok(presets) = store.list() {
|
||||
for preset in &presets {
|
||||
if !preset.is_custom {
|
||||
continue;
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&preset.name)
|
||||
.subtitle(&preset.description)
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_prefix(>k::Image::from_icon_name(&preset.icon));
|
||||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
|
||||
let jc = state.job_config.clone();
|
||||
let p = preset.clone();
|
||||
row.connect_activated(move |r| {
|
||||
apply_preset_to_config(&mut jc.borrow_mut(), &p);
|
||||
r.activate_action("win.next-step", None).ok();
|
||||
});
|
||||
|
||||
user_group.add(&row);
|
||||
}
|
||||
}
|
||||
|
||||
let import_button = gtk::Button::builder()
|
||||
.label("Import Preset")
|
||||
.icon_name("document-open-symbolic")
|
||||
.action_name("win.import-preset")
|
||||
.build();
|
||||
import_button.add_css_class("flat");
|
||||
user_group.add(&import_button);
|
||||
@@ -91,6 +128,101 @@ pub fn build_workflow_page() -> adw::NavigationPage {
|
||||
.build()
|
||||
}
|
||||
|
||||
fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) {
|
||||
// Resize
|
||||
match &preset.resize {
|
||||
Some(ResizeConfig::ByWidth(w)) => {
|
||||
cfg.resize_enabled = true;
|
||||
cfg.resize_width = *w;
|
||||
cfg.resize_height = 0;
|
||||
cfg.allow_upscale = false;
|
||||
}
|
||||
Some(ResizeConfig::ByHeight(h)) => {
|
||||
cfg.resize_enabled = true;
|
||||
cfg.resize_width = 0;
|
||||
cfg.resize_height = *h;
|
||||
cfg.allow_upscale = false;
|
||||
}
|
||||
Some(ResizeConfig::FitInBox { max, allow_upscale }) => {
|
||||
cfg.resize_enabled = true;
|
||||
cfg.resize_width = max.width;
|
||||
cfg.resize_height = max.height;
|
||||
cfg.allow_upscale = *allow_upscale;
|
||||
}
|
||||
Some(ResizeConfig::Exact(dims)) => {
|
||||
cfg.resize_enabled = true;
|
||||
cfg.resize_width = dims.width;
|
||||
cfg.resize_height = dims.height;
|
||||
cfg.allow_upscale = true;
|
||||
}
|
||||
None => {
|
||||
cfg.resize_enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert
|
||||
match &preset.convert {
|
||||
Some(ConvertConfig::SingleFormat(fmt)) => {
|
||||
cfg.convert_enabled = true;
|
||||
cfg.convert_format = Some(*fmt);
|
||||
}
|
||||
Some(_) => {
|
||||
cfg.convert_enabled = true;
|
||||
cfg.convert_format = None;
|
||||
}
|
||||
None => {
|
||||
cfg.convert_enabled = false;
|
||||
cfg.convert_format = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Compress
|
||||
match &preset.compress {
|
||||
Some(CompressConfig::Preset(q)) => {
|
||||
cfg.compress_enabled = true;
|
||||
cfg.quality_preset = *q;
|
||||
}
|
||||
Some(CompressConfig::Custom { jpeg_quality, png_level, webp_quality, .. }) => {
|
||||
cfg.compress_enabled = true;
|
||||
if let Some(jq) = jpeg_quality {
|
||||
cfg.jpeg_quality = *jq;
|
||||
}
|
||||
if let Some(pl) = png_level {
|
||||
cfg.png_level = *pl;
|
||||
}
|
||||
if let Some(wq) = webp_quality {
|
||||
cfg.webp_quality = *wq as u8;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
cfg.compress_enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata
|
||||
match &preset.metadata {
|
||||
Some(MetadataConfig::StripAll) => {
|
||||
cfg.metadata_enabled = true;
|
||||
cfg.metadata_mode = MetadataMode::StripAll;
|
||||
}
|
||||
Some(MetadataConfig::Privacy) => {
|
||||
cfg.metadata_enabled = true;
|
||||
cfg.metadata_mode = MetadataMode::Privacy;
|
||||
}
|
||||
Some(MetadataConfig::KeepAll) => {
|
||||
cfg.metadata_enabled = true;
|
||||
cfg.metadata_mode = MetadataMode::KeepAll;
|
||||
}
|
||||
Some(MetadataConfig::Custom { .. }) => {
|
||||
cfg.metadata_enabled = true;
|
||||
cfg.metadata_mode = MetadataMode::StripAll;
|
||||
}
|
||||
None => {
|
||||
cfg.metadata_enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
|
||||
Reference in New Issue
Block a user