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
+429
View File
@@ -0,0 +1,429 @@
//! Application state and the single `update` entry point.
//!
//! The walking skeleton recognises a small subset of the
//! canonical app-level commands from ADR-0003 — `quit` and
//! `mode` — plus the `:` one-shot escape from simple to advanced
//! per ADR-0003. Everything else is echoed to the output panel
//! tagged with the mode it was submitted under, so that mode
//! handling is visible end-to-end.
use std::collections::VecDeque;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use tracing::trace;
use crate::action::Action;
use crate::event::AppEvent;
use crate::mode::Mode;
/// Maximum number of output lines kept in the rolling buffer.
const OUTPUT_CAPACITY: usize = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputKind {
Echo,
System,
Error,
}
/// What mode the next submission would be evaluated in.
///
/// Derived from the persistent mode and the current input buffer.
/// The UI uses this to give immediate visual feedback for the `:`
/// one-shot escape: the moment a leading `:` is typed in simple
/// mode, the prompt flips to advanced styling, and reverts as
/// soon as the `:` is deleted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffectiveMode {
Simple,
AdvancedPersistent,
AdvancedOneShot,
}
impl EffectiveMode {
#[must_use]
pub const fn is_advanced(self) -> bool {
matches!(self, Self::AdvancedPersistent | Self::AdvancedOneShot)
}
}
#[derive(Debug, Clone)]
pub struct OutputLine {
pub text: String,
pub kind: OutputKind,
pub mode_at_submission: Mode,
}
#[derive(Debug)]
pub struct App {
pub mode: Mode,
pub input: String,
pub output: VecDeque<OutputLine>,
pub hint: Option<String>,
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
#[must_use]
pub fn new() -> Self {
Self {
mode: Mode::Simple,
input: String::new(),
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None,
}
}
/// Effective mode for the *next* submission, given the
/// persistent mode and the current input buffer. See
/// [`EffectiveMode`].
#[must_use]
pub fn effective_mode(&self) -> EffectiveMode {
match self.mode {
Mode::Advanced => EffectiveMode::AdvancedPersistent,
Mode::Simple if self.input.trim_start().starts_with(':') => {
EffectiveMode::AdvancedOneShot
}
Mode::Simple => EffectiveMode::Simple,
}
}
/// Process one event from the runtime, mutating state and
/// returning any actions for the runtime to enact.
pub fn update(&mut self, event: AppEvent) -> Vec<Action> {
match event {
AppEvent::Key(key) => self.handle_key(key),
AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(),
}
}
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
// On Windows, key events fire for both Press and Release; only
// honour Press to avoid double-handling. Other platforms only
// emit Press, so this is a no-op there.
if key.kind != KeyEventKind::Press {
return Vec::new();
}
trace!(?key, "handle_key");
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
(KeyCode::Enter, _) => self.submit(),
(KeyCode::Backspace, _) => {
self.input.pop();
Vec::new()
}
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
let was_empty = self.input.is_empty();
self.input.push(c);
// Convenience: when `:` becomes the leading character in
// simple mode, auto-insert a space after it so the input
// reads ": foo" rather than ":foo". The trailing space is
// an ordinary character — backspace removes it normally.
if c == ':' && was_empty && self.mode == Mode::Simple {
self.input.push(' ');
}
Vec::new()
}
_ => Vec::new(),
}
}
fn submit(&mut self) -> Vec<Action> {
let raw = std::mem::take(&mut self.input);
let trimmed = raw.trim();
if trimmed.is_empty() {
return Vec::new();
}
// `:` one-shot escape: in simple mode, a leading `:` means
// treat *this single submission* as advanced. The persistent
// mode is unchanged.
let (effective_mode, effective_input) =
if self.mode == Mode::Simple && trimmed.starts_with(':') {
(Mode::Advanced, trimmed[1..].trim().to_string())
} else {
(self.mode, trimmed.to_string())
};
if effective_input.is_empty() {
return Vec::new();
}
// Canonical app-level commands recognised in both modes.
// The walking skeleton implements only `quit` and `mode`;
// the rest of the canonical list lands in later iterations.
match effective_input.as_str() {
"quit" | "q" => return vec![Action::Quit],
other if other.starts_with("mode") => {
self.handle_mode_command(other);
return Vec::new();
}
_ => {}
}
// Default: echo the line tagged with its effective mode.
self.push_output(OutputLine {
text: effective_input,
kind: OutputKind::Echo,
mode_at_submission: effective_mode,
});
Vec::new()
}
fn handle_mode_command(&mut self, raw: &str) {
let arg = raw.strip_prefix("mode").unwrap_or(raw).trim();
match arg {
"simple" => {
self.mode = Mode::Simple;
self.note_system("mode: simple");
}
"advanced" => {
self.mode = Mode::Advanced;
self.note_system("mode: advanced");
}
"" => self.note_error("usage: mode simple | mode advanced"),
other => self.note_error(format!(
"unknown mode '{other}' (expected 'simple' or 'advanced')"
)),
}
}
fn note_system(&mut self, text: impl Into<String>) {
self.push_output(OutputLine {
text: text.into(),
kind: OutputKind::System,
mode_at_submission: self.mode,
});
}
fn note_error(&mut self, text: impl Into<String>) {
self.push_output(OutputLine {
text: text.into(),
kind: OutputKind::Error,
mode_at_submission: self.mode,
});
}
fn push_output(&mut self, line: OutputLine) {
self.output.push_back(line);
while self.output.len() > OUTPUT_CAPACITY {
self.output.pop_front();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use pretty_assertions::assert_eq;
fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn key_mod(code: KeyCode, mods: KeyModifiers) -> AppEvent {
AppEvent::Key(KeyEvent::new(code, mods))
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
#[test]
fn typing_accumulates_in_input_buffer() {
let mut app = App::new();
type_str(&mut app, "hello");
assert_eq!(app.input, "hello");
assert!(app.output.is_empty());
}
#[test]
fn backspace_removes_last_char() {
let mut app = App::new();
type_str(&mut app, "abc");
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "ab");
}
#[test]
fn enter_in_simple_mode_echoes_with_simple_tag() {
let mut app = App::new();
type_str(&mut app, "create table foo");
let actions = submit(&mut app);
assert!(actions.is_empty());
assert_eq!(app.input, "");
assert_eq!(app.output.len(), 1);
let line = &app.output[0];
assert_eq!(line.text, "create table foo");
assert_eq!(line.kind, OutputKind::Echo);
assert_eq!(line.mode_at_submission, Mode::Simple);
}
#[test]
fn enter_in_advanced_mode_echoes_with_advanced_tag() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "select 1");
submit(&mut app);
let line = &app.output[0];
assert_eq!(line.mode_at_submission, Mode::Advanced);
}
#[test]
fn mode_command_switches_persistently() {
let mut app = App::new();
type_str(&mut app, "mode advanced");
submit(&mut app);
assert_eq!(app.mode, Mode::Advanced);
type_str(&mut app, "mode simple");
submit(&mut app);
assert_eq!(app.mode, Mode::Simple);
}
#[test]
fn mode_command_with_unknown_arg_errors() {
let mut app = App::new();
type_str(&mut app, "mode sideways");
submit(&mut app);
assert_eq!(app.mode, Mode::Simple);
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.contains("unknown mode"));
}
#[test]
fn colon_prefix_in_simple_mode_is_one_shot_advanced() {
let mut app = App::new();
type_str(&mut app, ":select 1");
submit(&mut app);
// The persistent mode is unchanged.
assert_eq!(app.mode, Mode::Simple);
let line = &app.output[0];
// The submitted line was tagged advanced for this submission.
assert_eq!(line.mode_at_submission, Mode::Advanced);
// The leading `:` is stripped before echoing.
assert_eq!(line.text, "select 1");
// Subsequent submissions revert to simple.
type_str(&mut app, "list tables");
submit(&mut app);
assert_eq!(app.output[1].mode_at_submission, Mode::Simple);
}
#[test]
fn quit_command_returns_quit_action() {
let mut app = App::new();
type_str(&mut app, "quit");
let actions = submit(&mut app);
assert_eq!(actions, vec![Action::Quit]);
}
#[test]
fn ctrl_c_returns_quit_action() {
let mut app = App::new();
let actions = app.update(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(actions, vec![Action::Quit]);
}
#[test]
fn empty_submission_is_a_noop() {
let mut app = App::new();
let actions = submit(&mut app);
assert!(actions.is_empty());
assert!(app.output.is_empty());
}
#[test]
fn effective_mode_reflects_persistent_mode_when_no_input() {
let mut app = App::new();
assert_eq!(app.effective_mode(), EffectiveMode::Simple);
app.mode = Mode::Advanced;
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent);
}
#[test]
fn effective_mode_flips_to_one_shot_when_colon_typed_in_simple_mode() {
let mut app = App::new();
type_str(&mut app, ":sel");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
// Backspace through the colon reverts. The auto-inserted space
// after the colon counts as one extra character to clear.
while !app.input.is_empty() {
app.update(key(KeyCode::Backspace));
}
assert_eq!(app.effective_mode(), EffectiveMode::Simple);
}
#[test]
fn typing_colon_first_in_simple_mode_auto_inserts_a_space() {
let mut app = App::new();
type_str(&mut app, ":");
assert_eq!(app.input, ": ");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
}
#[test]
fn typing_colon_after_other_chars_does_not_auto_insert_space() {
let mut app = App::new();
type_str(&mut app, "ab:");
assert_eq!(app.input, "ab:");
}
#[test]
fn typing_colon_in_advanced_mode_does_not_auto_insert_space() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, ":");
assert_eq!(app.input, ":");
}
#[test]
fn auto_inserted_space_can_be_removed_with_backspace() {
let mut app = App::new();
type_str(&mut app, ":");
assert_eq!(app.input, ": ");
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, ":");
// The colon alone still triggers the one-shot.
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
}
#[test]
fn effective_mode_in_advanced_mode_ignores_leading_colon() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, ":hello");
// Leading `:` carries no special meaning in advanced mode.
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent);
}
#[test]
fn effective_mode_tolerates_leading_whitespace_before_colon() {
let mut app = App::new();
type_str(&mut app, " :select 1");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
}
#[test]
fn output_buffer_is_capped() {
let mut app = App::new();
for i in 0..(OUTPUT_CAPACITY + 50) {
type_str(&mut app, &format!("line{i}"));
submit(&mut app);
}
assert_eq!(app.output.len(), OUTPUT_CAPACITY);
// Oldest entries were dropped.
assert!(app.output.front().unwrap().text.starts_with("line50"));
}
}