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
+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);
}
}