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).map(|i| (i, true)), // boolean value to show this is special
313            )
314            .chain(
315                (2..(max_height as isize))
316                    .filter_map(|i| self.try_get_index(direction * i).map(|i| (i, false))),
317            );
318
319        // these variables have opposite meaning based on the direction, but they store
320        // the (index, height) of either the page_start or the page_end
321        let mut bound_a = (self.at, heights[self.at]);
322        let mut bound_b = (self.at, heights[self.at]);
323
324        let mut height = heights[self.at];
325
326        for (height_index, opposite_dir) in iter {
327            if height >= max_height {
328                // There are no more elements that can be shown
329                break;
330            }
331
332            let elem_height = if opposite_dir {
333                // To provide better continuity, the element in the opposite direction
334                // will have only one line shown. This prevents the cursor from jumping
335                // about when the element in the opposite direction has different height
336                // from the one rendered previously
337                1
338            } else {
339                (height + heights[height_index]).min(max_height) - height
340            };
341
342            // If you see the creation of iter, this special cases the second element in
343            // the iterator as it is the _only_ one in the opposite direction
344            //
345            // It cannot simply be checked as being the second element, as try_get_index
346            // may return None when looping is disabled
347            if opposite_dir {
348                bound_b.0 = height_index;
349                bound_b.1 = elem_height;
350            } else {
351                bound_a.0 = height_index;
352                bound_a.1 = elem_height;
353            }
354
355            height += elem_height;
356        }
357
358        if let Movement::Down = moved_to {
359            // When moving down, the special case is the element after `self.at`, so it
360            // is the page_end
361            self.page_start = bound_a.0;
362            self.page_start_height = bound_a.1;
363            self.page_end = bound_b.0;
364            self.page_end_height = bound_b.1;
365        } else {
366            // When moving up, the special case is the element before `self.at`, so it
367            // is the page_start
368            self.page_start = bound_b.0;
369            self.page_start_height = bound_b.1;
370            self.page_end = bound_a.0;
371            self.page_end_height = bound_a.1;
372        }
373    }
374
375    /// Adjust the page if required considering the direction we moved to
376    fn maybe_adjust_page(&mut self, moved_to: Movement) {
377        // Check whether at is within second and second last element of the page
378        if self.at_outside_page() {
379            self.adjust_page(moved_to)
380        }
381    }
382
383    fn init_page(&mut self) {
384        let heights = &self
385            .heights
386            .as_ref()
387            .expect("`init_page` called before `height` or `render`")
388            .heights[..];
389
390        self.page_start = 0;
391        self.page_start_height = heights[self.page_start];
392
393        if self.is_paginating() {
394            let mut height = heights[0];
395            // -1 since the message at the end takes one line
396            let max_height = self.page_size() - 1;
397
398            #[allow(clippy::needless_range_loop)]
399            for i in 1..heights.len() {
400                if height >= max_height {
401                    break;
402                }
403                self.page_end = i;
404                self.page_end_height = (height + heights[i]).min(max_height) - height;
405
406                height += heights[i];
407            }
408        } else {
409            self.page_end = self.list.len() - 1;
410            self.page_end_height = heights[self.page_end];
411        }
412    }
413
414    /// Renders the lines in a given iterator
415    fn render_in<I: Iterator<Item = usize>, B: Backend>(
416        &mut self,
417        iter: I,
418        old_layout: &mut Layout,
419        b: &mut B,
420    ) -> io::Result<()> {
421        let heights = &self
422            .heights
423            .as_ref()
424            .expect("`render_in` called from someplace other than `render`")
425            .heights[..];
426
427        // Create a new local copy of the layout to operate on to avoid changes in max_height and
428        // render_region to be reflected upstream
429        let mut layout = *old_layout;
430
431        for i in iter {
432            if i == self.page_start {
433                layout.max_height = self.page_start_height;
434                layout.render_region = RenderRegion::Bottom;
435            } else if i == self.page_end {
436                layout.max_height = self.page_end_height;
437                layout.render_region = RenderRegion::Top;
438            } else {
439                layout.max_height = heights[i];
440            }
441
442            self.list.render_item(i, i == self.at, layout, b)?;
443            layout.offset_y += layout.max_height;
444
445            b.move_cursor_to(layout.offset_x, layout.offset_y)?;
446        }
447
448        old_layout.offset_y = layout.offset_y;
449        layout.line_offset = 0;
450
451        Ok(())
452    }
453}
454
455impl<L: Index<usize>> Select<L> {
456    /// Returns a reference to the currently hovered item.
457    pub fn selected(&self) -> &L::Output {
458        &self.list[self.at]
459    }
460}
461
462impl<L: IndexMut<usize>> Select<L> {
463    /// Returns a mutable reference to the currently hovered item.
464    pub fn selected_mut(&mut self) -> &mut L::Output {
465        &mut self.list[self.at]
466    }
467}
468
469impl<L: List> super::Widget for Select<L> {
470    fn handle_key(&mut self, key: KeyEvent) -> bool {
471        let movement = match Movement::try_from_key(key) {
472            Some(movement) => movement,
473            None => return false,
474        };
475
476        let moved = match movement {
477            Movement::Up if self.list.should_loop() || self.at > self.first_selectable => {
478                self.at = self.prev_selectable();
479                Movement::Up
480            }
481            Movement::Down if self.list.should_loop() || self.at < self.last_selectable => {
482                self.at = self.next_selectable();
483                Movement::Down
484            }
485
486            Movement::PageUp
487                if !self.is_paginating() // No pagination, PageUp is same as Home
488                    // No looping and first item is shown in this page
489                    || (!self.list.should_loop() && self.page_start == 0) =>
490            {
491                if self.at <= self.first_selectable {
492                    return false;
493                }
494                self.at = self.first_selectable;
495                Movement::Up
496            }
497            Movement::PageUp => {
498                // We want the current self.at to be visible after the PageUp movement,
499                // and if possible we want to it to be the bottom most element visible
500
501                // We decrease self.at by 1, since adjust_page will put self.at as the
502                // second last element, so if (self.at - 1) is the second last element,
503                // self.at is the last element visible
504                self.at = self.try_get_index(-1).unwrap_or(self.at);
505                self.adjust_page(Movement::Down);
506
507                if self.page_start == 0 && !self.list.should_loop() {
508                    // We've reached the end, it is possible that because of the bounds
509                    // we gave earlier, self.page_end may not be right so we have to
510                    // recompute it
511                    self.at = self.first_selectable;
512                    self.init_page();
513                } else {
514                    // Now that the page is determined, we want to set self.at to be some
515                    // _selectable_ element which is not the top most element visible,
516                    // so we undershoot by 1
517                    self.at = self.page_start;
518                    // ...and then go forward at least one element
519                    //
520                    // note: self.at cannot directly be set to self.page_start + 1, since it
521                    // also has to be a selectable element
522                    self.at = self.next_selectable();
523                }
524
525                Movement::Up
526            }
527
528            Movement::PageDown
529                if !self.is_paginating() // No pagination, PageDown same as End
530                    || (!self.list.should_loop() // No looping and last item is shown in this page
531                        && self.page_end + 1 == self.list.len()) =>
532            {
533                if self.at >= self.last_selectable {
534                    return false;
535                }
536                self.at = self.last_selectable;
537                Movement::Down
538            }
539            Movement::PageDown => {
540                // We want the current self.at to be visible after the PageDown movement,
541                // and if possible we want to it to be the top most element visible
542
543                // We increase self.at by 1, since adjust_page will put self.at as the
544                // second element, so if (self.at + 1) is the second last element,
545                // self.at is the last element visible
546                self.at = self.try_get_index(1).unwrap_or(self.at);
547                self.adjust_page(Movement::Up);
548
549                // Now that the page is determined, we want to set self.at to be some
550                // _selectable_ element which is not the bottom most element visible,
551                // so we overshoot by 1...
552                self.at = self.page_end;
553
554                if self.page_end + 1 == self.list.len() && !self.list.should_loop() {
555                    // ...but since we reached the end and there is no looping, self.page_start may
556                    // not be right so we have to recompute it
557                    self.adjust_page(Movement::Down);
558                    self.at = self.last_selectable;
559                } else {
560                    // ...and then go back to at least one element
561                    //
562                    // note: self.at cannot directly be set to self.page_end - 1, since it
563                    // also has to be a selectable element
564                    self.at = self.prev_selectable();
565                }
566
567                Movement::Down
568            }
569
570            Movement::Home if self.at != self.first_selectable => {
571                self.at = self.first_selectable;
572                Movement::Up
573            }
574            Movement::End if self.at != self.last_selectable => {
575                self.at = self.last_selectable;
576                Movement::Down
577            }
578
579            _ => return false,
580        };
581
582        if self.is_paginating() {
583            self.maybe_adjust_page(moved)
584        }
585
586        true
587    }
588
589    fn render<B: Backend>(&mut self, layout: &mut Layout, b: &mut B) -> io::Result<()> {
590        self.maybe_update_heights(*layout);
591
592        // this is the first render, so we need to set page_end
593        if self.page_end == usize::MAX {
594            self.init_page();
595        }
596
597        if layout.line_offset != 0 {
598            layout.line_offset = 0;
599            layout.offset_y += 1;
600            b.move_cursor_to(layout.offset_x, layout.offset_y)?;
601        }
602
603        if self.page_end < self.page_start {
604            self.render_in(
605                (self.page_start..self.list.len()).chain(0..=self.page_end),
606                layout,
607                b,
608            )?;
609        } else {
610            self.render_in(self.page_start..=self.page_end, layout, b)?;
611        }
612
613        if self.is_paginating() {
614            // This is the message at the end that other places refer to
615            b.write_styled(&"(Move up and down to reveal more choices)".dark_grey())?;
616            layout.offset_y += 1;
617
618            b.move_cursor_to(layout.offset_x, layout.offset_y)?;
619        }
620
621        Ok(())
622    }
623
624    /// Returns the starting location of the layout. It should not be relied upon for a sensible
625    /// cursor position.
626    fn cursor_pos(&mut self, layout: Layout) -> (u16, u16) {
627        layout.offset_cursor((layout.line_offset, 0))
628    }
629
630    fn height(&mut self, layout: &mut Layout) -> u16 {
631        self.maybe_update_heights(*layout);
632
633        let height = (layout.line_offset != 0) as u16 // Add one if we go to the next line
634            // Try to show everything
635            + self
636                .height
637                // otherwise show whatever is possible
638                .min(self.page_size())
639                // but do not show less than a single element
640                .max(
641                    self.heights
642                    .as_ref()
643                    .expect("`maybe_update_heights` should set `self.heights` if missing")
644                    .heights
645                    .get(self.at)
646                    .unwrap_or(&0)
647                    // +1 if paginating since the message at the end takes one line
648                    + self.is_paginating() as u16,
649                );
650
651        layout.line_offset = 0;
652        layout.offset_y += height;
653
654        height
655    }
656}