Files
pixstrip/pixstrip-gtk/src/steps/step_watermark.rs
lashman be081307c4 Add accessible labels to sliders and watermark position grid
Screen readers now announce the purpose and range of brightness,
contrast, saturation, and compression quality sliders. The
watermark position grid frame also has a descriptive label.
2026-03-06 13:51:01 +02:00

327 lines
10 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(&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_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(&gtk::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(&gtk::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);
// 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)
.build();
let opacity_row = adw::SpinRow::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)
.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(&gtk::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(&gtk::Adjustment::new(20.0, 1.0, 100.0, 1.0, 5.0, 0.0))
.build();
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();
text_row.connect_changed(move |row| {
jc.borrow_mut().watermark_text = row.text().to_string();
});
}
{
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 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();
btn.connect_toggled(move |b| {
if b.is_active() {
jc.borrow_mut().watermark_position = i as u32;
label.set_label(names[i]);
// 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(&gtk::Image::from_icon_name(icon_name)));
}
}
});
}
{
let jc = state.job_config.clone();
opacity_row.connect_value_notify(move |row| {
jc.borrow_mut().watermark_opacity = row.value() as f32;
});
}
// 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()
}