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:
@@ -3,6 +3,7 @@ mod processing;
|
||||
mod settings;
|
||||
mod step_indicator;
|
||||
mod steps;
|
||||
mod tutorial;
|
||||
mod welcome;
|
||||
mod wizard;
|
||||
|
||||
|
||||
@@ -291,6 +291,7 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
|
||||
dialog.connect_closed(move |_| {
|
||||
let new_config = AppConfig {
|
||||
first_run_complete: true,
|
||||
tutorial_complete: true, // preserve if settings are being saved
|
||||
output_subfolder: subfolder_row.text().to_string(),
|
||||
output_fixed_path: None,
|
||||
overwrite_behavior: match overwrite_row.selected() {
|
||||
|
||||
195
pixstrip-gtk/src/tutorial.rs
Normal file
195
pixstrip-gtk/src/tutorial.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,18 @@ pub fn show_welcome_if_first_launch(window: &adw::ApplicationWindow) {
|
||||
|
||||
let dialog = build_welcome_dialog();
|
||||
let win = window.clone();
|
||||
let win_for_present = window.clone();
|
||||
dialog.connect_closed(move |_| {
|
||||
let config = pixstrip_core::storage::ConfigStore::new();
|
||||
if let Ok(mut cfg) = config.load() {
|
||||
cfg.first_run_complete = true;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user