Skip to main content

recursive/tui/ui/
input.rs

1//! Multi-mode PromptInput renderer (Goal 145).
2//!
3//! The input area is split into two stacked rectangles: the input
4//! frame (with a one-character mode indicator on the left) and a
5//! single-line footer hint (mode-dependent text on the second row).
6//!
7//! Real cursor positioning is computed from the prompt buffer's byte
8//! offset and pushed onto the frame via
9//! [`Frame::set_cursor_position`]. We deliberately don't draw a
10//! synthetic glyph (`β–Œ`) so the terminal's native cursor remains the
11//! single source of truth.
12//!
13//! Sizing: the input pane height is `min(buffer line count + 1, 6)`
14//! plus a one-row footer below.
15
16use ratatui::layout::Position;
17use ratatui::prelude::*;
18use ratatui::widgets::{Block, Borders, Paragraph};
19
20use crate::tui::app::{App, InputMode};
21
22/// Maximum visible input rows (after which the box scrolls
23/// internally). The +2 below accounts for the box's borders.
24pub const MAX_VISIBLE_ROWS: u16 = 6;
25
26/// Total height the chat layout should reserve for the input + footer
27/// stack, given the current buffer.
28pub fn total_height(app: &App) -> u16 {
29    visible_rows(&app.prompt.buffer) + 2 /* borders */ + 1 /* footer */
30}
31
32/// Number of buffer rows we want visible.
33fn visible_rows(buffer: &str) -> u16 {
34    let lines = buffer.lines().count().max(1);
35    // If the buffer ends in a `\n`, lines() under-counts the trailing
36    // empty line; add one so the cursor on the new line is visible.
37    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
42/// Render the input frame + footer hint into `area`. Sets the
43/// terminal cursor to the active edit position.
44pub fn render(frame: &mut Frame, area: Rect, app: &App) {
45    if area.height < 2 {
46        return;
47    }
48    // Split area into [input box, hint].
49    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    // Build the body lines: prefix the indicator on the very first
68    // visual row, plain space-padding on subsequent rows.
69    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    // Compute cursor visual position. The input box has 1-cell border
100    // and a 2-cell prefix ("X "), so the first column of editable
101    // content is `area.x + 1 + 2`.
102    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    // Clamp inside the input frame.
106    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    // Footer hint.
113    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
118/// Style of the mode indicator character on the left of the box.
119fn 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
131/// Title shown on the input box border.
132fn 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
143/// Convert a buffer + byte cursor offset into a (col, row) inside the
144/// edit area (zero-based, where col counts columns from the first
145/// editable cell, *not* including the box border).
146///
147/// The input renderer pads non-first lines with two spaces in place
148/// of the indicator to keep columns visually aligned, so per-line
149/// content always starts at the same x. We therefore only have to
150/// count `\n`s for the row, and the **display width** of the
151/// preceding chars on the active line for the column. Using
152/// `chars().count()` undercounts CJK / emoji / fullwidth glyphs
153/// (each takes 2 columns in a terminal), which made the cursor
154/// land in the middle of the previous double-width char rather
155/// than after it.
156pub 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
165/// Single-line hint shown below the input frame.
166///
167/// Note: the previous `ctrl+b/f or wheel scroll` segment was dropped
168/// β€” `Ctrl+B` / `Ctrl+F` now move the cursor by one char (emacs
169/// readline), and the remaining transcript-scroll affordances
170/// (PageUp/PageDown, Shift+ArrowUp/Down, trackpad / mouse wheel) are
171/// left implicit because the status bar already advertises the
172/// mode and the scroll behaviour is terminal-native.
173pub 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        // Build a tiny test terminal so we can inspect the rendered
194        // glyphs without running the full chat layout.
195        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            // Concatenate the first line into a String and assert
218            // the mode glyph appears.
219            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        // Cursor at very end (byte 8): row=2, col=1 ("f")
241        assert_eq!(cursor_visual_position(buf, buf.len()), (1, 2));
242        // Cursor at start of line 2 (just after first '\n', byte 3)
243        assert_eq!(cursor_visual_position(buf, 3), (0, 1));
244        // Cursor at byte 0
245        assert_eq!(cursor_visual_position(buf, 0), (0, 0));
246    }
247
248    /// Goal-150 follow-up: CJK / fullwidth chars take two terminal
249    /// columns each; `chars().count()` (the previous implementation)
250    /// undercounted them and left the cursor visually inside the
251    /// preceding glyph rather than after it.
252    #[test]
253    fn cursor_visual_position_counts_double_width_chars() {
254        let buf = "δ½ ε₯½";
255        // Two Chinese chars = 6 bytes (3 each), 4 visual columns.
256        assert_eq!(buf.len(), 6);
257        // Cursor after the first char (byte 3): col=2 (one CJK glyph).
258        assert_eq!(cursor_visual_position(buf, 3), (2, 0));
259        // Cursor at the end (byte 6): col=4 (two CJK glyphs).
260        assert_eq!(cursor_visual_position(buf, buf.len()), (4, 0));
261    }
262
263    #[test]
264    fn cursor_visual_position_mixed_ascii_and_cjk() {
265        // "abδΊ†" β€” 'a' + 'b' (1 col each) + 'δΊ†' (2 cols) = 4 cols.
266        let buf = "abδΊ†";
267        assert_eq!(cursor_visual_position(buf, buf.len()), (4, 0));
268        // After "ab" (byte 2): col=2.
269        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        // The input box itself is capped at MAX_VISIBLE_ROWS rows of
282        // editable area, plus 2 for borders, plus 1 for the footer.
283        assert!(h_max <= MAX_VISIBLE_ROWS + 2 + 1);
284    }
285}