Add ETA calculation, activity log, keyboard shortcuts, expand format options

- Processing: ETA calculated from elapsed time and progress, per-image log
  entries added to activity log with auto-scroll
- Keyboard shortcuts: Ctrl+Q quit, Ctrl+, settings, Ctrl+?/F1 shortcuts dialog
- Shortcuts dialog: AdwAlertDialog with all keyboard shortcuts listed
- Hamburger menu: added Keyboard Shortcuts entry
- Convert step: added AVIF, GIF, TIFF format options with descriptions
- Resize step: removed duplicate rotate/flip (now in Adjustments step),
  added missing social media presets (PeerTube, Friendica, Funkwhale,
  Instagram Portrait, Facebook Cover/Profile, LinkedIn Cover/Profile,
  TikTok, YouTube Channel Art, Threads, Twitter/X, 4K, etc.)
This commit is contained in:
2026-03-06 12:20:04 +02:00
parent 8154324929
commit e8cdddd08d
3 changed files with 252 additions and 124 deletions

View File

@@ -96,6 +96,14 @@ pub fn build_app() -> adw::Application {
app.connect_activate(build_ui); app.connect_activate(build_ui);
setup_shortcuts(&app); 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 app
} }
@@ -110,6 +118,9 @@ fn setup_shortcuts(app: &adw::Application) {
); );
} }
app.set_accels_for_action("win.add-files", &["<Control>o"]); app.set_accels_for_action("win.add-files", &["<Control>o"]);
app.set_accels_for_action("app.quit", &["<Control>q"]);
app.set_accels_for_action("win.show-settings", &["<Control>comma"]);
app.set_accels_for_action("win.show-shortcuts", &["<Control>question", "F1"]);
} }
fn build_ui(app: &adw::Application) { fn build_ui(app: &adw::Application) {
@@ -262,6 +273,7 @@ fn build_menu() -> gtk::gio::Menu {
let menu = gtk::gio::Menu::new(); let menu = gtk::gio::Menu::new();
menu.append(Some("Settings"), Some("win.show-settings")); menu.append(Some("Settings"), Some("win.show-settings"));
menu.append(Some("History"), Some("win.show-history")); menu.append(Some("History"), Some("win.show-history"));
menu.append(Some("Keyboard Shortcuts"), Some("win.show-shortcuts"));
menu menu
} }
@@ -403,6 +415,16 @@ fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
action_group.add_action(&action); 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 // Connect button clicks
ui.back_button.connect_clicked({ ui.back_button.connect_clicked({
let action_group = action_group.clone(); 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 // Poll for messages from the processing thread
let ui_for_rx = ui.clone(); let ui_for_rx = ui.clone();
let start_time = std::time::Instant::now();
glib::timeout_add_local(std::time::Duration::from_millis(50), move || { glib::timeout_add_local(std::time::Duration::from_millis(50), move || {
while let Ok(msg) = rx.try_recv() { while let Ok(msg) = rx.try_recv() {
match msg { 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_fraction(current as f64 / total as f64);
bar.set_text(Some(&format!("{}/{} - {}", current, total, file))); 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) => { ProcessingMessage::Done(result) => {
show_results(&ui_for_rx, &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() { if let Some(page) = nav_view.visible_page() {
walk_widgets(&page.child(), &|widget| { walk_widgets(&page.child(), &|widget| {
if let Some(label) = widget.downcast_ref::<gtk::Label>() { if let Some(label) = widget.downcast_ref::<gtk::Label>() {
if label.css_classes().iter().any(|c| c == "heading") 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)); label.set_label(&format!("{} / {} images", current, total));
} }
if label.css_classes().iter().any(|c| c == "dim-label") 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::<gtk::Box>()
&& 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::<gtk::ScrolledWindow>()
{
let adj = sw.vadjustment();
adj.set_value(adj.upper());
} }
} }
}); });
@@ -1410,6 +1479,61 @@ fn walk_widgets(widget: &Option<gtk::Widget>, f: &dyn Fn(&gtk::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 { fn format_bytes(bytes: u64) -> String {
if bytes < 1024 { if bytes < 1024 {
format!("{} B", bytes) format!("{} B", bytes)

View File

@@ -33,6 +33,7 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
// Format selection // Format selection
let format_group = adw::PreferencesGroup::builder() let format_group = adw::PreferencesGroup::builder()
.title("Output Format") .title("Output Format")
.description("Choose the format all images will be converted to")
.build(); .build();
let format_row = adw::ComboRow::builder() let format_row = adw::ComboRow::builder()
@@ -41,9 +42,12 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
.build(); .build();
let format_model = gtk::StringList::new(&[ let format_model = gtk::StringList::new(&[
"Keep Original", "Keep Original",
"JPEG - universal, lossy", "JPEG - universal, lossy, photos",
"PNG - lossless, graphics", "PNG - lossless, graphics, transparency",
"WebP - modern, excellent compression", "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)); 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::Jpeg) => 1,
Some(ImageFormat::Png) => 2, Some(ImageFormat::Png) => 2,
Some(ImageFormat::WebP) => 3, Some(ImageFormat::WebP) => 3,
_ => 0, Some(ImageFormat::Avif) => 4,
Some(ImageFormat::Gif) => 5,
Some(ImageFormat::Tiff) => 6,
}); });
format_group.add(&format_row); 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); content.append(&format_group);
drop(cfg); drop(cfg);
@@ -70,14 +88,19 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
} }
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
let label = info_label;
format_row.connect_selected_notify(move |row| { format_row.connect_selected_notify(move |row| {
let mut c = jc.borrow_mut(); let mut c = jc.borrow_mut();
c.convert_format = match row.selected() { c.convert_format = match row.selected() {
1 => Some(ImageFormat::Jpeg), 1 => Some(ImageFormat::Jpeg),
2 => Some(ImageFormat::Png), 2 => Some(ImageFormat::Png),
3 => Some(ImageFormat::WebP), 3 => Some(ImageFormat::WebP),
4 => Some(ImageFormat::Avif),
5 => Some(ImageFormat::Gif),
6 => Some(ImageFormat::Tiff),
_ => None, _ => None,
}; };
label.set_label(&format_info(c.convert_format));
}); });
} }
@@ -94,3 +117,15 @@ pub fn build_convert_page(state: &AppState) -> adw::NavigationPage {
.child(&clamp) .child(&clamp)
.build() .build()
} }
fn format_info(format: Option<ImageFormat>) -> 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(),
}
}

View File

@@ -29,9 +29,10 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
enable_group.add(&enable_row); enable_group.add(&enable_row);
content.append(&enable_group); content.append(&enable_group);
// Resize mode // Resize dimensions
let mode_group = adw::PreferencesGroup::builder() 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(); .build();
let width_row = adw::SpinRow::builder() let width_row = adw::SpinRow::builder()
@@ -53,132 +54,100 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
// Social media presets // Social media presets
let presets_group = adw::PreferencesGroup::builder() let presets_group = adw::PreferencesGroup::builder()
.title("Quick Dimension Presets") .title("Quick Dimension Presets")
.description("Click a preset to fill in the dimensions above")
.build(); .build();
let fedi_expander = adw::ExpanderRow::builder() // Helper to build preset expander sections
.title("Fediverse / Open Platforms") let build_preset_section = |title: &str, subtitle: &str, presets: &[(&str, u32, u32)]| -> adw::ExpanderRow {
.subtitle("Mastodon, Pixelfed, Bluesky, Lemmy") let expander = adw::ExpanderRow::builder()
.build(); .title(title)
.subtitle(subtitle)
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)
.build(); .build();
row.add_suffix(&gtk::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() for (name, w, h) in presets {
.title("Mainstream Platforms") let row = adw::ActionRow::builder()
.subtitle("Instagram, YouTube, LinkedIn, Pinterest") .title(*name)
.build(); .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_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![ expander
("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),
];
for (name, w, h) in &mainstream_presets { let fedi_expander = build_preset_section(
let row = adw::ActionRow::builder() "Fediverse / Open Platforms",
.title(*name) "Mastodon, Pixelfed, Bluesky, Lemmy, PeerTube",
.subtitle(format!("{} x {}", w, h)) &[
.activatable(true) ("Mastodon Post", 1920, 1080),
.build(); ("Mastodon Profile", 400, 400),
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic")); ("Mastodon Header", 1500, 500),
let width_row_c = width_row.clone(); ("Pixelfed Post", 1080, 1080),
let height_row_c = height_row.clone(); ("Pixelfed Story", 1080, 1920),
let w = *w; ("Bluesky Post", 1200, 630),
let h = *h; ("Bluesky Profile", 400, 400),
row.connect_activated(move |_| { ("Lemmy Post", 1200, 630),
width_row_c.set_value(w as f64); ("PeerTube Thumbnail", 1280, 720),
height_row_c.set_value(h as f64); ("Friendica Post", 1200, 630),
}); ("Funkwhale Cover", 500, 500),
mainstream_expander.add_row(&row); ],
} );
let other_expander = adw::ExpanderRow::builder() let mainstream_expander = build_preset_section(
.title("Common Sizes") "Mainstream Platforms",
.subtitle("HD, Blog, Thumbnail") "Instagram, YouTube, LinkedIn, Facebook, TikTok",
.build(); &[
("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![ let common_expander = build_preset_section(
("Full HD", 1920, 1080), "Common Sizes",
("Blog Image", 800, 0), "HD, 4K, Blog, Thumbnails",
("Thumbnail", 150, 150), &[
]; ("4K UHD", 3840, 2160),
("Full HD", 1920, 1080),
for (name, w, h) in &other_presets { ("HD Ready", 1280, 720),
let row = adw::ActionRow::builder() ("Blog Wide", 800, 0),
.title(*name) ("Blog Standard", 800, 600),
.subtitle(if *h == 0 { format!("{} wide", w) } else { format!("{} x {}", w, h) }) ("Email Header", 600, 200),
.activatable(true) ("Large Thumbnail", 300, 300),
.build(); ("Small Thumbnail", 150, 150),
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic")); ("Favicon", 32, 32),
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);
}
presets_group.add(&fedi_expander); presets_group.add(&fedi_expander);
presets_group.add(&mainstream_expander); presets_group.add(&mainstream_expander);
presets_group.add(&other_expander); presets_group.add(&common_expander);
content.append(&presets_group); content.append(&presets_group);
// Basic adjustments // Advanced options
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
let advanced_group = adw::PreferencesGroup::builder() let advanced_group = adw::PreferencesGroup::builder()
.title("Advanced Options") .title("Advanced Options")
.build(); .build();
@@ -194,7 +163,7 @@ pub fn build_resize_page(state: &AppState) -> adw::NavigationPage {
drop(cfg); drop(cfg);
// Wire signals to update JobConfig // Wire signals
{ {
let jc = state.job_config.clone(); let jc = state.job_config.clone();
enable_row.connect_active_notify(move |row| { enable_row.connect_active_notify(move |row| {