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:
2026-03-08 14:18:15 +02:00
parent 8d754017fa
commit f3668c45c3
26 changed files with 2292 additions and 473 deletions

View File

@@ -1,46 +1,7 @@
use adw::prelude::*;
use gtk::glib;
struct TourStop {
title: &'static str,
description: &'static str,
icon: &'static str,
}
const TOUR_STOPS: &[TourStop] = &[
TourStop {
title: "Step Indicator",
description: "This bar shows your progress through the wizard. Click any completed step to jump back to it.",
icon: "view-list-symbolic",
},
TourStop {
title: "Choose a Workflow",
description: "Start by picking a preset that matches what you need, or build a custom workflow from scratch.",
icon: "applications-graphics-symbolic",
},
TourStop {
title: "Add Your Images",
description: "Drag and drop files here, or use the Add button. You can paste from the clipboard too.",
icon: "image-x-generic-symbolic",
},
TourStop {
title: "Navigation",
description: "Use the Back and Next buttons to move between steps, or press Alt+Left/Right. Disabled steps are automatically skipped.",
icon: "go-next-symbolic",
},
TourStop {
title: "Main Menu",
description: "Access settings, keyboard shortcuts, processing history, and preset management from here.",
icon: "open-menu-symbolic",
},
TourStop {
title: "You're Ready!",
description: "That's everything you need to know. Each step also has a help button (?) in the header bar for detailed guidance.",
icon: "emblem-ok-symbolic",
},
];
/// Show the tutorial overlay if the user hasn't completed it yet.
/// Show the tutorial tour if the user hasn't completed it yet.
/// Called after the welcome wizard closes on first launch.
pub fn show_tutorial_if_needed(window: &adw::ApplicationWindow) {
let config_store = pixstrip_core::storage::ConfigStore::new();
@@ -53,28 +14,74 @@ pub fn show_tutorial_if_needed(window: &adw::ApplicationWindow) {
// Small delay to let the welcome dialog fully dismiss
let win = window.clone();
glib::timeout_add_local_once(std::time::Duration::from_millis(400), move || {
show_tour_dialog(&win, 0);
show_tour_stop(&win, 0);
});
}
fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
let stop = &TOUR_STOPS[stop_index];
let total = TOUR_STOPS.len();
/// Tour stops: (title, description, widget_name, popover_position)
fn tour_stops() -> Vec<(&'static str, &'static str, &'static str, gtk::PositionType)> {
vec![
(
"Choose a Workflow",
"Pick a preset that matches your needs, or scroll down to build a custom workflow from scratch.",
"tour-content",
gtk::PositionType::Bottom,
),
(
"Track Your Progress",
"This bar shows where you are in the wizard. Click any completed step to jump back to it.",
"tour-step-indicator",
gtk::PositionType::Bottom,
),
(
"Navigation",
"Use Back and Next to move between steps, or press Alt+Left / Alt+Right. Disabled steps are automatically skipped.",
"tour-next-button",
gtk::PositionType::Top,
),
(
"Main Menu",
"Settings, keyboard shortcuts, processing history, and preset management live here.",
"tour-menu-button",
gtk::PositionType::Bottom,
),
(
"Get Help",
"Every step has a help button with detailed guidance specific to that step.",
"tour-help-button",
gtk::PositionType::Bottom,
),
]
}
let dialog = adw::Dialog::builder()
.title("Quick Tour")
.content_width(420)
.content_height(300)
.build();
fn show_tour_stop(window: &adw::ApplicationWindow, index: usize) {
let stops = tour_stops();
let total = stops.len();
if index >= total {
mark_tutorial_complete();
return;
}
let (title, description, widget_name, position) = stops[index];
// Find the target widget by name in the widget tree
let Some(root) = window.content() else { return };
let Some(target) = find_widget_by_name(&root, widget_name) else {
// Widget not found - skip to next stop
show_tour_stop(window, index + 1);
return;
};
// Build popover content
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(16)
.margin_top(24)
.margin_bottom(24)
.margin_start(24)
.margin_end(24)
.spacing(8)
.margin_top(12)
.margin_bottom(12)
.margin_start(16)
.margin_end(16)
.build();
content.set_size_request(280, -1);
// Progress dots
let dots_box = gtk::Box::builder()
@@ -82,12 +89,11 @@ fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
.spacing(6)
.halign(gtk::Align::Center)
.build();
for i in 0..total {
let dot = gtk::Label::builder()
.label(if i == stop_index { "\u{25CF}" } else { "\u{25CB}" })
.label(if i == index { "\u{25CF}" } else { "\u{25CB}" })
.build();
if i == stop_index {
if i == index {
dot.add_css_class("accent");
} else {
dot.add_css_class("dim-label");
@@ -96,94 +102,132 @@ fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
}
content.append(&dots_box);
// Icon
let icon = gtk::Image::builder()
.icon_name(stop.icon)
.pixel_size(64)
.halign(gtk::Align::Center)
.build();
icon.add_css_class("accent");
content.append(&icon);
// Title
let title = gtk::Label::builder()
.label(stop.title)
.css_classes(["title-2"])
.halign(gtk::Align::Center)
let title_label = gtk::Label::builder()
.label(title)
.css_classes(["title-3"])
.halign(gtk::Align::Start)
.build();
content.append(&title);
// Step counter
let counter = gtk::Label::builder()
.label(&format!("{} of {}", stop_index + 1, total))
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Center)
.build();
content.append(&counter);
content.append(&title_label);
// Description
let desc = gtk::Label::builder()
.label(stop.description)
let desc_label = gtk::Label::builder()
.label(description)
.wrap(true)
.halign(gtk::Align::Center)
.justify(gtk::Justification::Center)
.max_width_chars(36)
.halign(gtk::Align::Start)
.xalign(0.0)
.build();
content.append(&desc);
content.append(&desc_label);
// Step counter
let counter_label = gtk::Label::builder()
.label(&format!("{} of {}", index + 1, total))
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Start)
.build();
content.append(&counter_label);
// Buttons
let button_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.halign(gtk::Align::Center)
.margin_top(8)
.halign(gtk::Align::End)
.margin_top(4)
.build();
let skip_button = gtk::Button::builder()
let skip_btn = gtk::Button::builder()
.label("Skip Tour")
.tooltip_text("Close the tour and start using Pixstrip")
.build();
skip_button.add_css_class("flat");
skip_btn.add_css_class("flat");
let is_last = stop_index + 1 >= total;
let next_label = if is_last { "Get Started" } else { "Next" };
let next_button = gtk::Button::builder()
.label(next_label)
let is_last = index + 1 >= total;
let next_btn = gtk::Button::builder()
.label(if is_last { "Done" } else { "Next" })
.tooltip_text(if is_last { "Finish the tour" } else { "Go to the next tour stop" })
.build();
next_button.add_css_class("suggested-action");
next_button.add_css_class("pill");
next_btn.add_css_class("suggested-action");
next_btn.add_css_class("pill");
button_box.append(&skip_button);
button_box.append(&next_button);
button_box.append(&skip_btn);
button_box.append(&next_btn);
content.append(&button_box);
dialog.set_child(Some(&content));
// Create popover attached to the target widget
let popover = gtk::Popover::builder()
.child(&content)
.position(position)
.autohide(false)
.has_arrow(true)
.build();
popover.set_parent(&target);
// Wire skip - mark tutorial complete and close
// For the content area (large widget), point to the upper portion
// where the preset cards are visible
if widget_name == "tour-content" {
let w = target.width();
if w > 0 {
let rect = gtk::gdk::Rectangle::new(w / 2 - 10, 40, 20, 20);
popover.set_pointing_to(Some(&rect));
}
}
// Accessible label for screen readers
popover.update_property(&[
gtk::accessible::Property::Label(
&format!("Tour step {} of {}: {}", index + 1, total, title)
),
]);
// Wire skip button
{
let dlg = dialog.clone();
skip_button.connect_clicked(move |_| {
let pop = popover.clone();
skip_btn.connect_clicked(move |_| {
mark_tutorial_complete();
dlg.close();
pop.popdown();
let p = pop.clone();
glib::idle_add_local_once(move || {
p.unparent();
});
});
}
// Wire next
// Wire next button
{
let dlg = dialog.clone();
let pop = popover.clone();
let win = window.clone();
next_button.connect_clicked(move |_| {
dlg.close();
if is_last {
mark_tutorial_complete();
} else {
let w = win.clone();
glib::idle_add_local_once(move || {
show_tour_dialog(&w, stop_index + 1);
});
}
next_btn.connect_clicked(move |_| {
pop.popdown();
let p = pop.clone();
let w = win.clone();
glib::idle_add_local_once(move || {
p.unparent();
if is_last {
mark_tutorial_complete();
} else {
show_tour_stop(&w, index + 1);
}
});
});
}
dialog.present(Some(window));
popover.popup();
}
/// Recursively search the widget tree for a widget with the given name.
fn find_widget_by_name(root: &gtk::Widget, name: &str) -> Option<gtk::Widget> {
if root.widget_name().as_str() == name {
return Some(root.clone());
}
let mut child = root.first_child();
while let Some(c) = child {
if let Some(found) = find_widget_by_name(&c, name) {
return Some(found);
}
child = c.next_sibling();
}
None
}
fn mark_tutorial_complete() {