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:
2026-03-07 19:47:23 +02:00
parent 270a7db60d
commit b432cc7431
44 changed files with 5748 additions and 2221 deletions

View File

@@ -9,3 +9,4 @@ pixstrip-core = { workspace = true }
gtk = { package = "gtk4", version = "0.11.0", features = ["gnome_49"] }
adw = { package = "libadwaita", version = "0.9.1", features = ["v1_8"] }
image = "0.25"
regex = "1"

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ mod settings;
mod step_indicator;
mod steps;
mod tutorial;
pub(crate) mod utils;
mod welcome;
mod wizard;

View File

@@ -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()
}

View File

@@ -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()

View File

@@ -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: &gtk::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");
}
}
}

View File

@@ -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
}

View File

@@ -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(&gtk::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(&gtk::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(&gtk::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(&gtk::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

View File

@@ -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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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: &gtk::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(&gtk::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(&gtk::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);
}
}

View File

@@ -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: &gtk::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 {

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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(&gtk::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(&gtk::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(&gtk::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: &gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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
}

View File

@@ -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(&gtk::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(&gtk::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(&gtk::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(&gtk::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
View 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))
}
}

View File

@@ -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> {