rskit_cli/prompt/kinds/
select.rs1use rskit_errors::AppResult;
5
6use super::{
7 cancelled, closed_input, focus_down, focus_up, non_interactive_error, notice, parse_index,
8 with_raw_mode, write_answer,
9};
10use crate::prompt::choice::{Choice, ChoiceId};
11use crate::prompt::key::Key;
12use crate::prompt::mode::PromptMode;
13use crate::prompt::render::{self, Style};
14use crate::prompt::terminal::Terminal;
15
16pub fn run(
18 terminal: &mut (impl Terminal + ?Sized),
19 style: Style,
20 mode: PromptMode,
21 prompt: &str,
22 choices: &[Choice],
23) -> AppResult<ChoiceId> {
24 if choices.is_empty() {
25 return Err(rskit_errors::AppError::invalid_input(
26 "prompt",
27 format!("select requires at least one choice: {prompt}"),
28 ));
29 }
30 let default = choices.iter().position(Choice::is_recommended);
31
32 if !mode.is_interactive() {
33 return default.map_or_else(
34 || Err(non_interactive_error(prompt)),
35 |index| Ok(choices[index].id().clone()),
36 );
37 }
38
39 if terminal.capabilities().is_key_driven() {
40 key_driven(terminal, style, prompt, choices, default)
41 } else {
42 line_driven(terminal, style, prompt, choices, default)
43 }
44}
45
46fn key_driven(
47 terminal: &mut (impl Terminal + ?Sized),
48 style: Style,
49 prompt: &str,
50 choices: &[Choice],
51 default: Option<usize>,
52) -> AppResult<ChoiceId> {
53 let len = choices.len();
54 let mut cursor = default.unwrap_or(0);
55 with_raw_mode(terminal, |terminal| {
56 let mut drawn = draw(terminal, style, prompt, choices, cursor, default)?;
57 loop {
58 let key = terminal.read_key()?;
59 match key {
60 Key::Enter => return Ok(choices[cursor].id().clone()),
61 Key::Escape | Key::Interrupt => return Err(cancelled(prompt)),
62 Key::Up => cursor = focus_up(cursor, len),
63 Key::Down | Key::Tab => cursor = focus_down(cursor, len),
64 Key::Home => cursor = 0,
65 Key::End => cursor = len - 1,
66 _ => continue,
67 }
68 terminal.clear_last_lines(drawn)?;
69 drawn = draw(terminal, style, prompt, choices, cursor, default)?;
70 }
71 })
72}
73
74fn draw(
75 terminal: &mut (impl Terminal + ?Sized),
76 style: Style,
77 prompt: &str,
78 choices: &[Choice],
79 cursor: usize,
80 default: Option<usize>,
81) -> AppResult<u16> {
82 terminal.write_line(&render::heading(style, prompt))?;
83 for row in render::frame_rows(style, choices, cursor, None, default) {
84 terminal.write_line(&row)?;
85 }
86 terminal.write_line(&render::key_hint(style, false))?;
87 terminal.flush()?;
88 Ok(u16::try_from(choices.len())
89 .unwrap_or(u16::MAX)
90 .saturating_add(2))
91}
92
93fn line_driven(
94 terminal: &mut (impl Terminal + ?Sized),
95 style: Style,
96 prompt: &str,
97 choices: &[Choice],
98 default: Option<usize>,
99) -> AppResult<ChoiceId> {
100 terminal.write_line(&render::heading(style, prompt))?;
101 for row in render::numbered_rows(style, choices, false, default) {
102 terminal.write_line(&row)?;
103 }
104 loop {
105 let hint = default.map(|index| format!("[{}]", index + 1));
106 write_answer(terminal, style, hint.as_deref())?;
107 let Some(line) = terminal.read_line()? else {
108 return Err(closed_input(prompt));
109 };
110 let answer = line.trim();
111 if answer.is_empty() {
112 if let Some(index) = default {
113 return Ok(choices[index].id().clone());
114 }
115 notice(terminal, style, "a choice is required")?;
116 continue;
117 }
118 if let Some(index) = parse_index(answer, choices.len()) {
119 return Ok(choices[index].id().clone());
120 }
121 notice(
122 terminal,
123 style,
124 &format!("enter a number between 1 and {}", choices.len()),
125 )?;
126 }
127}