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:
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
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 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user