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::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/// MultiSelect 组件的主题 slot。高亮为「`on_accent` 前景 + `selection` 底」;已勾选项取 `accent`。
24#[non_exhaustive]
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct MultiSelectTheme {
27    /// 列表项常规样式。
28    pub style: Style,
29    /// 边框样式。
30    pub border_style: Style,
31    /// 光标所在项高亮样式。
32    pub highlight_style: Style,
33    /// 已勾选项样式。
34    pub selected_item_style: Style,
35    /// 空态提示样式。
36    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    // 以下样式覆盖:`None` 用 `MultiSelectTheme`,`Some(s)` 以 `theme.patch(s)` 覆盖。
75    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    // 主题解析:每个 slot 铺底,对应 props 的 Option<Style> 在上 patch(None → 用主题)。
219    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}