use crate::{
components::{
Loading,
list_view::{ListView, RenderItem},
},
theme::AppChromeTheme,
};
use crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::{
layout::{Alignment, Constraint},
widgets::Block,
};
use ratatui_kit::prelude::*;
use std::{cell::RefCell, rc::Rc};
use tui_widget_list::{ListState, hit_test::Hit};
pub(super) fn pointer_item_index(
state: &ListState,
event: PointerEvent,
item_count: usize,
interactive: bool,
) -> Option<usize> {
if !interactive || !matches!(event.kind, PointerKind::Click { .. }) {
return None;
}
match state.hit_test(event.position.x, event.position.y) {
Some(Hit::Item(index)) if index < item_count => Some(index),
_ => None,
}
}
#[derive(Props)]
pub struct ListSelectProps<T>
where
T: Sync + Send + Clone + 'static,
{
pub items: Vec<T>,
pub on_select: ratatui_kit::Handler<'static, T>,
pub default_value: Option<usize>,
pub top_title: Option<ratatui::text::Line<'static>>,
pub bottom_title: Option<ratatui::text::Line<'static>>,
pub is_editing: bool,
pub render_item: RenderItem<'static>,
pub state: Option<ratatui_kit::State<ListState>>,
pub empty_message: String,
pub loading: bool,
pub loading_tip: String,
}
impl<T> Default for ListSelectProps<T>
where
T: Sync + Send + Clone + 'static,
{
fn default() -> Self {
Self {
items: vec![],
on_select: Handler::default(),
default_value: None,
top_title: None,
bottom_title: None,
is_editing: false,
render_item: RenderItem::default(),
state: None,
empty_message: String::default(),
loading: false,
loading_tip: String::from("加载中..."),
}
}
}
#[component]
pub fn ListSelect<T>(
props: &mut ListSelectProps<T>,
mut hooks: Hooks,
) -> impl Into<AnyElement<'static>>
where
T: Unpin + Sync + Clone + Send + 'static,
{
let theme = hooks.use_component_theme::<AppChromeTheme>();
let state = hooks.use_state(|| {
let mut state = ListState::default();
state.select(props.default_value);
state
});
let is_empty = props.items.is_empty();
let interactive = props.is_editing && !props.loading && !is_empty;
let state = props.state.unwrap_or(state);
let on_select = Rc::new(RefCell::new(props.on_select.take()));
hooks.use_event_handler(EventScope::Current, EventPriority::Normal, {
let on_select = on_select.clone();
let data = props.items.clone();
move |event| {
let Event::Key(key) = event else {
return EventResult::Ignored;
};
if key.kind != KeyEventKind::Press || !interactive {
return EventResult::Ignored;
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
state.write().next();
EventResult::Consumed
}
KeyCode::Char('k') | KeyCode::Up => {
state.write().previous();
EventResult::Consumed
}
KeyCode::Enter => {
let res = state.read().selected;
if let Some(path) = res
&& let Some(item) = data.get(path)
{
(on_select.borrow_mut())(item.clone());
}
EventResult::Consumed
}
_ => EventResult::Ignored,
}
}
});
hooks.use_pointer(PointerOptions::default(), {
let on_select = on_select.clone();
let data = props.items.clone();
move |ev| {
if !interactive {
return EventResult::Ignored;
}
match ev.kind {
PointerKind::ScrollUp => {
state.write().previous();
EventResult::Consumed
}
PointerKind::ScrollDown => {
state.write().next();
EventResult::Consumed
}
PointerKind::Click { .. } => {
let hit = pointer_item_index(&state.read(), ev, data.len(), interactive);
if let Some(index) = hit {
state.write().select(Some(index));
(on_select.borrow_mut())(data[index].clone());
EventResult::Consumed
} else {
EventResult::Ignored
}
}
_ => EventResult::Ignored,
}
}
});
let mut border = Block::bordered().border_style(theme.border);
if let Some(title) = props.top_title.clone() {
border = border.title_top(title);
}
if let Some(title) = props.bottom_title.clone() {
border = border.title_bottom(title);
}
if props.loading {
return element!(
Border(
top_title: props.top_title.clone(),
bottom_title: props.bottom_title.clone(),
border_style: theme.border,
){
Loading(tip: props.loading_tip.clone())
}
)
.into_any();
}
if is_empty {
return element!(
Border(
top_title: props.top_title.clone(),
bottom_title: props.bottom_title.clone(),
border_style: theme.border,
){
Center(
height:Constraint::Length(5),
width:Constraint::Percentage(50)
){
Text(
text: props.empty_message.clone(),
alignment: Alignment::Center,
style: theme.empty,
wrap: true,
)
}
}
)
.into_any();
}
element!(ListView(
state: state,
block: border,
render_item: std::mem::take(&mut props.render_item),
item_count: props.items.len(),
))
.into_any()
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyModifiers, MouseButton};
use ratatui::{
buffer::Buffer,
layout::{Position, Rect},
text::Line,
widgets::StatefulWidget,
};
use tui_widget_list::{ListBuilder, ListView as WidgetListView};
#[test]
fn loading_and_empty_views_do_not_hit_cached_list_items() {
let area = Rect::new(0, 0, 20, 8);
let mut state = ListState::default();
let builder = ListBuilder::new(|context| (Line::from(format!("row{}", context.index)), 2));
WidgetListView::new(builder, 3)
.block(Block::bordered())
.render(area, &mut Buffer::empty(area), &mut state);
let event = PointerEvent {
kind: PointerKind::Click {
button: MouseButton::Left,
count: 1,
},
position: Position::new(1, 3),
screen_position: Position::new(1, 3),
modifiers: KeyModifiers::NONE,
area,
};
assert_eq!(pointer_item_index(&state, event, 3, true), Some(1));
assert_eq!(pointer_item_index(&state, event, 3, false), None);
assert_eq!(pointer_item_index(&state, event, 0, false), None);
assert_eq!(pointer_item_index(&state, event, 1, true), None);
assert_eq!(
pointer_item_index(
&state,
PointerEvent {
position: Position::new(0, 3),
..event
},
3,
true
),
None
);
}
}