1use 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#[derive(Debug, Clone)]
44pub struct GroupItem {
45 pub group: String,
47 pub value: String,
49 pub index: usize,
51}
52
53#[derive(Debug, Clone, Default)]
55pub struct GroupSelected {
56 items: Vec<GroupItem>,
57}
58
59impl GroupSelected {
60 pub fn iter(&self) -> impl Iterator<Item = &GroupItem> {
62 self.items.iter()
63 }
64 pub fn is_empty(&self) -> bool {
66 self.items.is_empty()
67 }
68 pub fn len(&self) -> usize {
70 self.items.len()
71 }
72 pub fn values(&self) -> Vec<&str> {
74 self.items.iter().map(|i| i.value.as_str()).collect()
75 }
76 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
86pub 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
96pub 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 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 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 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 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 pub fn required(mut self, r: bool) -> Self {
163 self.required = r;
164 self
165 }
166
167 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}