requestty_ui/select/
mod.rs

1use std::{
2    io,
3    ops::{Index, IndexMut},
4};
5
6use crate::{
7    backend::Backend,
8    events::{KeyEvent, Movement},
9    layout::{Layout, RenderRegion},
10    style::Stylize,
11};
12
13#[cfg(test)]
14mod tests;
15
16/// A trait to represent a renderable list.
17///
18/// See [`Select`]
19pub trait List {
20    /// Render a single element at some index.
21    ///
22    /// When rendering the element, only _at most_ [`layout.max_height`] lines can be used. If more
23    /// lines are used, the list may not be rendered properly. The place the terminal cursor ends at
24    /// does not matter.
25    ///
26    /// [`layout.max_height`] may be less than the height given by [`height_at`].
27    /// [`layout.render_region`] can be used to determine which part of the element you want to
28    /// render.
29    ///
30    /// [`height_at`]: List::height_at
31    /// [`layout.max_height`]: Layout::max_height
32    /// [`layout.render_region`]: Layout.render_region
33    fn render_item<B: Backend>(
34        &mut self,
35        index: usize,
36        hovered: bool,
37        layout: Layout,
38        backend: &mut B,
39    ) -> io::Result<()>;
40
41    /// Whether the element at a particular index is selectable. Those that are not selectable are
42    /// skipped during navigation.
43    fn is_selectable(&self, index: usize) -> bool;
44
45    /// The maximum height that can be taken by the list.
46    ///
47    /// If the total height exceeds the page size, the list will be scrollable.
48    fn page_size(&self) -> usize;
49
50    /// Whether to wrap around when user gets to the last element.
51    ///
52    /// This only applies when the list is scrollable, i.e. page size > total height.
53    fn should_loop(&self) -> bool;
54
55    /// The height of the element at an index will take to render
56    fn height_at(&mut self, index: usize, layout: Layout) -> u16;
57
58    /// The length of the list
59    fn len(&self) -> usize;
60
61    /// Returns true if the list has no elements
62    fn is_empty(&self) -> bool {
63        self.len() == 0
64    }
65}
66
67#[derive(Debug, Clone)]
68struct Heights {
69    heights: Vec<u16>,
70    prev_layout: Layout,
71}
72
73/// A widget to select a single item from a list.
74///
75/// The list must implement the [`List`] trait.
76#[derive(Debug, Clone)]
77pub struct Select<L> {
78    first_selectable: usize,
79    last_selectable: usize,
80    at: usize,
81    page_start: usize,
82    page_end: usize,
83    page_start_height: u16,
84    page_end_height: u16,
85    height: u16,
86    heights: Option<Heights>,
87    /// The underlying list
88    pub list: L,
89}
90
91impl<L: List> Select<L> {
92    /// Creates a new [`Select`].
93    ///
94    /// # Panics
95    ///
96    /// Panics if there are no selectable items, or if `list.page_size()` is less than 5.
97    pub fn new(list: L) -> Self {
98        let first_selectable = (0..list.len())
99            .position(|i| list.is_selectable(i))
100            .expect("there must be at least one selectable item");
101
102        let last_selectable = (0..list.len())
103            .rposition(|i| list.is_selectable(i))
104            .unwrap();
105
106        assert!(list.page_size() >= 5, "page size can be a minimum of 5");
107
108        Self {
109            first_selectable,
110            last_selectable,
111            height: u16::MAX,
112            page_start_height: u16::MAX,
113            page_end_height: u16::MAX,
114            heights: None,
115            at: first_selectable,
116            page_start: 0,
117            page_end: usize::MAX,
118            list,
119        }
120    }
121
122    /// The index of the element that is currently being hovered.
123    pub fn get_at(&self) -> usize {
124        self.at
125    }
126
127    /// Set the index of the element that is currently being hovered.
128    ///
129    /// `at` can be any number (even beyond `list.len()`), but the caller is responsible for making
130    /// sure that it is a selectable element.
131    pub fn set_at(&mut self, at: usize) {
132        let dir = if self.at >= self.list.len() || self.at < at {
133            Movement::Down
134        } else {
135            Movement::Up
136        };
137
138        self.at = at;
139
140        if self.is_paginating() {
141            if at >= self.list.len() {
142                self.init_page();
143            } else if self.heights.is_some() {
144                self.maybe_adjust_page(dir);
145            }
146        }
147    }
148
149    /// Consumes the [`Select`] returning the original list.
150    pub fn into_inner(self) -> L {
151        self.list
152    }
153
154    fn next_selectable(&self) -> usize {
155        if self.at >= self.last_selectable {
156            return if self.list.should_loop() {
157                self.first_selectable
158            } else {
159                self.last_selectable
160            };
161        }
162
163        // at not guaranteed to be in the valid range of 0..list.len(), so the min is required
164        let mut at = self.at.min(self.list.len());
165        loop {
166            at = (at + 1) % self.list.len();
167            if self.list.is_selectable(at) {
168                break;
169            }
170        }
171        at
172    }
173
174    fn prev_selectable(&self) -> usize {
175        if self.at <= self.first_selectable {
176            return if self.list.should_loop() {
177                self.last_selectable
178            } else {
179                self.first_selectable
180            };
181        }
182
183        // at not guaranteed to be in the valid range of 0..list.len(), so the min is required
184        let mut at = self.at.min(self.list.len());
185        loop {
186            at = (self.list.len() + at - 1) % self.list.len();
187            if self.list.is_selectable(at) {
188                break;
189            }
190        }
191        at
192    }
193
194    fn maybe_update_heights(&mut self, mut layout: Layout) {
195        let heights = match self.heights {
196            Some(ref mut heights) if heights.prev_layout != layout => {
197                heights.heights.clear();
198                heights.prev_layout = layout;
199                &mut heights.heights
200            }
201            None => {
202                self.heights = Some(Heights {
203                    heights: Vec::with_capacity(self.list.len()),
204                    prev_layout: layout,
205                });
206
207                &mut self.heights.as_mut().unwrap().heights
208            }
209            _ => return,
210        };
211
212        layout.line_offset = 0;
213
214        self.height = 0;
215        for i in 0..self.list.len() {
216            let height = self.list.height_at(i, layout);
217            self.height += height;
218            heights.push(height);
219        }
220    }
221
222    fn page_size(&self) -> u16 {
223        self.list.page_size() as u16
224    }
225
226    fn is_paginating(&self) -> bool {
227        self.height > self.page_size()
228    }
229
230    /// Checks whether the page bounds need to be adjusted
231    ///
232    /// This returns true if at == page_start || at == page_end, and so even though it is visible,
233    /// the page bounds should be adjusted
234    fn at_outside_page(&self) -> bool {
235        if self.page_start < self.page_end {
236            // - a - - S - - - - - - E - a -
237            //   ^------- outside -------^
238            self.at <= self.page_start || self.at >= self.page_end
239        } else {
240            // - - - - E - - - a - - S - - -
241            //       outside --^
242            self.at <= self.page_start && self.at >= self.page_end
243        }
244    }
245
246    /// Gets the index at a given delta taking into account looping if enabled -- delta must be
247    /// within ±len
248    fn try_get_index(&self, delta: isize) -> Option<usize> {
249        if delta.is_positive() {
250            let res = self.at + delta as usize;
251
252            if res < self.list.len() {
253                Some(res)
254            } else if self.list.should_loop() {
255                Some(res - self.list.len())
256            } else {
257                None
258            }
259        } else {
260            let delta = -delta as usize;
261            if self.list.should_loop() {
262                Some((self.at + self.list.len() - delta) % self.list.len())
263            } else {
264                self.at.checked_sub(delta)
265            }
266        }
267    }
268
269    /// Adjust the page considering the direction we moved to
270    fn adjust_page(&mut self, moved_to: Movement) {
271        // note direction here refers to the direction we moved _from_, while moved means the
272        // direction we moved _to_, and so they have opposite meanings
273        let direction = match moved_to {
274            Movement::Down => -1,
275            Movement::Up => 1,
276            _ => unreachable!(),
277        };
278
279        let heights = &self
280            .heights
281            .as_ref()
282            .expect("`adjust_page` called before `height` or `render`")
283            .heights[..];
284
285        // -1 since the message at the end takes one line
286        let max_height = self.page_size() - 1;
287
288        // This first gets an element from the direction we have moved from, then one
289        // from the opposite, and the rest again from the direction we have move from
290        //
291        // for example,
292        // take that we have moved downwards (like from 2 to 3).
293        // .-----.
294        // |  0  | <-- iter[3]
295        // .-----.
296        // |  1  | <-- iter[2]
297        // .-----.
298        // |  2  | <-- iter[0] | We want this over 4 since we have come from that
299        // .-----.               direction and it provides continuity
300        // |  3  | <-- self.at
301        // .-----.
302        // |  4  | <-- iter[1] | We pick 4 over ones before 2 since it provides a
303        // '-----'               padding of one element at the end
304        //
305        // note: the above example avoids things like looping, which is handled by
306        // try_get_index
307        let iter = self
308            .try_get_index(direction)
309            .map(|i| (i, false))
310            .into_iter()
311            .chain(
312                self.try_get_index(-direction)
313                    .map(|i| (i, true)) // boolean value to show this is special
314                    .into_iter(),
315            )
316            .chain(
317                (2..(max_height as isize))
318                    .filter_map(|i| self.try_get_index(direction * i).map(|i| (i, false))),
319            );
320
321        // these variables have opposite meaning based on the direction, but they store
322        // the (index, height) of either the page_start or the page_end
323        let mut bound_a = (self.at, heights[self.at]);
324        let mut bound_b = (self.at, heights[self.at]);
325
326        let mut height = heights[self.at];
327
328        for (height_index, opposite_dir) in iter {
329            if height >= max_height {
330                // There are no more elements that can be shown
331                break;
332            }
333
334            let elem_height = if opposite_dir {
335                // To provide better continuity, the element in the opposite direction
336                // will have only one line shown. This prevents the cursor from jumping
337                // about when the element in the opposite direction has different height
338                // from the one rendered previously
339                1
340            } else {
341                (height + heights[height_index]).min(max_height) - height
342            };
343
344            // If you see the creation of iter, this special cases the second element in
345            // the iterator as it is the _only_ one in the opposite direction
346            //
347            // It cannot simply be checked as being the second element, as try_get_index
348            // may return None when looping is disabled
349            if opposite_dir {
350                bound_b.0 = height_index;
351                bound_b.1 = elem_height;
352            } else {
353                bound_a.0 = height_index;
354                bound_a.1 = elem_height;
355            }
356
357            height += elem_height;
358        }
359
360        if let Movement::Down = moved_to {
361            // When moving down, the special case is the element after `self.at`, so it
362            // is the page_end
363            self.page_start = bound_a.0;
364            self.page_start_height = bound_a.1;
365            self.page_end = bound_b.0;
366            self.page_end_height = bound_b.1;
367        } else {
368            // When moving up, the special case is the element before `self.at`, so it
369            // is the page_start
370            self.page_start = bound_b.0;
371            self.page_start_height = bound_b.1;
372            self.page_end = bound_a.0;
373            self.page_end_height = bound_a.1;
374        }
375    }
376
377    /// Adjust the page if required considering the direction we moved to
378    fn maybe_adjust_page(&mut self, moved_to: Movement) {
379        // Check whether at is within second and second last element of the page
380        if self.at_outside_page() {
381            self.adjust_page(moved_to)
382        }
383    }
384
385    fn init_page(&mut self) {
386        let heights = &self
387            .heights
388            .as_ref()
389            .expect("`init_page` called before `height` or `render`")
390            .heights[..];
391
392        self.page_start = 0;
393        self.page_start_height = heights[self.page_start];
394
395        if self.is_paginating() {
396            let mut height = heights[0];
397            // -1 since the message at the end takes one line
398            let max_height = self.page_size() - 1;
399
400            #[allow(clippy::needless_range_loop)]
401            for i in 1..heights.len() {
402                if height >= max_height {
403                    break;
404                }
405                self.page_end = i;
406                self.page_end_height = (height + heights[i]).min(max_height) - height;
407
408                height += heights[i];
409            }
410        } else {
411            self.page_end = self.list.len() - 1;
412            self.page_end_height = heights[self.page_end];
413        }
414    }
415
416    /// Renders the lines in a given iterator
417    fn render_in<I: Iterator<Item = usize>, B: Backend>(
418        &mut self,
419        iter: I,
420        old_layout: &mut Layout,
421        b: &mut B,
422    ) -> io::Result<()> {
423        let heights = &self
424            .heights
425            .as_ref()
426            .expect("`render_in` called from someplace other than `render`")
427            .heights[..];
428
429        // Create a new local copy of the layout to operate on to avoid changes in max_height and
430        // render_region to be reflected upstream
431        let mut layout = *old_layout;
432
433        for i in iter {
434            if i == self.page_start {
435                layout.max_height = self.page_start_height;
436                layout.render_region = RenderRegion::Bottom;
437            } else if i == self.page_end {
438                layout.max_height = self.page_end_height;
439                layout.render_region = RenderRegion::Top;
440            } else {
441                layout.max_height = heights[i];
442            }
443
444            self.list.render_item(i, i == self.at, layout, b)?;
445            layout.offset_y += layout.max_height;
446
447            b.move_cursor_to(layout.offset_x, layout.offset_y)?;
448        }
449
450        old_layout.offset_y = layout.offset_y;
451        layout.line_offset = 0;
452
453        Ok(())
454    }
455}
456
457impl<L: Index<usize>> Select<L> {
458    /// Returns a reference to the currently hovered item.
459    pub fn selected(&self) -> &L::Output {
460        &self.list[self.at]
461    }
462}
463
464impl<L: IndexMut<usize>> Select<L> {
465    /// Returns a mutable reference to the currently hovered item.
466    pub fn selected_mut(&mut self) -> &mut L::Output {
467        &mut self.list[self.at]
468    }
469}
470
471impl<L: List> super::Widget for Select<L> {
472    fn handle_key(&mut self, key: KeyEvent) -> bool {
473        let movement = match Movement::try_from_key(key) {
474            Some(movement) => movement,
475            None => return false,
476        };
477
478        let moved = match movement {
479            Movement::Up if self.list.should_loop() || self.at > self.first_selectable => {
480                self.at = self.prev_selectable();
481                Movement::Up
482            }
483            Movement::Down if self.list.should_loop() || self.at < self.last_selectable => {
484                self.at = self.next_selectable();
485                Movement::Down
486            }
487
488            Movement::PageUp
489                if !self.is_paginating() // No pagination, PageUp is same as Home
490                    // No looping and first item is shown in this page
491                    || (!self.list.should_loop() && self.page_start == 0) =>
492            {
493                if self.at <= self.first_selectable {
494                    return false;
495                }
496                self.at = self.first_selectable;
497                Movement::Up
498            }
499            Movement::PageUp => {
500                // We want the current self.at to be visible after the PageUp movement,
501                // and if possible we want to it to be the bottom most element visible
502
503                // We decrease self.at by 1, since adjust_page will put self.at as the
504                // second last element, so if (self.at - 1) is the second last element,
505                // self.at is the last element visible
506                self.at = self.try_get_index(-1).unwrap_or(self.at);
507                self.adjust_page(Movement::Down);
508
509                if self.page_start == 0 && !self.list.should_loop() {
510                    // We've reached the end, it is possible that because of the bounds
511                    // we gave earlier, self.page_end may not be right so we have to
512                    // recompute it
513                    self.at = self.first_selectable;
514                    self.init_page();
515                } else {
516                    // Now that the page is determined, we want to set self.at to be some
517                    // _selectable_ element which is not the top most element visible,
518                    // so we undershoot by 1
519                    self.at = self.page_start;
520                    // ...and then go forward at least one element
521                    //
522                    // note: self.at cannot directly be set to self.page_start + 1, since it
523                    // also has to be a selectable element
524                    self.at = self.next_selectable();
525                }
526
527                Movement::Up
528            }
529
530            Movement::PageDown
531                if !self.is_paginating() // No pagination, PageDown same as End
532                    || (!self.list.should_loop() // No looping and last item is shown in this page
533                        && self.page_end + 1 == self.list.len()) =>
534            {
535                if self.at >= self.last_selectable {
536                    return false;
537                }
538                self.at = self.last_selectable;
539                Movement::Down
540            }
541            Movement::PageDown => {
542                // We want the current self.at to be visible after the PageDown movement,
543                // and if possible we want to it to be the top most element visible
544
545                // We increase self.at by 1, since adjust_page will put self.at as the
546                // second element, so if (self.at + 1) is the second last element,
547                // self.at is the last element visible
548                self.at = self.try_get_index(1).unwrap_or(self.at);
549                self.adjust_page(Movement::Up);
550
551                // Now that the page is determined, we want to set self.at to be some
552                // _selectable_ element which is not the bottom most element visible,
553                // so we overshoot by 1...
554                self.at = self.page_end;
555
556                if self.page_end + 1 == self.list.len() && !self.list.should_loop() {
557                    // ...but since we reached the end and there is no looping, self.page_start may
558                    // not be right so we have to recompute it
559                    self.adjust_page(Movement::Down);
560                    self.at = self.last_selectable;
561                } else {
562                    // ...and then go back to at least one element
563                    //
564                    // note: self.at cannot directly be set to self.page_end - 1, since it
565                    // also has to be a selectable element
566                    self.at = self.prev_selectable();
567                }
568
569                Movement::Down
570            }
571
572            Movement::Home if self.at != self.first_selectable => {
573                self.at = self.first_selectable;
574                Movement::Up
575            }
576            Movement::End if self.at != self.last_selectable => {
577                self.at = self.last_selectable;
578                Movement::Down
579            }
580
581            _ => return false,
582        };
583
584        if self.is_paginating() {
585            self.maybe_adjust_page(moved)
586        }
587
588        true
589    }
590
591    fn render<B: Backend>(&mut self, layout: &mut Layout, b: &mut B) -> io::Result<()> {
592        self.maybe_update_heights(*layout);
593
594        // this is the first render, so we need to set page_end
595        if self.page_end == usize::MAX {
596            self.init_page();
597        }
598
599        if layout.line_offset != 0 {
600            layout.line_offset = 0;
601            layout.offset_y += 1;
602            b.move_cursor_to(layout.offset_x, layout.offset_y)?;
603        }
604
605        if self.page_end < self.page_start {
606            self.render_in(
607                (self.page_start..self.list.len()).chain(0..=self.page_end),
608                layout,
609                b,
610            )?;
611        } else {
612            self.render_in(self.page_start..=self.page_end, layout, b)?;
613        }
614
615        if self.is_paginating() {
616            // This is the message at the end that other places refer to
617            b.write_styled(&"(Move up and down to reveal more choices)".dark_grey())?;
618            layout.offset_y += 1;
619
620            b.move_cursor_to(layout.offset_x, layout.offset_y)?;
621        }
622
623        Ok(())
624    }
625
626    /// Returns the starting location of the layout. It should not be relied upon for a sensible
627    /// cursor position.
628    fn cursor_pos(&mut self, layout: Layout) -> (u16, u16) {
629        layout.offset_cursor((layout.line_offset, 0))
630    }
631
632    fn height(&mut self, layout: &mut Layout) -> u16 {
633        self.maybe_update_heights(*layout);
634
635        let height = (layout.line_offset != 0) as u16 // Add one if we go to the next line
636            // Try to show everything
637            + self
638                .height
639                // otherwise show whatever is possible
640                .min(self.page_size())
641                // but do not show less than a single element
642                .max(
643                    self.heights
644                    .as_ref()
645                    .expect("`maybe_update_heights` should set `self.heights` if missing")
646                    .heights
647                    .get(self.at)
648                    .unwrap_or(&0)
649                    // +1 if paginating since the message at the end takes one line
650                    + self.is_paginating() as u16,
651                );
652
653        layout.line_offset = 0;
654        layout.offset_y += height;
655
656        height
657    }
658}