entrust_dialog/
select.rs

1use crate::dialog::{Dialog, DialogState};
2use crate::input::prompt::Prompt;
3use crate::input::InputDialog;
4use crate::select::filter::get_filtered;
5use crate::theme::Theme;
6use crate::{cancel_key_event, input, key_event_pattern as kep};
7use ratatui::crossterm::event::Event::Key;
8use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
9use ratatui::widgets::ListState;
10use ratatui::{Frame, Viewport};
11use std::borrow::Cow;
12use std::env;
13use tracing::debug;
14
15mod filter;
16mod widget;
17
18#[derive(Debug)]
19pub struct SelectDialog<'a> {
20    items: Vec<Item<'a>>,
21    list_state: ListState,
22    height: u8,
23    theme: Cow<'static, Theme>,
24    filter_dialog: Option<InputDialog<'static, 'static>>,
25    state: DialogState,
26}
27
28#[derive(Clone, Debug, Default)]
29struct Item<'a> {
30    content: Cow<'a, str>,
31    index: usize,
32}
33
34impl<'a> SelectDialog<'a> {
35    pub fn new(options: Vec<Cow<'a, str>>) -> Self {
36        let list_state = if options.is_empty() {
37            ListState::default()
38        } else {
39            ListState::default().with_selected(Some(0))
40        };
41        let height =
42            env::var("ENT_SELECT_HEIGHT").map_or(10, |var| var.parse::<u8>().unwrap_or(10));
43        let items = options
44            .into_iter()
45            .enumerate()
46            .map(|(index, content)| Item { index, content })
47            .collect();
48        let theme = Cow::Borrowed(Theme::default_ref());
49        let filter = InputDialog::default()
50            .with_prompt(Prompt::inline("  "))
51            .with_placeholder("type to filter...")
52            .into();
53        SelectDialog {
54            items,
55            list_state,
56            height,
57            theme,
58            filter_dialog: filter,
59            state: DialogState::Pending,
60        }
61    }
62    pub fn with_theme<T: Into<Cow<'static, Theme>>>(mut self, theme: T) -> Self {
63        self.theme = theme.into();
64        self
65    }
66    pub fn with_height(mut self, height: u8) -> Self {
67        self.height = height;
68        self
69    }
70    pub fn without_filter_dialog(mut self) -> Self {
71        self.filter_dialog = None;
72        self
73    }
74
75    fn current_item_index(&self) -> Option<usize> {
76        if let Some(filter_dialog) = &self.filter_dialog {
77            let filtered = get_filtered(&self.items, &filter_dialog.current_content());
78            self.list_state.selected().map(|s| filtered[s].item.index)
79        } else {
80            self.list_state.selected()
81        }
82    }
83}
84
85#[derive(Debug)]
86pub enum Update {
87    Previous,
88    Next,
89    FilterInput(input::Update),
90    Confirm,
91    Cancel,
92}
93
94impl<'a> Dialog for SelectDialog<'a> {
95    type Update = Update;
96    type Output = Option<Cow<'a, str>>;
97
98    fn update_for_event(event: Event) -> Option<Self::Update> {
99        match event {
100            Key(ke) => match ke {
101                cancel_key_event!() => Update::Cancel.into(),
102                kep!(KeyCode::Up) => Update::Previous.into(),
103                kep!(KeyCode::Down) => Update::Next.into(),
104                kep!(KeyCode::Backspace) => {
105                    Update::FilterInput(input::Update::DeleteBeforeCursor).into()
106                }
107                kep!(KeyCode::Left) => Update::FilterInput(input::Update::MoveCursorLeft).into(),
108                kep!(KeyCode::Right) => Update::FilterInput(input::Update::MoveCursorRight).into(),
109                kep!(KeyCode::Enter) => Update::Confirm.into(),
110                _ => InputDialog::update_for_event(event).map(Update::FilterInput),
111            },
112            _ => None,
113        }
114    }
115
116    fn perform_update(&mut self, update: Self::Update) -> std::io::Result<()> {
117        debug!(?update, ?self.items, ?self.list_state);
118        match update {
119            Update::Previous => self.list_state.select_previous(),
120            Update::Next => self.list_state.select_next(),
121            Update::FilterInput(input) => {
122                if let Some(ref mut filter_dialog) = self.filter_dialog {
123                    filter_dialog.perform_update(input)?;
124                }
125            }
126            Update::Confirm => self.state = DialogState::Completed,
127            Update::Cancel => self.state = DialogState::Cancelled,
128        };
129        Ok(())
130    }
131
132    fn state(&self) -> DialogState {
133        self.state
134    }
135
136    fn output(mut self) -> Self::Output {
137        self.current_item_index()
138            .map(|i| self.items.swap_remove(i).content)
139    }
140
141    fn viewport(&self) -> Viewport {
142        Viewport::Inline(self.height as u16)
143    }
144
145    fn draw(&mut self, frame: &mut Frame) {
146        frame.render_widget(self, frame.area());
147    }
148
149    fn tick(&mut self) -> bool {
150        if let Some(ref mut filter_dialog) = self.filter_dialog {
151            filter_dialog.tick()
152        } else {
153            false
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::select::filter::apply_filter;
162
163    fn current_selected<'a>(dialog: &'a SelectDialog) -> Option<&'a str> {
164        dialog
165            .current_item_index()
166            .map(|i| &dialog.items[i].content)
167            .map(|c| c.as_ref())
168    }
169
170    #[test]
171    fn test_with_filter() {
172        let mut dialog = SelectDialog::new(
173            "I heard the trailing garments of the Night sweep through her marble halls"
174                .split(' ')
175                .map(Cow::Borrowed)
176                .collect(),
177        );
178
179        dialog.perform_update(Update::Next).unwrap();
180        let filtered = apply_filter(
181            dialog.items.as_slice(),
182            &mut dialog.list_state,
183            dialog
184                .filter_dialog
185                .as_ref()
186                .unwrap()
187                .current_content()
188                .as_str(),
189        );
190        assert_eq!(13, filtered.len());
191        assert_eq!(Some("heard"), current_selected(&dialog));
192
193        dialog
194            .perform_update(Update::FilterInput(input::Update::InsertChar('t')))
195            .unwrap();
196        let filtered = apply_filter(
197            dialog.items.as_slice(),
198            &mut dialog.list_state,
199            dialog
200                .filter_dialog
201                .as_ref()
202                .unwrap()
203                .current_content()
204                .as_str(),
205        );
206        assert_eq!(6, filtered.len());
207        assert_eq!(Some("trailing"), current_selected(&dialog));
208
209        dialog
210            .perform_update(Update::FilterInput(input::Update::InsertChar('x')))
211            .unwrap();
212        let filtered = apply_filter(
213            dialog.items.as_slice(),
214            &mut dialog.list_state,
215            dialog
216                .filter_dialog
217                .as_ref()
218                .unwrap()
219                .current_content()
220                .as_str(),
221        );
222        assert_eq!(0, filtered.len());
223        assert_eq!(None, current_selected(&dialog));
224
225        dialog
226            .perform_update(Update::FilterInput(input::Update::DeleteBeforeCursor))
227            .unwrap();
228        let filtered = apply_filter(
229            dialog.items.as_slice(),
230            &mut dialog.list_state,
231            dialog
232                .filter_dialog
233                .as_ref()
234                .unwrap()
235                .current_content()
236                .as_str(),
237        );
238        assert_eq!(6, filtered.len());
239        assert_eq!(Some("the"), current_selected(&dialog));
240    }
241
242    #[test]
243    fn test_without_filter() {
244        let mut dialog = SelectDialog::new(
245            "I saw her sable skirts all fringed with light from the celestial walls"
246                .split(' ')
247                .map(Cow::Borrowed)
248                .collect(),
249        )
250        .without_filter_dialog();
251
252        dialog.perform_update(Update::Next).unwrap();
253        assert_eq!(Some("saw"), current_selected(&dialog));
254
255        dialog
256            .perform_update(Update::FilterInput(input::Update::InsertChar('a')))
257            .unwrap();
258        assert_eq!(Some("saw"), current_selected(&dialog));
259
260        dialog.perform_update(Update::Previous).unwrap();
261        assert_eq!(Some("I"), current_selected(&dialog));
262
263        for _ in 0..11 {
264            dialog.perform_update(Update::Next).unwrap();
265        }
266        assert_eq!(Some("celestial"), current_selected(&dialog));
267
268        dialog.perform_update(Update::Next).unwrap();
269        assert_eq!(Some("walls"), current_selected(&dialog));
270    }
271}