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);
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", &["<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) {
@@ -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::<gtk::Label>() {
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::<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 {
if bytes < 1024 {
format!("{} B", bytes)

View File

@@ -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<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);
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(&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()
.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(&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![
("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(&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);
});
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(&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);
});
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| {