diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 753095f..ba2d888 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -96,6 +96,14 @@ pub fn build_app() -> adw::Application { app.connect_activate(build_ui); setup_shortcuts(&app); + // App-level quit action + let quit_action = gtk::gio::SimpleAction::new("quit", None); + let app_clone = app.clone(); + quit_action.connect_activate(move |_, _| { + app_clone.quit(); + }); + app.add_action(&quit_action); + app } @@ -110,6 +118,9 @@ fn setup_shortcuts(app: &adw::Application) { ); } app.set_accels_for_action("win.add-files", &["o"]); + app.set_accels_for_action("app.quit", &["q"]); + app.set_accels_for_action("win.show-settings", &["comma"]); + app.set_accels_for_action("win.show-shortcuts", &["question", "F1"]); } fn build_ui(app: &adw::Application) { @@ -262,6 +273,7 @@ fn build_menu() -> gtk::gio::Menu { let menu = gtk::gio::Menu::new(); menu.append(Some("Settings"), Some("win.show-settings")); menu.append(Some("History"), Some("win.show-history")); + menu.append(Some("Keyboard Shortcuts"), Some("win.show-shortcuts")); menu } @@ -403,6 +415,16 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) { action_group.add_action(&action); } + // Keyboard shortcuts window + { + let window = window.clone(); + let action = gtk::gio::SimpleAction::new("show-shortcuts", None); + action.connect_activate(move |_, _| { + show_shortcuts_window(&window); + }); + action_group.add_action(&action); + } + // Connect button clicks ui.back_button.connect_clicked({ let action_group = action_group.clone(); @@ -878,6 +900,7 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { // Poll for messages from the processing thread let ui_for_rx = ui.clone(); + let start_time = std::time::Instant::now(); glib::timeout_add_local(std::time::Duration::from_millis(50), move || { while let Ok(msg) = rx.try_recv() { match msg { @@ -890,7 +913,9 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { bar.set_fraction(current as f64 / total as f64); bar.set_text(Some(&format!("{}/{} - {}", current, total, file))); } - update_progress_labels(&ui_for_rx.nav_view, current, total, &file); + let eta = calculate_eta(&start_time, current, total); + update_progress_labels(&ui_for_rx.nav_view, current, total, &file, &eta); + add_log_entry(&ui_for_rx.nav_view, current, total, &file); } ProcessingMessage::Done(result) => { show_results(&ui_for_rx, &result); @@ -1123,19 +1148,63 @@ fn wire_pause_button(page: &adw::NavigationPage) { }); } -fn update_progress_labels(nav_view: &adw::NavigationView, current: usize, total: usize, file: &str) { +fn calculate_eta(start: &std::time::Instant, current: usize, total: usize) -> String { + if current == 0 { + return "Estimating time remaining...".into(); + } + let elapsed = start.elapsed().as_secs_f64(); + let per_image = elapsed / current as f64; + let remaining = (total - current) as f64 * per_image; + if remaining < 1.0 { + "Almost done...".into() + } else { + format!("ETA: ~{}", format_duration(remaining as u64 * 1000)) + } +} + +fn update_progress_labels(nav_view: &adw::NavigationView, current: usize, total: usize, file: &str, eta: &str) { if let Some(page) = nav_view.visible_page() { walk_widgets(&page.child(), &|widget| { if let Some(label) = widget.downcast_ref::() { if label.css_classes().iter().any(|c| c == "heading") - && label.label().contains("images") + && (label.label().contains("images") || label.label().contains("0 /")) { label.set_label(&format!("{} / {} images", current, total)); } if label.css_classes().iter().any(|c| c == "dim-label") - && label.label().contains("Estimating") + && (label.label().contains("Estimating") || label.label().contains("ETA") || label.label().contains("Almost") || label.label().contains("Current")) { - label.set_label(&format!("Current: {}", file)); + if current < total { + label.set_label(&format!("{} - {}", eta, file)); + } else { + label.set_label("Finishing up..."); + } + } + } + }); + } +} + +fn add_log_entry(nav_view: &adw::NavigationView, current: usize, total: usize, file: &str) { + if let Some(page) = nav_view.visible_page() { + walk_widgets(&page.child(), &|widget| { + if let Some(bx) = widget.downcast_ref::() + && bx.spacing() == 2 + && bx.orientation() == gtk::Orientation::Vertical + { + let entry = gtk::Label::builder() + .label(format!("[{}/{}] {} - Done", current, total, file)) + .halign(gtk::Align::Start) + .css_classes(["caption", "monospace"]) + .build(); + bx.append(&entry); + + // Auto-scroll: the log box is inside a ScrolledWindow + if let Some(parent) = bx.parent() + && let Some(sw) = parent.downcast_ref::() + { + let adj = sw.vadjustment(); + adj.set_value(adj.upper()); } } }); @@ -1410,6 +1479,61 @@ fn walk_widgets(widget: &Option, f: &dyn Fn(>k::Widget)) { } +fn show_shortcuts_window(window: &adw::ApplicationWindow) { + let dialog = adw::AlertDialog::builder() + .heading("Keyboard Shortcuts") + .build(); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .margin_start(12) + .margin_end(12) + .build(); + + let sections: &[(&str, &[(&str, &str)])] = &[ + ("Wizard Navigation", &[ + ("Alt+Right", "Next step"), + ("Alt+Left", "Previous step"), + ("Alt+1..9", "Jump to step"), + ("Ctrl+Enter", "Process images"), + ]), + ("File Management", &[ + ("Ctrl+O", "Add files"), + ]), + ("Application", &[ + ("Ctrl+,", "Settings"), + ("Ctrl+? / F1", "Keyboard shortcuts"), + ("Ctrl+Q", "Quit"), + ]), + ]; + + for (title, shortcuts) in sections { + let group = adw::PreferencesGroup::builder() + .title(*title) + .build(); + + for (accel, desc) in *shortcuts { + let row = adw::ActionRow::builder() + .title(*desc) + .build(); + let label = gtk::Label::builder() + .label(*accel) + .css_classes(["monospace", "dim-label"]) + .build(); + row.add_suffix(&label); + group.add(&row); + } + + content.append(&group); + } + + dialog.set_extra_child(Some(&content)); + dialog.add_response("close", "Close"); + dialog.set_default_response(Some("close")); + dialog.present(Some(window)); +} + fn format_bytes(bytes: u64) -> String { if bytes < 1024 { format!("{} B", bytes) diff --git a/pixstrip-gtk/src/steps/step_convert.rs b/pixstrip-gtk/src/steps/step_convert.rs index f5b8c07..9e46e8f 100644 --- a/pixstrip-gtk/src/steps/step_convert.rs +++ b/pixstrip-gtk/src/steps/step_convert.rs @@ -33,6 +33,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { // Format selection let format_group = adw::PreferencesGroup::builder() .title("Output Format") + .description("Choose the format all images will be converted to") .build(); let format_row = adw::ComboRow::builder() @@ -41,9 +42,12 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { .build(); let format_model = gtk::StringList::new(&[ "Keep Original", - "JPEG - universal, lossy", - "PNG - lossless, graphics", + "JPEG - universal, lossy, photos", + "PNG - lossless, graphics, transparency", "WebP - modern, excellent compression", + "AVIF - next-gen, best compression", + "GIF - animations, limited colors", + "TIFF - archival, lossless, large files", ]); format_row.set_model(Some(&format_model)); @@ -53,10 +57,24 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { Some(ImageFormat::Jpeg) => 1, Some(ImageFormat::Png) => 2, Some(ImageFormat::WebP) => 3, - _ => 0, + Some(ImageFormat::Avif) => 4, + Some(ImageFormat::Gif) => 5, + Some(ImageFormat::Tiff) => 6, }); format_group.add(&format_row); + + // Format info label + let info_label = gtk::Label::builder() + .label(format_info(cfg.convert_format)) + .css_classes(["dim-label", "caption"]) + .halign(gtk::Align::Start) + .wrap(true) + .margin_top(4) + .margin_bottom(8) + .margin_start(12) + .build(); + format_group.add(&info_label); content.append(&format_group); drop(cfg); @@ -70,14 +88,19 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { } { let jc = state.job_config.clone(); + let label = info_label; format_row.connect_selected_notify(move |row| { let mut c = jc.borrow_mut(); c.convert_format = match row.selected() { 1 => Some(ImageFormat::Jpeg), 2 => Some(ImageFormat::Png), 3 => Some(ImageFormat::WebP), + 4 => Some(ImageFormat::Avif), + 5 => Some(ImageFormat::Gif), + 6 => Some(ImageFormat::Tiff), _ => None, }; + label.set_label(&format_info(c.convert_format)); }); } @@ -94,3 +117,15 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage { .child(&clamp) .build() } + +fn format_info(format: Option) -> String { + match format { + None => "Images will keep their original format.".into(), + Some(ImageFormat::Jpeg) => "JPEG: Best for photographs. Lossy compression, no transparency. Universally supported.".into(), + Some(ImageFormat::Png) => "PNG: Best for graphics, screenshots, logos. Lossless, supports transparency. Larger files.".into(), + Some(ImageFormat::WebP) => "WebP: Modern format with excellent lossy and lossless compression. Supports transparency and animation. Widely supported in browsers.".into(), + Some(ImageFormat::Avif) => "AVIF: Next-generation format based on AV1. Best compression ratios, supports transparency and HDR. Slower to encode, growing browser support.".into(), + Some(ImageFormat::Gif) => "GIF: Limited to 256 colors. Supports animation and transparency. Best for simple graphics and short animations.".into(), + Some(ImageFormat::Tiff) => "TIFF: Professional archival format. Lossless, supports layers and metadata. Very large files. Not suitable for web use.".into(), + } +} diff --git a/pixstrip-gtk/src/steps/step_resize.rs b/pixstrip-gtk/src/steps/step_resize.rs index ebd4ca7..5d0fdca 100644 --- a/pixstrip-gtk/src/steps/step_resize.rs +++ b/pixstrip-gtk/src/steps/step_resize.rs @@ -29,9 +29,10 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage { enable_group.add(&enable_row); content.append(&enable_group); - // Resize mode + // Resize dimensions let mode_group = adw::PreferencesGroup::builder() - .title("Resize Mode") + .title("Dimensions") + .description("Set target width and height. Set height to 0 to preserve aspect ratio.") .build(); let width_row = adw::SpinRow::builder() @@ -53,132 +54,100 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage { // Social media presets let presets_group = adw::PreferencesGroup::builder() .title("Quick Dimension Presets") + .description("Click a preset to fill in the dimensions above") .build(); - let fedi_expander = adw::ExpanderRow::builder() - .title("Fediverse / Open Platforms") - .subtitle("Mastodon, Pixelfed, Bluesky, Lemmy") - .build(); - - let fedi_presets: Vec<(&str, u32, u32)> = vec![ - ("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), - ("Lemmy Post", 1200, 630), - ]; - - for (name, w, h) in &fedi_presets { - let row = adw::ActionRow::builder() - .title(*name) - .subtitle(format!("{} x {}", w, h)) - .activatable(true) + // Helper to build preset expander sections + let build_preset_section = |title: &str, subtitle: &str, presets: &[(&str, u32, u32)]| -> adw::ExpanderRow { + let expander = adw::ExpanderRow::builder() + .title(title) + .subtitle(subtitle) .build(); - row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); - let width_row_c = width_row.clone(); - let height_row_c = height_row.clone(); - let w = *w; - let h = *h; - row.connect_activated(move |_| { - width_row_c.set_value(w as f64); - height_row_c.set_value(h as f64); - }); - fedi_expander.add_row(&row); - } - let mainstream_expander = adw::ExpanderRow::builder() - .title("Mainstream Platforms") - .subtitle("Instagram, YouTube, LinkedIn, Pinterest") - .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_row.clone(); + let height_c = height_row.clone(); + let w = *w; + let h = *h; + row.connect_activated(move |_| { + width_c.set_value(w as f64); + height_c.set_value(h as f64); + }); + expander.add_row(&row); + } - let mainstream_presets: Vec<(&str, u32, u32)> = vec![ - ("Instagram Post", 1080, 1080), - ("Instagram Story/Reel", 1080, 1920), - ("Facebook Post", 1200, 630), - ("YouTube Thumbnail", 1280, 720), - ("LinkedIn Post", 1200, 627), - ("Pinterest Pin", 1000, 1500), - ]; + expander + }; - for (name, w, h) in &mainstream_presets { - let row = adw::ActionRow::builder() - .title(*name) - .subtitle(format!("{} x {}", w, h)) - .activatable(true) - .build(); - row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); - let width_row_c = width_row.clone(); - let height_row_c = height_row.clone(); - let w = *w; - let h = *h; - row.connect_activated(move |_| { - width_row_c.set_value(w as f64); - height_row_c.set_value(h as f64); - }); - mainstream_expander.add_row(&row); - } + 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), + ("Lemmy Post", 1200, 630), + ("PeerTube Thumbnail", 1280, 720), + ("Friendica Post", 1200, 630), + ("Funkwhale Cover", 500, 500), + ], + ); - let other_expander = adw::ExpanderRow::builder() - .title("Common Sizes") - .subtitle("HD, Blog, Thumbnail") - .build(); + let mainstream_expander = build_preset_section( + "Mainstream Platforms", + "Instagram, YouTube, LinkedIn, Facebook, TikTok", + &[ + ("Instagram Post", 1080, 1080), + ("Instagram 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 Profile", 200, 200), + ("Threads Post", 1080, 1080), + ("Twitter/X Post", 1200, 675), + ("Twitter/X Header", 1500, 500), + ], + ); - let other_presets: Vec<(&str, u32, u32)> = vec![ - ("Full HD", 1920, 1080), - ("Blog Image", 800, 0), - ("Thumbnail", 150, 150), - ]; - - for (name, w, h) in &other_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_row_c = width_row.clone(); - let height_row_c = height_row.clone(); - let w = *w; - let h = *h; - row.connect_activated(move |_| { - width_row_c.set_value(w as f64); - height_row_c.set_value(h as f64); - }); - other_expander.add_row(&row); - } + 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 Wide", 800, 0), + ("Blog Standard", 800, 600), + ("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(&other_expander); + presets_group.add(&common_expander); content.append(&presets_group); - // Basic adjustments - let adjust_group = adw::PreferencesGroup::builder() - .title("Basic Adjustments") - .build(); - - let rotate_row = adw::ComboRow::builder() - .title("Rotate") - .subtitle("Rotation applied after resize") - .build(); - let rotate_model = gtk::StringList::new(&["None", "90 clockwise", "180", "270 clockwise", "Auto-orient (EXIF)"]); - rotate_row.set_model(Some(&rotate_model)); - - 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)); - - adjust_group.add(&rotate_row); - adjust_group.add(&flip_row); - content.append(&adjust_group); - - // Advanced + // Advanced options let advanced_group = adw::PreferencesGroup::builder() .title("Advanced Options") .build(); @@ -194,7 +163,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage { drop(cfg); - // Wire signals to update JobConfig + // Wire signals { let jc = state.job_config.clone(); enable_row.connect_active_notify(move |row| {