Files
pixstrip/pixstrip-gtk/src/tutorial.rs
lashman f3668c45c3 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
2026-03-08 14:18:15 +02:00

240 lines
7.0 KiB
Rust

use adw::prelude::*;
use gtk::glib;
/// 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();
if let Ok(cfg) = config_store.load()
&& cfg.tutorial_complete
{
return;
}
// 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_stop(&win, 0);
});
}
/// 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,
),
]
}
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(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()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.halign(gtk::Align::Center)
.build();
for i in 0..total {
let dot = gtk::Label::builder()
.label(if i == index { "\u{25CF}" } else { "\u{25CB}" })
.build();
if i == index {
dot.add_css_class("accent");
} else {
dot.add_css_class("dim-label");
}
dots_box.append(&dot);
}
content.append(&dots_box);
// Title
let title_label = gtk::Label::builder()
.label(title)
.css_classes(["title-3"])
.halign(gtk::Align::Start)
.build();
content.append(&title_label);
// Description
let desc_label = gtk::Label::builder()
.label(description)
.wrap(true)
.max_width_chars(36)
.halign(gtk::Align::Start)
.xalign(0.0)
.build();
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::End)
.margin_top(4)
.build();
let skip_btn = gtk::Button::builder()
.label("Skip Tour")
.tooltip_text("Close the tour and start using Pixstrip")
.build();
skip_btn.add_css_class("flat");
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_btn.add_css_class("suggested-action");
next_btn.add_css_class("pill");
button_box.append(&skip_btn);
button_box.append(&next_btn);
content.append(&button_box);
// 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);
// 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 pop = popover.clone();
skip_btn.connect_clicked(move |_| {
mark_tutorial_complete();
pop.popdown();
let p = pop.clone();
glib::idle_add_local_once(move || {
p.unparent();
});
});
}
// Wire next button
{
let pop = popover.clone();
let win = window.clone();
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);
}
});
});
}
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() {
let config_store = pixstrip_core::storage::ConfigStore::new();
if let Ok(mut cfg) = config_store.load() {
cfg.tutorial_complete = true;
let _ = config_store.save(&cfg);
}
}