Skip to main content

thndrs_lib/cli/
input.rs

1//! Cursor-aware single-line/multi-line text input model.
2//!
3//! Wraps a `String` with a **grapheme-cluster index** cursor so that text can
4//! be inserted and deleted at an arbitrary point, not just appended. All public
5//! operations keep the cursor within bounds (`0..=grapheme_count`).
6//!
7//! A grapheme cluster is a user-perceived character, specifically a base codepoint
8//! plus any combining marks, zero-width joiners, and other sequence constituents.
9//! By indexing the cursor in grapheme clusters, cursor movement, backspace,
10//! delete, and transpose all operate on what the user sees as a single
11//! character, even when that character is composed of multiple Rust `char`s.
12
13pub mod history;
14
15#[cfg(test)]
16mod tests;
17
18use std::convert::Infallible;
19use std::str::FromStr;
20
21use unicode_segmentation::UnicodeSegmentation;
22
23/// A cursor-aware text buffer used for the prompt input line.
24///
25/// The cursor is stored as a **grapheme cluster index** so that combining marks,
26/// emoji sequences, and other multi-codepoint clusters are treated as a single
27/// unit. It is always in the range `0..=grapheme_count`.
28#[derive(Clone, Debug, Default, PartialEq, Eq)]
29pub struct PromptInput {
30    text: String,
31    /// Grapheme-cluster index of the cursor within `text`.
32    cursor: usize,
33}
34
35impl From<String> for PromptInput {
36    fn from(s: String) -> Self {
37        Self::from_text(&s)
38    }
39}
40
41impl From<&str> for PromptInput {
42    fn from(s: &str) -> Self {
43        Self::from_text(s)
44    }
45}
46
47impl FromStr for PromptInput {
48    type Err = Infallible;
49
50    fn from_str(s: &str) -> Result<Self, Self::Err> {
51        Ok(Self::from_text(s))
52    }
53}
54
55impl PromptInput {
56    /// Create an empty input.
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    fn from_text(s: &str) -> Self {
62        let cursor = s.graphemes(true).count();
63        Self { text: s.to_string(), cursor }
64    }
65
66    /// The full text content.
67    pub fn as_str(&self) -> &str {
68        &self.text
69    }
70
71    /// The full text content, owned.
72    pub fn text(&self) -> String {
73        self.text.clone()
74    }
75
76    /// Current cursor position as a grapheme-cluster index.
77    pub fn cursor(&self) -> usize {
78        self.cursor
79    }
80
81    /// Number of grapheme clusters in the buffer.
82    pub fn len_graphemes(&self) -> usize {
83        self.text.graphemes(true).count()
84    }
85
86    /// Iterator over the grapheme clusters.
87    pub fn graphemes(&self) -> unicode_segmentation::Graphemes<'_> {
88        self.text.graphemes(true)
89    }
90
91    /// Whether the buffer is empty.
92    pub fn is_empty(&self) -> bool {
93        self.text.is_empty()
94    }
95
96    /// Clear all text and reset the cursor.
97    pub fn clear(&mut self) {
98        self.text.clear();
99        self.cursor = 0;
100    }
101
102    /// Set the text to `s`, placing the cursor at the end.
103    pub fn set_text(&mut self, s: &str) {
104        self.text = s.to_string();
105        self.cursor = self.len_graphemes();
106    }
107
108    /// Move the cursor left by one grapheme cluster (clamped at 0).
109    pub fn cursor_left(&mut self) {
110        self.cursor = self.cursor.saturating_sub(1);
111    }
112
113    /// Move the cursor right by one grapheme cluster (clamped at end).
114    pub fn cursor_right(&mut self) {
115        if self.cursor < self.len_graphemes() {
116            self.cursor += 1;
117        }
118    }
119
120    /// Move the cursor to the start of the line (grapheme index 0).
121    pub fn cursor_to_start(&mut self) {
122        self.cursor = 0;
123    }
124
125    /// Move the cursor to the end of the text.
126    pub fn cursor_to_end(&mut self) {
127        self.cursor = self.len_graphemes();
128    }
129
130    /// Move the cursor to the previous logical line, preserving the column as
131    /// closely as possible. Returns `true` when the cursor moved.
132    pub fn cursor_up(&mut self) -> bool {
133        let graphemes: Vec<&str> = self.graphemes().collect();
134        let Some((line_start, column)) = line_start_and_column(&graphemes, self.cursor) else {
135            return false;
136        };
137        if line_start == 0 {
138            return false;
139        }
140
141        let prev_end = line_start.saturating_sub(1);
142        let prev_start = graphemes[..prev_end]
143            .iter()
144            .rposition(|g| g.contains('\n'))
145            .map_or(0, |idx| idx + 1);
146        let prev_len = prev_end.saturating_sub(prev_start);
147        self.cursor = prev_start + column.min(prev_len);
148        true
149    }
150
151    /// Move the cursor to the next logical line, preserving the column as
152    /// closely as possible. Returns `true` when the cursor moved.
153    pub fn cursor_down(&mut self) -> bool {
154        let graphemes: Vec<&str> = self.graphemes().collect();
155        let Some((line_start, column)) = line_start_and_column(&graphemes, self.cursor) else {
156            return false;
157        };
158        let current_end = graphemes[line_start..]
159            .iter()
160            .position(|g| g.contains('\n'))
161            .map(|offset| line_start + offset);
162        let Some(current_end) = current_end else {
163            return false;
164        };
165        let next_start = current_end + 1;
166        let next_end = graphemes[next_start..]
167            .iter()
168            .position(|g| g.contains('\n'))
169            .map_or(graphemes.len(), |offset| next_start + offset);
170        let next_len = next_end.saturating_sub(next_start);
171        self.cursor = next_start + column.min(next_len);
172        true
173    }
174
175    /// Move the cursor left to the start of the previous Unicode word.
176    ///
177    /// Uses `unicode-segmentation` word boundaries so that punctuation,
178    /// numbers, and other non-whitespace runs are treated as word units.
179    pub fn cursor_word_left(&mut self) {
180        let graphemes: Vec<&str> = self.graphemes().collect();
181        if self.cursor == 0 {
182            return;
183        }
184        self.cursor = prev_word_boundary(&graphemes, self.cursor);
185    }
186
187    /// Move the cursor right to the start of the next Unicode word.
188    ///
189    /// Uses `unicode-segmentation` word boundaries so that punctuation,
190    /// numbers, and other non-whitespace runs are treated as word units.
191    pub fn cursor_word_right(&mut self) {
192        let graphemes: Vec<&str> = self.graphemes().collect();
193        let len = graphemes.len();
194        if self.cursor >= len {
195            return;
196        }
197        self.cursor = next_word_boundary(&graphemes, self.cursor);
198    }
199
200    /// Insert a character at the cursor, then advance the cursor past it.
201    pub fn insert_char(&mut self, ch: char) {
202        let byte_idx = self.byte_offset_of(self.cursor);
203        self.text.insert(byte_idx, ch);
204        self.cursor += 1;
205    }
206
207    /// Insert a string at the cursor, advancing the cursor to the end of the
208    /// inserted text.
209    pub fn insert_str(&mut self, s: &str) {
210        let count = s.graphemes(true).count();
211        if count == 0 {
212            return;
213        }
214        let byte_idx = self.byte_offset_of(self.cursor);
215        self.text.insert_str(byte_idx, s);
216        self.cursor += count;
217    }
218
219    /// Replace a grapheme-cluster range and place the cursor after the inserted text.
220    pub fn replace_range(&mut self, start: usize, end: usize, replacement: &str) {
221        let len = self.len_graphemes();
222        let start = start.min(len);
223        let end = end.min(len).max(start);
224        let start_byte = self.byte_offset_of(start);
225        let end_byte = self.byte_offset_of(end);
226        self.text.replace_range(start_byte..end_byte, replacement);
227        self.cursor = start + replacement.graphemes(true).count();
228    }
229
230    /// Delete the grapheme cluster to the **left** of the cursor (backspace).
231    ///
232    /// Returns `true` if a grapheme was deleted.
233    pub fn backspace(&mut self) -> bool {
234        if self.cursor == 0 {
235            return false;
236        }
237        let graphemes: Vec<&str> = self.graphemes().collect();
238        let prev = graphemes[self.cursor - 1];
239        let byte_idx = self.byte_offset_of(self.cursor - 1);
240        self.text.replace_range(byte_idx..byte_idx + prev.len(), "");
241        self.cursor -= 1;
242        true
243    }
244
245    /// Delete the grapheme cluster to the **right** of the cursor (forward delete).
246    ///
247    /// Returns `true` if a grapheme was deleted.
248    pub fn delete_forward(&mut self) -> bool {
249        let len = self.len_graphemes();
250        if self.cursor >= len {
251            return false;
252        }
253        let graphemes: Vec<&str> = self.graphemes().collect();
254        let cur = graphemes[self.cursor];
255        let byte_idx = self.byte_offset_of(self.cursor);
256        self.text.replace_range(byte_idx..byte_idx + cur.len(), "");
257        true
258    }
259
260    /// Kill from the cursor to the end of the line. Returns the killed text.
261    pub fn kill_to_end_of_line(&mut self) -> String {
262        let graphemes: Vec<&str> = self.graphemes().collect();
263        let line_end = graphemes[self.cursor..]
264            .iter()
265            .position(|g| g.contains('\n'))
266            .map(|offset| self.cursor + offset)
267            .unwrap_or_else(|| self.len_graphemes());
268        let byte_idx = self.byte_offset_of(self.cursor);
269        let end_byte = self.byte_offset_of(line_end);
270        let killed = self.text[byte_idx..end_byte].to_string();
271        self.text.replace_range(byte_idx..end_byte, "");
272        killed
273    }
274
275    /// Kill from the start of the line to the cursor. Returns the killed text.
276    pub fn kill_to_start_of_line(&mut self) -> String {
277        let graphemes: Vec<&str> = self.graphemes().collect();
278        let line_start = graphemes[..self.cursor]
279            .iter()
280            .rposition(|g| g.contains('\n'))
281            .map_or(0, |idx| idx + 1);
282        let start_byte = self.byte_offset_of(line_start);
283        let end_byte = self.byte_offset_of(self.cursor);
284        let killed = self.text[start_byte..end_byte].to_string();
285        self.text.replace_range(start_byte..end_byte, "");
286        self.cursor = line_start;
287        killed
288    }
289
290    /// Kill the word before the cursor (unix-word-rubout style). Uses Unicode
291    /// word boundaries. Returns the killed text.
292    pub fn kill_word_left(&mut self) -> String {
293        let graphemes: Vec<&str> = self.graphemes().collect();
294        if self.cursor == 0 {
295            return String::new();
296        }
297        let target = prev_word_boundary(&graphemes, self.cursor);
298        let start_byte = self.byte_offset_of(target);
299        let end_byte = self.byte_offset_of(self.cursor);
300        let killed = self.text[start_byte..end_byte].to_string();
301        self.text.replace_range(start_byte..end_byte, "");
302        self.cursor = target;
303        killed
304    }
305
306    /// Kill the word after the cursor. Uses Unicode word boundaries.
307    /// Returns the killed text.
308    pub fn kill_word_right(&mut self) -> String {
309        let graphemes: Vec<&str> = self.graphemes().collect();
310        let len = graphemes.len();
311        if self.cursor >= len {
312            return String::new();
313        }
314        let target = next_word_boundary(&graphemes, self.cursor);
315        let start_byte = self.byte_offset_of(self.cursor);
316        let end_byte = self.byte_offset_of(target);
317        let killed = self.text[start_byte..end_byte].to_string();
318        self.text.replace_range(start_byte..end_byte, "");
319        killed
320    }
321
322    /// Transpose the grapheme clusters at the cursor and just before it.
323    /// Returns `true` if a transposition happened.
324    ///
325    /// At the start of the line, swaps the first two graphemes and moves the
326    /// cursor past both. In the middle, swaps the grapheme before and at the
327    /// cursor, then advances. At the end, swaps the last two graphemes.
328    pub fn transpose_chars(&mut self) -> bool {
329        let graphemes: Vec<&str> = self.graphemes().collect();
330        if graphemes.len() < 2 {
331            return false;
332        }
333        let (a, b) = if self.cursor >= graphemes.len() {
334            (graphemes.len() - 2, graphemes.len() - 1)
335        } else if self.cursor == 0 {
336            (0, 1)
337        } else {
338            (self.cursor - 1, self.cursor)
339        };
340        let byte_a = self.byte_offset_of(a);
341        let byte_b = self.byte_offset_of(b);
342        let combined = format!("{}{}", graphemes[b], graphemes[a]);
343        self.text.replace_range(byte_a..byte_b + graphemes[b].len(), &combined);
344        self.cursor = b + 1;
345        true
346    }
347
348    /// Yank (paste) text at the cursor, advancing the cursor past it.
349    pub fn yank(&mut self, text: &str) {
350        self.insert_str(text);
351    }
352
353    /// Convert a grapheme-cluster index to a byte offset into `text`.
354    fn byte_offset_of(&self, grapheme_index: usize) -> usize {
355        self.text
356            .grapheme_indices(true)
357            .nth(grapheme_index)
358            .map(|(byte_idx, _)| byte_idx)
359            .unwrap_or_else(|| self.text.len())
360    }
361
362    /// The text before the cursor.
363    pub fn text_before_cursor(&self) -> &str {
364        let byte_idx = self.byte_offset_of(self.cursor);
365        &self.text[..byte_idx]
366    }
367}
368
369/// Find the line start (grapheme index) and column (grapheme offset from line
370/// start) for the given cursor position.
371fn line_start_and_column(graphemes: &[&str], cursor: usize) -> Option<(usize, usize)> {
372    if cursor > graphemes.len() {
373        return None;
374    }
375    let line_start = graphemes[..cursor]
376        .iter()
377        .rposition(|g| g.contains('\n'))
378        .map_or(0, |idx| idx + 1);
379    Some((line_start, cursor.saturating_sub(line_start)))
380}
381
382/// Find the start of the previous Unicode word (non-whitespace segment) at or
383/// before `cursor`.
384///
385/// Uses `unicode-segmentation` word boundaries, skipping whitespace-only
386/// segments. For "foo bar baz" at cursor 11, returns 8 (start of "baz").
387fn prev_word_boundary(graphemes: &[&str], cursor: usize) -> usize {
388    if cursor == 0 {
389        return 0;
390    }
391    let combined: String = graphemes[..cursor].concat();
392    let segments: Vec<(usize, &str)> = combined
393        .split_word_bound_indices()
394        .map(|(byte_off, word)| {
395            let g_off = combined[..byte_off].graphemes(true).count();
396            (g_off, word)
397        })
398        .collect();
399
400    for &(start, word) in segments.iter().rev() {
401        if start < cursor && !word.trim().is_empty() {
402            return start;
403        }
404    }
405    0
406}
407
408/// Find the start of the next Unicode word (non-whitespace segment) at or
409/// after `cursor`.
410///
411/// Uses `unicode-segmentation` word boundaries, skipping whitespace-only
412/// segments. For "foo bar baz" at cursor 0, returns 4 (start of "bar").
413fn next_word_boundary(graphemes: &[&str], cursor: usize) -> usize {
414    let len = graphemes.len();
415    if cursor >= len {
416        return len;
417    }
418    let combined: String = graphemes[cursor..].concat();
419    let segments: Vec<(usize, &str)> = combined
420        .split_word_bound_indices()
421        .map(|(byte_off, word)| {
422            let g_off = combined[..byte_off].graphemes(true).count();
423            (g_off, word)
424        })
425        .collect();
426
427    for &(start, word) in segments.iter() {
428        if start > 0 && !word.trim().is_empty() {
429            return cursor + start;
430        }
431    }
432    len
433}