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}