use adw::prelude::*; use pixstrip_core::config::{AppConfig, ErrorBehavior, OverwriteBehavior, SkillLevel}; use pixstrip_core::storage::ConfigStore; pub fn build_settings_dialog() -> adw::PreferencesDialog { let dialog = adw::PreferencesDialog::builder() .title("Settings") .build(); let config_store = ConfigStore::new(); let config = config_store.load().unwrap_or_default(); // General page let general_page = adw::PreferencesPage::builder() .title("General") .icon_name("preferences-system-symbolic") .build(); let output_group = adw::PreferencesGroup::builder() .title("Output") .build(); // Output mode: subfolder or fixed path let output_mode_row = adw::ComboRow::builder() .title("Default output location") .subtitle("Where processed images are saved by default") .build(); let output_mode_model = gtk::StringList::new(&[ "Subfolder next to originals", "Fixed output folder", ]); output_mode_row.set_model(Some(&output_mode_model)); output_mode_row.set_selected(if config.output_fixed_path.is_some() { 1 } else { 0 }); let subfolder_row = adw::EntryRow::builder() .title("Default output subfolder") .text(&config.output_subfolder) .visible(config.output_fixed_path.is_none()) .build(); let fixed_path_row = adw::ActionRow::builder() .title("Fixed output folder") .subtitle( config.output_fixed_path .as_deref() .unwrap_or("No folder selected"), ) .activatable(true) .visible(config.output_fixed_path.is_some()) .build(); fixed_path_row.add_prefix(>k::Image::from_icon_name("folder-open-symbolic")); let choose_fixed_btn = gtk::Button::builder() .icon_name("document-open-symbolic") .tooltip_text("Choose output folder") .valign(gtk::Align::Center) .build(); choose_fixed_btn.add_css_class("flat"); fixed_path_row.add_suffix(&choose_fixed_btn); // Shared state for fixed path let fixed_path_state: std::rc::Rc>> = std::rc::Rc::new(std::cell::RefCell::new(config.output_fixed_path.clone())); // Wire output mode toggle { let sf = subfolder_row.clone(); let fp = fixed_path_row.clone(); output_mode_row.connect_selected_notify(move |row| { let is_fixed = row.selected() == 1; sf.set_visible(!is_fixed); fp.set_visible(is_fixed); }); } // Wire fixed path chooser { let fps = fixed_path_state.clone(); let fpr = fixed_path_row.clone(); choose_fixed_btn.connect_clicked(move |btn| { let fps = fps.clone(); let fpr = fpr.clone(); let dialog = gtk::FileDialog::builder() .title("Choose Output Folder") .modal(true) .build(); if let Some(window) = btn.root().and_then(|r| r.downcast::().ok()) { dialog.select_folder(Some(&window), gtk::gio::Cancellable::NONE, move |result| { if let Ok(file) = result && let Some(path) = file.path() { let path_str = path.display().to_string(); fpr.set_subtitle(&path_str); *fps.borrow_mut() = Some(path_str); } }); } }); } let overwrite_row = adw::ComboRow::builder() .title("Default overwrite behavior") .subtitle("What to do when output files already exist") .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_selected(match config.overwrite_behavior { OverwriteBehavior::Ask => 0, OverwriteBehavior::AutoRename => 1, OverwriteBehavior::Overwrite => 2, OverwriteBehavior::Skip => 3, }); let remember_row = adw::SwitchRow::builder() .title("Remember last-used settings") .subtitle("Restore wizard state on next launch") .active(config.remember_settings) .build(); output_group.add(&output_mode_row); output_group.add(&subfolder_row); output_group.add(&fixed_path_row); output_group.add(&overwrite_row); output_group.add(&remember_row); general_page.add(&output_group); let ui_group = adw::PreferencesGroup::builder() .title("Interface") .build(); let skill_row = adw::ComboRow::builder() .title("Detail level") .subtitle("Controls how many options are visible by default") .build(); let skill_model = gtk::StringList::new(&["Simple", "Detailed"]); skill_row.set_model(Some(&skill_model)); skill_row.set_selected(match config.skill_level { SkillLevel::Simple => 0, SkillLevel::Detailed => 1, }); let reset_button = gtk::Button::builder() .label("Reset to Defaults") .halign(gtk::Align::Start) .margin_top(8) .build(); reset_button.add_css_class("destructive-action"); ui_group.add(&skill_row); general_page.add(&ui_group); // File Manager Integration let fm_group = adw::PreferencesGroup::builder() .title("File Manager Integration") .description("Add 'Process with Pixstrip' to your file manager's right-click menu") .build(); use pixstrip_core::fm_integration::FileManager; let file_managers = [ (FileManager::Nautilus, "org.gnome.Nautilus"), (FileManager::Nemo, "org.nemo.Nemo"), (FileManager::Thunar, "thunar"), (FileManager::Dolphin, "org.kde.dolphin"), ]; let mut found_fm = false; for (fm, desktop_id) in &file_managers { let is_installed = gtk::gio::AppInfo::all() .iter() .any(|info| { info.id() .map(|id| id.as_str().contains(desktop_id)) .unwrap_or(false) }); if is_installed { found_fm = true; let already_installed = fm.is_installed(); let row = adw::SwitchRow::builder() .title(fm.name()) .subtitle(format!("Add right-click menu to {}", fm.name())) .active(already_installed) .build(); let fm_copy = *fm; row.connect_active_notify(move |row| { if row.is_active() { let _ = fm_copy.install(); } else { let _ = fm_copy.uninstall(); } }); fm_group.add(&row); } } if !found_fm { let row = adw::ActionRow::builder() .title("No supported file managers detected") .subtitle("Nautilus, Nemo, Thunar, and Dolphin are supported") .build(); row.add_prefix(>k::Image::from_icon_name("dialog-information-symbolic")); fm_group.add(&row); } general_page.add(&fm_group); // Reset defaults group let reset_group = adw::PreferencesGroup::new(); reset_group.add(&reset_button); general_page.add(&reset_group); dialog.add(&general_page); // Processing page let processing_page = adw::PreferencesPage::builder() .title("Processing") .icon_name("system-run-symbolic") .build(); let threads_group = adw::PreferencesGroup::builder() .title("Performance") .build(); let threads_row = adw::ComboRow::builder() .title("Processing threads") .subtitle("Auto uses all available CPU cores") .build(); let threads_model = gtk::StringList::new(&["Auto", "1", "2", "4", "8"]); threads_row.set_model(Some(&threads_model)); threads_row.set_selected(match config.thread_count { pixstrip_core::config::ThreadCount::Auto => 0, pixstrip_core::config::ThreadCount::Manual(1) => 1, pixstrip_core::config::ThreadCount::Manual(2) => 2, pixstrip_core::config::ThreadCount::Manual(n) if n <= 4 => 3, pixstrip_core::config::ThreadCount::Manual(_) => 4, }); let error_row = adw::ComboRow::builder() .title("On error") .subtitle("What to do when an image fails to process") .build(); let error_model = gtk::StringList::new(&["Skip and continue", "Pause on error"]); error_row.set_model(Some(&error_model)); error_row.set_selected(match config.error_behavior { ErrorBehavior::SkipAndContinue => 0, ErrorBehavior::PauseOnError => 1, }); threads_group.add(&threads_row); threads_group.add(&error_row); processing_page.add(&threads_group); dialog.add(&processing_page); // Accessibility page let a11y_page = adw::PreferencesPage::builder() .title("Accessibility") .icon_name("preferences-desktop-accessibility-symbolic") .build(); let a11y_group = adw::PreferencesGroup::builder() .title("Visual Preferences") .description("Override system settings for this app only") .build(); let contrast_row = adw::SwitchRow::builder() .title("High contrast") .subtitle("Increase visual contrast throughout the app") .active(config.high_contrast) .build(); let large_text_row = adw::SwitchRow::builder() .title("Large text") .subtitle("Increase text size throughout the app") .active(config.large_text) .build(); let motion_row = adw::SwitchRow::builder() .title("Reduced motion") .subtitle("Minimize animations and transitions") .active(config.reduced_motion) .build(); a11y_group.add(&contrast_row); a11y_group.add(&large_text_row); a11y_group.add(&motion_row); a11y_page.add(&a11y_group); dialog.add(&a11y_page); // Notifications page let notify_page = adw::PreferencesPage::builder() .title("Notifications") .icon_name("preferences-system-notifications-symbolic") .build(); let notify_group = adw::PreferencesGroup::builder() .title("Completion") .build(); let desktop_notify_row = adw::SwitchRow::builder() .title("Desktop notification") .subtitle("Show notification when processing completes") .active(config.notify_on_completion) .build(); let sound_row = adw::SwitchRow::builder() .title("Completion sound") .subtitle("Play a sound when processing completes") .active(config.play_completion_sound) .build(); let auto_open_row = adw::SwitchRow::builder() .title("Auto-open output folder") .subtitle("Open the output folder in file manager when done") .active(config.auto_open_output) .build(); notify_group.add(&desktop_notify_row); notify_group.add(&sound_row); notify_group.add(&auto_open_row); notify_page.add(¬ify_group); dialog.add(¬ify_page); // Watch Folders page let watch_page = adw::PreferencesPage::builder() .title("Watch Folders") .icon_name("folder-visiting-symbolic") .build(); let watch_group = adw::PreferencesGroup::builder() .title("Monitored Folders") .description("Automatically process images added to these folders") .build(); let watch_list = gtk::ListBox::builder() .selection_mode(gtk::SelectionMode::None) .css_classes(["boxed-list"]) .build(); watch_list.set_widget_name("watch-folder-list"); // Shared state for watch folders let watch_folders_state: std::rc::Rc>> = std::rc::Rc::new(std::cell::RefCell::new(config.watch_folders.clone())); // Build preset names list for dropdown let builtin_presets = pixstrip_core::preset::Preset::all_builtins(); let preset_names: Vec = builtin_presets.iter().map(|p| p.name.clone()).collect(); // Populate existing watch folders { let folders = watch_folders_state.borrow(); for folder in folders.iter() { let row = build_watch_folder_row(folder, &preset_names, &watch_folders_state, &watch_list); watch_list.append(&row); } } // Empty state let empty_label = gtk::Label::builder() .label("No watch folders configured.\nAdd a folder to start automatic processing.") .css_classes(["dim-label"]) .halign(gtk::Align::Center) .margin_top(16) .margin_bottom(16) .justify(gtk::Justification::Center) .build(); empty_label.set_visible(config.watch_folders.is_empty()); watch_group.add(&watch_list); watch_group.add(&empty_label); // Add folder button let add_button = gtk::Button::builder() .label("Add Watch Folder") .halign(gtk::Align::Start) .margin_top(8) .build(); add_button.add_css_class("suggested-action"); add_button.add_css_class("pill"); { let wfs = watch_folders_state.clone(); let wl = watch_list.clone(); let el = empty_label.clone(); let pnames = preset_names.clone(); add_button.connect_clicked(move |btn| { let wfs = wfs.clone(); let wl = wl.clone(); let el = el.clone(); let pnames = pnames.clone(); let dialog = gtk::FileDialog::builder() .title("Choose Watch Folder") .modal(true) .build(); if let Some(window) = btn.root().and_then(|r| r.downcast::().ok()) { dialog.select_folder(Some(&window), gtk::gio::Cancellable::NONE, move |result| { if let Ok(file) = result && let Some(path) = file.path() { let new_folder = pixstrip_core::watcher::WatchFolder { path: path.clone(), preset_name: "Blog Photos".to_string(), recursive: false, active: true, }; let row = build_watch_folder_row(&new_folder, &pnames, &wfs, &wl); wl.append(&row); wfs.borrow_mut().push(new_folder); el.set_visible(false); } }); } }); } let add_group = adw::PreferencesGroup::new(); add_group.add(&add_button); watch_page.add(&watch_group); watch_page.add(&add_group); dialog.add(&watch_page); // Wire reset button { let subfolder = subfolder_row.clone(); let overwrite = overwrite_row.clone(); let remember = remember_row.clone(); let skill = skill_row.clone(); let threads = threads_row.clone(); let error = error_row.clone(); let contrast = contrast_row.clone(); let large_text = large_text_row.clone(); let motion = motion_row.clone(); let notify = desktop_notify_row.clone(); let sound = sound_row.clone(); let auto_open = auto_open_row.clone(); let output_mode = output_mode_row.clone(); let fps_reset = fixed_path_state.clone(); reset_button.connect_clicked(move |_| { let defaults = AppConfig::default(); subfolder.set_text(&defaults.output_subfolder); overwrite.set_selected(0); remember.set_active(defaults.remember_settings); skill.set_selected(0); threads.set_selected(0); error.set_selected(0); contrast.set_active(defaults.high_contrast); large_text.set_active(defaults.large_text); motion.set_active(defaults.reduced_motion); notify.set_active(defaults.notify_on_completion); sound.set_active(defaults.play_completion_sound); auto_open.set_active(defaults.auto_open_output); output_mode.set_selected(0); *fps_reset.borrow_mut() = None; }); } // Preserve history settings from current config (not exposed in UI yet) let hist_max_entries = config.history_max_entries; let hist_max_days = config.history_max_days; // Save settings when the dialog closes dialog.connect_closed(move |_| { let new_config = AppConfig { first_run_complete: true, tutorial_complete: true, // preserve if settings are being saved output_subfolder: subfolder_row.text().to_string(), output_fixed_path: if output_mode_row.selected() == 1 { fixed_path_state.borrow().clone() } else { None }, overwrite_behavior: match overwrite_row.selected() { 1 => OverwriteBehavior::AutoRename, 2 => OverwriteBehavior::Overwrite, 3 => OverwriteBehavior::Skip, _ => OverwriteBehavior::Ask, }, remember_settings: remember_row.is_active(), skill_level: match skill_row.selected() { 1 => SkillLevel::Detailed, _ => SkillLevel::Simple, }, thread_count: match threads_row.selected() { 1 => pixstrip_core::config::ThreadCount::Manual(1), 2 => pixstrip_core::config::ThreadCount::Manual(2), 3 => pixstrip_core::config::ThreadCount::Manual(4), 4 => pixstrip_core::config::ThreadCount::Manual(8), _ => pixstrip_core::config::ThreadCount::Auto, }, error_behavior: match error_row.selected() { 1 => ErrorBehavior::PauseOnError, _ => ErrorBehavior::SkipAndContinue, }, notify_on_completion: desktop_notify_row.is_active(), play_completion_sound: sound_row.is_active(), auto_open_output: auto_open_row.is_active(), high_contrast: contrast_row.is_active(), large_text: large_text_row.is_active(), reduced_motion: motion_row.is_active(), history_max_entries: hist_max_entries, history_max_days: hist_max_days, watch_folders: watch_folders_state.borrow().clone(), }; let store = ConfigStore::new(); let _ = store.save(&new_config); }); dialog } fn build_watch_folder_row( folder: &pixstrip_core::watcher::WatchFolder, preset_names: &[String], watch_state: &std::rc::Rc>>, list_box: >k::ListBox, ) -> adw::ExpanderRow { let display_path = folder.path.file_name() .and_then(|n| n.to_str()) .unwrap_or_else(|| folder.path.to_str().unwrap_or("Unknown")); let row = adw::ExpanderRow::builder() .title(display_path) .subtitle(&folder.path.display().to_string()) .show_enable_switch(true) .enable_expansion(folder.active) .build(); row.add_prefix(>k::Image::from_icon_name("folder-visiting-symbolic")); // Preset selector let preset_row = adw::ComboRow::builder() .title("Linked Preset") .subtitle("Preset to apply to new images") .build(); let preset_model = gtk::StringList::new( &preset_names.iter().map(|s| s.as_str()).collect::>(), ); preset_row.set_model(Some(&preset_model)); // Set selected to matching preset let selected_idx = preset_names.iter() .position(|n| *n == folder.preset_name) .unwrap_or(0); preset_row.set_selected(selected_idx as u32); // Recursive toggle let recursive_row = adw::SwitchRow::builder() .title("Include Subfolders") .subtitle("Monitor subfolders recursively") .active(folder.recursive) .build(); // Remove button let remove_row = adw::ActionRow::builder() .title("Remove This Folder") .build(); remove_row.add_prefix(>k::Image::from_icon_name("user-trash-symbolic")); let remove_btn = gtk::Button::builder() .icon_name("edit-delete-symbolic") .tooltip_text("Remove watch folder") .valign(gtk::Align::Center) .build(); remove_btn.add_css_class("flat"); remove_btn.add_css_class("error"); remove_row.add_suffix(&remove_btn); row.add_row(&preset_row); row.add_row(&recursive_row); row.add_row(&remove_row); // Wire enable toggle let folder_path = folder.path.clone(); { let wfs = watch_state.clone(); let fp = folder_path.clone(); row.connect_enable_expansion_notify(move |r| { let mut folders = wfs.borrow_mut(); if let Some(f) = folders.iter_mut().find(|f| f.path == fp) { f.active = r.enables_expansion(); } }); } // Wire preset change { let wfs = watch_state.clone(); let fp = folder_path.clone(); let pnames = preset_names.to_vec(); preset_row.connect_selected_notify(move |r| { let mut folders = wfs.borrow_mut(); if let Some(f) = folders.iter_mut().find(|f| f.path == fp) { f.preset_name = pnames.get(r.selected() as usize) .cloned() .unwrap_or_default(); } }); } // Wire recursive toggle { let wfs = watch_state.clone(); let fp = folder_path.clone(); recursive_row.connect_active_notify(move |r| { let mut folders = wfs.borrow_mut(); if let Some(f) = folders.iter_mut().find(|f| f.path == fp) { f.recursive = r.is_active(); } }); } // Wire remove button { let wfs = watch_state.clone(); let lb = list_box.clone(); let fp = folder_path; let r = row.clone(); remove_btn.connect_clicked(move |_| { wfs.borrow_mut().retain(|f| f.path != fp); lb.remove(&r); }); } row }