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