TUI walking skeleton (Phase 4)

First implementation milestone: Cargo project, dependencies,
and a minimal but functional TUI shell built on Ratatui +
Crossterm + Tokio in the Elm-style update/view pattern
(Candidate A from Phase 2/3 selection).

Includes:
- Three-region layout: items list (left), output + input + hint
  (right), bottom status bar with mode-aware shortcuts.
- Two themes (light, dark) plus COLORFGBG auto-detect, per
  NFR-7. CLI: --theme {light,dark}, --log-file <path>.
- Input modes per ADR-0003: simple (default), advanced, with
  the `:` one-shot escape including immediate prompt reaction
  ("Advanced:" label, advanced border) and auto-inserted space
  after a leading `:` in simple mode.
- App-level commands: `quit`/`q`, `mode simple`/`mode advanced`
  (canonical list per ADR-0003 — remaining commands land in
  later iterations).
- File logging via tracing, defaulting to ~/.rdbms-playground/
  playground.log so the TUI is not corrupted by stdio.

Testing per ADR-0008:
- Tier 1: 29 unit tests covering input handling, mode switch,
  one-shot escape, auto-space, output buffering, CLI parsing.
- Tier 2: 4 insta snapshots (default simple/advanced/light,
  one-shot active) of TestBackend frames.
- Tier 3: 7 integration tests driving synthetic events through
  App::update + render path.

All green: 36 tests, 0 failures, 0 skips. Clippy clean with
nursery lints enabled.
This commit is contained in:
claude@clouddev1
2026-05-07 11:17:58 +00:00
parent aebfc7dcba
commit 25a0f1260f
19 changed files with 3624 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
//! Tokio-based event loop.
//!
//! A blocking task reads crossterm events and forwards them onto
//! an `mpsc` channel as `AppEvent`s. The main loop awaits events,
//! feeds them to `App::update`, enacts any returned `Action`s,
//! and redraws the terminal. Future async work (query execution,
//! snapshotting, auto-save) joins the same channel as additional
//! producers, which is why we set the architecture up this way
//! from day one.
use std::io;
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use futures_util::StreamExt;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::action::Action;
use crate::app::App;
use crate::event::AppEvent;
use crate::theme::Theme;
use crate::ui;
const EVENT_CHANNEL_CAPACITY: usize = 64;
const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
/// Run the application until a `Quit` action is enacted or the
/// terminal closes.
pub async fn run(theme: Theme) -> Result<()> {
let mut terminal = setup_terminal().context("setup terminal")?;
let result = run_loop(&mut terminal, theme).await;
if let Err(e) = teardown_terminal(&mut terminal) {
// Teardown failures should not mask the primary error.
warn!(error = %e, "terminal teardown failed");
}
result
}
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
theme: Theme,
) -> Result<()> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx);
let mut app = App::new();
// Initial draw before any events arrive.
terminal
.draw(|f| ui::render(&app, &theme, f))
.context("initial draw")?;
info!("entering main event loop");
while let Some(event) = event_rx.recv().await {
let actions = app.update(event);
let mut should_quit = false;
for action in actions {
match action {
Action::Quit => {
debug!("quit action received");
should_quit = true;
}
}
}
terminal
.draw(|f| ui::render(&app, &theme, f))
.context("redraw")?;
if should_quit {
break;
}
}
// Give the reader a moment to notice the dropped sender.
let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await;
info!("event loop exited");
Ok(())
}
fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut stream = EventStream::new();
while let Some(maybe_event) = stream.next().await {
match maybe_event {
Ok(CtEvent::Key(key)) => {
if tx.send(AppEvent::Key(key)).await.is_err() {
break;
}
}
Ok(CtEvent::Resize(cols, rows)) => {
if tx.send(AppEvent::Resize { cols, rows }).await.is_err() {
break;
}
}
Ok(_) => {
// Ignore other event kinds (paste, focus, mouse) for now.
}
Err(e) => {
error!(error = %e, "crossterm event stream error");
break;
}
}
}
debug!("event reader exiting");
})
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
enable_raw_mode().context("enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.context("enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend).context("construct terminal")?;
Ok(terminal)
}
fn teardown_terminal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
disable_raw_mode().context("disable raw mode")?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.context("leave alternate screen")?;
terminal.show_cursor().context("show cursor")?;
Ok(())
}