Skip to main content

ratatui_kit/components/
select.rs

1// Select 组件:带键盘事件处理的单选列表。
2
3use 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/// Select 组件的主题 slot。高亮为「`on_accent` 前景 + `selection` 底」的配对。
22#[non_exhaustive]
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct SelectTheme {
25    /// 列表项常规样式。
26    pub style: Style,
27    /// 边框样式。
28    pub border_style: Style,
29    /// 选中项高亮样式。
30    pub highlight_style: Style,
31    /// 空态提示样式。
32    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    // 以下样式覆盖:`None` 用 `SelectTheme`,`Some(s)` 以 `theme.patch(s)` 覆盖。
68    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    // 主题解析:每个 slot 铺底,对应 props 的 Option<Style> 在上 patch(None → 用主题)。
187    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}