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#[derive(Debug, Clone)]
13pub struct AskOption {
14 pub label: String,
15 pub description: Option<String>,
16 pub checked: bool, }
18
19#[derive(Debug, Clone, PartialEq)]
21pub enum AskMode {
22 SingleSelect,
23 MultiSelect,
24 FreeText,
25}
26
27#[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, pub input: String, pub input_cursor: usize, pub input_active: bool, 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; h += 1; }
110 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; h
115 }
116
117 pub fn prompt_height(&self, width: u16) -> u16 {
119 self.height(width).saturating_add(2)
120 }
121
122 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 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 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 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 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 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 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 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 self.input.is_empty() && !self.options.is_empty() {
245 self.input_active = false;
246 }
247 }
248
249 pub fn confirm(&self) -> AskResult {
251 if self.input_active && !self.input.is_empty() {
252 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 AskResult::Selected(vec![self.normalized_option_cursor()])
276 } else {
277 AskResult::Selected(selected)
278 }
279 }
280 }
281 }
282}
283
284#[derive(Debug)]
286pub enum AskResult {
287 Selected(Vec<usize>),
288 Text(String),
289}
290
291pub 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 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 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 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 } 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 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 y += 1;
427 }
428
429 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 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(); state.cursor_down();
562 state.cursor_down();
563 state.toggle_current(); 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 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); }
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 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}