Skip to content

Latest commit

 

History

History
358 lines (257 loc) · 9.08 KB

File metadata and controls

358 lines (257 loc) · 9.08 KB

Terminal Rendering Best Practices for VT Code

This guide documents rendering patterns and best practices specific to VT Code, based on Ratatui conventions.

Core Principle: Single Draw Per Frame

VT Code follows the Ratatui recipe of rendering everything in a single terminal.draw() closure per frame cycle.

Anti-Pattern: Multiple Draws

loop {
    terminal.draw(|f| {
        f.render_widget(widget1, area);
    })?;
    terminal.draw(|f| {
        f.render_widget(widget2, area);
    })?;
    terminal.draw(|f| {
        f.render_widget(widget3, area);
    })?;
}

Why it fails: Ratatui uses double buffering—only the last draw() call within a frame cycle gets rendered. The first two calls are overwritten.

Correct Pattern: Single Orchestrated Draw

loop {
    terminal.draw(|f| {
        f.render_widget(widget1, area1);
        f.render_widget(widget2, area2);
        f.render_widget(widget3, area3);
    })?;
}

Implementation in VT Code:

File: vtcode-core/src/ui/tui/session.rs

The Session::render() method orchestrates all UI components:

  • Header (top bar with model/status)
  • Navigation pane (left sidebar)
  • Transcript (message history, center)
  • Input area (bottom with user input)
  • Modals/overlays (palettes, search, etc.)

All rendering happens in one frame cycle.

Viewport Management

Single Buffer Concept

Ratatui allocates one rendering buffer for the entire terminal area. When you call terminal.draw(), all widgets write to this buffer. At frame end, diffs are sent to the terminal.

Impact on VT Code:

  1. Overlapping areas are safe - Later renders overwrite earlier ones
  2. Off-screen rendering is safe - Clamped to buffer bounds (mostly)
  3. Partial updates are automatic - Only changed cells redraw

Out-of-Bounds Protection

Ratatui does not prevent panics from rendering outside the buffer. VT Code must defend against this.

Pattern (from Ratatui FAQ):

fn render_ref(&self, area: Rect, buf: &mut Buffer) {
    // Clamp area to buffer bounds before rendering
    let area = area.intersection(buf.area);
    // Now safe to render...
}

Best practices:

  • Use Rect::intersection(other) to clamp to valid regions
  • Use Rect::clamp(constraining_rect) to clamp coordinates
  • Use Rect::columns() and Rect::rows() iterators (safe by design)

Layout Computation

Constraint-Based Layouts

VT Code uses Ratatui's Layout system, which guarantees valid region calculations:

use ratatui::layout::{Constraint, Direction, Layout};

let chunks = Layout::default()
    .direction(Direction::Vertical)
    .constraints([
        Constraint::Length(1),        // Header
        Constraint::Min(1),           // Content
        Constraint::Length(1),        // Input
    ])
    .split(area);

Safety guarantees:

  • Constraint::Length(n) - Exactly n rows/cols
  • Constraint::Percentage(p) - p% of available space
  • Constraint::Min(n) - At least n, fill remaining
  • Constraint::Max(n) - At most n

The Layout algorithm ensures all constraints fit within area.

Manual Coordinate Calculations

If you calculate regions manually, be defensive:

//   Unsafe: Unguarded math can overflow
let width = area.width - 2;
let y = area.top() + some_offset;

//   Safe: Use `saturating_sub()` and `clamp()`
let width = area.width.saturating_sub(2);
let y = (area.top() as u32 + offset).min(area.bottom() as u32) as u16;

Better yet, use iterators:

//   Safest: Iterator-based (can't go out of bounds)
for (i, cell) in f.buffer_mut().content.iter_mut().enumerate() {
    if i < max_items {
        // Safe to write
    }
}

Rendering Widgets

Composition Pattern

VT Code composes widgets hierarchically. Each "pane" renders into its allocated area:

Structure:


 Header: Info, status, theme       Render size: full_width × 1

  Nav  Transcript   Modal   
        (messages)  (if     
                     any)   

 Input bar (user text)             Render size: full_width × 1-3

Implementation:

pub fn render(&mut self, f: &mut Frame) {
    let area = f.area();
    
    // Split into main regions
    let [header_area, body_area, input_area] = Layout::vertical([
        Constraint::Length(1),
        Constraint::Min(1),
        Constraint::Length(3),
    ])
    .areas(area);
    
    // Render each component
    self.render_header(f, header_area);
    self.render_body(f, body_area);      // Handles nav + transcript + modal
    self.render_input(f, input_area);
}

Widget Type Safety

VT Code uses Ratatui's widget trait (Widget) for reusable components. Rendering happens via widget.render() call.

Pattern:

// Stateless widget (implements Widget trait)
impl Widget for MyCustomWidget {
    fn render(self, area: Rect, buf: &mut Buffer) {
        // Write to buf directly
        if area.width < 2 { return; }  // Defensive
        // Safe rendering...
    }
}

// In main render loop:
f.render_widget(my_widget, area);

Reflow and Text Wrapping

Dynamic Text Reflow

VT Code's message transcript must reflow when terminal resizes. This is expensive, so VT Code caches reflowed text:

Pattern (from vtcode-core):

struct Message {
    text: String,
    cached_lines: Mutex<Option<Vec<String>>>,
    cached_width: Mutex<u16>,
}

fn get_reflowed_lines(&self, width: u16) -> Vec<String> {
    let mut cache = self.cached_lines.lock();
    let mut cache_width = self.cached_width.lock();
    
    if *cache_width != width {
        // Reflow needed
        let lines = textwrap::wrap(&self.text, width);
        *cache = Some(lines.clone());
        *cache_width = width;
        return lines;
    }
    
    cache.clone().unwrap_or_default()
}

Handling Terminal Resize

On Event::Resize(w, h):

  1. Clear reflow caches
  2. Recalculate layout constraints
  3. Trigger next Event::Render

VT Code automatically clears caches and re-renders on resize.

Color and Styling

Ratatui Color Model

VT Code uses Ratatui's style system for portable colors:

use ratatui::style::{Color, Modifier, Style};

let style = Style::default()
    .fg(Color::Cyan)
    .bg(Color::Black)
    .add_modifier(Modifier::BOLD);

f.render_widget(
    Paragraph::new("Hello").style(style),
    area
);

Portable color options:

  • Named: Color::Red, Color::Blue, etc.
  • Indexed: Color::Indexed(200) (256-color palette)
  • RGB: Color::Rgb(255, 0, 0) (24-bit color, terminal permitting)

ANSI SGR Code Parsing

VT Code parses terminal ANSI escape codes and converts them to Ratatui styles:

File: vtcode-core/src/ui/tui/style.rs

Converts ANSI SGR codes (like \x1b[1;31m for bold red) to Ratatui Style.

Performance Considerations

Double Buffering Efficiency

Ratatui's double buffer means:

  • Only cells that changed are sent to terminal
  • No "flicker" (frame is complete before display)
  • Terminal I/O is optimized via escape sequence diffing

VT Code optimization:

  • Only send Event::Render at 60 FPS (not on every state change)
  • Batch state updates into Event::Tick (4 Hz)
  • Let Ratatui handle diff logic

Lazy Rendering

VT Code uses the Tick/Render split pattern:

Event::Tick (4 Hz)
   Update internal state (messages, selection, etc.)
  
Event::Render (60 FPS)
   Redraw UI from state (Ratatui handles diffing)

This separates "state updates" from "rendering," making both efficient.

Minimize String Allocations

Text widgets cache strings when possible:

//   Bad: Allocates new string every frame
loop {
    let text = format!("Status: {}", status);
    f.render_widget(Paragraph::new(text), area);
}

//   Good: Reuse string if unchanged
if status_changed {
    self.status_str = format!("Status: {}", status);
}
f.render_widget(Paragraph::new(&self.status_str), area);

Common Rendering Issues

Issue 1: Widget Disappears on Resize

Cause: Hard-coded area sizes (e.g., area.top() + 10 without bounds checking)

Fix: Use Constraint and Layout, not manual offsets.

Issue 2: Text Overlaps at Terminal Edge

Cause: Not clamping to buffer area

Fix: Use area.intersection(buf.area) before rendering.

Issue 3: Panic on Render

Cause: Off-bounds cell access in custom widget

Fix: Add bounds checks and use Rect::intersection().

Testing Rendering

VT Code includes render tests in vtcode-core:

#[test]
fn test_render_header() {
    let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
    
    terminal.draw(|f| {
        let mut session = Session::new();
        session.render_header(f, f.area());
    }).unwrap();
    
    let buffer = terminal.backend().buffer();
    // Assert expected cell content
}

Use TestBackend for offline rendering tests (no terminal needed).

See Also