rskit_cli/prompt/
render.rs1use crate::theme::{Glyphs, Palette};
10
11use super::choice::Choice;
12
13use std::collections::HashSet;
14
15#[derive(Debug, Clone, Copy)]
18pub struct Style {
19 palette: Palette,
20 glyphs: Glyphs,
21}
22
23impl Style {
24 #[must_use]
26 pub const fn new(palette: Palette, glyphs: Glyphs) -> Self {
27 Self { palette, glyphs }
28 }
29
30 #[must_use]
32 pub const fn palette(&self) -> Palette {
33 self.palette
34 }
35
36 #[must_use]
38 pub const fn glyphs(&self) -> Glyphs {
39 self.glyphs
40 }
41}
42
43#[must_use]
45pub fn heading(style: Style, prompt: &str) -> String {
46 style.palette().bold(prompt).into_owned()
47}
48
49#[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
63fn 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#[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#[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 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 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 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}