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,
16    /// (byte_index, width)
17    graphemes: Vec<(usize, u16)>,
18    pub config: InputConfig,
19    pub prompt: Span<'static>,
20    before: usize, // index into graphemes of the first visible grapheme
21    pub width: u16,
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: Span::from(config.prompt.clone()),
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    fn recompute_graphemes(&mut self) {
46        self.graphemes = self
47            .input
48            .grapheme_indices(true)
49            .map(|(idx, g)| (idx, g.width() as u16))
50            .collect();
51    }
52
53    fn byte_index(&self, grapheme_idx: usize) -> usize {
54        self.graphemes
55            .get(grapheme_idx)
56            .map(|(idx, _)| *idx)
57            .unwrap_or(self.input.len())
58    }
59
60    // ---------- GETTERS ---------
61
62    pub fn len(&self) -> usize {
63        self.input.len()
64    }
65    pub fn is_empty(&self) -> bool {
66        self.input.is_empty()
67    }
68
69    /// grapheme index
70    pub fn cursor(&self) -> u16 {
71        self.cursor as u16
72    }
73
74    /// Given a rect the widget is rendered with, produce the absolute position the cursor is rendered at.
75    pub fn cursor_offset(&self, rect: &Rect) -> Position {
76        let left = self.config.border.left();
77        let top = self.config.border.top();
78
79        let offset_x: u16 = self.graphemes[self.before..self.cursor]
80            .iter()
81            .map(|(_, w)| *w)
82            .sum();
83
84        Position::new(
85            rect.x + self.prompt.width() as u16 + left + offset_x,
86            rect.y + top,
87        )
88    }
89
90    pub fn push_char(&mut self, c: char) {
91        let byte_idx = self.byte_index(self.cursor);
92        self.input.insert(byte_idx, c);
93        self.recompute_graphemes();
94        self.cursor += 1;
95    }
96
97    pub fn push_str(&mut self, content: &str) {
98        let byte_idx = self.byte_index(self.cursor);
99        self.input.insert_str(byte_idx, content);
100        let added_graphemes = content.graphemes(true).count();
101        self.recompute_graphemes();
102        self.cursor += added_graphemes;
103    }
104
105    // ------------ SETTERS ---------------
106    pub fn set(&mut self, input: impl Into<Option<String>>, cursor: u16) {
107        if let Some(input) = input.into() {
108            self.input = input;
109            self.recompute_graphemes();
110        }
111        self.cursor = (cursor as usize).min(self.graphemes.len());
112    }
113
114    pub fn update_width(&mut self, width: u16) {
115        let text_width = width
116            .saturating_sub(self.prompt.width() as u16)
117            .saturating_sub(self.config.border.width());
118        if self.width != text_width {
119            self.width = text_width;
120        }
121    }
122
123    pub fn scroll_to_cursor(&mut self) {
124        if self.width == 0 {
125            return;
126        }
127        let padding = self.config.scroll_padding as usize;
128
129        // when cursor moves behind or on start, display grapheme before cursor as the first visible,
130        if self.before >= self.cursor {
131            self.before = self.cursor.saturating_sub(padding);
132            return;
133        }
134
135        // move start up
136        loop {
137            let visual_dist: u16 = self.graphemes
138                [self.before..=(self.cursor + padding).min(self.graphemes.len().saturating_sub(1))]
139                .iter()
140                .map(|(_, w)| *w)
141                .sum();
142
143            // ensures visual_start..=cursor is displayed
144            // Padding ensures the following element after cursor if present is displayed.
145            if visual_dist <= self.width {
146                break;
147            }
148
149            if self.before < self.cursor {
150                self.before += 1;
151            } else {
152                // never move before over cursor
153                break;
154            }
155        }
156    }
157
158    pub fn set_at_visual_offset(&mut self, visual_offset: u16) {
159        let mut current_width = 0;
160        let mut target_cursor = self.before;
161
162        for (i, &(_, width)) in self.graphemes.iter().enumerate().skip(self.before) {
163            if current_width + width > visual_offset {
164                // If clicked on the right half of a character, move cursor after it
165                if visual_offset - current_width > width / 2 {
166                    target_cursor = i + 1;
167                } else {
168                    target_cursor = i;
169                }
170                break;
171            }
172            current_width += width;
173            target_cursor = i + 1;
174        }
175
176        self.cursor = target_cursor;
177    }
178
179    pub fn cancel(&mut self) {
180        self.input.clear();
181        self.graphemes.clear();
182        self.cursor = 0;
183        self.before = 0;
184    }
185    pub fn reset_prompt(&mut self) {
186        self.prompt = Span::from(self.config.prompt.clone());
187    }
188
189    // ---------- EDITING -------------
190    pub fn forward_char(&mut self) {
191        if self.cursor < self.graphemes.len() {
192            self.cursor += 1;
193        }
194    }
195    pub fn backward_char(&mut self) {
196        if self.cursor > 0 {
197            self.cursor -= 1;
198        }
199    }
200
201    pub fn forward_word(&mut self) {
202        let mut in_word = false;
203        while self.cursor < self.graphemes.len() {
204            let byte_start = self.graphemes[self.cursor].0;
205            let byte_end = self
206                .graphemes
207                .get(self.cursor + 1)
208                .map(|(idx, _)| *idx)
209                .unwrap_or(self.input.len());
210            let g = &self.input[byte_start..byte_end];
211
212            if g.chars().all(|c| c.is_whitespace()) {
213                if in_word {
214                    break;
215                }
216            } else {
217                in_word = true;
218            }
219            self.cursor += 1;
220        }
221    }
222
223    pub fn backward_word(&mut self) {
224        let mut in_word = false;
225        while self.cursor > 0 {
226            let byte_start = self.graphemes[self.cursor - 1].0;
227            let byte_end = self
228                .graphemes
229                .get(self.cursor)
230                .map(|(idx, _)| *idx)
231                .unwrap_or(self.input.len());
232            let g = &self.input[byte_start..byte_end];
233
234            if g.chars().all(|c| c.is_whitespace()) {
235                if in_word {
236                    break;
237                }
238            } else {
239                in_word = true;
240            }
241            self.cursor -= 1;
242        }
243    }
244
245    pub fn delete(&mut self) {
246        if self.cursor > 0 {
247            let start = self.graphemes[self.cursor - 1].0;
248            let end = self.byte_index(self.cursor);
249            self.input.replace_range(start..end, "");
250            self.recompute_graphemes();
251            self.cursor -= 1;
252        }
253    }
254
255    pub fn delete_word(&mut self) {
256        let old_cursor = self.cursor;
257        self.backward_word();
258        let new_cursor = self.cursor;
259
260        let start = self.byte_index(new_cursor);
261        let end = self.byte_index(old_cursor);
262        self.input.replace_range(start..end, "");
263        self.recompute_graphemes();
264    }
265
266    pub fn delete_line_start(&mut self) {
267        let end = self.byte_index(self.cursor);
268        self.input.replace_range(0..end, "");
269        self.recompute_graphemes();
270        self.cursor = 0;
271        self.before = 0;
272    }
273
274    pub fn delete_line_end(&mut self) {
275        let start = self.byte_index(self.cursor);
276        self.input.truncate(start);
277        self.recompute_graphemes();
278    }
279
280    // ---------------------------------------
281    // remember to call scroll_to_cursor beforehand
282    pub fn make_input(&self) -> Paragraph<'_> {
283        let mut visible_width = 0;
284        let mut end_idx = self.before;
285
286        while end_idx < self.graphemes.len() {
287            let g_width = self.graphemes[end_idx].1;
288            if visible_width + g_width > self.width {
289                break;
290            }
291            visible_width += g_width;
292            end_idx += 1;
293        }
294
295        let start_byte = self.byte_index(self.before);
296        let end_byte = self.byte_index(end_idx);
297        let visible_input = &self.input[start_byte..end_byte];
298
299        let line = Line::from(vec![
300            self.prompt.clone(),
301            Span::raw(visible_input)
302                .style(self.config.fg)
303                .add_modifier(self.config.modifier),
304        ]);
305
306        Paragraph::new(line).block(self.config.border.as_block())
307    }
308}