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