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)