Skip to main content

ratatui_kit/components/
multi_select.rs

1// MultiSelect 组件:带键盘事件处理的多选列表。
2
3use 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}