1use crossterm::event::{Event, KeyCode, KeyEventKind};
4use ratatui::{
5 layout::{Alignment, Constraint},
6 style::{Color, Style},
7 text::Line,
8 widgets::{List, ListItem, ListState},
9};
10use ratatui_kit_macros::{Props, component, element, with_layout_style};
11
12use super::list_state::sync_default_selection;
13use crate::{
14 AnyElement, Handler, Hooks, State, UseEffect, UseEventHandler, UseState,
15 components::{Border, Center, Text, TextParagraph},
16 input::{EventPriority, EventResult, EventScope},
17};
18
19#[with_layout_style(margin, offset, width, height)]
20#[derive(Props)]
21pub struct SelectProps<T>
22where
23 T: Into<ListItem<'static>> + Clone + Send + Sync + 'static,
24{
25 pub items: Vec<T>,
26 pub on_select: Handler<'static, T>,
27 pub state: Option<State<ListState>>,
28 pub top_title: Option<Line<'static>>,
29 pub bottom_title: Option<Line<'static>>,
30 pub active: bool,
31 pub default_index: Option<usize>,
32 pub empty_message: TextParagraph<'static>,
33 pub highlight_symbol: Option<&'static str>,
34 pub style: Style,
35 pub border_style: Style,
36 pub highlight_style: Style,
37 pub empty_style: Style,
38 pub empty_width: Constraint,
39 pub empty_height: Constraint,
40}
41
42impl<T> Default for SelectProps<T>
43where
44 T: Into<ListItem<'static>> + Clone + Send + Sync,
45{
46 fn default() -> Self {
47 Self {
48 items: Vec::new(),
49 on_select: Handler::default(),
50 state: None,
51 top_title: None,
52 bottom_title: None,
53 active: true,
54 default_index: None,
55 empty_message: TextParagraph::from("No data"),
56 highlight_symbol: None,
57 style: Style::default(),
58 border_style: Style::default(),
59 highlight_style: Style::default().fg(Color::Black).bg(Color::Cyan),
60 empty_style: Style::default().fg(Color::Yellow),
61 empty_width: Constraint::Percentage(50),
62 empty_height: Constraint::Length(5),
63 margin: Default::default(),
64 offset: Default::default(),
65 width: Default::default(),
66 height: Default::default(),
67 }
68 }
69}
70
71#[component]
72pub fn Select<T>(props: &mut SelectProps<T>, mut hooks: Hooks) -> impl Into<AnyElement<'static>>
73where
74 T: Into<ListItem<'static>> + Clone + Send + Sync + 'static,
75{
76 let state = hooks.use_state(ListState::default);
77 let state = props.state.unwrap_or(state);
78
79 let default_index = props.default_index;
80 let item_count = props.items.len();
81 let mut last_default_index = hooks.use_state(|| None::<Option<usize>>);
82 hooks.use_effect(
83 move || {
84 let mut last_default = last_default_index.get();
85 sync_default_selection(
86 &mut state.write(),
87 &mut last_default,
88 default_index,
89 item_count,
90 );
91 last_default_index.set(last_default);
92 },
93 (default_index, item_count),
94 );
95
96 let selected_index = state.read().selected();
97 hooks.use_effect(
98 move || {
99 if selected_index.is_some_and(|index| index >= item_count) {
100 state.write().select(item_count.checked_sub(1));
101 }
102 },
103 (selected_index, item_count),
104 );
105
106 let active = props.active;
107 let items = props.items.clone();
108 let mut on_select = props.on_select.take();
109
110 hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
111 if !active || item_count == 0 {
112 return EventResult::Ignored;
113 }
114
115 let Event::Key(key) = event else {
116 return EventResult::Ignored;
117 };
118 if key.kind != KeyEventKind::Press {
119 return EventResult::Ignored;
120 }
121
122 match key.code {
123 KeyCode::Char('j') | KeyCode::Down => {
124 state.write().select_next();
125 EventResult::Consumed
126 }
127 KeyCode::Char('k') | KeyCode::Up => {
128 state.write().select_previous();
129 EventResult::Consumed
130 }
131 KeyCode::Home => {
132 state.write().select_first();
133 EventResult::Consumed
134 }
135 KeyCode::End => {
136 state.write().select_last();
137 EventResult::Consumed
138 }
139 KeyCode::Enter => {
140 let selected_index = state.read().selected();
141 if let Some(index) = selected_index
142 && let Some(item) = items.get(index)
143 {
144 on_select(item.clone());
145 }
146 EventResult::Consumed
147 }
148 _ => EventResult::Ignored,
149 }
150 });
151
152 let is_empty = props.items.is_empty();
153 let mut list = List::new(props.items.clone())
154 .style(props.style)
155 .highlight_style(props.highlight_style);
156
157 if let Some(highlight_symbol) = props.highlight_symbol {
158 list = list.highlight_symbol(highlight_symbol);
159 }
160
161 element!(Border(
162 margin: props.margin,
163 offset: props.offset,
164 width: props.width,
165 height: props.height,
166 border_style: props.border_style,
167 top_title: props.top_title.clone(),
168 bottom_title: props.bottom_title.clone(),
169 ) {
170 if is_empty {
171 Center(
172 width: props.empty_width,
173 height: props.empty_height,
174 ) {
175 Text(
176 text: props.empty_message.clone(),
177 alignment: Alignment::Center,
178 style: props.empty_style,
179 wrap: true,
180 )
181 }
182 } else {
183 stateful(list, state)
184 }
185 })
186}