Files
pixstrip/pixstrip-gtk/src/step_indicator.rs
lashman f3668c45c3 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
2026-03-08 14:18:15 +02:00

209 lines
6.9 KiB
Rust

use gtk::prelude::*;
use gtk::glib::prelude::ToVariant;
use std::cell::RefCell;
#[derive(Clone)]
pub struct StepIndicator {
container: gtk::Box,
grid: gtk::Grid,
dots: RefCell<Vec<StepDot>>,
/// Maps visual index -> actual step index
step_map: RefCell<Vec<usize>>,
}
#[derive(Clone)]
struct StepDot {
button: gtk::Button,
icon: gtk::Image,
label: gtk::Label,
}
impl StepIndicator {
pub fn new(step_names: &[String]) -> Self {
let container = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.halign(gtk::Align::Center)
.spacing(0)
.margin_top(8)
.margin_bottom(8)
.margin_start(12)
.margin_end(12)
.build();
container.set_overflow(gtk::Overflow::Hidden);
container.update_property(&[
gtk::accessible::Property::Label("Wizard step indicator"),
]);
let grid = gtk::Grid::builder()
.column_homogeneous(false)
.row_spacing(2)
.column_spacing(0)
.hexpand(false)
.build();
let indices: Vec<usize> = (0..step_names.len()).collect();
let dots = Self::build_dots(&grid, step_names, &indices);
container.append(&grid);
// First step starts as current
let total = dots.len();
if let Some(first) = dots.first() {
first.icon.set_icon_name(Some("radio-checked-symbolic"));
first.button.set_sensitive(true);
first.label.add_css_class("accent");
first.button.update_property(&[
gtk::accessible::Property::Label(
&format!("Step 1 of {}: {} (current)", total, first.label.label())
),
]);
}
// Label all non-current dots
for (i, dot) in dots.iter().enumerate().skip(1) {
dot.button.update_property(&[
gtk::accessible::Property::Label(
&format!("Step {} of {}: {}", i + 1, total, dot.label.label())
),
]);
}
Self {
container,
grid,
dots: RefCell::new(dots),
step_map: RefCell::new(indices),
}
}
fn build_dots(grid: &gtk::Grid, names: &[String], step_indices: &[usize]) -> Vec<StepDot> {
let mut dots = Vec::new();
for (visual_i, (name, &actual_i)) in names.iter().zip(step_indices.iter()).enumerate() {
let col = (visual_i * 2) as i32;
if visual_i > 0 {
let line = gtk::Separator::builder()
.orientation(gtk::Orientation::Horizontal)
.hexpand(false)
.valign(gtk::Align::Center)
.build();
line.set_size_request(12, -1);
grid.attach(&line, col - 1, 0, 1, 1);
}
let icon = gtk::Image::builder()
.icon_name("radio-symbolic")
.pixel_size(16)
.build();
let button = gtk::Button::builder()
.child(&icon)
.has_frame(false)
.tooltip_text(format!("Step {}: {} (Alt+{})", visual_i + 1, name, actual_i + 1))
.sensitive(false)
.action_name("win.goto-step")
.action_target(&(actual_i as i32 + 1).to_variant())
.halign(gtk::Align::Center)
.build();
button.add_css_class("circular");
let label = gtk::Label::builder()
.label(name)
.css_classes(["caption"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.width_chars(10)
.halign(gtk::Align::Center)
.build();
grid.attach(&button, col, 0, 1, 1);
grid.attach(&label, col, 1, 1, 1);
dots.push(StepDot {
button,
icon,
label,
});
}
dots
}
/// Rebuild the indicator to show only the given steps.
/// `visible_steps` is a list of (actual_step_index, name).
pub fn rebuild(&self, visible_steps: &[(usize, String)]) {
// Clear the grid
while let Some(child) = self.grid.first_child() {
self.grid.remove(&child);
}
let names: Vec<String> = visible_steps.iter().map(|(_, n)| n.clone()).collect();
let indices: Vec<usize> = visible_steps.iter().map(|(i, _)| *i).collect();
let dots = Self::build_dots(&self.grid, &names, &indices);
*self.dots.borrow_mut() = dots;
*self.step_map.borrow_mut() = indices;
}
/// Set the current step by actual step index. Finds the visual position.
pub fn set_current(&self, actual_index: usize) {
let dots = self.dots.borrow();
let map = self.step_map.borrow();
let total = dots.len();
for (visual_i, dot) in dots.iter().enumerate() {
let is_current = map.get(visual_i) == Some(&actual_index);
if is_current {
dot.icon.set_icon_name(Some("radio-checked-symbolic"));
dot.button.set_sensitive(true);
dot.label.add_css_class("accent");
dot.button.update_property(&[
gtk::accessible::Property::Label(
&format!("Step {} of {}: {} (current)", visual_i + 1, total, dot.label.label())
),
]);
} else if dot.icon.icon_name().as_deref() != Some("emblem-ok-symbolic") {
dot.icon.set_icon_name(Some("radio-symbolic"));
dot.label.remove_css_class("accent");
dot.button.update_property(&[
gtk::accessible::Property::Label(
&format!("Step {} of {}: {}", visual_i + 1, total, dot.label.label())
),
]);
}
}
}
/// Mark a step as completed by actual step index.
pub fn set_completed(&self, actual_index: usize) {
let dots = self.dots.borrow();
let map = self.step_map.borrow();
let total = dots.len();
if let Some(visual_i) = map.iter().position(|&i| i == actual_index) {
if let Some(dot) = dots.get(visual_i) {
dot.icon.set_icon_name(Some("emblem-ok-symbolic"));
dot.button.set_sensitive(true);
dot.label.remove_css_class("accent");
dot.button.update_property(&[
gtk::accessible::Property::Label(
&format!("Step {} of {}: {} (completed)", visual_i + 1, total, dot.label.label())
),
]);
}
}
}
pub fn widget(&self) -> &gtk::Box {
&self.container
}
}
// Allow appending the step indicator to containers
impl std::ops::Deref for StepIndicator {
type Target = gtk::Box;
fn deref(&self) -> &Self::Target {
&self.container
}
}