on main help sign in
feat: now tui is complete
commite3ba0a00729f051db739aa5be24886556c811cf6 authorrobcholz <84130577+robcholz@users.noreply.github.com> dateFri 6 Feb 2026, 15:12 (8 months ago) parentd710d39
1 file changed+17−585
src/tui.rs+17−585
src/tui.rs+17−585
@@ -1,37 +1,25 @@
11use std::{
2− io::{self, Stdout, Write},
2+ io::{self, Write},
33 path::PathBuf,
44};
55
66use color_eyre::Result;
77use crossterm::{
88 cursor::{MoveTo, Show},
9− event::{
10− DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyCode,
11− KeyEvent, KeyEventKind, KeyModifiers,
12− },
139 execute, queue,
1410 style::{
1511 Attribute, Color as CrosstermColor, Print, SetAttribute, SetBackgroundColor,
1612 SetForegroundColor,
1713 },
18− terminal::{
19− Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
20− enable_raw_mode,
21− },
14+ terminal::{Clear, ClearType},
2215};
23−use futures::StreamExt;
2416use ratatui::{
25− Frame, Terminal,
26− backend::CrosstermBackend,
2717 buffer::Buffer,
2818 layout::{Constraint, Direction, Layout, Rect},
2919 style::{Color, Style},
3020 text::{Line, Span, Text},
31− widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget},
21+ widgets::{Block, Borders, List, ListItem, Paragraph, Widget},
3222};
33−use tui_textarea::{Input, Key, TextArea};
34−use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
3523
3624const ASCII_BANNER: [&str; 7] = [
3725 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
@@ -54,18 +42,11 @@ pub struct VmInfo {
5442pub struct AppState {
5543 pub cwd: PathBuf,
5644 pub vm_info: VmInfo,
57− pub history: Vec<String>,
58− pub input: TextArea<'static>,
5945 pub commands: VibeboxCommands,
60− pub should_quit: bool,
61− key_input_mode: KeyInputMode,
62− page_scroll: usize,
63− input_view_width: u16,
6446}
6547
6648impl AppState {
6749 pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
68− let input = Self::default_input();
6950 let mut commands = VibeboxCommands::default();
7051 commands.add_command(":new", "Create a new session.");
7152 commands.add_command(":exit", "Exit Vibebox.");
@@ -73,119 +54,7 @@ impl AppState {
7354 Self {
7455 cwd,
7556 vm_info,
76− history: Vec::new(),
77− input,
7857 commands,
79− should_quit: false,
80− key_input_mode: KeyInputMode::Unknown,
81− page_scroll: 0,
82− input_view_width: 0,
83− }
84− }
85−
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"));
90− input
91− }
92−
93− fn reset_input(&mut self) {
94− self.input = Self::default_input();
95− }
96−
97− fn take_input_text(&mut self) -> String {
98− let text = self.input.lines().join("");
99− self.reset_input();
100− text
101− }
102−
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 {
109− 1
110− } else {
111− (line_width + inner_width - 1) / inner_width
112− };
113− visual_lines += wrapped;
114− }
115−
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);
119− }
120− height.max(1)
121− }
122−
123− fn input_inner_width(&self, available_width: u16) -> u16 {
124− if self.input.block().is_some() {
125− available_width.saturating_sub(2)
126− } else {
127− available_width
128− }
129− }
130−
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;
141− }
142− }
143− }
144−
145− if should_wrap {
146− if c == ' ' {
147− self.input.insert_char(c);
148− self.input.insert_newline();
149− } else {
150− self.input.insert_newline();
151− self.input.insert_char(c);
152− }
153− } else {
154− self.input.insert_char(c);
155− }
156− }
157−
158− fn insert_str_with_wrap(&mut self, text: &str) {
159− for c in text.chars() {
160− match c {
161− '\n' => self.insert_char_with_wrap(' '),
162− '\r' => {}
163− _ => self.insert_char_with_wrap(c),
164− }
165− }
166− }
167−
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);
173− }
174− }
175−
176− pub fn activate_commands(&mut self) {
177− self.commands.activate();
178− }
179−
180− pub fn deactivate_commands(&mut self) {
181− self.commands.deactivate();
182− }
183−
184− pub fn toggle_commands(&mut self) {
185− if self.commands.active {
186− self.deactivate_commands();
187− } else {
188− self.activate_commands();
18958 }
19059 }
19160}
@@ -193,23 +62,9 @@ impl AppState {
19362#[derive(Debug, Clone)]
19463pub struct VibeboxCommands {
19564 items: Vec<VibeboxCommand>,
196− selected: usize,
197− active: bool,
198−}
199−
200−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201−enum KeyInputMode {
202− Unknown,
203− Press,
204− Release,
20565}
20666
20767impl VibeboxCommands {
208− pub fn set_items(&mut self, items: Vec<String>) {
209− self.items = items.into_iter().map(VibeboxCommand::new).collect();
210− self.selected = 0;
211− }
212−
21368 pub fn add_command(&mut self, name: impl Into<String>, description: impl Into<String>) {
21469 self.items.push(VibeboxCommand {
21570 name: name.into(),
@@ -217,52 +72,9 @@ impl VibeboxCommands {
21772 });
21873 }
21974
220− pub fn activate(&mut self) {
221− self.active = !self.items.is_empty();
222− self.selected = 0;
223− }
224−
225− pub fn deactivate(&mut self) {
226− self.active = false;
227− }
228−
229− pub fn next(&mut self) {
230− if !self.active || self.items.is_empty() {
231− return;
232− }
233− self.selected = (self.selected + 1) % self.items.len();
234− }
235−
236− pub fn previous(&mut self) {
237− if !self.active || self.items.is_empty() {
238− return;
239− }
240− if self.selected == 0 {
241− self.selected = self.items.len() - 1;
242− } else {
243− self.selected -= 1;
244− }
245− }
246−
247− pub fn current(&self) -> Option<&str> {
248− if self.active {
249− self.items.get(self.selected).map(|cmd| cmd.name.as_str())
250− } else {
251− None
252− }
253− }
254−
25575 pub fn items(&self) -> &[VibeboxCommand] {
25676 &self.items
25777 }
258−
259− pub fn is_active(&self) -> bool {
260− self.active
261− }
262−
263− pub fn selected(&self) -> usize {
264− self.selected
265− }
26678}
26779
26880impl Default for VibeboxCommands {
@@ -272,8 +84,6 @@ impl Default for VibeboxCommands {
27284 name: ":help".to_string(),
27385 description: "Show Vibebox commands.".to_string(),
27486 }],
275− selected: 0,
276− active: false,
27787 }
27888 }
27989}
@@ -284,98 +94,31 @@ pub struct VibeboxCommand {
28494 pub description: String,
28595}
28696
287−impl VibeboxCommand {
288− pub fn new(name: String) -> Self {
289− let description = command_description(&name).to_string();
290− Self { name, description }
291− }
292−}
293−
294−#[allow(dead_code)]
295−#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
296−pub struct LayoutAreas {
297− pub header: Rect,
298− pub terminal: Rect,
299− pub input: Rect,
300− pub completions: Rect,
301− pub status: Rect,
302−}
303−
30497#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30598struct PageLayout {
30699 header: Rect,
307− terminal: Rect,
308− input: Rect,
309100 completions: Rect,
310− status: Rect,
311101 total_height: u16,
312102}
313103
314−#[allow(dead_code)]
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);
318−
319− let input_height = input_height.max(1).min(remaining);
320− remaining = remaining.saturating_sub(input_height);
321−
322− let completion_height = if completion_items == 0 {
323− 0
324− } else {
325− let desired = (completion_items as u16).saturating_add(2);
326− desired
327− };
328− let completion_height = completion_height.min(remaining);
329−
330− let terminal_height = 0;
331−
332− let mut y = 0u16;
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);
340−
341− LayoutAreas {
342− header,
343− terminal,
344− input,
345− completions,
346− status,
347− }
348−}
349−
350104fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
351105 let header_height = header_height();
352− let input_height = 0;
353106 let completion_items = app.commands.items().len();
354107 let completion_height = if completion_items == 0 {
355108 0
356109 } else {
357110 (completion_items as u16).saturating_add(2)
358111 };
359− let total_height = header_height
360− .saturating_add(input_height)
361− .saturating_add(completion_height)
362− .max(1);
112+ let total_height = header_height.saturating_add(completion_height).max(1);
363113
364114 let mut y = 0u16;
365115 let header = Rect::new(0, y, width, header_height);
366116 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);
370117 let completions = Rect::new(0, y, width, completion_height);
371− let status = Rect::new(0, y, width, 0);
372118
373119 PageLayout {
374120 header,
375− terminal,
376− input,
377121 completions,
378− status,
379122 total_height,
380123 }
381124}
@@ -387,25 +130,6 @@ fn header_height() -> u16 {
387130 welcome_height + banner_height + info_height
388131}
389132
390−pub async fn run_tui(mut app: AppState) -> Result<()> {
391− let mut terminal = TerminalGuard::init()?;
392− let mut events = EventStream::new();
393−
394− loop {
395− terminal.draw(|frame| render(frame, &mut app))?;
396−
397− if let Some(event) = events.next().await {
398− handle_event(event?, &mut app);
399− }
400−
401− if app.should_quit {
402− break;
403− }
404− }
405−
406− Ok(())
407−}
408−
409133pub fn render_tui_once(app: &mut AppState) -> Result<()> {
410134 let (width, _) = crossterm::terminal::size()?;
411135 if width == 0 {
@@ -561,178 +285,6 @@ fn queue_modifier(out: &mut impl Write, modifier: ratatui::style::Modifier) -> i
561285 Ok(())
562286}
563287
564−fn handle_event(event: CrosstermEvent, app: &mut AppState) {
565− match event {
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);
572− }
573− }
574−}
575−
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;
579− return;
580− }
581−
582− if app.commands.is_active() {
583− match key.code {
584− KeyCode::Esc => app.deactivate_commands(),
585− KeyCode::Up => app.commands.previous(),
586− KeyCode::Down => app.commands.next(),
587− KeyCode::Enter => {
588− if let Some(selection) = app.commands.current() {
589− app.input.insert_str(selection);
590− }
591− app.deactivate_commands();
592− }
593− _ => {}
594− }
595− return;
596− }
597−
598− if !should_handle_key_event(app, &key) {
599− return;
600− }
601−
602− match key.code {
603− KeyCode::PageUp => {}
604− KeyCode::PageDown => {}
605− KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {}
606− KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {}
607− KeyCode::Enter => {
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;
612− }
613− }
614− KeyCode::Tab => app.toggle_commands(),
615− KeyCode::Char(c)
616− if !key
617− .modifiers
618− .contains(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
619− {
620− app.insert_char_with_wrap(c);
621− }
622− _ => {
623− app.input.input(input_from_key_event(key));
624− }
625− }
626−}
627−
628−fn handle_mouse_event(event: crossterm::event::MouseEvent, app: &mut AppState) {
629− use crossterm::event::MouseEventKind;
630− match event.kind {
631− MouseEventKind::ScrollUp => {
632− app.page_scroll = app.page_scroll.saturating_sub(3);
633− }
634− MouseEventKind::ScrollDown => {
635− app.page_scroll = app.page_scroll.saturating_add(3);
636− }
637− _ => {}
638− }
639−}
640−
641−fn should_handle_key_event(app: &mut AppState, key: &KeyEvent) -> bool {
642− match key.kind {
643− KeyEventKind::Press | KeyEventKind::Repeat => {
644− app.key_input_mode = KeyInputMode::Press;
645− return true;
646− }
647− KeyEventKind::Release => {}
648− }
649−
650− if app.key_input_mode == KeyInputMode::Press {
651− return false;
652− }
653−
654− if key.code == KeyCode::Null {
655− return false;
656− }
657−
658− if app.key_input_mode == KeyInputMode::Unknown {
659− app.key_input_mode = KeyInputMode::Release;
660− }
661−
662− true
663−}
664−
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);
669−
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),
686− _ => Key::Null,
687− };
688−
689− Input {
690− key,
691− ctrl,
692− alt,
693− shift,
694− }
695−}
696−
697−fn command_description(command: &str) -> &'static str {
698− match command {
699− ":help" => "Show Vibebox commands.",
700− ":new" => "Create a new session.",
701− ":exit" => "Exit Vibebox.",
702− _ => "",
703− }
704−}
705−
706−fn render(frame: &mut Frame<'_>, app: &mut AppState) {
707− let viewport = frame.area();
708− if viewport.width == 0 || viewport.height == 0 {
709− return;
710− }
711−
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);
715−
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);
719−
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;
723−
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 {
728− break;
729− }
730− for x in 0..viewport.width {
731− view[(x, y)] = buffer[(x, src_y)].clone();
732− }
733− }
734−}
735−
736288fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
737289 if area.height == 0 {
738290 return;
@@ -757,7 +309,11 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
757309 let banner_lines = ASCII_BANNER.iter().map(|line| Line::from(*line));
758310 Paragraph::new(Text::from_iter(banner_lines)).render(header_chunks[1], buffer);
759311
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))
316+ .title("Session");
761317 let info_lines = vec![
762318 Line::from(vec![
763319 Span::raw("Directory: "),
@@ -796,137 +352,13 @@ fn render_completions(buffer: &mut Buffer, area: Rect, app: &mut AppState) {
796352 .map(|cmd| ListItem::new(Line::from(format!("{} {}", cmd.name, cmd.description))))
797353 .collect();
798354
799− let list = List::new(items)
800− .block(
801− Block::default()
802− .borders(Borders::ALL)
803− .title("Vibebox Commands"),
804− )
805− .highlight_style(Style::default().fg(Color::Yellow));
806−
807− let mut state = ListState::default();
808− if app.commands.is_active() && !app.commands.items().is_empty() {
809− state.select(Some(app.commands.selected()));
810− }
811−
812− StatefulWidget::render(list, area, buffer, &mut state);
813−}
814−
815−struct TerminalGuard {
816− terminal: Terminal<CrosstermBackend<Stdout>>,
817−}
818−
819−impl TerminalGuard {
820− fn init() -> Result<Self> {
821− enable_raw_mode()?;
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 })
827− }
828−
829− fn draw(&mut self, f: impl FnOnce(&mut Frame<'_>)) -> Result<()> {
830− self.terminal.draw(f)?;
831− Ok(())
832− }
833−}
834−
835−impl Drop for TerminalGuard {
836− fn drop(&mut self) {
837− let _ = disable_raw_mode();
838− let _ = execute!(
839− self.terminal.backend_mut(),
840− DisableMouseCapture,
841− LeaveAlternateScreen
842− );
843− }
844−}
845−
846−#[cfg(test)]
847−mod tests {
848− use super::*;
849−
850− #[test]
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);
355+ let list = List::new(items).block(
356+ Block::default()
357+ .borders(Borders::ALL)
358+ .border_style(Style::default().fg(Color::DarkGray))
359+ .title_style(Style::default().fg(Color::Reset))
360+ .title("Vibebox Commands"),
361+ );
854362
855− assert_eq!(layout.header.height, header_height());
856− assert_eq!(layout.status.height, 0);
857− assert_eq!(layout.completions.height, 0);
858− assert_eq!(layout.terminal.height, 0);
859− }
860−
861− #[test]
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);
865−
866− assert_eq!(layout.status.height, 0);
867− assert_eq!(layout.completions.height, 5);
868− }
869−
870− #[test]
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);
874−
875− assert_eq!(layout.header.height, header_height());
876− assert_eq!(layout.input.height, 1);
877− assert_eq!(layout.terminal.height, 0);
878− }
879−
880− #[test]
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();
885−
886− assert_eq!(commands.current(), Some(":new"));
887−
888− commands.next();
889− assert_eq!(commands.current(), Some(":exit"));
890−
891− commands.next();
892− commands.next();
893− assert_eq!(commands.current(), Some(":new"));
894−
895− commands.previous();
896− assert_eq!(commands.current(), Some(":help"));
897− }
898−
899− #[test]
900− fn commands_inactive_when_empty() {
901− let mut commands = VibeboxCommands::default();
902− commands.activate();
903−
904− assert!(!commands.is_active());
905− assert_eq!(commands.current(), None);
906− }
907−
908− #[test]
909− fn input_from_key_event_maps_char_and_modifiers() {
910− let key = KeyEvent::new(
911− KeyCode::Char('x'),
912− KeyModifiers::CONTROL | KeyModifiers::ALT,
913− );
914− let input = input_from_key_event(key);
915−
916− assert_eq!(input.key, Key::Char('x'));
917− assert!(input.ctrl);
918− assert!(input.alt);
919− assert!(!input.shift);
920− }
921−
922− #[test]
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);
926−
927− assert_eq!(input.key, Key::Backspace);
928− assert!(!input.ctrl);
929− assert!(!input.alt);
930− assert!(!input.shift);
931− }
363+ list.render(area, buffer);
932364}