1use 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
17pub struct UiRenderer;
19
20impl UiRenderer {
21 #[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 let activity_count = tui_bridge.activity_feed().lock().unwrap().len();
37 let activity_height = (activity_count as u16).min(3);
38
39 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 Self::render_chat_view(frame, &chunks[chunk_idx], chat_view, tui_bridge);
65 chunk_idx += 1;
66
67 if activity_height > 0 {
69 Self::render_activity_feed(frame, &chunks[chunk_idx], tui_bridge);
70 chunk_idx += 1;
71 }
72
73 Self::render_status_bar(frame, &chunks[chunk_idx], status_message, status_is_error);
75 chunk_idx += 1;
76
77 Self::render_input_area(frame, &chunks[chunk_idx], input_text, cursor_pos, cursor_blink_state);
79
80 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 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 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 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 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 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#[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 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}