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 push_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 scroll_to_cursor(&mut self) {
129        if self.width == 0 {
130            return;
131        }
132        let padding = self.config.scroll_padding as usize;
133
134        // when cursor moves behind or on start, display grapheme before cursor as the first visible,
135        if self.before >= self.cursor {
136            self.before = self.cursor.saturating_sub(padding);
137            return;
138        }
139
140        // move start up
141        loop {
142            let visual_dist: u16 = self.graphemes
143                [self.before..=(self.cursor + padding).min(self.graphemes.len().saturating_sub(1))]
144                .iter()
145                .map(|(_, w)| *w)
146                .sum();
147
148            // ensures visual_start..=cursor is displayed
149            // Padding ensures the following element after cursor if present is displayed.
150            if visual_dist <= self.width {
151                break;
152            }
153
154            if self.before < self.cursor {
155                self.before += 1;
156            } else {
157                // never move before over cursor
158                break;
159            }
160        }
161    }
162
163    pub fn cancel(&mut self) {
164        self.input.clear();
165        self.graphemes.clear();
166        self.cursor = 0;
167        self.before = 0;
168    }
169
170    pub fn prepare_column_change(&mut self) {
171        let trimmed = self.input.trim_end();
172        if let Some(pos) = trimmed.rfind(' ') {
173            let last_word = &trimmed[pos + 1..];
174            if last_word.starts_with('%') {
175                let bytes = trimmed[..pos].len();
176                self.input.truncate(bytes);
177            }
178        } else if trimmed.starts_with('%') {
179            self.input.clear();
180        }
181
182        if !self.input.is_empty() && !self.input.ends_with(' ') {
183            self.input.push(' ');
184        }
185        self.recompute_graphemes();
186        self.cursor = self.graphemes.len();
187    }
188
189    /// Set cursor to a visual offset relative to start position
190    pub fn set_at_visual_offset(&mut self, visual_offset: u16) {
191        let mut current_width = 0;
192        let mut target_cursor = self.before;
193
194        for (i, &(_, width)) in self.graphemes.iter().enumerate().skip(self.before) {
195            if current_width + width > visual_offset {
196                // If clicked on the right half of a character, move cursor after it
197                if visual_offset - current_width > width / 2 {
198                    target_cursor = i + 1;
199                } else {
200                    target_cursor = i;
201                }
202                break;
203            }
204            current_width += width;
205            target_cursor = i + 1;
206        }
207
208        self.cursor = target_cursor;
209    }
210
211    // ---------- EDITING -------------
212    pub fn forward_char(&mut self) {
213        if self.cursor < self.graphemes.len() {
214            self.cursor += 1;
215        }
216    }
217    pub fn backward_char(&mut self) {
218        if self.cursor > 0 {
219            self.cursor -= 1;
220        }
221    }
222
223    pub fn forward_word(&mut self) {
224        let mut in_word = false;
225        while self.cursor < self.graphemes.len() {
226            let byte_start = self.graphemes[self.cursor].0;
227            let byte_end = self
228                .graphemes
229                .get(self.cursor + 1)
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 backward_word(&mut self) {
246        let mut in_word = false;
247        while self.cursor > 0 {
248            let byte_start = self.graphemes[self.cursor - 1].0;
249            let byte_end = self
250                .graphemes
251                .get(self.cursor)
252                .map(|(idx, _)| *idx)
253                .unwrap_or(self.input.len());
254            let g = &self.input[byte_start..byte_end];
255
256            if g.chars().all(|c| c.is_whitespace()) {
257                if in_word {
258                    break;
259                }
260            } else {
261                in_word = true;
262            }
263            self.cursor -= 1;
264        }
265    }
266
267    pub fn delete(&mut self) {
268        if self.cursor > 0 {
269            let start = self.graphemes[self.cursor - 1].0;
270            let end = self.byte_index(self.cursor);
271            self.input.replace_range(start..end, "");
272            self.recompute_graphemes();
273            self.cursor -= 1;
274        }
275    }
276
277    pub fn delete_word(&mut self) {
278        let old_cursor = self.cursor;
279        self.backward_word();
280        let new_cursor = self.cursor;
281
282        let start = self.byte_index(new_cursor);
283        let end = self.byte_index(old_cursor);
284        self.input.replace_range(start..end, "");
285        self.recompute_graphemes();
286    }
287
288    pub fn delete_line_start(&mut self) {
289        let end = self.byte_index(self.cursor);
290        self.input.replace_range(0..end, "");
291        self.recompute_graphemes();
292        self.cursor = 0;
293        self.before = 0;
294    }
295
296    pub fn delete_line_end(&mut self) {
297        let start = self.byte_index(self.cursor);
298        self.input.truncate(start);
299        self.recompute_graphemes();
300    }
301
302    // ---------------------------------------
303    // remember to call scroll_to_cursor beforehand
304    pub fn make_input(&self) -> Paragraph<'_> {
305        let mut visible_width = 0;
306        let mut end_idx = self.before;
307
308        while end_idx < self.graphemes.len() {
309            let g_width = self.graphemes[end_idx].1;
310            if self.width != 0 && visible_width + g_width > self.width {
311                break;
312            }
313            visible_width += g_width;
314            end_idx += 1;
315        }
316
317        let start_byte = self.byte_index(self.before);
318        let end_byte = self.byte_index(end_idx);
319        let visible_input = &self.input[start_byte..end_byte];
320
321        let mut line = self.prompt.clone();
322        line.push_span(Span::styled(visible_input, self.config.text_style()));
323
324        Paragraph::new(line).block(self.config.border.as_block())
325    }
326
327    /// Set the input ui prefix. The prompt style from the config is used as the base style.
328    pub fn set_prompt(&mut self, template: Option<Line<'static>>) {
329        let line = template
330            .unwrap_or_else(|| self.config.prompt.clone().into())
331            .style(self.config.prompt_style());
332        self.set_prompt_line(line);
333    }
334
335    /// Set the input ui prefix directly.
336    pub fn set_prompt_line(&mut self, prompt: Line<'static>) {
337        let old_width = self.prompt.to_string().width();
338        let new_width = prompt.to_string().width();
339
340        if new_width > old_width {
341            self.width = self.width.saturating_sub((new_width - old_width) as u16);
342        } else if old_width > new_width {
343            self.width += (old_width - new_width) as u16;
344        }
345
346        self.prompt = prompt;
347    }
348}