mirror of
https://github.com/robcholz/vibebox.git
synced 2026-08-16 18:10:18 +02:00
feat: added tui
This commit is contained in:
Generated
+921
-6
File diff suppressed because it is too large
Load Diff
@@ -27,3 +27,9 @@ thiserror = "2.0.18"
|
|||||||
time = { version = "0.3", features = ["serde", "formatting", "parsing"] }
|
time = { version = "0.3", features = ["serde", "formatting", "parsing"] }
|
||||||
toml = "0.9.8"
|
toml = "0.9.8"
|
||||||
uuid = { version = "1", features = ["v7", "serde"] }
|
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"] }
|
||||||
|
|||||||
+21
-2
@@ -1,5 +1,7 @@
|
|||||||
# Tasks
|
# Tasks
|
||||||
|
|
||||||
|
## SessionManager
|
||||||
|
|
||||||
1. [x] Confirm requirements and scope from `implementations.md`.
|
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).
|
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`).
|
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).
|
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`).
|
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.
|
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
@@ -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.
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user