- 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
240 lines
7.0 KiB
Rust
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: >k::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);
|
|
}
|
|
}
|