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:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# Build artefacts
|
||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Snapshot test review files
|
||||||
|
*.snap.new
|
||||||
|
*.pending-snap
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
Generated
+2024
File diff suppressed because it is too large
Load Diff
+35
@@ -0,0 +1,35 @@
|
|||||||
|
[package]
|
||||||
|
name = "rdbms-playground"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "A cross-platform TUI playground for learning relational databases."
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/sturm/rdbms-playground"
|
||||||
|
readme = "README.md"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.102"
|
||||||
|
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||||
|
futures-util = "0.3.32"
|
||||||
|
ratatui = "0.30.0"
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
tokio = { version = "1.52.2", features = ["full"] }
|
||||||
|
tracing = "0.1.44"
|
||||||
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
insta = { version = "1.47.2", features = ["yaml"] }
|
||||||
|
pretty_assertions = "1.4.1"
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
|
unreachable_pub = "warn"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
all = { level = "warn", priority = -1 }
|
||||||
|
nursery = { level = "warn", priority = -1 }
|
||||||
|
# Allow common false-positives that don't materially improve our code.
|
||||||
|
module_name_repetitions = "allow"
|
||||||
|
missing_errors_doc = "allow"
|
||||||
|
missing_panics_doc = "allow"
|
||||||
@@ -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
@@ -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
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
//! Tier 3 integration tests for the walking skeleton (per ADR-0008).
|
||||||
|
//!
|
||||||
|
//! These tests drive synthetic crossterm events through `App::update`
|
||||||
|
//! and assert on the resulting state and rendered buffer. They
|
||||||
|
//! exercise the full input → state → render path without a real
|
||||||
|
//! terminal, so they run on every commit and catch regressions in
|
||||||
|
//! the wiring between modules.
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
|
||||||
|
use rdbms_playground::action::Action;
|
||||||
|
use rdbms_playground::app::{App, OutputKind};
|
||||||
|
use rdbms_playground::event::AppEvent;
|
||||||
|
use rdbms_playground::mode::Mode;
|
||||||
|
use rdbms_playground::theme::Theme;
|
||||||
|
use rdbms_playground::ui;
|
||||||
|
|
||||||
|
const fn key(code: KeyCode) -> AppEvent {
|
||||||
|
AppEvent::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: crossterm::event::KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_str(app: &mut App, s: &str) -> Vec<Action> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
for c in s.chars() {
|
||||||
|
actions.extend(app.update(key(KeyCode::Char(c))));
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit(app: &mut App) -> Vec<Action> {
|
||||||
|
app.update(key(KeyCode::Enter))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rendered_text(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| ui::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 typing_then_submitting_produces_an_echo_in_the_output_panel() {
|
||||||
|
let mut app = App::new();
|
||||||
|
let theme = Theme::dark();
|
||||||
|
|
||||||
|
type_str(&mut app, "hello world");
|
||||||
|
let pre_render = rendered_text(&app, &theme, 80, 24);
|
||||||
|
assert!(
|
||||||
|
pre_render.contains("hello world"),
|
||||||
|
"input field should display the typed text:\n{pre_render}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert!(actions.is_empty());
|
||||||
|
assert!(app.input.is_empty(), "input buffer cleared on submit");
|
||||||
|
assert_eq!(app.output.len(), 1);
|
||||||
|
|
||||||
|
let post_render = rendered_text(&app, &theme, 80, 24);
|
||||||
|
assert!(
|
||||||
|
post_render.contains("hello world"),
|
||||||
|
"output panel should display the echoed line:\n{post_render}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
post_render.contains("[simple]"),
|
||||||
|
"echo should be tagged with the submission mode:\n{post_render}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_switch_changes_label_and_subsequent_echoes() {
|
||||||
|
let mut app = App::new();
|
||||||
|
let theme = Theme::dark();
|
||||||
|
|
||||||
|
let initial = rendered_text(&app, &theme, 80, 24);
|
||||||
|
assert!(initial.contains("SIMPLE"));
|
||||||
|
assert!(!initial.contains("ADVANCED"));
|
||||||
|
|
||||||
|
type_str(&mut app, "mode advanced");
|
||||||
|
submit(&mut app);
|
||||||
|
assert_eq!(app.mode, Mode::Advanced);
|
||||||
|
|
||||||
|
let after_switch = rendered_text(&app, &theme, 80, 24);
|
||||||
|
assert!(after_switch.contains("ADVANCED"));
|
||||||
|
|
||||||
|
type_str(&mut app, "select 1");
|
||||||
|
submit(&mut app);
|
||||||
|
let last = app.output.back().expect("output present");
|
||||||
|
assert_eq!(last.mode_at_submission, Mode::Advanced);
|
||||||
|
assert_eq!(last.kind, OutputKind::Echo);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colon_escape_in_simple_mode_is_one_shot() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, ":select 1");
|
||||||
|
submit(&mut app);
|
||||||
|
assert_eq!(app.mode, Mode::Simple);
|
||||||
|
assert_eq!(app.output[0].mode_at_submission, Mode::Advanced);
|
||||||
|
assert_eq!(app.output[0].text, "select 1");
|
||||||
|
|
||||||
|
type_str(&mut app, "another line");
|
||||||
|
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 rendering_works_at_minimum_useful_size() {
|
||||||
|
// Sanity check that the layout does not panic at small sizes.
|
||||||
|
let app = App::new();
|
||||||
|
let theme = Theme::dark();
|
||||||
|
let _ = rendered_text(&app, &theme, 40, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
|
||||||
|
let mut app = App::new();
|
||||||
|
let theme = Theme::dark();
|
||||||
|
|
||||||
|
// No `:` yet — prompt shows SIMPLE.
|
||||||
|
type_str(&mut app, "sel");
|
||||||
|
let before = rendered_text(&app, &theme, 80, 24);
|
||||||
|
assert!(before.contains("SIMPLE"));
|
||||||
|
assert!(!before.contains("Advanced:"));
|
||||||
|
|
||||||
|
// Reset and type `:` first — prompt should flip immediately.
|
||||||
|
app.input.clear();
|
||||||
|
type_str(&mut app, ":");
|
||||||
|
let after_colon = rendered_text(&app, &theme, 80, 24);
|
||||||
|
assert!(
|
||||||
|
after_colon.contains("Advanced:"),
|
||||||
|
"input panel should show 'Advanced:' once `:` is typed:\n{after_colon}"
|
||||||
|
);
|
||||||
|
assert!(!after_colon.contains("SIMPLE"));
|
||||||
|
|
||||||
|
// Backspace through both the auto-inserted space and the `:`
|
||||||
|
// itself reverts the prompt.
|
||||||
|
while !app.input.is_empty() {
|
||||||
|
app.update(key(KeyCode::Backspace));
|
||||||
|
}
|
||||||
|
let after_revert = rendered_text(&app, &theme, 80, 24);
|
||||||
|
assert!(after_revert.contains("SIMPLE"));
|
||||||
|
assert!(!after_revert.contains("Advanced:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_bar_lists_quit_and_submit_in_all_modes() {
|
||||||
|
let mut app = App::new();
|
||||||
|
let theme = Theme::dark();
|
||||||
|
|
||||||
|
let simple = rendered_text(&app, &theme, 80, 24);
|
||||||
|
assert!(simple.contains("Enter"), "status bar lists Enter");
|
||||||
|
assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C");
|
||||||
|
assert!(simple.contains("mode advanced"));
|
||||||
|
|
||||||
|
type_str(&mut app, "mode advanced");
|
||||||
|
submit(&mut app);
|
||||||
|
let advanced = rendered_text(&app, &theme, 80, 24);
|
||||||
|
assert!(advanced.contains("Enter"));
|
||||||
|
assert!(advanced.contains("Ctrl-C"));
|
||||||
|
assert!(advanced.contains("mode simple"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user