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