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