Rendering Pipeline

This page documents how the TUI renders frames to the terminal. The rendering pipeline computes layouts, renders widgets, and manages the terminal buffer.

Pipeline Overview

┌─────────────────────────────────────────────────────────────────┐
│                    Frame Rendering                               │
├─────────────────────────────────────────────────────────────────┤
│  1. Build layout context                                         │
│  2. Compute widget sizes                                         │
│  3. Apply layout template                                        │
│  4. Update widget data                                           │
│  5. Render widgets in order                                      │
│  6. Render input/throbber                                        │
│  7. Render overlays                                              │
│  8. Set cursor position                                          │
└─────────────────────────────────────────────────────────────────┘

Terminal Drawing

The ratatui Terminal::draw() method handles buffer management:

terminal.draw(|frame| {
    self.render_frame(frame, show_throbber, prompt_len, indent_len);
})?;

The Frame object represents the current screen state. All rendering calls update a buffer that is flushed when the closure completes.

Frame Rendering

The main render function:

fn render_frame(
    &mut self,
    frame: &mut ratatui::Frame,
    show_throbber: bool,
    prompt_len: usize,
    indent_len: usize,
) {
    let frame_area = frame.area();
    let theme = theme();

    // 1. Build layout context
    let ctx = LayoutContext {
        frame_area,
        show_throbber,
        input_visual_lines: self.calculate_input_lines(),
        theme: &theme,
        active_widgets: self.active_widget_set(),
    };

    // 2. Compute widget sizes
    let sizes = self.compute_widget_sizes(frame_area.height);

    // 3. Apply layout template
    let layout = self.layout_template.compute(&ctx, &sizes);

    // 4. Update status bar data
    self.update_status_bar_data();

    // 5. Render widgets in order
    for widget_id in &layout.render_order {
        self.render_widget(frame, widget_id, &layout, &theme);
    }

    // 6. Render input or throbber
    if let Some(input_area) = layout.input_area {
        if show_throbber {
            self.render_throbber(frame, input_area, &theme);
        } else {
            self.render_input(frame, input_area, &theme);
        }
    }

    // 7. Render overlays
    self.render_overlays(frame, frame_area, &theme);

    // 8. Set cursor position
    if !show_throbber && !self.overlay_active() {
        let (x, y) = self.calculate_cursor_position(&layout);
        frame.set_cursor_position((x, y));
    }
}

Layout Context

Context passed to the layout system:

pub struct LayoutContext<'a> {
    pub frame_area: Rect,
    pub show_throbber: bool,
    pub input_visual_lines: usize,
    pub theme: &'a Theme,
    pub active_widgets: HashSet<&'static str>,
}

Widget Sizes

Computed sizes for layout:

pub struct WidgetSizes {
    pub heights: HashMap<&'static str, u16>,
    pub is_active: HashMap<&'static str, bool>,
}

fn compute_widget_sizes(&self, available_height: u16) -> WidgetSizes {
    let mut heights = HashMap::new();
    let mut is_active = HashMap::new();

    for (id, widget) in &self.widgets {
        is_active.insert(*id, widget.is_active());
        if widget.is_active() {
            heights.insert(*id, widget.required_height(available_height));
        }
    }

    WidgetSizes { heights, is_active }
}

Layout Template

The layout template computes widget positions:

pub trait LayoutProvider: Send + Sync + 'static {
    fn compute(&self, ctx: &LayoutContext, sizes: &WidgetSizes) -> LayoutResult;
}

pub struct LayoutResult {
    pub widget_areas: HashMap<&'static str, Rect>,
    pub render_order: Vec<&'static str>,
    pub input_area: Option<Rect>,
}

Standard Layout

The default layout arranges widgets vertically:

pub fn compute(ctx: &LayoutContext, sizes: &WidgetSizes) -> LayoutResult {
    // Calculate heights
    let input_height = if ctx.show_throbber { 3 } else { ctx.input_visual_lines + 2 };
    let panel_height = calculate_panel_height(sizes);
    let popup_height = calculate_popup_height(sizes);
    let status_bar_height = sizes.height(STATUS_BAR);

    // Build constraints
    let constraints = vec![
        Constraint::Min(MIN_MAIN_HEIGHT),    // Chat view
        Constraint::Length(panel_height),     // Panels
        Constraint::Length(popup_height),     // Popups
        Constraint::Length(input_height),     // Input
        Constraint::Length(status_bar_height), // Status bar
    ];

    // Apply layout
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints(constraints)
        .split(ctx.frame_area);

    // Map chunks to widget areas
    let mut widget_areas = HashMap::new();
    widget_areas.insert(CHAT_VIEW, chunks[0]);
    // ... assign other areas

    LayoutResult {
        widget_areas,
        render_order: vec![CHAT_VIEW, PERMISSION_PANEL, QUESTION_PANEL, SLASH_POPUP, STATUS_BAR],
        input_area: Some(chunks[3]),
    }
}

Layout Structure

┌─────────────────────────────────────────┐
│              Chat View                   │  Min height, flexible
├─────────────────────────────────────────┤
│         Permission/Question Panel        │  Fixed height when active
├─────────────────────────────────────────┤
│            Slash Popup                   │  Fixed height when active
├─────────────────────────────────────────┤
│          Input / Throbber               │  Dynamic height
├─────────────────────────────────────────┤
│            Status Bar                    │  Fixed height (2 lines)
└─────────────────────────────────────────┘

Widget Rendering

Widgets are rendered based on their type:

fn render_widget(
    &mut self,
    frame: &mut Frame,
    widget_id: &str,
    layout: &LayoutResult,
    theme: &Theme,
) {
    let area = match layout.widget_areas.get(widget_id) {
        Some(area) => *area,
        None => return,
    };

    match widget_id {
        CHAT_VIEW => {
            let pending = self.get_pending_status();
            self.conversation_view.render(frame, area, theme, pending);
        }
        SLASH_POPUP => {
            if let Some(popup) = self.widget::<SlashPopupState>(SLASH_POPUP) {
                if popup.is_active() {
                    render_slash_popup(popup, &self.commands, frame, area, theme);
                }
            }
        }
        _ => {
            if let Some(widget) = self.widgets.get_mut(widget_id) {
                if widget.is_active() {
                    widget.render(frame, area, theme);
                }
            }
        }
    }
}

Render Order

Widgets are rendered in a specific order:

  1. Main content (ChatView) - Bottom layer
  2. Panels (PermissionPanel, QuestionPanel) - Above chat
  3. Popups (SlashPopup) - Above panels
  4. Input/Throbber - Above popups
  5. Status bar - Bottom of screen
  6. Overlays (ThemePicker, SessionPicker) - Always on top

Overlay Rendering

Overlays are rendered last, covering everything:

fn render_overlays(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
    if let Some(picker) = self.widget::<ThemePickerState>(THEME_PICKER) {
        if picker.is_active() {
            render_theme_picker(picker, frame, area);
        }
    }

    if let Some(picker) = self.widget::<SessionPickerState>(SESSION_PICKER) {
        if picker.is_active() {
            render_session_picker(picker, frame, area, theme);
        }
    }
}

Input Rendering

The input widget handles text display and cursor:

fn render_input(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(theme.input_border)
        .title(" Input ");

    let inner = block.inner(area);
    frame.render_widget(block, area);

    let lines = self.wrap_input_text(inner.width as usize);
    let paragraph = Paragraph::new(lines)
        .style(theme.input_text);

    frame.render_widget(paragraph, inner);
}

Throbber Rendering

The throbber shows processing state:

fn render_throbber(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
    let message = self.get_throbber_message();
    let spinner = self.throbber_state.current_frame();

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(theme.throbber_border);

    let inner = block.inner(area);
    frame.render_widget(block, area);

    let text = format!("{} {}", spinner, message);
    let paragraph = Paragraph::new(text)
        .style(theme.throbber_text);

    frame.render_widget(paragraph, inner);
}

Cursor Positioning

The cursor is positioned after all rendering:

fn calculate_cursor_position(&self, layout: &LayoutResult) -> (u16, u16) {
    let input_area = layout.input_area.unwrap();
    let inner = Block::default().borders(Borders::ALL).inner(input_area);

    // Calculate cursor position within input
    let (line, col) = self.input_cursor_position(inner.width as usize);

    (inner.x + col as u16, inner.y + line as u16)
}

Theme Integration

All widgets receive the current theme:

let theme = theme(); // Global theme accessor

// Pass to widgets
widget.render(frame, area, &theme);

// Use in styling
let style = Style::default()
    .fg(theme.text_color)
    .bg(theme.background);

Performance Optimization

Minimal Redraws

Ratatui uses differential updates - only changed cells are written to the terminal.

Area Clipping

Widgets only render within their assigned area:

frame.render_widget(paragraph, area); // Auto-clipped to area

Lazy Computation

Layout is computed once per frame:

let layout = self.layout_template.compute(&ctx, &sizes);
// Reused for all widget rendering

Next Steps