1use ratatui::layout::Position;
17use ratatui::prelude::*;
18use ratatui::widgets::{Block, Borders, Paragraph};
19
20use crate::tui::app::{App, InputMode};
21
22pub const MAX_VISIBLE_ROWS: u16 = 6;
25
26pub fn total_height(app: &App) -> u16 {
29 visible_rows(&app.prompt.buffer) + 2 + 1 }
31
32fn visible_rows(buffer: &str) -> u16 {
34 let lines = buffer.lines().count().max(1);
35 let trailing = if buffer.ends_with('\n') { 1 } else { 0 };
38 let total = lines + trailing;
39 (total as u16).clamp(1, MAX_VISIBLE_ROWS)
40}
41
42pub fn render(frame: &mut Frame, area: Rect, app: &App) {
45 if area.height < 2 {
46 return;
47 }
48 let input_h = area.height.saturating_sub(1).max(3);
50 let input_area = Rect {
51 x: area.x,
52 y: area.y,
53 width: area.width,
54 height: input_h,
55 };
56 let hint_area = Rect {
57 x: area.x,
58 y: area.y + input_h,
59 width: area.width,
60 height: 1,
61 };
62
63 let mode = app.prompt.mode;
64 let buffer = &app.prompt.buffer;
65 let cursor_byte = app.prompt.cursor.min(buffer.len());
66
67 let indicator_style = indicator_style(mode);
70 let body_style = Style::default().fg(Color::White);
71
72 let lines: Vec<Line<'static>> = if buffer.is_empty() {
73 vec![Line::from(vec![
74 Span::styled(format!("{} ", mode.indicator()), indicator_style),
75 Span::styled(String::new(), body_style),
76 ])]
77 } else {
78 buffer
79 .split('\n')
80 .enumerate()
81 .map(|(i, line)| {
82 let prefix = if i == 0 {
83 Span::styled(format!("{} ", mode.indicator()), indicator_style)
84 } else {
85 Span::raw(" ")
86 };
87 Line::from(vec![prefix, Span::styled(line.to_string(), body_style)])
88 })
89 .collect()
90 };
91
92 let input = Paragraph::new(lines).block(
93 Block::default()
94 .borders(Borders::ALL)
95 .title(input_box_title(mode)),
96 );
97 frame.render_widget(input, input_area);
98
99 let (col, row) = cursor_visual_position(buffer, cursor_byte);
103 let cursor_x = input_area.x.saturating_add(1).saturating_add(2 + col);
104 let cursor_y = input_area.y.saturating_add(1).saturating_add(row);
105 let max_x = input_area.x + input_area.width.saturating_sub(2);
107 let max_y = input_area.y + input_area.height.saturating_sub(2);
108 let cx = cursor_x.min(max_x);
109 let cy = cursor_y.min(max_y);
110 frame.set_cursor_position(Position { x: cx, y: cy });
111
112 let hint =
114 Paragraph::new(footer_hint(mode)).style(Style::default().fg(Color::Gray).bg(Color::Reset));
115 frame.render_widget(hint, hint_area);
116}
117
118fn indicator_style(mode: InputMode) -> Style {
120 let fg = match mode {
121 InputMode::Prompt => Color::Cyan,
122 InputMode::Bash => Color::LightYellow,
123 InputMode::Note => Color::DarkGray,
124 InputMode::Command => Color::Magenta,
125 InputMode::AtFile => Color::Cyan,
126 InputMode::HistorySearch => Color::LightGreen,
127 };
128 Style::default().fg(fg).add_modifier(Modifier::BOLD)
129}
130
131fn input_box_title(mode: InputMode) -> &'static str {
133 match mode {
134 InputMode::Prompt => " Input ",
135 InputMode::Bash => " Bash ",
136 InputMode::Note => " Note ",
137 InputMode::Command => " Command ",
138 InputMode::AtFile => " @File ",
139 InputMode::HistorySearch => " π History Search ",
140 }
141}
142
143pub fn cursor_visual_position(buffer: &str, cursor: usize) -> (u16, u16) {
157 use unicode_width::UnicodeWidthStr;
158 let head = &buffer[..cursor.min(buffer.len())];
159 let row = head.matches('\n').count() as u16;
160 let line_start = head.rfind('\n').map(|i| i + 1).unwrap_or(0);
161 let col = UnicodeWidthStr::width(&head[line_start..]) as u16;
162 (col, row)
163}
164
165pub fn footer_hint(mode: InputMode) -> String {
174 match mode {
175 InputMode::Prompt => "β submit shift+tab mode ββ history esc clear".into(),
176 InputMode::Bash => "β run shell shift+tab mode ββ history esc clear".into(),
177 InputMode::Note => "β save note shift+tab mode ββ history esc clear".into(),
178 InputMode::Command => "β run command tab autocomplete ββ history".into(),
179 InputMode::AtFile => "β/tab confirm ββ select backspace edit esc cancel".into(),
180 InputMode::HistorySearch => {
181 "β confirm ββ select ctrl+r next backspace edit esc cancel".into()
182 }
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use crate::tui::app::AppScreen;
190
191 #[test]
192 fn renders_correct_indicator_per_mode() {
193 for (mode, ch) in [
196 (InputMode::Prompt, 'β―'),
197 (InputMode::Bash, '!'),
198 (InputMode::Note, '#'),
199 (InputMode::Command, '/'),
200 ] {
201 let mut app = App::new();
202 app.screen = AppScreen::Chat;
203 app.prompt.mode = mode;
204 let backend = ratatui::backend::TestBackend::new(40, 6);
205 let mut term = ratatui::Terminal::new(backend).unwrap();
206 term.draw(|f| {
207 let area = Rect {
208 x: 0,
209 y: 0,
210 width: 40,
211 height: 6,
212 };
213 render(f, area, &app);
214 })
215 .unwrap();
216 let buf = term.backend().buffer();
217 let row: String = (0..buf.area().width)
220 .map(|x| buf[(x, 1)].symbol())
221 .collect();
222 assert!(
223 row.contains(ch),
224 "mode {mode:?} indicator {ch:?} missing from row {row:?}"
225 );
226 }
227 }
228
229 #[test]
230 fn footer_hint_changes_per_mode() {
231 assert!(footer_hint(InputMode::Prompt).contains("submit"));
232 assert!(footer_hint(InputMode::Bash).contains("run shell"));
233 assert!(footer_hint(InputMode::Note).contains("save note"));
234 assert!(footer_hint(InputMode::Command).contains("run command"));
235 }
236
237 #[test]
238 fn cursor_visual_position_handles_multiline() {
239 let buf = "ab\ncde\nf";
240 assert_eq!(cursor_visual_position(buf, buf.len()), (1, 2));
242 assert_eq!(cursor_visual_position(buf, 3), (0, 1));
244 assert_eq!(cursor_visual_position(buf, 0), (0, 0));
246 }
247
248 #[test]
253 fn cursor_visual_position_counts_double_width_chars() {
254 let buf = "δ½ ε₯½";
255 assert_eq!(buf.len(), 6);
257 assert_eq!(cursor_visual_position(buf, 3), (2, 0));
259 assert_eq!(cursor_visual_position(buf, buf.len()), (4, 0));
261 }
262
263 #[test]
264 fn cursor_visual_position_mixed_ascii_and_cjk() {
265 let buf = "abδΊ";
267 assert_eq!(cursor_visual_position(buf, buf.len()), (4, 0));
268 assert_eq!(cursor_visual_position(buf, 2), (2, 0));
270 }
271
272 #[test]
273 fn total_height_grows_with_lines_until_cap() {
274 let mut app = App::new();
275 app.screen = AppScreen::Chat;
276 app.prompt.buffer = "a".into();
277 let h1 = total_height(&app);
278 app.prompt.buffer = "a\nb\nc\nd\ne\nf\ng".into();
279 let h_max = total_height(&app);
280 assert!(h_max > h1);
281 assert!(h_max <= MAX_VISIBLE_ROWS + 2 + 1);
284 }
285}