feat: added tui

This commit is contained in:
robcholz
2026-02-05 22:14:21 -05:00
parent e5be12d8b7
commit 0bd9aead3f
6 changed files with 1866 additions and 8 deletions

927
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,3 +27,9 @@ thiserror = "2.0.18"
time = { version = "0.3", features = ["serde", "formatting", "parsing"] }
toml = "0.9.8"
uuid = { version = "1", features = ["v7", "serde"] }
color-eyre = "0.6.3"
crossterm = { version = "0.28.1", features = ["event-stream"] }
futures = "0.3.31"
ratatui = "0.29.0"
tokio = { version = "1.40.0", features = ["full"] }
tui-textarea = { version = "0.4", default-features = false, features = ["ratatui"] }

View File

@@ -1,5 +1,7 @@
# Tasks
## SessionManager
1. [x] Confirm requirements and scope from `implementations.md`.
2. [x] Define `SessionManager` responsibilities and public API (create, load, list, update, delete, bump last_active, refcount handling, cleanup orphaned index entries).
3. [x] Choose 3rd-party crates for UUIDv7, TOML persistence, and error handling (e.g., `uuid` with v7, `serde` + `toml`, `thiserror`).
@@ -8,5 +10,22 @@
6. [x] Add tests for edge cases (missing index, invalid TOML, duplicate sessions, refcount transitions, cleanup on missing instance dir).
7. [ ] Run tests and coverage; target >=80% line/branch coverage using a Rust coverage tool (e.g., `cargo llvm-cov`).
8. [x] Refactor for clarity and reliability while keeping tests green.
9. [ ] Add TUI interface.
10. [ ] Integrate VM and SessionManager together.
## TUI
1. [x] Review `docs/tui.md` requirements and translate into concrete UI sections and state model.
2. [x] Add required dependencies for ratatui/crossterm/tokio/color-eyre/futures and pick a text input widget crate.
3. [x] Write unit tests for layout calculations (header/terminal/input/status/completions), completion state transitions, and CLI argument parsing.
4. [x] Implement TUI state model (header info, terminal history, input area, completion list, status bar visibility).
5. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
6. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
7. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
8. [ ] Run tests and validate coverage for the new module.
## TUI
## Integration
1. [ ] Integrate VM and SessionManager together.

28
docs/tui.md Normal file
View File

@@ -0,0 +1,28 @@
# TUI
The end-user ui for the vibebox
## TUI header
- This is a welcome header.
- Shows text: "Welcome to Vibebox vX.X.XX"
- Shows the ASCII banner
- An outlined box
- Shows the current directory
- Shows current vm version and max memory, cpu cores
- The position is flex, so this will move with the VM terminal history.
## Terminal Area
- Shows all the VM terminal history
## Vibebox input area
- A text input area, user can input text in it, it can vertically expand depending on the text length, by default it
is a line high.
- it should be able to switch to auto-completion mode, which will display a list of available commands. When in this mode, the bottom
status bar will disappear. The auto completions are displayed right below the text input area.
## Bottom status bar
- Display texts in gray, a line high. on the left it shows `:help` for help.

215
src/bin/vibebox-tui.rs Normal file
View File

@@ -0,0 +1,215 @@
use std::{env, ffi::OsString, path::PathBuf};
use color_eyre::Result;
use lexopt::prelude::*;
#[path = "../tui.rs"]
mod tui;
use tui::{AppState, VmInfo};
#[derive(Debug, Clone, PartialEq, Eq)]
struct TuiConfig {
cwd: PathBuf,
vm_version: String,
max_memory_mb: u64,
cpu_cores: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum TuiCommand {
Run(TuiConfig),
Help,
Version,
}
#[derive(Debug, thiserror::Error)]
enum CliError {
#[error("{0}")]
Message(String),
#[error(transparent)]
Lexopt(#[from] lexopt::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let command = parse_args(env::args_os())?;
match command {
TuiCommand::Help => {
print_help();
}
TuiCommand::Version => {
println!("vibebox-tui {}", env!("CARGO_PKG_VERSION"));
}
TuiCommand::Run(config) => {
let vm_info = VmInfo {
version: config.vm_version,
max_memory_mb: config.max_memory_mb,
cpu_cores: config.cpu_cores,
};
let mut app = AppState::new(config.cwd, vm_info);
app.push_history("VM output will appear here.");
app.push_history("TODO: wire VM IO into the TUI event loop.");
tui::run_tui(app).await?;
}
}
Ok(())
}
fn print_help() {
println!(
"vibebox-tui\n\nUsage:\n vibebox-tui [options]\n\nOptions:\n --help, -h Show this help\n --version Show version\n --cwd <path> Working directory for the session header\n --vm-version <ver> VM version string for the header\n --max-memory <mb> Max memory in MB (default 2048)\n --cpu-cores <count> CPU core count (default 2)\n"
);
}
fn parse_args<I>(args: I) -> Result<TuiCommand, CliError>
where
I: IntoIterator<Item = OsString>,
{
fn os_to_string(value: OsString, flag: &str) -> Result<String, CliError> {
value
.into_string()
.map_err(|_| CliError::Message(format!("{flag} expects valid UTF-8")))
}
let mut parser = lexopt::Parser::from_iter(args);
let mut cwd: Option<PathBuf> = None;
let mut vm_version = env!("CARGO_PKG_VERSION").to_string();
let mut max_memory_mb: u64 = 2048;
let mut cpu_cores: usize = 2;
while let Some(arg) = parser.next()? {
match arg {
Long("help") | Short('h') => return Ok(TuiCommand::Help),
Long("version") => return Ok(TuiCommand::Version),
Long("cwd") => {
let value = os_to_string(parser.value()?, "--cwd")?;
cwd = Some(PathBuf::from(value));
}
Long("vm-version") => {
vm_version = os_to_string(parser.value()?, "--vm-version")?;
}
Long("max-memory") => {
let value: u64 = os_to_string(parser.value()?, "--max-memory")?
.parse()
.map_err(|_| {
CliError::Message("--max-memory expects an integer".to_string())
})?;
if value == 0 {
return Err(CliError::Message("--max-memory must be >= 1".to_string()));
}
max_memory_mb = value;
}
Long("cpu-cores") => {
let value: usize = os_to_string(parser.value()?, "--cpu-cores")?
.parse()
.map_err(|_| CliError::Message("--cpu-cores expects an integer".to_string()))?;
if value == 0 {
return Err(CliError::Message("--cpu-cores must be >= 1".to_string()));
}
cpu_cores = value;
}
Value(value) => {
return Err(CliError::Message(format!(
"unexpected argument: {}",
value.to_string_lossy()
)));
}
_ => return Err(CliError::Message(arg.unexpected().to_string())),
}
}
let cwd = match cwd {
Some(dir) => dir,
None => env::current_dir()?,
};
Ok(TuiCommand::Run(TuiConfig {
cwd,
vm_version,
max_memory_mb,
cpu_cores,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_from(args: &[&str]) -> Result<TuiCommand, CliError> {
let mut argv = vec![OsString::from("vibebox-tui")];
argv.extend(args.iter().map(OsString::from));
parse_args(argv)
}
#[test]
fn parse_help_short_circuit() {
let command = parse_from(&["--help"]).unwrap();
assert!(matches!(command, TuiCommand::Help));
}
#[test]
fn parse_version_short_circuit() {
let command = parse_from(&["--version"]).unwrap();
assert!(matches!(command, TuiCommand::Version));
}
#[test]
fn parse_defaults() {
let command = parse_from(&[]).unwrap();
let TuiCommand::Run(config) = command else {
panic!("expected run command");
};
assert_eq!(config.vm_version, env!("CARGO_PKG_VERSION"));
assert_eq!(config.max_memory_mb, 2048);
assert_eq!(config.cpu_cores, 2);
}
#[test]
fn parse_overrides() {
let command = parse_from(&[
"--cwd",
"/tmp",
"--vm-version",
"13.1",
"--max-memory",
"4096",
"--cpu-cores",
"4",
])
.unwrap();
let TuiCommand::Run(config) = command else {
panic!("expected run command");
};
assert_eq!(config.cwd, PathBuf::from("/tmp"));
assert_eq!(config.vm_version, "13.1");
assert_eq!(config.max_memory_mb, 4096);
assert_eq!(config.cpu_cores, 4);
}
#[test]
fn parse_rejects_zero_cpu() {
let err = parse_from(&["--cpu-cores", "0"]).unwrap_err();
assert!(err.to_string().contains("cpu-cores"));
}
#[test]
fn parse_rejects_zero_memory() {
let err = parse_from(&["--max-memory", "0"]).unwrap_err();
assert!(err.to_string().contains("max-memory"));
}
#[test]
fn parse_rejects_unknown_argument() {
let err = parse_from(&["--unknown"]).unwrap_err();
assert!(!err.to_string().is_empty());
}
}

675
src/tui.rs Normal file
View File

@@ -0,0 +1,675 @@
use std::{
io::{self, Stdout},
path::PathBuf,
time::Duration,
};
use color_eyre::Result;
use crossterm::{
event::{
DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyCode,
KeyEvent, KeyEventKind, KeyModifiers,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use futures::StreamExt;
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
};
use tui_textarea::{Input, Key, TextArea};
const ASCII_BANNER: [&str; 7] = [
"░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░",
"░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
" ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
" ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓██████▓▒░",
" ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
" ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
" ░▒▓██▓▒░ ░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░",
];
const STATUS_BAR_HEIGHT: u16 = 1;
const COMPLETIONS_MAX_HEIGHT: u16 = 6;
const SPINNER_FRAMES: [&str; 4] = ["|", "/", "-", "\\"];
#[derive(Debug, Clone)]
pub struct VmInfo {
pub version: String,
pub max_memory_mb: u64,
pub cpu_cores: usize,
}
#[derive(Debug)]
pub struct AppState {
pub cwd: PathBuf,
pub vm_info: VmInfo,
pub history: Vec<String>,
pub input: TextArea<'static>,
pub completions: CompletionState,
pub should_quit: bool,
key_input_mode: KeyInputMode,
tick: u64,
spinner: usize,
terminal_scroll: usize,
}
impl AppState {
pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
let mut input = TextArea::default();
input.set_cursor_style(Style::default().fg(Color::Yellow));
input.set_block(Block::default().borders(Borders::ALL).title("Input"));
Self {
cwd,
vm_info,
history: Vec::new(),
input,
completions: CompletionState::default(),
should_quit: false,
key_input_mode: KeyInputMode::Unknown,
tick: 0,
spinner: 0,
terminal_scroll: 0,
}
}
pub fn input_line_count(&self) -> u16 {
self.input.lines().len().max(1) as u16
}
pub fn input_height(&self) -> u16 {
let mut height = self.input_line_count();
if self.input.block().is_some() {
height = height.saturating_add(2);
}
height.max(1)
}
pub fn push_history(&mut self, line: impl Into<String>) {
self.history.push(line.into());
if self.history.len() > 2000 {
let excess = self.history.len() - 2000;
self.history.drain(0..excess);
}
}
pub fn activate_completions(&mut self, items: Vec<String>) {
self.completions.set_items(items);
self.completions.activate();
}
pub fn deactivate_completions(&mut self) {
self.completions.deactivate();
}
pub fn toggle_completions(&mut self, items: Vec<String>) {
if self.completions.active {
self.deactivate_completions();
} else {
self.activate_completions(items);
}
}
}
#[derive(Debug, Default, Clone)]
pub struct CompletionState {
items: Vec<String>,
selected: usize,
active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KeyInputMode {
Unknown,
Press,
Release,
}
impl CompletionState {
pub fn set_items(&mut self, items: Vec<String>) {
self.items = items;
self.selected = 0;
}
pub fn activate(&mut self) {
self.active = !self.items.is_empty();
self.selected = 0;
}
pub fn deactivate(&mut self) {
self.active = false;
}
pub fn next(&mut self) {
if !self.active || self.items.is_empty() {
return;
}
self.selected = (self.selected + 1) % self.items.len();
}
pub fn previous(&mut self) {
if !self.active || self.items.is_empty() {
return;
}
if self.selected == 0 {
self.selected = self.items.len() - 1;
} else {
self.selected -= 1;
}
}
pub fn current(&self) -> Option<&str> {
if self.active {
self.items.get(self.selected).map(|s| s.as_str())
} else {
None
}
}
pub fn items(&self) -> &[String] {
&self.items
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn selected(&self) -> usize {
self.selected
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LayoutAreas {
pub header: Rect,
pub terminal: Rect,
pub input: Rect,
pub completions: Rect,
pub status: Rect,
}
pub fn compute_layout(
area: Rect,
input_height: u16,
completion_items: usize,
completion_active: bool,
) -> LayoutAreas {
let header_height = header_height().min(area.height);
let mut remaining = area.height.saturating_sub(header_height);
let input_height = input_height.max(1).min(remaining);
remaining = remaining.saturating_sub(input_height);
let (completion_height, status_height) = if completion_active {
let desired = (completion_items as u16).min(COMPLETIONS_MAX_HEIGHT);
let height = desired.min(remaining);
(height, 0)
} else {
let height = STATUS_BAR_HEIGHT.min(remaining);
(0, height)
};
remaining = remaining.saturating_sub(completion_height + status_height);
let terminal_height = remaining;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(header_height),
Constraint::Length(terminal_height),
Constraint::Length(input_height),
Constraint::Length(completion_height),
Constraint::Length(status_height),
])
.split(area);
LayoutAreas {
header: chunks[0],
terminal: chunks[1],
input: chunks[2],
completions: chunks[3],
status: chunks[4],
}
}
fn header_height() -> u16 {
let banner_height = ASCII_BANNER.len() as u16;
let welcome_height = 1;
let info_height = 4;
welcome_height + banner_height + info_height
}
pub async fn run_tui(mut app: AppState) -> Result<()> {
let mut terminal = TerminalGuard::init()?;
let mut events = EventStream::new();
let mut tick = tokio::time::interval(Duration::from_millis(250));
loop {
terminal.draw(|frame| render(frame, &mut app))?;
tokio::select! {
_ = tick.tick() => {
app.tick = app.tick.wrapping_add(1);
app.spinner = (app.spinner + 1) % SPINNER_FRAMES.len();
},
event = events.next() => {
if let Some(event) = event {
handle_event(event?, &mut app);
}
}
}
if app.should_quit {
break;
}
}
Ok(())
}
fn handle_event(event: CrosstermEvent, app: &mut AppState) {
match event {
CrosstermEvent::Key(key) => handle_key_event(key, app),
CrosstermEvent::Resize(_, _) => {}
CrosstermEvent::Mouse(event) => handle_mouse_event(event, app),
CrosstermEvent::FocusGained | CrosstermEvent::FocusLost => {}
CrosstermEvent::Paste(text) => {
app.input.insert_str(&text);
}
}
}
fn handle_key_event(key: KeyEvent, app: &mut AppState) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
app.should_quit = true;
return;
}
if app.completions.is_active() {
match key.code {
KeyCode::Esc => app.deactivate_completions(),
KeyCode::Up => app.completions.previous(),
KeyCode::Down => app.completions.next(),
KeyCode::Enter => {
if let Some(selection) = app.completions.current() {
app.input.insert_str(selection);
}
app.deactivate_completions();
}
_ => {}
}
return;
}
if !should_handle_key_event(app, &key) {
return;
}
match key.code {
KeyCode::PageUp => {
app.terminal_scroll = app.terminal_scroll.saturating_add(10);
}
KeyCode::PageDown => {
app.terminal_scroll = app.terminal_scroll.saturating_sub(10);
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.terminal_scroll = app.terminal_scroll.saturating_add(1);
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.terminal_scroll = app.terminal_scroll.saturating_sub(1);
}
KeyCode::Tab => app.toggle_completions(default_completions()),
_ => {
app.input.input(input_from_key_event(key));
}
}
}
fn handle_mouse_event(event: crossterm::event::MouseEvent, app: &mut AppState) {
use crossterm::event::MouseEventKind;
match event.kind {
MouseEventKind::ScrollUp => {
app.terminal_scroll = app.terminal_scroll.saturating_add(3);
}
MouseEventKind::ScrollDown => {
app.terminal_scroll = app.terminal_scroll.saturating_sub(3);
}
_ => {}
}
}
fn should_handle_key_event(app: &mut AppState, key: &KeyEvent) -> bool {
match key.kind {
KeyEventKind::Press | KeyEventKind::Repeat => {
app.key_input_mode = KeyInputMode::Press;
return true;
}
KeyEventKind::Release => {}
}
if app.key_input_mode == KeyInputMode::Press {
return false;
}
if key.code == KeyCode::Null {
return false;
}
if app.key_input_mode == KeyInputMode::Unknown {
app.key_input_mode = KeyInputMode::Release;
}
true
}
fn input_from_key_event(key: KeyEvent) -> Input {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let key = match key.code {
KeyCode::Char(c) => Key::Char(c),
KeyCode::Enter => Key::Enter,
KeyCode::Backspace => Key::Backspace,
KeyCode::Left => Key::Left,
KeyCode::Right => Key::Right,
KeyCode::Up => Key::Up,
KeyCode::Down => Key::Down,
KeyCode::Tab | KeyCode::BackTab => Key::Tab,
KeyCode::Delete => Key::Delete,
KeyCode::Home => Key::Home,
KeyCode::End => Key::End,
KeyCode::PageUp => Key::PageUp,
KeyCode::PageDown => Key::PageDown,
KeyCode::Esc => Key::Esc,
KeyCode::F(n) => Key::F(n),
_ => Key::Null,
};
Input {
key,
ctrl,
alt,
shift,
}
}
fn default_completions() -> Vec<String> {
vec![":help".to_string(), ":new".to_string(), ":exit".to_string()]
}
fn render(frame: &mut Frame<'_>, app: &mut AppState) {
let area = frame.area();
let layout = compute_layout(
area,
app.input_height(),
app.completions.items().len(),
app.completions.is_active(),
);
render_header(frame, layout.header, app);
render_terminal(frame, layout.terminal, app);
render_input(frame, layout.input, app);
if app.completions.is_active() {
render_completions(frame, layout.completions, app);
} else {
render_status(frame, layout.status, app);
}
}
fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
if area.height == 0 {
return;
}
let header_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(ASCII_BANNER.len() as u16),
Constraint::Length(4),
])
.split(area);
let welcome = Line::from(vec![
Span::raw("Welcome to Vibebox v"),
Span::styled(&app.vm_info.version, Style::default().fg(Color::Yellow)),
]);
frame.render_widget(Paragraph::new(welcome), header_chunks[0]);
let banner_lines = ASCII_BANNER.iter().map(|line| Line::from(*line));
frame.render_widget(
Paragraph::new(Text::from_iter(banner_lines)),
header_chunks[1],
);
let info_block = Block::default().borders(Borders::ALL).title("Session");
let info_lines = vec![
Line::from(vec![
Span::raw("Directory: "),
Span::styled(app.cwd.to_string_lossy(), Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::raw("VM Version: "),
Span::styled(&app.vm_info.version, Style::default().fg(Color::Green)),
]),
Line::from(vec![
Span::raw("CPU / Memory: "),
Span::styled(
format!(
"{} cores / {} MB",
app.vm_info.cpu_cores, app.vm_info.max_memory_mb
),
Style::default().fg(Color::Green),
),
]),
];
frame.render_widget(
Paragraph::new(info_lines).block(info_block),
header_chunks[2],
);
}
fn render_terminal(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
let lines = app.history.iter().map(|line| Line::from(line.as_str()));
let block = Block::default().borders(Borders::ALL).title("Terminal");
let inner = block.inner(area);
let inner_height = inner.height.max(1) as usize;
let total_lines = app.history.len();
let max_top = total_lines.saturating_sub(inner_height);
let terminal_scroll = app.terminal_scroll.min(max_top);
let scroll_top = max_top.saturating_sub(terminal_scroll);
let paragraph = Paragraph::new(Text::from_iter(lines))
.block(block)
.wrap(Wrap { trim: false })
.scroll((scroll_top.min(u16::MAX as usize) as u16, 0));
frame.render_widget(paragraph, area);
}
fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
frame.render_widget(app.input.widget(), area);
if area.height > 0 && area.width > 0 {
let cursor = app.input.cursor();
let inner = match app.input.block() {
Some(block) => block.inner(area),
None => area,
};
let x = inner.x.saturating_add(cursor.1 as u16);
let y = inner.y.saturating_add(cursor.0 as u16);
if x < inner.x.saturating_add(inner.width) && y < inner.y.saturating_add(inner.height) {
frame.set_cursor_position((x, y));
}
}
}
fn render_completions(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
if area.height == 0 {
return;
}
let items: Vec<ListItem<'_>> = app
.completions
.items()
.iter()
.map(|item| ListItem::new(Line::from(item.as_str())))
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Completions"))
.highlight_style(Style::default().fg(Color::Yellow));
let mut state = ListState::default();
if app.completions.is_active() && !app.completions.items().is_empty() {
state.select(Some(app.completions.selected()));
}
frame.render_stateful_widget(list, area, &mut state);
}
fn render_status(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
if area.height == 0 {
return;
}
let spinner = SPINNER_FRAMES[app.spinner % SPINNER_FRAMES.len()];
let status = Paragraph::new(Line::from(vec![
Span::styled(":help", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(
format!("tick {} {}", app.tick, spinner),
Style::default().fg(Color::DarkGray),
),
]));
frame.render_widget(status, area);
}
struct TerminalGuard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl TerminalGuard {
fn init() -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
fn draw(&mut self, f: impl FnOnce(&mut Frame<'_>)) -> Result<()> {
self.terminal.draw(f)?;
Ok(())
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(
self.terminal.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_without_completions_reserves_status_bar() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, 3, 0, false);
assert_eq!(layout.header.height, header_height());
assert_eq!(layout.status.height, STATUS_BAR_HEIGHT);
assert_eq!(layout.completions.height, 0);
assert!(layout.terminal.height > 0);
}
#[test]
fn layout_with_completions_hides_status_bar() {
let area = Rect::new(0, 0, 80, 30);
let layout = compute_layout(area, 4, 3, true);
assert_eq!(layout.status.height, 0);
assert_eq!(layout.completions.height, 3);
}
#[test]
fn layout_clamps_when_space_is_tight() {
let area = Rect::new(0, 0, 80, header_height() + 1);
let layout = compute_layout(area, 3, 10, true);
assert_eq!(layout.header.height, header_height());
assert_eq!(layout.input.height, 1);
assert_eq!(layout.terminal.height, 0);
}
#[test]
fn completion_state_wraps_navigation() {
let mut completions = CompletionState::default();
completions.set_items(vec!["a".into(), "b".into(), "c".into()]);
completions.activate();
assert_eq!(completions.current(), Some("a"));
completions.next();
assert_eq!(completions.current(), Some("b"));
completions.next();
completions.next();
assert_eq!(completions.current(), Some("a"));
completions.previous();
assert_eq!(completions.current(), Some("c"));
}
#[test]
fn completion_state_is_inactive_when_empty() {
let mut completions = CompletionState::default();
completions.activate();
assert!(!completions.is_active());
assert_eq!(completions.current(), None);
}
#[test]
fn input_from_key_event_maps_char_and_modifiers() {
let key = KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
);
let input = input_from_key_event(key);
assert_eq!(input.key, Key::Char('x'));
assert!(input.ctrl);
assert!(input.alt);
assert!(!input.shift);
}
#[test]
fn input_from_key_event_maps_special_keys() {
let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
let input = input_from_key_event(key);
assert_eq!(input.key, Key::Backspace);
assert!(!input.ctrl);
assert!(!input.alt);
assert!(!input.shift);
}
}