mirror of
https://github.com/robcholz/vibebox.git
synced 2026-05-21 06:56:48 +02:00
feat: added scrollable page and terminal height is fixed
This commit is contained in:
+156
-78
@@ -11,28 +11,29 @@ use crossterm::{
|
||||
KeyEvent, KeyEventKind, KeyModifiers,
|
||||
},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use ratatui::{
|
||||
Frame, Terminal,
|
||||
backend::CrosstermBackend,
|
||||
backend::CrosstermBackend, buffer::Buffer,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap},
|
||||
Frame,
|
||||
Terminal,
|
||||
};
|
||||
use tui_textarea::{Input, Key, TextArea};
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
const ASCII_BANNER: [&str; 7] = [
|
||||
"░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░",
|
||||
"░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
|
||||
" ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
|
||||
" ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓██████▓▒░",
|
||||
" ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
|
||||
" ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
|
||||
" ░▒▓██▓▒░ ░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░",
|
||||
"██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
|
||||
"██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
|
||||
"██║ ██║██║██████╔╝█████╗ ██████╔╝██║ ██║ ╚███╔╝",
|
||||
"╚██╗ ██╔╝██║██╔══██╗██╔══╝ ██╔══██╗██║ ██║ ██╔██╗",
|
||||
" ╚████╔╝ ██║██████╔╝███████╗██████╔╝╚██████╔╝██╔╝ ██╗",
|
||||
" ╚═══╝ ╚═╝╚═════╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
|
||||
"",
|
||||
];
|
||||
|
||||
const STATUS_BAR_HEIGHT: u16 = 1;
|
||||
@@ -57,7 +58,7 @@ pub struct AppState {
|
||||
key_input_mode: KeyInputMode,
|
||||
tick: u64,
|
||||
spinner: usize,
|
||||
terminal_scroll: usize,
|
||||
page_scroll: usize,
|
||||
input_view_width: u16,
|
||||
}
|
||||
|
||||
@@ -75,7 +76,7 @@ impl AppState {
|
||||
key_input_mode: KeyInputMode::Unknown,
|
||||
tick: 0,
|
||||
spinner: 0,
|
||||
terminal_scroll: 0,
|
||||
page_scroll: 0,
|
||||
input_view_width: 0,
|
||||
}
|
||||
}
|
||||
@@ -256,6 +257,7 @@ impl CompletionState {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct LayoutAreas {
|
||||
pub header: Rect,
|
||||
@@ -265,6 +267,17 @@ pub struct LayoutAreas {
|
||||
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,
|
||||
@@ -310,6 +323,62 @@ pub fn compute_layout(
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
|
||||
let header_height = header_height();
|
||||
let terminal_height = terminal_height(app, width);
|
||||
let input_height = app.input_height_for_width(width);
|
||||
let (completion_height, status_height) = if app.completions.is_active() {
|
||||
let desired = (app.completions.items().len() as u16).min(COMPLETIONS_MAX_HEIGHT);
|
||||
(desired.saturating_add(2), 0)
|
||||
} else {
|
||||
(0, STATUS_BAR_HEIGHT)
|
||||
};
|
||||
|
||||
let total_height = header_height
|
||||
.saturating_add(terminal_height)
|
||||
.saturating_add(input_height)
|
||||
.saturating_add(completion_height)
|
||||
.saturating_add(status_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, terminal_height);
|
||||
y = y.saturating_add(terminal_height);
|
||||
let input = Rect::new(0, y, width, input_height);
|
||||
y = y.saturating_add(input_height);
|
||||
let completions = Rect::new(0, y, width, completion_height);
|
||||
y = y.saturating_add(completion_height);
|
||||
let status = Rect::new(0, y, width, status_height);
|
||||
|
||||
PageLayout {
|
||||
header,
|
||||
terminal,
|
||||
input,
|
||||
completions,
|
||||
status,
|
||||
total_height,
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_height(app: &AppState, width: u16) -> u16 {
|
||||
if width == 0 {
|
||||
return 0;
|
||||
}
|
||||
let lines: Vec<Line> = app
|
||||
.history
|
||||
.iter()
|
||||
.map(|line| Line::from(line.as_str()))
|
||||
.collect();
|
||||
let block = Block::default().borders(Borders::ALL).title("Terminal");
|
||||
let paragraph = Paragraph::new(Text::from_iter(lines))
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: true });
|
||||
let count = paragraph.line_count(width).max(1);
|
||||
(count.min(u16::MAX as usize)) as u16
|
||||
}
|
||||
|
||||
fn header_height() -> u16 {
|
||||
let banner_height = ASCII_BANNER.len() as u16;
|
||||
let welcome_height = 1;
|
||||
@@ -384,23 +453,15 @@ fn handle_key_event(key: KeyEvent, app: &mut AppState) {
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::PageUp => {
|
||||
app.terminal_scroll = app.terminal_scroll.saturating_add(10);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
app.terminal_scroll = app.terminal_scroll.saturating_sub(10);
|
||||
}
|
||||
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
app.terminal_scroll = app.terminal_scroll.saturating_add(1);
|
||||
}
|
||||
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
app.terminal_scroll = app.terminal_scroll.saturating_sub(1);
|
||||
}
|
||||
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.terminal_scroll = 0;
|
||||
app.page_scroll = usize::MAX;
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => app.toggle_completions(default_completions()),
|
||||
@@ -421,10 +482,10 @@ fn handle_mouse_event(event: crossterm::event::MouseEvent, app: &mut AppState) {
|
||||
use crossterm::event::MouseEventKind;
|
||||
match event.kind {
|
||||
MouseEventKind::ScrollUp => {
|
||||
app.terminal_scroll = app.terminal_scroll.saturating_add(3);
|
||||
app.page_scroll = app.page_scroll.saturating_sub(3);
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
app.terminal_scroll = app.terminal_scroll.saturating_sub(3);
|
||||
app.page_scroll = app.page_scroll.saturating_add(3);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -491,27 +552,49 @@ fn default_completions() -> Vec<String> {
|
||||
}
|
||||
|
||||
fn render(frame: &mut Frame<'_>, app: &mut AppState) {
|
||||
let area = frame.area();
|
||||
app.input_view_width = app.input_inner_width(area.width);
|
||||
let layout = compute_layout(
|
||||
area,
|
||||
app.input_height_for_width(area.width),
|
||||
app.completions.items().len(),
|
||||
app.completions.is_active(),
|
||||
);
|
||||
let viewport = frame.area();
|
||||
if viewport.width == 0 || viewport.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
render_header(frame, layout.header, app);
|
||||
render_terminal(frame, layout.terminal, app);
|
||||
render_input(frame, layout.input, app);
|
||||
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_terminal(&mut buffer, layout.terminal, app);
|
||||
let cursor_pos = render_input(&mut buffer, layout.input, app);
|
||||
|
||||
if app.completions.is_active() {
|
||||
render_completions(frame, layout.completions, app);
|
||||
render_completions(&mut buffer, layout.completions, app);
|
||||
} else {
|
||||
render_status(frame, layout.status, app);
|
||||
render_status(&mut buffer, layout.status, 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();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((x, y)) = cursor_pos {
|
||||
if y >= scroll && y < scroll.saturating_add(viewport.height) {
|
||||
frame.set_cursor_position((x, y - scroll));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
|
||||
fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -530,13 +613,10 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
|
||||
Span::styled(&app.vm_info.version, Style::default().fg(Color::Yellow)),
|
||||
]);
|
||||
|
||||
frame.render_widget(Paragraph::new(welcome), header_chunks[0]);
|
||||
Paragraph::new(welcome).render(header_chunks[0], buffer);
|
||||
|
||||
let banner_lines = ASCII_BANNER.iter().map(|line| Line::from(*line));
|
||||
frame.render_widget(
|
||||
Paragraph::new(Text::from_iter(banner_lines)),
|
||||
header_chunks[1],
|
||||
);
|
||||
Paragraph::new(Text::from_iter(banner_lines)).render(header_chunks[1], buffer);
|
||||
|
||||
let info_block = Block::default().borders(Borders::ALL).title("Session");
|
||||
let info_lines = vec![
|
||||
@@ -560,46 +640,44 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
|
||||
]),
|
||||
];
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(info_lines).block(info_block),
|
||||
header_chunks[2],
|
||||
);
|
||||
Paragraph::new(info_lines)
|
||||
.block(info_block)
|
||||
.render(header_chunks[2], buffer);
|
||||
}
|
||||
|
||||
fn render_terminal(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
|
||||
fn render_terminal(buffer: &mut Buffer, area: Rect, app: &AppState) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let lines = app.history.iter().map(|line| Line::from(line.as_str()));
|
||||
let block = Block::default().borders(Borders::ALL).title("Terminal");
|
||||
let inner = block.inner(area);
|
||||
let inner_height = inner.height.max(1) as usize;
|
||||
let total_lines = app.history.len();
|
||||
let max_top = total_lines.saturating_sub(inner_height);
|
||||
let terminal_scroll = app.terminal_scroll.min(max_top);
|
||||
let scroll_top = max_top.saturating_sub(terminal_scroll);
|
||||
let paragraph = Paragraph::new(Text::from_iter(lines))
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: true })
|
||||
.scroll((scroll_top.min(u16::MAX as usize) as u16, 0));
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
paragraph.render(area, buffer);
|
||||
}
|
||||
|
||||
fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
|
||||
frame.render_widget(&app.input, area);
|
||||
if area.height > 0 && area.width > 0 {
|
||||
let cursor = app.input.cursor();
|
||||
let inner = match app.input.block() {
|
||||
Some(block) => block.inner(area),
|
||||
None => area,
|
||||
};
|
||||
let x = inner.x.saturating_add(cursor.1 as u16);
|
||||
let y = inner.y.saturating_add(cursor.0 as u16);
|
||||
if x < inner.x.saturating_add(inner.width) && y < inner.y.saturating_add(inner.height) {
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
fn render_input(buffer: &mut Buffer, area: Rect, app: &mut AppState) -> Option<(u16, u16)> {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return None;
|
||||
}
|
||||
app.input.render(area, buffer);
|
||||
let cursor = app.input.cursor();
|
||||
let inner = match app.input.block() {
|
||||
Some(block) => block.inner(area),
|
||||
None => area,
|
||||
};
|
||||
let x = inner.x.saturating_add(cursor.1 as u16);
|
||||
let y = inner.y.saturating_add(cursor.0 as u16);
|
||||
if x < inner.x.saturating_add(inner.width) && y < inner.y.saturating_add(inner.height) {
|
||||
Some((x, y))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn render_completions(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
|
||||
fn render_completions(buffer: &mut Buffer, area: Rect, app: &mut AppState) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -620,10 +698,10 @@ fn render_completions(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
|
||||
state.select(Some(app.completions.selected()));
|
||||
}
|
||||
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
StatefulWidget::render(list, area, buffer, &mut state);
|
||||
}
|
||||
|
||||
fn render_status(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
|
||||
fn render_status(buffer: &mut Buffer, area: Rect, app: &AppState) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -638,7 +716,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
|
||||
),
|
||||
]));
|
||||
|
||||
frame.render_widget(status, area);
|
||||
status.render(area, buffer);
|
||||
}
|
||||
|
||||
struct TerminalGuard {
|
||||
|
||||
Reference in New Issue
Block a user