1use std::collections::HashSet;
4
5use crossterm::event::{Event, KeyCode, KeyEventKind};
6use ratatui::{
7 layout::{Alignment, Constraint},
8 style::Style,
9 text::Line,
10 widgets::{List, ListItem, ListState},
11};
12use ratatui_kit_macros::{Props, component, element, with_layout_style};
13
14use super::list_state::sync_default_selection;
15use crate::{
16 AnyElement, ComponentTheme, Handler, Hooks, Palette, State, UseEffect, UseEventHandler,
17 UseState, UseTheme,
18 components::theme::resolve_style,
19 components::{Border, Center, Text, TextParagraph},
20 input::{EventPriority, EventResult, EventScope},
21};
22
23#[non_exhaustive]
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct MultiSelectTheme {
27 pub style: Style,
29 pub border_style: Style,
31 pub highlight_style: Style,
33 pub selected_item_style: Style,
35 pub empty_style: Style,
37}
38
39impl ComponentTheme for MultiSelectTheme {
40 fn from_palette(palette: &Palette) -> Self {
41 Self {
42 style: Style::new().fg(palette.fg),
43 border_style: Style::new().fg(palette.border),
44 highlight_style: Style::new().fg(palette.on_accent).bg(palette.selection),
45 selected_item_style: Style::new().fg(palette.accent),
46 empty_style: Style::new().fg(palette.warning),
47 }
48 }
49}
50
51impl Default for MultiSelectTheme {
52 fn default() -> Self {
53 Self::from_palette(&Palette::default())
54 }
55}
56
57#[with_layout_style(margin, offset, width, height)]
58#[derive(Props)]
59pub struct MultiSelectProps<T>
60where
61 T: Into<ListItem<'static>> + Clone + Send + Sync + 'static,
62{
63 pub items: Vec<T>,
64 pub on_change: Handler<'static, Vec<T>>,
65 pub on_select: Handler<'static, Vec<T>>,
66 pub state: Option<State<ListState>>,
67 pub selected: Option<State<HashSet<usize>>>,
68 pub top_title: Option<Line<'static>>,
69 pub bottom_title: Option<Line<'static>>,
70 pub active: bool,
71 pub default_index: Option<usize>,
72 pub empty_message: TextParagraph<'static>,
73 pub highlight_symbol: Option<&'static str>,
74 pub style: Option<Style>,
76 pub border_style: Option<Style>,
77 pub highlight_style: Option<Style>,
78 pub selected_item_style: Option<Style>,
79 pub empty_style: Option<Style>,
80 pub empty_width: Constraint,
81 pub empty_height: Constraint,
82}
83
84impl<T> Default for MultiSelectProps<T>
85where
86 T: Into<ListItem<'static>> + Clone + Send + Sync,
87{
88 fn default() -> Self {
89 Self {
90 items: Vec::new(),
91 on_change: Handler::default(),
92 on_select: Handler::default(),
93 state: None,
94 selected: None,
95 top_title: None,
96 bottom_title: None,
97 active: true,
98 default_index: None,
99 empty_message: TextParagraph::from("No data"),
100 highlight_symbol: None,
101 style: None,
102 border_style: None,
103 highlight_style: None,
104 selected_item_style: None,
105 empty_style: None,
106 empty_width: Constraint::Percentage(50),
107 empty_height: Constraint::Length(5),
108 margin: Default::default(),
109 offset: Default::default(),
110 width: Default::default(),
111 height: Default::default(),
112 }
113 }
114}
115
116#[component]
117pub fn MultiSelect<T>(
118 props: &mut MultiSelectProps<T>,
119 mut hooks: Hooks,
120) -> impl Into<AnyElement<'static>>
121where
122 T: Into<ListItem<'static>> + Clone + Send + Sync + 'static,
123{
124 let state = hooks.use_state(ListState::default);
125 let state = props.state.unwrap_or(state);
126 let selected = hooks.use_state(HashSet::<usize>::default);
127 let selected = props.selected.unwrap_or(selected);
128
129 let item_count = props.items.len();
130 let default_index = props.default_index;
131 let mut last_default_index = hooks.use_state(|| None::<Option<usize>>);
132 hooks.use_effect(
133 move || {
134 let mut last_default = last_default_index.get();
135 sync_default_selection(
136 &mut state.write(),
137 &mut last_default,
138 default_index,
139 item_count,
140 );
141 last_default_index.set(last_default);
142 },
143 (default_index, item_count),
144 );
145
146 hooks.use_effect(
147 move || {
148 selected.write().retain(|index| *index < item_count);
149 },
150 item_count,
151 );
152
153 let selected_index = state.read().selected();
154 hooks.use_effect(
155 move || {
156 if selected_index.is_some_and(|index| index >= item_count) {
157 state.write().select(item_count.checked_sub(1));
158 }
159 },
160 (selected_index, item_count),
161 );
162
163 let active = props.active;
164 let items = props.items.clone();
165 let mut on_change = props.on_change.take();
166 let mut on_select = props.on_select.take();
167
168 hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
169 if !active || item_count == 0 {
170 return EventResult::Ignored;
171 }
172
173 let Event::Key(key) = event else {
174 return EventResult::Ignored;
175 };
176 if key.kind != KeyEventKind::Press {
177 return EventResult::Ignored;
178 }
179
180 match key.code {
181 KeyCode::Char('j') | KeyCode::Down => {
182 state.write().select_next();
183 EventResult::Consumed
184 }
185 KeyCode::Char('k') | KeyCode::Up => {
186 state.write().select_previous();
187 EventResult::Consumed
188 }
189 KeyCode::Home => {
190 state.write().select_first();
191 EventResult::Consumed
192 }
193 KeyCode::End => {
194 state.write().select_last();
195 EventResult::Consumed
196 }
197 KeyCode::Char(' ') => {
198 if let Some(index) = state.read().selected() {
199 let mut selected_set = selected.write();
200 if !selected_set.insert(index) {
201 selected_set.remove(&index);
202 }
203 drop(selected_set);
204 let changed_items = selected_items(&items, &selected.read());
205 on_change(changed_items);
206 }
207 EventResult::Consumed
208 }
209 KeyCode::Enter => {
210 let chosen_items = selected_items(&items, &selected.read());
211 on_select(chosen_items);
212 EventResult::Consumed
213 }
214 _ => EventResult::Ignored,
215 }
216 });
217
218 let theme = hooks.use_component_theme::<MultiSelectTheme>();
220 let style = resolve_style(theme.style, props.style);
221 let border_style = resolve_style(theme.border_style, props.border_style);
222 let highlight_style = resolve_style(theme.highlight_style, props.highlight_style);
223 let selected_item_style = resolve_style(theme.selected_item_style, props.selected_item_style);
224 let empty_style = resolve_style(theme.empty_style, props.empty_style);
225
226 let is_empty = props.items.is_empty();
227 let selected_snapshot = selected.read().clone();
228 let list_items: Vec<ListItem<'static>> = props
229 .items
230 .clone()
231 .into_iter()
232 .enumerate()
233 .map(|(index, item)| {
234 let item: ListItem<'static> = item.into();
235 if selected_snapshot.contains(&index) {
236 item.style(selected_item_style)
237 } else {
238 item
239 }
240 })
241 .collect();
242
243 let mut list = List::new(list_items)
244 .style(style)
245 .highlight_style(highlight_style);
246
247 if let Some(highlight_symbol) = props.highlight_symbol {
248 list = list.highlight_symbol(highlight_symbol);
249 }
250
251 element!(Border(
252 margin: props.margin,
253 offset: props.offset,
254 width: props.width,
255 height: props.height,
256 border_style: border_style,
257 top_title: props.top_title.clone(),
258 bottom_title: props.bottom_title.clone(),
259 ) {
260 if is_empty {
261 Center(
262 width: props.empty_width,
263 height: props.empty_height,
264 ) {
265 Text(
266 text: props.empty_message.clone(),
267 alignment: Alignment::Center,
268 style: empty_style,
269 wrap: true,
270 )
271 }
272 } else {
273 stateful(list, state)
274 }
275 })
276}
277
278fn selected_items<T>(items: &[T], selected: &HashSet<usize>) -> Vec<T>
279where
280 T: Clone,
281{
282 items
283 .iter()
284 .enumerate()
285 .filter(|(index, _)| selected.contains(index))
286 .map(|(_, item)| item.clone())
287 .collect()
288}