From 6d917e018f57a2fcb6ec3fe10bc69ea66f14e3c2 Mon Sep 17 00:00:00 2001 From: David Hagerty Date: Sat, 24 Jan 2026 07:47:33 -0800 Subject: [PATCH] feat(tui): add basic app state and terminal setup --- src/lib.rs | 1 + src/tui/app.rs | 47 +++++++++++++++++++++++++++++++++ src/tui/mod.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/tui/ui.rs | 6 +++++ 4 files changed, 125 insertions(+) create mode 100644 src/tui/app.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/ui.rs diff --git a/src/lib.rs b/src/lib.rs index 33ec1cf..3ba99fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,3 +6,4 @@ pub mod ralph; pub mod security; pub mod spec; pub mod tools; +pub mod tui; diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..284e38f --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,47 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActiveTab { + Dashboard, + Planning, + Execution, +} + +pub struct App { + pub running: bool, + pub active_tab: ActiveTab, +} + +impl App { + pub fn new() -> Self { + Self { + running: true, + active_tab: ActiveTab::Dashboard, + } + } + + /// Handle key events. Uses full KeyEvent to preserve modifiers. + pub fn handle_key(&mut self, key: KeyEvent) { + match (key.code, key.modifiers) { + (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.running = false, + (KeyCode::Char('q'), KeyModifiers::NONE) => self.running = false, + (KeyCode::Char('1'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Dashboard, + (KeyCode::Char('2'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Planning, + (KeyCode::Char('3'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Execution, + (KeyCode::Tab, _) => { + self.active_tab = match self.active_tab { + ActiveTab::Dashboard => ActiveTab::Planning, + ActiveTab::Planning => ActiveTab::Execution, + ActiveTab::Execution => ActiveTab::Dashboard, + }; + } + _ => {} + } + } +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..f9c829a --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,71 @@ +mod app; +mod ui; + +pub use app::{App, ActiveTab}; + +use std::io; +use std::panic; +use crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + event::{self, Event, KeyEventKind}, +}; +use ratatui::{ + backend::CrosstermBackend, + Terminal, +}; + +pub type Tui = Terminal>; + +pub fn setup_terminal() -> io::Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + Terminal::new(backend) +} + +pub fn restore_terminal(terminal: &mut Tui) -> io::Result<()> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} + +/// Run the TUI event loop with panic safety. +/// Terminal is always restored even on panic or error. +pub fn run(terminal: &mut Tui, app: &mut App) -> anyhow::Result<()> { + // Set panic hook to restore terminal + let original_hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + original_hook(info); + })); + + let result = run_loop(terminal, app); + + // Restore original panic hook + let _ = panic::take_hook(); + + result +} + +fn run_loop(terminal: &mut Tui, app: &mut App) -> anyhow::Result<()> { + while app.running { + terminal.draw(|frame| crate::tui::ui::draw(frame, app))?; + + if event::poll(std::time::Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + app.handle_key(key); + } + Event::Resize(_, _) => { + // Terminal will redraw on next iteration + } + _ => {} + } + } + } + Ok(()) +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..45ee10c --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,6 @@ +use ratatui::Frame; +use crate::tui::App; + +pub fn draw(_frame: &mut Frame, _app: &App) { + // Placeholder - will be implemented in Task 4 +} -- 2.51.2