Skip to main content

cli_ui/prompt/
groupmultiselect.rs

1//! Grouped multi-select prompt — built on the [`Prompt`] trait.
2
3use crate::styles::{paint, ACCENT, DIM, OK, WHITE};
4
5use super::core::{Frame, Key, Prompt, RenderCtx, Step};
6use super::error::{PromptError, Result};
7use super::theme;
8
9#[derive(Clone, Copy)]
10enum Row {
11    Header { group_idx: usize },
12    Item { group_idx: usize, item_idx: usize },
13}
14
15#[derive(Default)]
16struct ItemData {
17    value: String,
18    hint: Option<String>,
19    checked: bool,
20}
21
22struct GroupData {
23    name: String,
24    items: Vec<ItemData>,
25}
26
27impl GroupData {
28    fn all_checked(&self) -> bool {
29        self.items.iter().all(|i| i.checked)
30    }
31    fn any_checked(&self) -> bool {
32        self.items.iter().any(|i| i.checked)
33    }
34    fn toggle_all(&mut self) {
35        let t = !self.all_checked();
36        for item in &mut self.items {
37            item.checked = t;
38        }
39    }
40}
41
42/// One checked item inside a [`GroupSelected`] result.
43#[derive(Debug, Clone)]
44pub struct GroupItem {
45    /// Name of the group the item belongs to.
46    pub group: String,
47    /// Machine value of the item (the argument to `.item(value)`).
48    pub value: String,
49    /// Zero-based position within its group.
50    pub index: usize,
51}
52
53/// Result of [`groupmultiselect`] — every item the user checked.
54#[derive(Debug, Clone, Default)]
55pub struct GroupSelected {
56    items: Vec<GroupItem>,
57}
58
59impl GroupSelected {
60    /// Iterate over every checked [`GroupItem`].
61    pub fn iter(&self) -> impl Iterator<Item = &GroupItem> {
62        self.items.iter()
63    }
64    /// `true` if the user submitted with nothing checked.
65    pub fn is_empty(&self) -> bool {
66        self.items.is_empty()
67    }
68    /// Number of checked items across all groups.
69    pub fn len(&self) -> usize {
70        self.items.len()
71    }
72    /// Machine values of every checked item.
73    pub fn values(&self) -> Vec<&str> {
74        self.items.iter().map(|i| i.value.as_str()).collect()
75    }
76    /// Machine values of checked items in a single group.
77    pub fn group_values(&self, group: &str) -> Vec<&str> {
78        self.items
79            .iter()
80            .filter(|i| i.group == group)
81            .map(|i| i.value.as_str())
82            .collect()
83    }
84}
85
86/// Builder returned by [`groupmultiselect()`].
87pub struct GroupMultiSelectPrompt {
88    label: String,
89    groups: Vec<GroupData>,
90    required: bool,
91    current_grp: Option<usize>,
92    rows: Vec<Row>,
93    cur: usize,
94}
95
96/// Multi-select prompt with grouped options. Each group can be toggled
97/// in bulk by hitting Space on the group header.
98///
99/// ```no_run
100/// use cli_ui::prompt::groupmultiselect;
101///
102/// let tools = groupmultiselect("Tools")
103///     .group("Lint")
104///         .item("clippy").checked()
105///     .group("Format")
106///         .item("rustfmt")
107///     .run()?;
108/// # Ok::<(), cli_ui::prompt::PromptError>(())
109/// ```
110pub fn groupmultiselect(label: impl Into<String>) -> GroupMultiSelectPrompt {
111    GroupMultiSelectPrompt {
112        label: label.into(),
113        groups: Vec::new(),
114        required: false,
115        current_grp: None,
116        rows: Vec::new(),
117        cur: 0,
118    }
119}
120
121impl GroupMultiSelectPrompt {
122    /// Start a new group. Subsequent [`item`](Self::item) calls add items
123    /// to this group.
124    pub fn group(mut self, name: impl Into<String>) -> Self {
125        self.groups.push(GroupData {
126            name: name.into(),
127            items: Vec::new(),
128        });
129        self.current_grp = Some(self.groups.len() - 1);
130        self
131    }
132    /// Append an item to the current group. Panics if [`group`](Self::group)
133    /// was never called.
134    pub fn item(mut self, value: impl Into<String>) -> Self {
135        let idx = self.current_grp.expect("call .group() before .item()");
136        self.groups[idx].items.push(ItemData {
137            value: value.into(),
138            hint: None,
139            checked: false,
140        });
141        self
142    }
143    /// Attach a hint to the most recently added item.
144    pub fn hint(mut self, text: impl Into<String>) -> Self {
145        if let Some(idx) = self.current_grp {
146            if let Some(last) = self.groups[idx].items.last_mut() {
147                last.hint = Some(text.into());
148            }
149        }
150        self
151    }
152    /// Mark the most recently added item as initially checked.
153    pub fn checked(mut self) -> Self {
154        if let Some(idx) = self.current_grp {
155            if let Some(last) = self.groups[idx].items.last_mut() {
156                last.checked = true;
157            }
158        }
159        self
160    }
161    /// When `true`, refuse to submit until at least one item is checked.
162    pub fn required(mut self, r: bool) -> Self {
163        self.required = r;
164        self
165    }
166
167    /// Run the prompt. Panics if any group has no items.
168    pub fn run(mut self) -> Result<GroupSelected> {
169        assert!(!self.groups.is_empty(), "groupmultiselect: no groups added");
170        assert!(
171            self.groups.iter().all(|g| !g.items.is_empty()),
172            "groupmultiselect: every group must have at least one item"
173        );
174        self.rows = build_rows(&self.groups);
175        super::core::run(self)
176    }
177
178    fn collect(&self) -> GroupSelected {
179        GroupSelected {
180            items: self
181                .groups
182                .iter()
183                .flat_map(|g| {
184                    g.items
185                        .iter()
186                        .enumerate()
187                        .filter(|(_, it)| it.checked)
188                        .map(|(i, it)| GroupItem {
189                            group: g.name.clone(),
190                            value: it.value.clone(),
191                            index: i,
192                        })
193                        .collect::<Vec<_>>()
194                })
195                .collect(),
196        }
197    }
198}
199
200impl Prompt for GroupMultiSelectPrompt {
201    type Output = GroupSelected;
202
203    fn handle(&mut self, key: Key) -> Step<GroupSelected> {
204        let last = self.rows.len() - 1;
205        match key {
206            Key::Up => {
207                if self.cur > 0 {
208                    self.cur -= 1;
209                }
210                Step::Continue
211            }
212            Key::Down => {
213                if self.cur < last {
214                    self.cur += 1;
215                }
216                Step::Continue
217            }
218            Key::Char(' ') => {
219                match self.rows[self.cur] {
220                    Row::Header { group_idx } => self.groups[group_idx].toggle_all(),
221                    Row::Item {
222                        group_idx,
223                        item_idx,
224                    } => {
225                        self.groups[group_idx].items[item_idx].checked ^= true;
226                    }
227                }
228                Step::Continue
229            }
230            Key::Enter => {
231                let result = self.collect();
232                if self.required && result.is_empty() {
233                    Step::Reject("Select at least one option".into())
234                } else {
235                    Step::Submit(result)
236                }
237            }
238            Key::Escape | Key::Interrupt => Step::Cancel,
239            _ => Step::Continue,
240        }
241    }
242
243    fn render(&self, _ctx: RenderCtx) -> Frame {
244        let mut f: Frame = Vec::new();
245        f.push(theme::label(&self.label));
246        f.push(theme::hint(""));
247        let mut row_i = 0usize;
248        for g in &self.groups {
249            let header_active = row_i == self.cur;
250            let gbullet = if g.all_checked() {
251                paint(OK, "◼")
252            } else if g.any_checked() {
253                paint(ACCENT, "◼")
254            } else {
255                paint(DIM, "◻")
256            };
257            if header_active {
258                f.push(format!(
259                    "{}  {} {} {}",
260                    paint(DIM, "│"),
261                    paint(ACCENT, "❯"),
262                    gbullet,
263                    paint(WHITE, &g.name)
264                ));
265            } else {
266                f.push(format!(
267                    "{}    {} {}",
268                    paint(DIM, "│"),
269                    gbullet,
270                    paint(DIM, &g.name)
271                ));
272            }
273            row_i += 1;
274            let count = g.items.len();
275            for ii in 0..count {
276                let it = &g.items[ii];
277                let is_last = ii == count - 1;
278                let is_cursor = row_i == self.cur;
279                let connector = if is_last {
280                    paint(DIM, "  └")
281                } else {
282                    paint(DIM, "  │")
283                };
284                let bullet = if it.checked {
285                    paint(OK, "◼")
286                } else {
287                    paint(DIM, "◻")
288                };
289                let hint_sfx = theme::hint_inline(it.hint.as_deref());
290                if is_cursor {
291                    f.push(format!(
292                        "{}  {} {} {} {}{}",
293                        paint(DIM, "│"),
294                        connector,
295                        paint(ACCENT, "❯"),
296                        bullet,
297                        paint(WHITE, &it.value),
298                        hint_sfx
299                    ));
300                } else {
301                    f.push(format!(
302                        "{}  {}   {} {}{}",
303                        paint(DIM, "│"),
304                        connector,
305                        bullet,
306                        paint(DIM, &it.value),
307                        paint(DIM, &hint_sfx)
308                    ));
309                }
310                row_i += 1;
311            }
312        }
313        f.push(theme::hint(
314            "Space to toggle · ↑/↓ to navigate · Enter to confirm",
315        ));
316        f.push(theme::frame_bot(None));
317        f
318    }
319
320    fn render_answered(&self, value: &GroupSelected) -> Frame {
321        let summary = if value.is_empty() {
322            "none".to_string()
323        } else {
324            value
325                .items
326                .iter()
327                .map(|i| i.value.as_str())
328                .collect::<Vec<_>>()
329                .join(", ")
330        };
331        theme::answered(&self.label, &summary)
332            .split("\r\n")
333            .map(String::from)
334            .collect()
335    }
336
337    fn run_fallback(self) -> Result<GroupSelected> {
338        use std::io::Write;
339        let mut out = std::io::stderr();
340        writeln!(out, "  {}", self.label).map_err(PromptError::Io)?;
341        let mut all: Vec<(String, String)> = Vec::new();
342        let mut n = 1usize;
343        for g in &self.groups {
344            writeln!(out, "  [ {} ]", g.name).map_err(PromptError::Io)?;
345            for it in &g.items {
346                match &it.hint {
347                    Some(h) => writeln!(out, "    {}. {} ({})", n, it.value, h),
348                    None => writeln!(out, "    {}. {}", n, it.value),
349                }
350                .map_err(PromptError::Io)?;
351                all.push((g.name.clone(), it.value.clone()));
352                n += 1;
353            }
354        }
355        write!(out, "  Enter numbers (space-separated): ").map_err(PromptError::Io)?;
356        out.flush().map_err(PromptError::Io)?;
357        let line = super::engine::fallback::read_line_raw()?;
358        let indices: Vec<usize> = line
359            .split_whitespace()
360            .filter_map(|t| t.parse::<usize>().ok())
361            .filter(|&i| i >= 1 && i < n)
362            .map(|i| i - 1)
363            .collect();
364        Ok(GroupSelected {
365            items: indices
366                .into_iter()
367                .map(|i| GroupItem {
368                    group: all[i].0.clone(),
369                    value: all[i].1.clone(),
370                    index: i,
371                })
372                .collect(),
373        })
374    }
375}
376
377fn build_rows(groups: &[GroupData]) -> Vec<Row> {
378    let mut rows = Vec::new();
379    for (gi, g) in groups.iter().enumerate() {
380        rows.push(Row::Header { group_idx: gi });
381        for ii in 0..g.items.len() {
382            rows.push(Row::Item {
383                group_idx: gi,
384                item_idx: ii,
385            });
386        }
387    }
388    rows
389}