on main help sign in
feat: now fixed
d710d39robcholz
docs/tui.md+7−11
@@ -4,6 +4,7 @@ The end-user ui for the vibebox
4
4
5## TUI header
5## TUI header
6
6
7+- One time, not fixed
7- This is a welcome header.
8- This is a welcome header.
8- Shows text: "Welcome to Vibebox vX.X.XX"
9- Shows text: "Welcome to Vibebox vX.X.XX"
9- Shows the ASCII banner
10- Shows the ASCII banner
@@ -12,17 +13,12 @@ The end-user ui for the vibebox
12 - Shows current vm version and max memory, cpu cores
13 - Shows current vm version and max memory, cpu cores
13- The position is flex, so this will move with the VM terminal history.
14- The position is flex, so this will move with the VM terminal history.
14
15
15−## Terminal Area
16+## Vibebox Commands
16
17
17−- Shows all the VM terminal history
18+- One time, not fixed
19+- Prints all commands.
20+- Each command includes a description.
18
21
19−## Vibebox input area
22+## Normal terminal
20
23
21−- A text input area, user can input text in it, it can vertically expand depending on the text length, by default it
24+- Then the tui should end.
22− is a line high.
23−- it should be able to switch to auto-completion mode, which will display a list of available commands. When in this mode, the bottom
24− status bar will disappear. The auto completions are displayed right below the text input area.
25−
26−## Bottom status bar
27−
28−- Display texts in gray, a line high. on the left it shows `:help` for help.
src/bin/vibebox-tui.rs+38−6
@@ -1,4 +1,9 @@
1+use std::{
2+ env,
1−use std::{env, ffi::OsString, path::PathBuf};
3+ ffi::OsString,
4+ io::{self, Read, Write},
5+ path::PathBuf,
6+};
2
7
3use color_eyre::Result;
8use color_eyre::Result;
4use lexopt::prelude::*;
9use lexopt::prelude::*;
@@ -33,8 +38,7 @@ enum CliError {
33 Io(#[from] std::io::Error),
38 Io(#[from] std::io::Error),
34}
39}
35
40
36−#[tokio::main]
37−async fn main() -> Result<()> {
41+fn main() -> Result<()> {
38 color_eyre::install()?;
42 color_eyre::install()?;
39
43
40 let command = parse_args(env::args_os())?;
44 let command = parse_args(env::args_os())?;
@@ -52,15 +56,43 @@ async fn main() -> Result<()> {
52 cpu_cores: config.cpu_cores,
56 cpu_cores: config.cpu_cores,
53 };
57 };
54 let mut app = AppState::new(config.cwd, vm_info);
58 let mut app = AppState::new(config.cwd, vm_info);
55− app.push_history("VM output will appear here.");
59+ tui::render_tui_once(&mut app)?;
56− app.push_history("TODO: wire VM IO into the TUI event loop.");
60+ {
57− tui::run_tui(app).await?;
61+ let mut stdout = io::stdout().lock();
62+ writeln!(stdout)?;
63+ stdout.flush()?;
64+ }
65+ passthrough_stdio(&mut app)?;
58 }
66 }
59 }
67 }
60
68
61 Ok(())
69 Ok(())
62}
70}
63
71
72+fn passthrough_stdio(app: &mut AppState) -> io::Result<()> {
73+ let mut stdin = io::stdin().lock();
74+ let mut buf = [0u8; 8192];
75+ let mut line_buf: Vec<u8> = Vec::new();
76+ loop {
77+ let n = stdin.read(&mut buf)?;
78+ if n == 0 {
79+ break;
80+ }
81+ for &b in &buf[..n] {
82+ line_buf.push(b);
83+ if b == b'\n' {
84+ let line = String::from_utf8_lossy(&line_buf);
85+ let trimmed = line.trim_end_matches(&['\r', '\n'][..]);
86+ if trimmed == ":help" {
87+ let _ = tui::render_commands_component(app);
88+ }
89+ line_buf.clear();
90+ }
91+ }
92+ }
93+ Ok(())
94+}
95+
64fn print_help() {
96fn print_help() {
65 println!(
97 println!(
66 "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"
98 "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"
src/tui.rs+300−207
@@ -1,27 +1,34 @@
1use std::{
1use std::{
2− io::{self, Stdout},
2+ io::{self, Stdout, Write},
3 path::PathBuf,
3 path::PathBuf,
4− time::Duration,
5};
4};
6
5
7use color_eyre::Result;
6use color_eyre::Result;
8use crossterm::{
7use crossterm::{
8+ cursor::{MoveTo, Show},
9 event::{
9 event::{
10 DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyCode,
10 DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyCode,
11 KeyEvent, KeyEventKind, KeyModifiers,
11 KeyEvent, KeyEventKind, KeyModifiers,
12 },
12 },
13− execute,
13+ execute, queue,
14− terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
14+ style::{
15+ Attribute, Color as CrosstermColor, Print, SetAttribute, SetBackgroundColor,
16+ SetForegroundColor,
17+ },
18+ terminal::{
19+ Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
20+ enable_raw_mode,
21+ },
15};
22};
16use futures::StreamExt;
23use futures::StreamExt;
17use ratatui::{
24use ratatui::{
25+ Frame, Terminal,
18− backend::CrosstermBackend, buffer::Buffer,
26+ backend::CrosstermBackend,
27+ buffer::Buffer,
19 layout::{Constraint, Direction, Layout, Rect},
28 layout::{Constraint, Direction, Layout, Rect},
20 style::{Color, Style},
29 style::{Color, Style},
21 text::{Line, Span, Text},
30 text::{Line, Span, Text},
22− widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap},
31+ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget},
23− Frame,
24− Terminal,
25};
32};
26use tui_textarea::{Input, Key, TextArea};
33use tui_textarea::{Input, Key, TextArea};
27use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
34use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
@@ -36,10 +43,6 @@ const ASCII_BANNER: [&str; 7] = [
36 "",
43 "",
37];
44];
38
45
39−const STATUS_BAR_HEIGHT: u16 = 1;
40−const COMPLETIONS_MAX_HEIGHT: u16 = 6;
41−const SPINNER_FRAMES: [&str; 4] = ["|", "/", "-", "\\"];
42−
43#[derive(Debug, Clone)]
46#[derive(Debug, Clone)]
44pub struct VmInfo {
47pub struct VmInfo {
45 pub version: String,
48 pub version: String,
@@ -53,11 +56,9 @@ pub struct AppState {
53 pub vm_info: VmInfo,
56 pub vm_info: VmInfo,
54 pub history: Vec<String>,
57 pub history: Vec<String>,
55 pub input: TextArea<'static>,
58 pub input: TextArea<'static>,
56− pub completions: CompletionState,
59+ pub commands: VibeboxCommands,
57 pub should_quit: bool,
60 pub should_quit: bool,
58 key_input_mode: KeyInputMode,
61 key_input_mode: KeyInputMode,
59− tick: u64,
60− spinner: usize,
61 page_scroll: usize,
62 page_scroll: usize,
62 input_view_width: u16,
63 input_view_width: u16,
63}
64}
@@ -65,17 +66,18 @@ pub struct AppState {
65impl AppState {
66impl AppState {
66 pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
67 pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
67 let input = Self::default_input();
68 let input = Self::default_input();
69+ let mut commands = VibeboxCommands::default();
70+ commands.add_command(":new", "Create a new session.");
71+ commands.add_command(":exit", "Exit Vibebox.");
68
72
69 Self {
73 Self {
70 cwd,
74 cwd,
71 vm_info,
75 vm_info,
72 history: Vec::new(),
76 history: Vec::new(),
73 input,
77 input,
74− completions: CompletionState::default(),
78+ commands,
75 should_quit: false,
79 should_quit: false,
76 key_input_mode: KeyInputMode::Unknown,
80 key_input_mode: KeyInputMode::Unknown,
77− tick: 0,
78− spinner: 0,
79 page_scroll: 0,
81 page_scroll: 0,
80 input_view_width: 0,
82 input_view_width: 0,
81 }
83 }
@@ -171,27 +173,26 @@ impl AppState {
171 }
173 }
172 }
174 }
173
175
174− pub fn activate_completions(&mut self, items: Vec<String>) {
176+ pub fn activate_commands(&mut self) {
175− self.completions.set_items(items);
177+ self.commands.activate();
176− self.completions.activate();
177 }
178 }
178
179
179− pub fn deactivate_completions(&mut self) {
180+ pub fn deactivate_commands(&mut self) {
180− self.completions.deactivate();
181+ self.commands.deactivate();
181 }
182 }
182
183
183− pub fn toggle_completions(&mut self, items: Vec<String>) {
184+ pub fn toggle_commands(&mut self) {
184− if self.completions.active {
185+ if self.commands.active {
185− self.deactivate_completions();
186+ self.deactivate_commands();
186 } else {
187 } else {
187− self.activate_completions(items);
188+ self.activate_commands();
188 }
189 }
189 }
190 }
190}
191}
191
192
192−#[derive(Debug, Default, Clone)]
193+#[derive(Debug, Clone)]
193−pub struct CompletionState {
194+pub struct VibeboxCommands {
194− items: Vec<String>,
195+ items: Vec<VibeboxCommand>,
195 selected: usize,
196 selected: usize,
196 active: bool,
197 active: bool,
197}
198}
@@ -203,12 +204,19 @@ enum KeyInputMode {
203 Release,
204 Release,
204}
205}
205
206
206−impl CompletionState {
207+impl VibeboxCommands {
207 pub fn set_items(&mut self, items: Vec<String>) {
208 pub fn set_items(&mut self, items: Vec<String>) {
208− self.items = items;
209+ self.items = items.into_iter().map(VibeboxCommand::new).collect();
209 self.selected = 0;
210 self.selected = 0;
210 }
211 }
211
212
213+ pub fn add_command(&mut self, name: impl Into<String>, description: impl Into<String>) {
214+ self.items.push(VibeboxCommand {
215+ name: name.into(),
216+ description: description.into(),
217+ });
218+ }
219+
212 pub fn activate(&mut self) {
220 pub fn activate(&mut self) {
213 self.active = !self.items.is_empty();
221 self.active = !self.items.is_empty();
214 self.selected = 0;
222 self.selected = 0;
@@ -238,13 +246,13 @@ impl CompletionState {
238
246
239 pub fn current(&self) -> Option<&str> {
247 pub fn current(&self) -> Option<&str> {
240 if self.active {
248 if self.active {
241− self.items.get(self.selected).map(|s| s.as_str())
249+ self.items.get(self.selected).map(|cmd| cmd.name.as_str())
242 } else {
250 } else {
243 None
251 None
244 }
252 }
245 }
253 }
246
254
247− pub fn items(&self) -> &[String] {
255+ pub fn items(&self) -> &[VibeboxCommand] {
248 &self.items
256 &self.items
249 }
257 }
250
258
@@ -257,6 +265,32 @@ impl CompletionState {
257 }
265 }
258}
266}
259
267
268+impl Default for VibeboxCommands {
269+ fn default() -> Self {
270+ Self {
271+ items: vec![VibeboxCommand {
272+ name: ":help".to_string(),
273+ description: "Show Vibebox commands.".to_string(),
274+ }],
275+ selected: 0,
276+ active: false,
277+ }
278+ }
279+}
280+
281+#[derive(Debug, Default, Clone)]
282+pub struct VibeboxCommand {
283+ pub name: String,
284+ pub description: String,
285+}
286+
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+
260#[allow(dead_code)]
294#[allow(dead_code)]
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
295#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
262pub struct LayoutAreas {
296pub struct LayoutAreas {
@@ -278,79 +312,63 @@ struct PageLayout {
278}
312}
279
313
280#[allow(dead_code)]
314#[allow(dead_code)]
281−pub fn compute_layout(
282− area: Rect,
283− input_height: u16,
284− completion_items: usize,
315+pub fn compute_layout(area: Rect, input_height: u16, completion_items: usize) -> LayoutAreas {
285− completion_active: bool,
286−) -> LayoutAreas {
287 let header_height = header_height().min(area.height);
316 let header_height = header_height().min(area.height);
288 let mut remaining = area.height.saturating_sub(header_height);
317 let mut remaining = area.height.saturating_sub(header_height);
289
318
290 let input_height = input_height.max(1).min(remaining);
319 let input_height = input_height.max(1).min(remaining);
291 remaining = remaining.saturating_sub(input_height);
320 remaining = remaining.saturating_sub(input_height);
292
321
293− let (completion_height, status_height) = if completion_active {
322+ let completion_height = if completion_items == 0 {
294− let desired = (completion_items as u16).min(COMPLETIONS_MAX_HEIGHT);
323+ 0
295− let height = desired.min(remaining);
296− (height, 0)
297 } else {
324 } else {
298− let height = STATUS_BAR_HEIGHT.min(remaining);
325+ let desired = (completion_items as u16).saturating_add(2);
299− (0, height)
326+ desired
300 };
327 };
328+ let completion_height = completion_height.min(remaining);
301
329
302− remaining = remaining.saturating_sub(completion_height + status_height);
303−
304− let terminal_height = remaining;
330+ let terminal_height = 0;
305
331
306− let chunks = Layout::default()
332+ let mut y = 0u16;
307− .direction(Direction::Vertical)
333+ let header = Rect::new(area.x, area.y + y, area.width, header_height);
308− .constraints([
309− Constraint::Length(header_height),
334+ y = y.saturating_add(header_height);
310− Constraint::Length(terminal_height),
335+ let terminal = Rect::new(area.x, area.y + y, area.width, terminal_height);
311− Constraint::Length(input_height),
336+ let input = Rect::new(area.x, area.y + y, area.width, input_height);
312− Constraint::Length(completion_height),
337+ y = y.saturating_add(input_height);
313− Constraint::Length(status_height),
338+ let completions = Rect::new(area.x, area.y + y, area.width, completion_height);
314− ])
339+ let status = Rect::new(area.x, area.y + y, area.width, 0);
315− .split(area);
316
340
317 LayoutAreas {
341 LayoutAreas {
318− header: chunks[0],
342+ header,
319− terminal: chunks[1],
343+ terminal,
320− input: chunks[2],
344+ input,
321− completions: chunks[3],
345+ completions,
322− status: chunks[4],
346+ status,
323 }
347 }
324}
348}
325
349
326fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
350fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
327 let header_height = header_height();
351 let header_height = header_height();
328− let terminal_height = terminal_height(app, width);
352+ let input_height = 0;
329− let input_height = app.input_height_for_width(width);
353+ let completion_items = app.commands.items().len();
330− let (completion_height, status_height) = if app.completions.is_active() {
354+ let completion_height = if completion_items == 0 {
331− let desired = (app.completions.items().len() as u16).min(COMPLETIONS_MAX_HEIGHT);
355+ 0
332− (desired.saturating_add(2), 0)
333 } else {
356 } else {
334− (0, STATUS_BAR_HEIGHT)
357+ (completion_items as u16).saturating_add(2)
335 };
358 };
336−
337 let total_height = header_height
359 let total_height = header_height
338− .saturating_add(terminal_height)
339 .saturating_add(input_height)
360 .saturating_add(input_height)
340 .saturating_add(completion_height)
361 .saturating_add(completion_height)
341− .saturating_add(status_height)
342 .max(1);
362 .max(1);
343
363
344 let mut y = 0u16;
364 let mut y = 0u16;
345 let header = Rect::new(0, y, width, header_height);
365 let header = Rect::new(0, y, width, header_height);
346 y = y.saturating_add(header_height);
366 y = y.saturating_add(header_height);
347− let terminal = Rect::new(0, y, width, terminal_height);
367+ let terminal = Rect::new(0, y, width, 0);
348− y = y.saturating_add(terminal_height);
349 let input = Rect::new(0, y, width, input_height);
368 let input = Rect::new(0, y, width, input_height);
350 y = y.saturating_add(input_height);
369 y = y.saturating_add(input_height);
351 let completions = Rect::new(0, y, width, completion_height);
370 let completions = Rect::new(0, y, width, completion_height);
352− y = y.saturating_add(completion_height);
353− let status = Rect::new(0, y, width, status_height);
371+ let status = Rect::new(0, y, width, 0);
354
372
355 PageLayout {
373 PageLayout {
356 header,
374 header,
@@ -362,23 +380,6 @@ fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
362 }
380 }
363}
381}
364
382
365−fn terminal_height(app: &AppState, width: u16) -> u16 {
366− if width == 0 {
367− return 0;
368− }
369− let lines: Vec<Line> = app
370− .history
371− .iter()
372− .map(|line| Line::from(line.as_str()))
373− .collect();
374− let block = Block::default().borders(Borders::ALL).title("Terminal");
375− let paragraph = Paragraph::new(Text::from_iter(lines))
376− .block(block)
377− .wrap(Wrap { trim: true });
378− let count = paragraph.line_count(width).max(1);
379− (count.min(u16::MAX as usize)) as u16
380−}
381−
382fn header_height() -> u16 {
383fn header_height() -> u16 {
383 let banner_height = ASCII_BANNER.len() as u16;
384 let banner_height = ASCII_BANNER.len() as u16;
384 let welcome_height = 1;
385 let welcome_height = 1;
@@ -389,21 +390,12 @@ fn header_height() -> u16 {
389pub async fn run_tui(mut app: AppState) -> Result<()> {
390pub async fn run_tui(mut app: AppState) -> Result<()> {
390 let mut terminal = TerminalGuard::init()?;
391 let mut terminal = TerminalGuard::init()?;
391 let mut events = EventStream::new();
392 let mut events = EventStream::new();
392− let mut tick = tokio::time::interval(Duration::from_millis(250));
393
393
394 loop {
394 loop {
395 terminal.draw(|frame| render(frame, &mut app))?;
395 terminal.draw(|frame| render(frame, &mut app))?;
396
396
397− tokio::select! {
398− _ = tick.tick() => {
399− app.tick = app.tick.wrapping_add(1);
400− app.spinner = (app.spinner + 1) % SPINNER_FRAMES.len();
401− },
402− event = events.next() => {
403− if let Some(event) = event {
397+ if let Some(event) = events.next().await {
404− handle_event(event?, &mut app);
398+ handle_event(event?, &mut app);
405− }
406− }
407 }
399 }
408
400
409 if app.should_quit {
401 if app.should_quit {
@@ -414,6 +406,161 @@ pub async fn run_tui(mut app: AppState) -> Result<()> {
414 Ok(())
406 Ok(())
415}
407}
416
408
409+pub fn render_tui_once(app: &mut AppState) -> Result<()> {
410+ let (width, _) = crossterm::terminal::size()?;
411+ if width == 0 {
412+ return Ok(());
413+ }
414+
415+ let buffer = render_static_buffer(app, width);
416+ let mut stdout = io::stdout();
417+ execute!(stdout, Clear(ClearType::All), MoveTo(0, 0), Show)?;
418+ write_buffer_with_style(&buffer, &mut stdout)?;
419+ stdout.flush()?;
420+ Ok(())
421+}
422+
423+pub fn render_commands_component(app: &mut AppState) -> Result<()> {
424+ let (width, _) = crossterm::terminal::size()?;
425+ if width == 0 {
426+ return Ok(());
427+ }
428+
429+ let command_count = app.commands.items().len() as u16;
430+ let height = if command_count == 0 {
431+ 0
432+ } else {
433+ command_count.saturating_add(2)
434+ };
435+ if height == 0 {
436+ return Ok(());
437+ }
438+
439+ let mut buffer = Buffer::empty(Rect::new(0, 0, width, height));
440+ let area = Rect::new(0, 0, width, height);
441+ render_completions(&mut buffer, area, app);
442+
443+ let mut stdout = io::stdout();
444+ write_buffer_with_style(&buffer, &mut stdout)?;
445+ stdout.flush()?;
446+ Ok(())
447+}
448+
449+fn render_static_buffer(app: &mut AppState, width: u16) -> Buffer {
450+ let layout = compute_page_layout(app, width);
451+ let content_height = layout.total_height.max(1);
452+ let mut buffer = Buffer::empty(Rect::new(0, 0, width, content_height));
453+ render_header(&mut buffer, layout.header, app);
454+ render_completions(&mut buffer, layout.completions, app);
455+ buffer
456+}
457+
458+fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<()> {
459+ let area = buffer.area;
460+ let mut current_fg: Option<CrosstermColor> = None;
461+ let mut current_bg: Option<CrosstermColor> = None;
462+ let mut current_modifier: Option<ratatui::style::Modifier> = None;
463+
464+ for y in 0..area.height {
465+ for x in 0..area.width {
466+ let cell = &buffer[(x, y)];
467+ if cell.skip {
468+ continue;
469+ }
470+
471+ let fg = map_color(cell.fg);
472+ let bg = map_color(cell.bg);
473+ let modifier = cell.modifier;
474+ if current_fg != Some(fg)
475+ || current_bg != Some(bg)
476+ || current_modifier != Some(modifier)
477+ {
478+ queue!(out, SetAttribute(Attribute::Reset))?;
479+ queue!(out, SetForegroundColor(fg), SetBackgroundColor(bg))?;
480+ queue_modifier(out, modifier)?;
481+ current_fg = Some(fg);
482+ current_bg = Some(bg);
483+ current_modifier = Some(modifier);
484+ }
485+
486+ let symbol = cell.symbol();
487+ if symbol.is_empty() {
488+ queue!(out, Print(" "))?;
489+ } else {
490+ queue!(out, Print(symbol))?;
491+ }
492+ }
493+ queue!(
494+ out,
495+ SetAttribute(Attribute::Reset),
496+ SetForegroundColor(CrosstermColor::Reset),
497+ SetBackgroundColor(CrosstermColor::Reset),
498+ Print("\n")
499+ )?;
500+ current_fg = None;
501+ current_bg = None;
502+ current_modifier = None;
503+ }
504+
505+ Ok(())
506+}
507+
508+fn map_color(color: ratatui::style::Color) -> CrosstermColor {
509+ match color {
510+ ratatui::style::Color::Reset => CrosstermColor::Reset,
511+ ratatui::style::Color::Black => CrosstermColor::Black,
512+ ratatui::style::Color::Red => CrosstermColor::DarkRed,
513+ ratatui::style::Color::Green => CrosstermColor::DarkGreen,
514+ ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
515+ ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
516+ ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
517+ ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
518+ ratatui::style::Color::Gray => CrosstermColor::Grey,
519+ ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
520+ ratatui::style::Color::LightRed => CrosstermColor::Red,
521+ ratatui::style::Color::LightGreen => CrosstermColor::Green,
522+ ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
523+ ratatui::style::Color::LightBlue => CrosstermColor::Blue,
524+ ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
525+ ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
526+ ratatui::style::Color::White => CrosstermColor::White,
527+ ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
528+ ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i),
529+ }
530+}
531+
532+fn queue_modifier(out: &mut impl Write, modifier: ratatui::style::Modifier) -> io::Result<()> {
533+ use ratatui::style::Modifier;
534+ if modifier.contains(Modifier::BOLD) {
535+ queue!(out, SetAttribute(Attribute::Bold))?;
536+ }
537+ if modifier.contains(Modifier::DIM) {
538+ queue!(out, SetAttribute(Attribute::Dim))?;
539+ }
540+ if modifier.contains(Modifier::ITALIC) {
541+ queue!(out, SetAttribute(Attribute::Italic))?;
542+ }
543+ if modifier.contains(Modifier::UNDERLINED) {
544+ queue!(out, SetAttribute(Attribute::Underlined))?;
545+ }
546+ if modifier.contains(Modifier::SLOW_BLINK) {
547+ queue!(out, SetAttribute(Attribute::SlowBlink))?;
548+ }
549+ if modifier.contains(Modifier::RAPID_BLINK) {
550+ queue!(out, SetAttribute(Attribute::RapidBlink))?;
551+ }
552+ if modifier.contains(Modifier::REVERSED) {
553+ queue!(out, SetAttribute(Attribute::Reverse))?;
554+ }
555+ if modifier.contains(Modifier::HIDDEN) {
556+ queue!(out, SetAttribute(Attribute::Hidden))?;
557+ }
558+ if modifier.contains(Modifier::CROSSED_OUT) {
559+ queue!(out, SetAttribute(Attribute::CrossedOut))?;
560+ }
561+ Ok(())
562+}
563+
417fn handle_event(event: CrosstermEvent, app: &mut AppState) {
564fn handle_event(event: CrosstermEvent, app: &mut AppState) {
418 match event {
565 match event {
419 CrosstermEvent::Key(key) => handle_key_event(key, app),
566 CrosstermEvent::Key(key) => handle_key_event(key, app),
@@ -432,16 +579,16 @@ fn handle_key_event(key: KeyEvent, app: &mut AppState) {
432 return;
579 return;
433 }
580 }
434
581
435− if app.completions.is_active() {
582+ if app.commands.is_active() {
436 match key.code {
583 match key.code {
437− KeyCode::Esc => app.deactivate_completions(),
584+ KeyCode::Esc => app.deactivate_commands(),
438− KeyCode::Up => app.completions.previous(),
585+ KeyCode::Up => app.commands.previous(),
439− KeyCode::Down => app.completions.next(),
586+ KeyCode::Down => app.commands.next(),
440 KeyCode::Enter => {
587 KeyCode::Enter => {
441− if let Some(selection) = app.completions.current() {
588+ if let Some(selection) = app.commands.current() {
442 app.input.insert_str(selection);
589 app.input.insert_str(selection);
443 }
590 }
444− app.deactivate_completions();
591+ app.deactivate_commands();
445 }
592 }
446 _ => {}
593 _ => {}
447 }
594 }
@@ -464,7 +611,7 @@ fn handle_key_event(key: KeyEvent, app: &mut AppState) {
464 app.page_scroll = usize::MAX;
611 app.page_scroll = usize::MAX;
465 }
612 }
466 }
613 }
467− KeyCode::Tab => app.toggle_completions(default_completions()),
614+ KeyCode::Tab => app.toggle_commands(),
468 KeyCode::Char(c)
615 KeyCode::Char(c)
469 if !key
616 if !key
470 .modifiers
617 .modifiers
@@ -547,8 +694,13 @@ fn input_from_key_event(key: KeyEvent) -> Input {
547 }
694 }
548}
695}
549
696
550−fn default_completions() -> Vec<String> {
697+fn command_description(command: &str) -> &'static str {
551− vec![":help".to_string(), ":new".to_string(), ":exit".to_string()]
698+ match command {
699+ ":help" => "Show Vibebox commands.",
700+ ":new" => "Create a new session.",
701+ ":exit" => "Exit Vibebox.",
702+ _ => "",
703+ }
552}
704}
553
705
554fn render(frame: &mut Frame<'_>, app: &mut AppState) {
706fn render(frame: &mut Frame<'_>, app: &mut AppState) {
@@ -563,14 +715,7 @@ fn render(frame: &mut Frame<'_>, app: &mut AppState) {
563
715
564 let mut buffer = Buffer::empty(Rect::new(0, 0, viewport.width, content_height));
716 let mut buffer = Buffer::empty(Rect::new(0, 0, viewport.width, content_height));
565 render_header(&mut buffer, layout.header, app);
717 render_header(&mut buffer, layout.header, app);
566− render_terminal(&mut buffer, layout.terminal, app);
567− let cursor_pos = render_input(&mut buffer, layout.input, app);
568−
569− if app.completions.is_active() {
570− render_completions(&mut buffer, layout.completions, app);
718+ render_completions(&mut buffer, layout.completions, app);
571− } else {
572− render_status(&mut buffer, layout.status, app);
573− }
574
719
575 let max_scroll = content_height.saturating_sub(viewport.height);
720 let max_scroll = content_height.saturating_sub(viewport.height);
576 app.page_scroll = app.page_scroll.min(max_scroll as usize);
721 app.page_scroll = app.page_scroll.min(max_scroll as usize);
@@ -586,12 +731,6 @@ fn render(frame: &mut Frame<'_>, app: &mut AppState) {
586 view[(x, y)] = buffer[(x, src_y)].clone();
731 view[(x, y)] = buffer[(x, src_y)].clone();
587 }
732 }
588 }
733 }
589−
590− if let Some((x, y)) = cursor_pos {
591− if y >= scroll && y < scroll.saturating_add(viewport.height) {
592− frame.set_cursor_position((x, y - scroll));
593− }
594− }
595}
734}
596
735
597fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
736fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
@@ -645,80 +784,34 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
645 .render(header_chunks[2], buffer);
784 .render(header_chunks[2], buffer);
646}
785}
647
786
648−fn render_terminal(buffer: &mut Buffer, area: Rect, app: &AppState) {
649− if area.height == 0 {
650− return;
651− }
652− let lines = app.history.iter().map(|line| Line::from(line.as_str()));
653− let block = Block::default().borders(Borders::ALL).title("Terminal");
654− let paragraph = Paragraph::new(Text::from_iter(lines))
655− .block(block)
656− .wrap(Wrap { trim: true });
657−
658− paragraph.render(area, buffer);
659−}
660−
661−fn render_input(buffer: &mut Buffer, area: Rect, app: &mut AppState) -> Option<(u16, u16)> {
662− if area.height == 0 || area.width == 0 {
663− return None;
664− }
665− app.input.render(area, buffer);
666− let cursor = app.input.cursor();
667− let inner = match app.input.block() {
668− Some(block) => block.inner(area),
669− None => area,
670− };
671− let x = inner.x.saturating_add(cursor.1 as u16);
672− let y = inner.y.saturating_add(cursor.0 as u16);
673− if x < inner.x.saturating_add(inner.width) && y < inner.y.saturating_add(inner.height) {
674− Some((x, y))
675− } else {
676− None
677− }
678−}
679−
680fn render_completions(buffer: &mut Buffer, area: Rect, app: &mut AppState) {
787fn render_completions(buffer: &mut Buffer, area: Rect, app: &mut AppState) {
681 if area.height == 0 {
788 if area.height == 0 {
682 return;
789 return;
683 }
790 }
684
791
685 let items: Vec<ListItem<'_>> = app
792 let items: Vec<ListItem<'_>> = app
686− .completions
793+ .commands
687 .items()
794 .items()
688 .iter()
795 .iter()
689− .map(|item| ListItem::new(Line::from(item.as_str())))
796+ .map(|cmd| ListItem::new(Line::from(format!("{} {}", cmd.name, cmd.description))))
690 .collect();
797 .collect();
691
798
692 let list = List::new(items)
799 let list = List::new(items)
800+ .block(
801+ Block::default()
693− .block(Block::default().borders(Borders::ALL).title("Completions"))
802+ .borders(Borders::ALL)
803+ .title("Vibebox Commands"),
804+ )
694 .highlight_style(Style::default().fg(Color::Yellow));
805 .highlight_style(Style::default().fg(Color::Yellow));
695
806
696 let mut state = ListState::default();
807 let mut state = ListState::default();
697− if app.completions.is_active() && !app.completions.items().is_empty() {
808+ if app.commands.is_active() && !app.commands.items().is_empty() {
698− state.select(Some(app.completions.selected()));
809+ state.select(Some(app.commands.selected()));
699 }
810 }
700
811
701 StatefulWidget::render(list, area, buffer, &mut state);
812 StatefulWidget::render(list, area, buffer, &mut state);
702}
813}
703
814
704−fn render_status(buffer: &mut Buffer, area: Rect, app: &AppState) {
705− if area.height == 0 {
706− return;
707− }
708−
709− let spinner = SPINNER_FRAMES[app.spinner % SPINNER_FRAMES.len()];
710− let status = Paragraph::new(Line::from(vec![
711− Span::styled(":help", Style::default().fg(Color::DarkGray)),
712− Span::raw(" "),
713− Span::styled(
714− format!("tick {} {}", app.tick, spinner),
715− Style::default().fg(Color::DarkGray),
716− ),
717− ]));
718−
719− status.render(area, buffer);
720−}
721−
722struct TerminalGuard {
815struct TerminalGuard {
723 terminal: Terminal<CrosstermBackend<Stdout>>,
816 terminal: Terminal<CrosstermBackend<Stdout>>,
724}
817}
@@ -755,29 +848,29 @@ mod tests {
755 use super::*;
848 use super::*;
756
849
757 #[test]
850 #[test]
758− fn layout_without_completions_reserves_status_bar() {
851+ fn layout_without_completions_hides_status_bar() {
759 let area = Rect::new(0, 0, 80, 30);
852 let area = Rect::new(0, 0, 80, 30);
760− let layout = compute_layout(area, 3, 0, false);
853+ let layout = compute_layout(area, 3, 0);
761
854
762 assert_eq!(layout.header.height, header_height());
855 assert_eq!(layout.header.height, header_height());
763− assert_eq!(layout.status.height, STATUS_BAR_HEIGHT);
856+ assert_eq!(layout.status.height, 0);
764 assert_eq!(layout.completions.height, 0);
857 assert_eq!(layout.completions.height, 0);
765− assert!(layout.terminal.height > 0);
858+ assert_eq!(layout.terminal.height, 0);
766 }
859 }
767
860
768 #[test]
861 #[test]
769 fn layout_with_completions_hides_status_bar() {
862 fn layout_with_completions_hides_status_bar() {
770 let area = Rect::new(0, 0, 80, 30);
863 let area = Rect::new(0, 0, 80, 30);
771− let layout = compute_layout(area, 4, 3, true);
864+ let layout = compute_layout(area, 4, 3);
772
865
773 assert_eq!(layout.status.height, 0);
866 assert_eq!(layout.status.height, 0);
774− assert_eq!(layout.completions.height, 3);
867+ assert_eq!(layout.completions.height, 5);
775 }
868 }
776
869
777 #[test]
870 #[test]
778 fn layout_clamps_when_space_is_tight() {
871 fn layout_clamps_when_space_is_tight() {
779 let area = Rect::new(0, 0, 80, header_height() + 1);
872 let area = Rect::new(0, 0, 80, header_height() + 1);
780− let layout = compute_layout(area, 3, 10, true);
873+ let layout = compute_layout(area, 3, 10);
781
874
782 assert_eq!(layout.header.height, header_height());
875 assert_eq!(layout.header.height, header_height());
783 assert_eq!(layout.input.height, 1);
876 assert_eq!(layout.input.height, 1);
@@ -785,31 +878,31 @@ mod tests {
785 }
878 }
786
879
787 #[test]
880 #[test]
788− fn completion_state_wraps_navigation() {
881+ fn commands_wrap_navigation() {
789− let mut completions = CompletionState::default();
882+ let mut commands = VibeboxCommands::default();
790− completions.set_items(vec!["a".into(), "b".into(), "c".into()]);
883+ commands.set_items(vec![":new".into(), ":exit".into(), ":help".into()]);
791− completions.activate();
884+ commands.activate();
792
885
793− assert_eq!(completions.current(), Some("a"));
886+ assert_eq!(commands.current(), Some(":new"));
794
887
795− completions.next();
888+ commands.next();
796− assert_eq!(completions.current(), Some("b"));
889+ assert_eq!(commands.current(), Some(":exit"));
797
890
798− completions.next();
891+ commands.next();
799− completions.next();
892+ commands.next();
800− assert_eq!(completions.current(), Some("a"));
893+ assert_eq!(commands.current(), Some(":new"));
801
894
802− completions.previous();
895+ commands.previous();
803− assert_eq!(completions.current(), Some("c"));
896+ assert_eq!(commands.current(), Some(":help"));
804 }
897 }
805
898
806 #[test]
899 #[test]
807− fn completion_state_is_inactive_when_empty() {
900+ fn commands_inactive_when_empty() {
808− let mut completions = CompletionState::default();
901+ let mut commands = VibeboxCommands::default();
809− completions.activate();
902+ commands.activate();
810
903
811− assert!(!completions.is_active());
904+ assert!(!commands.is_active());
812− assert_eq!(completions.current(), None);
905+ assert_eq!(commands.current(), None);
813 }
906 }
814
907
815 #[test]
908 #[test]