reovim_plugin_microscope/microscope/
state.rs

1//! Microscope state management
2
3use {
4    super::{
5        item::MicroscopeItem,
6        layout::{LayoutBounds, LayoutConfig, calculate_layout, visible_item_count},
7    },
8    reovim_core::highlight::Style,
9};
10
11/// Mode for the prompt input (vim-style)
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub enum PromptMode {
14    /// Insert mode - typing adds characters to query
15    #[default]
16    Insert,
17    /// Normal mode - j/k navigation, vim motions in prompt
18    Normal,
19}
20
21impl PromptMode {
22    /// Get display string for mode
23    #[must_use]
24    pub const fn display(&self) -> &'static str {
25        match self {
26            Self::Insert => "[I]",
27            Self::Normal => "[N]",
28        }
29    }
30}
31
32/// Loading state for async operations
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub enum LoadingState {
35    /// Not loading
36    #[default]
37    Idle,
38    /// Loading items (show spinner)
39    Loading,
40    /// Matching in progress
41    Matching,
42}
43
44impl LoadingState {
45    /// Get spinner character for current state
46    #[must_use]
47    pub const fn spinner(&self) -> Option<char> {
48        match self {
49            Self::Idle => None,
50            Self::Loading | Self::Matching => Some('⟳'),
51        }
52    }
53
54    /// Check if currently loading
55    #[must_use]
56    pub const fn is_loading(&self) -> bool {
57        !matches!(self, Self::Idle)
58    }
59}
60
61/// A styled span within a line (for syntax highlighting)
62#[derive(Debug, Clone)]
63pub struct StyledSpan {
64    /// Start column (0-indexed, byte offset)
65    pub start: usize,
66    /// End column (exclusive, byte offset)
67    pub end: usize,
68    /// Style to apply
69    pub style: Style,
70}
71
72impl StyledSpan {
73    /// Create a new styled span
74    #[must_use]
75    pub const fn new(start: usize, end: usize, style: Style) -> Self {
76        Self { start, end, style }
77    }
78}
79
80/// Preview content for the selected item
81#[derive(Debug, Clone, Default)]
82pub struct PreviewContent {
83    /// Lines of the preview content
84    pub lines: Vec<String>,
85    /// Line to highlight (0-indexed)
86    pub highlight_line: Option<usize>,
87    /// File extension for syntax highlighting
88    pub syntax: Option<String>,
89    /// Title for the preview panel
90    pub title: Option<String>,
91    /// Styled spans per line (for syntax highlighting)
92    /// Each inner Vec contains spans for one line, sorted by start position
93    pub styled_lines: Option<Vec<Vec<StyledSpan>>>,
94}
95
96impl PreviewContent {
97    /// Create a new preview content
98    #[must_use]
99    pub const fn new(lines: Vec<String>) -> Self {
100        Self {
101            lines,
102            highlight_line: None,
103            syntax: None,
104            title: None,
105            styled_lines: None,
106        }
107    }
108
109    /// Set styled lines for syntax highlighting
110    #[must_use]
111    pub fn with_styled_lines(mut self, styled_lines: Vec<Vec<StyledSpan>>) -> Self {
112        self.styled_lines = Some(styled_lines);
113        self
114    }
115
116    /// Set the line to highlight
117    #[must_use]
118    pub const fn with_highlight_line(mut self, line: usize) -> Self {
119        self.highlight_line = Some(line);
120        self
121    }
122
123    /// Set the syntax type
124    #[must_use]
125    pub fn with_syntax(mut self, syntax: impl Into<String>) -> Self {
126        self.syntax = Some(syntax.into());
127        self
128    }
129
130    /// Set the preview title
131    #[must_use]
132    pub fn with_title(mut self, title: impl Into<String>) -> Self {
133        self.title = Some(title.into());
134        self
135    }
136}
137
138/// Legacy layout - kept for compatibility during transition
139#[derive(Debug, Clone, Default)]
140pub struct MicroscopeLayout {
141    /// X position of the panel
142    pub x: u16,
143    /// Y position of the panel
144    pub y: u16,
145    /// Width of the results panel
146    pub width: u16,
147    /// Height of the panel
148    pub height: u16,
149    /// Width of the preview panel (if enabled)
150    pub preview_width: Option<u16>,
151    /// Maximum visible items
152    pub visible_items: usize,
153}
154
155/// State of the microscope fuzzy finder
156#[derive(Debug, Clone, Default)]
157pub struct MicroscopeState {
158    /// Whether microscope is currently active/visible
159    pub active: bool,
160    /// Current search query
161    pub query: String,
162    /// Cursor position in the query
163    pub cursor_pos: usize,
164    /// All items from the picker (unfiltered)
165    pub all_items: Vec<MicroscopeItem>,
166    /// Current list of items (filtered/sorted)
167    pub items: Vec<MicroscopeItem>,
168    /// Currently selected item index
169    pub selected_index: usize,
170    /// Scroll offset for long lists
171    pub scroll_offset: usize,
172    /// Name of the current picker
173    pub picker_name: String,
174    /// Title to display
175    pub title: String,
176    /// Prompt string
177    pub prompt: String,
178    /// Preview content (if available)
179    pub preview: Option<PreviewContent>,
180    /// Legacy layout configuration (for compatibility)
181    pub layout: MicroscopeLayout,
182    /// Whether preview is enabled
183    pub preview_enabled: bool,
184    /// Prompt mode (Insert/Normal)
185    pub prompt_mode: PromptMode,
186    /// Loading state
187    pub loading_state: LoadingState,
188    /// Helix-style layout bounds
189    pub bounds: LayoutBounds,
190    /// Layout configuration
191    pub layout_config: LayoutConfig,
192    /// Total item count (from matcher)
193    pub total_count: u32,
194    /// Matched item count (from matcher)
195    pub matched_count: u32,
196}
197
198impl MicroscopeState {
199    /// Create a new empty microscope state
200    #[must_use]
201    pub fn new() -> Self {
202        Self {
203            active: false,
204            query: String::new(),
205            cursor_pos: 0,
206            all_items: Vec::new(),
207            items: Vec::new(),
208            selected_index: 0,
209            scroll_offset: 0,
210            picker_name: String::new(),
211            title: String::new(),
212            prompt: "> ".to_string(),
213            preview: None,
214            layout: MicroscopeLayout::default(),
215            preview_enabled: true,
216            prompt_mode: PromptMode::Insert,
217            loading_state: LoadingState::Idle,
218            bounds: LayoutBounds::default(),
219            layout_config: LayoutConfig::default(),
220            total_count: 0,
221            matched_count: 0,
222        }
223    }
224
225    /// Open microscope with a picker
226    ///
227    /// Starts in Normal mode for j/k navigation. Press 'i' to enter Insert mode.
228    pub fn open(&mut self, picker_name: &str, title: &str, prompt: &str) {
229        self.active = true;
230        self.query.clear();
231        self.cursor_pos = 0;
232        self.all_items.clear();
233        self.items.clear();
234        self.selected_index = 0;
235        self.scroll_offset = 0;
236        self.picker_name = picker_name.to_string();
237        self.title = title.to_string();
238        self.prompt = prompt.to_string();
239        self.preview = None;
240        self.prompt_mode = PromptMode::Normal; // Start in Normal mode
241        self.loading_state = LoadingState::Loading;
242        self.total_count = 0;
243        self.matched_count = 0;
244    }
245
246    /// Close microscope
247    pub fn close(&mut self) {
248        self.active = false;
249        self.query.clear();
250        self.cursor_pos = 0;
251        self.all_items.clear();
252        self.items.clear();
253        self.selected_index = 0;
254        self.scroll_offset = 0;
255        self.picker_name.clear();
256        self.preview = None;
257        self.prompt_mode = PromptMode::Insert;
258        self.loading_state = LoadingState::Idle;
259    }
260
261    /// Enter insert mode
262    pub fn enter_insert(&mut self) {
263        self.prompt_mode = PromptMode::Insert;
264    }
265
266    /// Enter normal mode
267    pub fn enter_normal(&mut self) {
268        self.prompt_mode = PromptMode::Normal;
269    }
270
271    /// Toggle between insert and normal mode
272    pub fn toggle_mode(&mut self) {
273        self.prompt_mode = match self.prompt_mode {
274            PromptMode::Insert => PromptMode::Normal,
275            PromptMode::Normal => PromptMode::Insert,
276        };
277    }
278
279    /// Set loading state
280    pub fn set_loading(&mut self, state: LoadingState) {
281        self.loading_state = state;
282    }
283
284    /// Update bounds from screen dimensions
285    pub fn update_bounds(&mut self, screen_width: u16, screen_height: u16) {
286        self.bounds = calculate_layout(screen_width, screen_height, &self.layout_config);
287        // Also update legacy layout for compatibility
288        self.layout.x = self.bounds.results.x;
289        self.layout.y = self.bounds.results.y;
290        self.layout.width = self.bounds.results.width;
291        self.layout.height = self.bounds.results.height;
292        self.layout.preview_width = self.bounds.preview.map(|p| p.width);
293        self.layout.visible_items = visible_item_count(&self.bounds.results);
294    }
295
296    /// Get status line text
297    #[must_use]
298    pub fn status_text(&self) -> String {
299        let count_text = if self.total_count == 0 {
300            "No items".to_string()
301        } else if self.matched_count == self.total_count {
302            format!("{} items", self.total_count)
303        } else {
304            format!("{}/{} matched", self.matched_count, self.total_count)
305        };
306
307        let spinner = self
308            .loading_state
309            .spinner()
310            .map_or(String::new(), |s| format!(" {s}"));
311
312        format!("{count_text}{spinner}")
313    }
314
315    /// Update items from search results (initial load - stores in both `all_items` and items)
316    pub fn update_items(&mut self, items: Vec<MicroscopeItem>) {
317        self.all_items = items.clone();
318        self.items = items;
319        self.selected_index = 0;
320        self.scroll_offset = 0;
321        self.ensure_selected_visible();
322    }
323
324    /// Update filtered items only (for filtering - keeps `all_items` unchanged)
325    pub fn update_filtered_items(&mut self, items: Vec<MicroscopeItem>) {
326        self.items = items;
327        self.selected_index = 0;
328        self.scroll_offset = 0;
329        self.ensure_selected_visible();
330    }
331
332    /// Insert a character at cursor position
333    pub fn insert_char(&mut self, c: char) {
334        self.query.insert(self.cursor_pos, c);
335        self.cursor_pos += c.len_utf8();
336        self.apply_filter();
337    }
338
339    /// Delete character before cursor
340    pub fn delete_char(&mut self) {
341        if self.cursor_pos > 0 {
342            // Find the previous char boundary
343            let prev_pos = self.query[..self.cursor_pos]
344                .char_indices()
345                .last()
346                .map_or(0, |(i, _)| i);
347            self.query.remove(prev_pos);
348            self.cursor_pos = prev_pos;
349            self.apply_filter();
350        }
351    }
352
353    /// Apply query filter to items
354    ///
355    /// Simple substring filter. For fuzzy matching, use `MicroscopeMatcher`.
356    fn apply_filter(&mut self) {
357        if self.query.is_empty() {
358            // Show all items when query is empty
359            self.items = self.all_items.clone();
360        } else {
361            // Simple case-insensitive substring filter
362            let query_lower = self.query.to_lowercase();
363            self.items = self
364                .all_items
365                .iter()
366                .filter(|item| item.match_text().to_lowercase().contains(&query_lower))
367                .cloned()
368                .collect();
369        }
370        self.selected_index = 0;
371        self.scroll_offset = 0;
372        self.matched_count = self.items.len() as u32;
373        self.total_count = self.all_items.len() as u32;
374        self.ensure_selected_visible();
375    }
376
377    /// Move cursor left
378    pub fn cursor_left(&mut self) {
379        if self.cursor_pos > 0 {
380            self.cursor_pos = self.query[..self.cursor_pos]
381                .char_indices()
382                .last()
383                .map_or(0, |(i, _)| i);
384        }
385    }
386
387    /// Move cursor right
388    pub fn cursor_right(&mut self) {
389        if self.cursor_pos < self.query.len() {
390            let query_len = self.query.len();
391            self.cursor_pos = self.query[self.cursor_pos..]
392                .char_indices()
393                .nth(1)
394                .map_or(query_len, |(i, _)| self.cursor_pos + i);
395        }
396    }
397
398    /// Move cursor to start
399    pub const fn cursor_home(&mut self) {
400        self.cursor_pos = 0;
401    }
402
403    /// Move cursor to end
404    #[allow(clippy::missing_const_for_fn)] // String::len is not const-stable
405    pub fn cursor_end(&mut self) {
406        self.cursor_pos = self.query.len();
407    }
408
409    /// Move cursor forward one word
410    pub fn word_forward(&mut self) {
411        if self.cursor_pos >= self.query.len() {
412            return;
413        }
414
415        let chars: Vec<char> = self.query.chars().collect();
416        let mut pos = 0;
417        let mut idx = 0;
418
419        // Find current character index
420        for (i, c) in self.query.char_indices() {
421            if i >= self.cursor_pos {
422                idx = pos;
423                break;
424            }
425            pos += 1;
426            if i + c.len_utf8() > self.cursor_pos {
427                idx = pos;
428                break;
429            }
430        }
431
432        // Skip current word (non-whitespace)
433        while idx < chars.len() && !chars[idx].is_whitespace() {
434            idx += 1;
435        }
436        // Skip whitespace
437        while idx < chars.len() && chars[idx].is_whitespace() {
438            idx += 1;
439        }
440
441        // Convert back to byte position
442        self.cursor_pos = chars[..idx].iter().map(|c| c.len_utf8()).sum();
443    }
444
445    /// Move cursor backward one word
446    pub fn word_backward(&mut self) {
447        if self.cursor_pos == 0 {
448            return;
449        }
450
451        let chars: Vec<char> = self.query.chars().collect();
452
453        // Find current character index
454        let mut idx: usize = 0;
455        let mut byte_pos = 0;
456        for c in &chars {
457            if byte_pos >= self.cursor_pos {
458                break;
459            }
460            byte_pos += c.len_utf8();
461            idx += 1;
462        }
463
464        idx = idx.saturating_sub(1);
465
466        // Skip whitespace backward
467        while idx > 0 && chars[idx].is_whitespace() {
468            idx -= 1;
469        }
470        // Skip current word backward (non-whitespace)
471        while idx > 0 && !chars[idx - 1].is_whitespace() {
472            idx -= 1;
473        }
474
475        // Convert back to byte position
476        self.cursor_pos = chars[..idx].iter().map(|c| c.len_utf8()).sum();
477    }
478
479    /// Clear the query
480    pub fn clear_query(&mut self) {
481        self.query.clear();
482        self.cursor_pos = 0;
483        self.apply_filter();
484    }
485
486    /// Delete word before cursor
487    pub fn delete_word(&mut self) {
488        if self.cursor_pos == 0 {
489            return;
490        }
491
492        let old_pos = self.cursor_pos;
493        self.word_backward();
494        let new_pos = self.cursor_pos;
495
496        // Delete characters between new_pos and old_pos
497        self.query.drain(new_pos..old_pos);
498        self.apply_filter();
499    }
500
501    /// Select next item
502    pub fn select_next(&mut self) {
503        if !self.items.is_empty() {
504            self.selected_index = (self.selected_index + 1) % self.items.len();
505            self.ensure_selected_visible();
506        }
507    }
508
509    /// Select previous item
510    pub fn select_prev(&mut self) {
511        if !self.items.is_empty() {
512            self.selected_index = self
513                .selected_index
514                .checked_sub(1)
515                .unwrap_or(self.items.len() - 1);
516            self.ensure_selected_visible();
517        }
518    }
519
520    /// Page down
521    pub fn page_down(&mut self) {
522        if !self.items.is_empty() {
523            let page_size = self.layout.visible_items.max(1);
524            self.selected_index = (self.selected_index + page_size).min(self.items.len() - 1);
525            self.ensure_selected_visible();
526        }
527    }
528
529    /// Page up
530    pub fn page_up(&mut self) {
531        if !self.items.is_empty() {
532            let page_size = self.layout.visible_items.max(1);
533            self.selected_index = self.selected_index.saturating_sub(page_size);
534            self.ensure_selected_visible();
535        }
536    }
537
538    /// Move to first item
539    pub fn move_to_first(&mut self) {
540        if !self.items.is_empty() {
541            self.selected_index = 0;
542            self.ensure_selected_visible();
543        }
544    }
545
546    /// Move to last item
547    pub fn move_to_last(&mut self) {
548        if !self.items.is_empty() {
549            self.selected_index = self.items.len() - 1;
550            self.ensure_selected_visible();
551        }
552    }
553
554    /// Get currently selected item
555    #[must_use]
556    pub fn selected_item(&self) -> Option<&MicroscopeItem> {
557        self.items.get(self.selected_index)
558    }
559
560    /// Check if microscope is active and visible
561    #[must_use]
562    pub const fn is_visible(&self) -> bool {
563        self.active
564    }
565
566    /// Ensure selected item is visible in the viewport
567    fn ensure_selected_visible(&mut self) {
568        let visible = self.layout.visible_items.max(1);
569
570        // Scroll down if selected is below visible area
571        if self.selected_index >= self.scroll_offset + visible {
572            self.scroll_offset = self.selected_index - visible + 1;
573        }
574
575        // Scroll up if selected is above visible area
576        if self.selected_index < self.scroll_offset {
577            self.scroll_offset = self.selected_index;
578        }
579    }
580
581    /// Get visible items slice
582    #[must_use]
583    pub fn visible_items(&self) -> &[MicroscopeItem] {
584        let start = self.scroll_offset;
585        let end = (start + self.layout.visible_items).min(self.items.len());
586        &self.items[start..end]
587    }
588
589    /// Update preview content
590    pub fn set_preview(&mut self, content: Option<PreviewContent>) {
591        self.preview = content;
592    }
593
594    /// Calculate layout based on screen dimensions
595    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
596    pub fn calculate_layout(&mut self, screen_width: u16, screen_height: u16) {
597        // 80% width, 70% height
598        let total_width = (f32::from(screen_width) * 0.8) as u16;
599        let height = (f32::from(screen_height) * 0.7) as u16;
600
601        let x = (screen_width - total_width) / 2;
602        let y = (screen_height - height) / 2;
603
604        if self.preview_enabled {
605            // 40% for results, 60% for preview
606            let results_width = (f32::from(total_width) * 0.4) as u16;
607            let preview_width = total_width - results_width - 1; // -1 for separator
608
609            self.layout = MicroscopeLayout {
610                x,
611                y,
612                width: results_width,
613                height,
614                preview_width: Some(preview_width),
615                visible_items: usize::from(height.saturating_sub(4)), // -4 for borders and prompt
616            };
617        } else {
618            self.layout = MicroscopeLayout {
619                x,
620                y,
621                width: total_width,
622                height,
623                preview_width: None,
624                visible_items: usize::from(height.saturating_sub(4)),
625            };
626        }
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use {super::*, crate::microscope::item::MicroscopeData, std::path::PathBuf};
633
634    fn sample_items() -> Vec<MicroscopeItem> {
635        vec![
636            MicroscopeItem::new(
637                "1",
638                "file1.rs",
639                MicroscopeData::FilePath(PathBuf::from("file1.rs")),
640                "files",
641            ),
642            MicroscopeItem::new(
643                "2",
644                "file2.rs",
645                MicroscopeData::FilePath(PathBuf::from("file2.rs")),
646                "files",
647            ),
648            MicroscopeItem::new(
649                "3",
650                "file3.rs",
651                MicroscopeData::FilePath(PathBuf::from("file3.rs")),
652                "files",
653            ),
654        ]
655    }
656
657    #[test]
658    fn test_new_state() {
659        let state = MicroscopeState::new();
660        assert!(!state.active);
661        assert!(state.query.is_empty());
662        assert_eq!(state.cursor_pos, 0);
663        assert!(state.items.is_empty());
664    }
665
666    #[test]
667    fn test_open_close() {
668        let mut state = MicroscopeState::new();
669        state.open("files", "Find Files", "Files> ");
670
671        assert!(state.active);
672        assert_eq!(state.picker_name, "files");
673        assert_eq!(state.title, "Find Files");
674        assert_eq!(state.prompt, "Files> ");
675
676        state.close();
677        assert!(!state.active);
678        assert!(state.picker_name.is_empty());
679    }
680
681    #[test]
682    fn test_insert_delete() {
683        let mut state = MicroscopeState::new();
684        state.open("files", "Test", "> ");
685
686        state.insert_char('h');
687        state.insert_char('e');
688        state.insert_char('l');
689        state.insert_char('l');
690        state.insert_char('o');
691
692        assert_eq!(state.query, "hello");
693        assert_eq!(state.cursor_pos, 5);
694
695        state.delete_char();
696        assert_eq!(state.query, "hell");
697        assert_eq!(state.cursor_pos, 4);
698    }
699
700    #[test]
701    fn test_cursor_movement() {
702        let mut state = MicroscopeState::new();
703        state.open("files", "Test", "> ");
704        state.query = "hello".to_string();
705        state.cursor_pos = 3;
706
707        state.cursor_left();
708        assert_eq!(state.cursor_pos, 2);
709
710        state.cursor_right();
711        assert_eq!(state.cursor_pos, 3);
712
713        state.cursor_home();
714        assert_eq!(state.cursor_pos, 0);
715
716        state.cursor_end();
717        assert_eq!(state.cursor_pos, 5);
718    }
719
720    #[test]
721    fn test_selection() {
722        let mut state = MicroscopeState::new();
723        state.open("files", "Test", "> ");
724        state.layout.visible_items = 10;
725        state.update_items(sample_items());
726
727        assert_eq!(state.selected_index, 0);
728
729        state.select_next();
730        assert_eq!(state.selected_index, 1);
731
732        state.select_next();
733        assert_eq!(state.selected_index, 2);
734
735        state.select_next(); // Wraps
736        assert_eq!(state.selected_index, 0);
737
738        state.select_prev(); // Wraps back
739        assert_eq!(state.selected_index, 2);
740    }
741}