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:
282
pixstrip-gtk/src/app.rs
Normal file
282
pixstrip-gtk/src/app.rs
Normal 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: >k::Button, next_button: >k::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)"));
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
let app = adw::Application::builder()
|
||||
.application_id(APP_ID)
|
||||
.build();
|
||||
|
||||
app.connect_activate(build_ui);
|
||||
let app = app::build_app();
|
||||
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();
|
||||
}
|
||||
|
||||
126
pixstrip-gtk/src/step_indicator.rs
Normal file
126
pixstrip-gtk/src/step_indicator.rs
Normal 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) -> >k::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
|
||||
}
|
||||
}
|
||||
88
pixstrip-gtk/src/wizard.rs
Normal file
88
pixstrip-gtk/src/wizard.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user