- 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
478 lines
16 KiB
Rust
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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::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))
|
|
}
|