Skip to main content

matchmaker/ui/
input.rs

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