- Redesign tutorial tour from modal dialogs to popovers pointing at actual UI elements - Add beginner-friendly improvements: help buttons, tooltips, welcome wizard enhancements - Add AppStream metainfo with screenshots, branding, categories, keywords, provides - Update desktop file with GTK category and SingleMainWindow - Add hicolor icon theme with all sizes (16-512px) - Fix debounce SourceId panic in rename step - Various step UI improvements and bug fixes
942 lines
33 KiB
Rust
942 lines
33 KiB
Rust
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 cfg = state.job_config.borrow();
|
|
|
|
// === OUTER LAYOUT ===
|
|
let outer = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(0)
|
|
.vexpand(true)
|
|
.build();
|
|
|
|
// --- Enable toggle (full width) ---
|
|
let enable_group = adw::PreferencesGroup::builder()
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.margin_top(12)
|
|
.build();
|
|
let enable_row = adw::SwitchRow::builder()
|
|
.title("Enable Watermark")
|
|
.subtitle("Add text or image watermark to processed images")
|
|
.active(cfg.watermark_enabled)
|
|
.tooltip_text("Toggle watermark on or off")
|
|
.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);
|
|
preview_picture.set_focusable(true);
|
|
preview_picture.update_property(&[
|
|
gtk::accessible::Property::Label("Watermark preview - press Space to cycle images"),
|
|
]);
|
|
|
|
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();
|
|
|
|
let type_row = adw::ComboRow::builder()
|
|
.title("Type")
|
|
.subtitle("Choose text or image watermark")
|
|
.use_subtitle(true)
|
|
.tooltip_text("Choose between text or image/logo overlay")
|
|
.build();
|
|
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);
|
|
controls.append(&type_group);
|
|
|
|
// --- Text watermark settings ---
|
|
let text_group = adw::PreferencesGroup::builder()
|
|
.title("Text Watermark")
|
|
.visible(!cfg.watermark_use_image)
|
|
.build();
|
|
|
|
let text_row = adw::EntryRow::builder()
|
|
.title("Watermark Text")
|
|
.text(&cfg.watermark_text)
|
|
.tooltip_text("The text that appears as a watermark on each image")
|
|
.build();
|
|
|
|
let font_row = adw::ActionRow::builder()
|
|
.title("Font Family")
|
|
.subtitle("Choose a typeface for the watermark text")
|
|
.build();
|
|
|
|
let font_dialog = gtk::FontDialog::builder()
|
|
.title("Choose Watermark Font")
|
|
.modal(true)
|
|
.build();
|
|
let font_button = gtk::FontDialogButton::builder()
|
|
.dialog(&font_dialog)
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
|
|
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_button.update_property(&[
|
|
gtk::accessible::Property::Label("Choose watermark font"),
|
|
]);
|
|
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))
|
|
.tooltip_text("Size of watermark text in pixels")
|
|
.build();
|
|
|
|
text_group.add(&text_row);
|
|
text_group.add(&font_row);
|
|
text_group.add(&font_size_row);
|
|
controls.append(&text_group);
|
|
|
|
// --- Image watermark settings ---
|
|
let image_group = adw::PreferencesGroup::builder()
|
|
.title("Image Watermark")
|
|
.visible(cfg.watermark_use_image)
|
|
.build();
|
|
|
|
let image_path_row = adw::ActionRow::builder()
|
|
.title("Logo Image")
|
|
.subtitle(
|
|
cfg.watermark_image_path
|
|
.as_ref()
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_else(|| "No image selected".to_string()),
|
|
)
|
|
.activatable(true)
|
|
.build();
|
|
let image_prefix_icon = gtk::Image::from_icon_name("image-x-generic-symbolic");
|
|
image_prefix_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
|
image_path_row.add_prefix(&image_prefix_icon);
|
|
|
|
let choose_image_button = gtk::Button::builder()
|
|
.icon_name("document-open-symbolic")
|
|
.tooltip_text("Choose logo image")
|
|
.valign(gtk::Align::Center)
|
|
.has_frame(false)
|
|
.build();
|
|
choose_image_button.update_property(&[
|
|
gtk::accessible::Property::Label("Choose logo image"),
|
|
]);
|
|
image_path_row.add_suffix(&choose_image_button);
|
|
|
|
image_group.add(&image_path_row);
|
|
controls.append(&image_group);
|
|
|
|
// --- Position group with 3x3 grid ---
|
|
let position_group = adw::PreferencesGroup::builder()
|
|
.title("Position")
|
|
.description("Choose where the watermark appears on the image")
|
|
.build();
|
|
|
|
let position_names = [
|
|
"Top Left", "Top Center", "Top Right",
|
|
"Middle Left", "Center", "Middle Right",
|
|
"Bottom Left", "Bottom Center", "Bottom Right",
|
|
];
|
|
|
|
let grid = gtk::Grid::builder()
|
|
.row_spacing(4)
|
|
.column_spacing(4)
|
|
.halign(gtk::Align::Center)
|
|
.margin_top(8)
|
|
.margin_bottom(8)
|
|
.build();
|
|
|
|
// 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();
|
|
grid_frame.set_child(Some(&grid));
|
|
grid_frame.update_property(&[
|
|
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()
|
|
.tooltip_text(*name)
|
|
.width_request(48)
|
|
.height_request(48)
|
|
.build();
|
|
|
|
btn.update_property(&[
|
|
gtk::accessible::Property::Label(&format!("Watermark position: {}", name)),
|
|
]);
|
|
|
|
let icon = if i == cfg.watermark_position as usize {
|
|
"radio-checked-symbolic"
|
|
} else {
|
|
"radio-symbolic"
|
|
};
|
|
btn.set_child(Some(>k::Image::from_icon_name(icon)));
|
|
btn.set_active(i == cfg.watermark_position as usize);
|
|
|
|
if let Some(ref first) = first_button {
|
|
btn.set_group(Some(first));
|
|
} else {
|
|
first_button = Some(btn.clone());
|
|
}
|
|
|
|
let row = i / 3;
|
|
let col = i % 3;
|
|
grid.attach(&btn, col as i32, row as i32, 1, 1);
|
|
|
|
btn
|
|
}).collect();
|
|
|
|
position_group.add(&grid_outer);
|
|
|
|
let position_label = gtk::Label::builder()
|
|
.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);
|
|
|
|
controls.append(&position_group);
|
|
|
|
// --- Advanced options ---
|
|
let advanced_group = adw::PreferencesGroup::builder()
|
|
.title("Advanced")
|
|
.build();
|
|
|
|
let advanced_expander = adw::ExpanderRow::builder()
|
|
.title("Advanced Options")
|
|
.subtitle("Color, opacity, rotation, tiling, margin, scale")
|
|
.show_enable_switch(false)
|
|
.expanded(state.is_section_expanded("watermark-advanced"))
|
|
.build();
|
|
|
|
{
|
|
let st = state.clone();
|
|
advanced_expander.connect_expanded_notify(move |row| {
|
|
st.set_section_expanded("watermark-advanced", row.is_expanded());
|
|
});
|
|
}
|
|
|
|
// Text color picker
|
|
let color_row = adw::ActionRow::builder()
|
|
.title("Text Color")
|
|
.subtitle("Color of the watermark text")
|
|
.build();
|
|
|
|
let initial_color = gtk::gdk::RGBA::new(
|
|
cfg.watermark_color[0] as f32 / 255.0,
|
|
cfg.watermark_color[1] as f32 / 255.0,
|
|
cfg.watermark_color[2] as f32 / 255.0,
|
|
cfg.watermark_color[3] as f32 / 255.0,
|
|
);
|
|
let color_dialog = gtk::ColorDialog::builder()
|
|
.with_alpha(true)
|
|
.title("Watermark Text Color")
|
|
.build();
|
|
let color_button = gtk::ColorDialogButton::builder()
|
|
.dialog(&color_dialog)
|
|
.rgba(&initial_color)
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
color_button.update_property(&[
|
|
gtk::accessible::Property::Label("Choose watermark text color"),
|
|
]);
|
|
color_row.add_suffix(&color_button);
|
|
|
|
// Opacity slider + reset
|
|
let opacity_row = adw::ActionRow::builder()
|
|
.title("Opacity")
|
|
.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);
|
|
opacity_scale.update_property(&[
|
|
gtk::accessible::Property::Label("Watermark opacity, 0 to 100 percent"),
|
|
]);
|
|
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.update_property(&[
|
|
gtk::accessible::Property::Label("Reset opacity to 50%"),
|
|
]);
|
|
opacity_reset.set_sensitive((cfg.watermark_opacity - 0.5).abs() > 0.01);
|
|
opacity_row.add_suffix(&opacity_scale);
|
|
opacity_row.add_suffix(&opacity_reset);
|
|
|
|
// Rotation slider + reset (-180 to +180)
|
|
let rotation_row = adw::ActionRow::builder()
|
|
.title("Rotation")
|
|
.subtitle(&format!("{} degrees", cfg.watermark_rotation))
|
|
.build();
|
|
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);
|
|
rotation_scale.update_property(&[
|
|
gtk::accessible::Property::Label("Watermark rotation, -180 to +180 degrees"),
|
|
]);
|
|
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.update_property(&[
|
|
gtk::accessible::Property::Label("Reset rotation to 0 degrees"),
|
|
]);
|
|
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(cfg.watermark_tiled)
|
|
.tooltip_text("Repeat the watermark in a grid pattern across the entire image")
|
|
.build();
|
|
|
|
// Margin slider + reset
|
|
let margin_row = adw::ActionRow::builder()
|
|
.title("Margin from Edges")
|
|
.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);
|
|
margin_scale.update_property(&[
|
|
gtk::accessible::Property::Label("Watermark margin from edges, 0 to 200 pixels"),
|
|
]);
|
|
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.update_property(&[
|
|
gtk::accessible::Property::Label("Reset margin to 10 pixels"),
|
|
]);
|
|
margin_reset.set_sensitive(cfg.watermark_margin != 10);
|
|
margin_row.add_suffix(&margin_scale);
|
|
margin_row.add_suffix(&margin_reset);
|
|
|
|
// Scale slider + reset (only relevant for image watermarks)
|
|
let scale_row = adw::ActionRow::builder()
|
|
.title("Scale (% of image)")
|
|
.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);
|
|
scale_scale.update_property(&[
|
|
gtk::accessible::Property::Label("Watermark scale, 1 to 100 percent of image"),
|
|
]);
|
|
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.update_property(&[
|
|
gtk::accessible::Property::Label("Reset scale to 20%"),
|
|
]);
|
|
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);
|
|
advanced_expander.add_row(&rotation_row);
|
|
advanced_expander.add_row(&tiled_row);
|
|
advanced_expander.add_row(&margin_row);
|
|
advanced_expander.add_row(&scale_row);
|
|
|
|
advanced_group.add(&advanced_expander);
|
|
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);
|
|
|
|
// === 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().saturating_sub(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);
|
|
}
|
|
|
|
// Keyboard support for preview cycling (Space/Enter)
|
|
{
|
|
let key = gtk::EventControllerKey::new();
|
|
let pidx = preview_index.clone();
|
|
let files = state.loaded_files.clone();
|
|
let up = update_preview.clone();
|
|
key.connect_key_pressed(move |_, keyval, _, _| {
|
|
if keyval == gtk::gdk::Key::space || keyval == gtk::gdk::Key::Return {
|
|
let loaded = files.borrow();
|
|
if loaded.len() > 1 {
|
|
let next = (pidx.get() + 1) % loaded.len();
|
|
pidx.set(next);
|
|
up();
|
|
}
|
|
return gtk::glib::Propagation::Stop;
|
|
}
|
|
gtk::glib::Propagation::Proceed
|
|
});
|
|
preview_picture.add_controller(key);
|
|
}
|
|
|
|
// === 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 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();
|
|
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();
|
|
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();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 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]);
|
|
for (j, other) in all_buttons.iter().enumerate() {
|
|
let icon_name = if j == i {
|
|
"radio-checked-symbolic"
|
|
} else {
|
|
"radio-symbolic"
|
|
};
|
|
other.set_child(Some(>k::Image::from_icon_name(icon_name)));
|
|
}
|
|
up();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 = [
|
|
(c.red() * 255.0) as u8,
|
|
(c.green() * 255.0) as u8,
|
|
(c.blue() * 255.0) as u8,
|
|
(c.alpha() * 255.0) as u8,
|
|
];
|
|
up();
|
|
});
|
|
}
|
|
|
|
// Per-slider debounce counters (separate to avoid cross-slider cancellation)
|
|
let opacity_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
|
let rotation_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
|
let margin_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
|
let scale_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
|
|
|
// Opacity slider
|
|
{
|
|
let jc = state.job_config.clone();
|
|
let row = opacity_row.clone();
|
|
let up = update_preview.clone();
|
|
let rst = opacity_reset.clone();
|
|
let did = opacity_debounce.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);
|
|
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 = 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();
|
|
let did = rotation_debounce.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);
|
|
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 = 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();
|
|
});
|
|
}
|
|
|
|
// Margin slider
|
|
{
|
|
let jc = state.job_config.clone();
|
|
let row = margin_row.clone();
|
|
let up = update_preview.clone();
|
|
let rst = margin_reset.clone();
|
|
let did = margin_debounce.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);
|
|
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 = margin_scale.clone();
|
|
margin_reset.connect_clicked(move |_| {
|
|
scale.set_value(10.0);
|
|
});
|
|
}
|
|
|
|
// Scale slider
|
|
{
|
|
let jc = state.job_config.clone();
|
|
let row = scale_row.clone();
|
|
let up = update_preview.clone();
|
|
let rst = scale_reset.clone();
|
|
let did = scale_debounce.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);
|
|
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 = 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("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));
|
|
|
|
if let Some(window) = btn.root().and_then(|r| r.downcast::<gtk::Window>().ok()) {
|
|
dialog.open(Some(&window), gtk::gio::Cancellable::NONE, move |result| {
|
|
if let Ok(file) = result
|
|
&& let Some(path) = file.path()
|
|
{
|
|
path_row.set_subtitle(&path.display().to_string());
|
|
jc.borrow_mut().watermark_image_path = Some(path);
|
|
up();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
let page = adw::NavigationPage::builder()
|
|
.title("Watermark")
|
|
.tag("step-watermark")
|
|
.child(&outer)
|
|
.build();
|
|
|
|
// Sync enable toggle, refresh preview and sensitivity when navigating to this page
|
|
{
|
|
let up = update_preview.clone();
|
|
let lf = state.loaded_files.clone();
|
|
let ctrl = controls.clone();
|
|
let jc = state.job_config.clone();
|
|
let er = enable_row.clone();
|
|
page.connect_map(move |_| {
|
|
let enabled = jc.borrow().watermark_enabled;
|
|
er.set_active(enabled);
|
|
ctrl.set_sensitive(!lf.borrow().is_empty());
|
|
up();
|
|
});
|
|
}
|
|
|
|
page
|
|
}
|