use adw::prelude::*; use crate::app::AppState; use crate::utils::format_size; pub fn build_output_page(state: &AppState) -> adw::NavigationPage { let scrolled = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .build(); let content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(12) .margin_top(12) .margin_bottom(12) .margin_start(24) .margin_end(24) .build(); // Operation summary - dynamically rebuilt when this step is shown let summary_group = adw::PreferencesGroup::builder() .title("Operation Summary") .description("Review your processing settings before starting") .build(); let summary_box = gtk::ListBox::builder() .selection_mode(gtk::SelectionMode::None) .css_classes(["boxed-list"]) .build(); summary_box.set_widget_name("ops-summary-list"); summary_group.add(&summary_box); content.append(&summary_group); // Output directory let output_group = adw::PreferencesGroup::builder() .title("Output Directory") .build(); let default_output = state.output_dir.borrow() .as_ref() .map(|p| p.display().to_string()) .unwrap_or_else(|| "processed/ (subfolder next to originals)".to_string()); let output_row = adw::ActionRow::builder() .title("Output Location") .subtitle(&default_output) .activatable(true) .action_name("win.choose-output") .build(); output_row.add_prefix(>k::Image::from_icon_name("folder-symbolic")); let choose_button = gtk::Button::builder() .icon_name("folder-open-symbolic") .tooltip_text("Choose output folder") .valign(gtk::Align::Center) .action_name("win.choose-output") .build(); choose_button.add_css_class("flat"); output_row.add_suffix(&choose_button); output_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); let cfg = state.job_config.borrow(); let structure_row = adw::SwitchRow::builder() .title("Preserve Directory Structure") .subtitle("Keep subfolder hierarchy in output") .active(cfg.preserve_dir_structure) .build(); output_group.add(&output_row); output_group.add(&structure_row); content.append(&output_group); // Overwrite behavior let overwrite_group = adw::PreferencesGroup::builder() .title("If Files Already Exist") .build(); let overwrite_row = adw::ComboRow::builder() .title("Overwrite Behavior") .subtitle("What to do when output file already exists") .use_subtitle(true) .build(); let overwrite_model = gtk::StringList::new(&[ "Ask before overwriting", "Auto-rename with suffix", "Always overwrite", "Skip existing files", ]); overwrite_row.set_model(Some(&overwrite_model)); overwrite_row.set_list_factory(Some(&super::full_text_list_factory())); overwrite_row.set_selected(cfg.overwrite_behavior as u32); overwrite_group.add(&overwrite_row); content.append(&overwrite_group); // Image count - dynamically updated let stats_group = adw::PreferencesGroup::builder() .title("Batch Info") .build(); let excluded = state.excluded_files.borrow(); let files = state.loaded_files.borrow(); let included_count = files.iter().filter(|p| !excluded.contains(*p)).count(); let total_size: u64 = files.iter() .filter(|p| !excluded.contains(*p)) .filter_map(|p| std::fs::metadata(p).ok()) .map(|m| m.len()) .sum(); drop(files); drop(excluded); let count_row = adw::ActionRow::builder() .title("Images to process") .subtitle(format!("{} images ({})", included_count, format_size(total_size))) .build(); count_row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic")); stats_group.add(&count_row); content.append(&stats_group); drop(cfg); // 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 page = adw::NavigationPage::builder() .title("Output & Process") .tag("step-output") .child(&scrolled) .build(); // Refresh stats and summary when navigating to this page { let lf = state.loaded_files.clone(); let ef = state.excluded_files.clone(); let jc = state.job_config.clone(); let od = state.output_dir.clone(); let cr = count_row.clone(); let or = output_row.clone(); let sb = summary_box.clone(); page.connect_map(move |_| { // Update image count and size let files = lf.borrow(); let excluded = ef.borrow(); let included_count = files.iter().filter(|p| !excluded.contains(*p)).count(); let total_size: u64 = files.iter() .filter(|p| !excluded.contains(*p)) .filter_map(|p| std::fs::metadata(p).ok()) .map(|m| m.len()) .sum(); cr.set_subtitle(&format!("{} images ({})", included_count, format_size(total_size))); drop(files); drop(excluded); // Update output directory display let dir_text = od.borrow() .as_ref() .map(|p| p.display().to_string()) .unwrap_or_else(|| "processed/ (subfolder next to originals)".to_string()); or.set_subtitle(&dir_text); // Build operation summary while let Some(child) = sb.first_child() { sb.remove(&child); } let cfg = jc.borrow(); let mut ops: Vec<(&str, String)> = Vec::new(); if cfg.resize_enabled { let mode = match cfg.resize_mode { 0 => format!("{}x{} (exact)", cfg.resize_width, cfg.resize_height), _ => format!("fit {}x{}", cfg.resize_width, cfg.resize_height), }; ops.push(("Resize", mode)); } if cfg.adjustments_enabled { let mut parts = Vec::new(); if cfg.rotation > 0 { parts.push("rotate"); } if cfg.flip > 0 { parts.push("flip"); } if cfg.brightness != 0 { parts.push("brightness"); } if cfg.contrast != 0 { parts.push("contrast"); } if cfg.saturation != 0 { parts.push("saturation"); } if cfg.grayscale { parts.push("grayscale"); } if cfg.sepia { parts.push("sepia"); } if cfg.sharpen { parts.push("sharpen"); } if cfg.crop_aspect_ratio > 0 { parts.push("crop"); } if cfg.trim_whitespace { parts.push("trim"); } if cfg.canvas_padding > 0 { parts.push("padding"); } let desc = if parts.is_empty() { "enabled".into() } else { parts.join(", ") }; ops.push(("Adjustments", desc)); } if cfg.convert_enabled { let fmt = cfg.convert_format.map(|f| f.extension().to_uppercase()) .unwrap_or_else(|| "per-format mapping".into()); ops.push(("Convert", fmt)); } if cfg.compress_enabled { ops.push(("Compress", cfg.quality_preset.label().into())); } if cfg.metadata_enabled { let mode = match &cfg.metadata_mode { crate::app::MetadataMode::StripAll => "strip all", crate::app::MetadataMode::KeepAll => "keep all", crate::app::MetadataMode::Privacy => "privacy mode", crate::app::MetadataMode::Custom => "custom", }; ops.push(("Metadata", mode.into())); } if cfg.watermark_enabled { let wm_type = if cfg.watermark_use_image { "image" } else { "text" }; ops.push(("Watermark", wm_type.into())); } if cfg.rename_enabled { ops.push(("Rename", "enabled".into())); } if ops.is_empty() { let row = adw::ActionRow::builder() .title("No operations enabled") .subtitle("Go back and enable at least one operation") .build(); row.add_prefix(>k::Image::from_icon_name("dialog-warning-symbolic")); sb.append(&row); } else { for (name, desc) in &ops { let row = adw::ActionRow::builder() .title(*name) .subtitle(desc.as_str()) .build(); row.add_prefix(>k::Image::from_icon_name("emblem-ok-symbolic")); sb.append(&row); } } }); } page }