Skip to main content

limit_cli/tui/ui/
renderer.rs

1//! UI Renderer for TUI components
2//!
3//! Handles rendering of chat view, status bar, input area, and popups.
4
5use crate::tui::bridge::TuiBridge;
6use crate::tui::FileAutocompleteState;
7use limit_tui::components::{calculate_popup_area, ChatView, FileAutocompleteWidget};
8use ratatui::{
9    layout::{Constraint, Direction, Layout, Rect},
10    style::{Color, Modifier, Style},
11    text::{Line, Span},
12    widgets::{Block, Borders, Paragraph, Wrap},
13    Frame,
14};
15use std::sync::{Arc, Mutex};
16
17/// UI Renderer for drawing TUI components
18pub struct UiRenderer;
19
20impl UiRenderer {
21    /// Render the complete TUI interface
22    #[allow(clippy::too_many_arguments)]
23    pub fn render(
24        frame: &mut Frame,
25        area: Rect,
26        chat_view: &Arc<Mutex<ChatView>>,
27        input_text: &str,
28        cursor_pos: usize,
29        status_message: &str,
30        status_is_error: bool,
31        cursor_blink_state: bool,
32        tui_bridge: &TuiBridge,
33        file_autocomplete: &Option<FileAutocompleteState>,
34    ) {
35        // Calculate activity height
36        let activity_count = tui_bridge.activity_feed().lock().unwrap().len();
37        let activity_height = (activity_count as u16).min(3);
38
39        // Build constraints (pre-allocated array on stack)
40        let constraints = if activity_height == 0 {
41            [
42                Constraint::Percentage(90),
43                Constraint::Length(1),
44                Constraint::Length(6),
45                Constraint::Length(0),
46            ]
47        } else {
48            [
49                Constraint::Percentage(90),
50                Constraint::Length(activity_height),
51                Constraint::Length(1),
52                Constraint::Length(6),
53            ]
54        };
55
56        let chunks = Layout::default()
57            .direction(Direction::Vertical)
58            .constraints(constraints)
59            .split(area);
60
61        let mut chunk_idx = 0;
62
63        // Render chat view
64        Self::render_chat_view(frame, &chunks[chunk_idx], chat_view, tui_bridge);
65        chunk_idx += 1;
66
67        // Render activity feed if present
68        if activity_height > 0 {
69            Self::render_activity_feed(frame, &chunks[chunk_idx], tui_bridge);
70            chunk_idx += 1;
71        }
72
73        // Render status bar
74        Self::render_status_bar(frame, &chunks[chunk_idx], status_message, status_is_error);
75        chunk_idx += 1;
76
77        // Render input area
78        Self::render_input_area(frame, &chunks[chunk_idx], input_text, cursor_pos, cursor_blink_state);
79
80        // Render autocomplete popup
81        if let Some(ref ac) = file_autocomplete {
82            if ac.is_active && !ac.matches.is_empty() {
83                Self::render_autocomplete_popup(frame, &chunks[chunk_idx], ac);
84            }
85        }
86    }
87
88    /// Render chat view with border
89    fn render_chat_view(
90        frame: &mut Frame,
91        area: &Rect,
92        chat_view: &Arc<Mutex<ChatView>>,
93        tui_bridge: &TuiBridge,
94    ) {
95        let chat = chat_view.lock().unwrap();
96        let total_input = tui_bridge.total_input_tokens();
97        let total_output = tui_bridge.total_output_tokens();
98
99        let title = format!(" Chat (↑{} ↓{}) ", total_input, total_output);
100
101        let chat_block = Block::default()
102            .borders(Borders::ALL)
103            .title(title)
104            .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
105
106        frame.render_widget(&*chat, chat_block.inner(*area));
107        frame.render_widget(chat_block, *area);
108    }
109
110    /// Render activity feed
111    fn render_activity_feed(frame: &mut Frame, area: &Rect, tui_bridge: &TuiBridge) {
112        let activity_feed = tui_bridge.activity_feed().lock().unwrap();
113        let activity_block = Block::default().borders(Borders::NONE).style(Style::default().bg(Color::Reset));
114
115        let activity_inner = activity_block.inner(*area);
116        frame.render_widget(activity_block, *area);
117        activity_feed.render(activity_inner, frame.buffer_mut());
118    }
119
120    /// Render status bar
121    fn render_status_bar(frame: &mut Frame, area: &Rect, status_message: &str, status_is_error: bool) {
122        let status_style = if status_is_error {
123            Style::default().fg(Color::Red).bg(Color::Reset)
124        } else {
125            Style::default().fg(Color::Yellow)
126        };
127
128        let status = Paragraph::new(Line::from(vec![
129            Span::styled(" ● ", Style::default().fg(Color::Green)),
130            Span::styled(status_message, status_style),
131        ]));
132
133        frame.render_widget(status, *area);
134    }
135
136    /// Render input area with border
137    fn render_input_area(
138        frame: &mut Frame,
139        area: &Rect,
140        input_text: &str,
141        cursor_pos: usize,
142        cursor_blink_state: bool,
143    ) {
144        let input_block = Block::default()
145            .borders(Borders::ALL)
146            .title(" Input (Esc or /exit to quit) ")
147            .title_style(Style::default().fg(Color::Cyan));
148
149        let input_inner = input_block.inner(*area);
150        frame.render_widget(input_block, *area);
151
152        let input_line = if input_text.is_empty() {
153            Line::from(vec![Span::styled(
154                "Type your message here...",
155                Style::default().fg(Color::DarkGray),
156            )])
157        } else {
158            let (before_cursor, at_cursor, after_cursor) = split_text_at_cursor(input_text, cursor_pos);
159
160            let cursor_style = if cursor_blink_state {
161                Style::default().bg(Color::White).fg(Color::Black)
162            } else {
163                Style::default().bg(Color::Reset).fg(Color::Reset)
164            };
165
166            Line::from(vec![
167                Span::raw(before_cursor),
168                Span::styled(at_cursor, cursor_style),
169                Span::raw(after_cursor),
170            ])
171        };
172
173        frame.render_widget(Paragraph::new(input_line).wrap(Wrap { trim: false }), input_inner);
174    }
175
176    /// Render autocomplete popup
177    fn render_autocomplete_popup(frame: &mut Frame, input_area: &Rect, autocomplete: &FileAutocompleteState) {
178        let popup_area = calculate_popup_area(*input_area, autocomplete.matches.len());
179
180        let widget = FileAutocompleteWidget::new(
181            &autocomplete.matches,
182            autocomplete.selected_index,
183            &autocomplete.query,
184        );
185
186        frame.render_widget(widget, popup_area);
187    }
188}
189
190/// Split text at cursor position for rendering (freestanding function for reuse)
191#[inline]
192fn split_text_at_cursor(text: &str, cursor_pos: usize) -> (&str, &str, &str) {
193    if text.is_empty() {
194        return ("", " ", "");
195    }
196
197    let pos = cursor_pos.min(text.len());
198    let before_cursor = &text[..pos];
199
200    // Get char at cursor (or space if at end) - single-pass
201    text[pos..]
202        .chars()
203        .next()
204        .map(|c| {
205            let end = c.len_utf8();
206            (before_cursor, &text[pos..pos + end], &text[pos + end..])
207        })
208        .unwrap_or((before_cursor, " ", ""))
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_split_text_at_cursor() {
217        let (before, at, after) = split_text_at_cursor("", 0);
218        assert_eq!(before, "");
219        assert_eq!(at, " ");
220        assert_eq!(after, "");
221
222        let (before, at, after) = split_text_at_cursor("hello", 0);
223        assert_eq!(before, "");
224        assert_eq!(at, "h");
225        assert_eq!(after, "ello");
226
227        let (before, at, after) = split_text_at_cursor("hello", 2);
228        assert_eq!(before, "he");
229        assert_eq!(at, "l");
230        assert_eq!(after, "lo");
231
232        let (before, at, after) = split_text_at_cursor("hello", 5);
233        assert_eq!(before, "hello");
234        assert_eq!(at, " ");
235        assert_eq!(after, "");
236
237        let text = "héllo";
238        let pos = text.char_indices().nth(2).map(|(i, _)| i).unwrap();
239        let (before, at, after) = split_text_at_cursor(text, pos);
240        assert_eq!(before, "hé");
241        assert_eq!(at, "l");
242        assert_eq!(after, "lo");
243    }
244}