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(
79            frame,
80            &chunks[chunk_idx],
81            input_text,
82            cursor_pos,
83            cursor_blink_state,
84        );
85
86        // Render autocomplete popup
87        if let Some(ref ac) = file_autocomplete {
88            if ac.is_active && !ac.matches.is_empty() {
89                Self::render_autocomplete_popup(frame, &chunks[chunk_idx], ac);
90            }
91        }
92    }
93
94    /// Render chat view with border
95    fn render_chat_view(
96        frame: &mut Frame,
97        area: &Rect,
98        chat_view: &Arc<Mutex<ChatView>>,
99        tui_bridge: &TuiBridge,
100    ) {
101        let chat = chat_view.lock().unwrap();
102        let total_input = tui_bridge.total_input_tokens();
103        let total_output = tui_bridge.total_output_tokens();
104
105        let title = format!(" Chat (↑{} ↓{}) ", total_input, total_output);
106
107        let chat_block = Block::default()
108            .borders(Borders::ALL)
109            .title(title)
110            .title_style(
111                Style::default()
112                    .fg(Color::Cyan)
113                    .add_modifier(Modifier::BOLD),
114            );
115
116        frame.render_widget(&*chat, chat_block.inner(*area));
117        frame.render_widget(chat_block, *area);
118    }
119
120    /// Render activity feed
121    fn render_activity_feed(frame: &mut Frame, area: &Rect, tui_bridge: &TuiBridge) {
122        let activity_feed = tui_bridge.activity_feed().lock().unwrap();
123        let activity_block = Block::default()
124            .borders(Borders::NONE)
125            .style(Style::default().bg(Color::Reset));
126
127        let activity_inner = activity_block.inner(*area);
128        frame.render_widget(activity_block, *area);
129        activity_feed.render(activity_inner, frame.buffer_mut());
130    }
131
132    /// Render status bar
133    fn render_status_bar(
134        frame: &mut Frame,
135        area: &Rect,
136        status_message: &str,
137        status_is_error: bool,
138    ) {
139        let status_style = if status_is_error {
140            Style::default().fg(Color::Red).bg(Color::Reset)
141        } else {
142            Style::default().fg(Color::Yellow)
143        };
144
145        let status = Paragraph::new(Line::from(vec![
146            Span::styled(" ● ", Style::default().fg(Color::Green)),
147            Span::styled(status_message, status_style),
148        ]));
149
150        frame.render_widget(status, *area);
151    }
152
153    /// Render input area with border
154    fn render_input_area(
155        frame: &mut Frame,
156        area: &Rect,
157        input_text: &str,
158        cursor_pos: usize,
159        cursor_blink_state: bool,
160    ) {
161        let input_block = Block::default()
162            .borders(Borders::ALL)
163            .title(" Input (Esc or /exit to quit) ")
164            .title_style(Style::default().fg(Color::Cyan));
165
166        let input_inner = input_block.inner(*area);
167        frame.render_widget(input_block, *area);
168
169        let input_line = if input_text.is_empty() {
170            Line::from(vec![Span::styled(
171                "Type your message here...",
172                Style::default().fg(Color::DarkGray),
173            )])
174        } else {
175            let (before_cursor, at_cursor, after_cursor) =
176                split_text_at_cursor(input_text, cursor_pos);
177
178            let cursor_style = if cursor_blink_state {
179                Style::default().bg(Color::White).fg(Color::Black)
180            } else {
181                Style::default().bg(Color::Reset).fg(Color::Reset)
182            };
183
184            Line::from(vec![
185                Span::raw(before_cursor),
186                Span::styled(at_cursor, cursor_style),
187                Span::raw(after_cursor),
188            ])
189        };
190
191        frame.render_widget(
192            Paragraph::new(input_line).wrap(Wrap { trim: false }),
193            input_inner,
194        );
195    }
196
197    /// Render autocomplete popup
198    fn render_autocomplete_popup(
199        frame: &mut Frame,
200        input_area: &Rect,
201        autocomplete: &FileAutocompleteState,
202    ) {
203        let popup_area = calculate_popup_area(*input_area, autocomplete.matches.len());
204
205        let widget = FileAutocompleteWidget::new(
206            &autocomplete.matches,
207            autocomplete.selected_index,
208            &autocomplete.query,
209        );
210
211        frame.render_widget(widget, popup_area);
212    }
213}
214
215/// Split text at cursor position for rendering (freestanding function for reuse)
216#[inline]
217fn split_text_at_cursor(text: &str, cursor_pos: usize) -> (&str, &str, &str) {
218    if text.is_empty() {
219        return ("", " ", "");
220    }
221
222    let pos = cursor_pos.min(text.len());
223    let before_cursor = &text[..pos];
224
225    // Get char at cursor (or space if at end) - single-pass
226    text[pos..]
227        .chars()
228        .next()
229        .map(|c| {
230            let end = c.len_utf8();
231            (before_cursor, &text[pos..pos + end], &text[pos + end..])
232        })
233        .unwrap_or((before_cursor, " ", ""))
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_split_text_at_cursor() {
242        let (before, at, after) = split_text_at_cursor("", 0);
243        assert_eq!(before, "");
244        assert_eq!(at, " ");
245        assert_eq!(after, "");
246
247        let (before, at, after) = split_text_at_cursor("hello", 0);
248        assert_eq!(before, "");
249        assert_eq!(at, "h");
250        assert_eq!(after, "ello");
251
252        let (before, at, after) = split_text_at_cursor("hello", 2);
253        assert_eq!(before, "he");
254        assert_eq!(at, "l");
255        assert_eq!(after, "lo");
256
257        let (before, at, after) = split_text_at_cursor("hello", 5);
258        assert_eq!(before, "hello");
259        assert_eq!(at, " ");
260        assert_eq!(after, "");
261
262        let text = "héllo";
263        let pos = text.char_indices().nth(2).map(|(i, _)| i).unwrap();
264        let (before, at, after) = split_text_at_cursor(text, pos);
265        assert_eq!(before, "hé");
266        assert_eq!(at, "l");
267        assert_eq!(after, "lo");
268    }
269}