Skip to main content

matchmaker/ui/
input.rs

1use ratatui::{
2    layout::{Position, Rect},
3    style::Stylize,
4    text::{Line, Span},
5    widgets::Paragraph,
6};
7use unicode_segmentation::UnicodeSegmentation;
8use unicode_width::UnicodeWidthStr;
9
10use crate::config::InputConfig;
11
12#[derive(Debug)]
13pub struct InputUI {
14    cursor: usize,     // index into graphemes, can = graphemes.len()
15    pub input: String, // remember to call recompute_graphemes() after modifying directly
16    /// (byte_index, width)
17    graphemes: Vec<(usize, u16)>,
18    pub prompt: Span<'static>,
19    before: usize,  // index into graphemes of the first visible grapheme
20    pub width: u16, // only relevant to cursor scrolling
21
22    pub config: InputConfig,
23}
24
25impl InputUI {
26    pub fn new(config: InputConfig) -> Self {
27        let mut ui = Self {
28            cursor: 0,
29            input: "".into(),
30            graphemes: Vec::new(),
31            prompt: Span::from(config.prompt.clone()),
32            config,
33            before: 0,
34            width: 0,
35        };
36
37        if !ui.config.initial.is_empty() {
38            ui.input = ui.config.initial.clone();
39            ui.recompute_graphemes();
40            ui.cursor = ui.graphemes.len();
41        }
42
43        ui
44    }
45
46    // -------- UTILS -----------
47    pub fn recompute_graphemes(&mut self) {
48        self.graphemes = self
49            .input
50            .grapheme_indices(true)
51            .map(|(idx, g)| (idx, g.width() as u16))
52            .collect();
53    }
54
55    fn byte_index(&self, grapheme_idx: usize) -> usize {
56        self.graphemes
57            .get(grapheme_idx)
58            .map(|(idx, _)| *idx)
59            .unwrap_or(self.input.len())
60    }
61
62    // ---------- GETTERS ---------
63
64    pub fn len(&self) -> usize {
65        self.input.len()
66    }
67    pub fn is_empty(&self) -> bool {
68        self.input.is_empty()
69    }
70
71    /// grapheme index
72    pub fn cursor(&self) -> u16 {
73        self.cursor as u16
74    }
75
76    /// Given a rect the widget is rendered with, produce the absolute position the cursor is rendered at.
77    pub fn cursor_offset(&self, rect: &Rect) -> Position {
78        let left = self.config.border.left();
79        let top = self.config.border.top();
80
81        let offset_x: u16 = self.graphemes[self.before..self.cursor]
82            .iter()
83            .map(|(_, w)| *w)
84            .sum();
85
86        Position::new(
87            rect.x + self.prompt.width() as u16 + left + offset_x,
88            rect.y + top,
89        )
90    }
91
92    // ------------ SETTERS ---------------
93    pub fn update_width(&mut self, width: u16) {
94        let text_width = width
95            .saturating_sub(self.prompt.width() as u16)
96            .saturating_sub(self.config.border.width());
97        if self.width != text_width {
98            self.width = text_width;
99        }
100    }
101
102    pub fn set(&mut self, input: impl Into<Option<String>>, cursor: u16) {
103        if let Some(input) = input.into() {
104            self.input = input;
105            self.recompute_graphemes();
106        }
107        self.cursor = (cursor as usize).min(self.graphemes.len());
108    }
109
110    pub fn push_char(&mut self, c: char) {
111        let byte_idx = self.byte_index(self.cursor);
112        self.input.insert(byte_idx, c);
113        self.recompute_graphemes();
114        self.cursor += 1;
115    }
116
117    pub fn push_str(&mut self, content: &str) {
118        let byte_idx = self.byte_index(self.cursor);
119        self.input.insert_str(byte_idx, content);
120        let added_graphemes = content.graphemes(true).count();
121        self.recompute_graphemes();
122        self.cursor += added_graphemes;
123    }
124
125    pub fn scroll_to_cursor(&mut self) {
126        if self.width == 0 {
127            return;
128        }
129        let padding = self.config.scroll_padding as usize;
130
131        // when cursor moves behind or on start, display grapheme before cursor as the first visible,
132        if self.before >= self.cursor {
133            self.before = self.cursor.saturating_sub(padding);
134            return;
135        }
136
137        // move start up
138        loop {
139            let visual_dist: u16 = self.graphemes
140                [self.before..=(self.cursor + padding).min(self.graphemes.len().saturating_sub(1))]
141                .iter()
142                .map(|(_, w)| *w)
143                .sum();
144
145            // ensures visual_start..=cursor is displayed
146            // Padding ensures the following element after cursor if present is displayed.
147            if visual_dist <= self.width {
148                break;
149            }
150
151            if self.before < self.cursor {
152                self.before += 1;
153            } else {
154                // never move before over cursor
155                break;
156            }
157        }
158    }
159
160    pub fn cancel(&mut self) {
161        self.input.clear();
162        self.graphemes.clear();
163        self.cursor = 0;
164        self.before = 0;
165    }
166    pub fn reset_prompt(&mut self) {
167        self.prompt = Span::from(self.config.prompt.clone());
168    }
169
170    /// Set cursor to a visual offset relative to start position
171    pub fn set_at_visual_offset(&mut self, visual_offset: u16) {
172        let mut current_width = 0;
173        let mut target_cursor = self.before;
174
175        for (i, &(_, width)) in self.graphemes.iter().enumerate().skip(self.before) {
176            if current_width + width > visual_offset {
177                // If clicked on the right half of a character, move cursor after it
178                if visual_offset - current_width > width / 2 {
179                    target_cursor = i + 1;
180                } else {
181                    target_cursor = i;
182                }
183                break;
184            }
185            current_width += width;
186            target_cursor = i + 1;
187        }
188
189        self.cursor = target_cursor;
190    }
191
192    // ---------- EDITING -------------
193    pub fn forward_char(&mut self) {
194        if self.cursor < self.graphemes.len() {
195            self.cursor += 1;
196        }
197    }
198    pub fn backward_char(&mut self) {
199        if self.cursor > 0 {
200            self.cursor -= 1;
201        }
202    }
203
204    pub fn forward_word(&mut self) {
205        let mut in_word = false;
206        while self.cursor < self.graphemes.len() {
207            let byte_start = self.graphemes[self.cursor].0;
208            let byte_end = self
209                .graphemes
210                .get(self.cursor + 1)
211                .map(|(idx, _)| *idx)
212                .unwrap_or(self.input.len());
213            let g = &self.input[byte_start..byte_end];
214
215            if g.chars().all(|c| c.is_whitespace()) {
216                if in_word {
217                    break;
218                }
219            } else {
220                in_word = true;
221            }
222            self.cursor += 1;
223        }
224    }
225
226    pub fn backward_word(&mut self) {
227        let mut in_word = false;
228        while self.cursor > 0 {
229            let byte_start = self.graphemes[self.cursor - 1].0;
230            let byte_end = self
231                .graphemes
232                .get(self.cursor)
233                .map(|(idx, _)| *idx)
234                .unwrap_or(self.input.len());
235            let g = &self.input[byte_start..byte_end];
236
237            if g.chars().all(|c| c.is_whitespace()) {
238                if in_word {
239                    break;
240                }
241            } else {
242                in_word = true;
243            }
244            self.cursor -= 1;
245        }
246    }
247
248    pub fn delete(&mut self) {
249        if self.cursor > 0 {
250            let start = self.graphemes[self.cursor - 1].0;
251            let end = self.byte_index(self.cursor);
252            self.input.replace_range(start..end, "");
253            self.recompute_graphemes();
254            self.cursor -= 1;
255        }
256    }
257
258    pub fn delete_word(&mut self) {
259        let old_cursor = self.cursor;
260        self.backward_word();
261        let new_cursor = self.cursor;
262
263        let start = self.byte_index(new_cursor);
264        let end = self.byte_index(old_cursor);
265        self.input.replace_range(start..end, "");
266        self.recompute_graphemes();
267    }
268
269    pub fn delete_line_start(&mut self) {
270        let end = self.byte_index(self.cursor);
271        self.input.replace_range(0..end, "");
272        self.recompute_graphemes();
273        self.cursor = 0;
274        self.before = 0;
275    }
276
277    pub fn delete_line_end(&mut self) {
278        let start = self.byte_index(self.cursor);
279        self.input.truncate(start);
280        self.recompute_graphemes();
281    }
282
283    // ---------------------------------------
284    // remember to call scroll_to_cursor beforehand
285    pub fn make_input(&self) -> Paragraph<'_> {
286        let mut visible_width = 0;
287        let mut end_idx = self.before;
288
289        while end_idx < self.graphemes.len() {
290            let g_width = self.graphemes[end_idx].1;
291            if self.width != 0 && visible_width + g_width > self.width {
292                break;
293            }
294            visible_width += g_width;
295            end_idx += 1;
296        }
297
298        let start_byte = self.byte_index(self.before);
299        let end_byte = self.byte_index(end_idx);
300        let visible_input = &self.input[start_byte..end_byte];
301
302        let line = Line::from(vec![
303            self.prompt.clone(),
304            Span::raw(visible_input)
305                .style(self.config.fg)
306                .add_modifier(self.config.modifier),
307        ]);
308
309        Paragraph::new(line).block(self.config.border.as_block())
310    }
311}