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}