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(
79 frame,
80 &chunks[chunk_idx],
81 input_text,
82 cursor_pos,
83 cursor_blink_state,
84 );
85
86 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 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 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 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 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 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#[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 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}