use ratatui::prelude::*;
use super::{Component, Disableable, Focusable, ViewContext};
use crate::input::{Event, KeyCode};
use crate::scroll::ScrollState;
use crate::theme::Theme;
mod render;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ItemState {
#[default]
Ready,
Loading,
Error(String),
}
impl ItemState {
pub fn is_loading(&self) -> bool {
matches!(self, Self::Loading)
}
pub fn is_error(&self) -> bool {
matches!(self, Self::Error(_))
}
pub fn is_ready(&self) -> bool {
matches!(self, Self::Ready)
}
pub fn error_message(&self) -> Option<&str> {
if let Self::Error(msg) = self {
Some(msg)
} else {
None
}
}
pub fn symbol(&self, spinner_frame: usize) -> &'static str {
match self {
Self::Ready => " ",
Self::Loading => {
const LOADING_FRAMES: [&str; 10] =
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
LOADING_FRAMES[spinner_frame % LOADING_FRAMES.len()]
}
Self::Error(_) => "✗",
}
}
pub fn style(&self, theme: &Theme) -> Style {
match self {
Self::Ready => theme.normal_style(),
Self::Loading => theme.warning_style(),
Self::Error(_) => theme.error_style(),
}
}
}
#[derive(Clone, Debug)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct LoadingListItem<T: Clone> {
data: T,
label: String,
state: ItemState,
}
impl<T: Clone + PartialEq> PartialEq for LoadingListItem<T> {
fn eq(&self, other: &Self) -> bool {
self.data == other.data && self.label == other.label && self.state == other.state
}
}
impl<T: Clone> LoadingListItem<T> {
pub fn new(data: T, label: impl Into<String>) -> Self {
Self {
data,
label: label.into(),
state: ItemState::Ready,
}
}
pub fn data(&self) -> &T {
&self.data
}
pub fn data_mut(&mut self) -> &mut T {
&mut self.data
}
pub fn label(&self) -> &str {
&self.label
}
pub fn set_label(&mut self, label: impl Into<String>) {
self.label = label.into();
}
pub fn state(&self) -> &ItemState {
&self.state
}
pub fn set_state(&mut self, state: ItemState) {
self.state = state;
}
pub fn is_loading(&self) -> bool {
self.state.is_loading()
}
pub fn is_error(&self) -> bool {
self.state.is_error()
}
pub fn is_ready(&self) -> bool {
self.state.is_ready()
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum LoadingListMessage<T: Clone> {
SetItems(Vec<T>),
SetLoading(usize),
SetReady(usize),
SetError {
index: usize,
message: String,
},
ClearError(usize),
Up,
Down,
First,
Last,
Select,
Tick,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum LoadingListOutput<T: Clone> {
Selected(T),
SelectionChanged(usize),
ItemStateChanged {
index: usize,
state: ItemState,
},
}
#[derive(Clone, Debug)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct LoadingListState<T: Clone> {
items: Vec<LoadingListItem<T>>,
selected: Option<usize>,
focused: bool,
disabled: bool,
spinner_frame: usize,
title: Option<String>,
show_indicators: bool,
#[cfg_attr(feature = "serialization", serde(skip))]
scroll: ScrollState,
}
impl<T: Clone + PartialEq> PartialEq for LoadingListState<T> {
fn eq(&self, other: &Self) -> bool {
self.items == other.items
&& self.selected == other.selected
&& self.focused == other.focused
&& self.disabled == other.disabled
&& self.spinner_frame == other.spinner_frame
&& self.title == other.title
&& self.show_indicators == other.show_indicators
}
}
impl<T: Clone> Default for LoadingListState<T> {
fn default() -> Self {
Self {
items: Vec::new(),
selected: None,
focused: false,
disabled: false,
spinner_frame: 0,
title: None,
show_indicators: true,
scroll: ScrollState::default(),
}
}
}
impl<T: Clone> LoadingListState<T> {
pub fn new() -> Self {
Self::default()
}
pub fn with_items<F>(items: Vec<T>, label_fn: F) -> Self
where
F: Fn(&T) -> String,
{
let list_items: Vec<LoadingListItem<T>> = items
.into_iter()
.map(|data| {
let label = label_fn(&data);
LoadingListItem::new(data, label)
})
.collect();
let scroll = ScrollState::new(list_items.len());
Self {
items: list_items,
selected: None,
focused: false,
disabled: false,
spinner_frame: 0,
title: None,
show_indicators: true,
scroll,
}
}
pub fn with_selected(mut self, index: usize) -> Self {
if self.items.is_empty() {
return self;
}
self.selected = Some(index.min(self.items.len() - 1));
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_indicators(mut self, show: bool) -> Self {
self.show_indicators = show;
self
}
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn items(&self) -> &[LoadingListItem<T>] {
&self.items
}
pub fn items_mut(&mut self) -> &mut Vec<LoadingListItem<T>> {
&mut self.items
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
pub fn selected(&self) -> Option<usize> {
self.selected_index()
}
pub fn selected_item(&self) -> Option<&LoadingListItem<T>> {
self.selected.and_then(|i| self.items.get(i))
}
pub fn selected_data(&self) -> Option<&T> {
self.selected_item().map(|item| item.data())
}
pub fn set_selected(&mut self, index: Option<usize>) {
self.selected = index.map(|i| i.min(self.items.len().saturating_sub(1)));
}
pub fn get(&self, index: usize) -> Option<&LoadingListItem<T>> {
self.items.get(index)
}
pub fn get_mut(&mut self, index: usize) -> Option<&mut LoadingListItem<T>> {
self.items.get_mut(index)
}
pub fn set_loading(&mut self, index: usize) {
if let Some(item) = self.items.get_mut(index) {
item.state = ItemState::Loading;
}
}
pub fn set_ready(&mut self, index: usize) {
if let Some(item) = self.items.get_mut(index) {
item.state = ItemState::Ready;
}
}
pub fn set_error(&mut self, index: usize, message: impl Into<String>) {
if let Some(item) = self.items.get_mut(index) {
item.state = ItemState::Error(message.into());
}
}
pub fn loading_count(&self) -> usize {
self.items.iter().filter(|i| i.is_loading()).count()
}
pub fn error_count(&self) -> usize {
self.items.iter().filter(|i| i.is_error()).count()
}
pub fn has_loading(&self) -> bool {
self.items.iter().any(|i| i.is_loading())
}
pub fn has_errors(&self) -> bool {
self.items.iter().any(|i| i.is_error())
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
}
pub fn show_indicators(&self) -> bool {
self.show_indicators
}
pub fn set_show_indicators(&mut self, show: bool) {
self.show_indicators = show;
}
pub fn spinner_frame(&self) -> usize {
self.spinner_frame
}
pub fn clear(&mut self) {
self.items.clear();
self.selected = None;
self.scroll.set_content_length(0);
}
}
impl<T: Clone + 'static> LoadingListState<T> {
pub fn is_focused(&self) -> bool {
self.focused
}
pub fn set_focused(&mut self, focused: bool) {
self.focused = focused;
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
pub fn set_disabled(&mut self, disabled: bool) {
self.disabled = disabled;
}
pub fn handle_event(&self, event: &Event) -> Option<LoadingListMessage<T>> {
LoadingList::handle_event(self, event)
}
pub fn dispatch_event(&mut self, event: &Event) -> Option<LoadingListOutput<T>> {
LoadingList::dispatch_event(self, event)
}
pub fn update(&mut self, msg: LoadingListMessage<T>) -> Option<LoadingListOutput<T>> {
LoadingList::update(self, msg)
}
}
pub struct LoadingList<T: Clone>(std::marker::PhantomData<T>);
impl<T: Clone> Component for LoadingList<T> {
type State = LoadingListState<T>;
type Message = LoadingListMessage<T>;
type Output = LoadingListOutput<T>;
fn init() -> Self::State {
LoadingListState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
if state.disabled {
match msg {
LoadingListMessage::Up
| LoadingListMessage::Down
| LoadingListMessage::First
| LoadingListMessage::Last
| LoadingListMessage::Select => return None,
_ => {}
}
}
match msg {
LoadingListMessage::SetItems(items) => {
state.items = items
.into_iter()
.enumerate()
.map(|(i, data)| LoadingListItem::new(data, format!("Item {}", i + 1)))
.collect();
state.selected = None;
state.scroll.set_content_length(state.items.len());
None
}
LoadingListMessage::SetLoading(index) => {
if let Some(item) = state.items.get_mut(index) {
item.state = ItemState::Loading;
Some(LoadingListOutput::ItemStateChanged {
index,
state: ItemState::Loading,
})
} else {
None
}
}
LoadingListMessage::SetReady(index) => {
if let Some(item) = state.items.get_mut(index) {
item.state = ItemState::Ready;
Some(LoadingListOutput::ItemStateChanged {
index,
state: ItemState::Ready,
})
} else {
None
}
}
LoadingListMessage::SetError { index, message } => {
if let Some(item) = state.items.get_mut(index) {
let new_state = ItemState::Error(message.clone());
item.state = new_state.clone();
Some(LoadingListOutput::ItemStateChanged {
index,
state: new_state,
})
} else {
None
}
}
LoadingListMessage::ClearError(index) => {
if let Some(item) = state.items.get_mut(index) {
if item.is_error() {
item.state = ItemState::Ready;
return Some(LoadingListOutput::ItemStateChanged {
index,
state: ItemState::Ready,
});
}
}
None
}
LoadingListMessage::Up => {
if state.items.is_empty() {
return None;
}
let new_index = match state.selected {
Some(i) if i > 0 => i - 1,
Some(_) => state.items.len() - 1, None => state.items.len() - 1,
};
state.selected = Some(new_index);
state.scroll.ensure_visible(new_index);
Some(LoadingListOutput::SelectionChanged(new_index))
}
LoadingListMessage::Down => {
if state.items.is_empty() {
return None;
}
let new_index = match state.selected {
Some(i) if i < state.items.len() - 1 => i + 1,
Some(_) => 0, None => 0,
};
state.selected = Some(new_index);
state.scroll.ensure_visible(new_index);
Some(LoadingListOutput::SelectionChanged(new_index))
}
LoadingListMessage::First => {
if state.items.is_empty() {
return None;
}
state.selected = Some(0);
state.scroll.ensure_visible(0);
Some(LoadingListOutput::SelectionChanged(0))
}
LoadingListMessage::Last => {
if state.items.is_empty() {
return None;
}
let last = state.items.len() - 1;
state.selected = Some(last);
state.scroll.ensure_visible(last);
Some(LoadingListOutput::SelectionChanged(last))
}
LoadingListMessage::Select => {
if let Some(index) = state.selected {
if let Some(item) = state.items.get(index) {
return Some(LoadingListOutput::Selected(item.data.clone()));
}
}
None
}
LoadingListMessage::Tick => {
state.spinner_frame = (state.spinner_frame + 1) % 4;
None
}
}
}
fn handle_event(state: &Self::State, event: &Event) -> Option<Self::Message> {
if !state.focused || state.disabled {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
KeyCode::Up | KeyCode::Char('k') => Some(LoadingListMessage::Up),
KeyCode::Down | KeyCode::Char('j') => Some(LoadingListMessage::Down),
KeyCode::Enter => Some(LoadingListMessage::Select),
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme, ctx: &ViewContext) {
render::render_loading_list(state, frame, area, theme, ctx.focused, ctx.disabled);
}
}
impl<T: Clone> Focusable for LoadingList<T> {
fn is_focused(state: &Self::State) -> bool {
state.focused
}
fn set_focused(state: &mut Self::State, focused: bool) {
state.focused = focused;
}
}
impl<T: Clone> Disableable for LoadingList<T> {
fn is_disabled(state: &Self::State) -> bool {
state.disabled
}
fn set_disabled(state: &mut Self::State, disabled: bool) {
state.disabled = disabled;
}
}
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;