From e3ba0a00729f051db739aa5be24886556c811cf6 Mon Sep 17 00:00:00 2001 From: robcholz <84130577+robcholz@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:12:47 -0500 Subject: [PATCH] feat: now tui is complete --- src/tui.rs | 602 ++--------------------------------------------------- 1 file changed, 17 insertions(+), 585 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index 2044ced..8e4a70a 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,37 +1,25 @@ use std::{ - io::{self, Stdout, Write}, + io::{self, Write}, path::PathBuf, }; use color_eyre::Result; use crossterm::{ cursor::{MoveTo, Show}, - event::{ - DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyCode, - KeyEvent, KeyEventKind, KeyModifiers, - }, execute, queue, style::{ Attribute, Color as CrosstermColor, Print, SetAttribute, SetBackgroundColor, SetForegroundColor, }, - terminal::{ - Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, - enable_raw_mode, - }, + terminal::{Clear, ClearType}, }; -use futures::StreamExt; use ratatui::{ - Frame, Terminal, - backend::CrosstermBackend, buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, text::{Line, Span, Text}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget}, + widgets::{Block, Borders, List, ListItem, Paragraph, Widget}, }; -use tui_textarea::{Input, Key, TextArea}; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; const ASCII_BANNER: [&str; 7] = [ "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗", @@ -54,18 +42,11 @@ pub struct VmInfo { pub struct AppState { pub cwd: PathBuf, pub vm_info: VmInfo, - pub history: Vec, - pub input: TextArea<'static>, pub commands: VibeboxCommands, - pub should_quit: bool, - key_input_mode: KeyInputMode, - page_scroll: usize, - input_view_width: u16, } impl AppState { pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self { - let input = Self::default_input(); let mut commands = VibeboxCommands::default(); commands.add_command(":new", "Create a new session."); commands.add_command(":exit", "Exit Vibebox."); @@ -73,119 +54,7 @@ impl AppState { Self { cwd, vm_info, - history: Vec::new(), - input, commands, - should_quit: false, - key_input_mode: KeyInputMode::Unknown, - page_scroll: 0, - input_view_width: 0, - } - } - - fn default_input() -> TextArea<'static> { - let mut input = TextArea::default(); - input.set_cursor_style(Style::default().fg(Color::Yellow)); - input.set_block(Block::default().borders(Borders::ALL).title("Input")); - input - } - - fn reset_input(&mut self) { - self.input = Self::default_input(); - } - - fn take_input_text(&mut self) -> String { - let text = self.input.lines().join(""); - self.reset_input(); - text - } - - pub fn input_height_for_width(&self, available_width: u16) -> u16 { - let inner_width = self.input_inner_width(available_width).max(1) as usize; - let mut visual_lines = 0usize; - for line in self.input.lines() { - let line_width = UnicodeWidthStr::width(line.as_str()); - let wrapped = if line_width == 0 { - 1 - } else { - (line_width + inner_width - 1) / inner_width - }; - visual_lines += wrapped; - } - - let mut height = (visual_lines.max(1) as u16).max(1); - if self.input.block().is_some() { - height = height.saturating_add(2); - } - height.max(1) - } - - fn input_inner_width(&self, available_width: u16) -> u16 { - if self.input.block().is_some() { - available_width.saturating_sub(2) - } else { - available_width - } - } - - fn insert_char_with_wrap(&mut self, c: char) { - let char_width = UnicodeWidthChar::width(c).unwrap_or(0); - let width = self.input_view_width.max(1) as usize; - let mut should_wrap = false; - if char_width > 0 && width > 0 { - let (row, col) = self.input.cursor(); - if let Some(line) = self.input.lines().get(row) { - if col == line.chars().count() { - let line_width = UnicodeWidthStr::width(line.as_str()); - should_wrap = line_width + char_width > width; - } - } - } - - if should_wrap { - if c == ' ' { - self.input.insert_char(c); - self.input.insert_newline(); - } else { - self.input.insert_newline(); - self.input.insert_char(c); - } - } else { - self.input.insert_char(c); - } - } - - fn insert_str_with_wrap(&mut self, text: &str) { - for c in text.chars() { - match c { - '\n' => self.insert_char_with_wrap(' '), - '\r' => {} - _ => self.insert_char_with_wrap(c), - } - } - } - - pub fn push_history(&mut self, line: impl Into) { - self.history.push(line.into()); - if self.history.len() > 2000 { - let excess = self.history.len() - 2000; - self.history.drain(0..excess); - } - } - - pub fn activate_commands(&mut self) { - self.commands.activate(); - } - - pub fn deactivate_commands(&mut self) { - self.commands.deactivate(); - } - - pub fn toggle_commands(&mut self) { - if self.commands.active { - self.deactivate_commands(); - } else { - self.activate_commands(); } } } @@ -193,23 +62,9 @@ impl AppState { #[derive(Debug, Clone)] pub struct VibeboxCommands { items: Vec, - selected: usize, - active: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum KeyInputMode { - Unknown, - Press, - Release, } impl VibeboxCommands { - pub fn set_items(&mut self, items: Vec) { - self.items = items.into_iter().map(VibeboxCommand::new).collect(); - self.selected = 0; - } - pub fn add_command(&mut self, name: impl Into, description: impl Into) { self.items.push(VibeboxCommand { name: name.into(), @@ -217,52 +72,9 @@ impl VibeboxCommands { }); } - 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(|cmd| cmd.name.as_str()) - } else { - None - } - } - pub fn items(&self) -> &[VibeboxCommand] { &self.items } - - pub fn is_active(&self) -> bool { - self.active - } - - pub fn selected(&self) -> usize { - self.selected - } } impl Default for VibeboxCommands { @@ -272,8 +84,6 @@ impl Default for VibeboxCommands { name: ":help".to_string(), description: "Show Vibebox commands.".to_string(), }], - selected: 0, - active: false, } } } @@ -284,98 +94,31 @@ pub struct VibeboxCommand { pub description: String, } -impl VibeboxCommand { - pub fn new(name: String) -> Self { - let description = command_description(&name).to_string(); - Self { name, description } - } -} - -#[allow(dead_code)] -#[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, -} - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct PageLayout { header: Rect, - terminal: Rect, - input: Rect, completions: Rect, - status: Rect, total_height: u16, } -#[allow(dead_code)] -pub fn compute_layout(area: Rect, input_height: u16, completion_items: usize) -> 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 = if completion_items == 0 { - 0 - } else { - let desired = (completion_items as u16).saturating_add(2); - desired - }; - let completion_height = completion_height.min(remaining); - - let terminal_height = 0; - - let mut y = 0u16; - let header = Rect::new(area.x, area.y + y, area.width, header_height); - y = y.saturating_add(header_height); - let terminal = Rect::new(area.x, area.y + y, area.width, terminal_height); - let input = Rect::new(area.x, area.y + y, area.width, input_height); - y = y.saturating_add(input_height); - let completions = Rect::new(area.x, area.y + y, area.width, completion_height); - let status = Rect::new(area.x, area.y + y, area.width, 0); - - LayoutAreas { - header, - terminal, - input, - completions, - status, - } -} - fn compute_page_layout(app: &AppState, width: u16) -> PageLayout { let header_height = header_height(); - let input_height = 0; let completion_items = app.commands.items().len(); let completion_height = if completion_items == 0 { 0 } else { (completion_items as u16).saturating_add(2) }; - let total_height = header_height - .saturating_add(input_height) - .saturating_add(completion_height) - .max(1); + let total_height = header_height.saturating_add(completion_height).max(1); let mut y = 0u16; let header = Rect::new(0, y, width, header_height); y = y.saturating_add(header_height); - let terminal = Rect::new(0, y, width, 0); - let input = Rect::new(0, y, width, input_height); - y = y.saturating_add(input_height); let completions = Rect::new(0, y, width, completion_height); - let status = Rect::new(0, y, width, 0); PageLayout { header, - terminal, - input, completions, - status, total_height, } } @@ -387,25 +130,6 @@ fn header_height() -> u16 { 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(); - - loop { - terminal.draw(|frame| render(frame, &mut app))?; - - if let Some(event) = events.next().await { - handle_event(event?, &mut app); - } - - if app.should_quit { - break; - } - } - - Ok(()) -} - pub fn render_tui_once(app: &mut AppState) -> Result<()> { let (width, _) = crossterm::terminal::size()?; if width == 0 { @@ -561,178 +285,6 @@ fn queue_modifier(out: &mut impl Write, modifier: ratatui::style::Modifier) -> i 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.insert_str_with_wrap(&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.commands.is_active() { - match key.code { - KeyCode::Esc => app.deactivate_commands(), - KeyCode::Up => app.commands.previous(), - KeyCode::Down => app.commands.next(), - KeyCode::Enter => { - if let Some(selection) = app.commands.current() { - app.input.insert_str(selection); - } - app.deactivate_commands(); - } - _ => {} - } - return; - } - - if !should_handle_key_event(app, &key) { - return; - } - - match key.code { - KeyCode::PageUp => {} - KeyCode::PageDown => {} - KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {} - KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {} - KeyCode::Enter => { - let message = app.take_input_text(); - if !message.trim().is_empty() { - app.push_history(format!("> {}", message)); - app.page_scroll = usize::MAX; - } - } - KeyCode::Tab => app.toggle_commands(), - KeyCode::Char(c) - if !key - .modifiers - .contains(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - app.insert_char_with_wrap(c); - } - _ => { - 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.page_scroll = app.page_scroll.saturating_sub(3); - } - MouseEventKind::ScrollDown => { - app.page_scroll = app.page_scroll.saturating_add(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 command_description(command: &str) -> &'static str { - match command { - ":help" => "Show Vibebox commands.", - ":new" => "Create a new session.", - ":exit" => "Exit Vibebox.", - _ => "", - } -} - -fn render(frame: &mut Frame<'_>, app: &mut AppState) { - let viewport = frame.area(); - if viewport.width == 0 || viewport.height == 0 { - return; - } - - app.input_view_width = app.input_inner_width(viewport.width); - let layout = compute_page_layout(app, viewport.width); - let content_height = layout.total_height.max(1); - - let mut buffer = Buffer::empty(Rect::new(0, 0, viewport.width, content_height)); - render_header(&mut buffer, layout.header, app); - render_completions(&mut buffer, layout.completions, app); - - let max_scroll = content_height.saturating_sub(viewport.height); - app.page_scroll = app.page_scroll.min(max_scroll as usize); - let scroll = app.page_scroll as u16; - - let view = frame.buffer_mut(); - for y in 0..viewport.height { - let src_y = scroll.saturating_add(y); - if src_y >= content_height { - break; - } - for x in 0..viewport.width { - view[(x, y)] = buffer[(x, src_y)].clone(); - } - } -} - fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) { if area.height == 0 { return; @@ -757,7 +309,11 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) { let banner_lines = ASCII_BANNER.iter().map(|line| Line::from(*line)); Paragraph::new(Text::from_iter(banner_lines)).render(header_chunks[1], buffer); - let info_block = Block::default().borders(Borders::ALL).title("Session"); + let info_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title_style(Style::default().fg(Color::Reset)) + .title("Session"); let info_lines = vec![ Line::from(vec![ Span::raw("Directory: "), @@ -796,137 +352,13 @@ fn render_completions(buffer: &mut Buffer, area: Rect, app: &mut AppState) { .map(|cmd| ListItem::new(Line::from(format!("{} {}", cmd.name, cmd.description)))) .collect(); - let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title("Vibebox Commands"), - ) - .highlight_style(Style::default().fg(Color::Yellow)); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title_style(Style::default().fg(Color::Reset)) + .title("Vibebox Commands"), + ); - let mut state = ListState::default(); - if app.commands.is_active() && !app.commands.items().is_empty() { - state.select(Some(app.commands.selected())); - } - - StatefulWidget::render(list, area, buffer, &mut state); -} - -struct TerminalGuard { - terminal: Terminal>, -} - -impl TerminalGuard { - fn init() -> Result { - 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_hides_status_bar() { - let area = Rect::new(0, 0, 80, 30); - let layout = compute_layout(area, 3, 0); - - assert_eq!(layout.header.height, header_height()); - assert_eq!(layout.status.height, 0); - assert_eq!(layout.completions.height, 0); - assert_eq!(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); - - assert_eq!(layout.status.height, 0); - assert_eq!(layout.completions.height, 5); - } - - #[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); - - assert_eq!(layout.header.height, header_height()); - assert_eq!(layout.input.height, 1); - assert_eq!(layout.terminal.height, 0); - } - - #[test] - fn commands_wrap_navigation() { - let mut commands = VibeboxCommands::default(); - commands.set_items(vec![":new".into(), ":exit".into(), ":help".into()]); - commands.activate(); - - assert_eq!(commands.current(), Some(":new")); - - commands.next(); - assert_eq!(commands.current(), Some(":exit")); - - commands.next(); - commands.next(); - assert_eq!(commands.current(), Some(":new")); - - commands.previous(); - assert_eq!(commands.current(), Some(":help")); - } - - #[test] - fn commands_inactive_when_empty() { - let mut commands = VibeboxCommands::default(); - commands.activate(); - - assert!(!commands.is_active()); - assert_eq!(commands.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); - } + list.render(area, buffer); }