Files
pixstrip/pixstrip-gtk/src/steps/step_resize.rs
lashman f3668c45c3 Improve UX, add popover tour, metadata, and hicolor icons
- 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
2026-03-08 14:18:15 +02:00

950 lines
32 KiB
Rust

use adw::prelude::*;
use gtk::glib;
use std::cell::Cell;
use std::rc::Rc;
use crate::app::AppState;
fn get_first_image_aspect(files: &[std::path::PathBuf]) -> f64 {
let Some(first) = files.first() else { return 0.0 };
let Ok((w, h)) = image::image_dimensions(first) else { return 0.0 };
if h == 0 { return 0.0; }
w as f64 / h as f64
}
fn get_image_dims(path: &std::path::Path) -> (u32, u32) {
image::image_dimensions(path).unwrap_or((4000, 3000))
}
fn get_first_image_dims(files: &[std::path::PathBuf]) -> (u32, u32) {
let Some(first) = files.first() else { return (4000, 3000) };
get_image_dims(first)
}
fn algo_index_to_filter(idx: u32) -> image::imageops::FilterType {
match idx {
1 => image::imageops::FilterType::CatmullRom,
2 => image::imageops::FilterType::Triangle,
3 => image::imageops::FilterType::Nearest,
_ => image::imageops::FilterType::Lanczos3,
}
}
const CATEGORIES: &[&str] = &[
"Fediverse / Open Platforms",
"Mainstream Platforms",
"Common / Web",
];
fn presets_for_category(cat: u32) -> &'static [(&'static str, u32, u32)] {
match cat {
0 => &[
("Mastodon Post (1920 x 1080)", 1920, 1080),
("Mastodon Profile (400 x 400)", 400, 400),
("Mastodon Header (1500 x 500)", 1500, 500),
("Pixelfed Post (1080 x 1080)", 1080, 1080),
("Pixelfed Story (1080 x 1920)", 1080, 1920),
("Bluesky Post (1200 x 630)", 1200, 630),
("Bluesky Profile (400 x 400)", 400, 400),
("Bluesky Banner (1500 x 500)", 1500, 500),
("Lemmy Post (1200 x 630)", 1200, 630),
("PeerTube Thumbnail (1280 x 720)", 1280, 720),
("Friendica Post (1200 x 630)", 1200, 630),
("Funkwhale Cover (1400 x 1400)", 1400, 1400),
],
1 => &[
("Instagram Post Square (1080 x 1080)", 1080, 1080),
("Instagram Post Portrait (1080 x 1350)", 1080, 1350),
("Instagram Story/Reel (1080 x 1920)", 1080, 1920),
("Facebook Post (1200 x 630)", 1200, 630),
("Facebook Cover (820 x 312)", 820, 312),
("Facebook Profile (170 x 170)", 170, 170),
("YouTube Thumbnail (1280 x 720)", 1280, 720),
("YouTube Channel Art (2560 x 1440)", 2560, 1440),
("LinkedIn Post (1200 x 627)", 1200, 627),
("LinkedIn Cover (1584 x 396)", 1584, 396),
("LinkedIn Profile (400 x 400)", 400, 400),
("Pinterest Pin (1000 x 1500)", 1000, 1500),
("TikTok Video Cover (1080 x 1920)", 1080, 1920),
("Threads Post (1080 x 1080)", 1080, 1080),
],
_ => &[
("4K UHD (3840 x 2160)", 3840, 2160),
("Full HD (1920 x 1080)", 1920, 1080),
("HD Ready (1280 x 720)", 1280, 720),
("Blog Standard (800 wide)", 800, 0),
("Email Header (600 x 200)", 600, 200),
("Large Thumbnail (300 x 300)", 300, 300),
("Small Thumbnail (150 x 150)", 150, 150),
("Favicon (32 x 32)", 32, 32),
],
}
}
fn rebuild_size_model(size_row: &adw::ComboRow, cat: u32) {
let presets = presets_for_category(cat);
let mut names: Vec<&str> = vec!["(select a size)"];
names.extend(presets.iter().map(|p| p.0));
size_row.set_model(Some(&gtk::StringList::new(&names)));
size_row.set_list_factory(Some(&super::full_text_list_factory()));
size_row.set_selected(0);
}
pub fn build_resize_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 Resize")
.subtitle("Scale images to new dimensions")
.active(cfg.resize_enabled)
.tooltip_text("Toggle resizing of images on or off")
.build();
enable_group.add(&enable_row);
outer.append(&enable_group);
// --- Horizontal split: Preview (left 60%) | Controls (right 40%) ---
let split = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.margin_start(12)
.margin_end(12)
.margin_top(12)
.margin_bottom(12)
.vexpand(true)
.build();
// ========== LEFT: Preview ==========
let preview_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.hexpand(true)
.valign(gtk::Align::Start)
.build();
let thumb_picture = gtk::Picture::builder()
.content_fit(gtk::ContentFit::Contain)
.height_request(250)
.hexpand(true)
.build();
thumb_picture.add_css_class("card");
thumb_picture.update_property(&[
gtk::accessible::Property::Label("Resize preview - click to cycle images"),
]);
let dims_label = gtk::Label::builder()
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Center)
.margin_top(4)
.build();
let no_preview_label = gtk::Label::builder()
.label("Add images to see resize preview")
.css_classes(["dim-label"])
.halign(gtk::Align::Center)
.build();
preview_box.append(&thumb_picture);
preview_box.append(&dims_label);
preview_box.append(&no_preview_label);
// ========== RIGHT: Controls ==========
let controls_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.width_request(340)
.build();
let controls = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.build();
// --- Group 1: Preset (two-level: category then size) ---
let preset_group = adw::PreferencesGroup::builder()
.title("Preset")
.description("Pick a category, then a size")
.build();
let category_row = adw::ComboRow::builder()
.title("Category")
.use_subtitle(true)
.tooltip_text("Choose a category of size presets")
.build();
category_row.set_model(Some(&gtk::StringList::new(CATEGORIES)));
category_row.set_list_factory(Some(&super::full_text_list_factory()));
let size_row = adw::ComboRow::builder()
.title("Size")
.subtitle("Select a preset to fill dimensions")
.use_subtitle(true)
.tooltip_text("Pick a preset size to fill dimensions")
.build();
rebuild_size_model(&size_row, 0);
preset_group.add(&category_row);
preset_group.add(&size_row);
controls.append(&preset_group);
// --- Group 2: Dimensions ---
let dims_group = adw::PreferencesGroup::builder()
.title("Dimensions")
.build();
// Custom horizontal row: [W] [width_spin] [lock] [height_spin] [H] [px|%]
let dim_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.margin_start(12)
.margin_end(12)
.margin_top(10)
.margin_bottom(10)
.halign(gtk::Align::Center)
.build();
let w_label = gtk::Label::builder()
.label("W")
.css_classes(["dim-label"])
.build();
w_label.set_accessible_role(gtk::AccessibleRole::Presentation);
let width_spin = gtk::SpinButton::builder()
.adjustment(&gtk::Adjustment::new(
cfg.resize_width as f64, 0.0, 10000.0, 1.0, 100.0, 0.0,
))
.numeric(true)
.width_chars(6)
.tooltip_text("Width")
.build();
width_spin.update_property(&[
gtk::accessible::Property::Label("Width"),
]);
let lock_btn = gtk::ToggleButton::builder()
.icon_name("changes-prevent-symbolic")
.active(true)
.tooltip_text("Aspect ratio locked")
.build();
lock_btn.update_property(&[
gtk::accessible::Property::Label("Lock aspect ratio"),
]);
let height_spin = gtk::SpinButton::builder()
.adjustment(&gtk::Adjustment::new(
cfg.resize_height as f64, 0.0, 10000.0, 1.0, 100.0, 0.0,
))
.numeric(true)
.width_chars(6)
.tooltip_text("Height")
.build();
height_spin.update_property(&[
gtk::accessible::Property::Label("Height"),
]);
let h_label = gtk::Label::builder()
.label("H")
.css_classes(["dim-label"])
.build();
h_label.set_accessible_role(gtk::AccessibleRole::Presentation);
// Unit segmented toggle (px / %)
let unit_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
unit_box.add_css_class("linked");
unit_box.update_property(&[
gtk::accessible::Property::Label("Dimension unit toggle"),
]);
let px_btn = gtk::Button::builder()
.label("px")
.tooltip_text("Use pixel dimensions (currently active)")
.build();
px_btn.update_property(&[
gtk::accessible::Property::Label("Pixels - currently active"),
]);
let pct_btn = gtk::Button::builder()
.label("%")
.tooltip_text("Use percentage dimensions")
.build();
pct_btn.update_property(&[
gtk::accessible::Property::Label("Percentage"),
]);
px_btn.add_css_class("suggested-action");
unit_box.append(&px_btn);
unit_box.append(&pct_btn);
dim_row.append(&w_label);
dim_row.append(&width_spin);
dim_row.append(&lock_btn);
dim_row.append(&height_spin);
dim_row.append(&h_label);
dim_row.append(&unit_box);
dims_group.add(&dim_row);
// Mode
let mode_row = adw::ComboRow::builder()
.title("Mode")
.subtitle("How dimensions are applied to images")
.use_subtitle(true)
.tooltip_text("Exact stretches to dimensions; Fit keeps aspect ratio")
.build();
mode_row.set_model(Some(&gtk::StringList::new(&[
"Exact Size",
"Fit Within Box",
])));
mode_row.set_list_factory(Some(&super::full_text_list_factory()));
// Upscale
let upscale_row = adw::SwitchRow::builder()
.title("Allow Upscaling")
.subtitle("Enlarge images smaller than target size")
.active(cfg.allow_upscale)
.tooltip_text("When off, images smaller than target are left as-is")
.build();
dims_group.add(&mode_row);
dims_group.add(&upscale_row);
controls.append(&dims_group);
// --- Group 3: Advanced ---
let advanced_group = adw::PreferencesGroup::builder()
.title("Advanced")
.build();
let advanced_expander = adw::ExpanderRow::builder()
.title("Advanced Settings")
.subtitle("Resize algorithm and output DPI")
.show_enable_switch(false)
.expanded(state.is_section_expanded("resize-advanced"))
.build();
{
let st = state.clone();
advanced_expander.connect_expanded_notify(move |row| {
st.set_section_expanded("resize-advanced", row.is_expanded());
});
}
let algorithm_row = adw::ComboRow::builder()
.title("Resize Algorithm")
.use_subtitle(true)
.build();
algorithm_row.set_model(Some(&gtk::StringList::new(&[
"Lanczos3 (Best quality)",
"CatmullRom (Good, faster)",
"Bilinear (Fast)",
"Nearest (Pixelated)",
])));
algorithm_row.set_list_factory(Some(&super::full_text_list_factory()));
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(&algorithm_row);
advanced_expander.add_row(&dpi_row);
advanced_group.add(&advanced_expander);
controls.append(&advanced_group);
controls_scroll.set_child(Some(&controls));
split.append(&preview_box);
split.append(&controls_scroll);
outer.append(&split);
// === SHARED STATE ===
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 preview_upscale = std::rc::Rc::new(std::cell::Cell::new(cfg.allow_upscale));
let preview_mode = std::rc::Rc::new(std::cell::Cell::new(cfg.resize_mode));
let preview_algo = std::rc::Rc::new(std::cell::Cell::new(0u32));
let preview_index = std::rc::Rc::new(std::cell::Cell::new(0usize));
let is_pct = std::rc::Rc::new(std::cell::Cell::new(false));
let updating = std::rc::Rc::new(std::cell::Cell::new(false));
let loaded_files = state.loaded_files.clone();
drop(cfg);
// === RENDER CLOSURE ===
let resize_preview_gen: Rc<Cell<u32>> = Rc::new(Cell::new(0));
let render_thumb = {
let lf = loaded_files.clone();
let pw = preview_width.clone();
let ph = preview_height.clone();
let pu = preview_upscale.clone();
let pm = preview_mode.clone();
let pa = preview_algo.clone();
let pi = preview_index.clone();
let pic = thumb_picture.clone();
let dlbl = dims_label.clone();
let npl = no_preview_label.clone();
let bind_gen = resize_preview_gen.clone();
std::rc::Rc::new(move || {
let files = lf.borrow();
if files.is_empty() {
pic.set_paintable(gtk::gdk::Paintable::NONE);
pic.set_visible(false);
npl.set_visible(true);
npl.set_label("Add images to see resize preview");
dlbl.set_label("");
return;
}
let idx = pi.get() % files.len();
let Some(current) = files.get(idx) else { return };
pic.set_visible(true);
npl.set_visible(false);
let tw = *pw.borrow();
let th = *ph.borrow();
if tw == 0 && th == 0 {
pic.set_paintable(gtk::gdk::Paintable::NONE);
npl.set_visible(true);
npl.set_label("Set dimensions to preview");
pic.set_visible(false);
dlbl.set_label("");
return;
}
let file_count = files.len();
let (orig_w, orig_h) = get_image_dims(current.as_path());
let actual_tw = if tw > 0 { tw } else { orig_w };
let actual_th = if th > 0 {
th
} else if tw > 0 {
let scale = tw as f64 / orig_w as f64;
(orig_h as f64 * scale).round() as u32
} else {
orig_h
};
let allow_up = pu.get();
let (render_tw, render_th) = if !allow_up {
(actual_tw.min(orig_w), actual_th.min(orig_h))
} else {
(actual_tw, actual_th)
};
let scale_pct = if orig_w > 0 {
(render_tw as f64 / orig_w as f64 * 100.0).round() as u32
} else {
100
};
let counter = if file_count > 1 {
format!(" [{}/{}]", idx + 1, file_count)
} else {
String::new()
};
let clamp_note = if !allow_up && (actual_tw > orig_w || actual_th > orig_h) {
" (clamped)"
} else {
""
};
dlbl.set_label(&format!(
"{} x {} -> {} x {} ({}%){}{}", orig_w, orig_h,
render_tw, render_th, scale_pct, clamp_note, counter,
));
let my_gen = bind_gen.get().wrapping_add(1);
bind_gen.set(my_gen);
let gen_check = bind_gen.clone();
let path = current.clone();
let pic = pic.clone();
let algo = algo_index_to_filter(pa.get());
let mode = pm.get();
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 target_w = if render_tw > 0 { render_tw } else { img.width().max(1) };
let target_h = if render_th > 0 {
render_th
} else if img.width() > 0 {
let scale = target_w as f64 / img.width() as f64;
(img.height() as f64 * scale).round().max(1.0) as u32
} else {
target_w
};
let resized = if mode == 0 && render_th > 0 {
// Exact: stretch to exact dimensions
img.resize_exact(target_w.min(1024), target_h.min(1024), algo)
} else {
// Fit within box (or width-only): maintain aspect ratio
img.resize(target_w.min(1024), target_h.min(1024), algo)
};
let mut buf = Vec::new();
resized.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,
}
});
})
};
// === WIRE SIGNALS ===
// Enable toggle
{
let jc = state.job_config.clone();
enable_row.connect_active_notify(move |row| {
jc.borrow_mut().resize_enabled = row.is_active();
});
}
// Category selection - rebuild the size dropdown
{
let sr = size_row.clone();
category_row.connect_selected_notify(move |row| {
rebuild_size_model(&sr, row.selected());
});
}
// Size selection - auto-fill dimensions and lock aspect ratio
{
let ws = width_spin.clone();
let hs = height_spin.clone();
let lb = lock_btn.clone();
let jc = state.job_config.clone();
let pw = preview_width.clone();
let ph = preview_height.clone();
let upd = updating.clone();
let ip = is_pct.clone();
let px = px_btn.clone();
let pct = pct_btn.clone();
let rt = render_thumb.clone();
let cr = category_row.clone();
size_row.connect_selected_notify(move |row| {
let sel = row.selected() as usize;
if sel == 0 {
return; // "(select a size)" - don't change anything
}
let presets = presets_for_category(cr.selected());
let Some(&(_, w, h)) = presets.get(sel - 1) else { return };
// Switch to pixels if currently in percentage
if ip.get() {
ip.set(false);
px.add_css_class("suggested-action");
pct.remove_css_class("suggested-action");
ws.set_range(0.0, 10000.0);
ws.set_increments(1.0, 100.0);
hs.set_range(0.0, 10000.0);
hs.set_increments(1.0, 100.0);
}
upd.set(true);
ws.set_value(w as f64);
hs.set_value(h as f64);
upd.set(false);
// Lock aspect ratio
if !lb.is_active() {
lb.set_active(true);
}
let mut c = jc.borrow_mut();
c.resize_width = w;
c.resize_height = h;
*pw.borrow_mut() = w;
*ph.borrow_mut() = h;
rt();
});
}
// Lock button icon change
{
let lb = lock_btn.clone();
lock_btn.connect_active_notify(move |btn| {
if btn.is_active() {
lb.set_icon_name("changes-prevent-symbolic");
lb.set_tooltip_text(Some("Aspect ratio locked"));
lb.update_property(&[
gtk::accessible::Property::Label("Aspect ratio locked - click to unlock"),
]);
} else {
lb.set_icon_name("changes-allow-symbolic");
lb.set_tooltip_text(Some("Aspect ratio unlocked"));
lb.update_property(&[
gtk::accessible::Property::Label("Aspect ratio unlocked - click to lock"),
]);
}
});
}
// Width spin with aspect lock
{
let jc = state.job_config.clone();
let pw = preview_width.clone();
let ph = preview_height.clone();
let rt = render_thumb.clone();
let lb = lock_btn.clone();
let hs = height_spin.clone();
let files = state.loaded_files.clone();
let upd = updating.clone();
let ip = is_pct.clone();
let pr = size_row.clone();
width_spin.connect_value_notify(move |spin| {
if upd.get() { return; }
let val = spin.value();
let pixel_val = if ip.get() {
let dims = get_first_image_dims(&files.borrow());
(val / 100.0 * dims.0 as f64).round() as u32
} else {
val as u32
};
jc.borrow_mut().resize_width = pixel_val;
*pw.borrow_mut() = pixel_val;
// Reset preset to Custom when manually editing
if pr.selected() != 0 {
upd.set(true);
pr.set_selected(0);
upd.set(false);
}
if lb.is_active() && val > 0.0 {
if ip.get() {
upd.set(true);
hs.set_value(val);
let dims = get_first_image_dims(&files.borrow());
let pixel_h = (val / 100.0 * dims.1 as f64).round() as u32;
jc.borrow_mut().resize_height = pixel_h;
*ph.borrow_mut() = pixel_h;
upd.set(false);
} else {
let aspect = get_first_image_aspect(&files.borrow());
if aspect > 0.0 {
let new_h = (val / aspect).round() as u32;
upd.set(true);
hs.set_value(new_h as f64);
jc.borrow_mut().resize_height = new_h;
*ph.borrow_mut() = new_h;
upd.set(false);
}
}
}
rt();
});
}
// Height spin with aspect lock
{
let jc = state.job_config.clone();
let pw = preview_width.clone();
let ph = preview_height.clone();
let rt = render_thumb.clone();
let lb = lock_btn.clone();
let ws = width_spin.clone();
let files = state.loaded_files.clone();
let upd = updating.clone();
let ip = is_pct.clone();
let pr = size_row.clone();
height_spin.connect_value_notify(move |spin| {
if upd.get() { return; }
let val = spin.value();
let pixel_val = if ip.get() {
let dims = get_first_image_dims(&files.borrow());
(val / 100.0 * dims.1 as f64).round() as u32
} else {
val as u32
};
jc.borrow_mut().resize_height = pixel_val;
*ph.borrow_mut() = pixel_val;
// Reset preset to Custom when manually editing
if pr.selected() != 0 {
upd.set(true);
pr.set_selected(0);
upd.set(false);
}
if lb.is_active() && val > 0.0 {
if ip.get() {
upd.set(true);
ws.set_value(val);
let dims = get_first_image_dims(&files.borrow());
let pixel_w = (val / 100.0 * dims.0 as f64).round() as u32;
jc.borrow_mut().resize_width = pixel_w;
*pw.borrow_mut() = pixel_w;
upd.set(false);
} else {
let aspect = get_first_image_aspect(&files.borrow());
if aspect > 0.0 {
let new_w = (val * aspect).round() as u32;
upd.set(true);
ws.set_value(new_w as f64);
jc.borrow_mut().resize_width = new_w;
*pw.borrow_mut() = new_w;
upd.set(false);
}
}
}
rt();
});
}
// Unit toggle: px button
{
let pct = pct_btn.clone();
let px = px_btn.clone();
let ip = is_pct.clone();
let ws = width_spin.clone();
let hs = height_spin.clone();
let files = state.loaded_files.clone();
let upd = updating.clone();
let jc = state.job_config.clone();
let pw = preview_width.clone();
let ph = preview_height.clone();
let rt = render_thumb.clone();
px_btn.connect_clicked(move |_| {
if !ip.get() { return; } // already pixels
ip.set(false);
px.add_css_class("suggested-action");
pct.remove_css_class("suggested-action");
px.update_property(&[
gtk::accessible::Property::Label("Pixels - currently active"),
]);
pct.update_property(&[
gtk::accessible::Property::Label("Percentage"),
]);
px.set_tooltip_text(Some("Use pixel dimensions (currently active)"));
pct.set_tooltip_text(Some("Use percentage dimensions"));
let dims = get_first_image_dims(&files.borrow());
let pct_w = ws.value();
let pct_h = hs.value();
let pixel_w = (pct_w / 100.0 * dims.0 as f64).round();
let pixel_h = (pct_h / 100.0 * dims.1 as f64).round();
upd.set(true);
ws.set_range(0.0, 10000.0);
ws.set_increments(1.0, 100.0);
hs.set_range(0.0, 10000.0);
hs.set_increments(1.0, 100.0);
ws.set_value(pixel_w);
hs.set_value(pixel_h);
upd.set(false);
let mut c = jc.borrow_mut();
c.resize_width = pixel_w as u32;
c.resize_height = pixel_h as u32;
*pw.borrow_mut() = pixel_w as u32;
*ph.borrow_mut() = pixel_h as u32;
rt();
});
}
// Unit toggle: % button
{
let px = px_btn.clone();
let pct = pct_btn.clone();
let ip = is_pct.clone();
let ws = width_spin.clone();
let hs = height_spin.clone();
let files = state.loaded_files.clone();
let upd = updating.clone();
let rt = render_thumb.clone();
let jc = state.job_config.clone();
let pw = preview_width.clone();
let ph = preview_height.clone();
pct_btn.connect_clicked(move |_| {
if ip.get() { return; } // already percentage
ip.set(true);
pct.add_css_class("suggested-action");
px.remove_css_class("suggested-action");
pct.update_property(&[
gtk::accessible::Property::Label("Percentage - currently active"),
]);
px.update_property(&[
gtk::accessible::Property::Label("Pixels"),
]);
pct.set_tooltip_text(Some("Use percentage dimensions (currently active)"));
px.set_tooltip_text(Some("Use pixel dimensions"));
let dims = get_first_image_dims(&files.borrow());
let cur_w = ws.value();
let cur_h = hs.value();
let pct_w = if dims.0 > 0 { (cur_w / dims.0 as f64 * 100.0).round() } else { 100.0 };
let pct_h = if dims.1 > 0 { (cur_h / dims.1 as f64 * 100.0).round() } else { 100.0 };
upd.set(true);
ws.set_range(0.0, 1000.0);
ws.set_increments(1.0, 10.0);
hs.set_range(0.0, 1000.0);
hs.set_increments(1.0, 10.0);
ws.set_value(pct_w);
hs.set_value(pct_h);
upd.set(false);
// Update job_config with the pixel values
let pixel_w = (pct_w / 100.0 * dims.0 as f64).round() as u32;
let pixel_h = (pct_h / 100.0 * dims.1 as f64).round() as u32;
let mut c = jc.borrow_mut();
c.resize_width = pixel_w;
c.resize_height = pixel_h;
*pw.borrow_mut() = pixel_w;
*ph.borrow_mut() = pixel_h;
rt();
});
}
// Mode toggle - update labels and job_config
{
let ws = width_spin.clone();
let hs = height_spin.clone();
let jc = state.job_config.clone();
let rt = render_thumb.clone();
let pmode = preview_mode.clone();
mode_row.connect_selected_notify(move |row| {
jc.borrow_mut().resize_mode = row.selected();
pmode.set(row.selected());
if row.selected() == 1 {
ws.set_tooltip_text(Some("Maximum width"));
hs.set_tooltip_text(Some("Maximum height"));
} else {
ws.set_tooltip_text(Some("Width"));
hs.set_tooltip_text(Some("Height"));
}
rt();
});
}
// Upscale toggle
{
let jc = state.job_config.clone();
let pu = preview_upscale.clone();
let rt = render_thumb.clone();
upscale_row.connect_active_notify(move |row| {
jc.borrow_mut().allow_upscale = row.is_active();
pu.set(row.is_active());
rt();
});
}
// Algorithm
{
let jc = state.job_config.clone();
let pa = preview_algo.clone();
let rt = render_thumb.clone();
algorithm_row.connect_selected_notify(move |row| {
jc.borrow_mut().resize_algorithm = row.selected();
pa.set(row.selected());
rt();
});
}
// DPI
{
let jc = state.job_config.clone();
dpi_row.connect_value_notify(move |row| {
jc.borrow_mut().output_dpi = row.value() as u32;
});
}
// Click preview to cycle images
{
let pi = preview_index.clone();
let rt = render_thumb.clone();
let lf = loaded_files.clone();
let click = gtk::GestureClick::new();
click.connect_released(move |gesture, _, _, _| {
let count = lf.borrow().len();
if count > 1 {
pi.set((pi.get() + 1) % count);
rt();
}
gesture.set_state(gtk::EventSequenceState::Claimed);
});
thumb_picture.set_can_target(true);
thumb_picture.set_focusable(true);
thumb_picture.add_controller(click);
thumb_picture.set_cursor_from_name(Some("pointer"));
}
// Keyboard support for preview cycling (Space/Enter)
{
let pi = preview_index.clone();
let rt = render_thumb.clone();
let lf = loaded_files.clone();
let key = gtk::EventControllerKey::new();
key.connect_key_pressed(move |_, keyval, _, _| {
if keyval == gtk::gdk::Key::space || keyval == gtk::gdk::Key::Return {
let count = lf.borrow().len();
if count > 1 {
pi.set((pi.get() + 1) % count);
rt();
}
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
thumb_picture.add_controller(key);
}
// Initial render
{
let rt = render_thumb.clone();
glib::idle_add_local_once(move || rt());
}
let page = adw::NavigationPage::builder()
.title("Resize")
.tag("step-resize")
.child(&outer)
.build();
// Sync enable toggle and re-render on page map
{
let rt = render_thumb.clone();
let jc = state.job_config.clone();
let er = enable_row.clone();
page.connect_map(move |_| {
let enabled = jc.borrow().resize_enabled;
er.set_active(enabled);
rt();
});
}
page
}