use std::{
io,
ops::{Index, IndexMut},
};
use crate::{
backend::Backend,
events::{KeyEvent, Movement},
layout::{Layout, RenderRegion},
style::Stylize,
};
#[cfg(test)]
mod tests;
/// A trait to represent a renderable list.
///
/// See [`Select`]
pub trait List {
/// Render a single element at some index.
///
/// When rendering the element, only _at most_ [`layout.max_height`] lines can be used. If more
/// lines are used, the list may not be rendered properly. The place the terminal cursor ends at
/// does not matter.
///
/// [`layout.max_height`] may be less than the height given by [`height_at`].
/// [`layout.render_region`] can be used to determine which part of the element you want to
/// render.
///
/// [`height_at`]: List::height_at
/// [`layout.max_height`]: Layout::max_height
/// [`layout.render_region`]: Layout.render_region
fn render_item<B: Backend>(
&mut self,
index: usize,
hovered: bool,
layout: Layout,
backend: &mut B,
) -> io::Result<()>;
/// Whether the element at a particular index is selectable. Those that are not selectable are
/// skipped during navigation.
fn is_selectable(&self, index: usize) -> bool;
/// The maximum height that can be taken by the list.
///
/// If the total height exceeds the page size, the list will be scrollable.
fn page_size(&self) -> usize;
/// Whether to wrap around when user gets to the last element.
///
/// This only applies when the list is scrollable, i.e. page size > total height.
fn should_loop(&self) -> bool;
/// The height of the element at an index will take to render
fn height_at(&mut self, index: usize, layout: Layout) -> u16;
/// The length of the list
fn len(&self) -> usize;
/// Returns true if the list has no elements
fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone)]
struct Heights {
heights: Vec<u16>,
prev_layout: Layout,
}
/// A widget to select a single item from a list.
///
/// The list must implement the [`List`] trait.
#[derive(Debug, Clone)]
pub struct Select<L> {
first_selectable: usize,
last_selectable: usize,
at: usize,
page_start: usize,
page_end: usize,
page_start_height: u16,
page_end_height: u16,
height: u16,
heights: Option<Heights>,
/// The underlying list
pub list: L,
}
impl<L: List> Select<L> {
/// Creates a new [`Select`].
///
/// # Panics
///
/// Panics if there are no selectable items, or if `list.page_size()` is less than 5.
pub fn new(list: L) -> Self {
let first_selectable = (0..list.len())
.position(|i| list.is_selectable(i))
.expect("there must be at least one selectable item");
let last_selectable = (0..list.len())
.rposition(|i| list.is_selectable(i))
.unwrap();
assert!(list.page_size() >= 5, "page size can be a minimum of 5");
Self {
first_selectable,
last_selectable,
height: u16::MAX,
page_start_height: u16::MAX,
page_end_height: u16::MAX,
heights: None,
at: first_selectable,
page_start: 0,
page_end: usize::MAX,
list,
}
}
/// The index of the element that is currently being hovered.
pub fn get_at(&self) -> usize {
self.at
}
/// Set the index of the element that is currently being hovered.
///
/// `at` can be any number (even beyond `list.len()`), but the caller is responsible for making
/// sure that it is a selectable element.
pub fn set_at(&mut self, at: usize) {
let dir = if self.at >= self.list.len() || self.at < at {
Movement::Down
} else {
Movement::Up
};
self.at = at;
if self.is_paginating() {
if at >= self.list.len() {
self.init_page();
} else if self.heights.is_some() {
self.maybe_adjust_page(dir);
}
}
}
/// Consumes the [`Select`] returning the original list.
pub fn into_inner(self) -> L {
self.list
}
fn next_selectable(&self) -> usize {
if self.at >= self.last_selectable {
return if self.list.should_loop() {
self.first_selectable
} else {
self.last_selectable
};
}
// at not guaranteed to be in the valid range of 0..list.len(), so the min is required
let mut at = self.at.min(self.list.len());
loop {
at = (at + 1) % self.list.len();
if self.list.is_selectable(at) {
break;
}
}
at
}
fn prev_selectable(&self) -> usize {
if self.at <= self.first_selectable {
return if self.list.should_loop() {
self.last_selectable
} else {
self.first_selectable
};
}
// at not guaranteed to be in the valid range of 0..list.len(), so the min is required
let mut at = self.at.min(self.list.len());
loop {
at = (self.list.len() + at - 1) % self.list.len();
if self.list.is_selectable(at) {
break;
}
}
at
}
fn maybe_update_heights(&mut self, mut layout: Layout) {
let heights = match self.heights {
Some(ref mut heights) if heights.prev_layout != layout => {
heights.heights.clear();
heights.prev_layout = layout;
&mut heights.heights
}
None => {
self.heights = Some(Heights {
heights: Vec::with_capacity(self.list.len()),
prev_layout: layout,
});
&mut self.heights.as_mut().unwrap().heights
}
_ => return,
};
layout.line_offset = 0;
self.height = 0;
for i in 0..self.list.len() {
let height = self.list.height_at(i, layout);
self.height += height;
heights.push(height);
}
}
fn page_size(&self) -> u16 {
self.list.page_size() as u16
}
fn is_paginating(&self) -> bool {
self.height > self.page_size()
}
/// Checks whether the page bounds need to be adjusted
///
/// This returns true if at == page_start || at == page_end, and so even though it is visible,
/// the page bounds should be adjusted
fn at_outside_page(&self) -> bool {
if self.page_start < self.page_end {
// - a - - S - - - - - - E - a -
// ^------- outside -------^
self.at <= self.page_start || self.at >= self.page_end
} else {
// - - - - E - - - a - - S - - -
// outside --^
self.at <= self.page_start && self.at >= self.page_end
}
}
/// Gets the index at a given delta taking into account looping if enabled -- delta must be
/// within ±len
fn try_get_index(&self, delta: isize) -> Option<usize> {
if delta.is_positive() {
let res = self.at + delta as usize;
if res < self.list.len() {
Some(res)
} else if self.list.should_loop() {
Some(res - self.list.len())
} else {
None
}
} else {
let delta = -delta as usize;
if self.list.should_loop() {
Some((self.at + self.list.len() - delta) % self.list.len())
} else {
self.at.checked_sub(delta)
}
}
}
/// Adjust the page considering the direction we moved to
fn adjust_page(&mut self, moved_to: Movement) {
// note direction here refers to the direction we moved _from_, while moved means the
// direction we moved _to_, and so they have opposite meanings
let direction = match moved_to {
Movement::Down => -1,
Movement::Up => 1,
_ => unreachable!(),
};
let heights = &self
.heights
.as_ref()
.expect("`adjust_page` called before `height` or `render`")
.heights[..];
// -1 since the message at the end takes one line
let max_height = self.page_size() - 1;
// This first gets an element from the direction we have moved from, then one
// from the opposite, and the rest again from the direction we have move from
//
// for example,
// take that we have moved downwards (like from 2 to 3).
// .-----.
// | 0 | <-- iter[3]
// .-----.
// | 1 | <-- iter[2]
// .-----.
// | 2 | <-- iter[0] | We want this over 4 since we have come from that
// .-----. direction and it provides continuity
// | 3 | <-- self.at
// .-----.
// | 4 | <-- iter[1] | We pick 4 over ones before 2 since it provides a
// '-----' padding of one element at the end
//
// note: the above example avoids things like looping, which is handled by
// try_get_index
let iter = self
.try_get_index(direction)
.map(|i| (i, false))
.into_iter()
.chain(
self.try_get_index(-direction)
.map(|i| (i, true)) // boolean value to show this is special
.into_iter(),
)
.chain(
(2..(max_height as isize))
.filter_map(|i| self.try_get_index(direction * i).map(|i| (i, false))),
);
// these variables have opposite meaning based on the direction, but they store
// the (index, height) of either the page_start or the page_end
let mut bound_a = (self.at, heights[self.at]);
let mut bound_b = (self.at, heights[self.at]);
let mut height = heights[self.at];
for (height_index, opposite_dir) in iter {
if height >= max_height {
// There are no more elements that can be shown
break;
}
let elem_height = if opposite_dir {
// To provide better continuity, the element in the opposite direction
// will have only one line shown. This prevents the cursor from jumping
// about when the element in the opposite direction has different height
// from the one rendered previously
1
} else {
(height + heights[height_index]).min(max_height) - height
};
// If you see the creation of iter, this special cases the second element in
// the iterator as it is the _only_ one in the opposite direction
//
// It cannot simply be checked as being the second element, as try_get_index
// may return None when looping is disabled
if opposite_dir {
bound_b.0 = height_index;
bound_b.1 = elem_height;
} else {
bound_a.0 = height_index;
bound_a.1 = elem_height;
}
height += elem_height;
}
if let Movement::Down = moved_to {
// When moving down, the special case is the element after `self.at`, so it
// is the page_end
self.page_start = bound_a.0;
self.page_start_height = bound_a.1;
self.page_end = bound_b.0;
self.page_end_height = bound_b.1;
} else {
// When moving up, the special case is the element before `self.at`, so it
// is the page_start
self.page_start = bound_b.0;
self.page_start_height = bound_b.1;
self.page_end = bound_a.0;
self.page_end_height = bound_a.1;
}
}
/// Adjust the page if required considering the direction we moved to
fn maybe_adjust_page(&mut self, moved_to: Movement) {
// Check whether at is within second and second last element of the page
if self.at_outside_page() {
self.adjust_page(moved_to)
}
}
fn init_page(&mut self) {
let heights = &self
.heights
.as_ref()
.expect("`init_page` called before `height` or `render`")
.heights[..];
self.page_start = 0;
self.page_start_height = heights[self.page_start];
if self.is_paginating() {
let mut height = heights[0];
// -1 since the message at the end takes one line
let max_height = self.page_size() - 1;
#[allow(clippy::needless_range_loop)]
for i in 1..heights.len() {
if height >= max_height {
break;
}
self.page_end = i;
self.page_end_height = (height + heights[i]).min(max_height) - height;
height += heights[i];
}
} else {
self.page_end = self.list.len() - 1;
self.page_end_height = heights[self.page_end];
}
}
/// Renders the lines in a given iterator
fn render_in<I: Iterator<Item = usize>, B: Backend>(
&mut self,
iter: I,
old_layout: &mut Layout,
b: &mut B,
) -> io::Result<()> {
let heights = &self
.heights
.as_ref()
.expect("`render_in` called from someplace other than `render`")
.heights[..];
// Create a new local copy of the layout to operate on to avoid changes in max_height and
// render_region to be reflected upstream
let mut layout = *old_layout;
for i in iter {
if i == self.page_start {
layout.max_height = self.page_start_height;
layout.render_region = RenderRegion::Bottom;
} else if i == self.page_end {
layout.max_height = self.page_end_height;
layout.render_region = RenderRegion::Top;
} else {
layout.max_height = heights[i];
}
self.list.render_item(i, i == self.at, layout, b)?;
layout.offset_y += layout.max_height;
b.move_cursor_to(layout.offset_x, layout.offset_y)?;
}
old_layout.offset_y = layout.offset_y;
layout.line_offset = 0;
Ok(())
}
}
impl<L: Index<usize>> Select<L> {
/// Returns a reference to the currently hovered item.
pub fn selected(&self) -> &L::Output {
&self.list[self.at]
}
}
impl<L: IndexMut<usize>> Select<L> {
/// Returns a mutable reference to the currently hovered item.
pub fn selected_mut(&mut self) -> &mut L::Output {
&mut self.list[self.at]
}
}
impl<L: List> super::Widget for Select<L> {
fn handle_key(&mut self, key: KeyEvent) -> bool {
let movement = match Movement::try_from_key(key) {
Some(movement) => movement,
None => return false,
};
let moved = match movement {
Movement::Up if self.list.should_loop() || self.at > self.first_selectable => {
self.at = self.prev_selectable();
Movement::Up
}
Movement::Down if self.list.should_loop() || self.at < self.last_selectable => {
self.at = self.next_selectable();
Movement::Down
}
Movement::PageUp
if !self.is_paginating() // No pagination, PageUp is same as Home
// No looping and first item is shown in this page
|| (!self.list.should_loop() && self.page_start == 0) =>
{
if self.at <= self.first_selectable {
return false;
}
self.at = self.first_selectable;
Movement::Up
}
Movement::PageUp => {
// We want the current self.at to be visible after the PageUp movement,
// and if possible we want to it to be the bottom most element visible
// We decrease self.at by 1, since adjust_page will put self.at as the
// second last element, so if (self.at - 1) is the second last element,
// self.at is the last element visible
self.at = self.try_get_index(-1).unwrap_or(self.at);
self.adjust_page(Movement::Down);
if self.page_start == 0 && !self.list.should_loop() {
// We've reached the end, it is possible that because of the bounds
// we gave earlier, self.page_end may not be right so we have to
// recompute it
self.at = self.first_selectable;
self.init_page();
} else {
// Now that the page is determined, we want to set self.at to be some
// _selectable_ element which is not the top most element visible,
// so we undershoot by 1
self.at = self.page_start;
// ...and then go forward at least one element
//
// note: self.at cannot directly be set to self.page_start + 1, since it
// also has to be a selectable element
self.at = self.next_selectable();
}
Movement::Up
}
Movement::PageDown
if !self.is_paginating() // No pagination, PageDown same as End
|| (!self.list.should_loop() // No looping and last item is shown in this page
&& self.page_end + 1 == self.list.len()) =>
{
if self.at >= self.last_selectable {
return false;
}
self.at = self.last_selectable;
Movement::Down
}
Movement::PageDown => {
// We want the current self.at to be visible after the PageDown movement,
// and if possible we want to it to be the top most element visible
// We increase self.at by 1, since adjust_page will put self.at as the
// second element, so if (self.at + 1) is the second last element,
// self.at is the last element visible
self.at = self.try_get_index(1).unwrap_or(self.at);
self.adjust_page(Movement::Up);
// Now that the page is determined, we want to set self.at to be some
// _selectable_ element which is not the bottom most element visible,
// so we overshoot by 1...
self.at = self.page_end;
if self.page_end + 1 == self.list.len() && !self.list.should_loop() {
// ...but since we reached the end and there is no looping, self.page_start may
// not be right so we have to recompute it
self.adjust_page(Movement::Down);
self.at = self.last_selectable;
} else {
// ...and then go back to at least one element
//
// note: self.at cannot directly be set to self.page_end - 1, since it
// also has to be a selectable element
self.at = self.prev_selectable();
}
Movement::Down
}
Movement::Home if self.at != self.first_selectable => {
self.at = self.first_selectable;
Movement::Up
}
Movement::End if self.at != self.last_selectable => {
self.at = self.last_selectable;
Movement::Down
}
_ => return false,
};
if self.is_paginating() {
self.maybe_adjust_page(moved)
}
true
}
fn render<B: Backend>(&mut self, layout: &mut Layout, b: &mut B) -> io::Result<()> {
self.maybe_update_heights(*layout);
// this is the first render, so we need to set page_end
if self.page_end == usize::MAX {
self.init_page();
}
if layout.line_offset != 0 {
layout.line_offset = 0;
layout.offset_y += 1;
b.move_cursor_to(layout.offset_x, layout.offset_y)?;
}
if self.page_end < self.page_start {
self.render_in(
(self.page_start..self.list.len()).chain(0..=self.page_end),
layout,
b,
)?;
} else {
self.render_in(self.page_start..=self.page_end, layout, b)?;
}
if self.is_paginating() {
// This is the message at the end that other places refer to
b.write_styled(&"(Move up and down to reveal more choices)".dark_grey())?;
layout.offset_y += 1;
b.move_cursor_to(layout.offset_x, layout.offset_y)?;
}
Ok(())
}
/// Returns the starting location of the layout. It should not be relied upon for a sensible
/// cursor position.
fn cursor_pos(&mut self, layout: Layout) -> (u16, u16) {
layout.offset_cursor((layout.line_offset, 0))
}
fn height(&mut self, layout: &mut Layout) -> u16 {
self.maybe_update_heights(*layout);
let height = (layout.line_offset != 0) as u16 // Add one if we go to the next line
// Try to show everything
+ self
.height
// otherwise show whatever is possible
.min(self.page_size())
// but do not show less than a single element
.max(
self.heights
.as_ref()
.expect("`maybe_update_heights` should set `self.heights` if missing")
.heights
.get(self.at)
.unwrap_or(&0)
// +1 if paginating since the message at the end takes one line
+ self.is_paginating() as u16,
);
layout.line_offset = 0;
layout.offset_y += height;
height
}
}