Files
pixstrip/pixstrip-gtk/src/steps/step_resize.rs
lashman 3aeb05c9a0 Replace custom shortcuts dialog with GtkShortcutsWindow, fix Process More button
- Use proper GtkShortcutsWindow with ShortcutsSection/Group/Shortcut widgets
  instead of custom AdwDialog with ActionRows
- Hide step indicator during processing and results screens
- Fix "Process More" button re-triggering processing instead of resetting wizard
- Add accessible label to resize step size preview DrawingArea
2026-03-06 14:46:54 +02:00

478 lines
16 KiB
Rust

use adw::prelude::*;
use crate::app::AppState;
pub fn build_resize_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 Resize")
.subtitle("Resize images to new dimensions")
.active(cfg.resize_enabled)
.build();
let enable_group = adw::PreferencesGroup::new();
enable_group.add(&enable_row);
content.append(&enable_group);
// Mode selector using GtkStack with a stack switcher
let mode_stack = gtk::Stack::builder()
.transition_type(gtk::StackTransitionType::Crossfade)
.build();
let switcher = gtk::StackSwitcher::builder()
.stack(&mode_stack)
.halign(gtk::Align::Center)
.margin_top(6)
.margin_bottom(6)
.build();
content.append(&switcher);
// --- Mode 1: Width/Height ---
let wh_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.build();
let wh_group = adw::PreferencesGroup::builder()
.title("Target Dimensions")
.description("Set width and/or height. Set either to 0 to maintain aspect ratio.")
.build();
let width_row = adw::SpinRow::builder()
.title("Width")
.subtitle("Target width in pixels")
.adjustment(&gtk::Adjustment::new(cfg.resize_width as f64, 0.0, 10000.0, 1.0, 100.0, 0.0))
.build();
let height_row = adw::SpinRow::builder()
.title("Height")
.subtitle("Target height in pixels (0 = auto from aspect ratio)")
.adjustment(&gtk::Adjustment::new(cfg.resize_height as f64, 0.0, 10000.0, 1.0, 100.0, 0.0))
.build();
wh_group.add(&width_row);
wh_group.add(&height_row);
wh_box.append(&wh_group);
mode_stack.add_titled(&wh_box, Some("width-height"), "Width / Height");
// --- Mode 2: Preset Dimensions ---
let preset_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.build();
let presets_group = adw::PreferencesGroup::builder()
.title("Quick Dimension Presets")
.description("Select a preset to set the dimensions")
.build();
// Clone for closures
let width_for_preset = width_row.clone();
let height_for_preset = height_row.clone();
let build_preset_section = |title: &str, subtitle: &str, presets: &[(&str, u32, u32)]| -> adw::ExpanderRow {
let expander = adw::ExpanderRow::builder()
.title(title)
.subtitle(subtitle)
.build();
for (name, w, h) in presets {
let row = adw::ActionRow::builder()
.title(*name)
.subtitle(if *h == 0 { format!("{} wide", w) } else { format!("{} x {}", w, h) })
.activatable(true)
.build();
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
let width_c = width_for_preset.clone();
let height_c = height_for_preset.clone();
let w = *w;
let h = *h;
let stack_c = mode_stack.clone();
row.connect_activated(move |_| {
width_c.set_value(w as f64);
height_c.set_value(h as f64);
// Switch to width/height tab to show the values
stack_c.set_visible_child_name("width-height");
});
expander.add_row(&row);
}
expander
};
let fedi_expander = build_preset_section(
"Fediverse / Open Platforms",
"Mastodon, Pixelfed, Bluesky, Lemmy, PeerTube",
&[
("Mastodon Post", 1920, 1080),
("Mastodon Profile", 400, 400),
("Mastodon Header", 1500, 500),
("Pixelfed Post", 1080, 1080),
("Pixelfed Story", 1080, 1920),
("Bluesky Post", 1200, 630),
("Bluesky Profile", 400, 400),
("Bluesky Banner", 1500, 500),
("Lemmy Post", 1200, 630),
("PeerTube Thumbnail", 1280, 720),
("Friendica Post", 1200, 630),
("Funkwhale Cover", 1400, 1400),
],
);
let mainstream_expander = build_preset_section(
"Mainstream Platforms",
"Instagram, YouTube, LinkedIn, Facebook, TikTok",
&[
("Instagram Post Square", 1080, 1080),
("Instagram Post Portrait", 1080, 1350),
("Instagram Story/Reel", 1080, 1920),
("Facebook Post", 1200, 630),
("Facebook Cover", 820, 312),
("Facebook Profile", 170, 170),
("YouTube Thumbnail", 1280, 720),
("YouTube Channel Art", 2560, 1440),
("LinkedIn Post", 1200, 627),
("LinkedIn Cover", 1584, 396),
("LinkedIn Profile", 400, 400),
("Pinterest Pin", 1000, 1500),
("TikTok Video Cover", 1080, 1920),
("Threads Post", 1080, 1080),
],
);
let common_expander = build_preset_section(
"Common Sizes",
"HD, 4K, Blog, Thumbnails",
&[
("4K UHD", 3840, 2160),
("Full HD", 1920, 1080),
("HD Ready", 1280, 720),
("Blog Standard", 800, 0),
("Email Header", 600, 200),
("Large Thumbnail", 300, 300),
("Small Thumbnail", 150, 150),
("Favicon", 32, 32),
],
);
presets_group.add(&fedi_expander);
presets_group.add(&mainstream_expander);
presets_group.add(&common_expander);
preset_box.append(&presets_group);
mode_stack.add_titled(&preset_box, Some("presets"), "Presets");
// --- Mode 3: Fit in Box ---
let fit_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.build();
let fit_group = adw::PreferencesGroup::builder()
.title("Fit in Bounding Box")
.description("Images are scaled down to fit within these maximum dimensions while maintaining their aspect ratio. Images smaller than the box are not enlarged.")
.build();
let max_width_row = adw::SpinRow::builder()
.title("Maximum Width")
.subtitle("Images wider than this are scaled down")
.adjustment(&gtk::Adjustment::new(1200.0, 1.0, 10000.0, 1.0, 100.0, 0.0))
.build();
let max_height_row = adw::SpinRow::builder()
.title("Maximum Height")
.subtitle("Images taller than this are scaled down")
.adjustment(&gtk::Adjustment::new(1200.0, 1.0, 10000.0, 1.0, 100.0, 0.0))
.build();
fit_group.add(&max_width_row);
fit_group.add(&max_height_row);
fit_box.append(&fit_group);
// Wire fit-in-box to update width/height
{
let width_c = width_row.clone();
let height_c = height_row.clone();
max_width_row.connect_value_notify(move |row| {
width_c.set_value(row.value());
});
let height_c2 = height_row.clone();
max_height_row.connect_value_notify(move |row| {
height_c2.set_value(row.value());
});
let _ = height_c; // suppress unused
}
mode_stack.add_titled(&fit_box, Some("fit-box"), "Fit in Box");
content.append(&mode_stack);
// Size preview visualization
let preview_group = adw::PreferencesGroup::builder()
.title("Size Preview")
.description("Visual comparison of original and output dimensions")
.build();
// Use shared state for the preview drawing
let preview_width = std::rc::Rc::new(std::cell::RefCell::new(cfg.resize_width));
let preview_height = std::rc::Rc::new(std::cell::RefCell::new(cfg.resize_height));
let loaded_files = state.loaded_files.clone();
let drawing = gtk::DrawingArea::builder()
.content_width(300)
.content_height(150)
.halign(gtk::Align::Center)
.margin_top(8)
.margin_bottom(8)
.build();
drawing.update_property(&[
gtk::accessible::Property::Label("Visual comparison of original and target image dimensions"),
]);
let pw = preview_width.clone();
let ph = preview_height.clone();
let lf = loaded_files.clone();
drawing.set_draw_func(move |_area, cr, width, height| {
let target_w = *pw.borrow() as f64;
let target_h = *ph.borrow() as f64;
// Try to get actual first image dimensions
let (orig_w, orig_h) = {
let files = lf.borrow();
if let Some(first) = files.first() {
image_dimensions(first).unwrap_or((4000.0, 3000.0))
} else {
(4000.0, 3000.0)
}
};
let actual_target_w = if target_w > 0.0 { target_w } else { orig_w };
let actual_target_h = if target_h > 0.0 {
target_h
} else if target_w > 0.0 {
orig_h * target_w / orig_w
} else {
orig_h
};
let max_dim = orig_w.max(orig_h).max(actual_target_w).max(actual_target_h);
if max_dim == 0.0 {
return;
}
let pad = 20.0;
let avail_w = width as f64 - pad * 2.0;
let avail_h = height as f64 - pad * 2.0;
let scale = (avail_w / max_dim).min(avail_h / max_dim);
let ow = orig_w * scale;
let oh = orig_h * scale;
let tw = actual_target_w * scale;
let th = actual_target_h * scale;
// Draw original rectangle (semi-transparent)
let _ = cr.set_source_rgba(0.5, 0.5, 0.5, 0.3);
let _ = cr.rectangle(pad, pad, ow, oh);
let _ = cr.fill();
let _ = cr.set_source_rgba(0.5, 0.5, 0.5, 0.7);
let _ = cr.rectangle(pad, pad, ow, oh);
let _ = cr.stroke();
// Draw target rectangle (accent color)
let _ = cr.set_source_rgba(0.2, 0.5, 0.9, 0.3);
let _ = cr.rectangle(pad, pad, tw, th);
let _ = cr.fill();
let _ = cr.set_source_rgba(0.2, 0.5, 0.9, 0.9);
let _ = cr.rectangle(pad, pad, tw, th);
let _ = cr.stroke();
// Labels
let _ = cr.set_source_rgba(0.6, 0.6, 0.6, 1.0);
let _ = cr.set_font_size(10.0);
let _ = cr.move_to(pad + ow + 4.0, pad + oh / 2.0);
let _ = cr.show_text(&format!("{}x{}", orig_w as u32, orig_h as u32));
let _ = cr.set_source_rgba(0.2, 0.5, 0.9, 1.0);
let _ = cr.move_to(pad + tw + 4.0, pad + th / 2.0 + 12.0);
let _ = cr.show_text(&format!("{}x{}", actual_target_w as u32, actual_target_h as u32));
});
let preview_label = gtk::Label::builder()
.label("Gray = original, Blue = target size")
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Center)
.build();
let preview_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(4)
.margin_top(4)
.margin_bottom(4)
.build();
preview_box.append(&drawing);
preview_box.append(&preview_label);
preview_group.add(&preview_box);
content.append(&preview_group);
// Basic orientation adjustments (folded into resize step per design doc)
let orientation_group = adw::PreferencesGroup::builder()
.title("Orientation")
.description("Rotate and flip applied before resize")
.build();
let rotate_row = adw::ComboRow::builder()
.title("Rotate")
.subtitle("Rotation applied to all images")
.build();
let rotate_model = gtk::StringList::new(&[
"None",
"90 clockwise",
"180",
"270 clockwise",
"Auto-orient (from EXIF)",
]);
rotate_row.set_model(Some(&rotate_model));
rotate_row.set_selected(cfg.rotation);
let flip_row = adw::ComboRow::builder()
.title("Flip")
.subtitle("Mirror the image")
.build();
let flip_model = gtk::StringList::new(&["None", "Horizontal", "Vertical"]);
flip_row.set_model(Some(&flip_model));
flip_row.set_selected(cfg.flip);
orientation_group.add(&rotate_row);
orientation_group.add(&flip_row);
content.append(&orientation_group);
// Advanced options (AdwExpanderRow per design doc)
let advanced_group = adw::PreferencesGroup::builder()
.title("Advanced")
.build();
let advanced_expander = adw::ExpanderRow::builder()
.title("Advanced Options")
.subtitle("Resize algorithm, DPI, upscale behavior")
.show_enable_switch(false)
.build();
let upscale_row = adw::SwitchRow::builder()
.title("Allow Upscaling")
.subtitle("Enlarge images smaller than target size")
.active(cfg.allow_upscale)
.build();
let algorithm_row = adw::ComboRow::builder()
.title("Resize Algorithm")
.subtitle("Method used for pixel interpolation")
.build();
let algo_model = gtk::StringList::new(&[
"Lanczos3 (Best quality)",
"CatmullRom (Good quality, faster)",
"Bilinear (Fast, lower quality)",
"Nearest (Fastest, pixelated)",
]);
algorithm_row.set_model(Some(&algo_model));
let dpi_row = adw::SpinRow::builder()
.title("DPI")
.subtitle("Output resolution in dots per inch")
.adjustment(&gtk::Adjustment::new(72.0, 72.0, 600.0, 1.0, 10.0, 0.0))
.build();
advanced_expander.add_row(&upscale_row);
advanced_expander.add_row(&algorithm_row);
advanced_expander.add_row(&dpi_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().resize_enabled = row.is_active();
});
}
{
let jc = state.job_config.clone();
let pw = preview_width.clone();
let draw = drawing.clone();
width_row.connect_value_notify(move |row| {
let val = row.value() as u32;
jc.borrow_mut().resize_width = val;
*pw.borrow_mut() = val;
draw.queue_draw();
});
}
{
let jc = state.job_config.clone();
let ph = preview_height.clone();
let draw = drawing.clone();
height_row.connect_value_notify(move |row| {
let val = row.value() as u32;
jc.borrow_mut().resize_height = val;
*ph.borrow_mut() = val;
draw.queue_draw();
});
}
{
let jc = state.job_config.clone();
upscale_row.connect_active_notify(move |row| {
jc.borrow_mut().allow_upscale = row.is_active();
});
}
{
let jc = state.job_config.clone();
rotate_row.connect_selected_notify(move |row| {
jc.borrow_mut().rotation = row.selected();
});
}
{
let jc = state.job_config.clone();
flip_row.connect_selected_notify(move |row| {
jc.borrow_mut().flip = row.selected();
});
}
scrolled.set_child(Some(&content));
let clamp = adw::Clamp::builder()
.maximum_size(600)
.child(&scrolled)
.build();
adw::NavigationPage::builder()
.title("Resize")
.tag("step-resize")
.child(&clamp)
.build()
}
/// Get dimensions of an image file without loading the full image
fn image_dimensions(path: &std::path::Path) -> Option<(f64, f64)> {
let reader = image::ImageReader::open(path).ok()?;
let reader = reader.with_guessed_format().ok()?;
let (w, h) = reader.into_dimensions().ok()?;
Some((w as f64, h as f64))
}