1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
3use nucleo_matcher::{Config, Matcher, Utf32Str};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum InputAction {
8 Submit,
9 Cancel,
10 Backspace,
11 Delete,
12 Left,
13 Right,
14 Home,
15 End,
16 BackWord,
17 Clear,
18 ToggleDiff,
19 Insert(char),
20 CompletionUp,
22 CompletionDown,
24 Accept,
26 None,
27}
28
29impl InputAction {
30 pub fn from_key(key: KeyEvent) -> Self {
31 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
32 match key.code {
33 KeyCode::Enter => InputAction::Submit,
34 KeyCode::Esc => InputAction::Cancel,
35 KeyCode::Backspace => InputAction::Backspace,
36 KeyCode::Delete => InputAction::Delete,
37 KeyCode::Left => InputAction::Left,
38 KeyCode::Right => InputAction::Right,
39 KeyCode::Up => InputAction::CompletionUp,
40 KeyCode::Down => InputAction::CompletionDown,
41 KeyCode::Tab => InputAction::Accept,
42 KeyCode::Home => InputAction::Home,
43 KeyCode::End => InputAction::End,
44 KeyCode::Char('a') if ctrl => InputAction::Home,
45 KeyCode::Char('d') if ctrl => InputAction::ToggleDiff,
46 KeyCode::Char('e') if ctrl => InputAction::End,
47 KeyCode::Char('b') if ctrl => InputAction::BackWord,
48 KeyCode::Char('u') | KeyCode::Char('c') if ctrl => InputAction::Clear,
49 KeyCode::Char(c) => InputAction::Insert(c),
50 _ => InputAction::None,
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum InputResult {
58 Submit(String),
59 Cancel,
60}
61
62const WORD_BOUNDARIES: &[char] = &[':', '/', '?', '=', '&', '#', '@'];
64
65pub const MAX_VISIBLE_COMPLETIONS: usize = 2;
67
68const QUERY_PARAMS: &[(&str, &str)] = &[
70 ("?ref=", "Git/Mercurial branch or tag"),
71 ("?rev=", "Git/Mercurial commit hash"),
72 ("?dir=", "Subdirectory containing flake.nix"),
73 ("?branch=", "Git branch name"),
74 ("?host=", "Custom host for GitHub/GitLab/SourceHut"),
75 ("?shallow=", "Shallow clone (1 = enabled)"),
76 ("?submodules=", "Fetch Git submodules (1 = enabled)"),
77 ("?narHash=", "NAR hash in SRI format"),
78];
79
80#[derive(Debug, Clone)]
82struct QueryContext {
83 anchor: usize,
85 base_end: usize,
87 param_prefix: String,
89}
90
91impl QueryContext {
92 fn parse(input: &str) -> Option<Self> {
94 let has_uri = input.contains(':') && !input.ends_with(':');
96 if !has_uri {
97 return None;
98 }
99
100 let q_pos = input.rfind('?')?;
101 let after_q = &input[q_pos + 1..];
102
103 if let Some(amp_pos) = after_q.rfind('&') {
104 let param_part = &after_q[amp_pos + 1..];
105 if !param_part.contains('=') {
107 let pos = q_pos + 1 + amp_pos + 1;
108 return Some(Self {
109 anchor: pos,
110 base_end: pos,
111 param_prefix: param_part.to_string(),
112 });
113 }
114 } else if !after_q.contains('=') {
115 return Some(Self {
117 anchor: q_pos,
118 base_end: q_pos + 1,
119 param_prefix: after_q.to_string(),
120 });
121 }
122
123 None
124 }
125
126 fn base<'a>(&self, input: &'a str) -> &'a str {
128 &input[..self.base_end]
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct CompletionItem {
135 pub text: String,
136 pub description: Option<String>,
137 pub match_indices: Vec<u32>,
139}
140
141#[derive(Debug, Clone)]
143pub struct CompletionState {
144 items: Vec<String>,
146 filtered: Vec<CompletionItem>,
148 selected: Option<usize>,
150 scroll_offset: usize,
152 visible: bool,
154 query_context: Option<QueryContext>,
156}
157
158impl CompletionState {
159 fn new(items: Vec<String>) -> Self {
160 let filtered = items
161 .iter()
162 .map(|s| CompletionItem {
163 text: s.clone(),
164 description: None,
165 match_indices: Vec::new(),
166 })
167 .collect();
168 Self {
169 filtered,
170 items,
171 selected: None,
172 scroll_offset: 0,
173 visible: false,
174 query_context: None,
175 }
176 }
177
178 fn is_query_param(&self) -> bool {
179 self.query_context.is_some()
180 }
181
182 fn filter(&mut self, input: &str) {
183 let was_query_param = self.is_query_param();
184 let new_query_context = QueryContext::parse(input);
185
186 match &new_query_context {
187 Some(ctx) => self.filter_query_params(ctx),
188 None => self.filter_uris(input),
189 }
190
191 let mode_changed = was_query_param != new_query_context.is_some();
192 self.query_context = new_query_context;
193 self.update_selection_state(input, mode_changed);
194 }
195
196 fn filter_query_params(&mut self, ctx: &QueryContext) {
198 let prefix_lower = ctx.param_prefix.to_lowercase();
199 let query_with_prefix = format!("?{}", prefix_lower);
200 self.filtered = QUERY_PARAMS
201 .iter()
202 .filter(|(p, _)| p.to_lowercase().starts_with(&query_with_prefix))
203 .map(|(text, desc)| {
204 let match_indices: Vec<u32> = (0..query_with_prefix.len() as u32).collect();
205 CompletionItem {
206 text: text.to_string(),
207 description: Some(desc.to_string()),
208 match_indices,
209 }
210 })
211 .collect();
212 }
213
214 fn filter_uris(&mut self, input: &str) {
216 let mut matcher = Matcher::new(Config::DEFAULT);
217 let pattern = Pattern::parse(input, CaseMatching::Smart, Normalization::Smart);
218
219 let mut results: Vec<(String, u32, Vec<u32>)> = Vec::new();
220 let mut char_buf: Vec<char> = Vec::new();
221 let mut indices_buf: Vec<u32> = Vec::new();
222
223 for item in &self.items {
224 char_buf.clear();
225 indices_buf.clear();
226 let haystack = Utf32Str::new(item, &mut char_buf);
227 if let Some(score) = pattern.indices(haystack, &mut matcher, &mut indices_buf) {
228 results.push((item.clone(), score, indices_buf.clone()));
229 }
230 }
231
232 results.sort_by(|a, b| b.1.cmp(&a.1));
233
234 self.filtered = results
235 .into_iter()
236 .map(|(text, _, match_indices)| CompletionItem {
237 text,
238 description: None,
239 match_indices,
240 })
241 .collect();
242 }
243
244 fn update_selection_state(&mut self, input: &str, mode_changed: bool) {
246 if self.filtered.is_empty() {
247 self.selected = None;
248 self.scroll_offset = 0;
249 self.visible = false;
250 } else {
251 self.visible = !input.is_empty();
252 if mode_changed {
253 self.selected = Some(0);
254 self.scroll_offset = 0;
255 } else {
256 match self.selected {
257 None => {
258 self.selected = Some(0);
259 self.scroll_offset = 0;
260 }
261 Some(sel) if sel >= self.filtered.len() => {
262 self.selected = Some(self.filtered.len() - 1);
263 self.scroll_offset =
264 self.filtered.len().saturating_sub(MAX_VISIBLE_COMPLETIONS);
265 }
266 _ => {}
267 }
268 }
269 }
270 }
271
272 fn select_next(&mut self) {
273 if self.filtered.is_empty() {
274 return;
275 }
276 let new_selected = match self.selected {
277 None => 0,
278 Some(n) if n >= self.filtered.len() - 1 => 0,
279 Some(n) => n + 1,
280 };
281 self.selected = Some(new_selected);
282
283 if new_selected == 0 {
285 self.scroll_offset = 0;
287 } else if new_selected >= self.scroll_offset + MAX_VISIBLE_COMPLETIONS {
288 self.scroll_offset = new_selected + 1 - MAX_VISIBLE_COMPLETIONS;
290 }
291 }
292
293 fn select_prev(&mut self) {
294 if self.filtered.is_empty() {
295 return;
296 }
297 let new_selected = match self.selected {
298 None => self.filtered.len() - 1,
299 Some(0) => self.filtered.len() - 1,
300 Some(n) => n - 1,
301 };
302 self.selected = Some(new_selected);
303
304 if new_selected == self.filtered.len() - 1 {
306 self.scroll_offset = self.filtered.len().saturating_sub(MAX_VISIBLE_COMPLETIONS);
308 } else if new_selected < self.scroll_offset {
309 self.scroll_offset = new_selected;
311 }
312 }
313
314 fn selected_item(&self) -> Option<&str> {
315 self.selected
316 .and_then(|idx| self.filtered.get(idx))
317 .map(|item| item.text.as_str())
318 }
319
320 fn hide(&mut self) {
321 self.visible = false;
322 self.selected = None;
323 self.scroll_offset = 0;
324 }
325}
326
327#[derive(Debug, Clone)]
329pub struct InputState {
330 input: String,
331 cursor: usize,
332 completion: Option<CompletionState>,
334}
335
336impl InputState {
337 pub fn new(default: Option<&str>) -> Self {
338 let input = default.unwrap_or("").to_string();
339 let cursor = input.len();
340 Self {
341 input,
342 cursor,
343 completion: None,
344 }
345 }
346
347 pub fn with_completions(default: Option<&str>, items: Vec<String>) -> Self {
349 let input = default.unwrap_or("").to_string();
350 let cursor = input.len();
351 let mut completion = CompletionState::new(items);
352 if !input.is_empty() {
354 completion.filter(&input);
355 }
356 Self {
357 input,
358 cursor,
359 completion: Some(completion),
360 }
361 }
362
363 pub fn text(&self) -> &str {
364 &self.input
365 }
366
367 pub fn cursor(&self) -> usize {
368 self.cursor
369 }
370
371 pub fn is_empty(&self) -> bool {
372 self.input.is_empty()
373 }
374
375 pub fn has_visible_completions(&self) -> bool {
379 self.completion
380 .as_ref()
381 .is_some_and(|c| c.visible && !c.filtered.is_empty())
382 }
383
384 pub fn filtered_completions(&self) -> &[CompletionItem] {
386 static EMPTY: &[CompletionItem] = &[];
387 self.completion
388 .as_ref()
389 .map(|c| {
390 let start = c.scroll_offset;
391 let end = (c.scroll_offset + MAX_VISIBLE_COMPLETIONS).min(c.filtered.len());
392 &c.filtered[start..end]
393 })
394 .unwrap_or(EMPTY)
395 }
396
397 pub fn selected_index(&self) -> Option<usize> {
399 self.completion.as_ref().and_then(|c| c.selected)
400 }
401
402 pub fn visible_selection_index(&self) -> Option<usize> {
404 self.completion
405 .as_ref()
406 .and_then(|c| c.selected.map(|sel| sel.saturating_sub(c.scroll_offset)))
407 }
408
409 pub fn completion_anchor(&self) -> usize {
412 self.completion
413 .as_ref()
414 .and_then(|c| c.query_context.as_ref())
415 .map(|ctx| ctx.anchor)
416 .unwrap_or(0)
417 }
418
419 fn update_completions(&mut self) {
421 if let Some(ref mut comp) = self.completion {
422 comp.filter(&self.input);
423 }
424 }
425
426 fn accept_completion(&mut self) -> bool {
428 if let Some(ref mut comp) = self.completion
429 && let Some(text) = comp.selected_item()
430 {
431 if let Some(ref ctx) = comp.query_context {
432 let base = ctx.base(&self.input);
434 let param = text.trim_start_matches('?');
435 self.input = format!("{}{}", base, param);
436 } else {
437 self.input = text.to_string();
438 }
439 self.cursor = self.input.len();
440 comp.hide();
441 return true;
442 }
443 false
444 }
445
446 pub fn handle(&mut self, action: InputAction) -> Option<InputResult> {
448 match action {
449 InputAction::Submit => {
450 if !self.input.is_empty() {
453 return Some(InputResult::Submit(self.input.clone()));
454 }
455 }
456 InputAction::Cancel => {
457 if self.has_visible_completions() {
459 if let Some(ref mut comp) = self.completion {
460 comp.hide();
461 }
462 return None;
463 }
464 return Some(InputResult::Cancel);
465 }
466 InputAction::Backspace => {
467 if self.cursor > 0 {
468 self.input.remove(self.cursor - 1);
469 self.cursor -= 1;
470 self.update_completions();
471 }
472 }
473 InputAction::Delete => {
474 if self.cursor < self.input.len() {
475 self.input.remove(self.cursor);
476 self.update_completions();
477 }
478 }
479 InputAction::Left => {
480 self.cursor = self.cursor.saturating_sub(1);
481 }
482 InputAction::Right => {
483 if self.cursor < self.input.len() {
484 self.cursor += 1;
485 }
486 }
487 InputAction::Home => {
488 self.cursor = 0;
489 }
490 InputAction::End => {
491 self.cursor = self.input.len();
492 }
493 InputAction::BackWord => {
494 self.cursor = self.find_prev_boundary();
495 }
496 InputAction::Clear => {
497 self.input.clear();
498 self.cursor = 0;
499 self.update_completions();
500 }
501 InputAction::Insert(c) => {
502 self.input.insert(self.cursor, c);
503 self.cursor += 1;
504 self.update_completions();
505 }
506 InputAction::CompletionUp => {
507 if let Some(ref mut comp) = self.completion {
508 comp.select_prev();
509 }
510 }
511 InputAction::CompletionDown => {
512 if let Some(ref mut comp) = self.completion {
513 comp.select_next();
514 }
515 }
516 InputAction::Accept => {
517 if self
519 .completion
520 .as_ref()
521 .is_some_and(|c| c.selected.is_some())
522 {
523 self.accept_completion();
524 } else if let Some(ref mut comp) = self.completion {
525 comp.select_next(); }
527 }
528 InputAction::ToggleDiff | InputAction::None => {}
529 }
530 None
531 }
532
533 fn find_prev_boundary(&self) -> usize {
534 if self.cursor == 0 {
535 return 0;
536 }
537 let search_start = self.cursor.saturating_sub(1);
538 self.input[..search_start]
539 .rfind(WORD_BOUNDARIES)
540 .map(|p| p + 1)
541 .unwrap_or(0)
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 #[test]
550 fn test_input_state_new_empty() {
551 let state = InputState::new(None);
552 assert!(state.is_empty());
553 assert_eq!(state.cursor(), 0);
554 }
555
556 #[test]
557 fn test_input_state_new_with_default() {
558 let state = InputState::new(Some("hello"));
559 assert_eq!(state.text(), "hello");
560 assert_eq!(state.cursor(), 5); }
562
563 #[test]
564 fn test_input_insert() {
565 let mut state = InputState::new(None);
566 state.handle(InputAction::Insert('a'));
567 state.handle(InputAction::Insert('b'));
568 assert_eq!(state.text(), "ab");
569 assert_eq!(state.cursor(), 2);
570 }
571
572 #[test]
573 fn test_input_backspace() {
574 let mut state = InputState::new(Some("abc"));
575 state.handle(InputAction::Backspace);
576 assert_eq!(state.text(), "ab");
577 }
578
579 #[test]
580 fn test_input_submit() {
581 let mut state = InputState::new(Some("test"));
582 let result = state.handle(InputAction::Submit);
583 assert_eq!(result, Some(InputResult::Submit("test".to_string())));
584 }
585
586 #[test]
587 fn test_input_submit_empty() {
588 let mut state = InputState::new(None);
589 let result = state.handle(InputAction::Submit);
590 assert_eq!(result, None); }
592
593 #[test]
594 fn test_completions_filter() {
595 let items = vec![
596 "github:".to_string(),
597 "gitlab:".to_string(),
598 "git+https://".to_string(),
599 ];
600 let mut state = InputState::with_completions(None, items);
601
602 assert!(!state.has_visible_completions());
604
605 state.handle(InputAction::Insert('g'));
607 state.handle(InputAction::Insert('i'));
608 state.handle(InputAction::Insert('t'));
609 assert!(state.has_visible_completions());
610 assert_eq!(state.filtered_completions().len(), 2);
612
613 state.handle(InputAction::Insert('h'));
615 state.handle(InputAction::Insert('u'));
616 state.handle(InputAction::Insert('b'));
617 assert!(state.has_visible_completions());
618 assert_eq!(state.filtered_completions().len(), 1);
619 assert_eq!(state.filtered_completions()[0].text, "github:");
620
621 state.handle(InputAction::Insert('z'));
623 assert!(!state.has_visible_completions());
624 }
625
626 #[test]
627 fn test_completions_navigation() {
628 let items = vec!["alpha".to_string(), "able".to_string(), "about".to_string()];
630 let mut state = InputState::with_completions(None, items);
631
632 state.handle(InputAction::Insert('a'));
634 assert!(state.has_visible_completions());
635 assert_eq!(state.selected_index(), Some(0));
637
638 state.handle(InputAction::CompletionDown);
640 assert_eq!(state.selected_index(), Some(1));
641
642 state.handle(InputAction::CompletionUp);
644 assert_eq!(state.selected_index(), Some(0));
645
646 state.handle(InputAction::CompletionUp);
648 assert_eq!(state.selected_index(), Some(2));
649
650 state.handle(InputAction::CompletionDown);
652 assert_eq!(state.selected_index(), Some(0));
653 }
654
655 #[test]
656 fn test_completions_accept() {
657 let items = vec!["github:".to_string(), "gitlab:".to_string()];
658 let mut state = InputState::with_completions(None, items);
659
660 state.handle(InputAction::Insert('g'));
662 assert!(state.has_visible_completions());
663 assert_eq!(state.selected_index(), Some(0));
664
665 state.handle(InputAction::Accept);
667 assert_eq!(state.text(), "github:");
668 assert!(!state.has_visible_completions());
669 }
670
671 #[test]
672 fn test_completions_cancel_hides() {
673 let items = vec!["github:".to_string()];
674 let mut state = InputState::with_completions(None, items);
675
676 state.handle(InputAction::Insert('g'));
678 assert!(state.has_visible_completions());
679
680 let result = state.handle(InputAction::Cancel);
682 assert_eq!(result, None);
683 assert!(!state.has_visible_completions());
684
685 let result = state.handle(InputAction::Cancel);
687 assert_eq!(result, Some(InputResult::Cancel));
688 }
689
690 #[test]
691 fn test_query_param_completions() {
692 let items = vec!["github:".to_string()];
693 let mut state = InputState::with_completions(None, items);
694
695 for c in "github:nixos/nixpkgs?".chars() {
697 state.handle(InputAction::Insert(c));
698 }
699
700 assert!(state.has_visible_completions());
702 let completions = state.filtered_completions();
703 assert!(!completions.is_empty());
704 assert!(completions.iter().any(|c| c.text.contains("ref")));
705
706 state.handle(InputAction::Insert('r'));
708 assert!(state.has_visible_completions());
709 let completions = state.filtered_completions();
710 assert!(completions.iter().all(|c| c.text.contains("r")));
711
712 state.handle(InputAction::CompletionDown);
714 state.handle(InputAction::Accept);
715
716 assert!(state.text().starts_with("github:nixos/nixpkgs?"));
718 assert!(state.text().contains("="));
719 assert!(!state.has_visible_completions());
720 }
721
722 #[test]
723 fn test_query_param_no_completions_without_uri() {
724 let items = vec!["github:".to_string()];
725 let mut state = InputState::with_completions(None, items);
726
727 state.handle(InputAction::Insert('?'));
729 assert!(!state.has_visible_completions());
731 }
732
733 #[test]
734 fn test_fuzzy_matching() {
735 let items = vec![
736 "github:".to_string(),
737 "github:mic92/vmsh".to_string(),
738 "github:nixos/nixpkgs".to_string(),
739 ];
740 let mut state = InputState::with_completions(None, items);
741
742 state.handle(InputAction::Insert('v'));
744 state.handle(InputAction::Insert('m'));
745 assert!(state.has_visible_completions());
746 let completions = state.filtered_completions();
747 assert!(completions.iter().any(|c| c.text.contains("vmsh")));
748
749 let mut state2 = InputState::with_completions(
751 None,
752 vec![
753 "github:".to_string(),
754 "github:mic92/vmsh".to_string(),
755 "github:nixos/nixpkgs".to_string(),
756 ],
757 );
758 for c in "nix".chars() {
759 state2.handle(InputAction::Insert(c));
760 }
761 assert!(state2.has_visible_completions());
762 let completions = state2.filtered_completions();
763 assert!(completions.iter().any(|c| c.text.contains("nixpkgs")));
764 }
765
766 #[test]
767 fn test_completion_scrolling() {
768 let items = vec![
770 "item0".to_string(),
771 "item1".to_string(),
772 "item2".to_string(),
773 "item3".to_string(),
774 ];
775 let mut state = InputState::with_completions(None, items);
776
777 state.handle(InputAction::Insert('i'));
779 assert!(state.has_visible_completions());
780
781 assert_eq!(state.filtered_completions().len(), 2);
783 assert_eq!(state.filtered_completions()[0].text, "item0");
784 assert_eq!(state.filtered_completions()[1].text, "item1");
785 assert_eq!(state.visible_selection_index(), Some(0));
786
787 state.handle(InputAction::CompletionDown);
789 assert_eq!(state.selected_index(), Some(1));
790 assert_eq!(state.visible_selection_index(), Some(1));
791 assert_eq!(state.filtered_completions()[0].text, "item0");
792
793 state.handle(InputAction::CompletionDown);
795 assert_eq!(state.selected_index(), Some(2));
796 assert_eq!(state.visible_selection_index(), Some(1)); assert_eq!(state.filtered_completions()[0].text, "item1"); state.handle(InputAction::CompletionDown);
802 assert_eq!(state.selected_index(), Some(3));
803 assert_eq!(state.filtered_completions()[0].text, "item2"); state.handle(InputAction::CompletionDown);
807 assert_eq!(state.selected_index(), Some(0));
808 assert_eq!(state.visible_selection_index(), Some(0));
809 assert_eq!(state.filtered_completions()[0].text, "item0"); state.handle(InputAction::CompletionUp);
813 assert_eq!(state.selected_index(), Some(3));
814 assert_eq!(state.filtered_completions()[0].text, "item2");
816 }
817}