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