PNG files now embed pHYs chunk for DPI when output_dpi is set, matching the existing JPEG DPI support. Also fixed FontDialogButton signal handler to properly unwrap the Option<FontDescription>.
544 lines
18 KiB
Rust
544 lines
18 KiB
Rust
use adw::prelude::*;
|
|
use crate::app::AppState;
|
|
|
|
pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage {
|
|
let scrolled = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.vexpand(true)
|
|
.build();
|
|
|
|
let content = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(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);
|
|
|
|
// Watermark type selection
|
|
let type_group = adw::PreferencesGroup::builder()
|
|
.title("Watermark Type")
|
|
.build();
|
|
|
|
let type_row = adw::ComboRow::builder()
|
|
.title("Type")
|
|
.subtitle("Choose text or image watermark")
|
|
.build();
|
|
let type_model = gtk::StringList::new(&["Text Watermark", "Image Watermark"]);
|
|
type_row.set_model(Some(&type_model));
|
|
type_row.set_selected(if cfg.watermark_use_image { 1 } else { 0 });
|
|
|
|
type_group.add(&type_row);
|
|
content.append(&type_group);
|
|
|
|
// Text watermark settings
|
|
let text_group = adw::PreferencesGroup::builder()
|
|
.title("Text Watermark")
|
|
.build();
|
|
|
|
let text_row = adw::EntryRow::builder()
|
|
.title("Watermark Text")
|
|
.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")
|
|
.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();
|
|
|
|
// 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);
|
|
|
|
text_group.add(&text_row);
|
|
text_group.add(&font_row);
|
|
text_group.add(&font_size_row);
|
|
content.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();
|
|
image_path_row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic"));
|
|
|
|
let choose_image_button = gtk::Button::builder()
|
|
.icon_name("document-open-symbolic")
|
|
.tooltip_text("Choose logo image")
|
|
.valign(gtk::Align::Center)
|
|
.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);
|
|
|
|
// Visual 9-point position 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",
|
|
];
|
|
|
|
// Build a 3x3 grid of toggle buttons
|
|
let grid = gtk::Grid::builder()
|
|
.row_spacing(4)
|
|
.column_spacing(4)
|
|
.halign(gtk::Align::Center)
|
|
.margin_top(8)
|
|
.margin_bottom(8)
|
|
.build();
|
|
|
|
// Create a visual "image" area as background context
|
|
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."),
|
|
]);
|
|
|
|
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();
|
|
|
|
// Use a dot icon for each position
|
|
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_frame);
|
|
|
|
// Position label showing current selection
|
|
let position_label = gtk::Label::builder()
|
|
.label(position_names[cfg.watermark_position as usize])
|
|
.css_classes(["dim-label"])
|
|
.halign(gtk::Align::Center)
|
|
.margin_bottom(4)
|
|
.build();
|
|
position_group.add(&position_label);
|
|
|
|
content.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
|
|
let advanced_group = adw::PreferencesGroup::builder()
|
|
.title("Advanced")
|
|
.build();
|
|
|
|
let advanced_expander = adw::ExpanderRow::builder()
|
|
.title("Advanced Options")
|
|
.subtitle("Opacity, rotation, tiling, margin")
|
|
.show_enable_switch(false)
|
|
.expanded(state.detailed_mode)
|
|
.build();
|
|
|
|
// 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_row.add_suffix(&color_button);
|
|
|
|
let opacity_row = adw::SpinRow::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)
|
|
.build();
|
|
|
|
let rotation_row = adw::ComboRow::builder()
|
|
.title("Rotation")
|
|
.subtitle("Rotate the watermark")
|
|
.build();
|
|
let rotation_model = gtk::StringList::new(&["None", "45 degrees", "-45 degrees", "90 degrees"]);
|
|
rotation_row.set_model(Some(&rotation_model));
|
|
|
|
let tiled_row = adw::SwitchRow::builder()
|
|
.title("Tiled / Repeated")
|
|
.subtitle("Repeat watermark across the entire image")
|
|
.active(false)
|
|
.build();
|
|
|
|
let margin_row = adw::SpinRow::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))
|
|
.build();
|
|
|
|
let scale_row = adw::SpinRow::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))
|
|
.build();
|
|
|
|
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);
|
|
content.append(&advanced_group);
|
|
|
|
drop(cfg);
|
|
|
|
// Wire signals
|
|
{
|
|
let jc = state.job_config.clone();
|
|
enable_row.connect_active_notify(move |row| {
|
|
jc.borrow_mut().watermark_enabled = row.is_active();
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
let text_group_c = text_group.clone();
|
|
let image_group_c = image_group.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);
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
let wl = watermark_label.clone();
|
|
text_row.connect_changed(move |row| {
|
|
let text = row.text().to_string();
|
|
wl.set_label(&text);
|
|
jc.borrow_mut().watermark_text = text;
|
|
});
|
|
}
|
|
{
|
|
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();
|
|
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();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
// Wire 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();
|
|
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"
|
|
} else {
|
|
"radio-symbolic"
|
|
};
|
|
other.set_child(Some(>k::Image::from_icon_name(icon_name)));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
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();
|
|
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,
|
|
];
|
|
});
|
|
}
|
|
// Wire image chooser button
|
|
{
|
|
let jc = state.job_config.clone();
|
|
let path_row = image_path_row.clone();
|
|
choose_image_button.connect_clicked(move |btn| {
|
|
let jc = jc.clone();
|
|
let path_row = path_row.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.add_mime_type("image/png");
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
scrolled.set_child(Some(&content));
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(600)
|
|
.child(&scrolled)
|
|
.build();
|
|
|
|
adw::NavigationPage::builder()
|
|
.title("Watermark")
|
|
.tag("step-watermark")
|
|
.child(&clamp)
|
|
.build()
|
|
}
|