Skip to main content

tui_prompts/
select_prompt.rs

1use std::borrow::Cow;
2
3use ratatui_core::buffer::Buffer;
4use ratatui_core::layout::{Alignment, Rect};
5use ratatui_core::style::{Color, Modifier, Style, Stylize};
6use ratatui_core::terminal::Frame;
7use ratatui_core::text::{Line, Span};
8use ratatui_core::widgets::{StatefulWidget, Widget};
9use ratatui_widgets::block::Block;
10use ratatui_widgets::paragraph::Paragraph;
11
12use crate::prelude::*;
13use crate::select_state::SelectState;
14
15/// A prompt widget for choosing one option from a list.
16///
17/// `SelectPrompt` owns the label and options to render, while [`SelectState`] tracks the focused
18/// option, focus state, and completion status. Render the prompt before routing key events so the
19/// state can learn how many options are currently selectable.
20///
21/// When the render area is shorter than the option list, the prompt renders a window around the
22/// focused option so the highlighted row remains visible.
23#[derive(Debug, Default, Clone, PartialEq, Eq)]
24pub struct SelectPrompt<'a> {
25    label: Option<Cow<'a, str>>,
26    options: SelectOptionList<'a>,
27    block: Option<Block<'a>>,
28}
29
30/// An ordered list of selectable values for a [`SelectPrompt`].
31///
32/// Arrays and vectors of values that convert into [`SelectOption`] can be converted into a
33/// `SelectOptionList`, which keeps simple string lists concise:
34///
35/// ```
36/// use std::borrow::Cow;
37///
38/// use tui_prompts::{SelectOptionList, SelectPrompt};
39///
40/// let options: SelectOptionList = ["Rust", "Zig", "Go"].into();
41/// let prompt = SelectPrompt::new(Cow::Borrowed("Language"), options);
42/// ```
43#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
44pub struct SelectOptionList<'a> {
45    options: Vec<SelectOption<'a>>,
46}
47
48/// A selectable value rendered by a [`SelectPrompt`].
49///
50/// String slices and owned strings can be converted into options directly.
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct SelectOption<'a> {
53    value: Cow<'a, str>,
54}
55
56impl<'a> SelectPrompt<'a> {
57    /// Creates a select prompt from a label and an ordered option list.
58    #[must_use]
59    pub const fn new(label: Cow<'a, str>, options: SelectOptionList<'a>) -> Self {
60        Self {
61            label: Some(label),
62            options,
63            block: None,
64        }
65    }
66
67    /// Wraps the prompt in a [`Block`].
68    #[must_use]
69    pub fn with_block(mut self, block: Block<'a>) -> Self {
70        self.block = Some(block);
71        self
72    }
73}
74
75impl<'a> SelectOptionList<'a> {
76    /// Creates an option list from explicit option values.
77    #[must_use]
78    pub const fn new(options: Vec<SelectOption<'a>>) -> Self {
79        Self { options }
80    }
81
82    /// Returns the number of options in the list.
83    #[must_use]
84    pub const fn len(&self) -> usize {
85        self.options.len()
86    }
87
88    /// Returns whether the list contains no options.
89    #[must_use]
90    pub const fn is_empty(&self) -> bool {
91        self.options.is_empty()
92    }
93
94    /// Returns an iterator over the options.
95    pub fn iter(&self) -> impl Iterator<Item = &SelectOption<'a>> {
96        self.options.iter()
97    }
98}
99
100impl<'a> SelectOption<'a> {
101    /// Creates a selectable option.
102    #[must_use]
103    pub fn new(value: impl Into<Cow<'a, str>>) -> Self {
104        Self {
105            value: value.into(),
106        }
107    }
108
109    /// Returns the text rendered for this option.
110    #[must_use]
111    pub fn value(&self) -> &str {
112        &self.value
113    }
114}
115
116impl<'a> From<&'a str> for SelectOption<'a> {
117    fn from(value: &'a str) -> Self {
118        Self::new(value)
119    }
120}
121
122impl From<String> for SelectOption<'_> {
123    fn from(value: String) -> Self {
124        Self::new(value)
125    }
126}
127
128impl<'a, T> From<Vec<T>> for SelectOptionList<'a>
129where
130    T: Into<SelectOption<'a>>,
131{
132    fn from(options: Vec<T>) -> Self {
133        Self::new(options.into_iter().map(Into::into).collect())
134    }
135}
136
137impl<'a, T, const N: usize> From<[T; N]> for SelectOptionList<'a>
138where
139    T: Into<SelectOption<'a>>,
140{
141    fn from(options: [T; N]) -> Self {
142        Self::new(options.into_iter().map(Into::into).collect())
143    }
144}
145
146impl<'a> IntoIterator for &'a SelectOptionList<'a> {
147    type IntoIter = std::slice::Iter<'a, SelectOption<'a>>;
148    type Item = &'a SelectOption<'a>;
149
150    fn into_iter(self) -> Self::IntoIter {
151        self.options.iter()
152    }
153}
154
155impl Prompt for SelectPrompt<'_> {
156    fn draw(self, frame: &mut Frame, area: Rect, state: &mut Self::State) {
157        frame.render_stateful_widget(self, area, state);
158    }
159}
160
161impl<'a> StatefulWidget for SelectPrompt<'a> {
162    type State = SelectState;
163
164    fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
165        let area = self.render_block(area, buf);
166        let visible_option_count = self.visible_option_count(area);
167        self.sync_state(state, visible_option_count);
168
169        let lines = self.lines(state, visible_option_count);
170        Paragraph::new(lines)
171            .alignment(Alignment::Left)
172            .render(area, buf);
173    }
174}
175
176impl SelectPrompt<'_> {
177    fn render_block(&mut self, area: Rect, buf: &mut Buffer) -> Rect {
178        if let Some(block) = self.block.take() {
179            let inner_area = block.inner(area);
180            block.render(area, buf);
181            inner_area
182        } else {
183            area
184        }
185    }
186
187    fn visible_option_count(&self, area: Rect) -> usize {
188        let label_height = usize::from(self.label.is_some());
189        (area.height as usize).saturating_sub(label_height)
190    }
191
192    fn sync_state(&self, state: &mut SelectState, visible_option_count: usize) {
193        state.option_count = if visible_option_count == 0 {
194            0
195        } else {
196            self.options.len()
197        };
198        state.focused_index = state.clamp_focused_index(state.focused_index);
199    }
200
201    fn lines(mut self, state: &SelectState, visible_option_count: usize) -> Vec<Line<'static>> {
202        let mut lines = Vec::new();
203        if let Some(label) = self.label.take() {
204            lines.push(Line::from(vec![
205                state.status().symbol(),
206                " ".into(),
207                label.into_owned().bold(),
208            ]));
209        }
210
211        let option_start = visible_window_start(
212            state.focused_index(),
213            self.options.len(),
214            visible_option_count,
215        );
216        lines.extend(
217            self.options
218                .iter()
219                .enumerate()
220                .skip(option_start)
221                .take(visible_option_count)
222                .map(|(i, option)| option_line(option, i == state.focused_index())),
223        );
224        lines
225    }
226}
227
228fn option_line(option: &SelectOption<'_>, focused: bool) -> Line<'static> {
229    if focused {
230        Line::from(Span::styled(
231            format!("> {}", option.value()),
232            Style::default()
233                .fg(Color::Yellow)
234                .add_modifier(Modifier::BOLD),
235        ))
236    } else {
237        Line::from(Span::raw(format!("  {}", option.value())))
238    }
239}
240
241const fn visible_window_start(
242    focused_index: usize,
243    option_count: usize,
244    visible_count: usize,
245) -> usize {
246    if visible_count == 0 || option_count <= visible_count {
247        0
248    } else if focused_index >= visible_count {
249        focused_index + 1 - visible_count
250    } else {
251        0
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use ratatui::Terminal;
258    use ratatui::backend::TestBackend;
259    use ratatui::widgets::Borders;
260    use rstest::{fixture, rstest};
261
262    use super::*;
263
264    #[test]
265    fn new() {
266        let options = vec![
267            SelectOption::from("Option 1"),
268            SelectOption::from("Option 2"),
269            SelectOption::from("Option 3"),
270        ];
271
272        let prompt = SelectPrompt::new(Cow::Borrowed("label"), options.clone().into());
273        assert_eq!(prompt.options, SelectOptionList::new(options));
274        assert!(prompt.block.is_none());
275    }
276
277    #[test]
278    fn default() {
279        let prompt = SelectPrompt::default();
280        assert_eq!(prompt.options, SelectOptionList::default());
281        assert_eq!(prompt.block, None);
282    }
283
284    #[test]
285    fn option_list_from_strings() {
286        let options = SelectOptionList::from(["Option 1", "Option 2"]);
287
288        assert_eq!(options.len(), 2);
289        assert!(!options.is_empty());
290        assert_eq!(
291            (&options)
292                .into_iter()
293                .map(SelectOption::value)
294                .collect::<Vec<_>>(),
295            ["Option 1", "Option 2"],
296        );
297    }
298
299    #[test]
300    fn render_with_max_options() {
301        let options = vec![
302            SelectOption::from("Option 1"),
303            SelectOption::from("Option 2"),
304            SelectOption::from("Option 3"),
305        ];
306
307        let prompt = prompt_with_block(options.clone());
308        let mut state = SelectState::default();
309        state.set_focused_index(1);
310
311        let backend = TestBackend::new(20, 10);
312        let mut terminal = Terminal::new(backend).unwrap();
313
314        draw_prompt(&mut terminal, prompt, &mut state);
315
316        let mut expected = Buffer::with_lines(vec![
317            "┌Select────────────┐",
318            "│? label           │",
319            "│  Option 1        │",
320            "│> Option 2        │",
321            "│  Option 3        │",
322            "│                  │",
323            "│                  │",
324            "│                  │",
325            "│                  │",
326            "└──────────────────┘",
327        ]);
328
329        expected.set_style(Rect::new(1, 1, 1, 1), Color::Cyan);
330
331        expected.set_style(Rect::new(3, 1, 5, 1), Modifier::BOLD);
332
333        expected.set_style(Rect::new(1, 3, 10, 1), (Color::Yellow, Modifier::BOLD));
334
335        terminal.backend().assert_buffer(&expected);
336    }
337    #[fixture]
338    fn terminal() -> Terminal<TestBackend> {
339        Terminal::new(TestBackend::new(20, 10)).unwrap()
340    }
341
342    fn prompt_with_block(options: impl Into<SelectOptionList<'static>>) -> SelectPrompt<'static> {
343        SelectPrompt::new(Cow::Borrowed("label"), options.into())
344            .with_block(Block::default().borders(Borders::ALL).title("Select"))
345    }
346
347    fn draw_prompt(
348        terminal: &mut Terminal<TestBackend>,
349        prompt: SelectPrompt<'_>,
350        state: &mut SelectState,
351    ) {
352        terminal
353            .draw(|frame| {
354                let area = frame.area();
355                prompt.clone().draw(frame, area, state);
356            })
357            .unwrap();
358    }
359
360    #[rstest]
361    fn render_selected(mut terminal: Terminal<TestBackend>) {
362        let options = vec![
363            SelectOption::from("Option 1"),
364            SelectOption::from("Option 2"),
365            SelectOption::from("Option 3"),
366        ];
367
368        let prompt = prompt_with_block(options.clone());
369        let mut state = SelectState::default().with_status(Status::Done);
370        state.set_focused_index(2);
371
372        draw_prompt(&mut terminal, prompt, &mut state);
373
374        let mut expected = Buffer::with_lines(vec![
375            "┌Select────────────┐",
376            "│✔ label           │",
377            "│  Option 1        │",
378            "│  Option 2        │",
379            "│> Option 3        │",
380            "│                  │",
381            "│                  │",
382            "│                  │",
383            "│                  │",
384            "└──────────────────┘",
385        ]);
386
387        expected.set_style(Rect::new(1, 1, 1, 1), Color::Green);
388
389        expected.set_style(Rect::new(3, 1, 5, 1), Modifier::BOLD);
390
391        expected.set_style(Rect::new(1, 4, 10, 1), (Color::Yellow, Modifier::BOLD));
392
393        terminal.backend().assert_buffer(&expected);
394    }
395
396    #[test]
397    fn render_scrolls_focused_option_into_view() {
398        let options = ["Option 1", "Option 2", "Option 3", "Option 4"].into();
399        let prompt = SelectPrompt::new(Cow::Borrowed("label"), options);
400        let mut state = SelectState::new();
401        state.set_focused_index(3);
402
403        let backend = TestBackend::new(20, 3);
404        let mut terminal = Terminal::new(backend).unwrap();
405
406        draw_prompt(&mut terminal, prompt, &mut state);
407
408        let mut expected = Buffer::with_lines(vec![
409            "? label             ",
410            "  Option 3          ",
411            "> Option 4          ",
412        ]);
413
414        expected.set_style(Rect::new(0, 0, 1, 1), Color::Cyan);
415        expected.set_style(Rect::new(2, 0, 5, 1), Modifier::BOLD);
416        expected.set_style(Rect::new(0, 2, 10, 1), (Color::Yellow, Modifier::BOLD));
417
418        assert_eq!(state.option_count, 4);
419        terminal.backend().assert_buffer(&expected);
420    }
421
422    #[test]
423    fn render_disables_option_navigation_when_no_options_are_visible() {
424        let options = ["Option 1", "Option 2"].into();
425        let prompt = SelectPrompt::new(Cow::Borrowed("label"), options);
426        let mut state = SelectState::new();
427        state.set_focused_index(1);
428
429        let backend = TestBackend::new(20, 1);
430        let mut terminal = Terminal::new(backend).unwrap();
431
432        draw_prompt(&mut terminal, prompt, &mut state);
433
434        let mut expected = Buffer::with_lines(vec!["? label             "]);
435
436        expected.set_style(Rect::new(0, 0, 1, 1), Color::Cyan);
437        expected.set_style(Rect::new(2, 0, 5, 1), Modifier::BOLD);
438
439        assert_eq!(state.option_count, 0);
440        terminal.backend().assert_buffer(&expected);
441    }
442}