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)]
17pub struct Style {
18 palette: Palette,
19 glyphs: Glyphs,
20}
21
22impl Style {
23 #[must_use]
25 pub const fn new(palette: Palette, glyphs: Glyphs) -> Self {
26 Self { palette, glyphs }
27 }
28
29 #[must_use]
31 pub const fn palette(&self) -> Palette {
32 self.palette
33 }
34
35 #[must_use]
37 pub const fn glyphs(&self) -> Glyphs {
38 self.glyphs
39 }
40}
41
42#[must_use]
44pub fn heading(style: Style, prompt: &str) -> String {
45 style.palette().bold(prompt).into_owned()
46}
47
48#[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
62fn 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#[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#[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 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 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 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}