diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs new file mode 100644 index 0000000..6b246b5 --- /dev/null +++ b/pixstrip-gtk/src/app.rs @@ -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, + state: Rc>, +} + +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", &["Right"]); + app.set_accels_for_action("win.prev-step", &["Left"]); + app.set_accels_for_action("win.process", &["Return"]); + for i in 1..=9 { + app.set_accels_for_action( + &format!("win.goto-step({})", i), + &[&format!("{}", i)], + ); + } + app.set_accels_for_action("win.add-files", &["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::()) { + 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)")); + } +} diff --git a/pixstrip-gtk/src/main.rs b/pixstrip-gtk/src/main.rs index 7c2f667..e8ce251 100644 --- a/pixstrip-gtk/src/main.rs +++ b/pixstrip-gtk/src/main.rs @@ -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(); -} diff --git a/pixstrip-gtk/src/step_indicator.rs b/pixstrip-gtk/src/step_indicator.rs new file mode 100644 index 0000000..5ae6e59 --- /dev/null +++ b/pixstrip-gtk/src/step_indicator.rs @@ -0,0 +1,126 @@ +use gtk::prelude::*; +use std::cell::RefCell; + +#[derive(Clone)] +pub struct StepIndicator { + container: gtk::Box, + dots: RefCell>, +} + +#[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 + } +} diff --git a/pixstrip-gtk/src/wizard.rs b/pixstrip-gtk/src/wizard.rs new file mode 100644 index 0000000..d94553a --- /dev/null +++ b/pixstrip-gtk/src/wizard.rs @@ -0,0 +1,88 @@ +pub struct WizardState { + pub current_step: usize, + pub total_steps: usize, + pub visited: Vec, + names: Vec, +} + +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 { + 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 { + 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() +}