flake_edit/tui/components/input/
model.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
3use nucleo_matcher::{Config, Matcher, Utf32Str};
4
5/// Actions for text input UI
6#[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    /// Move completion selection up
21    CompletionUp,
22    /// Move completion selection down
23    CompletionDown,
24    /// Accept current completion (Tab)
25    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/// Result from input state machine
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum InputResult {
58    Submit(String),
59    Cancel,
60}
61
62/// Characters that act as word boundaries for cursor navigation
63const WORD_BOUNDARIES: &[char] = &[':', '/', '?', '=', '&', '#', '@'];
64
65/// Maximum number of completions to display (used by both model and view)
66pub const MAX_VISIBLE_COMPLETIONS: usize = 2;
67
68/// Query parameters available for flake URIs: (param, description)
69const 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/// Parsed query parameter context from a flake URI
81#[derive(Debug, Clone)]
82struct QueryContext {
83    /// Position where completions should be anchored for rendering
84    anchor: usize,
85    /// End position for the base string (after ? or &)
86    base_end: usize,
87    /// The partial param name being typed (e.g., "ref" when typing "?ref")
88    param_prefix: String,
89}
90
91impl QueryContext {
92    /// Parse query context from input, returns None if not in query param mode
93    fn parse(input: &str) -> Option<Self> {
94        // Must have a URI-like pattern first (contains : followed by content)
95        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            // Only if we haven't completed the param (no = yet)
106            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            // No & and no = means we're typing the first param name
116            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    /// Get the base input for appending a query param (everything up to and including ? or &)
127    fn base<'a>(&self, input: &'a str) -> &'a str {
128        &input[..self.base_end]
129    }
130}
131
132/// A completion item with text and optional description
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct CompletionItem {
135    pub text: String,
136    pub description: Option<String>,
137    /// Indices of matched characters (for highlighting)
138    pub match_indices: Vec<u32>,
139}
140
141/// State for completion dropdown
142#[derive(Debug, Clone)]
143pub struct CompletionState {
144    /// All available completion items (URI types)
145    items: Vec<String>,
146    /// Filtered items based on current input prefix
147    filtered: Vec<CompletionItem>,
148    /// Currently selected index in filtered list
149    selected: Option<usize>,
150    /// Scroll offset for the visible window
151    scroll_offset: usize,
152    /// Whether the completion dropdown is visible
153    visible: bool,
154    /// Query context if in query param mode, None for URI mode
155    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    /// Filter completions for query parameters (prefix matching)
197    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    /// Filter completions for URIs (fuzzy matching with nucleo)
215    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    /// Update selection and scroll state after filtering
245    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        // Adjust scroll to keep selection visible
284        if new_selected == 0 {
285            // Wrapped to top
286            self.scroll_offset = 0;
287        } else if new_selected >= self.scroll_offset + MAX_VISIBLE_COMPLETIONS {
288            // Selection below visible window
289            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        // Adjust scroll to keep selection visible
305        if new_selected == self.filtered.len() - 1 {
306            // Wrapped to bottom
307            self.scroll_offset = self.filtered.len().saturating_sub(MAX_VISIBLE_COMPLETIONS);
308        } else if new_selected < self.scroll_offset {
309            // Selection above visible window
310            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/// Text input state machine
328#[derive(Debug, Clone)]
329pub struct InputState {
330    input: String,
331    cursor: usize,
332    /// Optional completion state (None means completions disabled)
333    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    /// Create input state with completions enabled
348    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        // Filter based on initial input
353        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    // Completion accessors for the view
376
377    /// Whether completions are enabled and visible with items to show
378    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    /// Get the filtered completion items (visible window with scroll offset)
385    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    /// Get the currently selected completion index (absolute)
398    pub fn selected_index(&self) -> Option<usize> {
399        self.completion.as_ref().and_then(|c| c.selected)
400    }
401
402    /// Get the selected index relative to the visible window (for rendering)
403    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    /// Get the character position where completions should be anchored
410    /// For query params, this is at the ?; for normal completions, it's 0
411    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    /// Update completions filter based on current input
420    fn update_completions(&mut self) {
421        if let Some(ref mut comp) = self.completion {
422            comp.filter(&self.input);
423        }
424    }
425
426    /// Accept the currently selected completion
427    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                // Append query param: text is like "?ref=", we want "ref=" to append
433                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    /// Handle an input action, returns Some if the interaction is complete
447    pub fn handle(&mut self, action: InputAction) -> Option<InputResult> {
448        match action {
449            InputAction::Submit => {
450                // Enter always submits the user's typed input.
451                // Use Tab to accept a completion instead.
452                if !self.input.is_empty() {
453                    return Some(InputResult::Submit(self.input.clone()));
454                }
455            }
456            InputAction::Cancel => {
457                // If completions are visible, hide them first
458                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                // Tab: accept if selected, otherwise select first
518                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(); // Select first item
526                }
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); // Cursor at end
561    }
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); // Cannot submit empty
591    }
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        // No completions visible when input is empty
603        assert!(!state.has_visible_completions());
604
605        // Type "git" - should show matches (limited to visible window of 2)
606        state.handle(InputAction::Insert('g'));
607        state.handle(InputAction::Insert('i'));
608        state.handle(InputAction::Insert('t'));
609        assert!(state.has_visible_completions());
610        // Only 2 visible at once, but all 3 match
611        assert_eq!(state.filtered_completions().len(), 2);
612
613        // Type "hub" -> "github" - should show only "github:" (fuzzy)
614        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        // Type "z" -> "githubz" - no matches
622        state.handle(InputAction::Insert('z'));
623        assert!(!state.has_visible_completions());
624    }
625
626    #[test]
627    fn test_completions_navigation() {
628        // Use items that all fuzzy-match a common pattern
629        let items = vec!["alpha".to_string(), "able".to_string(), "about".to_string()];
630        let mut state = InputState::with_completions(None, items);
631
632        // Type "a" - all items match fuzzy
633        state.handle(InputAction::Insert('a'));
634        assert!(state.has_visible_completions());
635        // First item auto-selected
636        assert_eq!(state.selected_index(), Some(0));
637
638        // Down moves to second
639        state.handle(InputAction::CompletionDown);
640        assert_eq!(state.selected_index(), Some(1));
641
642        // Up moves back to first
643        state.handle(InputAction::CompletionUp);
644        assert_eq!(state.selected_index(), Some(0));
645
646        // Up from first wraps to last
647        state.handle(InputAction::CompletionUp);
648        assert_eq!(state.selected_index(), Some(2));
649
650        // Down from last wraps to first
651        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        // Type "g" to show completions - first item is auto-selected
661        state.handle(InputAction::Insert('g'));
662        assert!(state.has_visible_completions());
663        assert_eq!(state.selected_index(), Some(0));
664
665        // Accept with Tab (no need to press Down, first item already selected)
666        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        // Type to show completions
677        state.handle(InputAction::Insert('g'));
678        assert!(state.has_visible_completions());
679
680        // Cancel should hide completions, not return Cancel result
681        let result = state.handle(InputAction::Cancel);
682        assert_eq!(result, None);
683        assert!(!state.has_visible_completions());
684
685        // Cancel again should return Cancel result
686        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        // Type a full URI then ?
696        for c in "github:nixos/nixpkgs?".chars() {
697            state.handle(InputAction::Insert(c));
698        }
699
700        // Should show query param completions
701        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        // Type "r" to filter to ref/rev
707        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        // Select and accept
713        state.handle(InputAction::CompletionDown);
714        state.handle(InputAction::Accept);
715
716        // Should have appended the param
717        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        // Type just "?" without a URI - should not show query params
728        state.handle(InputAction::Insert('?'));
729        // Should show URI completions (none match "?") so not visible
730        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        // Type "vm" - should fuzzy match "github:mic92/vmsh"
743        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        // Type "nix" - should fuzzy match nixpkgs
750        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        // Create more items than MAX_VISIBLE_COMPLETIONS (2)
769        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        // Type to show all items
778        state.handle(InputAction::Insert('i'));
779        assert!(state.has_visible_completions());
780
781        // Should show first 2 items (scroll_offset = 0)
782        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        // Navigate down once - still in first window
788        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        // Navigate down one more - should scroll
794        state.handle(InputAction::CompletionDown);
795        assert_eq!(state.selected_index(), Some(2));
796        // Window should have scrolled
797        assert_eq!(state.visible_selection_index(), Some(1)); // relative to scroll offset
798        assert_eq!(state.filtered_completions()[0].text, "item1"); // scrolled by 1
799
800        // Navigate down more
801        state.handle(InputAction::CompletionDown);
802        assert_eq!(state.selected_index(), Some(3));
803        assert_eq!(state.filtered_completions()[0].text, "item2"); // scrolled so last 2 visible
804
805        // Navigate down wraps to top
806        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"); // scroll reset to top
810
811        // Navigate up from top wraps to bottom
812        state.handle(InputAction::CompletionUp);
813        assert_eq!(state.selected_index(), Some(3));
814        // Scroll should be at end (items 2-3 visible)
815        assert_eq!(state.filtered_completions()[0].text, "item2");
816    }
817}