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