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:
+140
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user