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
This commit is contained in:
@@ -339,7 +339,7 @@ fn build_ui(app: &adw::Application) {
|
||||
allow_upscale: false,
|
||||
resize_algorithm: 0,
|
||||
output_dpi: 72,
|
||||
adjustments_enabled: false,
|
||||
adjustments_enabled: if remember { sess_state.adjustments_enabled.unwrap_or(false) } else { false },
|
||||
rotation: 0,
|
||||
flip: 0,
|
||||
brightness: 0,
|
||||
@@ -445,6 +445,10 @@ fn build_ui(app: &adw::Application) {
|
||||
.tooltip_text("Help for this step")
|
||||
.build();
|
||||
help_button.add_css_class("flat");
|
||||
help_button.update_property(&[
|
||||
gtk::accessible::Property::Label("Help for this step"),
|
||||
]);
|
||||
help_button.set_widget_name("tour-help-button");
|
||||
header.pack_end(&help_button);
|
||||
|
||||
// Hamburger menu
|
||||
@@ -455,6 +459,7 @@ fn build_ui(app: &adw::Application) {
|
||||
.primary(true)
|
||||
.tooltip_text("Main Menu")
|
||||
.build();
|
||||
menu_button.set_widget_name("tour-menu-button");
|
||||
header.pack_end(&menu_button);
|
||||
|
||||
// Step indicator
|
||||
@@ -462,6 +467,7 @@ fn build_ui(app: &adw::Application) {
|
||||
|
||||
// Navigation view for wizard content
|
||||
let nav_view = adw::NavigationView::new();
|
||||
nav_view.set_widget_name("tour-content");
|
||||
nav_view.set_vexpand(true);
|
||||
nav_view.update_property(&[
|
||||
gtk::accessible::Property::Label("Wizard steps. Use Alt+Left/Right to navigate."),
|
||||
@@ -485,6 +491,7 @@ fn build_ui(app: &adw::Application) {
|
||||
.tooltip_text("Go to next step (Alt+Right)")
|
||||
.build();
|
||||
next_button.add_css_class("suggested-action");
|
||||
next_button.set_widget_name("tour-next-button");
|
||||
|
||||
let bottom_box = gtk::CenterBox::new();
|
||||
bottom_box.set_start_widget(Some(&back_button));
|
||||
@@ -514,6 +521,9 @@ fn build_ui(app: &adw::Application) {
|
||||
.tooltip_text("Watch Folders")
|
||||
.build();
|
||||
watch_button.add_css_class("flat");
|
||||
watch_button.update_property(&[
|
||||
gtk::accessible::Property::Label("Toggle watch folders panel"),
|
||||
]);
|
||||
header.pack_start(&watch_button);
|
||||
|
||||
{
|
||||
@@ -531,6 +541,7 @@ fn build_ui(app: &adw::Application) {
|
||||
.child(step_indicator.widget())
|
||||
.build();
|
||||
indicator_scroll.set_size_request(-1, 52);
|
||||
indicator_scroll.set_widget_name("tour-step-indicator");
|
||||
content_box.append(&indicator_scroll);
|
||||
content_box.append(&nav_view);
|
||||
content_box.append(&watch_revealer);
|
||||
@@ -608,6 +619,7 @@ fn build_ui(app: &adw::Application) {
|
||||
state.resize_enabled = Some(cfg.resize_enabled);
|
||||
state.resize_width = Some(cfg.resize_width);
|
||||
state.resize_height = Some(cfg.resize_height);
|
||||
state.adjustments_enabled = Some(cfg.adjustments_enabled);
|
||||
state.convert_enabled = Some(cfg.convert_enabled);
|
||||
state.convert_format = cfg.convert_format.map(|f| format!("{:?}", f));
|
||||
state.compress_enabled = Some(cfg.compress_enabled);
|
||||
@@ -1487,14 +1499,18 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
|
||||
.subtitle(&format!("{} - {}", time_label, subtitle))
|
||||
.show_enable_switch(false)
|
||||
.build();
|
||||
row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic"));
|
||||
let history_icon = gtk::Image::from_icon_name("image-x-generic-symbolic");
|
||||
history_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
row.add_prefix(&history_icon);
|
||||
|
||||
// Detail rows inside expander
|
||||
let input_row = adw::ActionRow::builder()
|
||||
.title("Input")
|
||||
.subtitle(&entry.input_dir)
|
||||
.build();
|
||||
input_row.add_prefix(>k::Image::from_icon_name("folder-symbolic"));
|
||||
let input_icon = gtk::Image::from_icon_name("folder-symbolic");
|
||||
input_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
input_row.add_prefix(&input_icon);
|
||||
row.add_row(&input_row);
|
||||
|
||||
let output_row = adw::ActionRow::builder()
|
||||
@@ -1502,7 +1518,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
|
||||
.subtitle(&entry.output_dir)
|
||||
.activatable(true)
|
||||
.build();
|
||||
output_row.add_prefix(>k::Image::from_icon_name("folder-open-symbolic"));
|
||||
let output_icon = gtk::Image::from_icon_name("folder-open-symbolic");
|
||||
output_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
output_row.add_prefix(&output_icon);
|
||||
let out_dir = entry.output_dir.clone();
|
||||
output_row.connect_activated(move |_| {
|
||||
let uri = gtk::gio::File::for_path(&out_dir).uri();
|
||||
@@ -1522,7 +1540,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
|
||||
savings
|
||||
))
|
||||
.build();
|
||||
size_row.add_prefix(>k::Image::from_icon_name("drive-harddisk-symbolic"));
|
||||
let size_icon = gtk::Image::from_icon_name("drive-harddisk-symbolic");
|
||||
size_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
size_row.add_prefix(&size_icon);
|
||||
row.add_row(&size_row);
|
||||
|
||||
if entry.failed > 0 {
|
||||
@@ -1530,7 +1550,9 @@ fn show_history_dialog(window: &adw::ApplicationWindow) {
|
||||
.title("Errors")
|
||||
.subtitle(&format!("{} files failed", entry.failed))
|
||||
.build();
|
||||
err_row.add_prefix(>k::Image::from_icon_name("dialog-warning-symbolic"));
|
||||
let err_icon = gtk::Image::from_icon_name("dialog-warning-symbolic");
|
||||
err_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
err_row.add_prefix(&err_icon);
|
||||
row.add_row(&err_row);
|
||||
}
|
||||
|
||||
@@ -2138,7 +2160,7 @@ fn continue_processing(
|
||||
}
|
||||
ProcessingMessage::Error(err) => {
|
||||
mark_current_queue_batch(&ui_for_rx, false, Some(&err));
|
||||
let toast = adw::Toast::new(&format!("Processing failed: {}", err));
|
||||
let toast = adw::Toast::new(&format!("Processing failed: {}. Try with fewer images or check that the output folder exists.", err));
|
||||
ui_for_rx.toast_overlay.add_toast(toast);
|
||||
ui_for_rx.back_button.set_visible(true);
|
||||
ui_for_rx.next_button.set_visible(true);
|
||||
@@ -2415,18 +2437,18 @@ fn undo_last_batch(ui: &WizardUi) {
|
||||
let entries = match history.list() {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No processing history available"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No processing history available. Process a batch first before undoing."));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(last) = entries.last() else {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No batches to undo"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No batches to undo. Process some images first."));
|
||||
return;
|
||||
};
|
||||
|
||||
if last.output_files.is_empty() {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No output files recorded for last batch"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No output files recorded for last batch. The batch may have been already undone."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2457,7 +2479,7 @@ fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
// Save the texture to a temp file
|
||||
let temp_dir = std::env::temp_dir().join("pixstrip-clipboard");
|
||||
if std::fs::create_dir_all(&temp_dir).is_err() {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("Failed to create temporary directory"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("Failed to create temporary directory. Check disk space and permissions on /tmp."));
|
||||
return;
|
||||
}
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
@@ -2480,10 +2502,10 @@ fn paste_images_from_clipboard(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
toast.set_timeout(2);
|
||||
ui.toast_overlay.add_toast(toast);
|
||||
} else {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("Failed to save clipboard image"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("Failed to save clipboard image. The image format may be unsupported."));
|
||||
}
|
||||
} else {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No image found in clipboard"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No image found in clipboard. Copy an image first, then try pasting again."));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2672,7 +2694,7 @@ fn import_preset(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
ui.toast_overlay.add_toast(toast);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Failed to import: {}", e));
|
||||
let toast = adw::Toast::new(&format!("Failed to import preset: {}. Make sure the file is a valid .pixstrip-preset file.", e));
|
||||
ui.toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
@@ -2732,6 +2754,90 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
.build();
|
||||
name_group.add(&desc_entry);
|
||||
|
||||
// Icon picker
|
||||
let icon_names = [
|
||||
("user-bookmarks-symbolic", "Bookmark"),
|
||||
("image-x-generic-symbolic", "Image"),
|
||||
("camera-photo-symbolic", "Camera"),
|
||||
("emblem-photos-symbolic", "Photos"),
|
||||
("applications-graphics-symbolic", "Graphics"),
|
||||
("starred-symbolic", "Star"),
|
||||
("emblem-favorite-symbolic", "Heart"),
|
||||
("folder-symbolic", "Folder"),
|
||||
("preferences-color-symbolic", "Color"),
|
||||
("emblem-system-symbolic", "Gear"),
|
||||
];
|
||||
let icon_string_list = gtk::StringList::new(&icon_names.map(|(_, label)| label));
|
||||
let icon_combo = adw::ComboRow::builder()
|
||||
.title("Icon")
|
||||
.model(&icon_string_list)
|
||||
.build();
|
||||
// Show icon preview as prefix
|
||||
let icon_preview = gtk::Image::builder()
|
||||
.icon_name("user-bookmarks-symbolic")
|
||||
.pixel_size(24)
|
||||
.build();
|
||||
icon_preview.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
icon_combo.add_prefix(&icon_preview);
|
||||
name_group.add(&icon_combo);
|
||||
|
||||
// Color picker
|
||||
let color_labels = ["Default", "Blue", "Green", "Yellow", "Red"];
|
||||
let color_values = ["", "accent", "success", "warning", "error"];
|
||||
let color_string_list = gtk::StringList::new(&color_labels);
|
||||
let color_combo = adw::ComboRow::builder()
|
||||
.title("Icon Color")
|
||||
.model(&color_string_list)
|
||||
.build();
|
||||
let color_preview = gtk::Image::builder()
|
||||
.icon_name("user-bookmarks-symbolic")
|
||||
.pixel_size(24)
|
||||
.build();
|
||||
color_preview.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
color_combo.add_prefix(&color_preview);
|
||||
name_group.add(&color_combo);
|
||||
|
||||
// Update icon preview when icon selection changes
|
||||
{
|
||||
let ip = icon_preview.clone();
|
||||
let cp = color_preview.clone();
|
||||
let cc = color_combo.clone();
|
||||
icon_combo.connect_selected_notify(move |combo| {
|
||||
let idx = combo.selected() as usize;
|
||||
if idx < icon_names.len() {
|
||||
let icon_name = icon_names[idx].0;
|
||||
ip.set_icon_name(Some(icon_name));
|
||||
cp.set_icon_name(Some(icon_name));
|
||||
// Re-apply color class
|
||||
for cv in &color_values {
|
||||
if !cv.is_empty() {
|
||||
cp.remove_css_class(cv);
|
||||
}
|
||||
}
|
||||
let cidx = cc.selected() as usize;
|
||||
if cidx < color_values.len() && !color_values[cidx].is_empty() {
|
||||
cp.add_css_class(color_values[cidx]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update color preview when color selection changes
|
||||
{
|
||||
let cp = color_preview;
|
||||
color_combo.connect_selected_notify(move |combo| {
|
||||
let idx = combo.selected() as usize;
|
||||
for cv in &color_values {
|
||||
if !cv.is_empty() {
|
||||
cp.remove_css_class(cv);
|
||||
}
|
||||
}
|
||||
if idx < color_values.len() && !color_values[idx].is_empty() {
|
||||
cp.add_css_class(color_values[idx]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let save_new_button = gtk::Button::builder()
|
||||
.label("Save New Preset")
|
||||
.halign(gtk::Align::Center)
|
||||
@@ -2770,9 +2876,15 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
let ui_c = ui.clone();
|
||||
let dlg_c = dialog.clone();
|
||||
let pname = preset_name.clone();
|
||||
let store_ref = pixstrip_core::storage::PresetStore::new();
|
||||
let existing_preset = store_ref.load(preset_name).ok();
|
||||
let existing_icon = existing_preset.as_ref().map(|p| p.icon.clone()).unwrap_or_default();
|
||||
let existing_color = existing_preset.as_ref().map(|p| p.icon_color.clone()).unwrap_or_default();
|
||||
row.connect_activated(move |_| {
|
||||
let cfg = ui_c.state.job_config.borrow();
|
||||
let preset = build_preset_from_config(&cfg, &pname, None);
|
||||
let ei = existing_icon.as_str();
|
||||
let ec = existing_color.as_str();
|
||||
let preset = build_preset_from_config(&cfg, &pname, None, Some(ei), Some(ec));
|
||||
drop(cfg);
|
||||
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
@@ -2782,7 +2894,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
ui_c.toast_overlay.add_toast(toast);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Failed to update: {}", e));
|
||||
let toast = adw::Toast::new(&format!("Failed to update preset: {}. The preset file may be read-only.", e));
|
||||
ui_c.toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
@@ -2801,6 +2913,8 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
let dlg_c = dialog.clone();
|
||||
let entry_c = name_entry.clone();
|
||||
let desc_c = desc_entry.clone();
|
||||
let icon_combo_c = icon_combo;
|
||||
let color_combo_c = color_combo;
|
||||
save_new_button.connect_clicked(move |_| {
|
||||
let name = entry_c.text().to_string();
|
||||
if name.trim().is_empty() {
|
||||
@@ -2810,8 +2924,12 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
}
|
||||
|
||||
let desc_text = desc_c.text().to_string();
|
||||
let icon_idx = icon_combo_c.selected() as usize;
|
||||
let selected_icon = icon_names.get(icon_idx).map(|(name, _)| *name);
|
||||
let color_idx = color_combo_c.selected() as usize;
|
||||
let selected_color = color_values.get(color_idx).copied();
|
||||
let cfg = ui_c.state.job_config.borrow();
|
||||
let preset = build_preset_from_config(&cfg, &name, Some(&desc_text));
|
||||
let preset = build_preset_from_config(&cfg, &name, Some(&desc_text), selected_icon, selected_color);
|
||||
drop(cfg);
|
||||
|
||||
let store = pixstrip_core::storage::PresetStore::new();
|
||||
@@ -2821,7 +2939,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
ui_c.toast_overlay.add_toast(toast);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Failed to save: {}", e));
|
||||
let toast = adw::Toast::new(&format!("Failed to save preset: {}. Check that the presets folder is writable.", e));
|
||||
ui_c.toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
@@ -2836,7 +2954,7 @@ fn save_preset_dialog(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
fn build_preset_from_config(cfg: &JobConfig, name: &str, description: Option<&str>) -> pixstrip_core::preset::Preset {
|
||||
fn build_preset_from_config(cfg: &JobConfig, name: &str, description: Option<&str>, icon: Option<&str>, icon_color: Option<&str>) -> pixstrip_core::preset::Preset {
|
||||
let resize = if cfg.resize_enabled && cfg.resize_width > 0 {
|
||||
if cfg.resize_height == 0 {
|
||||
Some(pixstrip_core::operations::ResizeConfig::ByWidth(cfg.resize_width))
|
||||
@@ -2980,7 +3098,8 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str, description: Option<&st
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_else(|| build_preset_description(cfg)),
|
||||
icon: "user-bookmarks-symbolic".into(),
|
||||
icon: icon.unwrap_or("user-bookmarks-symbolic").to_string(),
|
||||
icon_color: icon_color.unwrap_or("").to_string(),
|
||||
is_custom: true,
|
||||
resize,
|
||||
rotation,
|
||||
@@ -3188,11 +3307,12 @@ pub fn walk_widgets(widget: &Option<gtk::Widget>, f: &dyn Fn(>k::Widget)) {
|
||||
}
|
||||
|
||||
|
||||
#[allow(deprecated)] // ShortcutLabel deprecated in 4.18 with no replacement yet
|
||||
fn show_shortcuts_window(window: &adw::ApplicationWindow) {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Keyboard Shortcuts")
|
||||
.content_width(420)
|
||||
.content_height(480)
|
||||
.content_width(460)
|
||||
.content_height(520)
|
||||
.build();
|
||||
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
@@ -3201,56 +3321,75 @@ fn show_shortcuts_window(window: &adw::ApplicationWindow) {
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.margin_start(16)
|
||||
.margin_end(16)
|
||||
.margin_top(8)
|
||||
.margin_bottom(16)
|
||||
.spacing(16)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.margin_top(12)
|
||||
.margin_bottom(24)
|
||||
.spacing(18)
|
||||
.build();
|
||||
|
||||
let sections: &[(&str, &[(&str, &str)])] = &[
|
||||
("Wizard Navigation", &[
|
||||
("Alt + Right", "Next step"),
|
||||
("Alt + Left", "Previous step"),
|
||||
("Alt + 1-9", "Jump to step"),
|
||||
("Ctrl + Return", "Process images"),
|
||||
("<Alt>Right", "Next step"),
|
||||
("<Alt>Left", "Previous step"),
|
||||
("<Alt>1", "Jump to step (1-9)"),
|
||||
("<Control>Return", "Process images"),
|
||||
("Escape", "Cancel or go back"),
|
||||
]),
|
||||
("File Management", &[
|
||||
("Ctrl + O", "Add files"),
|
||||
("Ctrl + V", "Paste image from clipboard"),
|
||||
("Ctrl + A", "Select all images"),
|
||||
("Ctrl + Shift + A", "Deselect all images"),
|
||||
("<Control>o", "Add files"),
|
||||
("<Control>v", "Paste image from clipboard"),
|
||||
("<Control>a", "Select all images"),
|
||||
("<Control><Shift>a", "Deselect all images"),
|
||||
("Delete", "Remove selected images"),
|
||||
]),
|
||||
("Application", &[
|
||||
("Ctrl + ,", "Settings"),
|
||||
("<Control>comma", "Settings"),
|
||||
("F1", "Keyboard shortcuts"),
|
||||
("Ctrl + Z", "Undo last batch"),
|
||||
("Ctrl + Q", "Quit"),
|
||||
("<Control>z", "Undo last batch"),
|
||||
("<Control>q", "Quit"),
|
||||
]),
|
||||
];
|
||||
|
||||
for (section_title, shortcuts) in sections {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title(*section_title)
|
||||
let group = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(6)
|
||||
.build();
|
||||
|
||||
let title_label = gtk::Label::builder()
|
||||
.label(*section_title)
|
||||
.css_classes(["title-4"])
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_bottom(2)
|
||||
.build();
|
||||
group.append(&title_label);
|
||||
|
||||
for (accel, description) in *shortcuts {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(*description)
|
||||
let row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.build();
|
||||
let label = gtk::Label::builder()
|
||||
.label(*accel)
|
||||
.css_classes(["dim-label", "monospace"])
|
||||
.valign(gtk::Align::Center)
|
||||
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label(*description)
|
||||
.halign(gtk::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
row.add_suffix(&label);
|
||||
group.add(&row);
|
||||
|
||||
let shortcut_label = gtk::ShortcutLabel::builder()
|
||||
.accelerator(*accel)
|
||||
.halign(gtk::Align::End)
|
||||
.build();
|
||||
|
||||
row.append(&desc_label);
|
||||
row.append(&shortcut_label);
|
||||
group.append(&row);
|
||||
}
|
||||
|
||||
content.append(&group);
|
||||
@@ -3288,82 +3427,143 @@ fn apply_accessibility_settings() {
|
||||
}
|
||||
|
||||
fn show_step_help(window: &adw::ApplicationWindow, step: usize) {
|
||||
let (title, body) = match step {
|
||||
0 => ("Workflow", concat!(
|
||||
"Choose a preset to start quickly, or configure each step manually.\n\n",
|
||||
"Presets apply recommended settings for common tasks like web optimization, ",
|
||||
"social media, or print preparation. You can customize any preset after applying it.\n\n",
|
||||
"Use Import/Export to share presets with others."
|
||||
let (title, icon_name, body) = match step {
|
||||
0 => ("Workflow", "view-grid-symbolic", concat!(
|
||||
"Pick a built-in preset to start quickly, or select the Custom card to choose ",
|
||||
"which operations to include.\n\n",
|
||||
"Built-in presets auto-advance to the Images step with recommended settings. ",
|
||||
"Custom mode shows toggle switches for each operation (Resize, Adjustments, Convert, ",
|
||||
"Compress, Metadata, Watermark, Rename).\n\n",
|
||||
"Your saved presets appear below the built-in ones. Use Import to load a .pixstrip-preset file, ",
|
||||
"or drag one onto this page."
|
||||
)),
|
||||
1 => ("Images", concat!(
|
||||
1 => ("Images", "image-x-generic-symbolic", concat!(
|
||||
"Add the images you want to process.\n\n",
|
||||
"- Drag and drop files or folders onto this area\n",
|
||||
"- Use Browse to pick files from a file dialog\n",
|
||||
"- Drag image URLs from a web browser\n",
|
||||
"- Click Browse Files or press Ctrl+O\n",
|
||||
"- Press Ctrl+V to paste from clipboard\n\n",
|
||||
"Use checkboxes to include or exclude individual images. ",
|
||||
"When dropping a folder with subfolders, you'll be asked whether to include them. ",
|
||||
"Use checkboxes on each thumbnail to include or exclude images. ",
|
||||
"Ctrl+A selects all, Ctrl+Shift+A deselects all."
|
||||
)),
|
||||
2 => ("Resize", concat!(
|
||||
"Scale images to specific dimensions.\n\n",
|
||||
"Choose a preset size or enter custom dimensions. Width-only or height-only ",
|
||||
"resizing preserves the original aspect ratio.\n\n",
|
||||
"Enable 'Allow upscale' if you need images smaller than the target to be enlarged."
|
||||
2 => ("Resize", "view-fullscreen-symbolic", concat!(
|
||||
"Scale images to specific dimensions with a live preview.\n\n",
|
||||
"Pick a category and preset size, or enter custom width and height. ",
|
||||
"Toggle between pixel and percentage units. Lock the aspect ratio to keep proportions.\n\n",
|
||||
"Choose Exact Size or Fit Within Box mode. Enable Allow Upscaling to enlarge smaller images. ",
|
||||
"Expand Advanced Settings for resize algorithm (Lanczos3, CatmullRom, etc.) and output DPI."
|
||||
)),
|
||||
3 => ("Adjustments", concat!(
|
||||
"Fine-tune image appearance.\n\n",
|
||||
"Adjust brightness, contrast, and saturation with sliders. ",
|
||||
"Apply rotation, flipping, grayscale, or sepia effects.\n\n",
|
||||
"Crop to a specific aspect ratio or trim whitespace borders automatically."
|
||||
3 => ("Adjustments", "preferences-color-symbolic", concat!(
|
||||
"Fine-tune image appearance with a live preview.\n\n",
|
||||
"Orientation: rotate (including auto-orient from EXIF) and flip.\n",
|
||||
"Color: adjust brightness, contrast, and saturation with sliders.\n",
|
||||
"Effects: toggle grayscale, sepia, or sharpen.\n",
|
||||
"Crop and Canvas: crop to an aspect ratio, trim whitespace borders, or add padding."
|
||||
)),
|
||||
4 => ("Convert", concat!(
|
||||
4 => ("Convert", "document-save-as-symbolic", concat!(
|
||||
"Change image file format.\n\n",
|
||||
"Convert between JPEG, PNG, WebP, AVIF, GIF, TIFF, and BMP. ",
|
||||
"Each format has trade-offs between quality, file size, and compatibility.\n\n",
|
||||
"WebP and AVIF offer the best compression for web use."
|
||||
"Select a target format from the card grid (JPEG, PNG, WebP, AVIF) or use the ",
|
||||
"Other Formats dropdown for GIF, TIFF, and BMP. Keep Original preserves each file's format.\n\n",
|
||||
"Enable Progressive JPEG for gradual loading in browsers. Use Format Mapping to override ",
|
||||
"the output format for specific input types (e.g. convert PNG to WebP but keep JPEG as-is)."
|
||||
)),
|
||||
5 => ("Compress", concat!(
|
||||
5 => ("Compress", "drive-harddisk-symbolic", concat!(
|
||||
"Reduce file size while preserving quality.\n\n",
|
||||
"Choose a quality preset (Lossless, High, Balanced, Small, Tiny) or set custom ",
|
||||
"quality values per format.\n\n",
|
||||
"Expand Advanced Options for fine control over WebP encoding effort and AVIF speed."
|
||||
"Use the quality slider to set the overall level from Low to Maximum. ",
|
||||
"The split preview shows a side-by-side before/after comparison - drag the divider ",
|
||||
"or use Left/Right arrow keys to compare.\n\n",
|
||||
"Expand Per-Format Quality for fine control over JPEG quality, PNG compression level, ",
|
||||
"WebP quality and effort, and AVIF quality and speed."
|
||||
)),
|
||||
6 => ("Metadata", concat!(
|
||||
"Control what metadata is kept or removed.\n\n",
|
||||
"Strip All removes everything. Privacy mode keeps copyright and camera info but ",
|
||||
"removes GPS and timestamps. Custom mode lets you pick exactly what to strip.\n\n",
|
||||
"Removing metadata reduces file size and protects privacy."
|
||||
6 => ("Metadata", "dialog-password-symbolic", concat!(
|
||||
"Control what image metadata is kept or removed.\n\n",
|
||||
"- Strip All: remove everything for smallest files and maximum privacy\n",
|
||||
"- Privacy: strip GPS and camera serial, keep copyright\n",
|
||||
"- Photographer: keep copyright and camera model, strip GPS and software\n",
|
||||
"- Keep All: preserve all original metadata\n",
|
||||
"- Custom: choose exactly which categories to strip (GPS, camera, software, timestamps, copyright)"
|
||||
)),
|
||||
7 => ("Watermark", concat!(
|
||||
"Add a text or image watermark.\n\n",
|
||||
"Choose text or logo mode. Position the watermark using the visual grid. ",
|
||||
"Expand Advanced Options for opacity, rotation, tiling, margin, and scale controls.\n\n",
|
||||
"Logo watermarks support PNG images with transparency."
|
||||
7 => ("Watermark", "emblem-photos-symbolic", concat!(
|
||||
"Add a text or image watermark with a live preview.\n\n",
|
||||
"Text mode: enter your text, choose a font and size.\n",
|
||||
"Image mode: select a logo file (PNG with transparency works best).\n\n",
|
||||
"Position the watermark using the 3x3 grid. Expand Advanced Options for text color, ",
|
||||
"opacity, rotation, tiling, margin, and scale controls."
|
||||
)),
|
||||
8 => ("Rename", concat!(
|
||||
"Rename output files using patterns.\n\n",
|
||||
"Add a prefix, suffix, or use a full template with placeholders:\n",
|
||||
"- {name} - original filename\n",
|
||||
"- {n} - counter number\n",
|
||||
"- {date} - current date\n",
|
||||
"- {ext} - original extension\n\n",
|
||||
"Expand Advanced Options for case conversion and find-and-replace."
|
||||
8 => ("Rename", "document-edit-symbolic", concat!(
|
||||
"Rename output files with a live preview showing before and after names.\n\n",
|
||||
"Simple options: add a prefix or suffix, replace spaces, filter special characters, ",
|
||||
"convert case, and add a sequential counter.\n\n",
|
||||
"Expand Advanced for a template engine with variables like {name}, {counter}, {date}, ",
|
||||
"{exif_date}, {camera}, {width}, {height}, and more. Also includes find-and-replace with regex."
|
||||
)),
|
||||
9 => ("Output", concat!(
|
||||
"Review settings and choose where to save.\n\n",
|
||||
"The summary shows all operations that will be applied. ",
|
||||
9 => ("Output", "folder-download-symbolic", concat!(
|
||||
"Review and start processing.\n\n",
|
||||
"The operation summary lists all enabled steps and their settings. ",
|
||||
"Choose an output folder or use the default 'processed' subfolder.\n\n",
|
||||
"Set overwrite behavior for when output files already exist. ",
|
||||
"Press Process or Ctrl+Enter to start."
|
||||
"Toggle Preserve Directory Structure to keep subfolder hierarchy in output. ",
|
||||
"Set overwrite behavior for existing files. Press Process or Ctrl+Enter to start."
|
||||
)),
|
||||
_ => ("Help", "No help available for this step."),
|
||||
_ => ("Help", "help-about-symbolic", "No help available for this step."),
|
||||
};
|
||||
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(format!("Help: {}", title))
|
||||
.body(body)
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title(format!("Help: {}", title))
|
||||
.content_width(420)
|
||||
.content_height(360)
|
||||
.build();
|
||||
dialog.add_response("ok", "Got it");
|
||||
dialog.set_default_response(Some("ok"));
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.margin_top(24)
|
||||
.margin_bottom(24)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.build();
|
||||
|
||||
let icon = gtk::Image::builder()
|
||||
.icon_name(icon_name)
|
||||
.pixel_size(64)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
icon.add_css_class("accent");
|
||||
icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
content.append(&icon);
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label(title)
|
||||
.css_classes(["title-2"])
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
content.append(&heading);
|
||||
|
||||
let body_label = gtk::Label::builder()
|
||||
.label(body)
|
||||
.wrap(true)
|
||||
.halign(gtk::Align::Center)
|
||||
.justify(gtk::Justification::Center)
|
||||
.xalign(0.5)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
content.append(&body_label);
|
||||
|
||||
let close_button = gtk::Button::builder()
|
||||
.label("Got it")
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
close_button.add_css_class("suggested-action");
|
||||
close_button.add_css_class("pill");
|
||||
|
||||
let dlg = dialog.clone();
|
||||
close_button.connect_clicked(move |_| {
|
||||
dlg.close();
|
||||
});
|
||||
|
||||
content.append(&close_button);
|
||||
|
||||
dialog.set_child(Some(&content));
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
@@ -3415,6 +3615,9 @@ fn build_watch_folder_panel() -> gtk::Box {
|
||||
.tooltip_text("Add watch folder")
|
||||
.build();
|
||||
add_btn.add_css_class("flat");
|
||||
add_btn.update_property(&[
|
||||
gtk::accessible::Property::Label("Add watch folder"),
|
||||
]);
|
||||
header_box.append(&add_btn);
|
||||
|
||||
inner.append(&header_box);
|
||||
@@ -3451,15 +3654,29 @@ fn build_watch_folder_panel() -> gtk::Box {
|
||||
.title(display_name)
|
||||
.subtitle(&folder.preset_name)
|
||||
.build();
|
||||
row.add_prefix(>k::Image::from_icon_name("folder-visiting-symbolic"));
|
||||
let folder_icon = gtk::Image::from_icon_name("folder-visiting-symbolic");
|
||||
folder_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
row.add_prefix(&folder_icon);
|
||||
|
||||
// Status indicator
|
||||
let status_icon = gtk::Image::builder()
|
||||
.icon_name("emblem-ok-symbolic")
|
||||
.pixel_size(12)
|
||||
.build();
|
||||
status_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
let status = gtk::Label::builder()
|
||||
.label("Watching")
|
||||
.css_classes(["caption", "accent"])
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
row.add_suffix(&status);
|
||||
let status_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
status_box.append(&status_icon);
|
||||
status_box.append(&status);
|
||||
row.add_suffix(&status_box);
|
||||
|
||||
list_box.append(&row);
|
||||
}
|
||||
@@ -3518,14 +3735,28 @@ fn build_watch_folder_panel() -> gtk::Box {
|
||||
.title(&display_name)
|
||||
.subtitle(&new_folder.preset_name)
|
||||
.build();
|
||||
row.add_prefix(>k::Image::from_icon_name("folder-visiting-symbolic"));
|
||||
let folder_icon = gtk::Image::from_icon_name("folder-visiting-symbolic");
|
||||
folder_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
row.add_prefix(&folder_icon);
|
||||
|
||||
let dyn_status_icon = gtk::Image::builder()
|
||||
.icon_name("emblem-ok-symbolic")
|
||||
.pixel_size(12)
|
||||
.build();
|
||||
dyn_status_icon.set_accessible_role(gtk::AccessibleRole::Presentation);
|
||||
let status = gtk::Label::builder()
|
||||
.label("Watching")
|
||||
.css_classes(["caption", "accent"])
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
row.add_suffix(&status);
|
||||
let dyn_status_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
dyn_status_box.append(&dyn_status_icon);
|
||||
dyn_status_box.append(&status);
|
||||
row.add_suffix(&dyn_status_box);
|
||||
|
||||
list_box_c.append(&row);
|
||||
list_box_c.set_visible(true);
|
||||
@@ -3644,7 +3875,11 @@ fn refresh_queue_list(ui: &WizardUi) {
|
||||
.title(&batch.name)
|
||||
.subtitle(&format!("{} images - {}", batch.files.len(), status_text))
|
||||
.build();
|
||||
row.add_prefix(>k::Image::from_icon_name(status_icon));
|
||||
let batch_icon = gtk::Image::from_icon_name(status_icon);
|
||||
batch_icon.update_property(&[
|
||||
gtk::accessible::Property::Label(&status_text),
|
||||
]);
|
||||
row.add_prefix(&batch_icon);
|
||||
|
||||
// Add remove button for pending batches
|
||||
if batch.status == BatchStatus::Pending {
|
||||
@@ -3686,7 +3921,7 @@ fn add_current_batch_to_queue(ui: &WizardUi) {
|
||||
};
|
||||
|
||||
if files.is_empty() {
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No images to queue"));
|
||||
ui.toast_overlay.add_toast(adw::Toast::new("No images to queue. Go to Step 2 to add images first."));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user