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
+12
View File
@@ -0,0 +1,12 @@
//! Actions returned by the application's update function.
//!
//! `update` is pure with respect to the runtime: it mutates state
//! in place and returns a list of `Action`s for the runtime to
//! enact (e.g. quit the event loop). Side effects belong here,
//! not in the update logic itself, which keeps `update` directly
//! testable without a Tokio runtime or a real terminal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Quit,
}
+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"));
}
}
+132
View File
@@ -0,0 +1,132 @@
//! CLI argument parsing.
//!
//! Walking-skeleton scope is small enough that a hand-rolled
//! parser is simpler than pulling in clap. When the CLI grows
//! (project loading per L1, L2 etc.) we will revisit.
use std::env;
use std::path::PathBuf;
use crate::theme::Theme;
#[derive(Debug, Clone)]
pub struct Args {
pub theme: Theme,
pub log_path: Option<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub enum ArgsError {
#[error("missing value for --{0}")]
MissingValue(&'static str),
#[error("invalid value for --{flag}: {value} (expected one of: {expected})")]
InvalidValue {
flag: &'static str,
value: String,
expected: &'static str,
},
#[error("unknown argument: {0}")]
Unknown(String),
}
impl Args {
/// Parse `Args` from the process command line.
pub fn from_env() -> Result<Self, ArgsError> {
Self::parse(env::args().skip(1))
}
/// Parse `Args` from an arbitrary iterator (used by tests).
pub fn parse<I, S>(iter: I) -> Result<Self, ArgsError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut theme = default_theme();
let mut log_path = env::var_os("RDBMS_PLAYGROUND_LOG_FILE").map(PathBuf::from);
let mut iter = iter.into_iter().map(Into::into);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--theme" => {
let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?;
theme = match value.as_str() {
"light" => Theme::light(),
"dark" => Theme::dark(),
other => {
return Err(ArgsError::InvalidValue {
flag: "theme",
value: other.to_string(),
expected: "light, dark",
});
}
};
}
"--log-file" => {
let value = iter.next().ok_or(ArgsError::MissingValue("log-file"))?;
log_path = Some(PathBuf::from(value));
}
other => return Err(ArgsError::Unknown(other.to_string())),
}
}
Ok(Self { theme, log_path })
}
}
fn default_theme() -> Theme {
// NFR-7: support both backgrounds. For the walking skeleton we
// honour an explicit `--theme` flag and the COLORFGBG env var
// (which xterm/Konsole/iTerm export in the form `<fg>;<bg>`).
// True OSC-11 background querying is a later improvement.
if let Ok(value) = env::var("COLORFGBG")
&& let Some(bg) = value.split(';').next_back()
&& let Ok(code) = bg.trim().parse::<u8>()
{
// Standard convention: 0..=6 and 8 are dark backgrounds,
// 7 and 9..=15 are light. ITerm emits 15 for white-ish.
let is_dark = matches!(code, 0..=6 | 8);
return if is_dark { Theme::dark() } else { Theme::light() };
}
Theme::default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Background;
#[test]
fn no_args_yields_default_theme() {
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
// The default depends on environment; we only assert it parsed.
let _ = args.theme;
}
#[test]
fn theme_flag_light() {
let args = Args::parse(["--theme", "light"]).unwrap();
assert_eq!(args.theme.background, Background::Light);
}
#[test]
fn theme_flag_dark() {
let args = Args::parse(["--theme", "dark"]).unwrap();
assert_eq!(args.theme.background, Background::Dark);
}
#[test]
fn theme_flag_invalid() {
let err = Args::parse(["--theme", "neon"]).unwrap_err();
assert!(matches!(err, ArgsError::InvalidValue { flag: "theme", .. }));
}
#[test]
fn theme_flag_missing_value() {
let err = Args::parse(["--theme"]).unwrap_err();
assert!(matches!(err, ArgsError::MissingValue("theme")));
}
#[test]
fn unknown_flag_errors() {
let err = Args::parse(["--bogus"]).unwrap_err();
assert!(matches!(err, ArgsError::Unknown(s) if s == "--bogus"));
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Events fed into the application's update function.
//!
//! `AppEvent` is the single input type the runtime delivers to
//! `App::update`. Synthetic instances drive Tier 3 integration
//! tests (see ADR-0008), so the type is plain data with no
//! runtime dependency.
use crossterm::event::KeyEvent;
#[derive(Debug, Clone)]
pub enum AppEvent {
Key(KeyEvent),
Resize { cols: u16, rows: u16 },
Tick,
}
+16
View File
@@ -0,0 +1,16 @@
//! Library crate for RDBMS Playground.
//!
//! Most of the application lives here so that integration tests in
//! `tests/` and unit tests inside the modules can drive the same
//! code that `main.rs` runs in production. The binary entry point
//! is intentionally thin.
pub mod action;
pub mod app;
pub mod cli;
pub mod event;
pub mod logging;
pub mod mode;
pub mod runtime;
pub mod theme;
pub mod ui;
+74
View File
@@ -0,0 +1,74 @@
//! Tracing-based logging setup.
//!
//! TUI applications cannot write logs to stdout/stderr without
//! corrupting the terminal, so logs go to a file. The path comes
//! from the CLI (`--log-file`) or the `RDBMS_PLAYGROUND_LOG_FILE`
//! environment variable; if neither is set we default to
//! `~/.rdbms-playground/playground.log` and create directories as
//! needed.
use std::fs::{File, OpenOptions, create_dir_all};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
const DEFAULT_LOG_DIR: &str = ".rdbms-playground";
const DEFAULT_LOG_FILE: &str = "playground.log";
/// Initialise tracing to write to the given file path, or to a
/// platform-default path when `path` is `None`.
pub fn init(path: Option<&Path>) -> Result<PathBuf> {
let chosen = match path {
Some(p) => p.to_path_buf(),
None => default_log_path()?,
};
if let Some(parent) = chosen.parent() {
create_dir_all(parent)
.with_context(|| format!("create log directory {}", parent.display()))?;
}
let file = open_log_file(&chosen)?;
let filter = EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG")
.unwrap_or_else(|_| EnvFilter::new("info"));
let layer = fmt::layer()
.with_writer(file)
.with_ansi(false)
.with_target(true);
tracing_subscriber::registry()
.with(filter)
.with(layer)
.init();
tracing::info!(path = %chosen.display(), "logging initialised");
Ok(chosen)
}
fn open_log_file(path: &Path) -> Result<File> {
OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("open log file {}", path.display()))
}
fn default_log_path() -> Result<PathBuf> {
let home = home_dir().context("could not determine HOME directory for default log path")?;
Ok(home.join(DEFAULT_LOG_DIR).join(DEFAULT_LOG_FILE))
}
fn home_dir() -> Option<PathBuf> {
// std::env::home_dir is deprecated; do the lookup ourselves with
// the platform-conventional environment variables. Once we add a
// proper path strategy (project storage, ADR-0004) this can be
// replaced with the chosen helper crate.
if let Some(p) = std::env::var_os("HOME") {
return Some(PathBuf::from(p));
}
if let (Some(drive), Some(path)) =
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
{
let mut combined = PathBuf::from(drive);
combined.push(path);
return Some(combined);
}
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
+36
View File
@@ -0,0 +1,36 @@
use std::process::ExitCode;
use rdbms_playground::cli::Args;
use rdbms_playground::{logging, runtime};
fn main() -> ExitCode {
let args = match Args::from_env() {
Ok(args) => args,
Err(e) => {
eprintln!("rdbms-playground: {e}");
return ExitCode::from(2);
}
};
if let Err(e) = logging::init(args.log_path.as_deref()) {
eprintln!("rdbms-playground: failed to initialise logging: {e:#}");
return ExitCode::FAILURE;
}
let tokio_rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
tracing::error!(error = %e, "failed to start tokio runtime");
return ExitCode::FAILURE;
}
};
match tokio_rt.block_on(runtime::run(args.theme)) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!(error = %e, "runtime exited with error");
eprintln!("rdbms-playground: {e:#}");
ExitCode::FAILURE
}
}
}
+29
View File
@@ -0,0 +1,29 @@
//! Input mode for the command field.
//!
//! See ADR-0003 for the design. The two modes determine how the
//! input field interprets a submitted line. The `:` one-shot
//! escape from simple to advanced is handled at submission time
//! in `app::App::submit`, not as additional state here.
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Mode {
Simple,
Advanced,
}
impl Mode {
pub const fn label(self) -> &'static str {
match self {
Self::Simple => "SIMPLE",
Self::Advanced => "ADVANCED",
}
}
}
impl fmt::Display for Mode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
+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(())
}
@@ -0,0 +1,28 @@
---
source: src/ui.rs
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
│(none yet) ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ ADVANCED ────────────────────────────────────────╮
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint) │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Enter submit · mode simple switch · Ctrl-C quit
@@ -0,0 +1,28 @@
---
source: src/ui.rs
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
│(none yet) ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint) │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -0,0 +1,28 @@
---
source: src/ui.rs
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
│(none yet) ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint) │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -0,0 +1,28 @@
---
source: src/ui.rs
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
│(none yet) ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Advanced: ───────────────────────────────────────╮
│ ││: sel │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint) │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Enter submit · Backspace cancel one-shot · Ctrl-C quit
+69
View File
@@ -0,0 +1,69 @@
//! Theme and colour palette.
//!
//! Two themes are provided — one for light terminal backgrounds
//! and one for dark — per NFR-7. The palette is intentionally
//! small for the walking skeleton; it grows as more views are
//! added. Contrast is chosen against the target background so
//! that foreground text meets WCAG-AA (NFR-5) on both variants.
use ratatui::style::Color;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Background {
Light,
Dark,
}
#[derive(Debug, Clone)]
pub struct Theme {
pub background: Background,
pub bg: Color,
pub fg: Color,
pub muted: Color,
pub border: Color,
pub border_advanced: Color,
pub mode_simple: Color,
pub mode_advanced: Color,
pub system: Color,
pub error: Color,
}
impl Theme {
#[must_use]
pub const fn dark() -> Self {
Self {
background: Background::Dark,
bg: Color::Rgb(0x18, 0x1B, 0x22),
fg: Color::Rgb(0xE6, 0xE6, 0xE6),
muted: Color::Rgb(0x8B, 0x90, 0x9A),
border: Color::Rgb(0x4A, 0x52, 0x65),
border_advanced: Color::Rgb(0xE0, 0x60, 0x60),
mode_simple: Color::Rgb(0x6E, 0xC4, 0xFF),
mode_advanced: Color::Rgb(0xFF, 0x9E, 0x6B),
system: Color::Rgb(0x9F, 0xD8, 0x91),
error: Color::Rgb(0xFF, 0x6B, 0x6B),
}
}
#[must_use]
pub const fn light() -> Self {
Self {
background: Background::Light,
bg: Color::Rgb(0xFA, 0xFA, 0xF7),
fg: Color::Rgb(0x1A, 0x1F, 0x2C),
muted: Color::Rgb(0x60, 0x66, 0x73),
border: Color::Rgb(0xB6, 0xBC, 0xC8),
border_advanced: Color::Rgb(0xC2, 0x3A, 0x3A),
mode_simple: Color::Rgb(0x21, 0x69, 0xC7),
mode_advanced: Color::Rgb(0xB0, 0x4A, 0x12),
system: Color::Rgb(0x2E, 0x7C, 0x3C),
error: Color::Rgb(0xC0, 0x39, 0x2B),
}
}
}
impl Default for Theme {
fn default() -> Self {
Self::dark()
}
}
+303
View File
@@ -0,0 +1,303 @@
//! Rendering of the application state into a Ratatui frame.
//!
//! The render function is pure with respect to runtime: given an
//! `App` and a `Theme`, the same frame is produced regardless of
//! when or where it is called. That property is what makes Tier 2
//! (TestBackend) and Tier 3 (synthetic event) tests in ADR-0008
//! straightforward.
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap};
use crate::app::{App, EffectiveMode, OutputKind, OutputLine};
use crate::mode::Mode;
use crate::theme::Theme;
/// Render the entire application frame.
pub fn render(app: &App, theme: &Theme, frame: &mut Frame<'_>) {
let area = frame.area();
paint_background(theme, frame, area);
// Reserve a single row at the bottom for the shortcut/status bar.
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(1)])
.split(area);
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(20)])
.split(outer[0]);
render_items_panel(theme, frame, columns[0]);
render_right_column(app, theme, frame, columns[1]);
render_status_bar(app, theme, frame, outer[1]);
}
fn render_right_column(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5), // Output panel
Constraint::Length(3), // Input panel
Constraint::Length(3), // Hint panel
])
.split(area);
render_output_panel(app, theme, frame, rows[0]);
render_input_panel(app, theme, frame, rows[1]);
render_hint_panel(app, theme, frame, rows[2]);
}
fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default().style(Style::default().bg(theme.bg).fg(theme.fg));
frame.render_widget(block, area);
}
fn render_items_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
" Tables ",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let placeholder = Paragraph::new(Line::from(Span::styled(
"(none yet)",
Style::default().fg(theme.muted).add_modifier(Modifier::ITALIC),
)))
.block(block);
frame.render_widget(placeholder, area);
}
fn render_output_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
" Output ",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let inner = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
// Show the most recent lines that fit. The output buffer is
// append-only, so taking from the back gives "most recent".
let visible = inner.height as usize;
let lines: Vec<Line<'_>> = app
.output
.iter()
.rev()
.take(visible)
.rev()
.map(|line| render_output_line(line, theme))
.collect();
frame.render_widget(block, area);
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
frame.render_widget(paragraph, inner);
}
fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
let tag_style = match line.mode_at_submission {
Mode::Simple => Style::default().fg(theme.mode_simple),
Mode::Advanced => Style::default().fg(theme.mode_advanced),
};
let body_style = match line.kind {
OutputKind::Echo => Style::default().fg(theme.fg),
OutputKind::System => Style::default().fg(theme.system),
OutputKind::Error => Style::default().fg(theme.error),
};
let tag = match line.kind {
OutputKind::Echo => format!("[{}] ", line.mode_at_submission.label().to_lowercase()),
OutputKind::System => "[system] ".to_string(),
OutputKind::Error => "[error] ".to_string(),
};
Line::from(vec![
Span::styled(tag, tag_style),
Span::styled(line.text.as_str(), body_style),
])
}
fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let effective = app.effective_mode();
let (border_color, mode_color, label) = match effective {
EffectiveMode::Simple => (theme.border, theme.mode_simple, "SIMPLE"),
EffectiveMode::AdvancedPersistent => {
(theme.border_advanced, theme.mode_advanced, "ADVANCED")
}
// Mixed-case label distinguishes the one-shot (`:`-triggered)
// state from a persistent advanced mode at a glance.
EffectiveMode::AdvancedOneShot => {
(theme.border_advanced, theme.mode_advanced, "Advanced:")
}
};
let title = Line::from(vec![
Span::raw(" "),
Span::styled(
label,
Style::default()
.fg(mode_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.title(title)
.style(Style::default().bg(theme.bg).fg(theme.fg));
// Cursor block: the character at the cursor position is rendered
// inverted so it is visible without enabling a real terminal cursor.
let spans = vec![
Span::styled(app.input.as_str(), Style::default().fg(theme.fg)),
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
];
let paragraph = Paragraph::new(Line::from(spans)).block(block);
frame.render_widget(paragraph, area);
}
fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
" Hint ",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let body = app.hint.as_deref().unwrap_or("(no active hint)");
let paragraph = Paragraph::new(Line::from(Span::styled(
body,
Style::default().fg(theme.muted),
)))
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let key_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let sep_style = Style::default().fg(theme.muted);
let label_style = Style::default().fg(theme.muted);
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
let separator = Span::styled(" · ", sep_style);
let mut spans: Vec<Span<'_>> = Vec::new();
let push_shortcut = |spans: &mut Vec<Span<'_>>, key: &'static str, label: &'static str| {
if !spans.is_empty() {
spans.push(separator.clone());
}
spans.push(Span::styled(key, key_style));
spans.push(Span::raw(" "));
spans.push(Span::styled(label, label_style));
};
push_shortcut(&mut spans, "Enter", "submit");
match app.effective_mode() {
EffectiveMode::Simple => {
push_shortcut(&mut spans, ":", "advanced once");
push_shortcut(&mut spans, "mode advanced", "switch");
}
EffectiveMode::AdvancedPersistent => {
push_shortcut(&mut spans, "mode simple", "switch");
}
EffectiveMode::AdvancedOneShot => {
push_shortcut(&mut spans, "Backspace", "cancel one-shot");
}
}
push_shortcut(&mut spans, "Ctrl-C", "quit");
let paragraph = Paragraph::new(Line::from(spans)).style(bar_style);
frame.render_widget(paragraph, area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn render_to_string(app: &App, theme: &Theme, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create terminal");
terminal
.draw(|f| render(app, theme, f))
.expect("draw frame");
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
out.push_str(buffer[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn dark_theme_default_view_snapshot() {
let app = App::new();
let theme = Theme::dark();
let snapshot = render_to_string(&app, &theme, 80, 24);
insta::assert_snapshot!("default_simple_dark", snapshot);
}
#[test]
fn light_theme_default_view_snapshot() {
let app = App::new();
let theme = Theme::light();
let snapshot = render_to_string(&app, &theme, 80, 24);
insta::assert_snapshot!("default_simple_light", snapshot);
}
#[test]
fn advanced_mode_default_view_snapshot() {
let mut app = App::new();
app.mode = Mode::Advanced;
let theme = Theme::dark();
let snapshot = render_to_string(&app, &theme, 80, 24);
insta::assert_snapshot!("default_advanced_dark", snapshot);
}
#[test]
fn one_shot_advanced_prompt_snapshot() {
// Typing `:sel` in simple mode should flip the input panel
// label to `Advanced:` while the persistent mode stays simple.
// The visible input includes the auto-inserted space after `:`.
let mut app = App::new();
app.input.push_str(": sel");
let theme = Theme::dark();
let snapshot = render_to_string(&app, &theme, 80, 24);
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
}
}