Add GTK app shell with wizard navigation, step indicator, and actions

7-step wizard flow (Workflow, Images, Resize, Convert, Compress,
Metadata, Output) with AdwNavigationView, step indicator dots,
Back/Next buttons, keyboard shortcuts (Alt+arrows, Alt+1-9),
and hamburger menu with Settings and History placeholders.
This commit is contained in:
2026-03-06 11:03:11 +02:00
parent be7d345aa9
commit 20f4c24538
4 changed files with 501 additions and 33 deletions

282
pixstrip-gtk/src/app.rs Normal file
View File

@@ -0,0 +1,282 @@
use adw::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
use crate::step_indicator::StepIndicator;
use crate::wizard::WizardState;
pub const APP_ID: &str = "live.lashman.Pixstrip";
#[derive(Clone)]
struct WizardUi {
nav_view: adw::NavigationView,
step_indicator: StepIndicator,
back_button: gtk::Button,
next_button: gtk::Button,
title: adw::WindowTitle,
pages: Vec<adw::NavigationPage>,
state: Rc<RefCell<WizardState>>,
}
pub fn build_app() -> adw::Application {
let app = adw::Application::builder()
.application_id(APP_ID)
.build();
app.connect_activate(build_ui);
setup_shortcuts(&app);
app
}
fn setup_shortcuts(app: &adw::Application) {
app.set_accels_for_action("win.next-step", &["<Alt>Right"]);
app.set_accels_for_action("win.prev-step", &["<Alt>Left"]);
app.set_accels_for_action("win.process", &["<Control>Return"]);
for i in 1..=9 {
app.set_accels_for_action(
&format!("win.goto-step({})", i),
&[&format!("<Alt>{}", i)],
);
}
app.set_accels_for_action("win.add-files", &["<Control>o"]);
}
fn build_ui(app: &adw::Application) {
let state = Rc::new(RefCell::new(WizardState::new()));
// Header bar
let header = adw::HeaderBar::new();
let title = adw::WindowTitle::new("Pixstrip", "Batch Image Processor");
header.set_title_widget(Some(&title));
// Hamburger menu
let menu = build_menu();
let menu_button = gtk::MenuButton::builder()
.icon_name("open-menu-symbolic")
.menu_model(&menu)
.primary(true)
.tooltip_text("Main Menu")
.build();
header.pack_end(&menu_button);
// Step indicator
let step_indicator = StepIndicator::new(&state.borrow().step_names());
// Navigation view for wizard content
let nav_view = adw::NavigationView::new();
nav_view.set_vexpand(true);
// Build wizard pages
let pages = crate::wizard::build_wizard_pages();
for page in &pages {
nav_view.add(page);
}
// Bottom action bar
let back_button = gtk::Button::builder()
.label("Back")
.tooltip_text("Go to previous step (Alt+Left)")
.build();
back_button.add_css_class("flat");
let next_button = gtk::Button::builder()
.label("Next")
.tooltip_text("Go to next step (Alt+Right)")
.build();
next_button.add_css_class("suggested-action");
let bottom_box = gtk::CenterBox::new();
bottom_box.set_start_widget(Some(&back_button));
bottom_box.set_end_widget(Some(&next_button));
bottom_box.set_margin_start(12);
bottom_box.set_margin_end(12);
bottom_box.set_margin_top(6);
bottom_box.set_margin_bottom(6);
let bottom_bar = gtk::Box::new(gtk::Orientation::Vertical, 0);
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
bottom_bar.append(&separator);
bottom_bar.append(&bottom_box);
// Main content layout
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
content_box.append(step_indicator.widget());
content_box.append(&nav_view);
// Toolbar view with header and bottom bar
let toolbar_view = adw::ToolbarView::new();
toolbar_view.add_top_bar(&header);
toolbar_view.set_content(Some(&content_box));
toolbar_view.add_bottom_bar(&bottom_bar);
// Window
let window = adw::ApplicationWindow::builder()
.application(app)
.default_width(900)
.default_height(700)
.content(&toolbar_view)
.title("Pixstrip")
.build();
let ui = WizardUi {
nav_view,
step_indicator,
back_button,
next_button,
title,
pages,
state,
};
setup_window_actions(&window, &ui);
update_nav_buttons(&ui.state.borrow(), &ui.back_button, &ui.next_button);
ui.step_indicator.set_current(0);
window.present();
}
fn build_menu() -> gtk::gio::Menu {
let menu = gtk::gio::Menu::new();
menu.append(Some("Settings"), Some("win.show-settings"));
menu.append(Some("History"), Some("win.show-history"));
menu
}
fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
let action_group = gtk::gio::SimpleActionGroup::new();
// Next step action
{
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("next-step", None);
action.connect_activate(move |_, _| {
let mut s = ui.state.borrow_mut();
if s.can_go_next() {
s.go_next();
let idx = s.current_step;
drop(s);
navigate_to_step(&ui, idx);
}
});
action_group.add_action(&action);
}
// Previous step action
{
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new("prev-step", None);
action.connect_activate(move |_, _| {
let mut s = ui.state.borrow_mut();
if s.can_go_back() {
s.go_back();
let idx = s.current_step;
drop(s);
navigate_to_step(&ui, idx);
}
});
action_group.add_action(&action);
}
// Go to specific step action
{
let ui = ui.clone();
let action = gtk::gio::SimpleAction::new(
"goto-step",
Some(&i32::static_variant_type()),
);
action.connect_activate(move |_, param| {
if let Some(step) = param.and_then(|p| p.get::<i32>()) {
let target = (step - 1) as usize;
let s = ui.state.borrow();
if target < s.total_steps && s.visited[target] {
drop(s);
ui.state.borrow_mut().current_step = target;
navigate_to_step(&ui, target);
}
}
});
action_group.add_action(&action);
}
// Process action (placeholder)
{
let action = gtk::gio::SimpleAction::new("process", None);
action.connect_activate(move |_, _| {});
action_group.add_action(&action);
}
// Add files action (placeholder)
{
let action = gtk::gio::SimpleAction::new("add-files", None);
action.connect_activate(move |_, _| {});
action_group.add_action(&action);
}
// Settings action (placeholder)
{
let action = gtk::gio::SimpleAction::new("show-settings", None);
action.connect_activate(move |_, _| {});
action_group.add_action(&action);
}
// History action (placeholder)
{
let action = gtk::gio::SimpleAction::new("show-history", None);
action.connect_activate(move |_, _| {});
action_group.add_action(&action);
}
// Connect button clicks
ui.back_button.connect_clicked({
let action_group = action_group.clone();
move |_| {
ActionGroupExt::activate_action(&action_group, "prev-step", None);
}
});
ui.next_button.connect_clicked({
let action_group = action_group.clone();
move |_| {
ActionGroupExt::activate_action(&action_group, "next-step", None);
}
});
window.insert_action_group("win", Some(&action_group));
}
fn navigate_to_step(ui: &WizardUi, target: usize) {
let s = ui.state.borrow();
// Update step indicator
ui.step_indicator.set_current(target);
for (i, visited) in s.visited.iter().enumerate() {
if *visited && i != target {
ui.step_indicator.set_completed(i);
}
}
// Navigate - replace entire stack up to target
if target < ui.pages.len() {
ui.nav_view.replace(&ui.pages[..=target]);
ui.title.set_subtitle(&ui.pages[target].title());
}
update_nav_buttons(&s, &ui.back_button, &ui.next_button);
}
fn update_nav_buttons(state: &WizardState, back_button: &gtk::Button, next_button: &gtk::Button) {
back_button.set_sensitive(state.can_go_back());
back_button.set_visible(state.current_step > 0);
if state.is_last_step() {
next_button.set_label("Process");
next_button.remove_css_class("suggested-action");
next_button.add_css_class("suggested-action");
next_button.set_tooltip_text(Some("Start processing (Ctrl+Enter)"));
} else {
next_button.set_label("Next");
next_button.add_css_class("suggested-action");
next_button.set_tooltip_text(Some("Go to next step (Alt+Right)"));
}
}

View File

@@ -1,38 +1,10 @@
use adw::prelude::*; mod app;
mod step_indicator;
mod wizard;
const APP_ID: &str = "live.lashman.Pixstrip"; use gtk::prelude::*;
fn main() { fn main() {
let app = adw::Application::builder() let app = app::build_app();
.application_id(APP_ID)
.build();
app.connect_activate(build_ui);
app.run(); app.run();
} }
fn build_ui(app: &adw::Application) {
let header = adw::HeaderBar::new();
let title = adw::WindowTitle::new("Pixstrip", "Batch Image Processor");
header.set_title_widget(Some(&title));
let content = adw::StatusPage::builder()
.title("Pixstrip")
.description(format!("v{}", pixstrip_core::version()).as_str())
.icon_name("image-x-generic-symbolic")
.build();
let toolbar_view = adw::ToolbarView::new();
toolbar_view.add_top_bar(&header);
toolbar_view.set_content(Some(&content));
let window = adw::ApplicationWindow::builder()
.application(app)
.default_width(900)
.default_height(650)
.content(&toolbar_view)
.build();
window.present();
}

View File

@@ -0,0 +1,126 @@
use gtk::prelude::*;
use std::cell::RefCell;
#[derive(Clone)]
pub struct StepIndicator {
container: gtk::Box,
dots: RefCell<Vec<StepDot>>,
}
#[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();
let mut dots = Vec::new();
for (i, name) in step_names.iter().enumerate() {
if i > 0 {
// Connector line between dots
let line = gtk::Separator::builder()
.orientation(gtk::Orientation::Horizontal)
.hexpand(false)
.valign(gtk::Align::Center)
.build();
line.set_size_request(24, -1);
container.append(&line);
}
let dot_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(2)
.halign(gtk::Align::Center)
.build();
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 {}: {}", i + 1, name))
.sensitive(false)
.build();
button.add_css_class("circular");
let label = gtk::Label::builder()
.label(name)
.css_classes(["caption"])
.build();
dot_box.append(&button);
dot_box.append(&label);
container.append(&dot_box);
dots.push(StepDot {
button,
icon,
label,
});
}
// First step starts as current
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");
}
Self {
container,
dots: RefCell::new(dots),
}
}
pub fn set_current(&self, index: usize) {
let dots = self.dots.borrow();
for (i, dot) in dots.iter().enumerate() {
if i == index {
dot.icon.set_icon_name(Some("radio-checked-symbolic"));
dot.button.set_sensitive(true);
dot.label.add_css_class("accent");
} 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");
}
}
}
pub fn set_completed(&self, index: usize) {
let dots = self.dots.borrow();
if let Some(dot) = dots.get(index) {
dot.icon.set_icon_name(Some("emblem-ok-symbolic"));
dot.button.set_sensitive(true);
dot.label.remove_css_class("accent");
}
}
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
}
}

View File

@@ -0,0 +1,88 @@
pub struct WizardState {
pub current_step: usize,
pub total_steps: usize,
pub visited: Vec<bool>,
names: Vec<String>,
}
impl WizardState {
pub fn new() -> Self {
let names = vec![
"Workflow".into(),
"Images".into(),
"Resize".into(),
"Convert".into(),
"Compress".into(),
"Metadata".into(),
"Output".into(),
];
let total = names.len();
let mut visited = vec![false; total];
visited[0] = true;
Self {
current_step: 0,
total_steps: total,
visited,
names,
}
}
pub fn step_names(&self) -> Vec<String> {
self.names.clone()
}
pub fn can_go_next(&self) -> bool {
self.current_step < self.total_steps - 1
}
pub fn can_go_back(&self) -> bool {
self.current_step > 0
}
pub fn is_last_step(&self) -> bool {
self.current_step == self.total_steps - 1
}
pub fn go_next(&mut self) {
if self.can_go_next() {
self.current_step += 1;
self.visited[self.current_step] = true;
}
}
pub fn go_back(&mut self) {
if self.can_go_back() {
self.current_step -= 1;
}
}
}
pub fn build_wizard_pages() -> Vec<adw::NavigationPage> {
let steps = [
("step-workflow", "Choose a Workflow", "image-x-generic-symbolic", "Select a preset or build a custom workflow"),
("step-images", "Add Images", "folder-pictures-symbolic", "Drop images here or click Browse"),
("step-resize", "Resize", "view-fullscreen-symbolic", "Set output dimensions"),
("step-convert", "Convert", "document-save-symbolic", "Choose output format"),
("step-compress", "Compress", "system-file-manager-symbolic", "Set compression quality"),
("step-metadata", "Metadata", "security-high-symbolic", "Control metadata privacy"),
("step-output", "Output & Process", "emblem-ok-symbolic", "Review and process"),
];
steps
.iter()
.map(|(tag, title, icon, description)| {
let status_page = adw::StatusPage::builder()
.title(*title)
.description(*description)
.icon_name(*icon)
.build();
adw::NavigationPage::builder()
.title(*title)
.tag(*tag)
.child(&status_page)
.build()
})
.collect()
}