Skip to main content

yuru_tui/
state.rs

1use yuru_core::ScoredCandidate;
2
3/// What the selection points at, independently of where that lands in a result list.
4///
5/// A result list is replaced wholesale every time a search finishes, so a bare row index
6/// stops meaning the same row the moment that happens. Recording *what* is selected
7/// rather than *where* keeps the meaning across the replacement.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum SelectionTarget {
10    /// No row has been chosen since the query last changed, so the selection follows the
11    /// top of whatever the live search returns. This is the state right after typing.
12    Top,
13    /// A specific candidate the user moved to, identified by id and never by position.
14    Row(usize),
15}
16
17#[derive(Clone, Debug, Eq, PartialEq)]
18/// Mutable query, cursor, selection, and marking state.
19pub struct TuiState {
20    query: String,
21    cursor: usize,
22    /// Row the cursor is drawn on. This is a cache of where [`Self::target`] currently
23    /// sits, maintained by [`Self::reselect`] and by the selection-moving actions; it is
24    /// never the authority on what is selected.
25    selected: usize,
26    target: SelectionTarget,
27    /// Marked candidate ids, in the order they were marked. Marks are identities too, so
28    /// they survive a query change; the order is the one fzf prints them in. A list
29    /// rather than a set because it is only as long as the user has pressed the mark
30    /// key, and the order is part of the contract.
31    marked: Vec<usize>,
32}
33
34impl TuiState {
35    /// Creates TUI state with the given initial query.
36    pub fn new(query: impl Into<String>) -> Self {
37        let query = query.into();
38        let cursor = query.len();
39        Self {
40            query,
41            cursor,
42            selected: 0,
43            target: SelectionTarget::Top,
44            marked: Vec::new(),
45        }
46    }
47
48    /// Returns the current query text.
49    pub fn query(&self) -> &str {
50        &self.query
51    }
52
53    /// Returns the byte index of the query cursor.
54    pub fn cursor(&self) -> usize {
55        self.cursor
56    }
57
58    /// Returns the selected result index, for drawing the cursor.
59    pub fn selected(&self) -> usize {
60        self.selected
61    }
62
63    /// Returns what the selection points at.
64    ///
65    /// This is what an accept has to be resolved against: it stays meaningful while a
66    /// search is outstanding, whereas [`Self::selected`] does not.
67    pub fn target(&self) -> SelectionTarget {
68        self.target
69    }
70
71    /// Returns the marked candidate ids, in the order they were marked.
72    pub fn marked(&self) -> &[usize] {
73        &self.marked
74    }
75
76    /// Returns whether `id` is marked.
77    pub fn is_marked(&self, id: usize) -> bool {
78        self.marked.contains(&id)
79    }
80
81    /// Applies a state action against the result list the user is looking at.
82    ///
83    /// `results` is needed rather than just its length because every selection move
84    /// re-anchors [`Self::target`] to the row it lands on.
85    pub fn apply(&mut self, action: TuiAction, results: &[ScoredCandidate], cycle: bool) {
86        self.apply_with_results(action, results, cycle, false, None);
87    }
88
89    pub(crate) fn apply_with_results(
90        &mut self,
91        action: TuiAction,
92        results: &[ScoredCandidate],
93        cycle: bool,
94        multi: bool,
95        multi_limit: Option<usize>,
96    ) {
97        let result_len = results.len();
98        match action {
99            TuiAction::Insert(ch) => self.insert(ch),
100            TuiAction::Backspace => self.backspace(),
101            TuiAction::Delete => self.delete(),
102            TuiAction::DeleteOrExit => self.delete(),
103            TuiAction::DeleteToEnd => self.delete_to_end(),
104            TuiAction::DeleteWord => self.delete_word(),
105            TuiAction::ClearQuery => self.clear_query(),
106            TuiAction::MoveCursorLeft => self.move_cursor_left(),
107            TuiAction::MoveCursorRight => self.move_cursor_right(),
108            TuiAction::MoveCursorStart => self.cursor = 0,
109            TuiAction::MoveCursorEnd => self.cursor = self.query.len(),
110            TuiAction::MoveCursorWordLeft => self.move_cursor_word_left(),
111            TuiAction::MoveCursorWordRight => self.move_cursor_word_right(),
112            TuiAction::MoveSelectionUp => {
113                self.move_selection_up(result_len, cycle);
114                self.anchor_to_selected(results);
115            }
116            TuiAction::MoveSelectionDown => {
117                self.move_selection_down(result_len, cycle);
118                self.anchor_to_selected(results);
119            }
120            TuiAction::MoveSelectionFirst => {
121                self.selected = 0;
122                self.anchor_to_selected(results);
123            }
124            TuiAction::MoveSelectionLast => {
125                self.selected = result_len.saturating_sub(1);
126                self.anchor_to_selected(results);
127            }
128            TuiAction::PageUp(rows) => {
129                self.selected = self.selected.saturating_sub(rows.max(1));
130                self.anchor_to_selected(results);
131            }
132            TuiAction::PageDown(rows) => {
133                if result_len > 0 {
134                    self.selected = (self.selected + rows.max(1)).min(result_len - 1);
135                }
136                self.anchor_to_selected(results);
137            }
138            TuiAction::ToggleMark => {
139                self.toggle_selected_mark(results, multi, multi_limit);
140            }
141            TuiAction::ToggleMarkAndDown => {
142                self.toggle_selected_mark(results, multi, multi_limit);
143                self.move_selection_down(result_len, cycle);
144                self.anchor_to_selected(results);
145            }
146            TuiAction::ToggleMarkAndUp => {
147                self.toggle_selected_mark(results, multi, multi_limit);
148                self.move_selection_up(result_len, cycle);
149                self.anchor_to_selected(results);
150            }
151            TuiAction::PreviewUp
152            | TuiAction::PreviewDown
153            | TuiAction::PreviewPageUp(_)
154            | TuiAction::PreviewPageDown(_)
155            | TuiAction::PreviewTop
156            | TuiAction::PreviewBottom => {}
157        }
158    }
159
160    /// Re-resolves the selection against a freshly landed result list.
161    ///
162    /// This is the only place a result list replacement is allowed to move the cursor.
163    /// A [`SelectionTarget::Row`] that is still present keeps the selection on that same
164    /// candidate wherever it now sits. A row that is gone resets to the top and to
165    /// following the top, which is what fzf does and the least surprising of the
166    /// alternatives; an accept that was already committed against the lost row is
167    /// resolved from its own captured target and so is never redirected here.
168    pub(crate) fn reselect(&mut self, results: &[ScoredCandidate]) {
169        match self.target {
170            SelectionTarget::Top => self.selected = 0,
171            SelectionTarget::Row(id) => match results.iter().position(|row| row.id == id) {
172                Some(index) => self.selected = index,
173                None => self.reset_selection(),
174            },
175        }
176    }
177
178    /// Resolves `target` and the marks against `results` into accepted candidate ids.
179    ///
180    /// `target` is passed in rather than read from `self` because an accept made while a
181    /// search was outstanding has to resolve the selection as it was when the key was
182    /// pressed, not as it is once the replacement rows arrive.
183    pub(crate) fn accepted_ids(
184        &self,
185        target: SelectionTarget,
186        results: &[ScoredCandidate],
187        multi: bool,
188    ) -> Vec<usize> {
189        if multi && !self.marked.is_empty() {
190            return self.marked.clone();
191        }
192
193        match target {
194            // The row the user chose has to still be in the live results; if it is not,
195            // there is nothing to accept, and picking whatever took its place would
196            // return a row the user never selected.
197            SelectionTarget::Row(id) => {
198                if results.iter().any(|result| result.id == id) {
199                    vec![id]
200                } else {
201                    Vec::new()
202                }
203            }
204            SelectionTarget::Top => results
205                .first()
206                .map(|result| vec![result.id])
207                .unwrap_or_default(),
208        }
209    }
210
211    /// Points the selection back at the top of the list and at following the top.
212    fn reset_selection(&mut self) {
213        self.selected = 0;
214        self.target = SelectionTarget::Top;
215    }
216
217    /// Binds the target to the row the cursor now sits on.
218    fn anchor_to_selected(&mut self, results: &[ScoredCandidate]) {
219        if self.selected >= results.len() {
220            self.selected = results.len().saturating_sub(1);
221        }
222        self.target = match results.get(self.selected) {
223            Some(result) => SelectionTarget::Row(result.id),
224            None => SelectionTarget::Top,
225        };
226    }
227
228    fn toggle_selected_mark(
229        &mut self,
230        results: &[ScoredCandidate],
231        multi: bool,
232        multi_limit: Option<usize>,
233    ) {
234        if !multi {
235            return;
236        }
237        let Some(result) = results.get(self.selected) else {
238            return;
239        };
240        if self.marked.contains(&result.id) {
241            self.marked.retain(|marked| *marked != result.id);
242        } else if multi_limit.is_none_or(|limit| self.marked.len() < limit) {
243            self.marked.push(result.id);
244        }
245    }
246
247    fn insert(&mut self, ch: char) {
248        self.query.insert(self.cursor, ch);
249        self.cursor += ch.len_utf8();
250        self.reset_selection();
251    }
252
253    fn backspace(&mut self) {
254        if self.cursor == 0 {
255            return;
256        }
257        let previous = previous_boundary(&self.query, self.cursor);
258        self.query.drain(previous..self.cursor);
259        self.cursor = previous;
260        self.reset_selection();
261    }
262
263    fn delete(&mut self) {
264        if self.cursor == self.query.len() {
265            return;
266        }
267        let next = next_boundary(&self.query, self.cursor);
268        self.query.drain(self.cursor..next);
269        self.reset_selection();
270    }
271
272    fn delete_to_end(&mut self) {
273        self.query.truncate(self.cursor);
274        self.reset_selection();
275    }
276
277    fn delete_word(&mut self) {
278        if self.cursor == 0 {
279            return;
280        }
281        let word_start = previous_word_boundary(&self.query, self.cursor);
282        self.query.drain(word_start..self.cursor);
283        self.cursor = word_start;
284        self.reset_selection();
285    }
286
287    fn clear_query(&mut self) {
288        self.query.clear();
289        self.cursor = 0;
290        self.reset_selection();
291    }
292
293    fn move_cursor_left(&mut self) {
294        self.cursor = previous_boundary(&self.query, self.cursor);
295    }
296
297    fn move_cursor_right(&mut self) {
298        self.cursor = next_boundary(&self.query, self.cursor);
299    }
300
301    fn move_cursor_word_left(&mut self) {
302        self.cursor = previous_word_boundary(&self.query, self.cursor);
303    }
304
305    fn move_cursor_word_right(&mut self) {
306        self.cursor = next_word_boundary(&self.query, self.cursor);
307    }
308
309    fn move_selection_up(&mut self, result_len: usize, cycle: bool) {
310        if result_len == 0 {
311            self.selected = 0;
312        } else if self.selected == 0 {
313            self.selected = if cycle { result_len - 1 } else { 0 };
314        } else {
315            self.selected -= 1;
316        }
317    }
318
319    fn move_selection_down(&mut self, result_len: usize, cycle: bool) {
320        if result_len == 0 {
321            self.selected = 0;
322        } else if self.selected + 1 >= result_len {
323            self.selected = if cycle { 0 } else { result_len - 1 };
324        } else {
325            self.selected += 1;
326        }
327    }
328}
329
330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
331/// State transition used by the TUI event loop.
332pub enum TuiAction {
333    /// Insert a character at the query cursor.
334    Insert(char),
335    /// Delete the character before the cursor.
336    Backspace,
337    /// Delete the character at the cursor.
338    Delete,
339    /// Delete from cursor to end of line.
340    DeleteToEnd,
341    /// Delete word before cursor.
342    DeleteWord,
343    /// Clear the query text.
344    ClearQuery,
345    /// Move the query cursor left.
346    MoveCursorLeft,
347    /// Move the query cursor right.
348    MoveCursorRight,
349    /// Move the query cursor to the start.
350    MoveCursorStart,
351    /// Move the query cursor to the end.
352    MoveCursorEnd,
353    /// Move the query cursor to the start of the previous word.
354    MoveCursorWordLeft,
355    /// Move the query cursor to the end of the next word.
356    MoveCursorWordRight,
357    /// Move the selected row up.
358    MoveSelectionUp,
359    /// Move the selected row down.
360    MoveSelectionDown,
361    /// Move to the first row.
362    MoveSelectionFirst,
363    /// Move to the last row.
364    MoveSelectionLast,
365    /// Move selection up by the given number of rows.
366    PageUp(usize),
367    /// Move selection down by the given number of rows.
368    PageDown(usize),
369    /// Toggle the selected row mark.
370    ToggleMark,
371    /// Toggle the selected row mark and move down.
372    ToggleMarkAndDown,
373    /// Toggle the selected row mark and move up.
374    ToggleMarkAndUp,
375    /// Scroll preview up.
376    PreviewUp,
377    /// Scroll preview down.
378    PreviewDown,
379    /// Scroll preview up by the given number of rows.
380    PreviewPageUp(usize),
381    /// Scroll preview down by the given number of rows.
382    PreviewPageDown(usize),
383    /// Scroll preview to the top.
384    PreviewTop,
385    /// Scroll preview to the bottom.
386    PreviewBottom,
387    /// Delete character and exit if query becomes empty (Ctrl+D).
388    DeleteOrExit,
389}
390
391fn previous_boundary(text: &str, cursor: usize) -> usize {
392    text[..cursor]
393        .char_indices()
394        .next_back()
395        .map(|(index, _)| index)
396        .unwrap_or(0)
397}
398
399fn next_boundary(text: &str, cursor: usize) -> usize {
400    text[cursor..]
401        .char_indices()
402        .nth(1)
403        .map(|(index, _)| cursor + index)
404        .unwrap_or(text.len())
405}
406
407fn previous_word_boundary(text: &str, cursor: usize) -> usize {
408    let mut iter = text[..cursor].char_indices().rev().peekable();
409
410    // Skip any trailing boundary characters (cursor may sit right after whitespace).
411    while let Some(&(_, ch)) = iter.peek() {
412        if !is_word_boundary(ch) {
413            break;
414        }
415        iter.next();
416    }
417
418    // Skip word characters; the first boundary we peek at marks the word start.
419    while let Some(&(index, ch)) = iter.peek() {
420        if is_word_boundary(ch) {
421            return index + ch.len_utf8();
422        }
423        iter.next();
424    }
425
426    0
427}
428
429fn next_word_boundary(text: &str, cursor: usize) -> usize {
430    let mut iter = text[cursor..].char_indices().peekable();
431
432    let first_is_word = iter
433        .peek()
434        .map(|(_, ch)| !is_word_boundary(*ch))
435        .unwrap_or(false);
436
437    if first_is_word {
438        return iter
439            .find(|(_, ch)| is_word_boundary(*ch))
440            .map(|(index, _)| cursor + index)
441            .unwrap_or(text.len());
442    }
443
444    for (_, ch) in iter.by_ref() {
445        if !is_word_boundary(ch) {
446            return iter
447                .find(|(_, next_ch)| is_word_boundary(*next_ch))
448                .map(|(index, _)| cursor + index)
449                .unwrap_or(text.len());
450        }
451    }
452
453    text.len()
454}
455
456fn is_word_boundary(ch: char) -> bool {
457    ch.is_whitespace() || ch == '/' || ch == '-' || ch == '_' || ch == '.'
458}