Skip to main content

thndrs_lib/cli/renderer/
cursor.rs

1//! Prompt cursor coordinate calculation.
2//!
3//! Given prompt text, a grapheme-cluster cursor position, the body width
4//! (columns available for text), and an optional prompt indent, compute the
5//! `(row, col)` coordinate of the cursor within the prompt's visual rows.
6//!
7//! Uses [`unicode_width`] for display-width measurement so that wide
8//! characters (CJK), zero-width characters (combining marks), and emoji are
9//! handled correctly. Cursor position is expressed as a grapheme-cluster index
10//! (matching [`crate::input::PromptInput`]).
11//!
12//! Handles:
13//! - single-line input;
14//! - word/char wrapping at `body_width` using **display width**, not char count;
15//! - explicit `\n` newlines;
16//! - a leading prompt indent applied to every visual row;
17//! - multibyte and grapheme clusters.
18
19use crate::utils;
20
21use super::row::CursorCoord;
22use unicode_segmentation::UnicodeSegmentation;
23
24/// Compute the `(row, col)` coordinate of the cursor in the prompt's visual
25/// rows.
26///
27/// `text` is the full prompt string, `cursor` is the grapheme-cluster index of
28/// the cursor, `body_width` is the number of display columns available for
29/// prompt text (excluding any indent), and `indent` is the number of leading
30/// columns added to every visual row.
31///
32/// The returned column is relative to the start of the visual row including the
33/// indent, i.e. it is the absolute column where the cursor should be placed.
34pub fn prompt_cursor(text: &str, cursor: usize, body_width: usize, indent: usize) -> CursorCoord {
35    let body_width = body_width.max(1);
36    let mut row = 0usize;
37    let mut col = indent;
38    let mut pos = 0usize;
39
40    for grapheme in text.graphemes(true) {
41        if pos == cursor {
42            return CursorCoord { row, col };
43        }
44
45        if grapheme.contains('\n') {
46            row += 1;
47            col = indent;
48            pos += 1;
49            continue;
50        }
51
52        let g_width = utils::grapheme_width(grapheme);
53        if col - indent + g_width > body_width {
54            row += 1;
55            col = indent;
56        }
57        col += g_width;
58        pos += 1;
59    }
60
61    CursorCoord { row, col }
62}
63
64/// Decompose prompt text into visual rows of text (without the indent) for
65/// rendering. Uses display width for wrapping so wide characters occupy the
66/// correct number of columns.
67///
68/// Each returned string is the content of one visual row; the caller adds the
69/// indent prefix. Explicit `\n` characters are not included in the output.
70pub fn prompt_rows(text: &str, body_width: usize) -> Vec<String> {
71    let body_width = body_width.max(1);
72    let mut rows = Vec::new();
73    let mut current = String::new();
74    let mut current_width = 0usize;
75
76    for grapheme in text.graphemes(true) {
77        if grapheme.contains('\n') {
78            rows.push(std::mem::take(&mut current));
79            current_width = 0;
80            continue;
81        }
82        let g_width = utils::grapheme_width(grapheme);
83        if current_width > 0 && current_width + g_width > body_width {
84            rows.push(std::mem::take(&mut current));
85            current_width = 0;
86        }
87        current.push_str(grapheme);
88        current_width += g_width;
89    }
90    rows.push(current);
91    rows
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn single_line_cursor_at_end() {
100        let coord = prompt_cursor("hello", 5, 80, 0);
101        assert_eq!(coord, CursorCoord { row: 0, col: 5 });
102    }
103
104    #[test]
105    fn single_line_cursor_in_middle() {
106        let coord = prompt_cursor("hello", 2, 80, 0);
107        assert_eq!(coord, CursorCoord { row: 0, col: 2 });
108    }
109
110    #[test]
111    fn single_line_cursor_at_start() {
112        let coord = prompt_cursor("hello", 0, 80, 0);
113        assert_eq!(coord, CursorCoord { row: 0, col: 0 });
114    }
115
116    #[test]
117    fn empty_input_cursor_at_origin() {
118        let coord = prompt_cursor("", 0, 80, 0);
119        assert_eq!(coord, CursorCoord { row: 0, col: 0 });
120    }
121
122    #[test]
123    fn wrapped_line_cursor_on_second_row() {
124        let coord = prompt_cursor("abcdef", 4, 3, 0);
125        assert_eq!(coord, CursorCoord { row: 1, col: 1 });
126    }
127
128    #[test]
129    fn wrapped_line_cursor_at_wrap_boundary() {
130        let coord = prompt_cursor("abcdef", 3, 3, 0);
131        assert_eq!(coord, CursorCoord { row: 0, col: 3 });
132    }
133
134    #[test]
135    fn explicit_newline_cursor_on_second_line() {
136        let coord = prompt_cursor("line1\nline2", 7, 80, 0);
137        assert_eq!(coord, CursorCoord { row: 1, col: 1 });
138    }
139
140    #[test]
141    fn explicit_newline_cursor_at_line_start() {
142        let coord = prompt_cursor("line1\nline2", 6, 80, 0);
143        assert_eq!(coord, CursorCoord { row: 1, col: 0 });
144    }
145
146    #[test]
147    fn explicit_newline_cursor_at_newline_char() {
148        let coord = prompt_cursor("line1\nline2", 5, 80, 0);
149        assert_eq!(coord, CursorCoord { row: 0, col: 5 });
150    }
151
152    #[test]
153    fn indented_prompt_offsets_column() {
154        let coord = prompt_cursor("hello", 2, 80, 3);
155        assert_eq!(coord, CursorCoord { row: 0, col: 5 });
156    }
157
158    #[test]
159    fn indented_wrapped_line_resets_indent() {
160        let coord = prompt_cursor("abcdef", 4, 3, 3);
161        assert_eq!(coord, CursorCoord { row: 1, col: 4 });
162    }
163
164    #[test]
165    fn indented_multiline_resets_indent() {
166        let coord = prompt_cursor("ab\ncd", 4, 80, 3);
167        assert_eq!(coord, CursorCoord { row: 1, col: 4 });
168    }
169
170    #[test]
171    fn multibyte_cursor_counts_graphemes_not_bytes() {
172        let coord = prompt_cursor("héllo", 2, 80, 0);
173        assert_eq!(coord, CursorCoord { row: 0, col: 2 });
174    }
175
176    #[test]
177    fn multibyte_wrapped_cursor() {
178        let coord = prompt_cursor("éééé", 3, 2, 0);
179        assert_eq!(coord, CursorCoord { row: 1, col: 1 });
180    }
181
182    #[test]
183    fn cursor_beyond_end_clamps_to_final_row() {
184        let coord = prompt_cursor("ab", 99, 80, 0);
185        assert_eq!(coord, CursorCoord { row: 0, col: 2 });
186    }
187
188    #[test]
189    fn cursor_beyond_end_multiline() {
190        let coord = prompt_cursor("ab\ncd", 99, 80, 0);
191        assert_eq!(coord, CursorCoord { row: 1, col: 2 });
192    }
193
194    #[test]
195    fn prompt_rows_single_line() {
196        let rows = prompt_rows("hello", 80);
197        assert_eq!(rows, vec!["hello"]);
198    }
199
200    #[test]
201    fn prompt_rows_explicit_newline() {
202        let rows = prompt_rows("ab\ncd", 80);
203        assert_eq!(rows, vec!["ab", "cd"]);
204    }
205
206    #[test]
207    fn prompt_rows_wrapped() {
208        let rows = prompt_rows("abcdef", 3);
209        assert_eq!(rows, vec!["abc", "def"]);
210    }
211
212    #[test]
213    fn prompt_rows_empty() {
214        let rows = prompt_rows("", 80);
215        assert_eq!(rows, vec![""]);
216    }
217
218    #[test]
219    fn combined_wrap_and_newline() {
220        let rows = prompt_rows("abcd\nef", 2);
221        assert_eq!(rows, vec!["ab", "cd", "ef"]);
222
223        let coord = prompt_cursor("abcd\nef", 6, 2, 0);
224        assert_eq!(coord, CursorCoord { row: 2, col: 1 });
225    }
226
227    #[test]
228    fn cjk_wide_char_cursor_column() {
229        let coord = prompt_cursor("中", 1, 80, 0);
230        assert_eq!(coord, CursorCoord { row: 0, col: 2 });
231    }
232
233    #[test]
234    fn cjk_wide_char_wraps_correctly() {
235        let rows = prompt_rows("中中", 3);
236        assert_eq!(rows, vec!["中", "中"]);
237    }
238
239    #[test]
240    fn cjk_wide_char_cursor_after_wrap() {
241        let coord = prompt_cursor("中中", 1, 3, 0);
242        assert_eq!(coord, CursorCoord { row: 0, col: 2 });
243    }
244
245    #[test]
246    fn cjk_mixed_with_ascii_wraps_by_width() {
247        let rows = prompt_rows("a中b", 3);
248        assert_eq!(rows, vec!["a中", "b"]);
249    }
250
251    #[test]
252    fn combining_mark_does_not_advance_column() {
253        let text = "e\u{0301}";
254        let coord = prompt_cursor(text, 1, 80, 0);
255        assert_eq!(coord, CursorCoord { row: 0, col: 1 });
256    }
257
258    #[test]
259    fn combining_mark_does_not_cause_extra_wrap() {
260        let text = "ab\u{0327}";
261        let rows = prompt_rows(text, 2);
262        assert_eq!(rows, vec!["ab\u{0327}"], "combining mark should not force a wrap");
263    }
264
265    #[test]
266    fn emoji_width_2_cursor() {
267        let coord = prompt_cursor("\u{1F600}", 1, 80, 0);
268        assert_eq!(coord, CursorCoord { row: 0, col: 2 });
269    }
270
271    #[test]
272    fn emoji_zwj_one_grapheme_cursor() {
273        let text = "a\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}b";
274        let coord = prompt_cursor(text, 1, 80, 0);
275        assert_eq!(coord, CursorCoord { row: 0, col: 1 });
276
277        let coord = prompt_cursor(text, 2, 80, 0);
278        assert_eq!(coord, CursorCoord { row: 0, col: 3 });
279    }
280
281    #[test]
282    fn emoji_wraps_by_width() {
283        let rows = prompt_rows("\u{1F600}\u{1F600}", 3);
284        assert_eq!(rows, vec!["\u{1F600}", "\u{1F600}"]);
285    }
286
287    #[test]
288    fn zwnj_does_not_advance_width() {
289        let text = "ab\u{200C}cd";
290        let coord = prompt_cursor(text, 3, 80, 0);
291        assert_eq!(coord, CursorCoord { row: 0, col: 3 });
292    }
293}