Files
pixstrip/pixstrip-gtk/src/settings.rs
lashman 0460763d42 Add fixed output folder option in settings
Users can now choose between subfolder-next-to-originals or a fixed
output folder in Settings > General. The fixed path is selectable via
a folder picker dialog and persisted across sessions.
2026-03-06 17:23:57 +02:00

633 lines
22 KiB
Rust

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(&gtk::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::cell::RefCell<Option<String>>> =
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::<gtk::Window>().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(&gtk::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(&notify_group);
dialog.add(&notify_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::cell::RefCell<Vec<pixstrip_core::watcher::WatchFolder>>> =
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<String> = 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::<gtk::Window>().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<std::cell::RefCell<Vec<pixstrip_core::watcher::WatchFolder>>>,
list_box: &gtk::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(&gtk::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::<Vec<_>>(),
);
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(&gtk::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
}