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.
|
||||
//!
|
||||
//! This module will grow to include rendering and event-handling logic.
|
||||
//! For now it exposes the application state model used by all TUI screens.
|
||||
//! - [`state`] — application state model (pure data, no I/O)
|
||||
//! - [`run`] — terminal setup, main event loop, and teardown
|
||||
|
||||
pub mod run;
|
||||
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