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)"));
}
}