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