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