1use std::io;
22use std::time::Duration;
23
24use crossterm::event::{self, Event as CEvent, KeyCode, KeyEventKind, KeyModifiers};
25use ratatui::text::{Line, Span};
26use ratatui::{
27 layout::{Constraint, Direction, Layout},
28 style::Style as TuiStyle,
29 widgets::{Block, Borders, Clear, Paragraph},
30};
31
32use crate::utils::variables::Variable;
33
34use super::get_terminate;
35use super::state::is_ctrl_key;
36use super::theme::{get_theme, style_fg};
37
38struct TerminalGuard;
41
42impl Drop for TerminalGuard {
43 fn drop(&mut self) {
44 ratatui::restore();
45 }
46}
47
48pub enum VariablePromptResult {
50 Cancel,
53 Back,
56 Skip,
58 Values(Vec<(String, String)>),
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum KeyAction {
65 Continue,
67 Back,
69}
70
71pub fn prompt_variables(vars: Vec<Variable>) -> io::Result<VariablePromptResult> {
72 if vars.is_empty() {
73 return Ok(VariablePromptResult::Skip);
74 }
75
76 prompt_variables_inner(vars)
77}
78
79#[derive(Clone)]
81struct Field {
82 value: String,
83 cursor: usize,
85}
86
87impl Field {
88 fn new(default: &str) -> Self {
89 let value = default.to_string();
90 let cursor = value.len();
91 Self { value, cursor }
92 }
93
94 fn insert_char(&mut self, c: char) {
95 let pos = self.cursor.min(self.value.len());
96 self.value.insert(pos, c);
97 self.cursor = pos + c.len_utf8();
98 }
99
100 fn backspace(&mut self) {
102 if self.cursor == 0 {
103 return;
104 }
105 let prev = prev_char_boundary(&self.value, self.cursor);
106 self.value.drain(prev..self.cursor);
107 self.cursor = prev;
108 }
109
110 fn delete(&mut self) {
112 if self.cursor >= self.value.len() {
113 return;
114 }
115 let next = next_char_boundary(&self.value, self.cursor);
116 self.value.drain(self.cursor..next);
117 }
118
119 fn move_left(&mut self) {
120 if self.cursor == 0 {
121 return;
122 }
123 self.cursor = prev_char_boundary(&self.value, self.cursor);
124 }
125
126 fn move_right(&mut self) {
127 if self.cursor >= self.value.len() {
128 return;
129 }
130 self.cursor = next_char_boundary(&self.value, self.cursor);
131 }
132
133 fn move_start(&mut self) {
134 self.cursor = 0;
135 }
136
137 fn move_end(&mut self) {
138 self.cursor = self.value.len();
139 }
140
141 fn clear(&mut self) {
142 self.value.clear();
143 self.cursor = 0;
144 }
145}
146
147fn prev_char_boundary(s: &str, idx: usize) -> usize {
150 debug_assert!(idx <= s.len());
151 let mut i = idx - 1;
152 while !s.is_char_boundary(i) {
153 i -= 1;
154 }
155 i
156}
157
158fn next_char_boundary(s: &str, idx: usize) -> usize {
161 debug_assert!(idx < s.len());
162 let mut i = idx + 1;
163 while i < s.len() && !s.is_char_boundary(i) {
164 i += 1;
165 }
166 i
167}
168
169fn prompt_variables_inner(vars: Vec<Variable>) -> io::Result<VariablePromptResult> {
170 let mut terminal = ratatui::init();
171 let _guard = TerminalGuard; let defaults: Vec<String> = vars
174 .iter()
175 .map(|v| v.default.clone().unwrap_or_default())
176 .collect();
177
178 let mut fields: Vec<Field> = defaults.iter().map(|d| Field::new(d)).collect();
179 let mut selected = 0usize;
180 let mut show_defaults = true;
181 let mut insert_mode = true;
182 let terminate = get_terminate();
183
184 loop {
185 if terminate.load(std::sync::atomic::Ordering::SeqCst) {
186 return Ok(VariablePromptResult::Cancel);
187 }
188
189 terminal.draw(|f| {
190 let size = f.area();
191 f.render_widget(Clear, size);
192 if size.width < 10 || size.height < 10 {
193 let error_msg = "Terminal too small - resize to at least 10x10";
194 let paragraph = Paragraph::new(error_msg)
195 .centered()
196 .block(Block::default().title("Error").borders(Borders::ALL));
197 f.render_widget(paragraph, size);
198 return;
199 }
200 let theme = get_theme();
201 let block = Block::default()
202 .title("Enter variables")
203 .borders(Borders::ALL)
204 .style(style_fg(theme.border));
205
206 let num_vars = fields.len();
207 let var_height = num_vars * 3;
208 let chunks = Layout::default()
209 .direction(Direction::Vertical)
210 .margin(2)
211 .constraints([
212 Constraint::Length(var_height.min(u16::MAX as usize) as u16),
213 Constraint::Length(1),
214 ])
215 .split(size);
216
217 f.render_widget(block, size);
218
219 let var_chunks = Layout::default()
220 .direction(Direction::Vertical)
221 .constraints(vec![Constraint::Length(3); num_vars])
222 .split(chunks[0]);
223
224 for (i, var) in vars.iter().enumerate() {
225 let var_block = Block::default()
226 .title(var.name.as_str())
227 .borders(Borders::ALL)
228 .style(if i == selected {
229 TuiStyle::default().fg(theme.accent)
230 } else {
231 TuiStyle::default()
232 });
233
234 let prefix = if i == selected { "▶ " } else { " " };
235 let display_value = if show_defaults
236 && fields[i].value == defaults[i]
237 && !defaults[i].is_empty()
238 {
239 format!("{} (default: {})", fields[i].value, defaults[i])
240 } else if fields[i].value.is_empty() {
241 "_".to_string()
242 } else {
243 fields[i].value.clone()
244 };
245 let text = format!("{prefix}{display_value}");
246
247 let p = Paragraph::new(text)
248 .block(var_block)
249 .style(style_fg(theme.text));
250
251 f.render_widget(p, var_chunks[i]);
252 }
253
254 if selected < fields.len() {
257 use unicode_width::UnicodeWidthStr;
258 let prefix_len = 2;
259 let field = &fields[selected];
260 let cursor_byte = field.cursor.min(field.value.len());
261 let prefix_str = field.value.get(..cursor_byte).unwrap_or("");
262 let cursor_x = var_chunks[selected].x
263 + 1
264 + prefix_len
265 + prefix_str.width().min(u16::MAX as usize) as u16;
266 let cursor_y = var_chunks[selected].y + 1;
267 f.set_cursor_position((cursor_x, cursor_y));
268 }
269
270 let status_text = if insert_mode {
271 "[INS] type to enter | \u{2190}/\u{2192}: move | \u{2191}/\u{2193}/Tab/ctrl+d/ctrl+u: var | Enter: save | Esc: normal"
272 } else {
273 "[NOR] i: insert | h/l: move | 0/$: start/end | j/k/Tab: var | x: delete | d: defaults | Enter: save | q: back | ctrl+c: quit"
274 };
275 let status_widget = Paragraph::new(Line::from(vec![Span::styled(
276 status_text,
277 style_fg(theme.muted),
278 )]));
279 f.render_widget(status_widget, chunks[1]);
280 })?;
281
282 let polled = event::poll(Duration::from_millis(200)).unwrap_or(false);
283 if polled {
284 let key_event = event::read().ok();
285 if let Some(CEvent::Key(key)) = key_event
286 && key.kind == KeyEventKind::Press
287 {
288 if is_ctrl_key(&key, 'c') {
289 terminate.store(true, std::sync::atomic::Ordering::SeqCst);
290 return Ok(VariablePromptResult::Cancel);
291 }
292 let action = if insert_mode {
293 handle_insert_key(key, &mut fields, &mut selected, &defaults, &mut insert_mode)?
294 } else {
295 handle_normal_key(
296 key,
297 &mut fields,
298 &mut selected,
299 &mut show_defaults,
300 &mut insert_mode,
301 )?
302 };
303 match action {
304 KeyAction::Back => return Ok(VariablePromptResult::Back),
305 KeyAction::Continue => {
306 if matches!(key.code, KeyCode::Enter) {
307 break;
308 }
309 }
310 }
311 }
312 }
313 }
314
315 for (field, default) in fields.iter_mut().zip(defaults.iter()) {
316 if field.value.is_empty() && !default.is_empty() {
317 field.value = default.clone();
318 }
319 }
320
321 let result: Vec<(String, String)> = vars
322 .iter()
323 .zip(fields.iter())
324 .map(|(v, f)| (v.name.clone(), f.value.trim().to_string()))
325 .collect();
326 Ok(VariablePromptResult::Values(result))
327}
328
329fn handle_insert_key(
330 key: crossterm::event::KeyEvent,
331 fields: &mut [Field],
332 selected: &mut usize,
333 defaults: &[String],
334 insert_mode: &mut bool,
335) -> io::Result<KeyAction> {
336 let num_vars = fields.len();
337 match key.code {
338 KeyCode::Esc => {
339 *insert_mode = false;
340 }
341 KeyCode::Up => {
342 if *selected > 0 {
343 *selected -= 1;
344 }
345 }
346 KeyCode::Down => {
347 if *selected + 1 < num_vars {
348 *selected += 1;
349 }
350 }
351 KeyCode::Tab => {
352 if *selected + 1 < num_vars {
353 *selected += 1;
354 } else {
355 *selected = 0;
356 }
357 }
358 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
359 if *selected + 1 < num_vars {
360 *selected += 1;
361 }
362 }
363 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
364 if *selected > 0 {
365 *selected -= 1;
366 }
367 }
368 KeyCode::Left => {
369 fields[*selected].move_left();
370 }
371 KeyCode::Right => {
372 fields[*selected].move_right();
373 }
374 KeyCode::Backspace => {
375 fields[*selected].backspace();
376 }
377 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
378 let field = &mut fields[*selected];
379 if field.cursor >= field.value.len()
380 && !defaults[*selected].is_empty()
381 && field.value == defaults[*selected]
382 {
383 field.clear();
384 }
385 field.insert_char(c);
386 }
387 KeyCode::Enter => {}
388 _ => {}
389 }
390 Ok(KeyAction::Continue)
391}
392
393fn handle_normal_key(
394 key: crossterm::event::KeyEvent,
395 fields: &mut [Field],
396 selected: &mut usize,
397 show_defaults: &mut bool,
398 insert_mode: &mut bool,
399) -> io::Result<KeyAction> {
400 let num_vars = fields.len();
401 let mut action = KeyAction::Continue;
402 match key.code {
403 KeyCode::Char('q') => action = KeyAction::Back,
404 KeyCode::Char('i') | KeyCode::Esc => {
405 *insert_mode = true;
406 }
407 KeyCode::Char('a') => {
408 fields[*selected].move_right();
409 *insert_mode = true;
410 }
411 KeyCode::Char('A') => {
412 fields[*selected].move_end();
413 *insert_mode = true;
414 }
415 KeyCode::Char('I') => {
416 fields[*selected].move_start();
417 *insert_mode = true;
418 }
419 KeyCode::Char('h') | KeyCode::Left => {
420 fields[*selected].move_left();
421 }
422 KeyCode::Char('l') | KeyCode::Right => {
423 fields[*selected].move_right();
424 }
425 KeyCode::Char('0') => {
426 fields[*selected].move_start();
427 }
428 KeyCode::Char('$') => {
429 fields[*selected].move_end();
430 }
431 KeyCode::Char('x') | KeyCode::Delete => {
432 fields[*selected].delete();
433 }
434 KeyCode::Backspace => {
435 fields[*selected].backspace();
436 }
437 KeyCode::Char('j') | KeyCode::Down => {
438 if *selected + 1 < num_vars {
439 *selected += 1;
440 }
441 }
442 KeyCode::Char('k') | KeyCode::Up => {
443 if *selected > 0 {
444 *selected -= 1;
445 }
446 }
447 KeyCode::Tab => {
448 if *selected + 1 < num_vars {
449 *selected += 1;
450 } else {
451 *selected = 0;
452 }
453 }
454 KeyCode::Char('d') => {
455 *show_defaults = !*show_defaults;
456 }
457 KeyCode::Enter => {}
458 _ => {}
459 }
460 Ok(action)
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn field_new_places_cursor_at_end() {
469 let f = Field::new("hello");
470 assert_eq!(f.value, "hello");
471 assert_eq!(f.cursor, "hello".len());
472 }
473
474 #[test]
475 fn field_insert_at_cursor() {
476 let mut f = Field::new("hello");
477 f.cursor = 0;
478 f.insert_char('X');
479 assert_eq!(f.value, "Xhello");
480 assert_eq!(f.cursor, 1);
481 }
482
483 #[test]
484 fn field_insert_in_middle() {
485 let mut f = Field::new("hello");
486 f.cursor = 2;
487 f.insert_char('X');
488 assert_eq!(f.value, "heXllo");
489 assert_eq!(f.cursor, 3);
490 }
491
492 #[test]
493 fn field_backspace_removes_previous_char() {
494 let mut f = Field::new("hello");
495 f.backspace();
496 assert_eq!(f.value, "hell");
497 assert_eq!(f.cursor, 4);
498 }
499
500 #[test]
501 fn field_backspace_at_start_is_noop() {
502 let mut f = Field::new("hello");
503 f.cursor = 0;
504 f.backspace();
505 assert_eq!(f.value, "hello");
506 assert_eq!(f.cursor, 0);
507 }
508
509 #[test]
510 fn field_delete_removes_char_at_cursor() {
511 let mut f = Field::new("hello");
512 f.cursor = 1;
513 f.delete();
514 assert_eq!(f.value, "hllo");
515 assert_eq!(f.cursor, 1);
516 }
517
518 #[test]
519 fn field_move_left_right() {
520 let mut f = Field::new("hello");
521 f.move_left();
522 f.move_left();
523 assert_eq!(f.cursor, 3);
524 f.move_right();
525 assert_eq!(f.cursor, 4);
526 }
527
528 #[test]
529 fn field_handles_multibyte_chars() {
530 let mut f = Field::new("héllo");
531 assert_eq!(f.value.len(), 6);
532 f.cursor = 1;
533 f.insert_char('X');
534 assert_eq!(f.value, "hXéllo");
535 assert_eq!(f.cursor, 2);
536 f.backspace();
537 assert_eq!(f.value, "héllo");
538 assert_eq!(f.cursor, 1);
539 }
540
541 #[test]
542 fn field_start_end() {
543 let mut f = Field::new("hello");
544 f.move_start();
545 assert_eq!(f.cursor, 0);
546 f.move_end();
547 assert_eq!(f.cursor, 5);
548 }
549
550 #[test]
551 fn field_clear() {
552 let mut f = Field::new("hello");
553 f.clear();
554 assert_eq!(f.value, "");
555 assert_eq!(f.cursor, 0);
556 }
557
558 #[test]
559 fn prompt_variables_with_no_vars_skips() {
560 let result = prompt_variables(vec![]).unwrap();
561 assert!(matches!(result, VariablePromptResult::Skip));
562 }
563
564 fn press(c: char) -> crossterm::event::KeyEvent {
565 crossterm::event::KeyEvent {
566 code: crossterm::event::KeyCode::Char(c),
567 modifiers: crossterm::event::KeyModifiers::empty(),
568 kind: crossterm::event::KeyEventKind::Press,
569 state: crossterm::event::KeyEventState::NONE,
570 }
571 }
572
573 fn press_ctrl(c: char) -> crossterm::event::KeyEvent {
574 crossterm::event::KeyEvent {
575 code: crossterm::event::KeyCode::Char(c),
576 modifiers: crossterm::event::KeyModifiers::CONTROL,
577 kind: crossterm::event::KeyEventKind::Press,
578 state: crossterm::event::KeyEventState::NONE,
579 }
580 }
581
582 #[test]
583 fn ins_mode_inserts_alphanumerics_including_q() {
584 let mut fields = vec![Field::new("")];
585 let mut selected = 0;
586 let defaults = vec![String::new()];
587 let mut insert_mode = true;
588
589 for c in "djckDJCKqQ".chars() {
590 let action = handle_insert_key(
591 press(c),
592 &mut fields,
593 &mut selected,
594 &defaults,
595 &mut insert_mode,
596 )
597 .unwrap();
598 assert_eq!(action, KeyAction::Continue, "{c:?} should continue");
599 assert!(insert_mode, "{c:?} should keep INS mode");
600 }
601 assert_eq!(fields[0].value, "djckDJCKqQ");
602 }
603
604 #[test]
605 fn ins_mode_q_inserts_as_char() {
606 let mut fields = vec![Field::new("")];
610 let mut selected = 0;
611 let defaults = vec![String::new()];
612 let mut insert_mode = true;
613
614 for c in "queue".chars() {
615 let action = handle_insert_key(
616 press(c),
617 &mut fields,
618 &mut selected,
619 &defaults,
620 &mut insert_mode,
621 )
622 .unwrap();
623 assert_eq!(action, KeyAction::Continue);
624 }
625 assert_eq!(fields[0].value, "queue");
626 }
627
628 #[test]
629 fn ins_mode_ctrl_d_moves_to_next_var() {
630 let mut fields = vec![Field::new("a"), Field::new("b"), Field::new("c")];
631 let mut selected = 0;
632 let defaults = vec!["a".to_string(), "b".to_string(), "c".to_string()];
633 let mut insert_mode = true;
634
635 let action = handle_insert_key(
636 press_ctrl('d'),
637 &mut fields,
638 &mut selected,
639 &defaults,
640 &mut insert_mode,
641 )
642 .unwrap();
643 assert_eq!(action, KeyAction::Continue);
644 assert_eq!(selected, 1);
645 assert!(insert_mode);
646 }
647
648 #[test]
649 fn ins_mode_ctrl_d_at_last_var_clamps() {
650 let mut fields = vec![Field::new("a"), Field::new("b")];
651 let mut selected = 1;
652 let defaults = vec!["a".to_string(), "b".to_string()];
653 let mut insert_mode = true;
654
655 let action = handle_insert_key(
656 press_ctrl('d'),
657 &mut fields,
658 &mut selected,
659 &defaults,
660 &mut insert_mode,
661 )
662 .unwrap();
663 assert_eq!(action, KeyAction::Continue);
664 assert_eq!(selected, 1, "should stay at last var");
665 }
666
667 #[test]
668 fn ins_mode_ctrl_u_moves_to_prev_var() {
669 let mut fields = vec![Field::new("a"), Field::new("b"), Field::new("c")];
670 let mut selected = 2;
671 let defaults = vec!["a".to_string(), "b".to_string(), "c".to_string()];
672 let mut insert_mode = true;
673
674 let action = handle_insert_key(
675 press_ctrl('u'),
676 &mut fields,
677 &mut selected,
678 &defaults,
679 &mut insert_mode,
680 )
681 .unwrap();
682 assert_eq!(action, KeyAction::Continue);
683 assert_eq!(selected, 1);
684 assert!(insert_mode);
685 }
686
687 #[test]
688 fn ins_mode_ctrl_u_at_first_var_clamps() {
689 let mut fields = vec![Field::new("a"), Field::new("b")];
690 let mut selected = 0;
691 let defaults = vec!["a".to_string(), "b".to_string()];
692 let mut insert_mode = true;
693
694 let action = handle_insert_key(
695 press_ctrl('u'),
696 &mut fields,
697 &mut selected,
698 &defaults,
699 &mut insert_mode,
700 )
701 .unwrap();
702 assert_eq!(action, KeyAction::Continue);
703 assert_eq!(selected, 0, "should stay at first var");
704 }
705
706 #[test]
707 fn ins_mode_plain_d_still_inserts() {
708 let mut fields = vec![Field::new("")];
709 let mut selected = 0;
710 let defaults = vec![String::new()];
711 let mut insert_mode = true;
712
713 let action = handle_insert_key(
714 press('d'),
715 &mut fields,
716 &mut selected,
717 &defaults,
718 &mut insert_mode,
719 )
720 .unwrap();
721 assert_eq!(action, KeyAction::Continue);
722 assert_eq!(fields[0].value, "d");
723 }
724
725 #[test]
726 fn ins_mode_plain_u_still_inserts() {
727 let mut fields = vec![Field::new("")];
728 let mut selected = 0;
729 let defaults = vec![String::new()];
730 let mut insert_mode = true;
731
732 let action = handle_insert_key(
733 press('u'),
734 &mut fields,
735 &mut selected,
736 &defaults,
737 &mut insert_mode,
738 )
739 .unwrap();
740 assert_eq!(action, KeyAction::Continue);
741 assert_eq!(fields[0].value, "u");
742 }
743
744 #[test]
745 fn ins_mode_typing_into_default_clears_then_inserts() {
746 let mut fields = vec![Field::new("any.com")];
747 let mut selected = 0;
748 let defaults = vec!["any.com".to_string()];
749 let mut insert_mode = true;
750
751 for c in "dandy.com".chars() {
752 let action = handle_insert_key(
753 press(c),
754 &mut fields,
755 &mut selected,
756 &defaults,
757 &mut insert_mode,
758 )
759 .unwrap();
760 assert_eq!(action, KeyAction::Continue);
761 }
762 assert_eq!(fields[0].value, "dandy.com");
763 }
764
765 #[test]
766 fn nor_mode_q_returns_back_action() {
767 let mut fields = vec![Field::new("hello")];
768 let mut selected = 0;
769 let mut show_defaults = true;
770 let mut insert_mode = false;
771
772 let action = handle_normal_key(
773 press('q'),
774 &mut fields,
775 &mut selected,
776 &mut show_defaults,
777 &mut insert_mode,
778 )
779 .unwrap();
780 assert_eq!(action, KeyAction::Back);
781 assert_eq!(fields[0].value, "hello");
782 }
783
784 #[test]
785 fn ins_mode_ctrl_c_is_not_treated_as_char() {
786 let mut fields = vec![Field::new("")];
791 let mut selected = 0;
792 let defaults = vec![String::new()];
793 let mut insert_mode = true;
794
795 let action = handle_insert_key(
796 press_ctrl('c'),
797 &mut fields,
798 &mut selected,
799 &defaults,
800 &mut insert_mode,
801 )
802 .unwrap();
803 assert_eq!(action, KeyAction::Continue);
804 assert_eq!(fields[0].value, "", "Ctrl+C must not insert 'c'");
805 }
806}