//! Chat input box and autocomplete dropdown rendering. //! Implements the Component trait. use super::theme::Theme; use crate::components::Component; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use ratatui::Frame; #[derive(Default)] pub struct InputComponent; impl Component for InputComponent { fn draw(&mut self, frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) { if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() { let n = state.input.autocomplete_candidates.len().min(10) as u16; let dropdown_height = n + 2; let dropdown_area = Rect { x: area.x, y: area.y.saturating_sub(dropdown_height), width: area.width.min(45), height: dropdown_height, }; let dropdown_title = match state.input.autocomplete_kind { crate::state::AutocompleteKind::Command => " ⌘ Commands ", crate::state::AutocompleteKind::FileMention => " 📁 Files ", }; let dropdown_block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Theme::BORDER)) .title(Span::styled( dropdown_title, Style::default() .fg(Theme::PRIMARY) .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(Theme::SURFACE_ELEVATED)); let mut lines: Vec = Vec::new(); let selected = state.input.autocomplete_idx; for (i, candidate) in state .input .autocomplete_candidates .iter() .enumerate() .take(10) { let prefix = if i == selected { " ▸ " } else { " " }; let style = if i == selected { Style::default() .fg(Theme::TEXT) .bg(Theme::HIGHLIGHT_DIM) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::TEXT) }; let label = format!("{prefix}{candidate}"); lines.push(Line::from(Span::styled(label, style))); } let dropdown = Paragraph::new(lines).block(dropdown_block); frame.render_widget(ratatui::widgets::Clear, dropdown_area); frame.render_widget(dropdown, dropdown_area); } let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Theme::BORDER_FOCUSED)) .style(Style::default().bg(Theme::BG)); let input_text = &state.input.buffer; let cursor_pos = state.input.cursor; let prompt = Span::styled( " ❯ ", Style::default() .fg(Theme::PRIMARY) .add_modifier(Modifier::BOLD), ); let mut spans = vec![prompt]; if input_text.is_empty() { spans.push(Span::styled( "Type a message, @file, or /command...", Style::default() .fg(Theme::TEXT_DIM) .add_modifier(Modifier::ITALIC), )); } else { let (before, after) = input_text.split_at(cursor_pos); spans.push(Span::raw(before.to_string())); let (cursor_char, after_char) = if after.is_empty() { (" ".to_string(), "") } else { let c = after.chars().next().unwrap(); let char_len = c.len_utf8(); (c.to_string(), &after[char_len..]) }; // Blinking cursor logic based on tick count let cursor_style = if state.misc.tick_count % 10 < 5 { Style::default() .bg(Theme::PRIMARY) .fg(Theme::BG) .add_modifier(Modifier::BOLD) } else { Style::default() .bg(Theme::TEXT_DIM) .fg(Theme::BG) .add_modifier(Modifier::BOLD) }; spans.push(Span::styled(cursor_char, cursor_style)); if !after_char.is_empty() { spans.push(Span::raw(after_char.to_string())); } } let line = Line::from(spans); let paragraph = Paragraph::new(line).block(block); frame.render_widget(paragraph, area); } }