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        pending_input_preview: Option<&limit_tui::components::PendingInputPreview>,
35    ) {
36        // Calculate activity height
37        let activity_count = tui_bridge.activity_feed().lock().unwrap().len();
38        let activity_height = (activity_count as u16).min(3);
39
40        // Build constraints (pre-allocated array on stack)
41        let constraints = if activity_height == 0 {
42            [
43                Constraint::Percentage(90),
44                Constraint::Length(1),
45                Constraint::Length(6),
46                Constraint::Length(0),
47            ]
48        } else {
49            [
50                Constraint::Percentage(90),
51                Constraint::Length(activity_height),
52                Constraint::Length(1),
53                Constraint::Length(6),
54            ]
55        };
56
57        let chunks = Layout::default()
58            .direction(Direction::Vertical)
59            .constraints(constraints)
60            .split(area);
61
62        let mut chunk_idx = 0;
63
64        // Render chat view
65        Self::render_chat_view(frame, &chunks[chunk_idx], chat_view, tui_bridge);
66        chunk_idx += 1;
67
68        // Render activity feed if present
69        if activity_height > 0 {
70            Self::render_activity_feed(frame, &chunks[chunk_idx], tui_bridge);
71            chunk_idx += 1;
72        }
73
74        // Render status bar
75        Self::render_status_bar(frame, &chunks[chunk_idx], status_message, status_is_error);
76        chunk_idx += 1;
77
78        // Render input area
79        Self::render_input_area(
80            frame,
81            &chunks[chunk_idx],
82            input_text,
83            cursor_pos,
84            cursor_blink_state,
85        );
86
87        // Render pending input preview (above input area)
88        if let Some(preview) = pending_input_preview {
89            if preview.has_messages() {
90                Self::render_pending_input_preview(frame, &chunks[chunk_idx], preview);
91            }
92        }
93
94        // Render autocomplete popup
95        if let Some(ref ac) = file_autocomplete {
96            if ac.is_active && !ac.matches.is_empty() {
97                Self::render_autocomplete_popup(frame, &chunks[chunk_idx], ac);
98            }
99        }
100    }
101
102    /// Render chat view with border
103    fn render_chat_view(
104        frame: &mut Frame,
105        area: &Rect,
106        chat_view: &Arc<Mutex<ChatView>>,
107        tui_bridge: &TuiBridge,
108    ) {
109        let chat = chat_view.lock().unwrap();
110        let total_input = tui_bridge.total_input_tokens();
111        let total_output = tui_bridge.total_output_tokens();
112
113        let title = format!(" Chat (↑{} ↓{}) ", total_input, total_output);
114
115        let chat_block = Block::default()
116            .borders(Borders::ALL)
117            .title(title)
118            .title_style(
119                Style::default()
120                    .fg(Color::Cyan)
121                    .add_modifier(Modifier::BOLD),
122            );
123
124        frame.render_widget(&*chat, chat_block.inner(*area));
125        frame.render_widget(chat_block, *area);
126    }
127
128    /// Render activity feed
129    fn render_activity_feed(frame: &mut Frame, area: &Rect, tui_bridge: &TuiBridge) {
130        let activity_feed = tui_bridge.activity_feed().lock().unwrap();
131        let activity_block = Block::default()
132            .borders(Borders::NONE)
133            .style(Style::default().bg(Color::Reset));
134
135        let activity_inner = activity_block.inner(*area);
136        frame.render_widget(activity_block, *area);
137        activity_feed.render(activity_inner, frame.buffer_mut());
138    }
139
140    /// Render status bar
141    fn render_status_bar(
142        frame: &mut Frame,
143        area: &Rect,
144        status_message: &str,
145        status_is_error: bool,
146    ) {
147        let status_style = if status_is_error {
148            Style::default().fg(Color::Red).bg(Color::Reset)
149        } else {
150            Style::default().fg(Color::Yellow)
151        };
152
153        let status = Paragraph::new(Line::from(vec![
154            Span::styled(" ● ", Style::default().fg(Color::Green)),
155            Span::styled(status_message, status_style),
156        ]));
157
158        frame.render_widget(status, *area);
159    }
160
161    /// Render input area with border
162    fn render_input_area(
163        frame: &mut Frame,
164        area: &Rect,
165        input_text: &str,
166        cursor_pos: usize,
167        cursor_blink_state: bool,
168    ) {
169        let input_block = Block::default()
170            .borders(Borders::ALL)
171            .title(" Input (Esc or /exit to quit) ")
172            .title_style(Style::default().fg(Color::Cyan));
173
174        let input_inner = input_block.inner(*area);
175        frame.render_widget(input_block, *area);
176
177        let input_line = if input_text.is_empty() {
178            Line::from(vec![Span::styled(
179                "Type your message here...",
180                Style::default().fg(Color::DarkGray),
181            )])
182        } else {
183            let (before_cursor, at_cursor, after_cursor) =
184                split_text_at_cursor(input_text, cursor_pos);
185
186            let cursor_style = if cursor_blink_state {
187                Style::default().bg(Color::White).fg(Color::Black)
188            } else {
189                Style::default().bg(Color::Reset).fg(Color::Reset)
190            };
191
192            Line::from(vec![
193                Span::raw(before_cursor),
194                Span::styled(at_cursor, cursor_style),
195                Span::raw(after_cursor),
196            ])
197        };
198
199        frame.render_widget(
200            Paragraph::new(input_line).wrap(Wrap { trim: false }),
201            input_inner,
202        );
203    }
204
205    /// Render autocomplete popup
206    fn render_autocomplete_popup(
207        frame: &mut Frame,
208        input_area: &Rect,
209        autocomplete: &FileAutocompleteState,
210    ) {
211        let popup_area = calculate_popup_area(*input_area, autocomplete.matches.len());
212
213        let widget = FileAutocompleteWidget::new(
214            &autocomplete.matches,
215            autocomplete.selected_index,
216            &autocomplete.query,
217        );
218
219        frame.render_widget(widget, popup_area);
220    }
221
222    /// Render pending input preview
223    fn render_pending_input_preview(
224        frame: &mut Frame,
225        input_area: &Rect,
226        preview: &limit_tui::components::PendingInputPreview,
227    ) {
228        if !preview.has_messages() {
229            return;
230        }
231
232        // Calculate height needed for preview
233        let mut preview_height = 0u16;
234        if !preview.pending_steers.is_empty() {
235            preview_height += 1; // header
236            for steer in &preview.pending_steers {
237                preview_height += steer.lines().take(3).count() as u16;
238                if steer.lines().count() > 3 {
239                    preview_height += 1; // ellipsis
240                }
241            }
242        }
243        if !preview.queued_messages.is_empty() {
244            preview_height += 1; // header
245            for msg in &preview.queued_messages {
246                preview_height += msg.lines().take(3).count() as u16;
247                if msg.lines().count() > 3 {
248                    preview_height += 1; // ellipsis
249                }
250            }
251            preview_height += 1; // hint
252        }
253
254        // Cap preview height
255        let preview_height = preview_height
256            .min(8)
257            .min(input_area.height.saturating_sub(1));
258
259        if preview_height == 0 {
260            return;
261        }
262
263        // Render preview above input area
264        let preview_area = Rect {
265            x: input_area.x,
266            y: input_area.y.saturating_sub(preview_height),
267            width: input_area.width,
268            height: preview_height,
269        };
270
271        frame.render_widget(preview.clone(), preview_area);
272    }
273}
274
275/// Split text at cursor position for rendering (freestanding function for reuse)
276#[inline]
277fn split_text_at_cursor(text: &str, cursor_pos: usize) -> (&str, &str, &str) {
278    if text.is_empty() {
279        return ("", " ", "");
280    }
281
282    let pos = cursor_pos.min(text.len());
283    let before_cursor = &text[..pos];
284
285    // Get char at cursor (or space if at end) - single-pass
286    text[pos..]
287        .chars()
288        .next()
289        .map(|c| {
290            let end = c.len_utf8();
291            (before_cursor, &text[pos..pos + end], &text[pos + end..])
292        })
293        .unwrap_or((before_cursor, " ", ""))
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_split_text_at_cursor() {
302        let (before, at, after) = split_text_at_cursor("", 0);
303        assert_eq!(before, "");
304        assert_eq!(at, " ");
305        assert_eq!(after, "");
306
307        let (before, at, after) = split_text_at_cursor("hello", 0);
308        assert_eq!(before, "");
309        assert_eq!(at, "h");
310        assert_eq!(after, "ello");
311
312        let (before, at, after) = split_text_at_cursor("hello", 2);
313        assert_eq!(before, "he");
314        assert_eq!(at, "l");
315        assert_eq!(after, "lo");
316
317        let (before, at, after) = split_text_at_cursor("hello", 5);
318        assert_eq!(before, "hello");
319        assert_eq!(at, " ");
320        assert_eq!(after, "");
321
322        let text = "héllo";
323        let pos = text.char_indices().nth(2).map(|(i, _)| i).unwrap();
324        let (before, at, after) = split_text_at_cursor(text, pos);
325        assert_eq!(before, "hé");
326        assert_eq!(at, "l");
327        assert_eq!(after, "lo");
328    }
329}