Skip to main content

rskit_cli/prompt/
render.rs

1//! Shared choice/frame rendering for both line-driven and key-driven prompts.
2//!
3//! One place builds the styled strings a prompt shows, so the numbered list a
4//! [`LineTerminal`](super::terminal::LineTerminal) prints and the live frame a
5//! `RichTerminal` redraws stay visually
6//! consistent. Rendering only *builds* strings — writing and cursor movement are
7//! the terminal's job — so these helpers are pure and unit-testable.
8
9use crate::theme::{Glyphs, Palette};
10
11use super::choice::Choice;
12
13use std::collections::HashSet;
14
15/// Styling context shared by every rendered frame: color [`Palette`] and the
16/// resolved [`Glyphs`] set.
17#[derive(Debug, Clone, Copy)]
18pub struct Style {
19    palette: Palette,
20    glyphs: Glyphs,
21}
22
23impl Style {
24    /// Bundle a palette and glyph set into a rendering style.
25    #[must_use]
26    pub const fn new(palette: Palette, glyphs: Glyphs) -> Self {
27        Self { palette, glyphs }
28    }
29
30    /// The color palette.
31    #[must_use]
32    pub const fn palette(&self) -> Palette {
33        self.palette
34    }
35
36    /// The glyph set.
37    #[must_use]
38    pub const fn glyphs(&self) -> Glyphs {
39        self.glyphs
40    }
41}
42
43/// The bold heading line shown above a prompt's choices or input.
44#[must_use]
45pub fn heading(style: Style, prompt: &str) -> String {
46    style.palette().bold(prompt).into_owned()
47}
48
49/// The trailing, dimmed key-hint line for a key-driven frame.
50#[must_use]
51pub fn key_hint(style: Style, multi: bool) -> String {
52    let glyphs = style.glyphs();
53    let sep = glyphs.bullet();
54    let nav = format!("{}/{} move", glyphs.arrow_up(), glyphs.arrow_down());
55    let body = if multi {
56        format!("{nav} {sep} space toggle {sep} enter confirm {sep} esc cancel")
57    } else {
58        format!("{nav} {sep} enter select {sep} esc cancel")
59    };
60    format!("  {}", style.palette().dim(&body))
61}
62
63/// A single choice's label plus its annotation and recommended marker.
64fn decorate(style: Style, choice: &Choice, is_default: bool, multi: bool) -> String {
65    let mut line = choice.label().to_string();
66    if let Some(annotation) = choice.annotation() {
67        line.push(' ');
68        line.push_str(&style.palette().dim(annotation));
69    }
70    if choice.is_recommended() {
71        line.push(' ');
72        line.push_str(&style.palette().info("(recommended)"));
73    } else if !multi && is_default {
74        line.push(' ');
75        line.push_str(&style.palette().info("(default)"));
76    }
77    line
78}
79
80/// Build the static, numbered choice list a line-driven terminal prints once.
81///
82/// `default` highlights the single-select fallback; it is ignored when `multi`.
83#[must_use]
84pub fn numbered_rows(
85    style: Style,
86    choices: &[Choice],
87    multi: bool,
88    default: Option<usize>,
89) -> Vec<String> {
90    choices
91        .iter()
92        .enumerate()
93        .map(|(index, choice)| {
94            let body = decorate(style, choice, default == Some(index), multi);
95            format!("  {}) {body}", index + 1)
96        })
97        .collect()
98}
99
100/// Build the live frame a key-driven terminal redraws as focus and selection
101/// change.
102///
103/// `cursor` is the focused row. For single-select pass `selected` as `None` and
104/// each row shows a radio (`Glyphs::radio_on`/`radio_off`); for multi-select pass
105/// the chosen indices and each row shows a checkbox (`[x]`/`[ ]`). The focused row
106/// is marked with the pointer glyph and rendered bold.
107#[must_use]
108pub fn frame_rows(
109    style: Style,
110    choices: &[Choice],
111    cursor: usize,
112    selected: Option<&[usize]>,
113    default: Option<usize>,
114) -> Vec<String> {
115    let multi = selected.is_some();
116    let glyphs = style.glyphs();
117    // Precompute selected membership once so redraws stay O(n) rather than
118    // O(n^2) over the choice list (key-driven prompts redraw on every keypress).
119    let chosen: HashSet<usize> = selected
120        .map(|s| s.iter().copied().collect())
121        .unwrap_or_default();
122    choices
123        .iter()
124        .enumerate()
125        .map(|(index, choice)| {
126            let focused = index == cursor;
127            let pointer = if focused { glyphs.pointer() } else { " " };
128            let marker = match selected {
129                Some(_) if chosen.contains(&index) => "[x]".to_string(),
130                Some(_) => "[ ]".to_string(),
131                None if focused => glyphs.radio_on().to_string(),
132                None => glyphs.radio_off().to_string(),
133            };
134            let body = decorate(style, choice, default == Some(index), multi);
135            let label = if focused {
136                style.palette().bold(&body).into_owned()
137            } else {
138                body
139            };
140            format!("{pointer} {marker} {label}")
141        })
142        .collect()
143}
144
145#[cfg(test)]
146mod tests {
147    use super::{Style, frame_rows, key_hint, numbered_rows};
148    use crate::prompt::choice::Choice;
149    use crate::theme::{Glyphs, Palette};
150
151    fn style() -> Style {
152        Style::new(Palette::new(false), Glyphs::new(true))
153    }
154
155    fn choices() -> Vec<Choice> {
156        vec![
157            Choice::new("a", "Alpha"),
158            Choice::new("b", "Beta").recommended(),
159        ]
160    }
161
162    #[test]
163    fn numbered_rows_are_one_indexed() {
164        let rows = numbered_rows(style(), &choices(), false, Some(1));
165        assert!(rows[0].starts_with("  1) Alpha"));
166        assert!(rows[1].contains("(recommended)"));
167    }
168
169    #[test]
170    fn numbered_rows_mark_a_non_recommended_default() {
171        // A single-select fallback default that is not the recommended choice is
172        // annotated `(default)` rather than `(recommended)`.
173        let plain = vec![Choice::new("a", "Alpha"), Choice::new("b", "Beta")];
174        let rows = numbered_rows(style(), &plain, false, Some(0));
175        assert!(rows[0].contains("(default)"));
176        assert!(!rows[1].contains("(default)"));
177    }
178
179    #[test]
180    fn frame_rows_show_radio_for_single_select() {
181        let rows = frame_rows(style(), &choices(), 0, None, None);
182        assert!(rows[0].starts_with("❯ ◉ "));
183        assert!(rows[1].starts_with("  ○ "));
184    }
185
186    #[test]
187    fn frame_rows_show_checkbox_for_multi_select() {
188        let rows = frame_rows(style(), &choices(), 1, Some(&[1]), None);
189        assert!(rows[0].contains("[ ] "));
190        assert!(rows[1].starts_with("❯ [x] "));
191    }
192
193    #[test]
194    fn ascii_fallback_frames_are_byte_clean() {
195        // The ASCII glyph set must keep the rich frame and key hint byte-clean,
196        // so a legacy/mis-encoded terminal never renders replacement characters.
197        let style = Style::new(Palette::new(false), Glyphs::new(false));
198        for row in frame_rows(style, &choices(), 0, None, None) {
199            assert!(row.is_ascii(), "radio row must be ASCII: {row:?}");
200        }
201        for row in frame_rows(style, &choices(), 0, Some(&[0]), None) {
202            assert!(row.is_ascii(), "checkbox row must be ASCII: {row:?}");
203        }
204        assert!(key_hint(style, true).is_ascii());
205        assert!(key_hint(style, false).is_ascii());
206    }
207}