mirror of
https://github.com/robcholz/vibebox.git
synced 2026-04-01 00:10:15 +02:00
feat: added tui
This commit is contained in:
927
Cargo.lock
generated
927
Cargo.lock
generated
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"] }
|
||||
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"] }
|
||||
|
||||
@@ -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
28
docs/tui.md
Normal 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
215
src/bin/vibebox-tui.rs
Normal 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
675
src/tui.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user