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#[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#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
44pub struct SelectOptionList<'a> {
45 options: Vec<SelectOption<'a>>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct SelectOption<'a> {
53 value: Cow<'a, str>,
54}
55
56impl<'a> SelectPrompt<'a> {
57 #[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 #[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 #[must_use]
78 pub const fn new(options: Vec<SelectOption<'a>>) -> Self {
79 Self { options }
80 }
81
82 #[must_use]
84 pub const fn len(&self) -> usize {
85 self.options.len()
86 }
87
88 #[must_use]
90 pub const fn is_empty(&self) -> bool {
91 self.options.is_empty()
92 }
93
94 pub fn iter(&self) -> impl Iterator<Item = &SelectOption<'a>> {
96 self.options.iter()
97 }
98}
99
100impl<'a> SelectOption<'a> {
101 #[must_use]
103 pub fn new(value: impl Into<Cow<'a, str>>) -> Self {
104 Self {
105 value: value.into(),
106 }
107 }
108
109 #[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}