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
167    /// Restore prompt from config
168    pub fn reset_prompt(&mut self) {
169        self.prompt = Span::from(self.config.prompt.clone());
170    }
171
172    /// Set cursor to a visual offset relative to start position
173    pub fn set_at_visual_offset(&mut self, visual_offset: u16) {
174        let mut current_width = 0;
175        let mut target_cursor = self.before;
176
177        for (i, &(_, width)) in self.graphemes.iter().enumerate().skip(self.before) {
178            if current_width + width > visual_offset {
179                // If clicked on the right half of a character, move cursor after it
180                if visual_offset - current_width > width / 2 {
181                    target_cursor = i + 1;
182                } else {
183                    target_cursor = i;
184                }
185                break;
186            }
187            current_width += width;
188            target_cursor = i + 1;
189        }
190
191        self.cursor = target_cursor;
192    }
193
194    // ---------- EDITING -------------
195    pub fn forward_char(&mut self) {
196        if self.cursor < self.graphemes.len() {
197            self.cursor += 1;
198        }
199    }
200    pub fn backward_char(&mut self) {
201        if self.cursor > 0 {
202            self.cursor -= 1;
203        }
204    }
205
206    pub fn forward_word(&mut self) {
207        let mut in_word = false;
208        while self.cursor < self.graphemes.len() {
209            let byte_start = self.graphemes[self.cursor].0;
210            let byte_end = self
211                .graphemes
212                .get(self.cursor + 1)
213                .map(|(idx, _)| *idx)
214                .unwrap_or(self.input.len());
215            let g = &self.input[byte_start..byte_end];
216
217            if g.chars().all(|c| c.is_whitespace()) {
218                if in_word {
219                    break;
220                }
221            } else {
222                in_word = true;
223            }
224            self.cursor += 1;
225        }
226    }
227
228    pub fn backward_word(&mut self) {
229        let mut in_word = false;
230        while self.cursor > 0 {
231            let byte_start = self.graphemes[self.cursor - 1].0;
232            let byte_end = self
233                .graphemes
234                .get(self.cursor)
235                .map(|(idx, _)| *idx)
236                .unwrap_or(self.input.len());
237            let g = &self.input[byte_start..byte_end];
238
239            if g.chars().all(|c| c.is_whitespace()) {
240                if in_word {
241                    break;
242                }
243            } else {
244                in_word = true;
245            }
246            self.cursor -= 1;
247        }
248    }
249
250    pub fn delete(&mut self) {
251        if self.cursor > 0 {
252            let start = self.graphemes[self.cursor - 1].0;
253            let end = self.byte_index(self.cursor);
254            self.input.replace_range(start..end, "");
255            self.recompute_graphemes();
256            self.cursor -= 1;
257        }
258    }
259
260    pub fn delete_word(&mut self) {
261        let old_cursor = self.cursor;
262        self.backward_word();
263        let new_cursor = self.cursor;
264
265        let start = self.byte_index(new_cursor);
266        let end = self.byte_index(old_cursor);
267        self.input.replace_range(start..end, "");
268        self.recompute_graphemes();
269    }
270
271    pub fn delete_line_start(&mut self) {
272        let end = self.byte_index(self.cursor);
273        self.input.replace_range(0..end, "");
274        self.recompute_graphemes();
275        self.cursor = 0;
276        self.before = 0;
277    }
278
279    pub fn delete_line_end(&mut self) {
280        let start = self.byte_index(self.cursor);
281        self.input.truncate(start);
282        self.recompute_graphemes();
283    }
284
285    // ---------------------------------------
286    // remember to call scroll_to_cursor beforehand
287    pub fn make_input(&self) -> Paragraph<'_> {
288        let mut visible_width = 0;
289        let mut end_idx = self.before;
290
291        while end_idx < self.graphemes.len() {
292            let g_width = self.graphemes[end_idx].1;
293            if self.width != 0 && visible_width + g_width > self.width {
294                break;
295            }
296            visible_width += g_width;
297            end_idx += 1;
298        }
299
300        let start_byte = self.byte_index(self.before);
301        let end_byte = self.byte_index(end_idx);
302        let visible_input = &self.input[start_byte..end_byte];
303
304        let line = Line::from(vec![
305            self.prompt.clone(),
306            Span::raw(visible_input)
307                .style(self.config.fg)
308                .add_modifier(self.config.modifier),
309        ]);
310
311        Paragraph::new(line).block(self.config.border.as_block())
312    }
313}