Skip to main content

koda_cli/
wrap_input.rs

1//! Word-wrapping input renderer.
2//!
3//! Renders TextArea content with word-wrap (matching conversation text)
4//! while preserving cursor position. The TextArea handles editing logic;
5//! this module only handles display.
6//!
7//! Fixes #517: input prompt should word-wrap like conversation text blocks.
8
9use ratatui::{
10    buffer::Buffer,
11    layout::Rect,
12    style::Style,
13    text::{Line, Span},
14    widgets::{Paragraph, Widget, Wrap},
15};
16use ratatui_textarea::TextArea;
17use unicode_width::UnicodeWidthChar;
18
19/// Render the TextArea content with word-wrapping into the given area,
20/// drawing the cursor at the correct visual position.
21pub fn render_wrapped_input(
22    textarea: &TextArea,
23    area: Rect,
24    buf: &mut Buffer,
25    cursor_style: Style,
26) {
27    let lines = textarea.lines();
28    let (cursor_row, cursor_col) = textarea.cursor();
29    let width = area.width as usize;
30
31    if width == 0 || area.height == 0 {
32        return;
33    }
34
35    // Build styled lines for Paragraph rendering
36    let display_lines: Vec<Line<'_>> = lines
37        .iter()
38        .map(|l| Line::from(Span::raw(l.as_str())))
39        .collect();
40
41    // Render the text with word-wrapping
42    let paragraph = Paragraph::new(display_lines).wrap(Wrap { trim: false });
43    paragraph.render(area, buf);
44
45    // Calculate cursor visual position in the wrapped text
46    let (vis_row, vis_col) = logical_to_visual(lines, cursor_row, cursor_col, width);
47
48    // Draw cursor if it's within the visible area
49    let cursor_y = area.y + vis_row as u16;
50    let cursor_x = area.x + vis_col as u16;
51    if cursor_y < area.y + area.height && cursor_x < area.x + area.width {
52        let cell = &mut buf[(cursor_x, cursor_y)];
53        cell.set_style(cursor_style);
54    }
55}
56
57/// Compute the visual height (wrapped lines) for all textarea content.
58///
59/// Used to dynamically size the input area in the layout.
60pub fn wrapped_height(textarea: &TextArea, width: usize) -> usize {
61    if width == 0 {
62        return textarea.lines().len().max(1);
63    }
64    textarea
65        .lines()
66        .iter()
67        .map(|line| visual_line_count(line, width))
68        .sum::<usize>()
69        .max(1)
70}
71
72/// Map a logical cursor position `(row, col)` to a visual position
73/// `(visual_row, visual_col)` accounting for word wrapping.
74fn logical_to_visual(
75    lines: &[String],
76    cursor_row: usize,
77    cursor_col: usize,
78    width: usize,
79) -> (usize, usize) {
80    let mut visual_row = 0usize;
81
82    // Count visual rows for all lines before the cursor line
83    for line in lines.iter().take(cursor_row) {
84        visual_row += visual_line_count(line, width);
85    }
86
87    // Now find the visual position within the cursor line
88    let cursor_line = lines.get(cursor_row).map(|s| s.as_str()).unwrap_or("");
89    let (extra_rows, vis_col) = cursor_visual_offset(cursor_line, cursor_col, width);
90    visual_row += extra_rows;
91
92    (visual_row, vis_col)
93}
94
95/// Compute how many visual lines a text line occupies at a given width.
96///
97/// Delegates to `wrap_util::visual_line_count` — single source of truth.
98fn visual_line_count(line: &str, width: usize) -> usize {
99    crate::wrap_util::visual_line_count(line, width)
100}
101
102/// For a given cursor column position in a line, compute:
103/// - How many visual rows down from the start of this line the cursor is
104/// - What the visual column is on that visual row
105fn cursor_visual_offset(line: &str, cursor_col: usize, width: usize) -> (usize, usize) {
106    let w = width.max(1);
107    let mut visual_row = 0usize;
108    let mut col = 0usize;
109    let mut word_start_col = 0usize;
110    let mut in_word = false;
111    for (char_idx, ch) in line.chars().enumerate() {
112        if char_idx == cursor_col {
113            return (visual_row, col);
114        }
115
116        let char_w = ch.width().unwrap_or(0);
117        let is_space = ch == ' ' || ch == '\t';
118
119        if is_space {
120            in_word = false;
121            if col + char_w > w {
122                visual_row += 1;
123                col = char_w;
124            } else {
125                col += char_w;
126            }
127            word_start_col = col;
128        } else {
129            if !in_word {
130                word_start_col = col;
131                in_word = true;
132            }
133            if col + char_w > w {
134                if word_start_col > 0 && word_start_col <= w {
135                    visual_row += 1;
136                    let word_len_so_far = col - word_start_col;
137                    col = word_len_so_far + char_w;
138                    word_start_col = 0;
139                } else {
140                    visual_row += 1;
141                    col = char_w;
142                    word_start_col = 0;
143                }
144            } else {
145                col += char_w;
146            }
147        }
148    }
149
150    // Cursor at end of line
151    (visual_row, col)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_visual_line_count_short() {
160        assert_eq!(visual_line_count("hello", 80), 1);
161    }
162
163    #[test]
164    fn test_visual_line_count_empty() {
165        assert_eq!(visual_line_count("", 80), 1);
166    }
167
168    #[test]
169    fn test_visual_line_count_wrap() {
170        // 100 chars of 'x' at width 80 = 2 visual lines
171        let line = "x".repeat(100);
172        assert_eq!(visual_line_count(&line, 80), 2);
173    }
174
175    #[test]
176    fn test_visual_line_count_word_wrap() {
177        // "<75 a's> <75 b's>" at width 80
178        // Row 1: 75 a's + space (76), b's don't fit → wrap
179        // Row 2: 75 b's
180        let line = format!("{} {}", "a".repeat(75), "b".repeat(75));
181        assert_eq!(visual_line_count(&line, 80), 2);
182    }
183
184    #[test]
185    fn test_cursor_visual_offset_no_wrap() {
186        assert_eq!(cursor_visual_offset("hello world", 5, 80), (0, 5));
187    }
188
189    #[test]
190    fn test_cursor_visual_offset_after_wrap() {
191        // "<80 x's>abc" at width 80
192        // Row 0: 80 x's, Row 1: abc
193        // Cursor at col 82 → visual (1, 2)
194        let line = format!("{}abc", "x".repeat(80));
195        assert_eq!(cursor_visual_offset(&line, 82, 80), (1, 2));
196    }
197
198    #[test]
199    fn test_cursor_visual_offset_at_end() {
200        let line = "hello";
201        assert_eq!(cursor_visual_offset(line, 5, 80), (0, 5));
202    }
203
204    #[test]
205    fn test_wrapped_height_multiline() {
206        let mut ta = TextArea::default();
207        ta.insert_str("short line");
208        ta.insert_newline();
209        ta.insert_str("another line");
210        // 2 logical lines, both fit in 80 cols
211        assert_eq!(wrapped_height(&ta, 80), 2);
212    }
213
214    #[test]
215    fn test_wrapped_height_long_line() {
216        let mut ta = TextArea::default();
217        ta.insert_str("x".repeat(200));
218        // 1 logical line, 200 chars / 80 = 3 visual lines
219        assert_eq!(wrapped_height(&ta, 80), 3);
220    }
221
222    #[test]
223    fn test_logical_to_visual_first_line() {
224        let lines = vec!["hello world".to_string()];
225        assert_eq!(logical_to_visual(&lines, 0, 5, 80), (0, 5));
226    }
227
228    #[test]
229    fn test_logical_to_visual_second_line() {
230        let lines = vec!["first".to_string(), "second".to_string()];
231        assert_eq!(logical_to_visual(&lines, 1, 3, 80), (1, 3));
232    }
233
234    #[test]
235    fn test_logical_to_visual_with_wrapped_previous() {
236        // First line wraps into 3 visual lines at width 80
237        let lines = vec!["x".repeat(200), "hello".to_string()];
238        // First line = 3 visual rows, cursor on second line col 3
239        assert_eq!(logical_to_visual(&lines, 1, 3, 80), (3, 3));
240    }
241}