mirror of
https://github.com/robcholz/vibebox.git
synced 2026-05-23 07:09:40 +02:00
feat: now tui is complete
This commit is contained in:
+17
-585
@@ -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<String>,
|
||||
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<String>) {
|
||||
self.history.push(line.into());
|
||||
if self.history.len() > 2000 {
|
||||
let excess = self.history.len() - 2000;
|
||||
self.history.drain(0..excess);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate_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<VibeboxCommand>,
|
||||
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<String>) {
|
||||
self.items = items.into_iter().map(VibeboxCommand::new).collect();
|
||||
self.selected = 0;
|
||||
}
|
||||
|
||||
pub fn add_command(&mut self, name: impl Into<String>, description: impl Into<String>) {
|
||||
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<CrosstermBackend<Stdout>>,
|
||||
}
|
||||
|
||||
impl TerminalGuard {
|
||||
fn init() -> Result<Self> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let terminal = Terminal::new(backend)?;
|
||||
Ok(Self { terminal })
|
||||
}
|
||||
|
||||
fn draw(&mut self, f: impl FnOnce(&mut Frame<'_>)) -> Result<()> {
|
||||
self.terminal.draw(f)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(
|
||||
self.terminal.backend_mut(),
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layout_without_completions_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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user