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:
@@ -1,35 +1,75 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
let cfg = state.job_config.borrow();
|
||||
|
||||
// === OUTER LAYOUT ===
|
||||
let outer = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
// --- Enable toggle (full width) ---
|
||||
let enable_group = adw::PreferencesGroup::builder()
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.build();
|
||||
|
||||
let cfg = state.job_config.borrow();
|
||||
|
||||
// Enable toggle
|
||||
let enable_row = adw::SwitchRow::builder()
|
||||
.title("Enable Watermark")
|
||||
.subtitle("Add text or image watermark to processed images")
|
||||
.active(cfg.watermark_enabled)
|
||||
.build();
|
||||
|
||||
let enable_group = adw::PreferencesGroup::new();
|
||||
enable_group.add(&enable_row);
|
||||
content.append(&enable_group);
|
||||
outer.append(&enable_group);
|
||||
|
||||
// Watermark type selection
|
||||
// === LEFT SIDE: Preview ===
|
||||
|
||||
let preview_picture = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Contain)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_picture.set_can_target(true);
|
||||
|
||||
let info_label = gtk::Label::builder()
|
||||
.label("No images loaded")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
let preview_frame = gtk::Frame::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_frame.set_child(Some(&preview_picture));
|
||||
|
||||
let preview_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
preview_box.append(&preview_frame);
|
||||
preview_box.append(&info_label);
|
||||
|
||||
// === RIGHT SIDE: Controls (scrollable) ===
|
||||
|
||||
let controls = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.margin_start(12)
|
||||
.build();
|
||||
|
||||
// --- Watermark type ---
|
||||
let type_group = adw::PreferencesGroup::builder()
|
||||
.title("Watermark Type")
|
||||
.build();
|
||||
@@ -37,17 +77,19 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
let type_row = adw::ComboRow::builder()
|
||||
.title("Type")
|
||||
.subtitle("Choose text or image watermark")
|
||||
.use_subtitle(true)
|
||||
.build();
|
||||
let type_model = gtk::StringList::new(&["Text Watermark", "Image Watermark"]);
|
||||
type_row.set_model(Some(&type_model));
|
||||
type_row.set_model(Some(>k::StringList::new(&["Text Watermark", "Image Watermark"])));
|
||||
type_row.set_list_factory(Some(&super::full_text_list_factory()));
|
||||
type_row.set_selected(if cfg.watermark_use_image { 1 } else { 0 });
|
||||
|
||||
type_group.add(&type_row);
|
||||
content.append(&type_group);
|
||||
controls.append(&type_group);
|
||||
|
||||
// Text watermark settings
|
||||
// --- Text watermark settings ---
|
||||
let text_group = adw::PreferencesGroup::builder()
|
||||
.title("Text Watermark")
|
||||
.visible(!cfg.watermark_use_image)
|
||||
.build();
|
||||
|
||||
let text_row = adw::EntryRow::builder()
|
||||
@@ -55,13 +97,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.text(&cfg.watermark_text)
|
||||
.build();
|
||||
|
||||
let font_size_row = adw::SpinRow::builder()
|
||||
.title("Font Size")
|
||||
.subtitle("Size in pixels")
|
||||
.adjustment(>k::Adjustment::new(cfg.watermark_font_size as f64, 8.0, 200.0, 1.0, 10.0, 0.0))
|
||||
.build();
|
||||
|
||||
// Font family picker
|
||||
let font_row = adw::ActionRow::builder()
|
||||
.title("Font Family")
|
||||
.subtitle("Choose a typeface for the watermark text")
|
||||
@@ -76,20 +111,24 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
// Set initial font if one was previously selected
|
||||
if !cfg.watermark_font_family.is_empty() {
|
||||
let desc = gtk::pango::FontDescription::from_string(&cfg.watermark_font_family);
|
||||
font_button.set_font_desc(&desc);
|
||||
}
|
||||
|
||||
font_row.add_suffix(&font_button);
|
||||
|
||||
let font_size_row = adw::SpinRow::builder()
|
||||
.title("Font Size")
|
||||
.subtitle("Size in pixels")
|
||||
.adjustment(>k::Adjustment::new(cfg.watermark_font_size as f64, 8.0, 200.0, 1.0, 10.0, 0.0))
|
||||
.build();
|
||||
|
||||
text_group.add(&text_row);
|
||||
text_group.add(&font_row);
|
||||
text_group.add(&font_size_row);
|
||||
content.append(&text_group);
|
||||
controls.append(&text_group);
|
||||
|
||||
// Image watermark settings
|
||||
// --- Image watermark settings ---
|
||||
let image_group = adw::PreferencesGroup::builder()
|
||||
.title("Image Watermark")
|
||||
.visible(cfg.watermark_use_image)
|
||||
@@ -111,14 +150,14 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.icon_name("document-open-symbolic")
|
||||
.tooltip_text("Choose logo image")
|
||||
.valign(gtk::Align::Center)
|
||||
.has_frame(false)
|
||||
.build();
|
||||
choose_image_button.add_css_class("flat");
|
||||
image_path_row.add_suffix(&choose_image_button);
|
||||
|
||||
image_group.add(&image_path_row);
|
||||
content.append(&image_group);
|
||||
controls.append(&image_group);
|
||||
|
||||
// Visual 9-point position grid
|
||||
// --- Position group with 3x3 grid ---
|
||||
let position_group = adw::PreferencesGroup::builder()
|
||||
.title("Position")
|
||||
.description("Choose where the watermark appears on the image")
|
||||
@@ -130,7 +169,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
"Bottom Left", "Bottom Center", "Bottom Right",
|
||||
];
|
||||
|
||||
// Build a 3x3 grid of toggle buttons
|
||||
let grid = gtk::Grid::builder()
|
||||
.row_spacing(4)
|
||||
.column_spacing(4)
|
||||
@@ -139,7 +177,12 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
|
||||
// Create a visual "image" area as background context
|
||||
// Frame styled to look like a miniature image
|
||||
let grid_outer = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
let grid_frame = gtk::Frame::builder()
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
@@ -148,6 +191,17 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
gtk::accessible::Property::Label("Watermark position grid. Select where the watermark appears on the image."),
|
||||
]);
|
||||
|
||||
// Image outline label above the grid
|
||||
let grid_title = gtk::Label::builder()
|
||||
.label("Image")
|
||||
.css_classes(["dim-label", "caption"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
grid_outer.append(&grid_title);
|
||||
grid_outer.append(&grid_frame);
|
||||
|
||||
let mut first_button: Option<gtk::ToggleButton> = None;
|
||||
let buttons: Vec<gtk::ToggleButton> = position_names.iter().enumerate().map(|(i, name)| {
|
||||
let btn = gtk::ToggleButton::builder()
|
||||
@@ -156,7 +210,6 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.height_request(48)
|
||||
.build();
|
||||
|
||||
// Use a dot icon for each position
|
||||
let icon = if i == cfg.watermark_position as usize {
|
||||
"radio-checked-symbolic"
|
||||
} else {
|
||||
@@ -178,163 +231,26 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
btn
|
||||
}).collect();
|
||||
|
||||
position_group.add(&grid_frame);
|
||||
position_group.add(&grid_outer);
|
||||
|
||||
// Position label showing current selection
|
||||
let position_label = gtk::Label::builder()
|
||||
.label(position_names[cfg.watermark_position as usize])
|
||||
.label(position_names.get(cfg.watermark_position as usize).copied().unwrap_or("Center"))
|
||||
.css_classes(["dim-label"])
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
position_group.add(&position_label);
|
||||
|
||||
content.append(&position_group);
|
||||
controls.append(&position_group);
|
||||
|
||||
// Live preview section
|
||||
let preview_group = adw::PreferencesGroup::builder()
|
||||
.title("Preview")
|
||||
.description("Shows how the watermark will appear on your image")
|
||||
.build();
|
||||
|
||||
// Overlay container for image + watermark text
|
||||
let preview_overlay = gtk::Overlay::builder()
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
|
||||
let preview_picture = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Contain)
|
||||
.width_request(300)
|
||||
.height_request(200)
|
||||
.build();
|
||||
preview_picture.add_css_class("card");
|
||||
preview_overlay.set_child(Some(&preview_picture));
|
||||
|
||||
// Watermark text label overlay
|
||||
let watermark_label = gtk::Label::builder()
|
||||
.label(&cfg.watermark_text)
|
||||
.css_classes(["heading"])
|
||||
.opacity(cfg.watermark_opacity as f64)
|
||||
.build();
|
||||
preview_overlay.add_overlay(&watermark_label);
|
||||
|
||||
// Position the watermark label according to grid position
|
||||
fn set_watermark_alignment(label: >k::Label, position: u32) {
|
||||
let (h, v) = match position {
|
||||
0 => (gtk::Align::Start, gtk::Align::Start), // Top Left
|
||||
1 => (gtk::Align::Center, gtk::Align::Start), // Top Center
|
||||
2 => (gtk::Align::End, gtk::Align::Start), // Top Right
|
||||
3 => (gtk::Align::Start, gtk::Align::Center), // Middle Left
|
||||
4 => (gtk::Align::Center, gtk::Align::Center), // Center
|
||||
5 => (gtk::Align::End, gtk::Align::Center), // Middle Right
|
||||
6 => (gtk::Align::Start, gtk::Align::End), // Bottom Left
|
||||
7 => (gtk::Align::Center, gtk::Align::End), // Bottom Center
|
||||
_ => (gtk::Align::End, gtk::Align::End), // Bottom Right
|
||||
};
|
||||
label.set_halign(h);
|
||||
label.set_valign(v);
|
||||
label.set_margin_start(8);
|
||||
label.set_margin_end(8);
|
||||
label.set_margin_top(8);
|
||||
label.set_margin_bottom(8);
|
||||
}
|
||||
set_watermark_alignment(&watermark_label, cfg.watermark_position);
|
||||
|
||||
// Load first image from batch as preview background
|
||||
{
|
||||
let files = state.loaded_files.borrow();
|
||||
if let Some(first) = files.first() {
|
||||
preview_picture.set_filename(Some(first));
|
||||
}
|
||||
}
|
||||
|
||||
// "No preview" placeholder
|
||||
let no_preview_label = gtk::Label::builder()
|
||||
.label("Add images to see a preview")
|
||||
.css_classes(["dim-label"])
|
||||
.halign(gtk::Align::Center)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
{
|
||||
let has_files = !state.loaded_files.borrow().is_empty();
|
||||
no_preview_label.set_visible(!has_files);
|
||||
preview_picture.set_visible(has_files);
|
||||
}
|
||||
|
||||
// Thumbnail strip for selecting preview image
|
||||
let wm_thumb_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.halign(gtk::Align::Center)
|
||||
.margin_top(4)
|
||||
.build();
|
||||
{
|
||||
let files = state.loaded_files.borrow();
|
||||
let max_thumbs = files.len().min(10);
|
||||
for i in 0..max_thumbs {
|
||||
let pic = gtk::Picture::builder()
|
||||
.content_fit(gtk::ContentFit::Cover)
|
||||
.width_request(40)
|
||||
.height_request(40)
|
||||
.build();
|
||||
pic.set_filename(Some(&files[i]));
|
||||
let frame = gtk::Frame::builder()
|
||||
.child(&pic)
|
||||
.build();
|
||||
if i == 0 { frame.add_css_class("accent"); }
|
||||
|
||||
let btn = gtk::Button::builder()
|
||||
.child(&frame)
|
||||
.has_frame(false)
|
||||
.tooltip_text(files[i].file_name().and_then(|n| n.to_str()).unwrap_or("image"))
|
||||
.build();
|
||||
|
||||
let pp = preview_picture.clone();
|
||||
let path = files[i].clone();
|
||||
let tb = wm_thumb_box.clone();
|
||||
let current_idx = i;
|
||||
btn.connect_clicked(move |_| {
|
||||
pp.set_filename(Some(&path));
|
||||
let mut c = tb.first_child();
|
||||
let mut j = 0usize;
|
||||
while let Some(w) = c {
|
||||
if let Some(b) = w.downcast_ref::<gtk::Button>() {
|
||||
if let Some(f) = b.child().and_then(|c| c.downcast::<gtk::Frame>().ok()) {
|
||||
if j == current_idx { f.add_css_class("accent"); }
|
||||
else { f.remove_css_class("accent"); }
|
||||
}
|
||||
}
|
||||
c = w.next_sibling();
|
||||
j += 1;
|
||||
}
|
||||
});
|
||||
|
||||
wm_thumb_box.append(&btn);
|
||||
}
|
||||
wm_thumb_box.set_visible(max_thumbs > 1);
|
||||
}
|
||||
|
||||
let preview_stack = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
preview_stack.append(&preview_overlay);
|
||||
preview_stack.append(&wm_thumb_box);
|
||||
preview_stack.append(&no_preview_label);
|
||||
|
||||
preview_group.add(&preview_stack);
|
||||
content.append(&preview_group);
|
||||
|
||||
// Advanced options
|
||||
// --- Advanced options ---
|
||||
let advanced_group = adw::PreferencesGroup::builder()
|
||||
.title("Advanced")
|
||||
.build();
|
||||
|
||||
let advanced_expander = adw::ExpanderRow::builder()
|
||||
.title("Advanced Options")
|
||||
.subtitle("Opacity, rotation, tiling, margin")
|
||||
.subtitle("Color, opacity, rotation, tiling, margin, scale")
|
||||
.show_enable_switch(false)
|
||||
.expanded(state.is_section_expanded("watermark-advanced"))
|
||||
.build();
|
||||
@@ -369,37 +285,97 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
.build();
|
||||
color_row.add_suffix(&color_button);
|
||||
|
||||
let opacity_row = adw::SpinRow::builder()
|
||||
// Opacity slider + reset
|
||||
let opacity_row = adw::ActionRow::builder()
|
||||
.title("Opacity")
|
||||
.subtitle("0.0 (invisible) to 1.0 (fully opaque)")
|
||||
.adjustment(>k::Adjustment::new(cfg.watermark_opacity as f64, 0.0, 1.0, 0.05, 0.1, 0.0))
|
||||
.digits(2)
|
||||
.subtitle(&format!("{}%", (cfg.watermark_opacity * 100.0).round() as i32))
|
||||
.build();
|
||||
let opacity_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 100.0, 1.0);
|
||||
opacity_scale.set_value((cfg.watermark_opacity * 100.0) as f64);
|
||||
opacity_scale.set_draw_value(false);
|
||||
opacity_scale.set_hexpand(false);
|
||||
opacity_scale.set_valign(gtk::Align::Center);
|
||||
opacity_scale.set_width_request(180);
|
||||
let opacity_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 50%")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
opacity_reset.set_sensitive((cfg.watermark_opacity - 0.5).abs() > 0.01);
|
||||
opacity_row.add_suffix(&opacity_scale);
|
||||
opacity_row.add_suffix(&opacity_reset);
|
||||
|
||||
let rotation_row = adw::ComboRow::builder()
|
||||
// Rotation slider + reset (-180 to +180)
|
||||
let rotation_row = adw::ActionRow::builder()
|
||||
.title("Rotation")
|
||||
.subtitle("Rotate the watermark")
|
||||
.subtitle(&format!("{} degrees", cfg.watermark_rotation))
|
||||
.build();
|
||||
let rotation_model = gtk::StringList::new(&["None", "45 degrees", "-45 degrees", "90 degrees"]);
|
||||
rotation_row.set_model(Some(&rotation_model));
|
||||
let rotation_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, -180.0, 180.0, 1.0);
|
||||
rotation_scale.set_value(cfg.watermark_rotation as f64);
|
||||
rotation_scale.set_draw_value(false);
|
||||
rotation_scale.set_hexpand(false);
|
||||
rotation_scale.set_valign(gtk::Align::Center);
|
||||
rotation_scale.set_width_request(180);
|
||||
let rotation_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 0 degrees")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
rotation_reset.set_sensitive(cfg.watermark_rotation != 0);
|
||||
rotation_row.add_suffix(&rotation_scale);
|
||||
rotation_row.add_suffix(&rotation_reset);
|
||||
|
||||
// Tiled toggle
|
||||
let tiled_row = adw::SwitchRow::builder()
|
||||
.title("Tiled / Repeated")
|
||||
.subtitle("Repeat watermark across the entire image")
|
||||
.active(false)
|
||||
.active(cfg.watermark_tiled)
|
||||
.build();
|
||||
|
||||
let margin_row = adw::SpinRow::builder()
|
||||
// Margin slider + reset
|
||||
let margin_row = adw::ActionRow::builder()
|
||||
.title("Margin from Edges")
|
||||
.subtitle("Padding in pixels from image edges")
|
||||
.adjustment(>k::Adjustment::new(10.0, 0.0, 200.0, 1.0, 10.0, 0.0))
|
||||
.subtitle(&format!("{} px", cfg.watermark_margin))
|
||||
.build();
|
||||
let margin_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 200.0, 1.0);
|
||||
margin_scale.set_value(cfg.watermark_margin as f64);
|
||||
margin_scale.set_draw_value(false);
|
||||
margin_scale.set_hexpand(false);
|
||||
margin_scale.set_valign(gtk::Align::Center);
|
||||
margin_scale.set_width_request(180);
|
||||
let margin_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 10 px")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
margin_reset.set_sensitive(cfg.watermark_margin != 10);
|
||||
margin_row.add_suffix(&margin_scale);
|
||||
margin_row.add_suffix(&margin_reset);
|
||||
|
||||
let scale_row = adw::SpinRow::builder()
|
||||
// Scale slider + reset (only relevant for image watermarks)
|
||||
let scale_row = adw::ActionRow::builder()
|
||||
.title("Scale (% of image)")
|
||||
.subtitle("Watermark size relative to image")
|
||||
.adjustment(>k::Adjustment::new(20.0, 1.0, 100.0, 1.0, 5.0, 0.0))
|
||||
.subtitle(&format!("{}%", cfg.watermark_scale.round() as i32))
|
||||
.visible(cfg.watermark_use_image)
|
||||
.build();
|
||||
let scale_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 1.0, 100.0, 1.0);
|
||||
scale_scale.set_value(cfg.watermark_scale as f64);
|
||||
scale_scale.set_draw_value(false);
|
||||
scale_scale.set_hexpand(false);
|
||||
scale_scale.set_valign(gtk::Align::Center);
|
||||
scale_scale.set_width_request(180);
|
||||
let scale_reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Reset to 20%")
|
||||
.has_frame(false)
|
||||
.build();
|
||||
scale_reset.set_sensitive((cfg.watermark_scale - 20.0).abs() > 0.5);
|
||||
scale_row.add_suffix(&scale_scale);
|
||||
scale_row.add_suffix(&scale_reset);
|
||||
|
||||
advanced_expander.add_row(&color_row);
|
||||
advanced_expander.add_row(&opacity_row);
|
||||
@@ -409,67 +385,274 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
advanced_expander.add_row(&scale_row);
|
||||
|
||||
advanced_group.add(&advanced_expander);
|
||||
content.append(&advanced_group);
|
||||
controls.append(&advanced_group);
|
||||
|
||||
// Scrollable controls
|
||||
let controls_scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.width_request(360)
|
||||
.child(&controls)
|
||||
.build();
|
||||
|
||||
// === Main layout: 60/40 side-by-side ===
|
||||
let main_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
preview_box.set_width_request(400);
|
||||
main_box.append(&preview_box);
|
||||
main_box.append(&controls_scrolled);
|
||||
outer.append(&main_box);
|
||||
|
||||
// Preview state
|
||||
let preview_index: Rc<Cell<usize>> = Rc::new(Cell::new(0));
|
||||
|
||||
drop(cfg);
|
||||
|
||||
// Wire signals
|
||||
// === Preview update closure ===
|
||||
let preview_gen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
let update_preview = {
|
||||
let files = state.loaded_files.clone();
|
||||
let jc = state.job_config.clone();
|
||||
let pic = preview_picture.clone();
|
||||
let info = info_label.clone();
|
||||
let pidx = preview_index.clone();
|
||||
let bind_gen = preview_gen.clone();
|
||||
|
||||
Rc::new(move || {
|
||||
let loaded = files.borrow();
|
||||
if loaded.is_empty() {
|
||||
info.set_label("No images loaded");
|
||||
pic.set_paintable(gtk::gdk::Paintable::NONE);
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = pidx.get().min(loaded.len() - 1);
|
||||
pidx.set(idx);
|
||||
let path = loaded[idx].clone();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
|
||||
info.set_label(&format!("[{}/{}] {}", idx + 1, loaded.len(), name));
|
||||
|
||||
let cfg = jc.borrow();
|
||||
let wm_text = cfg.watermark_text.clone();
|
||||
let wm_use_image = cfg.watermark_use_image;
|
||||
let wm_image_path = cfg.watermark_image_path.clone();
|
||||
let wm_position = cfg.watermark_position;
|
||||
let wm_opacity = cfg.watermark_opacity;
|
||||
let wm_font_size = cfg.watermark_font_size;
|
||||
let wm_color = cfg.watermark_color;
|
||||
let wm_font_family = cfg.watermark_font_family.clone();
|
||||
let wm_tiled = cfg.watermark_tiled;
|
||||
let wm_margin = cfg.watermark_margin;
|
||||
let wm_scale = cfg.watermark_scale;
|
||||
let wm_rotation = cfg.watermark_rotation;
|
||||
let wm_enabled = cfg.watermark_enabled;
|
||||
drop(cfg);
|
||||
|
||||
let my_gen = bind_gen.get().wrapping_add(1);
|
||||
bind_gen.set(my_gen);
|
||||
let gen_check = bind_gen.clone();
|
||||
|
||||
let pic = pic.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Option<Vec<u8>>>();
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| -> Option<Vec<u8>> {
|
||||
let img = image::open(&path).ok()?;
|
||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
|
||||
let img = if wm_enabled {
|
||||
let position = match wm_position {
|
||||
0 => pixstrip_core::operations::WatermarkPosition::TopLeft,
|
||||
1 => pixstrip_core::operations::WatermarkPosition::TopCenter,
|
||||
2 => pixstrip_core::operations::WatermarkPosition::TopRight,
|
||||
3 => pixstrip_core::operations::WatermarkPosition::MiddleLeft,
|
||||
4 => pixstrip_core::operations::WatermarkPosition::Center,
|
||||
5 => pixstrip_core::operations::WatermarkPosition::MiddleRight,
|
||||
6 => pixstrip_core::operations::WatermarkPosition::BottomLeft,
|
||||
7 => pixstrip_core::operations::WatermarkPosition::BottomCenter,
|
||||
_ => pixstrip_core::operations::WatermarkPosition::BottomRight,
|
||||
};
|
||||
let rotation = if wm_rotation != 0 {
|
||||
Some(pixstrip_core::operations::WatermarkRotation::Custom(wm_rotation as f32))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let wm_config = if wm_use_image {
|
||||
wm_image_path.as_ref().map(|p| {
|
||||
pixstrip_core::operations::WatermarkConfig::Image {
|
||||
path: p.clone(),
|
||||
position,
|
||||
opacity: wm_opacity,
|
||||
scale: wm_scale / 100.0,
|
||||
rotation,
|
||||
tiled: wm_tiled,
|
||||
margin: wm_margin,
|
||||
}
|
||||
})
|
||||
} else if !wm_text.is_empty() {
|
||||
Some(pixstrip_core::operations::WatermarkConfig::Text {
|
||||
text: wm_text,
|
||||
position,
|
||||
font_size: wm_font_size,
|
||||
opacity: wm_opacity,
|
||||
color: wm_color,
|
||||
font_family: if wm_font_family.is_empty() { None } else { Some(wm_font_family) },
|
||||
rotation,
|
||||
tiled: wm_tiled,
|
||||
margin: wm_margin,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(config) = wm_config {
|
||||
pixstrip_core::operations::watermark::apply_watermark(img, &config).ok()?
|
||||
} else {
|
||||
img
|
||||
}
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buf),
|
||||
image::ImageFormat::Png,
|
||||
).ok()?;
|
||||
Some(buf)
|
||||
})();
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||
if gen_check.get() != my_gen {
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(Some(bytes)) => {
|
||||
let gbytes = glib::Bytes::from(&bytes);
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
|
||||
pic.set_paintable(Some(&texture));
|
||||
}
|
||||
glib::ControlFlow::Break
|
||||
}
|
||||
Ok(None) => glib::ControlFlow::Break,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
||||
Err(_) => glib::ControlFlow::Break,
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Click-to-cycle on preview
|
||||
{
|
||||
let click = gtk::GestureClick::new();
|
||||
let pidx = preview_index.clone();
|
||||
let files = state.loaded_files.clone();
|
||||
let up = update_preview.clone();
|
||||
click.connect_released(move |_, _, _, _| {
|
||||
let loaded = files.borrow();
|
||||
if loaded.len() > 1 {
|
||||
let next = (pidx.get() + 1) % loaded.len();
|
||||
pidx.set(next);
|
||||
up();
|
||||
}
|
||||
});
|
||||
preview_picture.add_controller(click);
|
||||
}
|
||||
|
||||
// === Wire signals ===
|
||||
|
||||
// Enable toggle
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
enable_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().watermark_enabled = row.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Type selector
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let text_group_c = text_group.clone();
|
||||
let image_group_c = image_group.clone();
|
||||
let scale_row_c = scale_row.clone();
|
||||
let up = update_preview.clone();
|
||||
type_row.connect_selected_notify(move |row| {
|
||||
let use_image = row.selected() == 1;
|
||||
jc.borrow_mut().watermark_use_image = use_image;
|
||||
text_group_c.set_visible(!use_image);
|
||||
image_group_c.set_visible(use_image);
|
||||
scale_row_c.set_visible(use_image);
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Text entry (debounced to avoid preview on every keystroke)
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let wl = watermark_label.clone();
|
||||
let up = update_preview.clone();
|
||||
let debounce_id: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
||||
text_row.connect_changed(move |row| {
|
||||
let text = row.text().to_string();
|
||||
wl.set_label(&text);
|
||||
jc.borrow_mut().watermark_text = text;
|
||||
let up = up.clone();
|
||||
let did = debounce_id.clone();
|
||||
let id = did.get().wrapping_add(1);
|
||||
did.set(id);
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(300), move || {
|
||||
if did.get() == id {
|
||||
up();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Font family
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
font_size_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_font_size = row.value() as f32;
|
||||
});
|
||||
}
|
||||
// Wire font family picker
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
font_button.connect_font_desc_notify(move |btn| {
|
||||
if let Some(desc) = btn.font_desc() {
|
||||
if let Some(family) = desc.family() {
|
||||
jc.borrow_mut().watermark_font_family = family.to_string();
|
||||
up();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Wire position grid buttons
|
||||
|
||||
// Font size
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
font_size_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_font_size = row.value() as f32;
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
// Position grid buttons
|
||||
for (i, btn) in buttons.iter().enumerate() {
|
||||
let jc = state.job_config.clone();
|
||||
let label = position_label.clone();
|
||||
let names = position_names;
|
||||
let all_buttons = buttons.clone();
|
||||
let wl = watermark_label.clone();
|
||||
let up = update_preview.clone();
|
||||
btn.connect_toggled(move |b| {
|
||||
if b.is_active() {
|
||||
jc.borrow_mut().watermark_position = i as u32;
|
||||
label.set_label(names[i]);
|
||||
set_watermark_alignment(&wl, i as u32);
|
||||
// Update icons
|
||||
for (j, other) in all_buttons.iter().enumerate() {
|
||||
let icon_name = if j == i {
|
||||
"radio-checked-symbolic"
|
||||
@@ -478,21 +661,15 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
};
|
||||
other.set_child(Some(>k::Image::from_icon_name(icon_name)));
|
||||
}
|
||||
up();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Color picker
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let wl = watermark_label.clone();
|
||||
opacity_row.connect_value_notify(move |row| {
|
||||
let val = row.value() as f32;
|
||||
wl.set_opacity(val as f64);
|
||||
jc.borrow_mut().watermark_opacity = val;
|
||||
});
|
||||
}
|
||||
// Wire color picker
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
color_button.connect_rgba_notify(move |btn| {
|
||||
let c = btn.rgba();
|
||||
jc.borrow_mut().watermark_color = [
|
||||
@@ -501,44 +678,123 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
(c.blue() * 255.0) as u8,
|
||||
(c.alpha() * 255.0) as u8,
|
||||
];
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire tiled toggle
|
||||
|
||||
// Opacity slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let row = opacity_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = opacity_reset.clone();
|
||||
opacity_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
let opacity = val as f32 / 100.0;
|
||||
jc.borrow_mut().watermark_opacity = opacity;
|
||||
row.set_subtitle(&format!("{}%", val));
|
||||
rst.set_sensitive(val != 50);
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = opacity_scale.clone();
|
||||
opacity_reset.connect_clicked(move |_| {
|
||||
scale.set_value(50.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Rotation slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let row = rotation_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = rotation_reset.clone();
|
||||
rotation_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().watermark_rotation = val;
|
||||
row.set_subtitle(&format!("{} degrees", val));
|
||||
rst.set_sensitive(val != 0);
|
||||
up();
|
||||
});
|
||||
}
|
||||
{
|
||||
let scale = rotation_scale.clone();
|
||||
rotation_reset.connect_clicked(move |_| {
|
||||
scale.set_value(0.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Tiled toggle
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let up = update_preview.clone();
|
||||
tiled_row.connect_active_notify(move |row| {
|
||||
jc.borrow_mut().watermark_tiled = row.is_active();
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire margin spinner
|
||||
|
||||
// Margin slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
margin_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_margin = row.value() as u32;
|
||||
let row = margin_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = margin_reset.clone();
|
||||
margin_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().watermark_margin = val as u32;
|
||||
row.set_subtitle(&format!("{} px", val));
|
||||
rst.set_sensitive(val != 10);
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire scale spinner
|
||||
{
|
||||
let scale = margin_scale.clone();
|
||||
margin_reset.connect_clicked(move |_| {
|
||||
scale.set_value(10.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Scale slider
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
scale_row.connect_value_notify(move |row| {
|
||||
jc.borrow_mut().watermark_scale = row.value() as f32;
|
||||
let row = scale_row.clone();
|
||||
let up = update_preview.clone();
|
||||
let rst = scale_reset.clone();
|
||||
scale_scale.connect_value_changed(move |scale| {
|
||||
let val = scale.value().round() as i32;
|
||||
jc.borrow_mut().watermark_scale = val as f32;
|
||||
row.set_subtitle(&format!("{}%", val));
|
||||
rst.set_sensitive((val - 20).abs() > 0);
|
||||
up();
|
||||
});
|
||||
}
|
||||
// Wire image chooser button
|
||||
{
|
||||
let scale = scale_scale.clone();
|
||||
scale_reset.connect_clicked(move |_| {
|
||||
scale.set_value(20.0);
|
||||
});
|
||||
}
|
||||
|
||||
// Image chooser button
|
||||
{
|
||||
let jc = state.job_config.clone();
|
||||
let path_row = image_path_row.clone();
|
||||
let up = update_preview.clone();
|
||||
choose_image_button.connect_clicked(move |btn| {
|
||||
let jc = jc.clone();
|
||||
let path_row = path_row.clone();
|
||||
let up = up.clone();
|
||||
let dialog = gtk::FileDialog::builder()
|
||||
.title("Choose Watermark Image")
|
||||
.modal(true)
|
||||
.build();
|
||||
|
||||
let filter = gtk::FileFilter::new();
|
||||
filter.set_name(Some("PNG images"));
|
||||
filter.set_name(Some("Images"));
|
||||
filter.add_mime_type("image/png");
|
||||
filter.add_mime_type("image/svg+xml");
|
||||
let filters = gtk::gio::ListStore::new::<gtk::FileFilter>();
|
||||
filters.append(&filter);
|
||||
dialog.set_filters(Some(&filters));
|
||||
@@ -550,22 +806,29 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
||||
{
|
||||
path_row.set_subtitle(&path.display().to_string());
|
||||
jc.borrow_mut().watermark_image_path = Some(path);
|
||||
up();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.child(&scrolled)
|
||||
.build();
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Watermark")
|
||||
.tag("step-watermark")
|
||||
.child(&clamp)
|
||||
.build()
|
||||
.child(&outer)
|
||||
.build();
|
||||
|
||||
// Refresh preview and sensitivity when navigating to this page
|
||||
{
|
||||
let up = update_preview.clone();
|
||||
let lf = state.loaded_files.clone();
|
||||
let ctrl = controls.clone();
|
||||
page.connect_map(move |_| {
|
||||
ctrl.set_sensitive(!lf.borrow().is_empty());
|
||||
up();
|
||||
});
|
||||
}
|
||||
|
||||
page
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user