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
This commit is contained in:
@@ -1,46 +1,7 @@
|
||||
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.
|
||||
/// 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();
|
||||
@@ -53,28 +14,74 @@ pub fn show_tutorial_if_needed(window: &adw::ApplicationWindow) {
|
||||
// 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);
|
||||
show_tour_stop(&win, 0);
|
||||
});
|
||||
}
|
||||
|
||||
fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
|
||||
let stop = &TOUR_STOPS[stop_index];
|
||||
let total = TOUR_STOPS.len();
|
||||
/// 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,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Quick Tour")
|
||||
.content_width(420)
|
||||
.content_height(300)
|
||||
.build();
|
||||
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(16)
|
||||
.margin_top(24)
|
||||
.margin_bottom(24)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.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()
|
||||
@@ -82,12 +89,11 @@ fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
|
||||
.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}" })
|
||||
.label(if i == index { "\u{25CF}" } else { "\u{25CB}" })
|
||||
.build();
|
||||
if i == stop_index {
|
||||
if i == index {
|
||||
dot.add_css_class("accent");
|
||||
} else {
|
||||
dot.add_css_class("dim-label");
|
||||
@@ -96,94 +102,132 @@ fn show_tour_dialog(window: &adw::ApplicationWindow, stop_index: usize) {
|
||||
}
|
||||
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)
|
||||
let title_label = gtk::Label::builder()
|
||||
.label(title)
|
||||
.css_classes(["title-3"])
|
||||
.halign(gtk::Align::Start)
|
||||
.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);
|
||||
content.append(&title_label);
|
||||
|
||||
// Description
|
||||
let desc = gtk::Label::builder()
|
||||
.label(stop.description)
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label(description)
|
||||
.wrap(true)
|
||||
.halign(gtk::Align::Center)
|
||||
.justify(gtk::Justification::Center)
|
||||
.max_width_chars(36)
|
||||
.halign(gtk::Align::Start)
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
content.append(&desc);
|
||||
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::Center)
|
||||
.margin_top(8)
|
||||
.halign(gtk::Align::End)
|
||||
.margin_top(4)
|
||||
.build();
|
||||
|
||||
let skip_button = gtk::Button::builder()
|
||||
let skip_btn = gtk::Button::builder()
|
||||
.label("Skip Tour")
|
||||
.tooltip_text("Close the tour and start using Pixstrip")
|
||||
.build();
|
||||
skip_button.add_css_class("flat");
|
||||
skip_btn.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)
|
||||
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_button.add_css_class("suggested-action");
|
||||
next_button.add_css_class("pill");
|
||||
next_btn.add_css_class("suggested-action");
|
||||
next_btn.add_css_class("pill");
|
||||
|
||||
button_box.append(&skip_button);
|
||||
button_box.append(&next_button);
|
||||
button_box.append(&skip_btn);
|
||||
button_box.append(&next_btn);
|
||||
content.append(&button_box);
|
||||
|
||||
dialog.set_child(Some(&content));
|
||||
// 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);
|
||||
|
||||
// Wire skip - mark tutorial complete and close
|
||||
// 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 dlg = dialog.clone();
|
||||
skip_button.connect_clicked(move |_| {
|
||||
let pop = popover.clone();
|
||||
skip_btn.connect_clicked(move |_| {
|
||||
mark_tutorial_complete();
|
||||
dlg.close();
|
||||
pop.popdown();
|
||||
let p = pop.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
p.unparent();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Wire next
|
||||
// Wire next button
|
||||
{
|
||||
let dlg = dialog.clone();
|
||||
let pop = popover.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);
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(window));
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user