Skip to main content

imp_tui/views/
ask_bar.rs

1use crate::theme::Theme;
2use crate::views::editor::{
3    clamp_cursor_to_boundary, cursor_visual_position_for_text, wrapped_lines_for_width,
4};
5use ratatui::buffer::Buffer;
6use ratatui::layout::Rect;
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Widget};
10
11/// Selection state for a single option in the ask overlay.
12#[derive(Debug, Clone)]
13pub struct AskOption {
14    pub label: String,
15    pub description: Option<String>,
16    pub checked: bool, // only used in multi-select mode
17}
18
19/// The mode of the ask overlay.
20#[derive(Debug, Clone, PartialEq)]
21pub enum AskMode {
22    SingleSelect,
23    MultiSelect,
24    FreeText,
25}
26
27/// State for the ask overlay bar.
28#[derive(Debug, Clone)]
29pub struct AskState {
30    pub question: String,
31    pub context: String,
32    pub options: Vec<AskOption>,
33    pub mode: AskMode,
34    pub cursor: usize,       // highlighted option index
35    pub input: String,       // user's typed text
36    pub input_cursor: usize, // cursor position in input
37    pub input_active: bool,  // true when user is typing (options dimmed)
38    pub placeholder: String,
39    pub editor_cursor: usize,
40}
41
42impl AskState {
43    fn normalized_option_cursor(&self) -> usize {
44        if self.options.is_empty() {
45            0
46        } else {
47            self.cursor.min(self.options.len() - 1)
48        }
49    }
50
51    fn normalize_option_cursor(&mut self) {
52        self.cursor = self.normalized_option_cursor();
53    }
54
55    fn normalized_input_cursor(&self) -> usize {
56        clamp_cursor_to_boundary(&self.input, self.input_cursor)
57    }
58
59    pub fn new(question: String, context: String, options: Vec<AskOption>, multi: bool) -> Self {
60        Self::with_placeholder(question, context, options, multi, String::new())
61    }
62
63    pub fn with_placeholder(
64        question: String,
65        context: String,
66        options: Vec<AskOption>,
67        multi: bool,
68        placeholder: String,
69    ) -> Self {
70        let input_active = options.is_empty();
71        let mode = if options.is_empty() {
72            AskMode::FreeText
73        } else if multi {
74            AskMode::MultiSelect
75        } else {
76            AskMode::SingleSelect
77        };
78        Self {
79            question,
80            context,
81            options,
82            mode,
83            cursor: 0,
84            input: String::new(),
85            input_cursor: 0,
86            input_active,
87            placeholder,
88            editor_cursor: 0,
89        }
90    }
91
92    pub fn sync_from_editor(&mut self, text: &str, cursor: usize) {
93        self.input = text.to_string();
94        self.input_cursor = clamp_cursor_to_boundary(&self.input, cursor);
95        self.editor_cursor = self.input_cursor;
96        self.normalize_option_cursor();
97        self.input_active = !self.input.is_empty() || self.options.is_empty();
98    }
99
100    pub fn height(&self, width: u16) -> u16 {
101        let w = width.max(1);
102        let mut h: u16 = wrapped_lines_for_width(&self.question, w).len() as u16;
103        if !self.context.is_empty() {
104            h += wrapped_lines_for_width(&self.context, w).len() as u16;
105        }
106        if !self.options.is_empty() {
107            h += self.options.len() as u16; // one per option
108            h += 1; // blank line between options and input
109        }
110        // Input line(s) — account for "❯ " prefix
111        let input_w = w.saturating_sub(2).max(1);
112        h += wrapped_lines_for_width(&self.input, input_w).len() as u16;
113        h += 1; // hint line
114        h
115    }
116
117    /// Height needed to render this prompt, including its border.
118    pub fn prompt_height(&self, width: u16) -> u16 {
119        self.height(width).saturating_add(2)
120    }
121
122    /// Cursor position inside the ask prompt area.
123    pub fn cursor_screen_position(&self, area: Rect) -> (u16, u16) {
124        if area.width == 0 || area.height == 0 {
125            return (area.x, area.y);
126        }
127
128        let inner_x = area.x.saturating_add(1);
129        let inner_y = area.y.saturating_add(1);
130        let inner_width = area.width.saturating_sub(2).max(1);
131
132        let mut input_row = inner_y
133            .saturating_add(wrapped_lines_for_width(&self.question, inner_width).len() as u16);
134        if !self.context.is_empty() {
135            input_row = input_row
136                .saturating_add(wrapped_lines_for_width(&self.context, inner_width).len() as u16);
137        }
138        if !self.options.is_empty() {
139            input_row = input_row
140                .saturating_add(self.options.len() as u16)
141                .saturating_add(1);
142        }
143
144        let (visual_row, visual_col) = cursor_visual_position_for_text(
145            &self.input,
146            self.normalized_input_cursor(),
147            inner_width.saturating_sub(2),
148        );
149
150        let max_x = area.x.saturating_add(area.width.saturating_sub(2));
151        let max_y = area.y.saturating_add(area.height.saturating_sub(2));
152        (
153            (inner_x + 2 + visual_col as u16).min(max_x),
154            (input_row + visual_row as u16).min(max_y),
155        )
156    }
157
158    /// Move cursor up.
159    pub fn cursor_up(&mut self) {
160        if !self.options.is_empty() {
161            self.input_active = false;
162            if self.cursor > 0 {
163                self.cursor -= 1;
164            } else {
165                self.cursor = self.options.len() - 1;
166            }
167        }
168    }
169
170    /// Move cursor down.
171    pub fn cursor_down(&mut self) {
172        if !self.options.is_empty() {
173            self.input_active = false;
174            if self.cursor < self.options.len() - 1 {
175                self.cursor += 1;
176            } else {
177                self.cursor = 0;
178            }
179        }
180    }
181
182    /// Toggle checkbox in multi-select mode.
183    pub fn toggle_current(&mut self) {
184        if self.mode == AskMode::MultiSelect && !self.input_active {
185            self.normalize_option_cursor();
186            if let Some(opt) = self.options.get_mut(self.cursor) {
187                opt.checked = !opt.checked;
188            }
189        }
190    }
191
192    /// Tab: copy highlighted option text into the input editor.
193    pub fn tab_to_edit(&mut self) {
194        if !self.options.is_empty() && !self.input_active {
195            self.normalize_option_cursor();
196            if let Some(option) = self.options.get(self.cursor) {
197                self.input = option.label.clone();
198                self.input_cursor = self.input.len();
199                self.editor_cursor = self.input_cursor;
200                self.input_active = true;
201            }
202        }
203    }
204
205    /// Toggle or quick-select by number (1-9).
206    pub fn quick_select(&mut self, n: usize) -> bool {
207        if n == 0 || n > self.options.len() || self.input_active {
208            return false;
209        }
210
211        self.cursor = n - 1;
212        if self.mode == AskMode::MultiSelect {
213            self.toggle_current();
214            false
215        } else {
216            true
217        }
218    }
219
220    /// Insert a character into the input.
221    pub fn type_char(&mut self, ch: char) {
222        self.input_active = true;
223        let cursor = self.normalized_input_cursor();
224        self.input.insert(cursor, ch);
225        self.input_cursor = cursor + ch.len_utf8();
226        self.editor_cursor = self.input_cursor;
227    }
228
229    /// Backspace in the input.
230    pub fn backspace(&mut self) {
231        self.input_cursor = self.normalized_input_cursor();
232        self.editor_cursor = self.input_cursor;
233        if self.input_cursor > 0 && !self.input.is_empty() {
234            let prev = self.input[..self.input_cursor]
235                .char_indices()
236                .next_back()
237                .map(|(i, _)| i)
238                .unwrap_or(0);
239            self.input.drain(prev..self.input_cursor);
240            self.input_cursor = prev;
241            self.editor_cursor = prev;
242        }
243        // If input is now empty and we have options, go back to option mode
244        if self.input.is_empty() && !self.options.is_empty() {
245            self.input_active = false;
246        }
247    }
248
249    /// Get the final answer when Enter is pressed.
250    pub fn confirm(&self) -> AskResult {
251        if self.input_active && !self.input.is_empty() {
252            // User typed custom text
253            return AskResult::Text(self.input.clone());
254        }
255
256        match self.mode {
257            AskMode::FreeText => AskResult::Text(self.input.clone()),
258            AskMode::SingleSelect => {
259                if self.options.is_empty() {
260                    AskResult::Text(self.input.clone())
261                } else {
262                    AskResult::Selected(vec![self.normalized_option_cursor()])
263                }
264            }
265            AskMode::MultiSelect => {
266                let selected: Vec<usize> = self
267                    .options
268                    .iter()
269                    .enumerate()
270                    .filter(|(_, o)| o.checked)
271                    .map(|(i, _)| i)
272                    .collect();
273                if selected.is_empty() {
274                    // If nothing checked, use the highlighted one
275                    AskResult::Selected(vec![self.normalized_option_cursor()])
276                } else {
277                    AskResult::Selected(selected)
278                }
279            }
280        }
281    }
282}
283
284/// Result of the ask interaction.
285#[derive(Debug)]
286pub enum AskResult {
287    Selected(Vec<usize>),
288    Text(String),
289}
290
291/// Widget that renders the ask overlay bar.
292pub struct AskBar<'a> {
293    state: &'a AskState,
294    theme: &'a Theme,
295}
296
297impl<'a> AskBar<'a> {
298    pub fn new(state: &'a AskState, theme: &'a Theme) -> Self {
299        Self { state, theme }
300    }
301}
302
303impl Widget for AskBar<'_> {
304    fn render(self, area: Rect, buf: &mut Buffer) {
305        if area.height < 3 || area.width < 4 {
306            return;
307        }
308
309        let s = self.state;
310        let theme = self.theme;
311        let dim = theme.muted_style();
312        let highlight = theme.accent_style().add_modifier(Modifier::BOLD);
313        let normal = theme.style();
314        let question_style = theme.header_style().add_modifier(Modifier::BOLD);
315
316        let block = Block::default()
317            .title(" ask ")
318            .borders(Borders::ALL)
319            .border_style(Style::default().fg(theme.accent));
320        let inner = block.inner(area);
321        block.render(area, buf);
322
323        if inner.height == 0 || inner.width == 0 {
324            return;
325        }
326
327        let mut y = inner.y;
328        let w = inner.width as usize;
329
330        // Question (word-wrapped)
331        let question_wrapped = wrapped_lines_for_width(&s.question, inner.width);
332        for ql in &question_wrapped {
333            if y >= inner.y + inner.height {
334                return;
335            }
336            buf.set_line(
337                inner.x,
338                y,
339                &Line::from(Span::styled(ql.clone(), question_style)),
340                inner.width,
341            );
342            y += 1;
343        }
344
345        // Context (if any, word-wrapped)
346        if !s.context.is_empty() {
347            let context_wrapped = wrapped_lines_for_width(&s.context, inner.width);
348            for cl in &context_wrapped {
349                if y >= inner.y + inner.height {
350                    return;
351                }
352                buf.set_line(
353                    inner.x,
354                    y,
355                    &Line::from(Span::styled(cl.clone(), dim)),
356                    inner.width,
357                );
358                y += 1;
359            }
360        }
361
362        // Options
363        if !s.options.is_empty() {
364            for (i, opt) in s.options.iter().enumerate() {
365                if y >= inner.y + inner.height {
366                    break;
367                }
368
369                let is_highlighted = i == s.cursor && !s.input_active;
370
371                let prefix = match s.mode {
372                    AskMode::MultiSelect => {
373                        if opt.checked {
374                            "[x] "
375                        } else {
376                            "[ ] "
377                        }
378                    }
379                    AskMode::SingleSelect => {
380                        if is_highlighted {
381                            " ❯ "
382                        } else {
383                            "   "
384                        }
385                    }
386                    AskMode::FreeText => "",
387                };
388
389                let num = format!("[{}] ", i + 1);
390                let label = &opt.label;
391                let desc = opt.description.as_deref().unwrap_or("");
392
393                let style = if s.input_active {
394                    dim // dim all options when user is typing
395                } else if is_highlighted {
396                    highlight
397                } else {
398                    normal
399                };
400
401                let mut spans = vec![
402                    Span::styled(prefix, style),
403                    Span::styled(label.to_string(), style),
404                ];
405                if !desc.is_empty() {
406                    spans.push(Span::styled(format!(" — {desc}"), dim));
407                }
408                // Right-align the number hint
409                let content_len: usize = spans.iter().map(|s| s.content.len()).sum();
410                let num_hint_style = if s.input_active {
411                    dim
412                } else {
413                    theme.muted_style()
414                };
415                if content_len + num.len() + 1 < w {
416                    let padding = w - content_len - num.len();
417                    spans.push(Span::raw(" ".repeat(padding)));
418                    spans.push(Span::styled(num, num_hint_style));
419                }
420
421                buf.set_line(inner.x, y, &Line::from(spans), inner.width);
422                y += 1;
423            }
424
425            // Blank line before input
426            y += 1;
427        }
428
429        // Input line
430        if y < inner.y + inner.height {
431            let cursor_char = if s.input_active { "│" } else { " " };
432            let available_width = inner.width.saturating_sub(2);
433            let mut rendered_any = false;
434
435            if s.input.is_empty() {
436                let placeholder = if !s.placeholder.is_empty() {
437                    s.placeholder.clone()
438                } else {
439                    "type to answer freely…".to_string()
440                };
441                let line = Line::from(vec![
442                    Span::styled("❯ ", Style::default().fg(theme.accent)),
443                    Span::styled(placeholder, dim),
444                    Span::styled(cursor_char, Style::default().fg(theme.accent)),
445                ]);
446                buf.set_line(inner.x, y, &line, inner.width);
447                y += 1;
448                rendered_any = true;
449            } else {
450                let lines = wrapped_lines_for_width(&s.input, available_width);
451                let (visual_row, visual_col) =
452                    cursor_visual_position_for_text(&s.input, s.editor_cursor, available_width);
453                for (idx, input_line) in lines.iter().enumerate() {
454                    if y >= inner.y + inner.height {
455                        break;
456                    }
457                    let is_cursor_row = idx == visual_row;
458                    let mut line_text = input_line.clone();
459                    if is_cursor_row {
460                        let insert_at = visual_col.min(line_text.chars().count());
461                        let byte_idx = char_to_byte_idx(&line_text, insert_at);
462                        line_text.insert_str(byte_idx, cursor_char);
463                    }
464                    let prefix = if idx == 0 { "❯ " } else { "  " };
465                    let line = Line::from(vec![
466                        Span::styled(prefix, Style::default().fg(theme.accent)),
467                        Span::styled(line_text, normal),
468                    ]);
469                    buf.set_line(inner.x, y, &line, inner.width);
470                    y += 1;
471                    rendered_any = true;
472                }
473            }
474
475            if !rendered_any {
476                let line = Line::from(vec![
477                    Span::styled("❯ ", Style::default().fg(theme.accent)),
478                    Span::styled(cursor_char, Style::default().fg(theme.accent)),
479                ]);
480                buf.set_line(inner.x, y, &line, inner.width);
481                y += 1;
482            }
483        }
484
485        // Hint line
486        if y < inner.y + inner.height {
487            let hints = match s.mode {
488                AskMode::FreeText => "Enter: send  Esc: skip",
489                AskMode::SingleSelect => "↑↓: navigate  Tab: edit  Enter: pick  Esc: skip",
490                AskMode::MultiSelect => {
491                    "↑↓: navigate  Space: toggle  Tab: edit  Enter: confirm  Esc: skip"
492                }
493            };
494            buf.set_line(
495                inner.x,
496                y,
497                &Line::from(Span::styled(hints, dim)),
498                inner.width,
499            );
500        }
501    }
502}
503
504fn char_to_byte_idx(s: &str, char_idx: usize) -> usize {
505    s.char_indices()
506        .nth(char_idx)
507        .map(|(idx, _)| idx)
508        .unwrap_or(s.len())
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use ratatui::layout::Rect;
515
516    #[test]
517    fn single_select_confirm() {
518        let opts = vec![
519            AskOption {
520                label: "React".into(),
521                description: None,
522                checked: false,
523            },
524            AskOption {
525                label: "Svelte".into(),
526                description: None,
527                checked: false,
528            },
529        ];
530        let mut state = AskState::new("Pick one".into(), String::new(), opts, false);
531        assert_eq!(state.mode, AskMode::SingleSelect);
532
533        state.cursor_down();
534        let result = state.confirm();
535        assert!(matches!(result, AskResult::Selected(v) if v == vec![1]));
536    }
537
538    #[test]
539    fn multi_select_toggle() {
540        let opts = vec![
541            AskOption {
542                label: "A".into(),
543                description: None,
544                checked: false,
545            },
546            AskOption {
547                label: "B".into(),
548                description: None,
549                checked: false,
550            },
551            AskOption {
552                label: "C".into(),
553                description: None,
554                checked: false,
555            },
556        ];
557        let mut state = AskState::new("Pick".into(), String::new(), opts, true);
558        assert_eq!(state.mode, AskMode::MultiSelect);
559
560        state.toggle_current(); // toggle A
561        state.cursor_down();
562        state.cursor_down();
563        state.toggle_current(); // toggle C
564
565        let result = state.confirm();
566        assert!(matches!(result, AskResult::Selected(v) if v == vec![0, 2]));
567    }
568
569    #[test]
570    fn free_text_input() {
571        let mut state = AskState::new("What color?".into(), String::new(), vec![], false);
572        assert_eq!(state.mode, AskMode::FreeText);
573        assert!(state.input_active);
574
575        state.type_char('r');
576        state.type_char('e');
577        state.type_char('d');
578
579        let result = state.confirm();
580        assert!(matches!(result, AskResult::Text(t) if t == "red"));
581    }
582
583    #[test]
584    fn tab_copies_option_to_input() {
585        let opts = vec![AskOption {
586            label: "React".into(),
587            description: None,
588            checked: false,
589        }];
590        let mut state = AskState::new("Pick".into(), String::new(), opts, false);
591
592        state.tab_to_edit();
593        assert!(state.input_active);
594        assert_eq!(state.input, "React");
595
596        // Modify it
597        state.type_char('!');
598        let result = state.confirm();
599        assert!(matches!(result, AskResult::Text(t) if t == "React!"));
600    }
601
602    #[test]
603    fn typing_activates_input_mode() {
604        let opts = vec![AskOption {
605            label: "A".into(),
606            description: None,
607            checked: false,
608        }];
609        let mut state = AskState::new("Pick".into(), String::new(), opts, false);
610        assert!(!state.input_active);
611
612        state.type_char('c');
613        assert!(state.input_active);
614        assert_eq!(state.input, "c");
615    }
616
617    #[test]
618    fn backspace_returns_to_option_mode() {
619        let opts = vec![AskOption {
620            label: "A".into(),
621            description: None,
622            checked: false,
623        }];
624        let mut state = AskState::new("Pick".into(), String::new(), opts, false);
625
626        state.type_char('x');
627        assert!(state.input_active);
628
629        state.backspace();
630        assert!(!state.input_active); // back to option mode
631    }
632
633    #[test]
634    fn quick_select_confirms_single_select() {
635        let opts = vec![
636            AskOption {
637                label: "A".into(),
638                description: None,
639                checked: false,
640            },
641            AskOption {
642                label: "B".into(),
643                description: None,
644                checked: false,
645            },
646        ];
647        let mut state = AskState::new("Pick".into(), String::new(), opts, false);
648
649        assert!(state.quick_select(2));
650        assert_eq!(state.cursor, 1);
651    }
652
653    #[test]
654    fn quick_select_toggles_multi_select_without_confirming() {
655        let opts = vec![
656            AskOption {
657                label: "A".into(),
658                description: None,
659                checked: false,
660            },
661            AskOption {
662                label: "B".into(),
663                description: None,
664                checked: false,
665            },
666        ];
667        let mut state = AskState::new("Pick".into(), String::new(), opts, true);
668
669        assert!(!state.quick_select(2));
670        assert_eq!(state.cursor, 1);
671        assert!(state.options[1].checked);
672    }
673
674    #[test]
675    fn height_calculation() {
676        let opts = vec![
677            AskOption {
678                label: "A".into(),
679                description: None,
680                checked: false,
681            },
682            AskOption {
683                label: "B".into(),
684                description: None,
685                checked: false,
686            },
687        ];
688        let state = AskState::new("Q".into(), "ctx".into(), opts, false);
689        // question(1) + context(1) + 2 options(2) + blank(1) + input(1) + hints(1) = 7
690        // Use a wide width so nothing wraps
691        assert_eq!(state.height(100), 7);
692    }
693
694    #[test]
695    fn tab_to_edit_clamps_stale_option_cursor() {
696        let opts = vec![AskOption {
697            label: "React".into(),
698            description: None,
699            checked: false,
700        }];
701        let mut state = AskState::new("Pick".into(), String::new(), opts, false);
702        state.cursor = 99;
703
704        state.tab_to_edit();
705
706        assert_eq!(state.input, "React");
707        assert_eq!(state.input_cursor, state.input.len());
708        assert_eq!(state.editor_cursor, state.input.len());
709    }
710
711    #[test]
712    fn sync_from_editor_clamps_invalid_utf8_boundary() {
713        let mut state = AskState::new("Pick".into(), String::new(), vec![], false);
714
715        state.sync_from_editor("éx", 1);
716
717        assert_eq!(state.input_cursor, 0);
718        assert_eq!(state.editor_cursor, 0);
719    }
720
721    #[test]
722    fn confirm_clamps_stale_selected_cursor() {
723        let opts = vec![AskOption {
724            label: "Only".into(),
725            description: None,
726            checked: false,
727        }];
728        let mut state = AskState::new("Pick".into(), String::new(), opts, false);
729        state.cursor = 42;
730
731        let result = state.confirm();
732        assert!(matches!(result, AskResult::Selected(v) if v == vec![0]));
733    }
734
735    #[test]
736    fn cursor_screen_position_handles_tiny_area_and_stale_cursor() {
737        let mut state = AskState::new("Q".into(), String::new(), vec![], false);
738        state.input = "abc".into();
739        state.input_cursor = usize::MAX;
740        state.editor_cursor = usize::MAX;
741
742        let (x, y) = state.cursor_screen_position(Rect::new(3, 4, 0, 0));
743        assert_eq!((x, y), (3, 4));
744
745        let (x, y) = state.cursor_screen_position(Rect::new(3, 4, 1, 1));
746        assert_eq!((x, y), (3, 4));
747    }
748}