Fix 26 bugs, edge cases, and consistency issues from fifth audit pass
Critical: undo toast now trashes only batch output files (not entire dir), JPEG scanline write errors propagated, selective metadata write result returned. High: zero-dimension guards in ResizeConfig/fit_within, negative aspect ratio rejection, FM integration toggle infinite recursion guard, saturating counter arithmetic in executor. Medium: PNG compression level passed to oxipng, pct mode updates job_config, external file loading updates step indicator, CLI undo removes history entries, watch config write failures reported, fast-copy path reads image dimensions for rename templates, discovery excludes unprocessable formats (heic/svg/ico/jxl), CLI warns on invalid algorithm/overwrite values, resolve_collision trailing dot fix, generation guards on all preview threads to cancel stale results, default DPI aligned to 0, watermark text width uses char count not byte length. Low: binary path escaped in Nautilus extension, file dialog filter aligned with discovery, reset_wizard clears preset_mode and output_dir.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ mod settings;
|
||||
mod step_indicator;
|
||||
mod steps;
|
||||
mod tutorial;
|
||||
pub(crate) mod utils;
|
||||
mod welcome;
|
||||
mod wizard;
|
||||
|
||||
|
||||
@@ -88,15 +88,10 @@ pub fn build_processing_page() -> adw::NavigationPage {
|
||||
content.append(&log_group);
|
||||
content.append(&button_box);
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&content)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Processing")
|
||||
.tag("processing")
|
||||
.child(&clamp)
|
||||
.child(&content)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -232,14 +227,9 @@ pub fn build_results_page() -> adw::NavigationPage {
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Results")
|
||||
.tag("results")
|
||||
.child(&clamp)
|
||||
.child(&scrolled)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use adw::prelude::*;
|
||||
use std::cell::Cell;
|
||||
use pixstrip_core::config::{AppConfig, ErrorBehavior, OverwriteBehavior, SkillLevel};
|
||||
use pixstrip_core::storage::ConfigStore;
|
||||
|
||||
@@ -8,7 +9,13 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
.build();
|
||||
|
||||
let config_store = ConfigStore::new();
|
||||
let config = config_store.load().unwrap_or_default();
|
||||
let config = match config_store.load() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to load config, using defaults: {}", e);
|
||||
AppConfig::default()
|
||||
}
|
||||
};
|
||||
|
||||
// General page
|
||||
let general_page = adw::PreferencesPage::builder()
|
||||
@@ -24,12 +31,14 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let output_mode_row = adw::ComboRow::builder()
|
||||
.title("Default output location")
|
||||
.subtitle("Where processed images are saved by default")
|
||||
.use_subtitle(true)
|
||||
.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_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
output_mode_row.set_selected(if config.output_fixed_path.is_some() { 1 } else { 0 });
|
||||
|
||||
let subfolder_row = adw::EntryRow::builder()
|
||||
@@ -101,6 +110,7 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let overwrite_row = adw::ComboRow::builder()
|
||||
.title("Default overwrite behavior")
|
||||
.subtitle("What to do when output files already exist")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let overwrite_model = gtk::StringList::new(&[
|
||||
"Ask before overwriting",
|
||||
@@ -109,6 +119,7 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
"Skip existing files",
|
||||
]);
|
||||
overwrite_row.set_model(Some(&overwrite_model));
|
||||
overwrite_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
overwrite_row.set_selected(match config.overwrite_behavior {
|
||||
OverwriteBehavior::Ask => 0,
|
||||
OverwriteBehavior::AutoRename => 1,
|
||||
@@ -136,9 +147,11 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let skill_row = adw::ComboRow::builder()
|
||||
.title("Detail level")
|
||||
.subtitle("Controls how many options are visible by default")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let skill_model = gtk::StringList::new(&["Simple", "Detailed"]);
|
||||
skill_row.set_model(Some(&skill_model));
|
||||
skill_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
skill_row.set_selected(match config.skill_level {
|
||||
SkillLevel::Simple => 0,
|
||||
SkillLevel::Detailed => 1,
|
||||
@@ -188,11 +201,22 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
.build();
|
||||
|
||||
let fm_copy = *fm;
|
||||
let reverting = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let reverting_clone = reverting.clone();
|
||||
row.connect_active_notify(move |row| {
|
||||
if row.is_active() {
|
||||
let _ = fm_copy.install();
|
||||
if reverting_clone.get() {
|
||||
return;
|
||||
}
|
||||
let result = if row.is_active() {
|
||||
fm_copy.install()
|
||||
} else {
|
||||
let _ = fm_copy.uninstall();
|
||||
fm_copy.uninstall()
|
||||
};
|
||||
if let Err(e) = result {
|
||||
eprintln!("File manager integration error for {}: {}", fm_copy.name(), e);
|
||||
reverting_clone.set(true);
|
||||
row.set_active(!row.is_active());
|
||||
reverting_clone.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -231,9 +255,11 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let threads_row = adw::ComboRow::builder()
|
||||
.title("Processing threads")
|
||||
.subtitle("Auto uses all available CPU cores")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let threads_model = gtk::StringList::new(&["Auto", "1", "2", "4", "8"]);
|
||||
threads_row.set_model(Some(&threads_model));
|
||||
threads_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
threads_row.set_selected(match config.thread_count {
|
||||
pixstrip_core::config::ThreadCount::Auto => 0,
|
||||
pixstrip_core::config::ThreadCount::Manual(1) => 1,
|
||||
@@ -245,9 +271,11 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let error_row = adw::ComboRow::builder()
|
||||
.title("On error")
|
||||
.subtitle("What to do when an image fails to process")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let error_model = gtk::StringList::new(&["Skip and continue", "Pause on error"]);
|
||||
error_row.set_model(Some(&error_model));
|
||||
error_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
error_row.set_selected(match config.error_behavior {
|
||||
ErrorBehavior::SkipAndContinue => 0,
|
||||
ErrorBehavior::PauseOnError => 1,
|
||||
@@ -287,6 +315,53 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
.active(config.reduced_motion)
|
||||
.build();
|
||||
|
||||
// Wire high contrast to apply immediately
|
||||
{
|
||||
contrast_row.connect_active_notify(move |row| {
|
||||
if let Some(settings) = gtk::Settings::default() {
|
||||
if row.is_active() {
|
||||
settings.set_gtk_theme_name(Some("HighContrast"));
|
||||
} else {
|
||||
// Revert to the default Adwaita theme
|
||||
settings.set_gtk_theme_name(Some("Adwaita"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wire large text to apply immediately
|
||||
{
|
||||
let original_dpi: std::rc::Rc<Cell<i32>> = std::rc::Rc::new(Cell::new(0));
|
||||
let orig_dpi = original_dpi.clone();
|
||||
large_text_row.connect_active_notify(move |row| {
|
||||
if let Some(settings) = gtk::Settings::default() {
|
||||
if row.is_active() {
|
||||
// Store original DPI before modifying
|
||||
let current_dpi = settings.gtk_xft_dpi();
|
||||
if current_dpi > 0 {
|
||||
orig_dpi.set(current_dpi);
|
||||
settings.set_gtk_xft_dpi(current_dpi * 5 / 4);
|
||||
}
|
||||
} else {
|
||||
// Restore the original DPI (only if we actually changed it)
|
||||
let saved = orig_dpi.get();
|
||||
if saved > 0 {
|
||||
settings.set_gtk_xft_dpi(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wire reduced motion to apply immediately
|
||||
{
|
||||
motion_row.connect_active_notify(move |row| {
|
||||
if let Some(settings) = gtk::Settings::default() {
|
||||
settings.set_gtk_enable_animations(!row.is_active());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
a11y_group.add(&contrast_row);
|
||||
a11y_group.add(&large_text_row);
|
||||
a11y_group.add(&motion_row);
|
||||
@@ -443,6 +518,9 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
let auto_open = auto_open_row.clone();
|
||||
let output_mode = output_mode_row.clone();
|
||||
let fps_reset = fixed_path_state.clone();
|
||||
let wfs_reset = watch_folders_state.clone();
|
||||
let wl_reset = watch_list.clone();
|
||||
let el_reset = empty_label.clone();
|
||||
reset_button.connect_clicked(move |_| {
|
||||
let defaults = AppConfig::default();
|
||||
subfolder.set_text(&defaults.output_subfolder);
|
||||
@@ -459,6 +537,10 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
auto_open.set_active(defaults.auto_open_output);
|
||||
output_mode.set_selected(0);
|
||||
*fps_reset.borrow_mut() = None;
|
||||
// Clear watch folders
|
||||
wfs_reset.borrow_mut().clear();
|
||||
wl_reset.remove_all();
|
||||
el_reset.set_visible(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -539,11 +621,13 @@ fn build_watch_folder_row(
|
||||
let preset_row = adw::ComboRow::builder()
|
||||
.title("Linked Preset")
|
||||
.subtitle("Preset to apply to new images")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let preset_model = gtk::StringList::new(
|
||||
&preset_names.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
||||
);
|
||||
preset_row.set_model(Some(&preset_model));
|
||||
preset_row.set_list_factory(Some(&crate::steps::full_text_list_factory()));
|
||||
|
||||
// Set selected to matching preset
|
||||
let selected_idx = preset_names.iter()
|
||||
|
||||
@@ -5,7 +5,10 @@ use std::cell::RefCell;
|
||||
#[derive(Clone)]
|
||||
pub struct StepIndicator {
|
||||
container: gtk::Box,
|
||||
grid: gtk::Grid,
|
||||
dots: RefCell<Vec<StepDot>>,
|
||||
/// Maps visual index -> actual step index
|
||||
step_map: RefCell<Vec<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -27,65 +30,23 @@ impl StepIndicator {
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
// Prevent negative allocation warnings when window is narrow
|
||||
container.set_overflow(gtk::Overflow::Hidden);
|
||||
|
||||
container.update_property(&[
|
||||
gtk::accessible::Property::Label("Wizard step indicator"),
|
||||
]);
|
||||
|
||||
let mut dots = Vec::new();
|
||||
let grid = gtk::Grid::builder()
|
||||
.column_homogeneous(false)
|
||||
.row_spacing(2)
|
||||
.column_spacing(0)
|
||||
.hexpand(false)
|
||||
.build();
|
||||
|
||||
for (i, name) in step_names.iter().enumerate() {
|
||||
if i > 0 {
|
||||
// Connector line between dots
|
||||
let line = gtk::Separator::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.hexpand(false)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
line.set_size_request(12, -1);
|
||||
container.append(&line);
|
||||
}
|
||||
let indices: Vec<usize> = (0..step_names.len()).collect();
|
||||
let dots = Self::build_dots(&grid, step_names, &indices);
|
||||
|
||||
let dot_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(2)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name("radio-symbolic")
|
||||
.pixel_size(16)
|
||||
.build();
|
||||
|
||||
let button = gtk::Button::builder()
|
||||
.child(&icon)
|
||||
.has_frame(false)
|
||||
.tooltip_text(format!("Step {}: {} (Alt+{})", i + 1, name, i + 1))
|
||||
.sensitive(false)
|
||||
.action_name("win.goto-step")
|
||||
.action_target(&(i as i32 + 1).to_variant())
|
||||
.build();
|
||||
button.add_css_class("circular");
|
||||
|
||||
let label = gtk::Label::builder()
|
||||
.label(name)
|
||||
.css_classes(["caption"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.max_width_chars(8)
|
||||
.build();
|
||||
|
||||
dot_box.append(&button);
|
||||
dot_box.append(&label);
|
||||
container.append(&dot_box);
|
||||
|
||||
dots.push(StepDot {
|
||||
button,
|
||||
icon,
|
||||
label,
|
||||
});
|
||||
}
|
||||
container.append(&grid);
|
||||
|
||||
// First step starts as current
|
||||
if let Some(first) = dots.first() {
|
||||
@@ -96,22 +57,95 @@ impl StepIndicator {
|
||||
|
||||
Self {
|
||||
container,
|
||||
grid,
|
||||
dots: RefCell::new(dots),
|
||||
step_map: RefCell::new(indices),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_current(&self, index: usize) {
|
||||
fn build_dots(grid: >k::Grid, names: &[String], step_indices: &[usize]) -> Vec<StepDot> {
|
||||
let mut dots = Vec::new();
|
||||
|
||||
for (visual_i, (name, &actual_i)) in names.iter().zip(step_indices.iter()).enumerate() {
|
||||
let col = (visual_i * 2) as i32;
|
||||
|
||||
if visual_i > 0 {
|
||||
let line = gtk::Separator::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.hexpand(false)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
line.set_size_request(12, -1);
|
||||
grid.attach(&line, col - 1, 0, 1, 1);
|
||||
}
|
||||
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name("radio-symbolic")
|
||||
.pixel_size(16)
|
||||
.build();
|
||||
|
||||
let button = gtk::Button::builder()
|
||||
.child(&icon)
|
||||
.has_frame(false)
|
||||
.tooltip_text(format!("Step {}: {} (Alt+{})", visual_i + 1, name, actual_i + 1))
|
||||
.sensitive(false)
|
||||
.action_name("win.goto-step")
|
||||
.action_target(&(actual_i as i32 + 1).to_variant())
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
button.add_css_class("circular");
|
||||
|
||||
let label = gtk::Label::builder()
|
||||
.label(name)
|
||||
.css_classes(["caption"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.width_chars(10)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
grid.attach(&button, col, 0, 1, 1);
|
||||
grid.attach(&label, col, 1, 1, 1);
|
||||
|
||||
dots.push(StepDot {
|
||||
button,
|
||||
icon,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
dots
|
||||
}
|
||||
|
||||
/// Rebuild the indicator to show only the given steps.
|
||||
/// `visible_steps` is a list of (actual_step_index, name).
|
||||
pub fn rebuild(&self, visible_steps: &[(usize, String)]) {
|
||||
// Clear the grid
|
||||
while let Some(child) = self.grid.first_child() {
|
||||
self.grid.remove(&child);
|
||||
}
|
||||
|
||||
let names: Vec<String> = visible_steps.iter().map(|(_, n)| n.clone()).collect();
|
||||
let indices: Vec<usize> = visible_steps.iter().map(|(i, _)| *i).collect();
|
||||
|
||||
let dots = Self::build_dots(&self.grid, &names, &indices);
|
||||
*self.dots.borrow_mut() = dots;
|
||||
*self.step_map.borrow_mut() = indices;
|
||||
}
|
||||
|
||||
/// Set the current step by actual step index. Finds the visual position.
|
||||
pub fn set_current(&self, actual_index: usize) {
|
||||
let dots = self.dots.borrow();
|
||||
let map = self.step_map.borrow();
|
||||
let total = dots.len();
|
||||
for (i, dot) in dots.iter().enumerate() {
|
||||
if i == index {
|
||||
for (visual_i, dot) in dots.iter().enumerate() {
|
||||
let is_current = map.get(visual_i) == Some(&actual_index);
|
||||
if is_current {
|
||||
dot.icon.set_icon_name(Some("radio-checked-symbolic"));
|
||||
dot.button.set_sensitive(true);
|
||||
dot.label.add_css_class("accent");
|
||||
// Update accessible description for screen readers
|
||||
dot.button.update_property(&[
|
||||
gtk::accessible::Property::Label(
|
||||
&format!("Step {} of {}: {} (current)", i + 1, total, dot.label.label())
|
||||
&format!("Step {} of {}: {} (current)", visual_i + 1, total, dot.label.label())
|
||||
),
|
||||
]);
|
||||
} else if dot.icon.icon_name().as_deref() != Some("emblem-ok-symbolic") {
|
||||
@@ -119,19 +153,23 @@ impl StepIndicator {
|
||||
dot.label.remove_css_class("accent");
|
||||
dot.button.update_property(&[
|
||||
gtk::accessible::Property::Label(
|
||||
&format!("Step {} of {}: {}", i + 1, total, dot.label.label())
|
||||
&format!("Step {} of {}: {}", visual_i + 1, total, dot.label.label())
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_completed(&self, index: usize) {
|
||||
/// Mark a step as completed by actual step index.
|
||||
pub fn set_completed(&self, actual_index: usize) {
|
||||
let dots = self.dots.borrow();
|
||||
if let Some(dot) = dots.get(index) {
|
||||
dot.icon.set_icon_name(Some("emblem-ok-symbolic"));
|
||||
dot.button.set_sensitive(true);
|
||||
dot.label.remove_css_class("accent");
|
||||
let map = self.step_map.borrow();
|
||||
if let Some(visual_i) = map.iter().position(|&i| i == actual_index) {
|
||||
if let Some(dot) = dots.get(visual_i) {
|
||||
dot.icon.set_icon_name(Some("emblem-ok-symbolic"));
|
||||
dot.button.set_sensitive(true);
|
||||
dot.label.remove_css_class("accent");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,33 @@ pub mod step_rename;
|
||||
pub mod step_resize;
|
||||
pub mod step_watermark;
|
||||
pub mod step_workflow;
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
/// Creates a list factory for ComboRow dropdowns where labels never truncate.
|
||||
/// The default GTK factory ellipsizes text, which cuts off long option names.
|
||||
pub fn full_text_list_factory() -> gtk::SignalListItemFactory {
|
||||
let factory = gtk::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let label = gtk::Label::builder()
|
||||
.xalign(0.0)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
item.set_child(Some(&label));
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
if let Some(obj) = item.item() {
|
||||
if let Some(string_obj) = obj.downcast_ref::<gtk::StringObject>() {
|
||||
if let Some(label) = item.child().and_downcast_ref::<gtk::Label>() {
|
||||
label.set_label(&string_obj.string());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
factory
|
||||
}
|
||||
|
||||
@@ -1,65 +1,211 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
let cfg = state.job_config.borrow();
|
||||
|
||||
// === OUTER LAYOUT ===
|
||||
let outer = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
// --- Enable toggle (full width) ---
|
||||
let enable_group = adw::PreferencesGroup::builder()
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.build();
|
||||
let enable_row = adw::SwitchRow::builder()
|
||||
.title("Enable Adjustments")
|
||||
.subtitle("Rotate, flip, brightness, contrast, effects")
|
||||
.active(cfg.adjustments_enabled)
|
||||
.build();
|
||||
enable_group.add(&enable_row);
|
||||
outer.append(&enable_group);
|
||||
|
||||
// === LEFT SIDE: Preview ===
|
||||
|
||||
let preview_picture = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Contain)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_picture.set_can_target(true);
|
||||
|
||||
let info_label = gtk::Label::builder()
|
||||
.label("No images loaded")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
let cfg = state.job_config.borrow();
|
||||
let preview_frame = gtk::Frame::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_frame.set_child(Some(&preview_picture));
|
||||
|
||||
// Rotate
|
||||
let rotate_group = adw::PreferencesGroup::builder()
|
||||
let preview_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_box.append(&preview_frame);
|
||||
preview_box.append(&info_label);
|
||||
|
||||
// === RIGHT SIDE: Controls (scrollable) ===
|
||||
|
||||
let controls = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.margin_start(12)
|
||||
.build();
|
||||
|
||||
// --- Orientation group ---
|
||||
let orient_group = adw::PreferencesGroup::builder()
|
||||
.title("Orientation")
|
||||
.description("Rotate and flip images")
|
||||
.build();
|
||||
|
||||
let rotate_row = adw::ComboRow::builder()
|
||||
.title("Rotate")
|
||||
.subtitle("Rotation applied to all images")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let rotate_model = gtk::StringList::new(&[
|
||||
rotate_row.set_model(Some(>k::StringList::new(&[
|
||||
"None",
|
||||
"90 clockwise",
|
||||
"180",
|
||||
"270 clockwise",
|
||||
"Auto-orient (from EXIF)",
|
||||
]);
|
||||
rotate_row.set_model(Some(&rotate_model));
|
||||
])));
|
||||
rotate_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
rotate_row.set_selected(cfg.rotation);
|
||||
|
||||
let flip_row = adw::ComboRow::builder()
|
||||
.title("Flip")
|
||||
.subtitle("Mirror the image")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let flip_model = gtk::StringList::new(&["None", "Horizontal", "Vertical"]);
|
||||
flip_row.set_model(Some(&flip_model));
|
||||
flip_row.set_model(Some(>k::StringList::new(&["None", "Horizontal", "Vertical"])));
|
||||
flip_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
flip_row.set_selected(cfg.flip);
|
||||
|
||||
rotate_group.add(&rotate_row);
|
||||
rotate_group.add(&flip_row);
|
||||
content.append(&rotate_group);
|
||||
orient_group.add(&rotate_row);
|
||||
orient_group.add(&flip_row);
|
||||
controls.append(&orient_group);
|
||||
|
||||
// Crop and canvas group
|
||||
// --- Color adjustments group ---
|
||||
let color_group = adw::PreferencesGroup::builder()
|
||||
.title("Color")
|
||||
.build();
|
||||
|
||||
// Helper to build a slider row with reset button
|
||||
let make_slider_row = |title: &str, label_text: &str, value: i32| -> (adw::ActionRow, gtk::Scale, gtk::Button) {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(&format!("{}", value))
|
||||
.build();
|
||||
let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
scale.set_value(value as f64);
|
||||
scale.set_draw_value(false);
|
||||
scale.set_hexpand(false);
|
||||
scale.set_valign(gtk::Align::Center);
|
||||
scale.set_width_request(180);
|
||||
scale.set_tooltip_text(Some(label_text));
|
||||
scale.update_property(&[
|
||||
gtk::accessible::Property::Label(label_text),
|
||||
]);
|
||||
|
||||
let reset_btn = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 0")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
reset_btn.set_sensitive(value != 0);
|
||||
|
||||
row.add_suffix(&scale);
|
||||
row.add_suffix(&reset_btn);
|
||||
(row, scale, reset_btn)
|
||||
};
|
||||
|
||||
let (brightness_row, brightness_scale, brightness_reset) =
|
||||
make_slider_row("Brightness", "Brightness adjustment, -100 to +100", cfg.brightness);
|
||||
let (contrast_row, contrast_scale, contrast_reset) =
|
||||
make_slider_row("Contrast", "Contrast adjustment, -100 to +100", cfg.contrast);
|
||||
let (saturation_row, saturation_scale, saturation_reset) =
|
||||
make_slider_row("Saturation", "Saturation adjustment, -100 to +100", cfg.saturation);
|
||||
|
||||
color_group.add(&brightness_row);
|
||||
color_group.add(&contrast_row);
|
||||
color_group.add(&saturation_row);
|
||||
controls.append(&color_group);
|
||||
|
||||
// --- Effects group (compact toggle buttons) ---
|
||||
let effects_group = adw::PreferencesGroup::builder()
|
||||
.title("Effects")
|
||||
.build();
|
||||
|
||||
let effects_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
|
||||
let grayscale_btn = gtk::ToggleButton::builder()
|
||||
.label("Grayscale")
|
||||
.active(cfg.grayscale)
|
||||
.tooltip_text("Convert to grayscale")
|
||||
.build();
|
||||
grayscale_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Grayscale effect toggle"),
|
||||
]);
|
||||
|
||||
let sepia_btn = gtk::ToggleButton::builder()
|
||||
.label("Sepia")
|
||||
.active(cfg.sepia)
|
||||
.tooltip_text("Apply sepia tone")
|
||||
.build();
|
||||
sepia_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Sepia effect toggle"),
|
||||
]);
|
||||
|
||||
let sharpen_btn = gtk::ToggleButton::builder()
|
||||
.label("Sharpen")
|
||||
.active(cfg.sharpen)
|
||||
.tooltip_text("Sharpen the image")
|
||||
.build();
|
||||
sharpen_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Sharpen effect toggle"),
|
||||
]);
|
||||
|
||||
effects_box.append(&grayscale_btn);
|
||||
effects_box.append(&sepia_btn);
|
||||
effects_box.append(&sharpen_btn);
|
||||
effects_group.add(&effects_box);
|
||||
controls.append(&effects_group);
|
||||
|
||||
// --- Crop & Canvas group ---
|
||||
let crop_group = adw::PreferencesGroup::builder()
|
||||
.title("Crop and Canvas")
|
||||
.build();
|
||||
|
||||
let crop_row = adw::ComboRow::builder()
|
||||
.title("Crop to Aspect Ratio")
|
||||
.subtitle("Crop images to a specific aspect ratio from center")
|
||||
.subtitle("Crop from center to a specific ratio")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let crop_model = gtk::StringList::new(&[
|
||||
crop_row.set_model(Some(>k::StringList::new(&[
|
||||
"None",
|
||||
"1:1 (Square)",
|
||||
"4:3",
|
||||
@@ -68,8 +214,8 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
"9:16 (Portrait)",
|
||||
"3:4 (Portrait)",
|
||||
"2:3 (Portrait)",
|
||||
]);
|
||||
crop_row.set_model(Some(&crop_model));
|
||||
])));
|
||||
crop_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
crop_row.set_selected(cfg.crop_aspect_ratio);
|
||||
|
||||
let trim_row = adw::SwitchRow::builder()
|
||||
@@ -80,201 +226,481 @@ pub fn build_adjustments_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
let padding_row = adw::SpinRow::builder()
|
||||
.title("Canvas Padding")
|
||||
.subtitle("Add uniform padding around the image (pixels)")
|
||||
.subtitle("Add uniform padding (pixels)")
|
||||
.adjustment(>k::Adjustment::new(cfg.canvas_padding as f64, 0.0, 500.0, 1.0, 10.0, 0.0))
|
||||
.build();
|
||||
|
||||
crop_group.add(&crop_row);
|
||||
crop_group.add(&trim_row);
|
||||
crop_group.add(&padding_row);
|
||||
content.append(&crop_group);
|
||||
controls.append(&crop_group);
|
||||
|
||||
// Image adjustments
|
||||
let adjust_group = adw::PreferencesGroup::builder()
|
||||
.title("Image Adjustments")
|
||||
// Scrollable controls
|
||||
let controls_scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.width_request(360)
|
||||
.child(&controls)
|
||||
.build();
|
||||
|
||||
let adjust_expander = adw::ExpanderRow::builder()
|
||||
.title("Advanced Adjustments")
|
||||
.subtitle("Brightness, contrast, saturation, effects")
|
||||
.show_enable_switch(false)
|
||||
.expanded(state.is_section_expanded("adjustments-advanced"))
|
||||
// === Main layout: 60/40 side-by-side ===
|
||||
let main_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
{
|
||||
let st = state.clone();
|
||||
adjust_expander.connect_expanded_notify(move |row| {
|
||||
st.set_section_expanded("adjustments-advanced", row.is_expanded());
|
||||
});
|
||||
}
|
||||
preview_box.set_width_request(400);
|
||||
main_box.append(&preview_box);
|
||||
main_box.append(&controls_scrolled);
|
||||
outer.append(&main_box);
|
||||
|
||||
// Brightness slider (-100 to +100)
|
||||
let brightness_row = adw::ActionRow::builder()
|
||||
.title("Brightness")
|
||||
.subtitle(format!("{}", cfg.brightness))
|
||||
.build();
|
||||
let brightness_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
brightness_scale.set_value(cfg.brightness as f64);
|
||||
brightness_scale.set_hexpand(true);
|
||||
brightness_scale.set_valign(gtk::Align::Center);
|
||||
brightness_scale.set_size_request(200, -1);
|
||||
brightness_scale.set_draw_value(false);
|
||||
brightness_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Brightness adjustment, -100 to +100"),
|
||||
]);
|
||||
brightness_row.add_suffix(&brightness_scale);
|
||||
adjust_expander.add_row(&brightness_row);
|
||||
|
||||
// Contrast slider (-100 to +100)
|
||||
let contrast_row = adw::ActionRow::builder()
|
||||
.title("Contrast")
|
||||
.subtitle(format!("{}", cfg.contrast))
|
||||
.build();
|
||||
let contrast_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
contrast_scale.set_value(cfg.contrast as f64);
|
||||
contrast_scale.set_hexpand(true);
|
||||
contrast_scale.set_valign(gtk::Align::Center);
|
||||
contrast_scale.set_size_request(200, -1);
|
||||
contrast_scale.set_draw_value(false);
|
||||
contrast_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Contrast adjustment, -100 to +100"),
|
||||
]);
|
||||
contrast_row.add_suffix(&contrast_scale);
|
||||
adjust_expander.add_row(&contrast_row);
|
||||
|
||||
// Saturation slider (-100 to +100)
|
||||
let saturation_row = adw::ActionRow::builder()
|
||||
.title("Saturation")
|
||||
.subtitle(format!("{}", cfg.saturation))
|
||||
.build();
|
||||
let saturation_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -100.0, 100.0, 1.0);
|
||||
saturation_scale.set_value(cfg.saturation as f64);
|
||||
saturation_scale.set_hexpand(true);
|
||||
saturation_scale.set_valign(gtk::Align::Center);
|
||||
saturation_scale.set_size_request(200, -1);
|
||||
saturation_scale.set_draw_value(false);
|
||||
saturation_scale.update_property(&[
|
||||
gtk::accessible::Property::Label("Saturation adjustment, -100 to +100"),
|
||||
]);
|
||||
saturation_row.add_suffix(&saturation_scale);
|
||||
adjust_expander.add_row(&saturation_row);
|
||||
|
||||
// Sharpen after resize
|
||||
let sharpen_row = adw::SwitchRow::builder()
|
||||
.title("Sharpen after resize")
|
||||
.subtitle("Apply subtle sharpening to resized images")
|
||||
.active(cfg.sharpen)
|
||||
.build();
|
||||
adjust_expander.add_row(&sharpen_row);
|
||||
|
||||
// Grayscale
|
||||
let grayscale_row = adw::SwitchRow::builder()
|
||||
.title("Grayscale")
|
||||
.subtitle("Convert images to black and white")
|
||||
.active(cfg.grayscale)
|
||||
.build();
|
||||
adjust_expander.add_row(&grayscale_row);
|
||||
|
||||
// Sepia
|
||||
let sepia_row = adw::SwitchRow::builder()
|
||||
.title("Sepia")
|
||||
.subtitle("Apply a warm vintage tone")
|
||||
.active(cfg.sepia)
|
||||
.build();
|
||||
adjust_expander.add_row(&sepia_row);
|
||||
|
||||
adjust_group.add(&adjust_expander);
|
||||
content.append(&adjust_group);
|
||||
// Preview state
|
||||
let preview_index: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
||||
|
||||
drop(cfg);
|
||||
|
||||
// Wire signals
|
||||
// === Preview update closure ===
|
||||
let preview_gen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
let update_preview = {
|
||||
let files = state.loaded_files.clone();
|
||||
let jc = state.job_config.clone();
|
||||
let pic = preview_picture.clone();
|
||||
let info = info_label.clone();
|
||||
let pidx = preview_index.clone();
|
||||
let bind_gen = preview_gen.clone();
|
||||
|
||||
Rc::new(move || {
|
||||
let loaded = files.borrow();
|
||||
if loaded.is_empty() {
|
||||
info.set_label("No images loaded");
|
||||
pic.set_paintable(gtk::gdk::Paintable::NONE);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = pidx.get().min(loaded.len() - 1);
|
||||
pidx.set(idx);
|
||||
let path = loaded[idx].clone();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
|
||||
info.set_label(&format!("[{}/{}] {}", idx + 1, loaded.len(), name));
|
||||
|
||||
let cfg = jc.borrow();
|
||||
let rotation = cfg.rotation;
|
||||
let flip = cfg.flip;
|
||||
let brightness = cfg.brightness;
|
||||
let contrast = cfg.contrast;
|
||||
let saturation = cfg.saturation;
|
||||
let grayscale = cfg.grayscale;
|
||||
let sepia = cfg.sepia;
|
||||
let sharpen = cfg.sharpen;
|
||||
let crop_aspect = cfg.crop_aspect_ratio;
|
||||
let trim_ws = cfg.trim_whitespace;
|
||||
let padding = cfg.canvas_padding;
|
||||
drop(cfg);
|
||||
|
||||
let my_gen = bind_gen.get().wrapping_add(1);
|
||||
bind_gen.set(my_gen);
|
||||
let gen_check = bind_gen.clone();
|
||||
|
||||
let pic = pic.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Option<Vec<u8>>>();
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| -> Option<Vec<u8>> {
|
||||
let img = image::open(&path).ok()?;
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
|
||||
// Rotation
|
||||
let mut img = match rotation {
|
||||
1 => img.rotate90(),
|
||||
2 => img.rotate180(),
|
||||
3 => img.rotate270(),
|
||||
// 4 = auto-orient from EXIF - skip in preview (would need exif crate)
|
||||
_ => img,
|
||||
};
|
||||
|
||||
// Flip
|
||||
match flip {
|
||||
1 => img = img.fliph(),
|
||||
2 => img = img.flipv(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Crop to aspect ratio
|
||||
if crop_aspect > 0 {
|
||||
let (target_w, target_h): (f64, f64) = match crop_aspect {
|
||||
1 => (1.0, 1.0), // 1:1
|
||||
2 => (4.0, 3.0), // 4:3
|
||||
3 => (3.0, 2.0), // 3:2
|
||||
4 => (16.0, 9.0), // 16:9
|
||||
5 => (9.0, 16.0), // 9:16
|
||||
6 => (3.0, 4.0), // 3:4
|
||||
7 => (2.0, 3.0), // 2:3
|
||||
_ => (1.0, 1.0),
|
||||
};
|
||||
let iw = img.width() as f64;
|
||||
let ih = img.height() as f64;
|
||||
let target_ratio = target_w / target_h;
|
||||
let current_ratio = iw / ih;
|
||||
let (crop_w, crop_h) = if current_ratio > target_ratio {
|
||||
((ih * target_ratio) as u32, img.height())
|
||||
} else {
|
||||
(img.width(), (iw / target_ratio) as u32)
|
||||
};
|
||||
let cx = (img.width().saturating_sub(crop_w)) / 2;
|
||||
let cy = (img.height().saturating_sub(crop_h)) / 2;
|
||||
img = img.crop_imm(cx, cy, crop_w, crop_h);
|
||||
}
|
||||
|
||||
// Trim whitespace (matches core algorithm with threshold)
|
||||
if trim_ws {
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = (rgba.width(), rgba.height());
|
||||
if w > 2 && h > 2 {
|
||||
let bg = *rgba.get_pixel(0, 0);
|
||||
let threshold = 30u32;
|
||||
let is_bg = |p: &image::Rgba<u8>| -> bool {
|
||||
let dr = (p[0] as i32 - bg[0] as i32).unsigned_abs();
|
||||
let dg = (p[1] as i32 - bg[1] as i32).unsigned_abs();
|
||||
let db = (p[2] as i32 - bg[2] as i32).unsigned_abs();
|
||||
dr + dg + db < threshold
|
||||
};
|
||||
let mut top = 0u32;
|
||||
let mut bottom = h - 1;
|
||||
let mut left = 0u32;
|
||||
let mut right = w - 1;
|
||||
'top: for y in 0..h {
|
||||
for x in 0..w {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { top = y; break 'top; }
|
||||
}
|
||||
}
|
||||
'bottom: for y in (0..h).rev() {
|
||||
for x in 0..w {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { bottom = y; break 'bottom; }
|
||||
}
|
||||
}
|
||||
'left: for x in 0..w {
|
||||
for y in top..=bottom {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { left = x; break 'left; }
|
||||
}
|
||||
}
|
||||
'right: for x in (0..w).rev() {
|
||||
for y in top..=bottom {
|
||||
if !is_bg(rgba.get_pixel(x, y)) { right = x; break 'right; }
|
||||
}
|
||||
}
|
||||
let cw = right.saturating_sub(left).saturating_add(1);
|
||||
let ch = bottom.saturating_sub(top).saturating_add(1);
|
||||
if cw > 0 && ch > 0 && (cw < w || ch < h) {
|
||||
img = img.crop_imm(left, top, cw, ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brightness
|
||||
if brightness != 0 {
|
||||
img = img.brighten(brightness);
|
||||
}
|
||||
// Contrast
|
||||
if contrast != 0 {
|
||||
img = img.adjust_contrast(contrast as f32);
|
||||
}
|
||||
// Saturation
|
||||
if saturation != 0 {
|
||||
let sat = saturation.clamp(-100, 100);
|
||||
let factor = 1.0 + (sat as f64 / 100.0);
|
||||
let mut rgba = img.into_rgba8();
|
||||
for pixel in rgba.pixels_mut() {
|
||||
let r = pixel[0] as f64;
|
||||
let g = pixel[1] as f64;
|
||||
let b = pixel[2] as f64;
|
||||
let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
pixel[0] = (gray + (r - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||
pixel[1] = (gray + (g - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||
pixel[2] = (gray + (b - gray) * factor).clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
img = image::DynamicImage::ImageRgba8(rgba);
|
||||
}
|
||||
// Sharpen
|
||||
if sharpen {
|
||||
img = img.unsharpen(1.0, 5);
|
||||
}
|
||||
// Grayscale
|
||||
if grayscale {
|
||||
img = image::DynamicImage::ImageLuma8(img.to_luma8());
|
||||
}
|
||||
// Sepia
|
||||
if sepia {
|
||||
let mut rgba = img.into_rgba8();
|
||||
for pixel in rgba.pixels_mut() {
|
||||
let r = pixel[0] as f64;
|
||||
let g = pixel[1] as f64;
|
||||
let b = pixel[2] as f64;
|
||||
pixel[0] = (0.393 * r + 0.769 * g + 0.189 * b).clamp(0.0, 255.0) as u8;
|
||||
pixel[1] = (0.349 * r + 0.686 * g + 0.168 * b).clamp(0.0, 255.0) as u8;
|
||||
pixel[2] = (0.272 * r + 0.534 * g + 0.131 * b).clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
img = image::DynamicImage::ImageRgba8(rgba);
|
||||
}
|
||||
|
||||
// Canvas padding
|
||||
if padding > 0 {
|
||||
let pad = padding.min(200); // cap for preview
|
||||
let new_w = img.width().saturating_add(pad.saturating_mul(2));
|
||||
let new_h = img.height().saturating_add(pad.saturating_mul(2));
|
||||
let mut canvas = image::RgbaImage::from_pixel(
|
||||
new_w, new_h,
|
||||
image::Rgba([255, 255, 255, 255]),
|
||||
);
|
||||
image::imageops::overlay(&mut canvas, &img.to_rgba8(), pad as i64, pad as i64);
|
||||
img = image::DynamicImage::ImageRgba8(canvas);
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buf),
|
||||
image::ImageFormat::Png,
|
||||
).ok()?;
|
||||
Some(buf)
|
||||
})();
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||
if gen_check.get() != my_gen {
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(Some(bytes)) => {
|
||||
let gbytes = glib::Bytes::from(&bytes);
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
|
||||
pic.set_paintable(Some(&texture));
|
||||
}
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(None) => glib::ControlFlow::Break,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(_) => glib::ControlFlow::Break,
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Click-to-cycle on preview
|
||||
{
|
||||
let click = gtk::GestureClick::new();
|
||||
let pidx = preview_index.clone();
|
||||
let files = state.loaded_files.clone();
|
||||
let up = update_preview.clone();
|
||||
click.connect_released(move |_, _, _, _| {
|
||||
let loaded = files.borrow();
|
||||
if loaded.len() > 1 {
|
||||
let next = (pidx.get() + 1) % loaded.len();
|
||||
pidx.set(next);
|
||||
up();
|
||||
}
|
||||
});
|
||||
preview_picture.add_controller(click);
|
||||
}
|
||||
|
||||
// === Wire signals ===
|
||||
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
enable_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().adjustments_enabled = row.is_active();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
rotate_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().rotation = row.selected();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
flip_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().flip = row.selected();
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Shared debounce counter for slider-driven previews
|
||||
let slider_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
|
||||
// Brightness
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
crop_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().crop_aspect_ratio = row.selected();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
trim_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().trim_whitespace = row.is_active();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
padding_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().canvas_padding = row.value() as u32;
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = brightness_row;
|
||||
let row = brightness_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = brightness_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
brightness_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().brightness = val;
|
||||
label.set_subtitle(&format!("{}", val));
|
||||
row.set_subtitle(&format!("{}", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = brightness_scale.clone();
|
||||
brightness_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Contrast
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = contrast_row;
|
||||
let row = contrast_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = contrast_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
contrast_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().contrast = val;
|
||||
label.set_subtitle(&format!("{}", val));
|
||||
row.set_subtitle(&format!("{}", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = contrast_scale.clone();
|
||||
contrast_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Saturation
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = saturation_row;
|
||||
let row = saturation_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = saturation_reset.clone();
|
||||
let did = slider_debounce.clone();
|
||||
saturation_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().saturation = val;
|
||||
label.set_subtitle(&format!("{}", val));
|
||||
row.set_subtitle(&format!("{}", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = saturation_scale.clone();
|
||||
saturation_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Effects toggle buttons
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
grayscale_btn.connect_toggled(move |btn| {
|
||||
jc.borrow_mut().grayscale = btn.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
sharpen_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().sharpen = row.is_active();
|
||||
let up = update_preview.clone();
|
||||
sepia_btn.connect_toggled(move |btn| {
|
||||
jc.borrow_mut().sepia = btn.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
grayscale_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().grayscale = row.is_active();
|
||||
let up = update_preview.clone();
|
||||
sharpen_btn.connect_toggled(move |btn| {
|
||||
jc.borrow_mut().sharpen = btn.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Crop & Canvas
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
crop_row.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().crop_aspect_ratio = row.selected();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
sepia_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().sepia = row.is_active();
|
||||
let up = update_preview.clone();
|
||||
trim_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().trim_whitespace = row.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
let did = slider_debounce.clone();
|
||||
padding_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().canvas_padding = row.value() as u32;
|
||||
let up = up.clone();
|
||||
let did = did.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Adjustments")
|
||||
.tag("step-adjustments")
|
||||
.child(&clamp)
|
||||
.build()
|
||||
.child(&outer)
|
||||
.build();
|
||||
|
||||
// Refresh preview and sensitivity when navigating to this page
|
||||
{
|
||||
let up = update_preview.clone();
|
||||
let lf = state.loaded_files.clone();
|
||||
let ctrl = controls.clone();
|
||||
page.connect_map(move |_| {
|
||||
ctrl.set_sensitive(!lf.borrow().is_empty());
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,61 @@
|
||||
use adw::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app::AppState;
|
||||
use pixstrip_core::types::ImageFormat;
|
||||
|
||||
/// All format labels shown in the card grid.
|
||||
/// Keep Original + 7 common formats = 8 cards.
|
||||
const CARD_FORMATS: &[(&str, &str, &str, Option<ImageFormat>)] = &[
|
||||
("Keep Original", "No conversion", "edit-copy-symbolic", None),
|
||||
("JPEG", "Universal photo format\nLossy, small files", "image-x-generic-symbolic", Some(ImageFormat::Jpeg)),
|
||||
("PNG", "Lossless, transparency\nGraphics, screenshots", "image-x-generic-symbolic", Some(ImageFormat::Png)),
|
||||
("WebP", "Modern web format\nExcellent compression", "web-browser-symbolic", Some(ImageFormat::WebP)),
|
||||
("AVIF", "Next-gen format\nBest compression", "emblem-favorite-symbolic", Some(ImageFormat::Avif)),
|
||||
("GIF", "Animations, 256 colors\nSimple graphics", "media-playback-start-symbolic", Some(ImageFormat::Gif)),
|
||||
("TIFF", "Archival, lossless\nVery large files", "drive-harddisk-symbolic", Some(ImageFormat::Tiff)),
|
||||
("BMP", "Uncompressed bitmap\nLegacy format", "image-x-generic-symbolic", None),
|
||||
];
|
||||
|
||||
/// Extra formats available only in the "Other Formats" dropdown: (short name, dropdown label)
|
||||
const DROPDOWN_ONLY_FORMATS: &[(&str, &str)] = &[
|
||||
("ICO", "ICO - Favicon and icon format, max 256x256 pixels"),
|
||||
("HDR", "HDR - Radiance HDR, high dynamic range imaging"),
|
||||
("PNM/PPM", "PNM/PPM - Portable anymap, simple uncompressed"),
|
||||
("TGA", "TGA - Targa, legacy game/video format"),
|
||||
("HEIC/HEIF", "HEIC/HEIF - Apple's photo format, excellent compression"),
|
||||
("JXL (JPEG XL)", "JXL (JPEG XL) - Next-gen JPEG successor, lossless and lossy"),
|
||||
("QOI", "QOI - Quite OK Image, fast lossless compression"),
|
||||
("EXR", "EXR - OpenEXR, VFX/film industry HDR standard"),
|
||||
("Farbfeld", "Farbfeld - Minimal 16-bit lossless, suckless format"),
|
||||
];
|
||||
|
||||
/// Ordered list of format labels for the per-format mapping dropdowns.
|
||||
/// Index 0 = "Same as above", 1 = "Keep Original", then common formats with descriptions.
|
||||
const MAPPING_CHOICES: &[&str] = &[
|
||||
"Same as above - use the global output format",
|
||||
"Keep Original - no conversion for this type",
|
||||
"JPEG - universal photo format, lossy compression",
|
||||
"PNG - lossless with transparency, larger files",
|
||||
"WebP - modern web format, excellent compression",
|
||||
"AVIF - next-gen, best compression, slower encode",
|
||||
"GIF - 256 colors, animation support",
|
||||
"TIFF - archival lossless, very large files",
|
||||
"BMP - uncompressed bitmap, legacy format",
|
||||
"ICO - favicon/icon format, max 256x256",
|
||||
"HDR - Radiance high dynamic range",
|
||||
"PNM/PPM - portable anymap, uncompressed",
|
||||
"TGA - Targa, legacy game/video format",
|
||||
"HEIC/HEIF - Apple photo format, great compression",
|
||||
"JXL (JPEG XL) - next-gen JPEG successor",
|
||||
"QOI - fast lossless compression",
|
||||
"EXR - OpenEXR, VFX/film HDR standard",
|
||||
"Farbfeld - minimal 16-bit lossless",
|
||||
];
|
||||
|
||||
pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
@@ -30,7 +84,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
enable_group.add(&enable_row);
|
||||
content.append(&enable_group);
|
||||
|
||||
// Visual format cards grid
|
||||
// --- Visual format cards grid ---
|
||||
let cards_group = adw::PreferencesGroup::builder()
|
||||
.title("Output Format")
|
||||
.description("Choose the format all images will be converted to")
|
||||
@@ -47,25 +101,14 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
let formats: &[(&str, &str, &str, Option<ImageFormat>)] = &[
|
||||
("Keep Original", "No conversion", "edit-copy-symbolic", None),
|
||||
("JPEG", "Universal photo format\nLossy, small files", "image-x-generic-symbolic", Some(ImageFormat::Jpeg)),
|
||||
("PNG", "Lossless, transparency\nGraphics, screenshots", "image-x-generic-symbolic", Some(ImageFormat::Png)),
|
||||
("WebP", "Modern web format\nExcellent compression", "web-browser-symbolic", Some(ImageFormat::WebP)),
|
||||
("AVIF", "Next-gen format\nBest compression", "emblem-favorite-symbolic", Some(ImageFormat::Avif)),
|
||||
("GIF", "Animations, 256 colors\nSimple graphics", "media-playback-start-symbolic", Some(ImageFormat::Gif)),
|
||||
("TIFF", "Archival, lossless\nVery large files", "drive-harddisk-symbolic", Some(ImageFormat::Tiff)),
|
||||
];
|
||||
|
||||
// Track which card should be initially selected
|
||||
let initial_format = cfg.convert_format;
|
||||
|
||||
for (name, desc, icon_name, _fmt) in formats {
|
||||
for (name, desc, icon_name, _fmt) in CARD_FORMATS {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.hexpand(true)
|
||||
.vexpand(false)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.set_size_request(130, 110);
|
||||
@@ -73,10 +116,10 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.margin_start(4)
|
||||
.margin_end(4)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.vexpand(true)
|
||||
@@ -107,20 +150,20 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
flow.append(&card);
|
||||
}
|
||||
|
||||
// Select the initial card
|
||||
let initial_idx = match initial_format {
|
||||
None => 0,
|
||||
Some(ImageFormat::Jpeg) => 1,
|
||||
Some(ImageFormat::Png) => 2,
|
||||
Some(ImageFormat::WebP) => 3,
|
||||
Some(ImageFormat::Avif) => 4,
|
||||
Some(ImageFormat::Gif) => 5,
|
||||
Some(ImageFormat::Tiff) => 6,
|
||||
};
|
||||
if let Some(child) = flow.child_at_index(initial_idx) {
|
||||
// Select the initial card (only if format matches a card)
|
||||
let initial_idx = card_index_for_format(initial_format);
|
||||
if let Some(idx) = initial_idx
|
||||
&& let Some(child) = flow.child_at_index(idx)
|
||||
{
|
||||
flow.select_child(&child);
|
||||
}
|
||||
|
||||
// Wrap FlowBox in a Clamp so cards don't stretch too wide when maximized
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(800)
|
||||
.child(&flow)
|
||||
.build();
|
||||
|
||||
// Format info label (updates based on selection)
|
||||
let info_label = gtk::Label::builder()
|
||||
.label(format_info(cfg.convert_format))
|
||||
@@ -132,161 +175,316 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
|
||||
.margin_start(4)
|
||||
.build();
|
||||
|
||||
cards_group.add(&flow);
|
||||
cards_group.add(&clamp);
|
||||
cards_group.add(&info_label);
|
||||
content.append(&cards_group);
|
||||
|
||||
// Advanced options expander
|
||||
let advanced_group = adw::PreferencesGroup::builder()
|
||||
.title("Advanced Options")
|
||||
// --- "Other Formats" dropdown for less common formats ---
|
||||
let other_group = adw::PreferencesGroup::builder()
|
||||
.title("Other Formats")
|
||||
.description("Less common formats not shown in the card grid")
|
||||
.build();
|
||||
|
||||
let advanced_expander = adw::ExpanderRow::builder()
|
||||
.title("Format Mapping")
|
||||
.subtitle("Different input formats can convert to different outputs")
|
||||
.show_enable_switch(false)
|
||||
.expanded(state.is_section_expanded("convert-advanced"))
|
||||
let other_combo = adw::ComboRow::builder()
|
||||
.title("Select format")
|
||||
.subtitle("Choosing a format here deselects the card grid")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
|
||||
{
|
||||
let st = state.clone();
|
||||
advanced_expander.connect_expanded_notify(move |row| {
|
||||
st.set_section_expanded("convert-advanced", row.is_expanded());
|
||||
});
|
||||
// Build model: first entry is "(none)" for no selection, then extra formats with descriptions
|
||||
let mut other_items: Vec<&str> = vec!["(none)"];
|
||||
for (_short, label) in DROPDOWN_ONLY_FORMATS {
|
||||
other_items.push(label);
|
||||
}
|
||||
other_combo.set_model(Some(>k::StringList::new(&other_items)));
|
||||
other_combo.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
other_combo.set_selected(0);
|
||||
|
||||
other_group.add(&other_combo);
|
||||
content.append(&cards_group);
|
||||
content.append(&other_group);
|
||||
|
||||
// --- JPEG encoding options (only visible when JPEG or Keep Original is selected) ---
|
||||
let jpeg_group = adw::PreferencesGroup::builder()
|
||||
.title("JPEG Encoding")
|
||||
.build();
|
||||
|
||||
let progressive_row = adw::SwitchRow::builder()
|
||||
.title("Progressive JPEG")
|
||||
.subtitle("Loads gradually in browsers, slightly larger")
|
||||
.subtitle("Loads gradually in browsers, slightly larger file size")
|
||||
.active(cfg.progressive_jpeg)
|
||||
.build();
|
||||
|
||||
// Format mapping rows - per input format output selection
|
||||
let mapping_header = adw::ActionRow::builder()
|
||||
.title("Per-Format Mapping")
|
||||
.subtitle("Override the output format for specific input types")
|
||||
jpeg_group.add(&progressive_row);
|
||||
|
||||
// Show only for JPEG (card index 1) or Keep Original (card index 0)
|
||||
let shows_jpeg = matches!(initial_format, None | Some(ImageFormat::Jpeg));
|
||||
jpeg_group.set_visible(shows_jpeg);
|
||||
|
||||
content.append(&jpeg_group);
|
||||
|
||||
// --- Format mapping group (dynamic, rebuilt on page map) ---
|
||||
let mapping_group = adw::PreferencesGroup::builder()
|
||||
.title("Format Mapping")
|
||||
.description("Override the output format for specific input types")
|
||||
.build();
|
||||
mapping_header.add_prefix(>k::Image::from_icon_name("preferences-system-symbolic"));
|
||||
|
||||
let output_choices = ["Same as above", "JPEG", "PNG", "WebP", "AVIF", "Keep Original"];
|
||||
|
||||
let jpeg_mapping = adw::ComboRow::builder()
|
||||
.title("JPEG inputs")
|
||||
.subtitle("Output format for JPEG source files")
|
||||
// Container for dynamically added mapping rows
|
||||
let mapping_list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(["boxed-list"])
|
||||
.build();
|
||||
jpeg_mapping.set_model(Some(>k::StringList::new(&output_choices)));
|
||||
jpeg_mapping.set_selected(cfg.format_mapping_jpeg);
|
||||
|
||||
let png_mapping = adw::ComboRow::builder()
|
||||
.title("PNG inputs")
|
||||
.subtitle("Output format for PNG source files")
|
||||
.build();
|
||||
png_mapping.set_model(Some(>k::StringList::new(&output_choices)));
|
||||
png_mapping.set_selected(cfg.format_mapping_png);
|
||||
|
||||
let webp_mapping = adw::ComboRow::builder()
|
||||
.title("WebP inputs")
|
||||
.subtitle("Output format for WebP source files")
|
||||
.build();
|
||||
webp_mapping.set_model(Some(>k::StringList::new(&output_choices)));
|
||||
webp_mapping.set_selected(cfg.format_mapping_webp);
|
||||
|
||||
let tiff_mapping = adw::ComboRow::builder()
|
||||
.title("TIFF inputs")
|
||||
.subtitle("Output format for TIFF source files")
|
||||
.build();
|
||||
tiff_mapping.set_model(Some(>k::StringList::new(&output_choices)));
|
||||
tiff_mapping.set_selected(cfg.format_mapping_tiff);
|
||||
|
||||
advanced_expander.add_row(&progressive_row);
|
||||
advanced_expander.add_row(&mapping_header);
|
||||
advanced_expander.add_row(&jpeg_mapping);
|
||||
advanced_expander.add_row(&png_mapping);
|
||||
advanced_expander.add_row(&webp_mapping);
|
||||
advanced_expander.add_row(&tiff_mapping);
|
||||
advanced_group.add(&advanced_expander);
|
||||
content.append(&advanced_group);
|
||||
mapping_group.add(&mapping_list);
|
||||
content.append(&mapping_group);
|
||||
|
||||
drop(cfg);
|
||||
|
||||
// Wire signals
|
||||
// --- Wire signals ---
|
||||
|
||||
// Enable toggle
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
enable_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().convert_enabled = row.is_active();
|
||||
});
|
||||
}
|
||||
|
||||
// Card grid selection
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = info_label;
|
||||
let label = info_label.clone();
|
||||
let combo = other_combo.clone();
|
||||
let jg = jpeg_group.clone();
|
||||
flow.connect_child_activated(move |_flow, child| {
|
||||
let idx = child.index() as usize;
|
||||
let mut c = jc.borrow_mut();
|
||||
c.convert_format = match idx {
|
||||
1 => Some(ImageFormat::Jpeg),
|
||||
2 => Some(ImageFormat::Png),
|
||||
3 => Some(ImageFormat::WebP),
|
||||
4 => Some(ImageFormat::Avif),
|
||||
5 => Some(ImageFormat::Gif),
|
||||
6 => Some(ImageFormat::Tiff),
|
||||
_ => None,
|
||||
};
|
||||
c.convert_format = format_for_card_index(idx);
|
||||
label.set_label(&format_info(c.convert_format));
|
||||
// Deselect the "Other Formats" dropdown when a card is picked
|
||||
combo.set_selected(0);
|
||||
// Progressive JPEG only relevant for JPEG or Keep Original
|
||||
jg.set_visible(idx == 0 || idx == 1);
|
||||
});
|
||||
}
|
||||
|
||||
// "Other Formats" dropdown selection
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let label = info_label;
|
||||
let fl = flow.clone();
|
||||
let jg = jpeg_group.clone();
|
||||
other_combo.connect_selected_notify(move |row| {
|
||||
let selected = row.selected();
|
||||
if selected == 0 {
|
||||
// "(none)" selected - do nothing, cards take priority
|
||||
return;
|
||||
}
|
||||
// Deselect all cards
|
||||
fl.unselect_all();
|
||||
// Hide progressive JPEG - none of the "other" formats are JPEG
|
||||
jg.set_visible(false);
|
||||
// These formats are not in the ImageFormat enum,
|
||||
// so set convert_format to None and show a note
|
||||
let mut c = jc.borrow_mut();
|
||||
c.convert_format = None;
|
||||
let name = DROPDOWN_ONLY_FORMATS
|
||||
.get((selected - 1) as usize)
|
||||
.map(|(short, _)| *short)
|
||||
.unwrap_or("Unknown");
|
||||
label.set_label(&format!(
|
||||
"{}: This format is not yet supported by the processing engine. \
|
||||
Support is planned for a future release.",
|
||||
name
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// Progressive JPEG toggle
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
progressive_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().progressive_jpeg = row.is_active();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
jpeg_mapping.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().format_mapping_jpeg = row.selected();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
png_mapping.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().format_mapping_png = row.selected();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
webp_mapping.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().format_mapping_webp = row.selected();
|
||||
});
|
||||
}
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
tiff_mapping.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut().format_mapping_tiff = row.selected();
|
||||
});
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Convert")
|
||||
.tag("step-convert")
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Convert")
|
||||
.tag("step-convert")
|
||||
.child(&clamp)
|
||||
.build()
|
||||
// Rebuild format mapping rows when navigating to this page
|
||||
{
|
||||
let files = state.loaded_files.clone();
|
||||
let list = mapping_list;
|
||||
let jc = state.job_config.clone();
|
||||
page.connect_map(move |_| {
|
||||
rebuild_format_mapping(&list, &files.borrow(), &jc);
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
/// Returns the card grid index for a given ImageFormat, or None if not in the card grid.
|
||||
fn card_index_for_format(format: Option<ImageFormat>) -> Option<i32> {
|
||||
match format {
|
||||
None => Some(0),
|
||||
Some(ImageFormat::Jpeg) => Some(1),
|
||||
Some(ImageFormat::Png) => Some(2),
|
||||
Some(ImageFormat::WebP) => Some(3),
|
||||
Some(ImageFormat::Avif) => Some(4),
|
||||
Some(ImageFormat::Gif) => Some(5),
|
||||
Some(ImageFormat::Tiff) => Some(6),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the ImageFormat for a given card grid index.
|
||||
fn format_for_card_index(idx: usize) -> Option<ImageFormat> {
|
||||
match idx {
|
||||
1 => Some(ImageFormat::Jpeg),
|
||||
2 => Some(ImageFormat::Png),
|
||||
3 => Some(ImageFormat::WebP),
|
||||
4 => Some(ImageFormat::Avif),
|
||||
5 => Some(ImageFormat::Gif),
|
||||
6 => Some(ImageFormat::Tiff),
|
||||
_ => None, // 0 = Keep Original, 7 = BMP (not in enum)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_info(format: Option<ImageFormat>) -> String {
|
||||
match format {
|
||||
None => "Images will keep their original format. No conversion applied.".into(),
|
||||
Some(ImageFormat::Jpeg) => "JPEG: Best for photographs. Lossy compression, no transparency support. Universally compatible with all devices and browsers.".into(),
|
||||
Some(ImageFormat::Png) => "PNG: Best for graphics, screenshots, and logos. Lossless compression, supports full transparency. Produces larger files than JPEG or WebP.".into(),
|
||||
Some(ImageFormat::WebP) => "WebP: Modern format with excellent lossy and lossless compression. Supports transparency and animation. Widely supported in modern browsers.".into(),
|
||||
Some(ImageFormat::Avif) => "AVIF: Next-generation format based on AV1 video codec. Best compression ratios available. Supports transparency and HDR. Slower to encode, growing browser support.".into(),
|
||||
Some(ImageFormat::Gif) => "GIF: Limited to 256 colors per frame. Supports basic animation and binary transparency. Best for simple graphics and short animations.".into(),
|
||||
Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, supports layers and rich metadata. Very large files. Not suitable for web.".into(),
|
||||
Some(ImageFormat::Jpeg) => "JPEG: Best for photographs. Lossy compression, \
|
||||
no transparency support. Universally compatible with all devices and browsers."
|
||||
.into(),
|
||||
Some(ImageFormat::Png) => "PNG: Best for graphics, screenshots, and logos. \
|
||||
Lossless compression, supports full transparency. Produces larger files \
|
||||
than JPEG or WebP."
|
||||
.into(),
|
||||
Some(ImageFormat::WebP) => "WebP: Modern format with excellent lossy and lossless \
|
||||
compression. Supports transparency and animation. Widely supported in modern browsers."
|
||||
.into(),
|
||||
Some(ImageFormat::Avif) => "AVIF: Next-generation format based on AV1 video codec. \
|
||||
Best compression ratios available. Supports transparency and HDR. Slower to encode, \
|
||||
growing browser support."
|
||||
.into(),
|
||||
Some(ImageFormat::Gif) => "GIF: Limited to 256 colors per frame. Supports basic \
|
||||
animation and binary transparency. Best for simple graphics and short animations."
|
||||
.into(),
|
||||
Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless compression, \
|
||||
supports layers and rich metadata. Very large files. Not suitable for web."
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the per-format mapping rows based on which file types are actually loaded.
|
||||
/// Uses a dedicated ListBox container that can be easily cleared and rebuilt.
|
||||
fn rebuild_format_mapping(
|
||||
list: >k::ListBox,
|
||||
loaded_files: &[PathBuf],
|
||||
job_config: &Rc<RefCell<crate::app::JobConfig>>,
|
||||
) {
|
||||
// Clear all existing rows from the list
|
||||
list.remove_all();
|
||||
|
||||
// Detect which file extensions are present in loaded files
|
||||
let mut seen_extensions: HashSet<String> = HashSet::new();
|
||||
for path in loaded_files {
|
||||
if let Some(ext) = path.extension()
|
||||
&& let Some(ext_str) = ext.to_str()
|
||||
{
|
||||
seen_extensions.insert(ext_str.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
if seen_extensions.is_empty() {
|
||||
// No files loaded yet - add a placeholder row
|
||||
let placeholder = adw::ActionRow::builder()
|
||||
.title("No files loaded")
|
||||
.subtitle("Load images first to configure per-format mappings")
|
||||
.build();
|
||||
placeholder.add_prefix(>k::Image::from_icon_name("dialog-information-symbolic"));
|
||||
list.append(&placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize extensions to canonical display names, maintaining a stable order
|
||||
let mut format_entries: Vec<(String, String)> = Vec::new(); // (canonical ext, display name)
|
||||
let ext_to_name: &[(&[&str], &str)] = &[
|
||||
(&["jpg", "jpeg"], "JPEG"),
|
||||
(&["png"], "PNG"),
|
||||
(&["webp"], "WebP"),
|
||||
(&["avif"], "AVIF"),
|
||||
(&["gif"], "GIF"),
|
||||
(&["tiff", "tif"], "TIFF"),
|
||||
(&["bmp"], "BMP"),
|
||||
(&["ico"], "ICO"),
|
||||
(&["hdr"], "HDR"),
|
||||
(&["pnm", "ppm", "pgm", "pbm"], "PNM/PPM"),
|
||||
(&["tga"], "TGA"),
|
||||
(&["heic", "heif"], "HEIC/HEIF"),
|
||||
(&["jxl"], "JXL (JPEG XL)"),
|
||||
(&["qoi"], "QOI"),
|
||||
(&["exr"], "EXR"),
|
||||
(&["ff", "farbfeld"], "Farbfeld"),
|
||||
];
|
||||
|
||||
let mut added_names: HashSet<String> = HashSet::new();
|
||||
for (exts, display_name) in ext_to_name {
|
||||
for ext in *exts {
|
||||
if seen_extensions.contains(*ext) && added_names.insert(display_name.to_string()) {
|
||||
// Use the first extension as canonical key
|
||||
format_entries.push((exts[0].to_string(), display_name.to_string()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also handle any unknown extensions in sorted order
|
||||
let mut unknown: Vec<String> = Vec::new();
|
||||
for ext in &seen_extensions {
|
||||
let known = ext_to_name
|
||||
.iter()
|
||||
.any(|(exts, _)| exts.contains(&ext.as_str()));
|
||||
if !known {
|
||||
unknown.push(ext.clone());
|
||||
}
|
||||
}
|
||||
unknown.sort();
|
||||
for ext in unknown {
|
||||
let upper = ext.to_uppercase();
|
||||
if added_names.insert(upper.clone()) {
|
||||
format_entries.push((ext, upper));
|
||||
}
|
||||
}
|
||||
|
||||
let cfg = job_config.borrow();
|
||||
|
||||
for (canonical_ext, display_name) in &format_entries {
|
||||
let combo = adw::ComboRow::builder()
|
||||
.title(format!("{} inputs", display_name))
|
||||
.subtitle(format!("Output format for {} source files", display_name))
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
combo.set_model(Some(>k::StringList::new(MAPPING_CHOICES)));
|
||||
combo.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
|
||||
// Restore saved selection if any
|
||||
let saved = cfg.format_mappings.get(canonical_ext).copied().unwrap_or(0);
|
||||
combo.set_selected(saved);
|
||||
|
||||
// Wire signal to save selection
|
||||
let jc = job_config.clone();
|
||||
let ext_key = canonical_ext.clone();
|
||||
combo.connect_selected_notify(move |row| {
|
||||
jc.borrow_mut()
|
||||
.format_mappings
|
||||
.insert(ext_key.clone(), row.selected());
|
||||
});
|
||||
|
||||
list.append(&combo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app::AppState;
|
||||
use crate::utils::format_size;
|
||||
|
||||
const THUMB_SIZE: i32 = 120;
|
||||
|
||||
@@ -183,7 +184,16 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
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"),
|
||||
Some(ext) => matches!(ext.as_str(),
|
||||
"jpg" | "jpeg" | "png" | "webp" | "avif" | "gif" | "tiff" | "tif" | "bmp"
|
||||
| "ico" | "hdr" | "exr" | "pnm" | "ppm" | "pgm" | "pbm" | "pam"
|
||||
| "tga" | "dds" | "ff" | "farbfeld" | "qoi"
|
||||
| "heic" | "heif" | "jxl"
|
||||
| "svg" | "svgz"
|
||||
| "raw" | "cr2" | "cr3" | "nef" | "nrw" | "arw" | "srf" | "sr2"
|
||||
| "orf" | "rw2" | "raf" | "dng" | "pef" | "srw" | "x3f"
|
||||
| "pcx" | "xpm" | "xbm" | "wbmp" | "jp2" | "j2k" | "jpf" | "jpx"
|
||||
),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
@@ -295,7 +305,7 @@ fn refresh_grid(
|
||||
}
|
||||
|
||||
/// Walk the widget tree to find our ListStore and count label, then rebuild
|
||||
fn rebuild_grid_model(
|
||||
pub fn rebuild_grid_model(
|
||||
widget: >k::Widget,
|
||||
loaded_files: &Rc<RefCell<Vec<PathBuf>>>,
|
||||
excluded: &Rc<RefCell<HashSet<PathBuf>>>,
|
||||
@@ -380,18 +390,6 @@ fn update_count_label(
|
||||
update_heading_label(widget, count, included_count, &size_str);
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// GObject wrapper for list store items
|
||||
// ------------------------------------------------------------------
|
||||
@@ -487,7 +485,7 @@ fn build_empty_state() -> gtk::Box {
|
||||
.build();
|
||||
|
||||
let formats_label = gtk::Label::builder()
|
||||
.label("Supported: JPEG, PNG, WebP, AVIF, GIF, TIFF, BMP")
|
||||
.label("Supports all common image formats including RAW")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(8)
|
||||
@@ -573,7 +571,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
// Factory: setup
|
||||
{
|
||||
factory.connect_setup(move |_factory, list_item| {
|
||||
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
|
||||
|
||||
let overlay = gtk::Overlay::builder()
|
||||
.width_request(THUMB_SIZE)
|
||||
@@ -656,13 +654,13 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
let excluded = state.excluded_files.clone();
|
||||
let loaded = state.loaded_files.clone();
|
||||
factory.connect_bind(move |_factory, list_item| {
|
||||
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let item = list_item.item().and_downcast::<ImageItem>().unwrap();
|
||||
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
|
||||
let Some(item) = list_item.item().and_downcast::<ImageItem>() else { return };
|
||||
let path = item.path().to_path_buf();
|
||||
|
||||
let vbox = list_item.child().and_downcast::<gtk::Box>().unwrap();
|
||||
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap();
|
||||
let name_label = overlay.next_sibling().and_downcast::<gtk::Label>().unwrap();
|
||||
let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
|
||||
let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
|
||||
let Some(name_label) = overlay.next_sibling().and_downcast::<gtk::Label>() else { return };
|
||||
|
||||
// Set filename
|
||||
let file_name = path.file_name()
|
||||
@@ -671,19 +669,36 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
name_label.set_label(file_name);
|
||||
|
||||
// Get the frame -> stack -> picture
|
||||
let frame = overlay.child().and_downcast::<gtk::Frame>().unwrap();
|
||||
let thumb_stack = frame.child().and_downcast::<gtk::Stack>().unwrap();
|
||||
let picture = thumb_stack.child_by_name("picture")
|
||||
.and_downcast::<gtk::Picture>().unwrap();
|
||||
let Some(frame) = overlay.child().and_downcast::<gtk::Frame>() else { return };
|
||||
let Some(thumb_stack) = frame.child().and_downcast::<gtk::Stack>() else { return };
|
||||
let Some(picture) = thumb_stack.child_by_name("picture")
|
||||
.and_downcast::<gtk::Picture>() else { return };
|
||||
|
||||
// Reset to placeholder
|
||||
thumb_stack.set_visible_child_name("placeholder");
|
||||
|
||||
// Bump bind generation so stale idle callbacks are ignored
|
||||
let bind_gen: u32 = unsafe {
|
||||
thumb_stack.data::<u32>("bind-gen")
|
||||
.map(|p| *p.as_ref())
|
||||
.unwrap_or(0)
|
||||
.wrapping_add(1)
|
||||
};
|
||||
unsafe { thumb_stack.set_data("bind-gen", bind_gen); }
|
||||
|
||||
// Load thumbnail asynchronously
|
||||
let thumb_stack_c = thumb_stack.clone();
|
||||
let picture_c = picture.clone();
|
||||
let path_c = path.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
let current: u32 = unsafe {
|
||||
thumb_stack_c.data::<u32>("bind-gen")
|
||||
.map(|p| *p.as_ref())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
if current != bind_gen {
|
||||
return; // Item was recycled; skip stale load
|
||||
}
|
||||
load_thumbnail(&path_c, &picture_c, &thumb_stack_c);
|
||||
});
|
||||
|
||||
@@ -725,9 +740,9 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
||||
// Factory: unbind - disconnect signal to avoid stale closures
|
||||
{
|
||||
factory.connect_unbind(move |_factory, list_item| {
|
||||
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let vbox = list_item.child().and_downcast::<gtk::Box>().unwrap();
|
||||
let overlay = vbox.first_child().and_downcast::<gtk::Overlay>().unwrap();
|
||||
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else { return };
|
||||
let Some(vbox) = list_item.child().and_downcast::<gtk::Box>() else { return };
|
||||
let Some(overlay) = vbox.first_child().and_downcast::<gtk::Overlay>() else { return };
|
||||
|
||||
if let Some(check) = find_check_button(overlay.upcast_ref::<gtk::Widget>()) {
|
||||
let handler: Option<glib::SignalHandlerId> = unsafe {
|
||||
|
||||
@@ -185,21 +185,23 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
let copyright_c = copyright_row.clone();
|
||||
photographer_check.connect_toggled(move |check| {
|
||||
if check.is_active() {
|
||||
let mut cfg = jc.borrow_mut();
|
||||
cfg.metadata_mode = MetadataMode::Custom;
|
||||
// Photographer: keep copyright + camera model, strip GPS + software
|
||||
cfg.strip_gps = true;
|
||||
cfg.strip_camera = false;
|
||||
cfg.strip_software = true;
|
||||
cfg.strip_timestamps = false;
|
||||
cfg.strip_copyright = false;
|
||||
// Update UI to match
|
||||
{
|
||||
let mut cfg = jc.borrow_mut();
|
||||
cfg.metadata_mode = MetadataMode::Custom;
|
||||
// Photographer: keep copyright + camera model, strip GPS + software
|
||||
cfg.strip_gps = true;
|
||||
cfg.strip_camera = false;
|
||||
cfg.strip_software = true;
|
||||
cfg.strip_timestamps = false;
|
||||
cfg.strip_copyright = false;
|
||||
}
|
||||
// Update UI to match (after dropping borrow to avoid re-entrancy)
|
||||
gps_c.set_active(true);
|
||||
camera_c.set_active(false);
|
||||
software_c.set_active(true);
|
||||
timestamps_c.set_active(false);
|
||||
copyright_c.set_active(false);
|
||||
cg.set_visible(true);
|
||||
cg.set_visible(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -258,14 +260,9 @@ pub fn build_metadata_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Metadata")
|
||||
.tag("step-metadata")
|
||||
.child(&clamp)
|
||||
.child(&scrolled)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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()
|
||||
@@ -79,6 +80,7 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
||||
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",
|
||||
@@ -87,6 +89,7 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
||||
"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);
|
||||
@@ -137,26 +140,9 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Output & Process")
|
||||
.tag("step-output")
|
||||
.child(&clamp)
|
||||
.child(&scrolled)
|
||||
.build()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,75 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
let cfg = state.job_config.borrow();
|
||||
|
||||
// === OUTER LAYOUT ===
|
||||
let outer = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
// --- Enable toggle (full width) ---
|
||||
let enable_group = adw::PreferencesGroup::builder()
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.build();
|
||||
|
||||
let cfg = state.job_config.borrow();
|
||||
|
||||
// Enable toggle
|
||||
let enable_row = adw::SwitchRow::builder()
|
||||
.title("Enable Watermark")
|
||||
.subtitle("Add text or image watermark to processed images")
|
||||
.active(cfg.watermark_enabled)
|
||||
.build();
|
||||
|
||||
let enable_group = adw::PreferencesGroup::new();
|
||||
enable_group.add(&enable_row);
|
||||
content.append(&enable_group);
|
||||
outer.append(&enable_group);
|
||||
|
||||
// Watermark type selection
|
||||
// === LEFT SIDE: Preview ===
|
||||
|
||||
let preview_picture = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Contain)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_picture.set_can_target(true);
|
||||
|
||||
let info_label = gtk::Label::builder()
|
||||
.label("No images loaded")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
let preview_frame = gtk::Frame::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_frame.set_child(Some(&preview_picture));
|
||||
|
||||
let preview_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_box.append(&preview_frame);
|
||||
preview_box.append(&info_label);
|
||||
|
||||
// === RIGHT SIDE: Controls (scrollable) ===
|
||||
|
||||
let controls = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.margin_start(12)
|
||||
.build();
|
||||
|
||||
// --- Watermark type ---
|
||||
let type_group = adw::PreferencesGroup::builder()
|
||||
.title("Watermark Type")
|
||||
.build();
|
||||
@@ -37,17 +77,19 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
let type_row = adw::ComboRow::builder()
|
||||
.title("Type")
|
||||
.subtitle("Choose text or image watermark")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let type_model = gtk::StringList::new(&["Text Watermark", "Image Watermark"]);
|
||||
type_row.set_model(Some(&type_model));
|
||||
type_row.set_model(Some(>k::StringList::new(&["Text Watermark", "Image Watermark"])));
|
||||
type_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
type_row.set_selected(if cfg.watermark_use_image { 1 } else { 0 });
|
||||
|
||||
type_group.add(&type_row);
|
||||
content.append(&type_group);
|
||||
controls.append(&type_group);
|
||||
|
||||
// Text watermark settings
|
||||
// --- Text watermark settings ---
|
||||
let text_group = adw::PreferencesGroup::builder()
|
||||
.title("Text Watermark")
|
||||
.visible(!cfg.watermark_use_image)
|
||||
.build();
|
||||
|
||||
let text_row = adw::EntryRow::builder()
|
||||
@@ -55,13 +97,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.text(&cfg.watermark_text)
|
||||
.build();
|
||||
|
||||
let font_size_row = adw::SpinRow::builder()
|
||||
.title("Font Size")
|
||||
.subtitle("Size in pixels")
|
||||
.adjustment(>k::Adjustment::new(cfg.watermark_font_size as f64, 8.0, 200.0, 1.0, 10.0, 0.0))
|
||||
.build();
|
||||
|
||||
// Font family picker
|
||||
let font_row = adw::ActionRow::builder()
|
||||
.title("Font Family")
|
||||
.subtitle("Choose a typeface for the watermark text")
|
||||
@@ -76,20 +111,24 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
// Set initial font if one was previously selected
|
||||
if !cfg.watermark_font_family.is_empty() {
|
||||
let desc = gtk::pango::FontDescription::from_string(&cfg.watermark_font_family);
|
||||
font_button.set_font_desc(&desc);
|
||||
}
|
||||
|
||||
font_row.add_suffix(&font_button);
|
||||
|
||||
let font_size_row = adw::SpinRow::builder()
|
||||
.title("Font Size")
|
||||
.subtitle("Size in pixels")
|
||||
.adjustment(>k::Adjustment::new(cfg.watermark_font_size as f64, 8.0, 200.0, 1.0, 10.0, 0.0))
|
||||
.build();
|
||||
|
||||
text_group.add(&text_row);
|
||||
text_group.add(&font_row);
|
||||
text_group.add(&font_size_row);
|
||||
content.append(&text_group);
|
||||
controls.append(&text_group);
|
||||
|
||||
// Image watermark settings
|
||||
// --- Image watermark settings ---
|
||||
let image_group = adw::PreferencesGroup::builder()
|
||||
.title("Image Watermark")
|
||||
.visible(cfg.watermark_use_image)
|
||||
@@ -111,14 +150,14 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.icon_name("document-open-symbolic")
|
||||
.tooltip_text("Choose logo image")
|
||||
.valign(gtk::Align::Center)
|
||||
.has_frame(false)
|
||||
.build();
|
||||
choose_image_button.add_css_class("flat");
|
||||
image_path_row.add_suffix(&choose_image_button);
|
||||
|
||||
image_group.add(&image_path_row);
|
||||
content.append(&image_group);
|
||||
controls.append(&image_group);
|
||||
|
||||
// Visual 9-point position grid
|
||||
// --- Position group with 3x3 grid ---
|
||||
let position_group = adw::PreferencesGroup::builder()
|
||||
.title("Position")
|
||||
.description("Choose where the watermark appears on the image")
|
||||
@@ -130,7 +169,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
"Bottom Left", "Bottom Center", "Bottom Right",
|
||||
];
|
||||
|
||||
// Build a 3x3 grid of toggle buttons
|
||||
let grid = gtk::Grid::builder()
|
||||
.row_spacing(4)
|
||||
.column_spacing(4)
|
||||
@@ -139,7 +177,12 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
|
||||
// Create a visual "image" area as background context
|
||||
// Frame styled to look like a miniature image
|
||||
let grid_outer = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
let grid_frame = gtk::Frame::builder()
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
@@ -148,6 +191,17 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
gtk::accessible::Property::Label("Watermark position grid. Select where the watermark appears on the image."),
|
||||
]);
|
||||
|
||||
// Image outline label above the grid
|
||||
let grid_title = gtk::Label::builder()
|
||||
.label("Image")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
grid_outer.append(&grid_title);
|
||||
grid_outer.append(&grid_frame);
|
||||
|
||||
let mut first_button: Option<gtk::ToggleButton> = None;
|
||||
let buttons: Vec<gtk::ToggleButton> = position_names.iter().enumerate().map(|(i, name)| {
|
||||
let btn = gtk::ToggleButton::builder()
|
||||
@@ -156,7 +210,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.height_request(48)
|
||||
.build();
|
||||
|
||||
// Use a dot icon for each position
|
||||
let icon = if i == cfg.watermark_position as usize {
|
||||
"radio-checked-symbolic"
|
||||
} else {
|
||||
@@ -178,163 +231,26 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
btn
|
||||
}).collect();
|
||||
|
||||
position_group.add(&grid_frame);
|
||||
position_group.add(&grid_outer);
|
||||
|
||||
// Position label showing current selection
|
||||
let position_label = gtk::Label::builder()
|
||||
.label(position_names[cfg.watermark_position as usize])
|
||||
.label(position_names.get(cfg.watermark_position as usize).copied().unwrap_or("Center"))
|
||||
.css_classes(["dim-label"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
position_group.add(&position_label);
|
||||
|
||||
content.append(&position_group);
|
||||
controls.append(&position_group);
|
||||
|
||||
// Live preview section
|
||||
let preview_group = adw::PreferencesGroup::builder()
|
||||
.title("Preview")
|
||||
.description("Shows how the watermark will appear on your image")
|
||||
.build();
|
||||
|
||||
// Overlay container for image + watermark text
|
||||
let preview_overlay = gtk::Overlay::builder()
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
let preview_picture = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Contain)
|
||||
.width_request(300)
|
||||
.height_request(200)
|
||||
.build();
|
||||
preview_picture.add_css_class("card");
|
||||
preview_overlay.set_child(Some(&preview_picture));
|
||||
|
||||
// Watermark text label overlay
|
||||
let watermark_label = gtk::Label::builder()
|
||||
.label(&cfg.watermark_text)
|
||||
.css_classes(["heading"])
|
||||
.opacity(cfg.watermark_opacity as f64)
|
||||
.build();
|
||||
preview_overlay.add_overlay(&watermark_label);
|
||||
|
||||
// Position the watermark label according to grid position
|
||||
fn set_watermark_alignment(label: >k::Label, position: u32) {
|
||||
let (h, v) = match position {
|
||||
0 => (gtk::Align::Start, gtk::Align::Start), // Top Left
|
||||
1 => (gtk::Align::Center, gtk::Align::Start), // Top Center
|
||||
2 => (gtk::Align::End, gtk::Align::Start), // Top Right
|
||||
3 => (gtk::Align::Start, gtk::Align::Center), // Middle Left
|
||||
4 => (gtk::Align::Center, gtk::Align::Center), // Center
|
||||
5 => (gtk::Align::End, gtk::Align::Center), // Middle Right
|
||||
6 => (gtk::Align::Start, gtk::Align::End), // Bottom Left
|
||||
7 => (gtk::Align::Center, gtk::Align::End), // Bottom Center
|
||||
_ => (gtk::Align::End, gtk::Align::End), // Bottom Right
|
||||
};
|
||||
label.set_halign(h);
|
||||
label.set_valign(v);
|
||||
label.set_margin_start(8);
|
||||
label.set_margin_end(8);
|
||||
label.set_margin_top(8);
|
||||
label.set_margin_bottom(8);
|
||||
}
|
||||
set_watermark_alignment(&watermark_label, cfg.watermark_position);
|
||||
|
||||
// Load first image from batch as preview background
|
||||
{
|
||||
let files = state.loaded_files.borrow();
|
||||
if let Some(first) = files.first() {
|
||||
preview_picture.set_filename(Some(first));
|
||||
}
|
||||
}
|
||||
|
||||
// "No preview" placeholder
|
||||
let no_preview_label = gtk::Label::builder()
|
||||
.label("Add images to see a preview")
|
||||
.css_classes(["dim-label"])
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
{
|
||||
let has_files = !state.loaded_files.borrow().is_empty();
|
||||
no_preview_label.set_visible(!has_files);
|
||||
preview_picture.set_visible(has_files);
|
||||
}
|
||||
|
||||
// Thumbnail strip for selecting preview image
|
||||
let wm_thumb_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(4)
|
||||
.build();
|
||||
{
|
||||
let files = state.loaded_files.borrow();
|
||||
let max_thumbs = files.len().min(10);
|
||||
for i in 0..max_thumbs {
|
||||
let pic = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Cover)
|
||||
.width_request(40)
|
||||
.height_request(40)
|
||||
.build();
|
||||
pic.set_filename(Some(&files[i]));
|
||||
let frame = gtk::Frame::builder()
|
||||
.child(&pic)
|
||||
.build();
|
||||
if i == 0 { frame.add_css_class("accent"); }
|
||||
|
||||
let btn = gtk::Button::builder()
|
||||
.child(&frame)
|
||||
.has_frame(false)
|
||||
.tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image"))
|
||||
.build();
|
||||
|
||||
let pp = preview_picture.clone();
|
||||
let path = files[i].clone();
|
||||
let tb = wm_thumb_box.clone();
|
||||
let current_idx = i;
|
||||
btn.connect_clicked(move |_| {
|
||||
pp.set_filename(Some(&path));
|
||||
let mut c = tb.first_child();
|
||||
let mut j = 0usize;
|
||||
while let Some(w) = c {
|
||||
if let Some(b) = w.downcast_ref::<gtk::Button>() {
|
||||
if let Some(f) = b.child().and_then(|c| c.downcast::<gtk::Frame>().ok()) {
|
||||
if j == current_idx { f.add_css_class("accent"); }
|
||||
else { f.remove_css_class("accent"); }
|
||||
}
|
||||
}
|
||||
c = w.next_sibling();
|
||||
j += 1;
|
||||
}
|
||||
});
|
||||
|
||||
wm_thumb_box.append(&btn);
|
||||
}
|
||||
wm_thumb_box.set_visible(max_thumbs > 1);
|
||||
}
|
||||
|
||||
let preview_stack = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
preview_stack.append(&preview_overlay);
|
||||
preview_stack.append(&wm_thumb_box);
|
||||
preview_stack.append(&no_preview_label);
|
||||
|
||||
preview_group.add(&preview_stack);
|
||||
content.append(&preview_group);
|
||||
|
||||
// Advanced options
|
||||
// --- Advanced options ---
|
||||
let advanced_group = adw::PreferencesGroup::builder()
|
||||
.title("Advanced")
|
||||
.build();
|
||||
|
||||
let advanced_expander = adw::ExpanderRow::builder()
|
||||
.title("Advanced Options")
|
||||
.subtitle("Opacity, rotation, tiling, margin")
|
||||
.subtitle("Color, opacity, rotation, tiling, margin, scale")
|
||||
.show_enable_switch(false)
|
||||
.expanded(state.is_section_expanded("watermark-advanced"))
|
||||
.build();
|
||||
@@ -369,37 +285,97 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.build();
|
||||
color_row.add_suffix(&color_button);
|
||||
|
||||
let opacity_row = adw::SpinRow::builder()
|
||||
// Opacity slider + reset
|
||||
let opacity_row = adw::ActionRow::builder()
|
||||
.title("Opacity")
|
||||
.subtitle("0.0 (invisible) to 1.0 (fully opaque)")
|
||||
.adjustment(>k::Adjustment::new(cfg.watermark_opacity as f64, 0.0, 1.0, 0.05, 0.1, 0.0))
|
||||
.digits(2)
|
||||
.subtitle(&format!("{}%", (cfg.watermark_opacity * 100.0).round() as i32))
|
||||
.build();
|
||||
let opacity_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 100.0, 1.0);
|
||||
opacity_scale.set_value((cfg.watermark_opacity * 100.0) as f64);
|
||||
opacity_scale.set_draw_value(false);
|
||||
opacity_scale.set_hexpand(false);
|
||||
opacity_scale.set_valign(gtk::Align::Center);
|
||||
opacity_scale.set_width_request(180);
|
||||
let opacity_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 50%")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
opacity_reset.set_sensitive((cfg.watermark_opacity - 0.5).abs() > 0.01);
|
||||
opacity_row.add_suffix(&opacity_scale);
|
||||
opacity_row.add_suffix(&opacity_reset);
|
||||
|
||||
let rotation_row = adw::ComboRow::builder()
|
||||
// Rotation slider + reset (-180 to +180)
|
||||
let rotation_row = adw::ActionRow::builder()
|
||||
.title("Rotation")
|
||||
.subtitle("Rotate the watermark")
|
||||
.subtitle(&format!("{} degrees", cfg.watermark_rotation))
|
||||
.build();
|
||||
let rotation_model = gtk::StringList::new(&["None", "45 degrees", "-45 degrees", "90 degrees"]);
|
||||
rotation_row.set_model(Some(&rotation_model));
|
||||
let rotation_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -180.0, 180.0, 1.0);
|
||||
rotation_scale.set_value(cfg.watermark_rotation as f64);
|
||||
rotation_scale.set_draw_value(false);
|
||||
rotation_scale.set_hexpand(false);
|
||||
rotation_scale.set_valign(gtk::Align::Center);
|
||||
rotation_scale.set_width_request(180);
|
||||
let rotation_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 0 degrees")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
rotation_reset.set_sensitive(cfg.watermark_rotation != 0);
|
||||
rotation_row.add_suffix(&rotation_scale);
|
||||
rotation_row.add_suffix(&rotation_reset);
|
||||
|
||||
// Tiled toggle
|
||||
let tiled_row = adw::SwitchRow::builder()
|
||||
.title("Tiled / Repeated")
|
||||
.subtitle("Repeat watermark across the entire image")
|
||||
.active(false)
|
||||
.active(cfg.watermark_tiled)
|
||||
.build();
|
||||
|
||||
let margin_row = adw::SpinRow::builder()
|
||||
// Margin slider + reset
|
||||
let margin_row = adw::ActionRow::builder()
|
||||
.title("Margin from Edges")
|
||||
.subtitle("Padding in pixels from image edges")
|
||||
.adjustment(>k::Adjustment::new(10.0, 0.0, 200.0, 1.0, 10.0, 0.0))
|
||||
.subtitle(&format!("{} px", cfg.watermark_margin))
|
||||
.build();
|
||||
let margin_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 200.0, 1.0);
|
||||
margin_scale.set_value(cfg.watermark_margin as f64);
|
||||
margin_scale.set_draw_value(false);
|
||||
margin_scale.set_hexpand(false);
|
||||
margin_scale.set_valign(gtk::Align::Center);
|
||||
margin_scale.set_width_request(180);
|
||||
let margin_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 10 px")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
margin_reset.set_sensitive(cfg.watermark_margin != 10);
|
||||
margin_row.add_suffix(&margin_scale);
|
||||
margin_row.add_suffix(&margin_reset);
|
||||
|
||||
let scale_row = adw::SpinRow::builder()
|
||||
// Scale slider + reset (only relevant for image watermarks)
|
||||
let scale_row = adw::ActionRow::builder()
|
||||
.title("Scale (% of image)")
|
||||
.subtitle("Watermark size relative to image")
|
||||
.adjustment(>k::Adjustment::new(20.0, 1.0, 100.0, 1.0, 5.0, 0.0))
|
||||
.subtitle(&format!("{}%", cfg.watermark_scale.round() as i32))
|
||||
.visible(cfg.watermark_use_image)
|
||||
.build();
|
||||
let scale_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 100.0, 1.0);
|
||||
scale_scale.set_value(cfg.watermark_scale as f64);
|
||||
scale_scale.set_draw_value(false);
|
||||
scale_scale.set_hexpand(false);
|
||||
scale_scale.set_valign(gtk::Align::Center);
|
||||
scale_scale.set_width_request(180);
|
||||
let scale_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 20%")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
scale_reset.set_sensitive((cfg.watermark_scale - 20.0).abs() > 0.5);
|
||||
scale_row.add_suffix(&scale_scale);
|
||||
scale_row.add_suffix(&scale_reset);
|
||||
|
||||
advanced_expander.add_row(&color_row);
|
||||
advanced_expander.add_row(&opacity_row);
|
||||
@@ -409,67 +385,274 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
advanced_expander.add_row(&scale_row);
|
||||
|
||||
advanced_group.add(&advanced_expander);
|
||||
content.append(&advanced_group);
|
||||
controls.append(&advanced_group);
|
||||
|
||||
// Scrollable controls
|
||||
let controls_scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.width_request(360)
|
||||
.child(&controls)
|
||||
.build();
|
||||
|
||||
// === Main layout: 60/40 side-by-side ===
|
||||
let main_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
preview_box.set_width_request(400);
|
||||
main_box.append(&preview_box);
|
||||
main_box.append(&controls_scrolled);
|
||||
outer.append(&main_box);
|
||||
|
||||
// Preview state
|
||||
let preview_index: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
||||
|
||||
drop(cfg);
|
||||
|
||||
// Wire signals
|
||||
// === Preview update closure ===
|
||||
let preview_gen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
let update_preview = {
|
||||
let files = state.loaded_files.clone();
|
||||
let jc = state.job_config.clone();
|
||||
let pic = preview_picture.clone();
|
||||
let info = info_label.clone();
|
||||
let pidx = preview_index.clone();
|
||||
let bind_gen = preview_gen.clone();
|
||||
|
||||
Rc::new(move || {
|
||||
let loaded = files.borrow();
|
||||
if loaded.is_empty() {
|
||||
info.set_label("No images loaded");
|
||||
pic.set_paintable(gtk::gdk::Paintable::NONE);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = pidx.get().min(loaded.len() - 1);
|
||||
pidx.set(idx);
|
||||
let path = loaded[idx].clone();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
|
||||
info.set_label(&format!("[{}/{}] {}", idx + 1, loaded.len(), name));
|
||||
|
||||
let cfg = jc.borrow();
|
||||
let wm_text = cfg.watermark_text.clone();
|
||||
let wm_use_image = cfg.watermark_use_image;
|
||||
let wm_image_path = cfg.watermark_image_path.clone();
|
||||
let wm_position = cfg.watermark_position;
|
||||
let wm_opacity = cfg.watermark_opacity;
|
||||
let wm_font_size = cfg.watermark_font_size;
|
||||
let wm_color = cfg.watermark_color;
|
||||
let wm_font_family = cfg.watermark_font_family.clone();
|
||||
let wm_tiled = cfg.watermark_tiled;
|
||||
let wm_margin = cfg.watermark_margin;
|
||||
let wm_scale = cfg.watermark_scale;
|
||||
let wm_rotation = cfg.watermark_rotation;
|
||||
let wm_enabled = cfg.watermark_enabled;
|
||||
drop(cfg);
|
||||
|
||||
let my_gen = bind_gen.get().wrapping_add(1);
|
||||
bind_gen.set(my_gen);
|
||||
let gen_check = bind_gen.clone();
|
||||
|
||||
let pic = pic.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Option<Vec<u8>>>();
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| -> Option<Vec<u8>> {
|
||||
let img = image::open(&path).ok()?;
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
|
||||
let img = if wm_enabled {
|
||||
let position = match wm_position {
|
||||
0 => pixstrip_core::operations::WatermarkPosition::TopLeft,
|
||||
1 => pixstrip_core::operations::WatermarkPosition::TopCenter,
|
||||
2 => pixstrip_core::operations::WatermarkPosition::TopRight,
|
||||
3 => pixstrip_core::operations::WatermarkPosition::MiddleLeft,
|
||||
4 => pixstrip_core::operations::WatermarkPosition::Center,
|
||||
5 => pixstrip_core::operations::WatermarkPosition::MiddleRight,
|
||||
6 => pixstrip_core::operations::WatermarkPosition::BottomLeft,
|
||||
7 => pixstrip_core::operations::WatermarkPosition::BottomCenter,
|
||||
_ => pixstrip_core::operations::WatermarkPosition::BottomRight,
|
||||
};
|
||||
let rotation = if wm_rotation != 0 {
|
||||
Some(pixstrip_core::operations::WatermarkRotation::Custom(wm_rotation as f32))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let wm_config = if wm_use_image {
|
||||
wm_image_path.as_ref().map(|p| {
|
||||
pixstrip_core::operations::WatermarkConfig::Image {
|
||||
path: p.clone(),
|
||||
position,
|
||||
opacity: wm_opacity,
|
||||
scale: wm_scale / 100.0,
|
||||
rotation,
|
||||
tiled: wm_tiled,
|
||||
margin: wm_margin,
|
||||
}
|
||||
})
|
||||
} else if !wm_text.is_empty() {
|
||||
Some(pixstrip_core::operations::WatermarkConfig::Text {
|
||||
text: wm_text,
|
||||
position,
|
||||
font_size: wm_font_size,
|
||||
opacity: wm_opacity,
|
||||
color: wm_color,
|
||||
font_family: if wm_font_family.is_empty() { None } else { Some(wm_font_family) },
|
||||
rotation,
|
||||
tiled: wm_tiled,
|
||||
margin: wm_margin,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(config) = wm_config {
|
||||
pixstrip_core::operations::watermark::apply_watermark(img, &config).ok()?
|
||||
} else {
|
||||
img
|
||||
}
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buf),
|
||||
image::ImageFormat::Png,
|
||||
).ok()?;
|
||||
Some(buf)
|
||||
})();
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||
if gen_check.get() != my_gen {
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(Some(bytes)) => {
|
||||
let gbytes = glib::Bytes::from(&bytes);
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
|
||||
pic.set_paintable(Some(&texture));
|
||||
}
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(None) => glib::ControlFlow::Break,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(_) => glib::ControlFlow::Break,
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Click-to-cycle on preview
|
||||
{
|
||||
let click = gtk::GestureClick::new();
|
||||
let pidx = preview_index.clone();
|
||||
let files = state.loaded_files.clone();
|
||||
let up = update_preview.clone();
|
||||
click.connect_released(move |_, _, _, _| {
|
||||
let loaded = files.borrow();
|
||||
if loaded.len() > 1 {
|
||||
let next = (pidx.get() + 1) % loaded.len();
|
||||
pidx.set(next);
|
||||
up();
|
||||
}
|
||||
});
|
||||
preview_picture.add_controller(click);
|
||||
}
|
||||
|
||||
// === Wire signals ===
|
||||
|
||||
// Enable toggle
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
enable_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().watermark_enabled = row.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Type selector
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let text_group_c = text_group.clone();
|
||||
let image_group_c = image_group.clone();
|
||||
let scale_row_c = scale_row.clone();
|
||||
let up = update_preview.clone();
|
||||
type_row.connect_selected_notify(move |row| {
|
||||
let use_image = row.selected() == 1;
|
||||
jc.borrow_mut().watermark_use_image = use_image;
|
||||
text_group_c.set_visible(!use_image);
|
||||
image_group_c.set_visible(use_image);
|
||||
scale_row_c.set_visible(use_image);
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Text entry (debounced to avoid preview on every keystroke)
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let wl = watermark_label.clone();
|
||||
let up = update_preview.clone();
|
||||
let debounce_id: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
text_row.connect_changed(move |row| {
|
||||
let text = row.text().to_string();
|
||||
wl.set_label(&text);
|
||||
jc.borrow_mut().watermark_text = text;
|
||||
let up = up.clone();
|
||||
let did = debounce_id.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(300), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Font family
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
font_size_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_font_size = row.value() as f32;
|
||||
});
|
||||
}
|
||||
// Wire font family picker
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
font_button.connect_font_desc_notify(move |btn| {
|
||||
if let Some(desc) = btn.font_desc() {
|
||||
if let Some(family) = desc.family() {
|
||||
jc.borrow_mut().watermark_font_family = family.to_string();
|
||||
up();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Wire position grid buttons
|
||||
|
||||
// Font size
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
font_size_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_font_size = row.value() as f32;
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Position grid buttons
|
||||
for (i, btn) in buttons.iter().enumerate() {
|
||||
let jc = state.job_config.clone();
|
||||
let label = position_label.clone();
|
||||
let names = position_names;
|
||||
let all_buttons = buttons.clone();
|
||||
let wl = watermark_label.clone();
|
||||
let up = update_preview.clone();
|
||||
btn.connect_toggled(move |b| {
|
||||
if b.is_active() {
|
||||
jc.borrow_mut().watermark_position = i as u32;
|
||||
label.set_label(names[i]);
|
||||
set_watermark_alignment(&wl, i as u32);
|
||||
// Update icons
|
||||
for (j, other) in all_buttons.iter().enumerate() {
|
||||
let icon_name = if j == i {
|
||||
"radio-checked-symbolic"
|
||||
@@ -478,21 +661,15 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
};
|
||||
other.set_child(Some(>k::Image::from_icon_name(icon_name)));
|
||||
}
|
||||
up();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Color picker
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let wl = watermark_label.clone();
|
||||
opacity_row.connect_value_notify(move |row| {
|
||||
let val = row.value() as f32;
|
||||
wl.set_opacity(val as f64);
|
||||
jc.borrow_mut().watermark_opacity = val;
|
||||
});
|
||||
}
|
||||
// Wire color picker
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
color_button.connect_rgba_notify(move |btn| {
|
||||
let c = btn.rgba();
|
||||
jc.borrow_mut().watermark_color = [
|
||||
@@ -501,44 +678,123 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
(c.blue() * 255.0) as u8,
|
||||
(c.alpha() * 255.0) as u8,
|
||||
];
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire tiled toggle
|
||||
|
||||
// Opacity slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let row = opacity_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = opacity_reset.clone();
|
||||
opacity_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
let opacity = val as f32 / 100.0;
|
||||
jc.borrow_mut().watermark_opacity = opacity;
|
||||
row.set_subtitle(&format!("{}%", val));
|
||||
rst.set_sensitive(val != 50);
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = opacity_scale.clone();
|
||||
opacity_reset.connect_clicked(move |_| {
|
||||
scale.set_value(50.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Rotation slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let row = rotation_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = rotation_reset.clone();
|
||||
rotation_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().watermark_rotation = val;
|
||||
row.set_subtitle(&format!("{} degrees", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = rotation_scale.clone();
|
||||
rotation_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Tiled toggle
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
tiled_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().watermark_tiled = row.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire margin spinner
|
||||
|
||||
// Margin slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
margin_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_margin = row.value() as u32;
|
||||
let row = margin_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = margin_reset.clone();
|
||||
margin_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().watermark_margin = val as u32;
|
||||
row.set_subtitle(&format!("{} px", val));
|
||||
rst.set_sensitive(val != 10);
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire scale spinner
|
||||
{
|
||||
let scale = margin_scale.clone();
|
||||
margin_reset.connect_clicked(move |_| {
|
||||
scale.set_value(10.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Scale slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
scale_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_scale = row.value() as f32;
|
||||
let row = scale_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = scale_reset.clone();
|
||||
scale_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().watermark_scale = val as f32;
|
||||
row.set_subtitle(&format!("{}%", val));
|
||||
rst.set_sensitive((val - 20).abs() > 0);
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire image chooser button
|
||||
{
|
||||
let scale = scale_scale.clone();
|
||||
scale_reset.connect_clicked(move |_| {
|
||||
scale.set_value(20.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Image chooser button
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let path_row = image_path_row.clone();
|
||||
let up = update_preview.clone();
|
||||
choose_image_button.connect_clicked(move |btn| {
|
||||
let jc = jc.clone();
|
||||
let path_row = path_row.clone();
|
||||
let up = up.clone();
|
||||
let dialog = gtk::FileDialog::builder()
|
||||
.title("Choose Watermark Image")
|
||||
.modal(true)
|
||||
.build();
|
||||
|
||||
let filter = gtk::FileFilter::new();
|
||||
filter.set_name(Some("PNG images"));
|
||||
filter.set_name(Some("Images"));
|
||||
filter.add_mime_type("image/png");
|
||||
filter.add_mime_type("image/svg+xml");
|
||||
let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
|
||||
filters.append(&filter);
|
||||
dialog.set_filters(Some(&filters));
|
||||
@@ -550,22 +806,29 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
{
|
||||
path_row.set_subtitle(&path.display().to_string());
|
||||
jc.borrow_mut().watermark_image_path = Some(path);
|
||||
up();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Watermark")
|
||||
.tag("step-watermark")
|
||||
.child(&clamp)
|
||||
.build()
|
||||
.child(&outer)
|
||||
.build();
|
||||
|
||||
// Refresh preview and sensitivity when navigating to this page
|
||||
{
|
||||
let up = update_preview.clone();
|
||||
let lf = state.loaded_files.clone();
|
||||
let ctrl = controls.clone();
|
||||
page.connect_map(move |_| {
|
||||
ctrl.set_sensitive(!lf.borrow().is_empty());
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
@@ -26,38 +26,29 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
|
||||
let builtin_flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::Single)
|
||||
.max_children_per_line(4)
|
||||
.max_children_per_line(5)
|
||||
.min_children_per_line(2)
|
||||
.row_spacing(8)
|
||||
.column_spacing(8)
|
||||
.homogeneous(true)
|
||||
.build();
|
||||
|
||||
// Custom card is always first (index 0)
|
||||
let custom_card = build_custom_card();
|
||||
builtin_flow.append(&custom_card);
|
||||
|
||||
// Then all built-in presets (indices 1..=9)
|
||||
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, 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);
|
||||
|
||||
// Custom workflow section
|
||||
// Custom workflow section (hidden until Custom card is selected)
|
||||
let custom_group = adw::PreferencesGroup::builder()
|
||||
.title("Custom Workflow")
|
||||
.description("Choose which operations to include, then click Next")
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
let resize_check = adw::SwitchRow::builder()
|
||||
@@ -110,6 +101,36 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
custom_group.add(&watermark_check);
|
||||
custom_group.add(&rename_check);
|
||||
|
||||
// When a card is activated: Custom shows toggles, presets apply config and advance
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let custom_group_c = custom_group.clone();
|
||||
builtin_flow.connect_child_activated(move |flow, child| {
|
||||
let idx = child.index() as usize;
|
||||
if idx == 0 {
|
||||
// Custom card - show toggles, don't advance, enable step-by-step mode
|
||||
jc.borrow_mut().preset_mode = false;
|
||||
custom_group_c.set_visible(true);
|
||||
} else {
|
||||
// Built-in preset - apply config, skip intermediate steps, advance
|
||||
custom_group_c.set_visible(false);
|
||||
if let Some(preset) = builtins.get(idx - 1) {
|
||||
let mut cfg = jc.borrow_mut();
|
||||
apply_preset_to_config(&mut cfg, preset);
|
||||
cfg.preset_mode = true;
|
||||
}
|
||||
flow.activate_action("win.next-step", None).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let builtin_clamp = adw::Clamp::builder()
|
||||
.maximum_size(1200)
|
||||
.child(&builtin_flow)
|
||||
.build();
|
||||
builtin_group.add(&builtin_clamp);
|
||||
content.append(&builtin_group);
|
||||
|
||||
// Wire custom operation toggles to job config
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
@@ -154,86 +175,18 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
});
|
||||
}
|
||||
|
||||
content.append(&custom_group);
|
||||
|
||||
// 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));
|
||||
|
||||
// Export button
|
||||
let export_btn = gtk::Button::builder()
|
||||
.icon_name("document-save-as-symbolic")
|
||||
.tooltip_text("Export preset")
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
export_btn.add_css_class("flat");
|
||||
let preset_for_export = preset.clone();
|
||||
export_btn.connect_clicked(move |btn| {
|
||||
let p = preset_for_export.clone();
|
||||
let dialog = gtk::FileDialog::builder()
|
||||
.title("Export Preset")
|
||||
.initial_name(&format!("{}.pixstrip-preset", p.name))
|
||||
.modal(true)
|
||||
.build();
|
||||
if let Some(window) = btn.root().and_then(|r| r.downcast::<gtk::Window>().ok()) {
|
||||
dialog.save(Some(&window), gtk::gio::Cancellable::NONE, move |result| {
|
||||
if let Ok(file) = result
|
||||
&& let Some(path) = file.path()
|
||||
{
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
let _ = store.export_to_file(&p, &path);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
row.add_suffix(&export_btn);
|
||||
|
||||
// Delete button
|
||||
let delete_btn = gtk::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Delete preset")
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
delete_btn.add_css_class("flat");
|
||||
delete_btn.add_css_class("error");
|
||||
let pname = preset.name.clone();
|
||||
let row_ref = row.clone();
|
||||
let group_ref = user_group.clone();
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
let _ = store.delete(&pname);
|
||||
group_ref.remove(&row_ref);
|
||||
});
|
||||
row.add_suffix(&delete_btn);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Container for dynamically-rebuilt user preset rows
|
||||
let user_rows_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
.build();
|
||||
user_group.add(&user_rows_box);
|
||||
|
||||
let import_button = gtk::Button::builder()
|
||||
.label("Import Preset")
|
||||
@@ -243,14 +196,10 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
import_button.add_css_class("flat");
|
||||
user_group.add(&import_button);
|
||||
content.append(&user_group);
|
||||
content.append(&custom_group);
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(800)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
// Drop target for .pixstrip-preset files
|
||||
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
|
||||
let jc_drop = state.job_config.clone();
|
||||
@@ -269,13 +218,108 @@ pub fn build_workflow_page(state: &AppState) -> adw::NavigationPage {
|
||||
}
|
||||
false
|
||||
});
|
||||
clamp.add_controller(drop_target);
|
||||
scrolled.add_controller(drop_target);
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Choose a Workflow")
|
||||
.tag("step-workflow")
|
||||
.child(&clamp)
|
||||
.build()
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
// Refresh user presets every time this page is shown
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let rows_box = user_rows_box.clone();
|
||||
page.connect_map(move |_| {
|
||||
// Clear existing rows
|
||||
while let Some(child) = rows_box.first_child() {
|
||||
rows_box.remove(&child);
|
||||
}
|
||||
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
if let Ok(presets) = store.list() {
|
||||
for preset in &presets {
|
||||
if !preset.is_custom {
|
||||
continue;
|
||||
}
|
||||
let list_box = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(["boxed-list"])
|
||||
.build();
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&preset.name)
|
||||
.subtitle(&preset.description)
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_prefix(>k::Image::from_icon_name(&preset.icon));
|
||||
|
||||
// Export button
|
||||
let export_btn = gtk::Button::builder()
|
||||
.icon_name("document-save-as-symbolic")
|
||||
.tooltip_text("Export preset")
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
export_btn.add_css_class("flat");
|
||||
let preset_for_export = preset.clone();
|
||||
export_btn.connect_clicked(move |btn| {
|
||||
let p = preset_for_export.clone();
|
||||
let dialog = gtk::FileDialog::builder()
|
||||
.title("Export Preset")
|
||||
.initial_name(&format!("{}.pixstrip-preset", p.name))
|
||||
.modal(true)
|
||||
.build();
|
||||
if let Some(window) = btn.root().and_then(|r| r.downcast::<gtk::Window>().ok()) {
|
||||
dialog.save(Some(&window), gtk::gio::Cancellable::NONE, move |result| {
|
||||
if let Ok(file) = result
|
||||
&& let Some(path) = file.path()
|
||||
{
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
let _ = store.export_to_file(&p, &path);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
row.add_suffix(&export_btn);
|
||||
|
||||
// Delete button
|
||||
let delete_btn = gtk::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Delete preset")
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
delete_btn.add_css_class("flat");
|
||||
delete_btn.add_css_class("error");
|
||||
let pname = preset.name.clone();
|
||||
let list_box_ref = list_box.clone();
|
||||
let rows_box_ref = rows_box.clone();
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
let _ = store.delete(&pname);
|
||||
rows_box_ref.remove(&list_box_ref);
|
||||
});
|
||||
row.add_suffix(&delete_btn);
|
||||
|
||||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
|
||||
let jc2 = jc.clone();
|
||||
let p = preset.clone();
|
||||
row.connect_activated(move |r| {
|
||||
let mut cfg = jc2.borrow_mut();
|
||||
apply_preset_to_config(&mut cfg, &p);
|
||||
cfg.preset_mode = true;
|
||||
drop(cfg);
|
||||
r.activate_action("win.next-step", None).ok();
|
||||
});
|
||||
|
||||
list_box.append(&row);
|
||||
rows_box.append(&list_box);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) {
|
||||
@@ -378,12 +422,12 @@ fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
fn build_custom_card() -> gtk::Box {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Start)
|
||||
.hexpand(true)
|
||||
.vexpand(false)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.set_size_request(180, 120);
|
||||
@@ -391,10 +435,58 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(16)
|
||||
.margin_bottom(16)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name("emblem-system-symbolic")
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
|
||||
let name_label = gtk::Label::builder()
|
||||
.label("Custom")
|
||||
.css_classes(["heading"])
|
||||
.build();
|
||||
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label("Pick and choose operations")
|
||||
.css_classes(["caption", "dim-label"])
|
||||
.wrap(true)
|
||||
.justify(gtk::Justification::Center)
|
||||
.max_width_chars(20)
|
||||
.build();
|
||||
|
||||
inner.append(&icon);
|
||||
inner.append(&name_label);
|
||||
inner.append(&desc_label);
|
||||
card.append(&inner);
|
||||
|
||||
card
|
||||
}
|
||||
|
||||
fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.hexpand(true)
|
||||
.vexpand(false)
|
||||
.build();
|
||||
card.add_css_class("card");
|
||||
card.set_size_request(180, 120);
|
||||
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.vexpand(true)
|
||||
@@ -425,4 +517,3 @@ fn build_preset_card(preset: &Preset) -> gtk::Box {
|
||||
|
||||
card
|
||||
}
|
||||
|
||||
|
||||
11
pixstrip-gtk/src/utils.rs
Normal file
11
pixstrip-gtk/src/utils.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub 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))
|
||||
}
|
||||
}
|
||||
@@ -49,19 +49,6 @@ impl WizardState {
|
||||
pub fn is_last_step(&self) -> bool {
|
||||
self.current_step == self.total_steps - 1
|
||||
}
|
||||
|
||||
pub fn go_next(&mut self) {
|
||||
if self.can_go_next() {
|
||||
self.current_step += 1;
|
||||
self.visited[self.current_step] = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn go_back(&mut self) {
|
||||
if self.can_go_back() {
|
||||
self.current_step -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_wizard_pages(state: &AppState) -> Vec<adw::NavigationPage> {
|
||||
|
||||
Reference in New Issue
Block a user