Skip to main content

matchmaker/ui/
input.rs

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