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}