Skip to main content

rskit_cli/prompt/kinds/
select.rs

1//! Single-choice selection: a radio list in key-driven mode, a numbered list in
2//! line-driven mode.
3
4use 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
16/// Ask for exactly one choice, dispatching on mode and terminal capability.
17pub 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}