Skip to main content

rskit_cli/prompt/kinds/
multi_select.rs

1//! Multi-choice selection: a checkbox list in key-driven mode, a comma-separated
2//! numbered list in line-driven mode.
3
4use rskit_errors::AppResult;
5
6use super::{
7    cancelled, closed_input, focus_down, focus_up, notice, parse_index, with_raw_mode, write_answer,
8};
9use crate::prompt::choice::{Choice, ChoiceId};
10use crate::prompt::key::Key;
11use crate::prompt::mode::PromptMode;
12use crate::prompt::render::{self, Style};
13use crate::prompt::terminal::Terminal;
14
15/// Ask for zero or more choices, dispatching on mode and terminal capability.
16pub fn run(
17    terminal: &mut (impl Terminal + ?Sized),
18    style: Style,
19    mode: PromptMode,
20    prompt: &str,
21    choices: &[Choice],
22) -> AppResult<Vec<ChoiceId>> {
23    if choices.is_empty() {
24        return Err(rskit_errors::AppError::invalid_input(
25            "prompt",
26            format!("multi_select requires at least one choice: {prompt}"),
27        ));
28    }
29    let defaults: Vec<usize> = choices
30        .iter()
31        .enumerate()
32        .filter_map(|(index, choice)| choice.is_recommended().then_some(index))
33        .collect();
34
35    if !mode.is_interactive() {
36        return Ok(ids(choices, &defaults));
37    }
38
39    if terminal.capabilities().is_key_driven() {
40        key_driven(terminal, style, prompt, choices, defaults)
41    } else {
42        line_driven(terminal, style, prompt, choices, &defaults)
43    }
44}
45
46fn ids(choices: &[Choice], indices: &[usize]) -> Vec<ChoiceId> {
47    indices.iter().map(|&i| choices[i].id().clone()).collect()
48}
49
50fn key_driven(
51    terminal: &mut (impl Terminal + ?Sized),
52    style: Style,
53    prompt: &str,
54    choices: &[Choice],
55    defaults: Vec<usize>,
56) -> AppResult<Vec<ChoiceId>> {
57    let len = choices.len();
58    let mut cursor = 0;
59    let mut selected = defaults;
60    selected.sort_unstable();
61    with_raw_mode(terminal, |terminal| {
62        let mut drawn = draw(terminal, style, prompt, choices, cursor, &selected)?;
63        loop {
64            let key = terminal.read_key()?;
65            match key {
66                Key::Enter => return Ok(ids(choices, &selected)),
67                Key::Escape | Key::Interrupt => return Err(cancelled(prompt)),
68                Key::Up => cursor = focus_up(cursor, len),
69                Key::Down | Key::Tab => cursor = focus_down(cursor, len),
70                Key::Home => cursor = 0,
71                Key::End => cursor = len - 1,
72                Key::Space => toggle(&mut selected, cursor),
73                _ => continue,
74            }
75            terminal.clear_last_lines(drawn)?;
76            drawn = draw(terminal, style, prompt, choices, cursor, &selected)?;
77        }
78    })
79}
80
81fn toggle(selected: &mut Vec<usize>, index: usize) {
82    if let Some(position) = selected.iter().position(|&i| i == index) {
83        selected.remove(position);
84    } else {
85        selected.push(index);
86        selected.sort_unstable();
87    }
88}
89
90fn draw(
91    terminal: &mut (impl Terminal + ?Sized),
92    style: Style,
93    prompt: &str,
94    choices: &[Choice],
95    cursor: usize,
96    selected: &[usize],
97) -> AppResult<u16> {
98    terminal.write_line(&render::heading(style, prompt))?;
99    for row in render::frame_rows(style, choices, cursor, Some(selected), None) {
100        terminal.write_line(&row)?;
101    }
102    terminal.write_line(&render::key_hint(style, true))?;
103    terminal.flush()?;
104    Ok(u16::try_from(choices.len())
105        .unwrap_or(u16::MAX)
106        .saturating_add(2))
107}
108
109fn line_driven(
110    terminal: &mut (impl Terminal + ?Sized),
111    style: Style,
112    prompt: &str,
113    choices: &[Choice],
114    defaults: &[usize],
115) -> AppResult<Vec<ChoiceId>> {
116    terminal.write_line(&render::heading(style, prompt))?;
117    for row in render::numbered_rows(style, choices, true, None) {
118        terminal.write_line(&row)?;
119    }
120    loop {
121        write_answer(terminal, style, Some(&default_hint(defaults)))?;
122        let Some(line) = terminal.read_line()? else {
123            return Err(closed_input(prompt));
124        };
125        let answer = line.trim();
126        if answer.is_empty() {
127            return Ok(ids(choices, defaults));
128        }
129        if let Some(indices) = parse_indices(answer, choices.len()) {
130            return Ok(ids(choices, &indices));
131        }
132        notice(
133            terminal,
134            style,
135            &format!(
136                "enter comma-separated numbers between 1 and {}",
137                choices.len()
138            ),
139        )?;
140    }
141}
142
143fn default_hint(defaults: &[usize]) -> String {
144    if defaults.is_empty() {
145        return "[none]".to_string();
146    }
147    let list = defaults
148        .iter()
149        .map(|index| (index + 1).to_string())
150        .collect::<Vec<_>>()
151        .join(",");
152    format!("[{list}]")
153}
154
155fn parse_indices(input: &str, len: usize) -> Option<Vec<usize>> {
156    let mut indices = Vec::new();
157    for token in input.split(',') {
158        let index = parse_index(token.trim(), len)?;
159        if !indices.contains(&index) {
160            indices.push(index);
161        }
162    }
163    Some(indices)
164}