1use {
4 super::{
5 item::MicroscopeItem,
6 layout::{LayoutBounds, LayoutConfig, calculate_layout, visible_item_count},
7 },
8 reovim_core::highlight::Style,
9};
10
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub enum PromptMode {
14 #[default]
16 Insert,
17 Normal,
19}
20
21impl PromptMode {
22 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub enum LoadingState {
35 #[default]
37 Idle,
38 Loading,
40 Matching,
42}
43
44impl LoadingState {
45 #[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 #[must_use]
56 pub const fn is_loading(&self) -> bool {
57 !matches!(self, Self::Idle)
58 }
59}
60
61#[derive(Debug, Clone)]
63pub struct StyledSpan {
64 pub start: usize,
66 pub end: usize,
68 pub style: Style,
70}
71
72impl StyledSpan {
73 #[must_use]
75 pub const fn new(start: usize, end: usize, style: Style) -> Self {
76 Self { start, end, style }
77 }
78}
79
80#[derive(Debug, Clone, Default)]
82pub struct PreviewContent {
83 pub lines: Vec<String>,
85 pub highlight_line: Option<usize>,
87 pub syntax: Option<String>,
89 pub title: Option<String>,
91 pub styled_lines: Option<Vec<Vec<StyledSpan>>>,
94}
95
96impl PreviewContent {
97 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Default)]
140pub struct MicroscopeLayout {
141 pub x: u16,
143 pub y: u16,
145 pub width: u16,
147 pub height: u16,
149 pub preview_width: Option<u16>,
151 pub visible_items: usize,
153}
154
155#[derive(Debug, Clone, Default)]
157pub struct MicroscopeState {
158 pub active: bool,
160 pub query: String,
162 pub cursor_pos: usize,
164 pub all_items: Vec<MicroscopeItem>,
166 pub items: Vec<MicroscopeItem>,
168 pub selected_index: usize,
170 pub scroll_offset: usize,
172 pub picker_name: String,
174 pub title: String,
176 pub prompt: String,
178 pub preview: Option<PreviewContent>,
180 pub layout: MicroscopeLayout,
182 pub preview_enabled: bool,
184 pub prompt_mode: PromptMode,
186 pub loading_state: LoadingState,
188 pub bounds: LayoutBounds,
190 pub layout_config: LayoutConfig,
192 pub total_count: u32,
194 pub matched_count: u32,
196}
197
198impl MicroscopeState {
199 #[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 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; self.loading_state = LoadingState::Loading;
242 self.total_count = 0;
243 self.matched_count = 0;
244 }
245
246 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 pub fn enter_insert(&mut self) {
263 self.prompt_mode = PromptMode::Insert;
264 }
265
266 pub fn enter_normal(&mut self) {
268 self.prompt_mode = PromptMode::Normal;
269 }
270
271 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 pub fn set_loading(&mut self, state: LoadingState) {
281 self.loading_state = state;
282 }
283
284 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 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 #[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 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 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 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 pub fn delete_char(&mut self) {
341 if self.cursor_pos > 0 {
342 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 fn apply_filter(&mut self) {
357 if self.query.is_empty() {
358 self.items = self.all_items.clone();
360 } else {
361 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 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 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 pub const fn cursor_home(&mut self) {
400 self.cursor_pos = 0;
401 }
402
403 #[allow(clippy::missing_const_for_fn)] pub fn cursor_end(&mut self) {
406 self.cursor_pos = self.query.len();
407 }
408
409 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 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 while idx < chars.len() && !chars[idx].is_whitespace() {
434 idx += 1;
435 }
436 while idx < chars.len() && chars[idx].is_whitespace() {
438 idx += 1;
439 }
440
441 self.cursor_pos = chars[..idx].iter().map(|c| c.len_utf8()).sum();
443 }
444
445 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 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 while idx > 0 && chars[idx].is_whitespace() {
468 idx -= 1;
469 }
470 while idx > 0 && !chars[idx - 1].is_whitespace() {
472 idx -= 1;
473 }
474
475 self.cursor_pos = chars[..idx].iter().map(|c| c.len_utf8()).sum();
477 }
478
479 pub fn clear_query(&mut self) {
481 self.query.clear();
482 self.cursor_pos = 0;
483 self.apply_filter();
484 }
485
486 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 self.query.drain(new_pos..old_pos);
498 self.apply_filter();
499 }
500
501 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 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 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 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 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 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 #[must_use]
556 pub fn selected_item(&self) -> Option<&MicroscopeItem> {
557 self.items.get(self.selected_index)
558 }
559
560 #[must_use]
562 pub const fn is_visible(&self) -> bool {
563 self.active
564 }
565
566 fn ensure_selected_visible(&mut self) {
568 let visible = self.layout.visible_items.max(1);
569
570 if self.selected_index >= self.scroll_offset + visible {
572 self.scroll_offset = self.selected_index - visible + 1;
573 }
574
575 if self.selected_index < self.scroll_offset {
577 self.scroll_offset = self.selected_index;
578 }
579 }
580
581 #[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 pub fn set_preview(&mut self, content: Option<PreviewContent>) {
591 self.preview = content;
592 }
593
594 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
596 pub fn calculate_layout(&mut self, screen_width: u16, screen_height: u16) {
597 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 let results_width = (f32::from(total_width) * 0.4) as u16;
607 let preview_width = total_width - results_width - 1; 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)), };
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(); assert_eq!(state.selected_index, 0);
737
738 state.select_prev(); assert_eq!(state.selected_index, 2);
740 }
741}