feat(tui): add terminal setup, event loop, and teardown
Implements run_tui() with RAII terminal guard (TerminalGuard), panic hook for safe cleanup, and 50ms poll event loop. Placeholder renderer draws a bordered box. Wires into stubs.rs replacing the coming-soon stub. Closes claudbg-ut9q Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
c3721bc9ef
commit
1ddf7fb4fe
@ -1,6 +1,7 @@
|
|||||||
//! TUI module — terminal user interface for claudbg.
|
//! TUI module — terminal user interface for claudbg.
|
||||||
//!
|
//!
|
||||||
//! This module will grow to include rendering and event-handling logic.
|
//! - [`state`] — application state model (pure data, no I/O)
|
||||||
//! For now it exposes the application state model used by all TUI screens.
|
//! - [`run`] — terminal setup, main event loop, and teardown
|
||||||
|
|
||||||
|
pub mod run;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
@ -0,0 +1,198 @@
|
|||||||
|
//! TUI entry point: terminal setup, event loop, and graceful teardown.
|
||||||
|
//!
|
||||||
|
//! The public surface is [`run_tui`], a synchronous function that:
|
||||||
|
//!
|
||||||
|
//! 1. Enables raw mode and switches to the alternate screen.
|
||||||
|
//! 2. Installs a panic hook that restores the terminal before printing the
|
||||||
|
//! panic message, so the user's shell is never left in raw mode.
|
||||||
|
//! 3. Runs the main draw / event loop until the user quits.
|
||||||
|
//! 4. Tears down the terminal (disable raw mode, leave alternate screen) via
|
||||||
|
//! an RAII guard, so cleanup happens even if an error is returned.
|
||||||
|
|
||||||
|
use std::io::{self, Stdout};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||||
|
use ratatui::crossterm::execute;
|
||||||
|
use ratatui::crossterm::terminal::{
|
||||||
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||||
|
};
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::layout::Alignment;
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::tui::state::AppState;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RAII terminal guard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Restores the terminal to its original state when dropped.
|
||||||
|
///
|
||||||
|
/// Wraps the `Terminal` so that `disable_raw_mode` and
|
||||||
|
/// `LeaveAlternateScreen` are always called, whether the event loop exits
|
||||||
|
/// normally or returns an error.
|
||||||
|
struct TerminalGuard {
|
||||||
|
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalGuard {
|
||||||
|
/// Set up raw mode + alternate screen and build the `Terminal`.
|
||||||
|
fn new() -> Result<Self> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(io::stdout());
|
||||||
|
let terminal = Terminal::new(backend)?;
|
||||||
|
Ok(Self { terminal })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TerminalGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Best-effort: ignore errors during cleanup so we never panic in Drop.
|
||||||
|
let _ = disable_raw_mode();
|
||||||
|
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Panic hook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Install a panic hook that restores the terminal before printing the panic.
|
||||||
|
///
|
||||||
|
/// Without this, a panic in raw mode leaves the user's shell in an unusable
|
||||||
|
/// state. The hook disables raw mode and leaves the alternate screen, then
|
||||||
|
/// delegates to the default panic handler.
|
||||||
|
fn install_panic_hook() {
|
||||||
|
let default_hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
// Ignore errors — we are already panicking.
|
||||||
|
let _ = disable_raw_mode();
|
||||||
|
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||||
|
default_hook(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Draw a single frame.
|
||||||
|
///
|
||||||
|
/// Currently renders a placeholder block. Future tickets will replace this
|
||||||
|
/// with proper screen-dispatched rendering.
|
||||||
|
fn render(frame: &mut ratatui::Frame, _state: &AppState) {
|
||||||
|
let area = frame.area();
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" claudbg TUI ")
|
||||||
|
.borders(Borders::ALL);
|
||||||
|
let paragraph = Paragraph::new("Press q or Esc to quit.")
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Handle a single crossterm [`Event`], mutating `state` as needed.
|
||||||
|
fn handle_event(event: Event, state: &mut AppState) {
|
||||||
|
if let Event::Key(key) = event {
|
||||||
|
// Only react to key-press events (ignore release/repeat on Windows).
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') => state.should_quit = true,
|
||||||
|
KeyCode::Esc => state.go_back(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main event loop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Run the TUI synchronously.
|
||||||
|
///
|
||||||
|
/// Initialises the terminal, enters the event loop, and tears down cleanly
|
||||||
|
/// whether the loop exits via `should_quit` or via an error.
|
||||||
|
pub fn run_tui() -> Result<()> {
|
||||||
|
install_panic_hook();
|
||||||
|
|
||||||
|
let mut guard = TerminalGuard::new()?;
|
||||||
|
let mut state = AppState::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
guard.terminal.draw(|f| render(f, &state))?;
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(50))? {
|
||||||
|
handle_event(event::read()?, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.should_quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
// `guard` is dropped here → terminal restored automatically.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::tui::state::Screen;
|
||||||
|
|
||||||
|
fn key_press(code: KeyCode) -> Event {
|
||||||
|
Event::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pressing `q` sets `should_quit`.
|
||||||
|
#[test]
|
||||||
|
fn q_sets_should_quit() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
handle_event(key_press(KeyCode::Char('q')), &mut state);
|
||||||
|
assert!(state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pressing `Esc` calls `go_back` (returns to SessionList).
|
||||||
|
#[test]
|
||||||
|
fn esc_goes_back() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.enter_transcript("some-session");
|
||||||
|
handle_event(key_press(KeyCode::Esc), &mut state);
|
||||||
|
assert_eq!(state.screen, Screen::SessionList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key-release events are ignored.
|
||||||
|
#[test]
|
||||||
|
fn key_release_ignored() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let release_event = Event::Key(KeyEvent {
|
||||||
|
code: KeyCode::Char('q'),
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Release,
|
||||||
|
state: KeyEventState::NONE,
|
||||||
|
});
|
||||||
|
handle_event(release_event, &mut state);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue