Add tutorial overlay tour after welcome wizard

Semi-transparent dialog-based tour with 6 stops covering step
indicator, workflow selection, image adding, navigation, menu,
and a final ready message. Skippable at any time. State persisted
via tutorial_complete flag in AppConfig.
This commit is contained in:
2026-03-06 15:48:06 +02:00
parent 33659a323b
commit ced65f10ec
5 changed files with 204 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
#[serde(default)] #[serde(default)]
pub struct AppConfig { pub struct AppConfig {
pub first_run_complete: bool, pub first_run_complete: bool,
pub tutorial_complete: bool,
pub output_subfolder: String, pub output_subfolder: String,
pub output_fixed_path: Option<String>, pub output_fixed_path: Option<String>,
pub overwrite_behavior: OverwriteBehavior, pub overwrite_behavior: OverwriteBehavior,
@@ -25,6 +26,7 @@ impl Default for AppConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
first_run_complete: false, first_run_complete: false,
tutorial_complete: false,
output_subfolder: "processed".into(), output_subfolder: "processed".into(),
output_fixed_path: None, output_fixed_path: None,
overwrite_behavior: OverwriteBehavior::Ask, overwrite_behavior: OverwriteBehavior::Ask,

View File

@@ -3,6 +3,7 @@ mod processing;
mod settings; mod settings;
mod step_indicator; mod step_indicator;
mod steps; mod steps;
mod tutorial;
mod welcome; mod welcome;
mod wizard; mod wizard;

View File

@@ -291,6 +291,7 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
dialog.connect_closed(move |_| { dialog.connect_closed(move |_| {
let new_config = AppConfig { let new_config = AppConfig {
first_run_complete: true, first_run_complete: true,
tutorial_complete: true, // preserve if settings are being saved
output_subfolder: subfolder_row.text().to_string(), output_subfolder: subfolder_row.text().to_string(),
output_fixed_path: None, output_fixed_path: None,
overwrite_behavior: match overwrite_row.selected() { overwrite_behavior: match overwrite_row.selected() {

View File

@@ -0,0 +1,195 @@
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.
/// 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_dialog(&win, 0);
});
}
fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
let stop = &TOUR_STOPS[stop_index];
let total = TOUR_STOPS.len();
let dialog = adw::Dialog::builder()
.title("Quick Tour")
.content_width(420)
.content_height(300)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(16)
.margin_top(24)
.margin_bottom(24)
.margin_start(24)
.margin_end(24)
.build();
// 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 == stop_index { "\u{25CF}" } else { "\u{25CB}" })
.build();
if i == stop_index {
dot.add_css_class("accent");
} else {
dot.add_css_class("dim-label");
}
dots_box.append(&dot);
}
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)
.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);
// Description
let desc = gtk::Label::builder()
.label(stop.description)
.wrap(true)
.halign(gtk::Align::Center)
.justify(gtk::Justification::Center)
.build();
content.append(&desc);
// Buttons
let button_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.halign(gtk::Align::Center)
.margin_top(8)
.build();
let skip_button = gtk::Button::builder()
.label("Skip Tour")
.build();
skip_button.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)
.build();
next_button.add_css_class("suggested-action");
next_button.add_css_class("pill");
button_box.append(&skip_button);
button_box.append(&next_button);
content.append(&button_box);
dialog.set_child(Some(&content));
// Wire skip - mark tutorial complete and close
{
let dlg = dialog.clone();
skip_button.connect_clicked(move |_| {
mark_tutorial_complete();
dlg.close();
});
}
// Wire next
{
let dlg = dialog.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);
});
}
});
}
dialog.present(Some(window));
}
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);
}
}

View File

@@ -10,14 +10,18 @@ pub fn show_welcome_if_first_launch(window: &adw::ApplicationWindow) {
let dialog = build_welcome_dialog(); let dialog = build_welcome_dialog();
let win = window.clone(); let win = window.clone();
let win_for_present = window.clone();
dialog.connect_closed(move |_| { dialog.connect_closed(move |_| {
let config = pixstrip_core::storage::ConfigStore::new(); let config = pixstrip_core::storage::ConfigStore::new();
if let Ok(mut cfg) = config.load() { if let Ok(mut cfg) = config.load() {
cfg.first_run_complete = true; cfg.first_run_complete = true;
let _ = config.save(&cfg); let _ = config.save(&cfg);
} }
// Launch the tutorial tour after the welcome wizard
crate::tutorial::show_tutorial_if_needed(&win);
}); });
dialog.present(Some(&win)); dialog.present(Some(&win_for_present));
} }
fn build_welcome_dialog() -> adw::Dialog { fn build_welcome_dialog() -> adw::Dialog {