@@ -1,37 +1,25 @@
2− io::{self, Stdout, Write},
2+ io::{self, Write},
3 path::PathBuf,
3 path::PathBuf,
6use color_eyre::Result;
6use color_eyre::Result;
7use crossterm::{
7use crossterm::{
8 cursor::{MoveTo, Show},
8 cursor::{MoveTo, Show},
10− DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyCode,
11− KeyEvent, KeyEventKind, KeyModifiers,
13 execute, queue,
9 execute, queue,
15 Attribute, Color as CrosstermColor, Print, SetAttribute, SetBackgroundColor,
11 Attribute, Color as CrosstermColor, Print, SetAttribute, SetBackgroundColor,
16 SetForegroundColor,
12 SetForegroundColor,
18− terminal::{
14+ terminal::{Clear, ClearType},
19− Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
23−use futures::StreamExt;
24use ratatui::{
16use ratatui::{
26− backend::CrosstermBackend,
27 buffer::Buffer,
17 buffer::Buffer,
28 layout::{Constraint, Direction, Layout, Rect},
18 layout::{Constraint, Direction, Layout, Rect},
29 style::{Color, Style},
19 style::{Color, Style},
30 text::{Line, Span, Text},
20 text::{Line, Span, Text},
31− widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget},
21+ widgets::{Block, Borders, List, ListItem, Paragraph, Widget},
33−use tui_textarea::{Input, Key, TextArea};
34−use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
36const ASCII_BANNER: [&str; 7] = [
24const ASCII_BANNER: [&str; 7] = [
37 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
25 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
@@ -54,18 +42,11 @@ pub struct VmInfo {
54pub struct AppState {
42pub struct AppState {
55 pub cwd: PathBuf,
43 pub cwd: PathBuf,
56 pub vm_info: VmInfo,
44 pub vm_info: VmInfo,
57− pub history: Vec<String>,
58− pub input: TextArea<'static>,
59 pub commands: VibeboxCommands,
45 pub commands: VibeboxCommands,
60− pub should_quit: bool,
61− key_input_mode: KeyInputMode,
63− input_view_width: u16,
66impl AppState {
48impl AppState {
67 pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
49 pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
68− let input = Self::default_input();
69 let mut commands = VibeboxCommands::default();
50 let mut commands = VibeboxCommands::default();
70 commands.add_command(":new", "Create a new session.");
51 commands.add_command(":new", "Create a new session.");
71 commands.add_command(":exit", "Exit Vibebox.");
52 commands.add_command(":exit", "Exit Vibebox.");
@@ -73,119 +54,7 @@ impl AppState {
80− key_input_mode: KeyInputMode::Unknown,
86− fn default_input() -> TextArea<'static> {
87− let mut input = TextArea::default();
88− input.set_cursor_style(Style::default().fg(Color::Yellow));
89− input.set_block(Block::default().borders(Borders::ALL).title("Input"));
93− fn reset_input(&mut self) {
94− self.input = Self::default_input();
97− fn take_input_text(&mut self) -> String {
98− let text = self.input.lines().join("");
103− pub fn input_height_for_width(&self, available_width: u16) -> u16 {
104− let inner_width = self.input_inner_width(available_width).max(1) as usize;
105− let mut visual_lines = 0usize;
106− for line in self.input.lines() {
107− let line_width = UnicodeWidthStr::width(line.as_str());
108− let wrapped = if line_width == 0 {
111− (line_width + inner_width - 1) / inner_width
113− visual_lines += wrapped;
116− let mut height = (visual_lines.max(1) as u16).max(1);
117− if self.input.block().is_some() {
118− height = height.saturating_add(2);
123− fn input_inner_width(&self, available_width: u16) -> u16 {
124− if self.input.block().is_some() {
125− available_width.saturating_sub(2)
131− fn insert_char_with_wrap(&mut self, c: char) {
132− let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
133− let width = self.input_view_width.max(1) as usize;
134− let mut should_wrap = false;
135− if char_width > 0 && width > 0 {
136− let (row, col) = self.input.cursor();
137− if let Some(line) = self.input.lines().get(row) {
138− if col == line.chars().count() {
139− let line_width = UnicodeWidthStr::width(line.as_str());
140− should_wrap = line_width + char_width > width;
147− self.input.insert_char(c);
148− self.input.insert_newline();
150− self.input.insert_newline();
151− self.input.insert_char(c);
154− self.input.insert_char(c);
158− fn insert_str_with_wrap(&mut self, text: &str) {
159− for c in text.chars() {
161− '\n' => self.insert_char_with_wrap(' '),
163− _ => self.insert_char_with_wrap(c),
168− pub fn push_history(&mut self, line: impl Into<String>) {
169− self.history.push(line.into());
170− if self.history.len() > 2000 {
171− let excess = self.history.len() - 2000;
172− self.history.drain(0..excess);
176− pub fn activate_commands(&mut self) {
177− self.commands.activate();
180− pub fn deactivate_commands(&mut self) {
181− self.commands.deactivate();
184− pub fn toggle_commands(&mut self) {
185− if self.commands.active {
186− self.deactivate_commands();
188− self.activate_commands();
@@ -193,23 +62,9 @@ impl AppState {
193#[derive(Debug, Clone)]
62#[derive(Debug, Clone)]
194pub struct VibeboxCommands {
63pub struct VibeboxCommands {
195 items: Vec<VibeboxCommand>,
64 items: Vec<VibeboxCommand>,
200−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207impl VibeboxCommands {
67impl VibeboxCommands {
208− pub fn set_items(&mut self, items: Vec<String>) {
209− self.items = items.into_iter().map(VibeboxCommand::new).collect();
213 pub fn add_command(&mut self, name: impl Into<String>, description: impl Into<String>) {
68 pub fn add_command(&mut self, name: impl Into<String>, description: impl Into<String>) {
214 self.items.push(VibeboxCommand {
69 self.items.push(VibeboxCommand {
215 name: name.into(),
70 name: name.into(),
@@ -217,52 +72,9 @@ impl VibeboxCommands {
220− pub fn activate(&mut self) {
221− self.active = !self.items.is_empty();
225− pub fn deactivate(&mut self) {
226− self.active = false;
229− pub fn next(&mut self) {
230− if !self.active || self.items.is_empty() {
233− self.selected = (self.selected + 1) % self.items.len();
236− pub fn previous(&mut self) {
237− if !self.active || self.items.is_empty() {
240− if self.selected == 0 {
241− self.selected = self.items.len() - 1;
247− pub fn current(&self) -> Option<&str> {
249− self.items.get(self.selected).map(|cmd| cmd.name.as_str())
255 pub fn items(&self) -> &[VibeboxCommand] {
75 pub fn items(&self) -> &[VibeboxCommand] {
256 &self.items
76 &self.items
259− pub fn is_active(&self) -> bool {
263− pub fn selected(&self) -> usize {
268impl Default for VibeboxCommands {
80impl Default for VibeboxCommands {
@@ -272,8 +84,6 @@ impl Default for VibeboxCommands {
272 name: ":help".to_string(),
84 name: ":help".to_string(),
273 description: "Show Vibebox commands.".to_string(),
85 description: "Show Vibebox commands.".to_string(),
@@ -284,98 +94,31 @@ pub struct VibeboxCommand {
284 pub description: String,
94 pub description: String,
287−impl VibeboxCommand {
288− pub fn new(name: String) -> Self {
289− let description = command_description(&name).to_string();
290− Self { name, description }
295−#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
296−pub struct LayoutAreas {
300− pub completions: Rect,
304#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
305struct PageLayout {
98struct PageLayout {
306 header: Rect,
99 header: Rect,
309 completions: Rect,
100 completions: Rect,
311 total_height: u16,
101 total_height: u16,
315−pub fn compute_layout(area: Rect, input_height: u16, completion_items: usize) -> LayoutAreas {
316− let header_height = header_height().min(area.height);
317− let mut remaining = area.height.saturating_sub(header_height);
319− let input_height = input_height.max(1).min(remaining);
320− remaining = remaining.saturating_sub(input_height);
322− let completion_height = if completion_items == 0 {
325− let desired = (completion_items as u16).saturating_add(2);
328− let completion_height = completion_height.min(remaining);
330− let terminal_height = 0;
333− let header = Rect::new(area.x, area.y + y, area.width, header_height);
334− y = y.saturating_add(header_height);
335− let terminal = Rect::new(area.x, area.y + y, area.width, terminal_height);
336− let input = Rect::new(area.x, area.y + y, area.width, input_height);
337− y = y.saturating_add(input_height);
338− let completions = Rect::new(area.x, area.y + y, area.width, completion_height);
339− let status = Rect::new(area.x, area.y + y, area.width, 0);
350fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
104fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
351 let header_height = header_height();
105 let header_height = header_height();
352− let input_height = 0;
353 let completion_items = app.commands.items().len();
106 let completion_items = app.commands.items().len();
354 let completion_height = if completion_items == 0 {
107 let completion_height = if completion_items == 0 {
357 (completion_items as u16).saturating_add(2)
110 (completion_items as u16).saturating_add(2)
359− let total_height = header_height
360− .saturating_add(input_height)
361− .saturating_add(completion_height)
112+ let total_height = header_height.saturating_add(completion_height).max(1);
364 let mut y = 0u16;
114 let mut y = 0u16;
365 let header = Rect::new(0, y, width, header_height);
115 let header = Rect::new(0, y, width, header_height);
366 y = y.saturating_add(header_height);
116 y = y.saturating_add(header_height);
367− let terminal = Rect::new(0, y, width, 0);
368− let input = Rect::new(0, y, width, input_height);
369− y = y.saturating_add(input_height);
370 let completions = Rect::new(0, y, width, completion_height);
117 let completions = Rect::new(0, y, width, completion_height);
371− let status = Rect::new(0, y, width, 0);
373 PageLayout {
119 PageLayout {
377 completions,
121 completions,
379 total_height,
122 total_height,
@@ -387,25 +130,6 @@ fn header_height() -> u16 {
387 welcome_height + banner_height + info_height
130 welcome_height + banner_height + info_height
390−pub async fn run_tui(mut app: AppState) -> Result<()> {
391− let mut terminal = TerminalGuard::init()?;
392− let mut events = EventStream::new();
395− terminal.draw(|frame| render(frame, &mut app))?;
397− if let Some(event) = events.next().await {
398− handle_event(event?, &mut app);
401− if app.should_quit {
409pub fn render_tui_once(app: &mut AppState) -> Result<()> {
133pub fn render_tui_once(app: &mut AppState) -> Result<()> {
410 let (width, _) = crossterm::terminal::size()?;
134 let (width, _) = crossterm::terminal::size()?;
411 if width == 0 {
135 if width == 0 {
@@ -561,178 +285,6 @@ fn queue_modifier(out: &mut impl Write, modifier: ratatui::style::Modifier) -> i
564−fn handle_event(event: CrosstermEvent, app: &mut AppState) {
566− CrosstermEvent::Key(key) => handle_key_event(key, app),
567− CrosstermEvent::Resize(_, _) => {}
568− CrosstermEvent::Mouse(event) => handle_mouse_event(event, app),
569− CrosstermEvent::FocusGained | CrosstermEvent::FocusLost => {}
570− CrosstermEvent::Paste(text) => {
571− app.insert_str_with_wrap(&text);
576−fn handle_key_event(key: KeyEvent, app: &mut AppState) {
577− if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
578− app.should_quit = true;
582− if app.commands.is_active() {
584− KeyCode::Esc => app.deactivate_commands(),
585− KeyCode::Up => app.commands.previous(),
586− KeyCode::Down => app.commands.next(),
588− if let Some(selection) = app.commands.current() {
589− app.input.insert_str(selection);
591− app.deactivate_commands();
598− if !should_handle_key_event(app, &key) {
603− KeyCode::PageUp => {}
604− KeyCode::PageDown => {}
605− KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {}
606− KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {}
608− let message = app.take_input_text();
609− if !message.trim().is_empty() {
610− app.push_history(format!("> {}", message));
611− app.page_scroll = usize::MAX;
614− KeyCode::Tab => app.toggle_commands(),
618− .contains(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
620− app.insert_char_with_wrap(c);
623− app.input.input(input_from_key_event(key));
628−fn handle_mouse_event(event: crossterm::event::MouseEvent, app: &mut AppState) {
629− use crossterm::event::MouseEventKind;
631− MouseEventKind::ScrollUp => {
632− app.page_scroll = app.page_scroll.saturating_sub(3);
634− MouseEventKind::ScrollDown => {
635− app.page_scroll = app.page_scroll.saturating_add(3);
641−fn should_handle_key_event(app: &mut AppState, key: &KeyEvent) -> bool {
643− KeyEventKind::Press | KeyEventKind::Repeat => {
644− app.key_input_mode = KeyInputMode::Press;
647− KeyEventKind::Release => {}
650− if app.key_input_mode == KeyInputMode::Press {
654− if key.code == KeyCode::Null {
658− if app.key_input_mode == KeyInputMode::Unknown {
659− app.key_input_mode = KeyInputMode::Release;
665−fn input_from_key_event(key: KeyEvent) -> Input {
666− let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
667− let alt = key.modifiers.contains(KeyModifiers::ALT);
668− let shift = key.modifiers.contains(KeyModifiers::SHIFT);
670− let key = match key.code {
671− KeyCode::Char(c) => Key::Char(c),
672− KeyCode::Enter => Key::Enter,
673− KeyCode::Backspace => Key::Backspace,
674− KeyCode::Left => Key::Left,
675− KeyCode::Right => Key::Right,
676− KeyCode::Up => Key::Up,
677− KeyCode::Down => Key::Down,
678− KeyCode::Tab | KeyCode::BackTab => Key::Tab,
679− KeyCode::Delete => Key::Delete,
680− KeyCode::Home => Key::Home,
681− KeyCode::End => Key::End,
682− KeyCode::PageUp => Key::PageUp,
683− KeyCode::PageDown => Key::PageDown,
684− KeyCode::Esc => Key::Esc,
685− KeyCode::F(n) => Key::F(n),
697−fn command_description(command: &str) -> &'static str {
699− ":help" => "Show Vibebox commands.",
700− ":new" => "Create a new session.",
701− ":exit" => "Exit Vibebox.",
706−fn render(frame: &mut Frame<'_>, app: &mut AppState) {
707− let viewport = frame.area();
708− if viewport.width == 0 || viewport.height == 0 {
712− app.input_view_width = app.input_inner_width(viewport.width);
713− let layout = compute_page_layout(app, viewport.width);
714− let content_height = layout.total_height.max(1);
716− let mut buffer = Buffer::empty(Rect::new(0, 0, viewport.width, content_height));
717− render_header(&mut buffer, layout.header, app);
718− render_completions(&mut buffer, layout.completions, app);
720− let max_scroll = content_height.saturating_sub(viewport.height);
721− app.page_scroll = app.page_scroll.min(max_scroll as usize);
722− let scroll = app.page_scroll as u16;
724− let view = frame.buffer_mut();
725− for y in 0..viewport.height {
726− let src_y = scroll.saturating_add(y);
727− if src_y >= content_height {
730− for x in 0..viewport.width {
731− view[(x, y)] = buffer[(x, src_y)].clone();
736fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
288fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
737 if area.height == 0 {
289 if area.height == 0 {
@@ -757,7 +309,11 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
757 let banner_lines = ASCII_BANNER.iter().map(|line| Line::from(*line));
309 let banner_lines = ASCII_BANNER.iter().map(|line| Line::from(*line));
758 Paragraph::new(Text::from_iter(banner_lines)).render(header_chunks[1], buffer);
310 Paragraph::new(Text::from_iter(banner_lines)).render(header_chunks[1], buffer);
760− let info_block = Block::default().borders(Borders::ALL).title("Session");
312+ let info_block = Block::default()
313+ .borders(Borders::ALL)
314+ .border_style(Style::default().fg(Color::DarkGray))
315+ .title_style(Style::default().fg(Color::Reset))
761 let info_lines = vec![
317 let info_lines = vec![
762 Line::from(vec![
318 Line::from(vec![
763 Span::raw("Directory: "),
319 Span::raw("Directory: "),
@@ -796,137 +352,13 @@ fn render_completions(buffer: &mut Buffer, area: Rect, app: &mut AppState) {
796 .map(|cmd| ListItem::new(Line::from(format!("{} {}", cmd.name, cmd.description))))
352 .map(|cmd| ListItem::new(Line::from(format!("{} {}", cmd.name, cmd.description))))
797 .collect();
353 .collect();
799− let list = List::new(items)
355+ let list = List::new(items).block(
801− Block::default()
356+ Block::default()
802− .borders(Borders::ALL)
357+ .borders(Borders::ALL)
358+ .border_style(Style::default().fg(Color::DarkGray))
359+ .title_style(Style::default().fg(Color::Reset))
803− .title("Vibebox Commands"),
360+ .title("Vibebox Commands"),
805− .highlight_style(Style::default().fg(Color::Yellow));
807− let mut state = ListState::default();
808− if app.commands.is_active() && !app.commands.items().is_empty() {
809− state.select(Some(app.commands.selected()));
812− StatefulWidget::render(list, area, buffer, &mut state);
815−struct TerminalGuard {
816− terminal: Terminal<CrosstermBackend<Stdout>>,
820− fn init() -> Result<Self> {
822− let mut stdout = io::stdout();
823− execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
824− let backend = CrosstermBackend::new(stdout);
825− let terminal = Terminal::new(backend)?;
826− Ok(Self { terminal })
829− fn draw(&mut self, f: impl FnOnce(&mut Frame<'_>)) -> Result<()> {
830− self.terminal.draw(f)?;
835−impl Drop for TerminalGuard {
836− fn drop(&mut self) {
837− let _ = disable_raw_mode();
839− self.terminal.backend_mut(),
840− DisableMouseCapture,
841− LeaveAlternateScreen
851− fn layout_without_completions_hides_status_bar() {
852− let area = Rect::new(0, 0, 80, 30);
853− let layout = compute_layout(area, 3, 0);
855− assert_eq!(layout.header.height, header_height());
363+ list.render(area, buffer);
856− assert_eq!(layout.status.height, 0);
857− assert_eq!(layout.completions.height, 0);
858− assert_eq!(layout.terminal.height, 0);
862− fn layout_with_completions_hides_status_bar() {
863− let area = Rect::new(0, 0, 80, 30);
864− let layout = compute_layout(area, 4, 3);
866− assert_eq!(layout.status.height, 0);
867− assert_eq!(layout.completions.height, 5);
871− fn layout_clamps_when_space_is_tight() {
872− let area = Rect::new(0, 0, 80, header_height() + 1);
873− let layout = compute_layout(area, 3, 10);
875− assert_eq!(layout.header.height, header_height());
876− assert_eq!(layout.input.height, 1);
877− assert_eq!(layout.terminal.height, 0);
881− fn commands_wrap_navigation() {
882− let mut commands = VibeboxCommands::default();
883− commands.set_items(vec![":new".into(), ":exit".into(), ":help".into()]);
884− commands.activate();
886− assert_eq!(commands.current(), Some(":new"));
889− assert_eq!(commands.current(), Some(":exit"));
893− assert_eq!(commands.current(), Some(":new"));
895− commands.previous();
896− assert_eq!(commands.current(), Some(":help"));
900− fn commands_inactive_when_empty() {
901− let mut commands = VibeboxCommands::default();
902− commands.activate();
904− assert!(!commands.is_active());
905− assert_eq!(commands.current(), None);
909− fn input_from_key_event_maps_char_and_modifiers() {
910− let key = KeyEvent::new(
912− KeyModifiers::CONTROL | KeyModifiers::ALT,
914− let input = input_from_key_event(key);
916− assert_eq!(input.key, Key::Char('x'));
917− assert!(input.ctrl);
919− assert!(!input.shift);
923− fn input_from_key_event_maps_special_keys() {
924− let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
925− let input = input_from_key_event(key);
927− assert_eq!(input.key, Key::Backspace);
928− assert!(!input.ctrl);
929− assert!(!input.alt);
930− assert!(!input.shift);