use ratatui::{
layout::{Position, Rect},
text::{Line, Text},
};
use crate::linear_nav::{self};
pub const SCROLL_STEP: usize = 3;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct WheelPark {
offset: usize,
anchor: Option<usize>,
parked: bool,
}
impl WheelPark {
pub const fn park(&mut self, offset: usize, cursor: Option<usize>) {
self.offset = offset;
self.anchor = cursor;
self.parked = true;
}
pub const fn settle(&mut self, cursor: Option<usize>) {
if !same_index(self.anchor, cursor) {
self.parked = false;
}
}
pub const fn record(&mut self, painted: usize) {
self.offset = painted;
}
#[must_use]
pub const fn offset(&self) -> usize {
self.offset
}
#[must_use]
pub const fn cursor_to_show(&self, cursor: Option<usize>) -> Option<usize> {
if self.parked && same_index(self.anchor, cursor) {
None
} else {
cursor
}
}
}
const fn same_index(left: Option<usize>, right: Option<usize>) -> bool {
match (left, right) {
(Some(left), Some(right)) => left == right,
(None, None) => true,
_ => false,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListItem<T> {
value: T,
label: String,
disabled: bool,
}
impl<T> ListItem<T> {
#[must_use]
pub fn new(value: T, label: impl Into<String>) -> Self {
Self {
value,
label: label.into(),
disabled: false,
}
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub const fn value(&self) -> &T {
&self.value
}
#[must_use]
pub fn label(&self) -> &str {
&self.label
}
#[must_use]
pub const fn is_disabled(&self) -> bool {
self.disabled
}
}
impl From<&str> for ListItem<String> {
fn from(label: &str) -> Self {
Self::new(label.to_owned(), label)
}
}
impl From<String> for ListItem<String> {
fn from(label: String) -> Self {
Self::new(label.clone(), label)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ListItemState<'a, T> {
pub index: usize,
pub value: &'a T,
pub label: &'a str,
pub focused: bool,
pub selected: bool,
pub disabled: bool,
}
#[must_use]
pub fn fit_to_height(mut text: Text<'static>, height: u16) -> Text<'static> {
let height = usize::from(height);
text.lines.truncate(height);
while text.lines.len() < height {
text.lines.push(Line::default());
}
text
}
#[must_use]
pub fn index_of<T: PartialEq>(items: &[ListItem<T>], value: &T) -> Option<usize> {
items.iter().position(|item| item.value == *value)
}
#[must_use]
pub fn disabled_at<T>(items: &[ListItem<T>], index: usize) -> bool {
items.get(index).is_some_and(|item| item.disabled)
}
pub fn assert_unique_values<'a, T: PartialEq + 'a>(
values: impl IntoIterator<Item = &'a T>,
component: &str,
) {
let values: Vec<&T> = values.into_iter().collect();
for (index, value) in values.iter().enumerate() {
assert!(
!values[index + 1..].iter().any(|other| other == value),
"{component} item values must be unique within a {component} declaration"
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RowViewport {
rows_per_item: u16,
painted_offset: usize,
}
impl RowViewport {
#[must_use]
pub const fn new(row_height: u16) -> Self {
Self {
rows_per_item: if row_height == 0 { 1 } else { row_height },
painted_offset: 0,
}
}
#[must_use]
pub const fn rows_per_item(&self) -> u16 {
self.rows_per_item
}
#[must_use]
pub const fn painted_offset(&self) -> usize {
self.painted_offset
}
pub const fn record_painted_offset(&mut self, offset: usize) {
self.painted_offset = offset;
}
#[must_use]
pub const fn visible_items(&self, area: Rect) -> usize {
(area.height / self.rows_per_item) as usize
}
#[must_use]
pub fn row_at(&self, area: Rect, len: usize, column: u16, row: u16) -> Option<usize> {
if !area.contains(Position { x: column, y: row }) {
return None;
}
let rows_per_item = usize::from(self.rows_per_item);
let local_row = usize::from(row - area.y);
if local_row >= self.visible_items(area).saturating_mul(rows_per_item) {
return None;
}
linear_nav::index_at_row(len, self.painted_offset, local_row / rows_per_item)
}
#[must_use]
pub fn cursor_visible_offset(
&self,
area: Rect,
len: usize,
requested: usize,
cursor: Option<usize>,
) -> usize {
linear_nav::cursor_visible_offset(len, self.visible_items(area), requested, cursor)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_of_and_disabled_at_answer_by_value_and_position() {
let items = [
ListItem::new(1, "one"),
ListItem::new(2, "two").disabled(true),
];
assert_eq!(index_of(&items, &2), Some(1));
assert_eq!(index_of(&items, &3), None);
assert!(disabled_at(&items, 1));
assert!(!disabled_at(&items, 0));
assert!(!disabled_at(&items, 99));
}
#[test]
fn duplicate_values_panic_with_the_component_name() {
let items = [ListItem::new(1, "a"), ListItem::new(1, "b")];
let panic = std::panic::catch_unwind(|| {
assert_unique_values(items.iter().map(ListItem::value), "List");
})
.expect_err("duplicates must panic");
let message = panic
.downcast_ref::<String>()
.expect("panic carries a String");
assert_eq!(
message,
"List item values must be unique within a List declaration"
);
}
#[test]
fn row_at_divides_multi_row_items_and_uses_the_painted_offset() {
let mut viewport = RowViewport::new(2);
viewport.record_painted_offset(3);
let area = Rect::new(0, 10, 10, 4);
assert_eq!(viewport.row_at(area, 10, 0, 11), Some(3));
assert_eq!(viewport.row_at(area, 10, 0, 12), Some(4));
assert_eq!(viewport.row_at(area, 10, 0, 9), None);
assert_eq!(viewport.row_at(area, 4, 0, 12), None);
}
#[test]
fn row_at_excludes_the_partial_row_below_the_last_whole_item() {
let viewport = RowViewport::new(2);
let area = Rect::new(0, 0, 10, 5);
assert_eq!(viewport.visible_items(area), 2);
assert_eq!(viewport.row_at(area, 10, 0, 4), None);
}
#[test]
fn zero_row_height_is_treated_as_one() {
let viewport = RowViewport::new(0);
assert_eq!(viewport.rows_per_item(), 1);
}
}