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