1use yuru_core::ScoredCandidate;
2
3#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum SelectionTarget {
10 Top,
13 Row(usize),
15}
16
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct TuiState {
20 query: String,
21 cursor: usize,
22 selected: usize,
26 target: SelectionTarget,
27 marked: Vec<usize>,
32}
33
34impl TuiState {
35 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 pub fn query(&self) -> &str {
50 &self.query
51 }
52
53 pub fn cursor(&self) -> usize {
55 self.cursor
56 }
57
58 pub fn selected(&self) -> usize {
60 self.selected
61 }
62
63 pub fn target(&self) -> SelectionTarget {
68 self.target
69 }
70
71 pub fn marked(&self) -> &[usize] {
73 &self.marked
74 }
75
76 pub fn is_marked(&self, id: usize) -> bool {
78 self.marked.contains(&id)
79 }
80
81 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 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 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 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 fn reset_selection(&mut self) {
213 self.selected = 0;
214 self.target = SelectionTarget::Top;
215 }
216
217 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)]
331pub enum TuiAction {
333 Insert(char),
335 Backspace,
337 Delete,
339 DeleteToEnd,
341 DeleteWord,
343 ClearQuery,
345 MoveCursorLeft,
347 MoveCursorRight,
349 MoveCursorStart,
351 MoveCursorEnd,
353 MoveCursorWordLeft,
355 MoveCursorWordRight,
357 MoveSelectionUp,
359 MoveSelectionDown,
361 MoveSelectionFirst,
363 MoveSelectionLast,
365 PageUp(usize),
367 PageDown(usize),
369 ToggleMark,
371 ToggleMarkAndDown,
373 ToggleMarkAndUp,
375 PreviewUp,
377 PreviewDown,
379 PreviewPageUp(usize),
381 PreviewPageDown(usize),
383 PreviewTop,
385 PreviewBottom,
387 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 while let Some(&(_, ch)) = iter.peek() {
412 if !is_word_boundary(ch) {
413 break;
414 }
415 iter.next();
416 }
417
418 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}