@@ -11,28 +11,29 @@ use crossterm::{
11 KeyEvent, KeyEventKind, KeyModifiers,
11 KeyEvent, KeyEventKind, KeyModifiers,
14− terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
14+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
16use futures::StreamExt;
16use futures::StreamExt;
17use ratatui::{
17use ratatui::{
19− backend::CrosstermBackend,
18+ backend::CrosstermBackend, buffer::Buffer,
20 layout::{Constraint, Direction, Layout, Rect},
19 layout::{Constraint, Direction, Layout, Rect},
21 style::{Color, Style},
20 style::{Color, Style},
22 text::{Line, Span, Text},
21 text::{Line, Span, Text},
23− widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
22+ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap},
25use tui_textarea::{Input, Key, TextArea};
26use tui_textarea::{Input, Key, TextArea};
26use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
27use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
28const ASCII_BANNER: [&str; 7] = [
29const ASCII_BANNER: [&str; 7] = [
29− "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░",
30+ "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
30− "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
31+ "██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
31− " ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
32+ "██║ ██║██║██████╔╝█████╗ ██████╔╝██║ ██║ ╚███╔╝",
32− " ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓██████▓▒░",
33+ "╚██╗ ██╔╝██║██╔══██╗██╔══╝ ██╔══██╗██║ ██║ ██╔██╗",
33− " ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
34+ " ╚████╔╝ ██║██████╔╝███████╗██████╔╝╚██████╔╝██╔╝ ██╗",
34− " ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
35+ " ╚═══╝ ╚═╝╚═════╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
35− " ░▒▓██▓▒░ ░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░",
36+ "",
38const STATUS_BAR_HEIGHT: u16 = 1;
39const STATUS_BAR_HEIGHT: u16 = 1;
@@ -57,7 +58,7 @@ pub struct AppState {
57 key_input_mode: KeyInputMode,
58 key_input_mode: KeyInputMode,
58 tick: u64,
59 tick: u64,
59 spinner: usize,
60 spinner: usize,
60− terminal_scroll: usize,
61+ page_scroll: usize,
61 input_view_width: u16,
62 input_view_width: u16,
@@ -75,7 +76,7 @@ impl AppState {
75 key_input_mode: KeyInputMode::Unknown,
76 key_input_mode: KeyInputMode::Unknown,
77 spinner: 0,
78 spinner: 0,
78− terminal_scroll: 0,
79+ page_scroll: 0,
79 input_view_width: 0,
80 input_view_width: 0,
@@ -256,6 +257,7 @@ impl CompletionState {
259#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
260pub struct LayoutAreas {
262pub struct LayoutAreas {
261 pub header: Rect,
263 pub header: Rect,
@@ -265,6 +267,17 @@ pub struct LayoutAreas {
265 pub status: Rect,
267 pub status: Rect,
270+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
268pub fn compute_layout(
281pub fn compute_layout(
269 area: Rect,
282 area: Rect,
270 input_height: u16,
283 input_height: u16,
@@ -310,6 +323,62 @@ pub fn compute_layout(
326+fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
327+ let header_height = header_height();
328+ let terminal_height = terminal_height(app, width);
329+ let input_height = app.input_height_for_width(width);
330+ let (completion_height, status_height) = if app.completions.is_active() {
331+ let desired = (app.completions.items().len() as u16).min(COMPLETIONS_MAX_HEIGHT);
332+ (desired.saturating_add(2), 0)
334+ (0, STATUS_BAR_HEIGHT)
337+ let total_height = header_height
338+ .saturating_add(terminal_height)
339+ .saturating_add(input_height)
340+ .saturating_add(completion_height)
341+ .saturating_add(status_height)
345+ let header = Rect::new(0, y, width, header_height);
346+ y = y.saturating_add(header_height);
347+ let terminal = Rect::new(0, y, width, terminal_height);
348+ y = y.saturating_add(terminal_height);
349+ let input = Rect::new(0, y, width, input_height);
350+ y = y.saturating_add(input_height);
351+ 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);
365+fn terminal_height(app: &AppState, width: u16) -> u16 {
369+ let lines: Vec<Line> = app
372+ .map(|line| Line::from(line.as_str()))
374+ let block = Block::default().borders(Borders::ALL).title("Terminal");
375+ let paragraph = Paragraph::new(Text::from_iter(lines))
377+ .wrap(Wrap { trim: true });
378+ let count = paragraph.line_count(width).max(1);
379+ (count.min(u16::MAX as usize)) as u16
313fn header_height() -> u16 {
382fn header_height() -> u16 {
314 let banner_height = ASCII_BANNER.len() as u16;
383 let banner_height = ASCII_BANNER.len() as u16;
315 let welcome_height = 1;
384 let welcome_height = 1;
@@ -384,23 +453,15 @@ fn handle_key_event(key: KeyEvent, app: &mut AppState) {
386 match key.code {
455 match key.code {
387− KeyCode::PageUp => {
456+ KeyCode::PageUp => {}
388− app.terminal_scroll = app.terminal_scroll.saturating_add(10);
390− KeyCode::PageDown => {
457+ KeyCode::PageDown => {}
391− app.terminal_scroll = app.terminal_scroll.saturating_sub(10);
393− KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
458+ KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {}
394− app.terminal_scroll = app.terminal_scroll.saturating_add(1);
396− KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
459+ KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {}
397− app.terminal_scroll = app.terminal_scroll.saturating_sub(1);
399 KeyCode::Enter => {
460 KeyCode::Enter => {
400 let message = app.take_input_text();
461 let message = app.take_input_text();
401 if !message.trim().is_empty() {
462 if !message.trim().is_empty() {
402 app.push_history(format!("> {}", message));
463 app.push_history(format!("> {}", message));
403− app.terminal_scroll = 0;
464+ app.page_scroll = usize::MAX;
406 KeyCode::Tab => app.toggle_completions(default_completions()),
467 KeyCode::Tab => app.toggle_completions(default_completions()),
@@ -421,10 +482,10 @@ fn handle_mouse_event(event: crossterm::event::MouseEvent, app: &mut AppState) {
421 use crossterm::event::MouseEventKind;
482 use crossterm::event::MouseEventKind;
422 match event.kind {
483 match event.kind {
423 MouseEventKind::ScrollUp => {
484 MouseEventKind::ScrollUp => {
424− app.terminal_scroll = app.terminal_scroll.saturating_add(3);
485+ app.page_scroll = app.page_scroll.saturating_sub(3);
426 MouseEventKind::ScrollDown => {
487 MouseEventKind::ScrollDown => {
427− app.terminal_scroll = app.terminal_scroll.saturating_sub(3);
488+ app.page_scroll = app.page_scroll.saturating_add(3);
@@ -491,27 +552,49 @@ fn default_completions() -> Vec<String> {
493fn render(frame: &mut Frame<'_>, app: &mut AppState) {
554fn render(frame: &mut Frame<'_>, app: &mut AppState) {
494− let area = frame.area();
555+ let viewport = frame.area();
556+ if viewport.width == 0 || viewport.height == 0 {
495− app.input_view_width = app.input_inner_width(area.width);
560+ app.input_view_width = app.input_inner_width(viewport.width);
496− let layout = compute_layout(
561+ let layout = compute_page_layout(app, viewport.width);
497− area,
562+ let content_height = layout.total_height.max(1);
498− app.input_height_for_width(area.width),
563+
499− app.completions.items().len(),
564+ let mut buffer = Buffer::empty(Rect::new(0, 0, viewport.width, content_height));
500− app.completions.is_active(),
503− render_header(frame, layout.header, app);
565+ render_header(&mut buffer, layout.header, app);
504− render_terminal(frame, layout.terminal, app);
566+ render_terminal(&mut buffer, layout.terminal, app);
505− render_input(frame, layout.input, app);
567+ let cursor_pos = render_input(&mut buffer, layout.input, app);
507 if app.completions.is_active() {
569 if app.completions.is_active() {
508− render_completions(frame, layout.completions, app);
570+ render_completions(&mut buffer, layout.completions, app);
510− render_status(frame, layout.status, app);
572+ render_status(&mut buffer, layout.status, app);
575+ let max_scroll = content_height.saturating_sub(viewport.height);
576+ app.page_scroll = app.page_scroll.min(max_scroll as usize);
577+ let scroll = app.page_scroll as u16;
579+ let view = frame.buffer_mut();
580+ for y in 0..viewport.height {
581+ let src_y = scroll.saturating_add(y);
582+ if src_y >= content_height {
585+ for x in 0..viewport.width {
586+ view[(x, y)] = buffer[(x, src_y)].clone();
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));
514−fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
597+fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
515 if area.height == 0 {
598 if area.height == 0 {
@@ -530,13 +613,10 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
530 Span::styled(&app.vm_info.version, Style::default().fg(Color::Yellow)),
613 Span::styled(&app.vm_info.version, Style::default().fg(Color::Yellow)),
533− frame.render_widget(Paragraph::new(welcome), header_chunks[0]);
616+ Paragraph::new(welcome).render(header_chunks[0], buffer);
535 let banner_lines = ASCII_BANNER.iter().map(|line| Line::from(*line));
618 let banner_lines = ASCII_BANNER.iter().map(|line| Line::from(*line));
536− frame.render_widget(
537− Paragraph::new(Text::from_iter(banner_lines)),
619+ Paragraph::new(Text::from_iter(banner_lines)).render(header_chunks[1], buffer);
541 let info_block = Block::default().borders(Borders::ALL).title("Session");
621 let info_block = Block::default().borders(Borders::ALL).title("Session");
542 let info_lines = vec![
622 let info_lines = vec![
@@ -560,46 +640,44 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
563− frame.render_widget(
564− Paragraph::new(info_lines).block(info_block),
643+ Paragraph::new(info_lines)
565− header_chunks[2],
645+ .render(header_chunks[2], buffer);
569−fn render_terminal(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
648+fn render_terminal(buffer: &mut Buffer, area: Rect, app: &AppState) {
649+ if area.height == 0 {
570 let lines = app.history.iter().map(|line| Line::from(line.as_str()));
652 let lines = app.history.iter().map(|line| Line::from(line.as_str()));
571 let block = Block::default().borders(Borders::ALL).title("Terminal");
653 let block = Block::default().borders(Borders::ALL).title("Terminal");
572− let inner = block.inner(area);
573− let inner_height = inner.height.max(1) as usize;
574− let total_lines = app.history.len();
575− let max_top = total_lines.saturating_sub(inner_height);
576− let terminal_scroll = app.terminal_scroll.min(max_top);
577− let scroll_top = max_top.saturating_sub(terminal_scroll);
578 let paragraph = Paragraph::new(Text::from_iter(lines))
654 let paragraph = Paragraph::new(Text::from_iter(lines))
579 .block(block)
655 .block(block)
580− .wrap(Wrap { trim: true })
656+ .wrap(Wrap { trim: true });
581− .scroll((scroll_top.min(u16::MAX as usize) as u16, 0));
583− frame.render_widget(paragraph, area);
658+ paragraph.render(area, buffer);
586−fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
661+fn render_input(buffer: &mut Buffer, area: Rect, app: &mut AppState) -> Option<(u16, u16)> {
587− frame.render_widget(&app.input, area);
588− if area.height > 0 && area.width > 0 {
662+ if area.height == 0 || area.width == 0 {
665+ app.input.render(area, buffer);
589− let cursor = app.input.cursor();
666+ let cursor = app.input.cursor();
590− let inner = match app.input.block() {
667+ let inner = match app.input.block() {
591− Some(block) => block.inner(area),
668+ Some(block) => block.inner(area),
592− None => area,
669+ None => area,
594− let x = inner.x.saturating_add(cursor.1 as u16);
671+ let x = inner.x.saturating_add(cursor.1 as u16);
595− let y = inner.y.saturating_add(cursor.0 as u16);
672+ let y = inner.y.saturating_add(cursor.0 as u16);
596− if x < inner.x.saturating_add(inner.width) && y < inner.y.saturating_add(inner.height) {
673+ if x < inner.x.saturating_add(inner.width) && y < inner.y.saturating_add(inner.height) {
597− frame.set_cursor_position((x, y));
674+ Some((x, y))
602−fn render_completions(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
680+fn render_completions(buffer: &mut Buffer, area: Rect, app: &mut AppState) {
603 if area.height == 0 {
681 if area.height == 0 {
@@ -620,10 +698,10 @@ fn render_completions(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
620 state.select(Some(app.completions.selected()));
698 state.select(Some(app.completions.selected()));
623− frame.render_stateful_widget(list, area, &mut state);
701+ StatefulWidget::render(list, area, buffer, &mut state);
626−fn render_status(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
704+fn render_status(buffer: &mut Buffer, area: Rect, app: &AppState) {
627 if area.height == 0 {
705 if area.height == 0 {
@@ -638,7 +716,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
641− frame.render_widget(status, area);
719+ status.render(area, buffer);
644struct TerminalGuard {
722struct TerminalGuard {