modcli/input/
builders.rs

1use crate::output::hook;
2use crate::output::themes::current_theme;
3use crossterm::style::{Color, Stylize};
4use crossterm::{
5    cursor,
6    event::{self, Event, KeyCode},
7    execute, terminal,
8};
9use std::collections::HashSet;
10use std::io::{stdin, stdout, Write};
11
12// Reduce type complexity for validator closures used by text/number inputs
13type TextValidator = dyn Fn(&str) -> Result<(), String> + Send + Sync;
14type NumberValidator = dyn Fn(f64) -> Result<(), String> + Send + Sync;
15
16pub struct TextInput<'a> {
17    label: &'a str,
18    default: Option<String>,
19    required: bool,
20    min_len: Option<usize>,
21    max_len: Option<usize>,
22    validator: Option<Box<TextValidator>>,
23    mask: Option<char>,
24}
25
26impl<'a> TextInput<'a> {
27    pub fn default(mut self, v: impl Into<String>) -> Self {
28        self.default = Some(v.into());
29        self
30    }
31    pub fn required(mut self) -> Self {
32        self.required = true;
33        self
34    }
35    pub fn min_len(mut self, n: usize) -> Self {
36        self.min_len = Some(n);
37        self
38    }
39    pub fn max_len(mut self, n: usize) -> Self {
40        self.max_len = Some(n);
41        self
42    }
43    pub fn validate<F>(mut self, f: F) -> Self
44    where
45        F: Fn(&str) -> Result<(), String> + Send + Sync + 'static,
46    {
47        self.validator = Some(Box::new(f));
48        self
49    }
50    pub fn mask(mut self, ch: char) -> Self {
51        self.mask = Some(ch);
52        self
53    }
54
55    pub fn get(self) -> Result<String, String> {
56        loop {
57            let hint = self
58                .default
59                .as_deref()
60                .map(|d| format!(" [{d}]"))
61                .unwrap_or_default();
62            print!("{}{} ", self.label, hint);
63            if let Err(e) = stdout().flush() {
64                hook::warn(&format!("flush failed: {e}"));
65            }
66
67            let input = if self.mask.is_some() {
68                // Basic masked input without backspace handling for now
69                let mut s = String::new();
70                if let Err(e) = stdin().read_line(&mut s) {
71                    hook::error(&format!("failed to read: {e}"));
72                    return Err("read error".into());
73                }
74                s
75            } else {
76                let mut s = String::new();
77                if let Err(e) = stdin().read_line(&mut s) {
78                    hook::error(&format!("failed to read: {e}"));
79                    return Err("read error".into());
80                }
81                s
82            };
83            let trimmed = input.trim();
84            let value = if trimmed.is_empty() {
85                self.default.as_deref().unwrap_or("")
86            } else {
87                trimmed
88            };
89
90            if self.required && value.is_empty() {
91                hook::warn("Value required");
92                continue;
93            }
94            if let Some(n) = self.min_len {
95                if value.chars().count() < n {
96                    hook::warn(&format!("Min length {n}"));
97                    continue;
98                }
99            }
100            if let Some(n) = self.max_len {
101                if value.chars().count() > n {
102                    hook::warn(&format!("Max length {n}"));
103                    continue;
104                }
105            }
106            if let Some(v) = &self.validator {
107                if let Err(msg) = v(value) {
108                    hook::warn(&msg);
109                    continue;
110                }
111            }
112
113            return Ok(value.to_string());
114        }
115    }
116}
117
118pub fn text(label: &str) -> TextInput<'_> {
119    TextInput {
120        label,
121        default: None,
122        required: false,
123        min_len: None,
124        max_len: None,
125        validator: None,
126        mask: None,
127    }
128}
129
130pub struct NumberInput<'a> {
131    label: &'a str,
132    default: Option<f64>,
133    min: Option<f64>,
134    max: Option<f64>,
135    step: f64,
136    validator: Option<Box<NumberValidator>>,
137}
138
139impl<'a> NumberInput<'a> {
140    pub fn default(mut self, v: f64) -> Self {
141        self.default = Some(v);
142        self
143    }
144    pub fn min(mut self, v: f64) -> Self {
145        self.min = Some(v);
146        self
147    }
148    pub fn max(mut self, v: f64) -> Self {
149        self.max = Some(v);
150        self
151    }
152    pub fn step(mut self, v: f64) -> Self {
153        self.step = v;
154        self
155    }
156    pub fn validate<F>(mut self, f: F) -> Self
157    where
158        F: Fn(f64) -> Result<(), String> + Send + Sync + 'static,
159    {
160        self.validator = Some(Box::new(f));
161        self
162    }
163
164    pub fn get(self) -> Result<f64, String> {
165        loop {
166            let hint = self.default.map(|d| format!(" [{d}]")).unwrap_or_default();
167            print!("{}{} ", self.label, hint);
168            if let Err(e) = stdout().flush() {
169                hook::warn(&format!("flush failed: {e}"));
170            }
171
172            let mut s = String::new();
173            if let Err(e) = stdin().read_line(&mut s) {
174                hook::error(&format!("failed to read: {e}"));
175                return Err("read error".into());
176            }
177            let trimmed = s.trim();
178            let value = if trimmed.is_empty() {
179                self.default.unwrap_or(0.0)
180            } else {
181                match trimmed.parse::<f64>() {
182                    Ok(v) => v,
183                    Err(_) => {
184                        hook::warn("Enter a valid number");
185                        continue;
186                    }
187                }
188            };
189
190            if let Some(min) = self.min {
191                if value < min {
192                    hook::warn(&format!("Min {min}"));
193                    continue;
194                }
195            }
196            if let Some(max) = self.max {
197                if value > max {
198                    hook::warn(&format!("Max {max}"));
199                    continue;
200                }
201            }
202            if let Some(v) = &self.validator {
203                if let Err(msg) = v(value) {
204                    hook::warn(&msg);
205                    continue;
206                }
207            }
208            return Ok(value);
209        }
210    }
211}
212
213pub fn number(label: &str) -> NumberInput<'_> {
214    NumberInput {
215        label,
216        default: None,
217        min: None,
218        max: None,
219        step: 1.0,
220        validator: None,
221    }
222}
223
224pub struct ConfirmInput<'a> {
225    label: &'a str,
226    default_yes: bool,
227}
228impl<'a> ConfirmInput<'a> {
229    pub fn default_yes(mut self) -> Self {
230        self.default_yes = true;
231        self
232    }
233    pub fn default_no(mut self) -> Self {
234        self.default_yes = false;
235        self
236    }
237    pub fn get(self) -> bool {
238        let hint = if self.default_yes { "[Y/n]" } else { "[y/N]" };
239        print!("{} {} ", self.label, hint);
240        if let Err(e) = stdout().flush() {
241            hook::warn(&format!("flush failed: {e}"));
242        }
243        let mut s = String::new();
244        if let Err(e) = stdin().read_line(&mut s) {
245            hook::warn(&format!("failed to read: {e}"));
246            return self.default_yes;
247        }
248        match s.trim().to_lowercase().as_str() {
249            "y" | "yes" => true,
250            "n" | "no" => false,
251            "" => self.default_yes,
252            _ => self.default_yes,
253        }
254    }
255}
256
257pub fn confirm(label: &str) -> ConfirmInput<'_> {
258    ConfirmInput {
259        label,
260        default_yes: true,
261    }
262}
263
264// --- Menus & Buttons (simple stdin-based) ---
265
266pub struct SelectInput<'a> {
267    label: &'a str,
268    items: Vec<String>,
269    initial: Option<usize>,
270}
271
272impl<'a> SelectInput<'a> {
273    pub fn initial(mut self, idx: usize) -> Self {
274        self.initial = Some(idx);
275        self
276    }
277    pub fn get(self) -> Result<usize, String> {
278        println!("{}", self.label);
279        for (i, it) in self.items.iter().enumerate() {
280            println!("  {}. {}", i + 1, it);
281        }
282        print!(
283            "Enter choice [1-{}]{}: ",
284            self.items.len(),
285            self.initial
286                .map(|i| format!(" (default {})", i + 1))
287                .unwrap_or_default()
288        );
289        if let Err(e) = stdout().flush() {
290            hook::warn(&format!("flush failed: {e}"));
291        }
292        let mut s = String::new();
293        if let Err(e) = stdin().read_line(&mut s) {
294            return Err(format!("read error: {e}"));
295        }
296        let trimmed = s.trim();
297        if trimmed.is_empty() {
298            if let Some(i) = self.initial {
299                return Ok(i);
300            }
301        }
302        match trimmed.parse::<usize>() {
303            Ok(n) if n >= 1 && n <= self.items.len() => Ok(n - 1),
304            _ => Err("invalid selection".into()),
305        }
306    }
307}
308
309pub fn select(label: &str, items: impl IntoIterator<Item = impl Into<String>>) -> SelectInput<'_> {
310    SelectInput {
311        label,
312        items: items.into_iter().map(Into::into).collect(),
313        initial: None,
314    }
315}
316
317pub struct MultiSelectInput<'a> {
318    label: &'a str,
319    items: Vec<String>,
320}
321
322impl<'a> MultiSelectInput<'a> {
323    pub fn get(self) -> Result<Vec<usize>, String> {
324        println!("{}", self.label);
325        for (i, it) in self.items.iter().enumerate() {
326            println!("  {}. {}", i + 1, it);
327        }
328        println!("Enter comma-separated indexes (e.g., 1,3,4) or empty for none:");
329        print!("> ");
330        if let Err(e) = stdout().flush() {
331            hook::warn(&format!("flush failed: {e}"));
332        }
333        let mut s = String::new();
334        if let Err(e) = stdin().read_line(&mut s) {
335            return Err(format!("read error: {e}"));
336        }
337        let trimmed = s.trim();
338        if trimmed.is_empty() {
339            return Ok(Vec::new());
340        }
341        let mut out = Vec::new();
342        for part in trimmed.split(',') {
343            let t = part.trim();
344            if let Ok(n) = t.parse::<usize>() {
345                if n >= 1 && n <= self.items.len() {
346                    out.push(n - 1);
347                }
348            }
349        }
350        Ok(out)
351    }
352}
353
354pub fn multi_select(
355    label: &str,
356    items: impl IntoIterator<Item = impl Into<String>>,
357) -> MultiSelectInput<'_> {
358    MultiSelectInput {
359        label,
360        items: items.into_iter().map(Into::into).collect(),
361    }
362}
363
364pub struct ButtonsInput<'a> {
365    buttons: Vec<(String, char)>,
366    default: Option<usize>,
367    label: &'a str,
368}
369
370impl<'a> ButtonsInput<'a> {
371    pub fn default(mut self, idx: usize) -> Self {
372        self.default = Some(idx);
373        self
374    }
375    pub fn get(self) -> usize {
376        println!("{}", self.label);
377        println!(
378            "{}",
379            self.buttons
380                .iter()
381                .map(|(t, k)| format!("[{k}] {t}"))
382                .collect::<Vec<_>>()
383                .join("  ")
384        );
385        let default_hint = self
386            .default
387            .and_then(|i| self.buttons.get(i).map(|(_, k)| format!(" (default {k})")))
388            .unwrap_or_default();
389        print!("Choose by hotkey{default_hint}: ");
390        if let Err(e) = stdout().flush() {
391            hook::warn(&format!("flush failed: {e}"));
392        }
393        let mut s = String::new();
394        if let Err(e) = stdin().read_line(&mut s) {
395            hook::warn(&format!("read error: {e}"));
396            return self.default.unwrap_or(0);
397        }
398        let ch = s.trim().chars().next();
399        if let Some(c) = ch {
400            if let Some((idx, _)) = self.buttons.iter().enumerate().find(|(_, (_, k))| *k == c) {
401                return idx;
402            }
403        }
404        self.default.unwrap_or(0)
405    }
406}
407
408pub fn buttons(
409    label: &str,
410    buttons: impl IntoIterator<Item = (impl Into<String>, char)>,
411) -> ButtonsInput<'_> {
412    ButtonsInput {
413        label,
414        buttons: buttons.into_iter().map(|(t, k)| (t.into(), k)).collect(),
415        default: None,
416    }
417}
418
419// --- Raw-mode interactive select ---
420
421/// Customizable key bindings for raw-mode inputs.
422#[derive(Clone)]
423pub struct KeyMap {
424    pub up: KeyCode,
425    pub down: KeyCode,
426    pub left: KeyCode,
427    pub right: KeyCode,
428    pub page_up: KeyCode,
429    pub page_down: KeyCode,
430    pub home: KeyCode,
431    pub end: KeyCode,
432    pub confirm: KeyCode,
433    pub cancel: KeyCode,
434    pub backspace: KeyCode,
435    pub toggle_char: char, // for multi-select
436}
437
438impl Default for KeyMap {
439    fn default() -> Self {
440        Self {
441            up: KeyCode::Up,
442            down: KeyCode::Down,
443            left: KeyCode::Left,
444            right: KeyCode::Right,
445            page_up: KeyCode::PageUp,
446            page_down: KeyCode::PageDown,
447            home: KeyCode::Home,
448            end: KeyCode::End,
449            confirm: KeyCode::Enter,
450            cancel: KeyCode::Esc,
451            backspace: KeyCode::Backspace,
452            toggle_char: ' ',
453        }
454    }
455}
456
457pub struct RawSelectInput<'a> {
458    label: &'a str,
459    items: Vec<String>,
460    initial: usize,
461    keymap: KeyMap,
462}
463
464impl<'a> RawSelectInput<'a> {
465    pub fn initial(mut self, idx: usize) -> Self {
466        self.initial = idx.min(self.items.len().saturating_sub(1));
467        self
468    }
469    pub fn keymap(mut self, km: KeyMap) -> Self {
470        self.keymap = km;
471        self
472    }
473    pub fn get(self) -> Option<usize> {
474        let mut stdout = stdout();
475        let mut selected = self.initial;
476        if terminal::enable_raw_mode().is_err() {
477            return None;
478        }
479        let _ = execute!(stdout, terminal::Clear(terminal::ClearType::All));
480        loop {
481            let _ = execute!(stdout, cursor::MoveTo(0, 0));
482            println!("{}\n", self.label);
483            for (i, it) in self.items.iter().enumerate() {
484                if i == selected {
485                    println!("  > {it}");
486                } else {
487                    println!("    {it}");
488                }
489            }
490            let _ = stdout.flush();
491            if let Ok(Event::Key(k)) = event::read() {
492                match k.code {
493                    c if c == self.keymap.up => {
494                        selected = selected.saturating_sub(1);
495                    }
496                    c if c == self.keymap.down => {
497                        if selected + 1 < self.items.len() {
498                            selected += 1;
499                        }
500                    }
501                    c if c == self.keymap.confirm => {
502                        let _ = terminal::disable_raw_mode();
503                        return Some(selected);
504                    }
505                    c if c == self.keymap.cancel => {
506                        let _ = terminal::disable_raw_mode();
507                        return None;
508                    }
509                    _ => {}
510                }
511            }
512        }
513    }
514}
515
516pub fn raw_select(
517    label: &str,
518    items: impl IntoIterator<Item = impl Into<String>>,
519) -> RawSelectInput<'_> {
520    RawSelectInput {
521        label,
522        items: items.into_iter().map(Into::into).collect(),
523        initial: 0,
524        keymap: KeyMap::default(),
525    }
526}
527
528pub struct RawMultiSelectInput<'a> {
529    label: &'a str,
530    items: Vec<String>,
531    initial: usize,
532    keymap: KeyMap,
533}
534
535impl<'a> RawMultiSelectInput<'a> {
536    pub fn initial(mut self, idx: usize) -> Self {
537        self.initial = idx.min(self.items.len().saturating_sub(1));
538        self
539    }
540    pub fn keymap(mut self, km: KeyMap) -> Self {
541        self.keymap = km;
542        self
543    }
544    pub fn get(self) -> Option<Vec<usize>> {
545        let mut stdout = stdout();
546        let mut cursor_idx = self.initial;
547        let mut picked: Vec<bool> = vec![false; self.items.len()];
548        if terminal::enable_raw_mode().is_err() {
549            return None;
550        }
551        let _ = execute!(stdout, terminal::Clear(terminal::ClearType::All));
552        loop {
553            let _ = execute!(stdout, cursor::MoveTo(0, 0));
554            println!(
555                "{}\n(space=toggle, enter=confirm, esc=cancel)\n",
556                self.label
557            );
558            for (i, it) in self.items.iter().enumerate() {
559                let mark = if picked[i] { "[x]" } else { "[ ]" };
560                if i == cursor_idx {
561                    println!("  > {mark} {it}");
562                } else {
563                    println!("    {mark} {it}");
564                }
565            }
566            let _ = stdout.flush();
567            if let Ok(Event::Key(k)) = event::read() {
568                match k.code {
569                    c if c == self.keymap.up => {
570                        cursor_idx = cursor_idx.saturating_sub(1);
571                    }
572                    c if c == self.keymap.down => {
573                        if cursor_idx + 1 < self.items.len() {
574                            cursor_idx += 1;
575                        }
576                    }
577                    KeyCode::Char(c) if c == self.keymap.toggle_char => {
578                        if let Some(p) = picked.get_mut(cursor_idx) {
579                            *p = !*p;
580                        }
581                    }
582                    c if c == self.keymap.confirm => {
583                        let _ = terminal::disable_raw_mode();
584                        let res: Vec<usize> = picked
585                            .iter()
586                            .enumerate()
587                            .filter_map(|(i, b)| if *b { Some(i) } else { None })
588                            .collect();
589                        return Some(res);
590                    }
591                    c if c == self.keymap.cancel => {
592                        let _ = terminal::disable_raw_mode();
593                        return None;
594                    }
595                    _ => {}
596                }
597            }
598        }
599    }
600}
601
602pub fn raw_multi_select(
603    label: &str,
604    items: impl IntoIterator<Item = impl Into<String>>,
605) -> RawMultiSelectInput<'_> {
606    RawMultiSelectInput {
607        label,
608        items: items.into_iter().map(Into::into).collect(),
609        initial: 0,
610        keymap: KeyMap::default(),
611    }
612}
613
614// --- Paged raw-mode builders ---
615
616pub struct RawPagedSelectInput<'a> {
617    label: &'a str,
618    items: Vec<String>,
619    cursor: usize,
620    page_size: usize,
621    keymap: KeyMap,
622}
623
624impl<'a> RawPagedSelectInput<'a> {
625    pub fn initial(mut self, idx: usize) -> Self {
626        self.cursor = idx.min(self.items.len().saturating_sub(1));
627        self
628    }
629    pub fn page_size(mut self, n: usize) -> Self {
630        self.page_size = n.max(1);
631        self
632    }
633    pub fn keymap(mut self, km: KeyMap) -> Self {
634        self.keymap = km;
635        self
636    }
637    pub fn get(mut self) -> Option<usize> {
638        if self.items.is_empty() {
639            return None;
640        }
641        let mut stdout = stdout();
642        if terminal::enable_raw_mode().is_err() {
643            return None;
644        }
645        let _ = execute!(stdout, terminal::Clear(terminal::ClearType::All));
646        let mut query = String::new();
647        loop {
648            // Filter
649            let ql = query.to_lowercase();
650            let filtered: Vec<usize> = self
651                .items
652                .iter()
653                .enumerate()
654                .filter(|(_, s)| s.to_lowercase().contains(&ql))
655                .map(|(i, _)| i)
656                .collect();
657            if filtered.is_empty() {
658                self.cursor = 0;
659            } else if self.cursor >= filtered.len() {
660                self.cursor = filtered.len() - 1;
661            }
662            let total_pages = filtered.len().div_ceil(self.page_size);
663            let page = if filtered.is_empty() {
664                0
665            } else {
666                self.cursor / self.page_size
667            };
668            let start = page * self.page_size;
669            let end = (start + self.page_size).min(filtered.len());
670
671            let _ = execute!(stdout, cursor::MoveTo(0, 0));
672            println!("{}\n", self.label);
673            println!("Search: {}\n", query.as_str().with(Color::DarkGrey));
674            println!(
675                "Page {}/{}\n",
676                if total_pages == 0 { 0 } else { page + 1 },
677                total_pages
678            );
679            let theme = current_theme();
680            let sel_bg = theme.get_log_color("menu_selected_bg");
681            let sel_fg = theme.get_log_color("menu_selected_fg");
682            let stripe_fg = theme.get_log_color("menu_stripe_fg");
683            for (row, fi) in (start..end).enumerate().map(|(row, i)| (row, filtered[i])) {
684                let label = &self.items[fi];
685                if (start + row) == self.cursor {
686                    let line = format!("  > {label}").with(sel_fg).on(sel_bg).bold();
687                    println!("{line}");
688                } else if row % 2 == 1 {
689                    println!("{}", format!("    {label}").with(stripe_fg));
690                } else {
691                    println!("    {label}");
692                }
693            }
694            println!(
695                "\nKeys: ↑/↓ PgUp/PgDn Home/End Enter Esc  (type to search, Backspace clears)"
696            );
697            let _ = stdout.flush();
698
699            if let Ok(Event::Key(k)) = event::read() {
700                match k.code {
701                    c if c == self.keymap.up => {
702                        self.cursor = self.cursor.saturating_sub(1);
703                    }
704                    c if c == self.keymap.down => {
705                        if !filtered.is_empty() && self.cursor + 1 < filtered.len() {
706                            self.cursor += 1;
707                        }
708                    }
709                    c if c == self.keymap.page_up => {
710                        if self.cursor >= self.page_size {
711                            self.cursor -= self.page_size;
712                        } else {
713                            self.cursor = 0;
714                        }
715                    }
716                    c if c == self.keymap.page_down => {
717                        if !filtered.is_empty() && self.cursor + self.page_size < filtered.len() {
718                            self.cursor += self.page_size;
719                        } else {
720                            self.cursor = filtered.len().saturating_sub(1);
721                        }
722                    }
723                    c if c == self.keymap.home => {
724                        self.cursor = 0;
725                    }
726                    c if c == self.keymap.end => {
727                        self.cursor = filtered.len().saturating_sub(1);
728                    }
729                    c if c == self.keymap.confirm => {
730                        let _ = terminal::disable_raw_mode();
731                        return filtered.get(self.cursor).copied();
732                    }
733                    c if c == self.keymap.cancel => {
734                        let _ = terminal::disable_raw_mode();
735                        return None;
736                    }
737                    c if c == self.keymap.backspace => {
738                        query.pop();
739                    }
740                    KeyCode::Char(c) => {
741                        query.push(c);
742                    }
743                    _ => {}
744                }
745            }
746        }
747    }
748}
749
750pub fn raw_select_paged(
751    label: &str,
752    items: impl IntoIterator<Item = impl Into<String>>,
753) -> RawPagedSelectInput<'_> {
754    RawPagedSelectInput {
755        label,
756        items: items.into_iter().map(Into::into).collect(),
757        cursor: 0,
758        page_size: 10,
759        keymap: KeyMap::default(),
760    }
761}
762
763pub struct RawPagedMultiSelectInput<'a> {
764    label: &'a str,
765    items: Vec<String>,
766    cursor: usize,
767    page_size: usize,
768    picked: Vec<bool>,
769    keymap: KeyMap,
770}
771
772impl<'a> RawPagedMultiSelectInput<'a> {
773    pub fn initial(mut self, idx: usize) -> Self {
774        self.cursor = idx.min(self.items.len().saturating_sub(1));
775        self
776    }
777    pub fn page_size(mut self, n: usize) -> Self {
778        self.page_size = n.max(1);
779        self
780    }
781    pub fn keymap(mut self, km: KeyMap) -> Self {
782        self.keymap = km;
783        self
784    }
785    pub fn get(mut self) -> Option<Vec<usize>> {
786        if self.items.is_empty() {
787            return Some(Vec::new());
788        }
789        let mut stdout = stdout();
790        if terminal::enable_raw_mode().is_err() {
791            return None;
792        }
793        let _ = execute!(stdout, terminal::Clear(terminal::ClearType::All));
794        let mut query = String::new();
795        loop {
796            // Filter
797            let ql = query.to_lowercase();
798            let filtered: Vec<usize> = self
799                .items
800                .iter()
801                .enumerate()
802                .filter(|(_, s)| s.to_lowercase().contains(&ql))
803                .map(|(i, _)| i)
804                .collect();
805            if filtered.is_empty() {
806                self.cursor = 0;
807            } else if self.cursor >= filtered.len() {
808                self.cursor = filtered.len() - 1;
809            }
810            let total_pages = filtered.len().div_ceil(self.page_size);
811            let page = if filtered.is_empty() {
812                0
813            } else {
814                self.cursor / self.page_size
815            };
816            let start = page * self.page_size;
817            let end = (start + self.page_size).min(filtered.len());
818
819            let _ = execute!(stdout, cursor::MoveTo(0, 0));
820            println!("{}\n(space=toggle)\n", self.label);
821            println!("Search: {}\n", query.as_str().with(Color::DarkGrey));
822            println!(
823                "Page {}/{}\n",
824                if total_pages == 0 { 0 } else { page + 1 },
825                total_pages
826            );
827            let theme = current_theme();
828            let sel_bg = theme.get_log_color("menu_selected_bg");
829            let sel_fg = theme.get_log_color("menu_selected_fg");
830            let stripe_fg = theme.get_log_color("menu_stripe_fg");
831            for (row, fi) in (start..end).enumerate().map(|(row, i)| (row, filtered[i])) {
832                let mark = if *self.picked.get(fi).unwrap_or(&false) {
833                    "[x]"
834                } else {
835                    "[ ]"
836                };
837                let label = &self.items[fi];
838                if (start + row) == self.cursor {
839                    let line = format!("  > {mark} {label}").with(sel_fg).on(sel_bg).bold();
840                    println!("{line}");
841                } else if row % 2 == 1 {
842                    println!("{}", format!("    {mark} {label}").with(stripe_fg));
843                } else {
844                    println!("    {mark} {label}");
845                }
846            }
847            println!("\nKeys: ↑/↓ PgUp/PgDn Home/End Space Enter Esc  (type to search, Backspace clears)");
848            let _ = stdout.flush();
849
850            if let Ok(Event::Key(k)) = event::read() {
851                match k.code {
852                    c if c == self.keymap.up => {
853                        self.cursor = self.cursor.saturating_sub(1);
854                    }
855                    c if c == self.keymap.down => {
856                        if !filtered.is_empty() && self.cursor + 1 < filtered.len() {
857                            self.cursor += 1;
858                        }
859                    }
860                    c if c == self.keymap.page_up => {
861                        if self.cursor >= self.page_size {
862                            self.cursor -= self.page_size;
863                        } else {
864                            self.cursor = 0;
865                        }
866                    }
867                    c if c == self.keymap.page_down => {
868                        if !filtered.is_empty() && self.cursor + self.page_size < filtered.len() {
869                            self.cursor += 1;
870                        } else {
871                            self.cursor = filtered.len().saturating_sub(1);
872                        }
873                    }
874                    c if c == self.keymap.home => {
875                        self.cursor = 0;
876                    }
877                    c if c == self.keymap.end => {
878                        self.cursor = filtered.len().saturating_sub(1);
879                    }
880                    KeyCode::Char(c1) if c1 == self.keymap.toggle_char => {
881                        if let Some(fi) = filtered.get(self.cursor) {
882                            if let Some(p) = self.picked.get_mut(*fi) {
883                                *p = !*p;
884                            }
885                        }
886                    }
887                    c if c == self.keymap.confirm => {
888                        let _ = terminal::disable_raw_mode();
889                        let res: Vec<usize> = self
890                            .picked
891                            .iter()
892                            .enumerate()
893                            .filter_map(|(i, b)| if *b { Some(i) } else { None })
894                            .collect();
895                        return Some(res);
896                    }
897                    c if c == self.keymap.cancel => {
898                        let _ = terminal::disable_raw_mode();
899                        return None;
900                    }
901                    c if c == self.keymap.backspace => {
902                        query.pop();
903                    }
904                    KeyCode::Char(c) => {
905                        query.push(c);
906                    }
907                    _ => {}
908                }
909            }
910        }
911    }
912}
913
914pub fn raw_multi_select_paged(
915    label: &str,
916    items: impl IntoIterator<Item = impl Into<String>>,
917) -> RawPagedMultiSelectInput<'_> {
918    let v: Vec<String> = items.into_iter().map(Into::into).collect();
919    let picked = vec![false; v.len()];
920    RawPagedMultiSelectInput {
921        label,
922        items: v,
923        cursor: 0,
924        page_size: 10,
925        picked,
926        keymap: KeyMap::default(),
927    }
928}
929
930// --- Form builder (sequential prompts using existing inputs) ---
931
932pub enum FormValue {
933    Text(String),
934    Number(f64),
935    Confirm(bool),
936}
937
938enum FormItem {
939    Text {
940        label: String,
941        cfg: Box<dyn Fn(TextInput) -> TextInput>,
942    },
943    Number {
944        label: String,
945        cfg: Box<dyn Fn(NumberInput) -> NumberInput>,
946    },
947    Confirm {
948        label: String,
949        default_yes: bool,
950    },
951}
952
953pub struct FormBuilder {
954    items: Vec<FormItem>,
955}
956
957impl FormBuilder {
958    pub fn new() -> Self {
959        Self { items: Vec::new() }
960    }
961
962    pub fn text(mut self, label: &str, cfg: impl Fn(TextInput) -> TextInput + 'static) -> Self {
963        self.items.push(FormItem::Text {
964            label: label.to_string(),
965            cfg: Box::new(cfg),
966        });
967        self
968    }
969
970    pub fn number(
971        mut self,
972        label: &str,
973        cfg: impl Fn(NumberInput) -> NumberInput + 'static,
974    ) -> Self {
975        self.items.push(FormItem::Number {
976            label: label.to_string(),
977            cfg: Box::new(cfg),
978        });
979        self
980    }
981
982    pub fn confirm(mut self, label: &str, default_yes: bool) -> Self {
983        self.items.push(FormItem::Confirm {
984            label: label.to_string(),
985            default_yes,
986        });
987        self
988    }
989
990    /// Run the form sequentially; returns the collected values paired with their labels.
991    pub fn run(self) -> Result<Vec<(String, FormValue)>, String> {
992        let mut out = Vec::with_capacity(self.items.len());
993        for item in self.items {
994            match item {
995                FormItem::Text { label, cfg } => {
996                    let v = cfg(text(&label)).get()?;
997                    out.push((label, FormValue::Text(v)));
998                }
999                FormItem::Number { label, cfg } => {
1000                    let v = cfg(number(&label)).get()?;
1001                    out.push((label, FormValue::Number(v)));
1002                }
1003                FormItem::Confirm { label, default_yes } => {
1004                    let v = if default_yes {
1005                        confirm(&label).default_yes().get()
1006                    } else {
1007                        confirm(&label).default_no().get()
1008                    };
1009                    out.push((label, FormValue::Confirm(v)));
1010                }
1011            }
1012        }
1013        Ok(out)
1014    }
1015}
1016
1017pub fn form() -> FormBuilder {
1018    FormBuilder::new()
1019}
1020
1021impl Default for FormBuilder {
1022    fn default() -> Self {
1023        Self::new()
1024    }
1025}
1026
1027// --- Raw-mode buttons row (left/right + hotkeys) ---
1028
1029pub struct RawButtonsInput<'a> {
1030    label: &'a str,
1031    buttons: Vec<(String, char)>,
1032    cursor: usize,
1033    disabled: HashSet<usize>,
1034    helps: Vec<Option<String>>, // one per button (optional)
1035    danger: HashSet<usize>,     // destructive actions
1036    confirm_on_danger: bool,
1037    keymap: KeyMap,
1038}
1039
1040impl<'a> RawButtonsInput<'a> {
1041    pub fn cursor(mut self, idx: usize) -> Self {
1042        self.cursor = idx.min(self.buttons.len().saturating_sub(1));
1043        self
1044    }
1045    pub fn disabled(mut self, idxs: &[usize]) -> Self {
1046        for &i in idxs {
1047            self.disabled.insert(i);
1048        }
1049        self
1050    }
1051    pub fn helps<I, S>(mut self, helps: I) -> Self
1052    where
1053        I: IntoIterator<Item = S>,
1054        S: Into<Option<String>>,
1055    {
1056        self.helps = helps.into_iter().map(|s| s.into()).collect();
1057        self
1058    }
1059    pub fn danger(mut self, idxs: &[usize]) -> Self {
1060        for &i in idxs {
1061            self.danger.insert(i);
1062        }
1063        self
1064    }
1065    pub fn confirm_on_danger(mut self, yes: bool) -> Self {
1066        self.confirm_on_danger = yes;
1067        self
1068    }
1069    pub fn keymap(mut self, km: KeyMap) -> Self {
1070        self.keymap = km;
1071        self
1072    }
1073    pub fn get(mut self) -> Option<usize> {
1074        if self.buttons.is_empty() {
1075            return None;
1076        }
1077        let mut stdout = stdout();
1078        if terminal::enable_raw_mode().is_err() {
1079            return None;
1080        }
1081        let _ = execute!(stdout, terminal::Clear(terminal::ClearType::All));
1082        loop {
1083            let theme = current_theme();
1084            let sel_bg = theme.get_log_color("menu_selected_bg");
1085            let sel_fg = theme.get_log_color("menu_selected_fg");
1086            let _ = execute!(stdout, cursor::MoveTo(0, 0));
1087            println!("{}\n", self.label);
1088            // Render buttons as a row
1089            let mut line = String::new();
1090            for (i, (title, key)) in self.buttons.iter().enumerate() {
1091                let token = format!("[{key}] {title}");
1092                let is_disabled = self.disabled.contains(&i);
1093                let is_danger = self.danger.contains(&i);
1094                let styled = if i == self.cursor {
1095                    // Selected state
1096                    if is_disabled {
1097                        token.as_str().with(Color::DarkGrey).on(sel_bg).to_string()
1098                    } else if is_danger {
1099                        token
1100                            .as_str()
1101                            .with(sel_fg)
1102                            .on(Color::Red)
1103                            .bold()
1104                            .to_string()
1105                    } else {
1106                        token.as_str().with(sel_fg).on(sel_bg).bold().to_string()
1107                    }
1108                } else if is_disabled {
1109                    token.as_str().with(Color::DarkGrey).to_string()
1110                } else if is_danger {
1111                    token.as_str().with(Color::Red).to_string()
1112                } else {
1113                    token.clone()
1114                };
1115                line.push_str(&format!(" {styled}"));
1116            }
1117            println!("{line}\n");
1118            // Tooltip/help under the row if provided
1119            if let Some(Some(help)) = self.helps.get(self.cursor) {
1120                println!("{help}\n");
1121            }
1122            println!(
1123                "Keys: Left/Right to move, Enter select, hotkeys {}, Esc cancel",
1124                self.buttons.iter().map(|(_, k)| k).collect::<String>()
1125            );
1126            let _ = stdout.flush();
1127
1128            if let Ok(Event::Key(k)) = event::read() {
1129                match k.code {
1130                    c if c == self.keymap.left => {
1131                        // Move left skipping disabled if possible
1132                        if self.cursor > 0 {
1133                            self.cursor -= 1;
1134                        }
1135                        while self.disabled.contains(&self.cursor) && self.cursor > 0 {
1136                            self.cursor -= 1;
1137                        }
1138                    }
1139                    c if c == self.keymap.right => {
1140                        if self.cursor + 1 < self.buttons.len() {
1141                            self.cursor += 1;
1142                        }
1143                        while self.cursor + 1 < self.buttons.len()
1144                            && self.disabled.contains(&self.cursor)
1145                        {
1146                            self.cursor += 1;
1147                        }
1148                    }
1149                    c if c == self.keymap.confirm => {
1150                        if self.disabled.contains(&self.cursor) {
1151                            continue;
1152                        }
1153                        // Danger confirmation if enabled
1154                        if self.confirm_on_danger && self.danger.contains(&self.cursor) {
1155                            let _ = terminal::disable_raw_mode();
1156                            println!("Confirm '{}'? [y/N] ", self.buttons[self.cursor].0);
1157                            use std::io::stdin;
1158                            let mut buf = String::new();
1159                            let _ = stdin().read_line(&mut buf);
1160                            if !matches!(buf.trim().to_lowercase().as_str(), "y" | "yes") {
1161                                let _ = terminal::enable_raw_mode();
1162                                continue;
1163                            }
1164                            return Some(self.cursor);
1165                        } else {
1166                            let _ = terminal::disable_raw_mode();
1167                            return Some(self.cursor);
1168                        }
1169                    }
1170                    c if c == self.keymap.cancel => {
1171                        let _ = terminal::disable_raw_mode();
1172                        return None;
1173                    }
1174                    KeyCode::Char(c) => {
1175                        if let Some((idx, _)) = self
1176                            .buttons
1177                            .iter()
1178                            .enumerate()
1179                            .find(|(i, (_, k))| *k == c && !self.disabled.contains(i))
1180                        {
1181                            if self.confirm_on_danger && self.danger.contains(&idx) {
1182                                let _ = terminal::disable_raw_mode();
1183                                println!("Confirm '{}'? [y/N] ", self.buttons[idx].0);
1184                                use std::io::stdin;
1185                                let mut buf = String::new();
1186                                let _ = stdin().read_line(&mut buf);
1187                                if !matches!(buf.trim().to_lowercase().as_str(), "y" | "yes") {
1188                                    let _ = terminal::enable_raw_mode();
1189                                    continue;
1190                                }
1191                                return Some(idx);
1192                            }
1193                            let _ = terminal::disable_raw_mode();
1194                            return Some(idx);
1195                        }
1196                    }
1197                    _ => {}
1198                }
1199            }
1200        }
1201    }
1202}
1203
1204pub fn raw_buttons(
1205    label: &str,
1206    buttons: impl IntoIterator<Item = (impl Into<String>, char)>,
1207) -> RawButtonsInput<'_> {
1208    RawButtonsInput {
1209        label,
1210        buttons: buttons.into_iter().map(|(t, k)| (t.into(), k)).collect(),
1211        cursor: 0,
1212        disabled: HashSet::new(),
1213        helps: Vec::new(),
1214        danger: HashSet::new(),
1215        confirm_on_danger: true,
1216        keymap: KeyMap::default(),
1217    }
1218}