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::{Color, 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, Handler, Hooks, State, UseEffect, UseEventHandler, UseState,
15    components::{Border, Center, Text, TextParagraph},
16    input::{EventPriority, EventResult, EventScope},
17};
18
19#[with_layout_style(margin, offset, width, height)]
20#[derive(Props)]
21pub struct SelectProps<T>
22where
23    T: Into<ListItem<'static>> + Clone + Send + Sync + 'static,
24{
25    pub items: Vec<T>,
26    pub on_select: Handler<'static, T>,
27    pub state: Option<State<ListState>>,
28    pub top_title: Option<Line<'static>>,
29    pub bottom_title: Option<Line<'static>>,
30    pub active: bool,
31    pub default_index: Option<usize>,
32    pub empty_message: TextParagraph<'static>,
33    pub highlight_symbol: Option<&'static str>,
34    pub style: Style,
35    pub border_style: Style,
36    pub highlight_style: Style,
37    pub empty_style: Style,
38    pub empty_width: Constraint,
39    pub empty_height: Constraint,
40}
41
42impl<T> Default for SelectProps<T>
43where
44    T: Into<ListItem<'static>> + Clone + Send + Sync,
45{
46    fn default() -> Self {
47        Self {
48            items: Vec::new(),
49            on_select: Handler::default(),
50            state: None,
51            top_title: None,
52            bottom_title: None,
53            active: true,
54            default_index: None,
55            empty_message: TextParagraph::from("No data"),
56            highlight_symbol: None,
57            style: Style::default(),
58            border_style: Style::default(),
59            highlight_style: Style::default().fg(Color::Black).bg(Color::Cyan),
60            empty_style: Style::default().fg(Color::Yellow),
61            empty_width: Constraint::Percentage(50),
62            empty_height: Constraint::Length(5),
63            margin: Default::default(),
64            offset: Default::default(),
65            width: Default::default(),
66            height: Default::default(),
67        }
68    }
69}
70
71#[component]
72pub fn Select<T>(props: &mut SelectProps<T>, mut hooks: Hooks) -> impl Into<AnyElement<'static>>
73where
74    T: Into<ListItem<'static>> + Clone + Send + Sync + 'static,
75{
76    let state = hooks.use_state(ListState::default);
77    let state = props.state.unwrap_or(state);
78
79    let default_index = props.default_index;
80    let item_count = props.items.len();
81    let mut last_default_index = hooks.use_state(|| None::<Option<usize>>);
82    hooks.use_effect(
83        move || {
84            let mut last_default = last_default_index.get();
85            sync_default_selection(
86                &mut state.write(),
87                &mut last_default,
88                default_index,
89                item_count,
90            );
91            last_default_index.set(last_default);
92        },
93        (default_index, item_count),
94    );
95
96    let selected_index = state.read().selected();
97    hooks.use_effect(
98        move || {
99            if selected_index.is_some_and(|index| index >= item_count) {
100                state.write().select(item_count.checked_sub(1));
101            }
102        },
103        (selected_index, item_count),
104    );
105
106    let active = props.active;
107    let items = props.items.clone();
108    let mut on_select = props.on_select.take();
109
110    hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
111        if !active || item_count == 0 {
112            return EventResult::Ignored;
113        }
114
115        let Event::Key(key) = event else {
116            return EventResult::Ignored;
117        };
118        if key.kind != KeyEventKind::Press {
119            return EventResult::Ignored;
120        }
121
122        match key.code {
123            KeyCode::Char('j') | KeyCode::Down => {
124                state.write().select_next();
125                EventResult::Consumed
126            }
127            KeyCode::Char('k') | KeyCode::Up => {
128                state.write().select_previous();
129                EventResult::Consumed
130            }
131            KeyCode::Home => {
132                state.write().select_first();
133                EventResult::Consumed
134            }
135            KeyCode::End => {
136                state.write().select_last();
137                EventResult::Consumed
138            }
139            KeyCode::Enter => {
140                let selected_index = state.read().selected();
141                if let Some(index) = selected_index
142                    && let Some(item) = items.get(index)
143                {
144                    on_select(item.clone());
145                }
146                EventResult::Consumed
147            }
148            _ => EventResult::Ignored,
149        }
150    });
151
152    let is_empty = props.items.is_empty();
153    let mut list = List::new(props.items.clone())
154        .style(props.style)
155        .highlight_style(props.highlight_style);
156
157    if let Some(highlight_symbol) = props.highlight_symbol {
158        list = list.highlight_symbol(highlight_symbol);
159    }
160
161    element!(Border(
162        margin: props.margin,
163        offset: props.offset,
164        width: props.width,
165        height: props.height,
166        border_style: props.border_style,
167        top_title: props.top_title.clone(),
168        bottom_title: props.bottom_title.clone(),
169    ) {
170        if is_empty {
171            Center(
172                width: props.empty_width,
173                height: props.empty_height,
174            ) {
175                Text(
176                    text: props.empty_message.clone(),
177                    alignment: Alignment::Center,
178                    style: props.empty_style,
179                    wrap: true,
180                )
181            }
182        } else {
183            stateful(list, state)
184        }
185    })
186}