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 pending_input_preview: Option<&limit_tui::components::PendingInputPreview>,
35 ) {
36 let activity_count = tui_bridge.activity_feed().lock().unwrap().len();
38 let activity_height = (activity_count as u16).min(3);
39
40 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 Self::render_chat_view(frame, &chunks[chunk_idx], chat_view, tui_bridge);
66 chunk_idx += 1;
67
68 if activity_height > 0 {
70 Self::render_activity_feed(frame, &chunks[chunk_idx], tui_bridge);
71 chunk_idx += 1;
72 }
73
74 Self::render_status_bar(frame, &chunks[chunk_idx], status_message, status_is_error);
76 chunk_idx += 1;
77
78 Self::render_input_area(
80 frame,
81 &chunks[chunk_idx],
82 input_text,
83 cursor_pos,
84 cursor_blink_state,
85 );
86
87 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 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 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 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 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 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 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 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 let mut preview_height = 0u16;
234 if !preview.pending_steers.is_empty() {
235 preview_height += 1; 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; }
241 }
242 }
243 if !preview.queued_messages.is_empty() {
244 preview_height += 1; 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; }
250 }
251 preview_height += 1; }
253
254 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 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#[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 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}