diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-17 16:39:37 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-17 16:39:37 -0700 |
| commit | 2ef3d38ab316e67ae57f24c281dfe8614f8670d6 (patch) | |
| tree | ac04d9c75e291ed6558ce21aa827d5734873510c /crates/atuin-ai/src/tui/render.rs | |
| parent | feat: Report distro name with OS for distro-specific commands (#3289) (diff) | |
| download | atuin-2ef3d38ab316e67ae57f24c281dfe8614f8670d6.zip | |
chore: Replace atuin-ai rendering with component-oriented system (#3288)
Diffstat (limited to 'crates/atuin-ai/src/tui/render.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/render.rs | 515 |
1 files changed, 18 insertions, 497 deletions
diff --git a/crates/atuin-ai/src/tui/render.rs b/crates/atuin-ai/src/tui/render.rs index 9326b0df..9b514ccf 100644 --- a/crates/atuin-ai/src/tui/render.rs +++ b/crates/atuin-ai/src/tui/render.rs @@ -3,35 +3,22 @@ use pulldown_cmark::{Event, Parser, Tag, TagEnd}; use ratatui::{ Frame, backend::FromCrossterm, - layout::{Alignment, Constraint, Direction, Layout, Rect}, + layout::{Alignment, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block as RatatuiBlock, Borders, Padding, Paragraph, Wrap}, + widgets::{Block as RatatuiBlock, Borders, Padding}, }; -use tui_textarea::TextArea; +use super::component::Component; +pub use super::component::RenderContext; +use super::components::build_component_tree; use super::spinner::active_frame; use super::state::AppState; -use super::view_model::{Blocks, Content, WarningKind}; +use super::view_model::Blocks; /// Fixed card width for the TUI pub(crate) const CARD_WIDTH: u16 = 64; -pub struct RenderContext<'a> { - pub theme: &'a Theme, - pub anchor_col: u16, - pub textarea: Option<&'a TextArea<'static>>, - /// Maximum viewport height (for scroll calculations) - pub max_height: u16, - /// When true, the viewport is a fixed rect already positioned for the card. - /// The card fills the entire viewport instead of positioning via anchor_col. - pub popup_mode: bool, - /// When true, blocks are rendered in reverse order so that the input field - /// appears at the bottom of the card (close to the prompt when the popup - /// is above the cursor). - pub render_above: bool, -} - /// Calculate the height needed to render the current state. /// Used to dynamically resize the viewport before rendering. /// `card_width` is the outer card width (including borders); pass 0 to use CARD_WIDTH default. @@ -42,20 +29,13 @@ pub fn calculate_needed_height(state: &AppState, card_width: u16) -> u16 { } else { CARD_WIDTH }; - let content_width = usize::from(w.saturating_sub(4)).max(1); + let content_width = w.saturating_sub(4).max(1); - let mut total_height = 0u16; - for (idx, block) in view.items.iter().enumerate() { - if idx > 0 { - total_height = total_height.saturating_add(1); // separator - total_height = total_height.saturating_add(1); // leading blank after separator - } - total_height = - total_height.saturating_add(calculate_block_height(&block.content, content_width)); - } + let items: Vec<_> = view.items.iter().collect(); + let tree = build_component_tree(&items, w); // Add borders (2) + top padding (1), minimum 5 - total_height.saturating_add(3).max(5) + tree.height(content_width).saturating_add(3).max(5) } /// Main render function: derives view model from state, then renders it @@ -89,7 +69,6 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { let preferred_x = full_area.x + ctx.anchor_col.saturating_sub(2); (full_area, preferred_x.min(max_x), dw) }; - let content_width = usize::from(desired_width.saturating_sub(4)).max(1); // Build ordered items list — the active content (input/LLM response) // should always be closest to the cursor/prompt: @@ -102,20 +81,11 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { view.items.iter().collect() }; - // Calculate height from view model - let mut total_height = 0u16; - for (idx, block) in items.iter().enumerate() { - if idx > 0 { - total_height = total_height.saturating_add(1); // separator - total_height = total_height.saturating_add(1); // leading blank after separator - } - total_height = - total_height.saturating_add(calculate_block_height(&block.content, content_width)); - } + // Build component tree from view model + let mut tree = build_component_tree(&items, desired_width); + let content_width = desired_width.saturating_sub(4).max(1); - let desired_height = total_height - .saturating_add(3) // borders (2) + top padding (1), no bottom padding - .max(5); + let desired_height = tree.height(content_width).saturating_add(3).max(5); // Cap card height at viewport height to prevent overflow let actual_height = desired_height.min(area.height); @@ -124,7 +94,7 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { // When render_above=false (popup below cursor), items are reversed so the active // content (input/spinner) is at the top — scroll_offset stays 0 to show the top. // Otherwise, scroll to show the bottom where the active content lives. - let scroll_offset = if ctx.popup_mode && !ctx.render_above { + tree.scroll_offset = if ctx.popup_mode && !ctx.render_above { 0 } else { desired_height.saturating_sub(actual_height) @@ -164,460 +134,11 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { let inner_area = outer_block.inner(card); frame.render_widget(outer_block, card); - // Render blocks (with scroll offset for overflowing content) - render_blocks_content(frame, &items, ctx, inner_area, card.width, scroll_offset); -} - -fn render_blocks_content( - frame: &mut Frame, - items: &[&super::view_model::Block], - ctx: &RenderContext, - area: Rect, - card_width: u16, - scroll_offset: u16, -) { - let content_width = usize::from(area.width).max(1); - - // Build layout constraints for full content - let mut constraints = Vec::new(); - let mut block_heights = Vec::new(); - for (idx, block) in items.iter().enumerate() { - if idx > 0 { - constraints.push(Constraint::Length(1)); // separator - constraints.push(Constraint::Length(1)); // leading blank after separator - block_heights.push(1); - block_heights.push(1); - } - let height = calculate_block_height(&block.content, content_width); - constraints.push(Constraint::Length(height)); - block_heights.push(height); - } - - if constraints.is_empty() { - return; - } - - // Calculate cumulative heights to find which blocks are visible after scrolling - let mut cumulative: Vec<u16> = Vec::with_capacity(block_heights.len() + 1); - cumulative.push(0); - for h in &block_heights { - cumulative.push(cumulative.last().unwrap() + h); - } - - // Render each chunk, offsetting by scroll_offset and clipping to visible area - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(area); - - let mut chunk_idx = 0; - for (idx, block) in items.iter().enumerate() { - if idx > 0 { - // Check if separator is visible (its position minus scroll_offset) - let sep_start = cumulative[chunk_idx]; - if sep_start >= scroll_offset && sep_start < scroll_offset + area.height { - let adjusted_chunk = Rect { - y: area.y + sep_start - scroll_offset, - ..chunks[chunk_idx] - }; - render_separator(frame, adjusted_chunk, ctx, card_width); - } - chunk_idx += 1; - chunk_idx += 1; // skip leading blank - } - - // Check if this block is at least partially visible - let block_start = cumulative[chunk_idx]; - let block_end = cumulative[chunk_idx + 1]; - - // Block is visible if it starts before viewport end and ends after viewport start - if block_start < scroll_offset + area.height && block_end > scroll_offset { - // Calculate visible portion - let visible_start = block_start.max(scroll_offset); - let visible_end = block_end.min(scroll_offset + area.height); - - let adjusted_chunk = Rect { - x: area.x, - y: area.y + visible_start - scroll_offset, - width: area.width, - height: visible_end - visible_start, - }; - - render_block_content(frame, &block.content, adjusted_chunk, ctx); - } - - chunk_idx += 1; - } -} - -/// Render all content items in a block -fn render_block_content(frame: &mut Frame, content: &[Content], area: Rect, ctx: &RenderContext) { - if content.is_empty() { - return; - } - - let content_width = usize::from(area.width).max(1); - - // Build layout constraints for each content item WITH spacing between items - let mut constraints = Vec::new(); - for (idx, c) in content.iter().enumerate() { - if idx > 0 { - constraints.push(Constraint::Length(1)); // blank line between items - } - constraints.push(Constraint::Length(calculate_single_content_height( - c, - content_width, - ))); - } - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(area); - - let mut chunk_idx = 0; - for (idx, item) in content.iter().enumerate() { - if idx > 0 { - chunk_idx += 1; // skip the blank line chunk - } - render_single_content(frame, item, chunks[chunk_idx], ctx); - chunk_idx += 1; - } -} - -/// Render a single content item using ratatui's native wrapping. -/// Symbol is rendered at column 0, text wraps in columns 2+ (offset area). -fn render_single_content(frame: &mut Frame, content: &Content, area: Rect, ctx: &RenderContext) { - // Helper to create offset text area (2 chars for symbol column) - let text_area = Rect { - x: area.x.saturating_add(2), - y: area.y, - width: area.width.saturating_sub(2), - height: area.height, - }; - - match content { - Content::Input { text, active, .. } => { - let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Guidance)); - let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); - - // Render ">" symbol at column 0 - render_symbol(frame, ">", symbol_style, area); - - if *active { - // Active input: render TextArea widget (handles cursor display) - if let Some(textarea) = ctx.textarea { - frame.render_widget(textarea, text_area); - } - } else { - // Inactive input: render as plain paragraph - let paragraph = Paragraph::new(text.as_str()) - .style(text_style) - .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, text_area); - } - } - - Content::Command { text, faded } => { - let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Important)); - let mut text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); - if *faded { - text_style = text_style.add_modifier(Modifier::DIM); - } - - render_symbol(frame, "$", symbol_style, area); - - let paragraph = Paragraph::new(text.as_str()) - .style(text_style) - .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, text_area); - } - - Content::Text { markdown } => { - // No symbol, just indent - render directly in offset area - let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); - - let paragraph = Paragraph::new(markdown.as_str()) - .style(text_style) - .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, text_area); - } - - Content::Error { message } => { - let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::AlertError)); - let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); - - render_symbol(frame, "!", symbol_style, area); - - let paragraph = Paragraph::new(message.as_str()) - .style(text_style) - .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, text_area); - } - - Content::Warning { - kind, - text, - pending_confirm, - } => { - let (symbol, meaning) = match kind { - WarningKind::Danger => ("!", Meaning::AlertError), - WarningKind::LowConfidence => ("?", Meaning::AlertWarn), - }; - let symbol_style = Style::from_crossterm(ctx.theme.as_style(meaning)); - let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); - - let display_text = if *pending_confirm { - "Press Enter again to run this dangerous command" - } else { - text.as_str() - }; - - render_symbol(frame, symbol, symbol_style, area); - - let paragraph = Paragraph::new(display_text) - .style(text_style) - .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, text_area); - } - - Content::Spinner { - frame: spinner_frame, - status_text, - } => { - let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); - let symbol = active_frame(*spinner_frame); - - render_symbol(frame, symbol, style, area); - - let paragraph = Paragraph::new(status_text.as_str()).style(style); - frame.render_widget(paragraph, text_area); - } - - Content::ToolStatus { - completed_count, - current_label, - frame: spinner_frame, - } => { - let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); - - let (symbol, text) = if let Some(label) = current_label { - let spinner = active_frame(*spinner_frame); - let text = if *completed_count > 0 { - format!( - "{} (used {} tool{})", - label, - completed_count, - if *completed_count == 1 { "" } else { "s" } - ) - } else { - label.clone() - }; - (spinner, text) - } else { - ( - "\u{2713}", - format!( - "Used {} tool{}", - completed_count, - if *completed_count == 1 { "" } else { "s" } - ), - ) - }; - - render_symbol(frame, symbol, style, area); - - let paragraph = Paragraph::new(text).style(style); - frame.render_widget(paragraph, text_area); - } - } -} - -/// Render a single-character symbol at the start of an area -fn render_symbol(frame: &mut Frame, symbol: &str, style: Style, area: Rect) { - let symbol_area = Rect { - x: area.x, - y: area.y, - width: 1, - height: 1, - }; - frame.render_widget(Paragraph::new(symbol).style(style), symbol_area); -} - -fn render_separator(frame: &mut Frame, area: Rect, ctx: &RenderContext, card_width: u16) { - let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Muted)); - - // Build separator: ├ + ─ repeated + ┤ spanning the full card width - // -2 for the ├ and ┤ characters themselves - let inner_width = card_width.saturating_sub(2) as usize; - let separator = format!( - "\u{251c}{}\u{2524}", // ├ ... ┤ - "\u{2500}".repeat(inner_width) // ─ - ); - - let paragraph = Paragraph::new(Span::styled(separator, style)); - - // Render at x offset to overlap the border (area is inside padding, border is 2 chars left) - let sep_area = Rect { - x: area.x.saturating_sub(2), // move left to overlap left border - y: area.y, - width: card_width, - height: 1, - }; - frame.render_widget(paragraph, sep_area); -} - -/// Calculate total height for all content items in a block -fn calculate_block_height(content: &[Content], width: usize) -> u16 { - let content_height: u16 = content - .iter() - .map(|c| calculate_single_content_height(c, width)) - .sum(); - - // Add spacing between items (n-1 blank lines for n items) - let spacing = if content.len() > 1 { - (content.len() - 1) as u16 - } else { - 0 - }; - - // Add 1 for trailing blank line (padding after content) - content_height.saturating_add(spacing).saturating_add(1) -} - -/// Calculate height for a single content item. -/// Uses ratatui's Paragraph::line_count for consistency with rendering. -fn calculate_single_content_height(content: &Content, width: usize) -> u16 { - // Text area is offset by 2 for symbol column - let text_width = width.saturating_sub(2); - - match content { - // Input uses word wrapping (WrapMode::Word) in TextArea, which can produce - // more lines than character wrapping since it won't break words mid-word - Content::Input { text, active, .. } => { - if *active { - // For active input, use word-wrap line counting to match TextArea behavior - let (lines, last_line_width) = - word_wrap_line_count_with_last_width(text, text_width); - // Only add extra line for cursor if the last line is full - if last_line_width >= text_width { - lines.saturating_add(1) - } else { - lines - } - } else { - line_count_wrapped(text, text_width) - } - } - Content::Command { text, .. } => line_count_wrapped(text, text_width), - Content::Text { markdown } => line_count_wrapped(markdown, text_width), - Content::Error { message } => line_count_wrapped(message, text_width), - Content::Warning { - text, - pending_confirm, - .. - } => { - let display_text = if *pending_confirm { - "Press Enter again to run this dangerous command" - } else { - text.as_str() - }; - line_count_wrapped(display_text, text_width) - } - Content::Spinner { .. } => 1, - Content::ToolStatus { .. } => 1, - } -} - -/// Count lines when text is wrapped at given width. -/// Uses ratatui's Paragraph::line_count for accurate wrapping calculation. -fn line_count_wrapped(text: &str, width: usize) -> u16 { - if width == 0 { - return 1; - } - - let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); - paragraph.line_count(width as u16).max(1) as u16 -} - -/// Count lines using word-wrap algorithm (matches TextArea's WrapMode::Word). -/// Words won't be broken mid-word, so this may produce more lines than character wrapping. -/// Returns (line_count, last_line_width) so caller can determine if cursor needs extra space. -fn word_wrap_line_count_with_last_width(text: &str, width: usize) -> (u16, usize) { - if width == 0 || text.is_empty() { - return (1, 0); - } - - let mut line_count = 0u16; - let mut current_line_width = 0usize; - - for line in text.lines() { - if line.is_empty() { - line_count += 1; - current_line_width = 0; - continue; - } - - let mut line_started = false; - - for word in line.split_whitespace() { - let word_width = unicode_width::UnicodeWidthStr::width(word); - - if !line_started { - // First word on line - if word_width > width { - // Word is longer than width, it will be split by character - // Count how many lines it takes - line_count += word_width.div_ceil(width) as u16; - current_line_width = word_width % width; - if current_line_width == 0 { - current_line_width = 0; - line_started = false; - } else { - line_started = true; - } - } else { - current_line_width = word_width; - line_started = true; - } - } else { - // Subsequent word - need space before it - let needed = current_line_width + 1 + word_width; - if needed > width { - // Word doesn't fit, start new line - line_count += 1; - if word_width > width { - // Word itself is too long, will be split - line_count += word_width.div_ceil(width) as u16; - current_line_width = word_width % width; - if current_line_width == 0 { - line_started = false; - } - } else { - current_line_width = word_width; - } - } else { - current_line_width = needed; - } - } - } - - // Count the last line of this logical line - if line_started { - line_count += 1; - } - } - - // Handle case where text has no lines() output (empty or just whitespace) - if line_count == 0 { - line_count = 1; - current_line_width = 0; - } - - (line_count, current_line_width) + // Render the component tree + tree.render(frame, inner_area, ctx); } -/// Convert markdown to styled spans (existing function, kept as-is) +/// Convert markdown to styled spans pub fn markdown_to_spans<'a>(text: &'a str, theme: &'a Theme) -> Vec<Line<'a>> { let parser = Parser::new(text); let mut lines: Vec<Vec<Span<'a>>> = vec![Vec::new()]; |
