1mod highlight;
8mod state;
9mod theme;
10mod variables;
11
12mod _generated_bundled_themes;
13
14pub use theme::get_theme;
15pub use variables::{VariablePromptResult, prompt_variables};
16
17use std::io;
18use std::sync::LazyLock;
19use std::time::{Duration, Instant};
20
21use crossterm::event::{self, Event as CEvent, KeyCode, KeyEventKind, KeyModifiers};
22use fuzzy_matcher::FuzzyMatcher;
23use fuzzy_matcher::skim::SkimMatcherV2;
24use ratatui::text::{Line, Span};
25use ratatui::{
26 layout::{Constraint, Direction, Layout},
27 style::Style as TuiStyle,
28 widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation},
29};
30
31use crate::clipboard;
32use crate::utils::extract_variables_for_display;
33use crate::utils::has_unmatched_angle_bracket;
34use crate::utils::strip_escape_sequences;
35
36struct TerminalGuard;
39
40impl Drop for TerminalGuard {
41 fn drop(&mut self) {
42 let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
43 ratatui::restore();
44 }
45}
46
47use highlight::highlight_command;
48use state::{FilterState, SelectState, SortMode, is_ctrl_key};
49use theme::{style_fg, style_fg_bg};
50
51static TERMINATE: LazyLock<std::sync::Arc<std::sync::atomic::AtomicBool>> =
52 LazyLock::new(|| std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)));
53
54pub fn get_terminate() -> std::sync::Arc<std::sync::atomic::AtomicBool> {
55 TERMINATE.clone()
56}
57
58static MATCHER: LazyLock<SkimMatcherV2> = LazyLock::new(SkimMatcherV2::default);
59
60#[derive(Clone, Debug, PartialEq, Eq)]
61struct FilterRequest {
62 text: String,
63 text_lower: String,
64 include_tags: bool,
65 incremental: bool,
66}
67
68impl FilterRequest {
69 fn is_empty(&self) -> bool {
70 self.text.is_empty()
71 }
72
73 fn can_narrow_from(&self, previous: &Self) -> bool {
74 !previous.text.is_empty()
75 && self.incremental == previous.incremental
76 && self.include_tags == previous.include_tags
77 && self.text.starts_with(&previous.text)
78 }
79}
80
81fn current_filter_request(
82 filter: &str,
83 incremental_search: &str,
84 filter_state: &FilterState,
85 tag_filter_mode: bool,
86) -> FilterRequest {
87 if !incremental_search.is_empty() {
88 return FilterRequest {
89 text: incremental_search.to_string(),
90 text_lower: incremental_search.to_lowercase(),
91 include_tags: false,
92 incremental: true,
93 };
94 }
95
96 let has_main_filter = !filter.is_empty() || !filter_state.tag_filter_text.is_empty();
97 if !has_main_filter {
98 return FilterRequest {
99 text: String::new(),
100 text_lower: String::new(),
101 include_tags: false,
102 incremental: false,
103 };
104 }
105
106 let text = if tag_filter_mode {
107 &filter_state.tag_filter_text
108 } else {
109 filter
110 };
111 FilterRequest {
112 text: text.to_string(),
113 text_lower: text.to_lowercase(),
114 include_tags: tag_filter_mode || !filter_state.tag_filter_text.is_empty(),
115 incremental: false,
116 }
117}
118
119fn filter_request_would_narrow(
120 filter: &str,
121 incremental_search: &str,
122 filter_state: &FilterState,
123 tag_filter_mode: bool,
124 last_filter_request: Option<&FilterRequest>,
125) -> bool {
126 let request = current_filter_request(filter, incremental_search, filter_state, tag_filter_mode);
127 last_filter_request.is_some_and(|previous| request.can_narrow_from(previous))
128}
129
130fn filter_update_deadline_for_insert(
131 filter: &str,
132 incremental_search: &str,
133 filter_state: &FilterState,
134 tag_filter_mode: bool,
135 last_filter_request: Option<&FilterRequest>,
136) -> Option<Instant> {
137 if last_filter_request.is_none()
138 || filter_request_would_narrow(
139 filter,
140 incremental_search,
141 filter_state,
142 tag_filter_mode,
143 last_filter_request,
144 )
145 {
146 None
147 } else {
148 Some(Instant::now())
149 }
150}
151
152fn rebuild_filter_candidates(
153 candidates: &mut Vec<(usize, Option<i64>)>,
154 source_indices: impl Iterator<Item = usize>,
155 request: &FilterRequest,
156 all_display: &[String],
157 all_display_lower: &[String],
158 all_tags_search: &[String],
159) {
160 candidates.clear();
161
162 if request.is_empty() {
163 candidates.extend((0..all_display.len()).map(|i| (i, None)));
164 return;
165 }
166
167 for i in source_indices {
168 let Some(display) = all_display.get(i) else {
169 continue;
170 };
171 let is_exact_display = all_display_lower
172 .get(i)
173 .is_some_and(|display| display == &request.text_lower);
174 let tag_match = request.include_tags
175 && all_tags_search
176 .get(i)
177 .is_some_and(|snippet_tags| snippet_tags.contains(&request.text_lower));
178
179 if is_exact_display || tag_match {
180 candidates.push((i, Some(i64::MAX)));
181 } else if let Some(score) = MATCHER.fuzzy_match(display, &request.text) {
182 candidates.push((i, Some(score)));
183 }
184 }
185}
186
187fn sort_filtered_indices(
188 filtered: &mut [(usize, Option<i64>)],
189 filter_state: &FilterState,
190 snippets: &[crate::library::Snippet],
191 display_lower: &[String],
192 has_filter: bool,
193) {
194 if filter_state.sort_mode == SortMode::None && !has_filter {
195 return;
196 }
197
198 filtered.sort_unstable_by(|a, b| {
199 let score_cmp = match (a.1, b.1) {
200 (Some(sa), Some(sb)) => sb.cmp(&sa),
201 (Some(_), None) => std::cmp::Ordering::Less,
202 (None, Some(_)) => std::cmp::Ordering::Greater,
203 (None, None) => std::cmp::Ordering::Equal,
204 };
205
206 if score_cmp != std::cmp::Ordering::Equal || !has_filter {
207 let explicit_sort = match filter_state.sort_mode {
208 SortMode::Newest => {
209 snippets
210 .get(b.0)
211 .zip(snippets.get(a.0))
212 .map(|(b_snip, a_snip)| {
213 b_snip
214 .created_at
215 .cmp(&a_snip.created_at)
216 .then_with(|| b.0.cmp(&a.0))
217 })
218 }
219 SortMode::Oldest => {
220 snippets
221 .get(a.0)
222 .zip(snippets.get(b.0))
223 .map(|(a_snip, b_snip)| {
224 a_snip
225 .created_at
226 .cmp(&b_snip.created_at)
227 .then_with(|| a.0.cmp(&b.0))
228 })
229 }
230 _ => None,
231 };
232
233 let secondary = match filter_state.sort_mode {
234 SortMode::AlphaAsc => display_lower
235 .get(a.0)
236 .zip(display_lower.get(b.0))
237 .map(|(a_display, b_display)| a_display.cmp(b_display)),
238 SortMode::AlphaDesc => display_lower
239 .get(a.0)
240 .zip(display_lower.get(b.0))
241 .map(|(a_display, b_display)| b_display.cmp(a_display)),
242 _ => None,
243 };
244
245 match explicit_sort {
246 Some(c) if c != std::cmp::Ordering::Equal => c,
247 _ => secondary.unwrap_or(score_cmp),
248 }
249 } else {
250 score_cmp
251 }
252 });
253}
254
255fn debounce_elapsed(last_update: Option<Instant>, debounce_ms: u64) -> bool {
256 last_update.is_none_or(|t| t.elapsed() >= Duration::from_millis(debounce_ms))
257}
258
259fn pending_debounce_timeout(
260 dirty: bool,
261 last_update: Option<Instant>,
262 debounce_ms: u64,
263) -> Option<Duration> {
264 if !dirty {
265 return None;
266 }
267
268 let last_update = last_update?;
269 let debounce = Duration::from_millis(debounce_ms);
270 Some(debounce.saturating_sub(last_update.elapsed()))
271}
272
273fn next_event_poll_timeout(
274 filter_dirty: bool,
275 last_filter_update: Option<Instant>,
276 filter_debounce_ms: u64,
277 theme_dirty: bool,
278 last_theme_update: Option<Instant>,
279 theme_debounce_ms: u64,
280) -> Duration {
281 [
282 pending_debounce_timeout(filter_dirty, last_filter_update, filter_debounce_ms),
283 pending_debounce_timeout(theme_dirty, last_theme_update, theme_debounce_ms),
284 ]
285 .into_iter()
286 .flatten()
287 .min()
288 .unwrap_or_else(|| Duration::from_millis(200))
289}
290
291fn command_line_for_display(
292 idx: usize,
293 commands: &[String],
294 highlighted_commands: &[Option<Line<'static>>],
295 style: TuiStyle,
296) -> Line<'static> {
297 if let Some(Some(line)) = highlighted_commands.get(idx) {
298 return line.clone();
299 }
300
301 Line::from(Span::styled(
302 commands.get(idx).cloned().unwrap_or_default(),
303 style,
304 ))
305}
306
307fn warm_visible_highlights(
308 commands: &[String],
309 highlighted_commands: &mut [Option<Line<'static>>],
310 visible_indices: &[usize],
311 budget: Duration,
312) -> bool {
313 let started = Instant::now();
314 let mut warmed_any = false;
315 for &idx in visible_indices {
316 let Some(command) = commands.get(idx) else {
317 continue;
318 };
319 let Some(slot) = highlighted_commands.get_mut(idx) else {
320 continue;
321 };
322 if slot.is_some() {
323 continue;
324 }
325
326 *slot = Some(highlight_command(command));
327 warmed_any = true;
328 if started.elapsed() >= budget {
329 break;
330 }
331 }
332 warmed_any
333}
334
335fn extract_variables(command: &str) -> Vec<String> {
336 extract_variables_for_display(command)
337}
338
339#[derive(Clone, Debug)]
340struct SnippetPreview {
341 content: String,
342}
343
344impl SnippetPreview {
345 fn from_command(command: &str) -> Self {
346 let vars = extract_variables(command);
347 let has_unmatched = has_unmatched_angle_bracket(command);
348 let content = if !vars.is_empty() || has_unmatched {
349 let mut content = format!(
350 "{}\n\nVars: {}",
351 strip_escape_sequences(command),
352 vars.join(", ")
353 );
354 if has_unmatched {
355 content.push_str("\n\nWarning: unmatched '<' found - will be treated as literal");
356 }
357 content
358 } else {
359 strip_escape_sequences(command)
360 };
361
362 Self { content }
363 }
364}
365
366fn ensure_theme_picker_ready(
367 manager: &mut Option<theme::ThemeManager>,
368 list: &mut Vec<theme::ThemeInfo>,
369 filtered: &mut Vec<usize>,
370 active_idx: &mut usize,
371) -> io::Result<()> {
372 if manager.is_some() {
373 return Ok(());
374 }
375 let mut mgr =
376 theme::ThemeManager::new().map_err(|e| io::Error::other(format!("theme manager: {e}")))?;
377 mgr.init_themes_dir()
378 .map_err(|e| io::Error::other(format!("init themes: {e}")))?;
379 *list = mgr
380 .list_themes()
381 .map_err(|e| io::Error::other(format!("list themes: {e}")))?;
382 *filtered = (0..list.len()).collect();
383 *active_idx = mgr
384 .get_active_theme_name()
385 .as_ref()
386 .and_then(|n| list.iter().position(|t| &t.name == n))
387 .unwrap_or(0);
388 *manager = Some(mgr);
389 Ok(())
390}
391
392fn apply_theme_from_picker(
393 manager: &Option<theme::ThemeManager>,
394 list: &[theme::ThemeInfo],
395 filtered: &[usize],
396 active_idx: usize,
397) -> bool {
398 let Some(mgr) = manager else { return false };
399 let Some(&idx) = filtered.get(active_idx) else {
400 return false;
401 };
402 let Some(info) = list.get(idx) else {
403 return false;
404 };
405 match mgr.load_theme(&info.name) {
406 Ok(t) => {
407 theme::set_active_theme(t);
408 true
409 }
410 Err(e) => {
411 tracing::warn!("failed to load theme {}: {e}", info.name);
412 false
413 }
414 }
415}
416
417fn commit_theme_picker(
418 manager: &mut Option<theme::ThemeManager>,
419 list: &[theme::ThemeInfo],
420 filtered: &[usize],
421 active_idx: usize,
422) -> io::Result<()> {
423 if let Some(mgr) = manager.as_mut()
424 && let Some(&idx) = filtered.get(active_idx)
425 && let Some(info) = list.get(idx)
426 {
427 mgr.set_active_theme(&info.name)
428 .map_err(|e| io::Error::other(format!("save theme: {e}")))?;
429 }
430 Ok(())
431}
432
433fn cancel_theme_picker(manager: &Option<theme::ThemeManager>, original: Option<theme::Theme>) {
434 if let Some(t) = original {
438 theme::set_active_theme(t);
439 return;
440 }
441 if let Some(mgr) = manager
444 && let Some(name) = mgr.get_active_theme_name()
445 && let Ok(t) = mgr.load_theme(&name)
446 {
447 theme::set_active_theme(t);
448 }
449}
450
451pub struct SnippetListParams<'a> {
452 pub descriptions: &'a [String],
453 pub commands: &'a [String],
454 pub tags: &'a [Vec<String>],
455 pub is_search: bool,
456 pub initial_filter: Option<&'a str>,
457 pub folders: &'a [Vec<String>],
458 pub favorites: &'a [bool],
459 pub snippets: &'a [crate::library::Snippet],
460 pub original_indices: &'a [usize],
461}
462
463pub enum SnippetSelection {
465 Selected(usize, Option<String>),
467 Delete(usize),
469}
470
471pub fn select_snippet(params: SnippetListParams) -> io::Result<Option<SnippetSelection>> {
472 select_snippet_inner(params)
473}
474
475#[allow(clippy::collapsible_match)]
476fn select_snippet_inner(params: SnippetListParams) -> io::Result<Option<SnippetSelection>> {
477 let SnippetListParams {
478 descriptions,
479 commands,
480 tags,
481 is_search,
482 initial_filter,
483 folders: _folders,
484 favorites,
485 snippets,
486 original_indices: _original_indices,
487 } = params;
488 if let Err(e) = crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture) {
491 tracing::warn!(
492 "Mouse capture not available, continuing without mouse support: {}",
493 e
494 );
495 }
496 let mut terminal = ratatui::init();
497 let _guard = TerminalGuard; let mut highlighted_commands: Vec<Option<Line<'static>>> = vec![None; commands.len()];
502 let mut snippet_previews: Vec<Option<SnippetPreview>> = vec![None; commands.len()];
503
504 let mut sel = SelectState::new();
505 let mut filter = initial_filter.map(String::from).unwrap_or_default();
506 let mut incremental_search = String::new();
507 let mut input_text = String::new();
509 let mut filter_state = FilterState::default();
510 let mut filtered: Vec<usize> = (0..descriptions.len()).collect();
511 let mut insert_mode = true;
512 let mut tag_filter_mode = false;
513 let mut list_display_mode = 0;
514 let mut should_copy: Option<String> = None;
515 let mut copied_message: Option<(String, std::time::Instant)> = None;
516 let mut delete_confirmation: Option<usize> = None;
517 let mut visual_mode = false;
518 let mut visual_start = 0usize;
519 let mut visual_end = 0usize;
520 let mut pending_gg = false;
521 let mut pending_gg_time: Option<std::time::Instant> = None;
522 const GG_TIMEOUT_MS: u64 = 500;
523
524 let mut theme_picker_mode = false;
526 let mut theme_picker_insert_mode = false;
527 let mut theme_filter = String::new();
528 let mut theme_input_text = String::new();
529 let mut theme_filtered: Vec<usize> = Vec::new();
530 let mut theme_dirty = false;
531 let mut theme_active_idx: usize = 0;
532 let mut theme_picker_manager: Option<theme::ThemeManager> = None;
533 let mut theme_picker_list: Vec<theme::ThemeInfo> = Vec::new();
534 let mut theme_picker_original: Option<theme::Theme> = None;
538 let mut pending_rehighlight = false;
539 let mut last_theme_update: Option<std::time::Instant> = None;
540 const THEME_DEBOUNCE_MS: u64 = 100;
541
542 let mut filter_dirty = !filter.is_empty();
544 let mut last_filter_update: Option<std::time::Instant> = None;
545 const FILTER_DEBOUNCE_MS: u64 = 35;
546 const HIGHLIGHT_WARM_BUDGET: Duration = Duration::from_millis(2);
547
548 let mut last_click_row: Option<u16> = None;
550 let mut last_click_time: Option<std::time::Instant> = None;
551 const DOUBLE_CLICK_DURATION_MS: u64 = 500;
552
553 let all_display: Vec<String> = descriptions
554 .iter()
555 .enumerate()
556 .map(|(i, _)| format!("[{}]: {}", descriptions[i], commands[i]))
557 .collect();
558 let all_display_lower: Vec<String> = all_display.iter().map(|d| d.to_lowercase()).collect();
559 let all_tags_search: Vec<String> = tags
560 .iter()
561 .map(|snippet_tags| snippet_tags.join("\n").to_lowercase())
562 .collect();
563 let mut filter_candidates: Vec<(usize, Option<i64>)> =
564 (0..all_display.len()).map(|i| (i, None)).collect();
565 let mut last_filter_request: Option<FilterRequest> = None;
566 let mut needs_redraw = true;
567 sel.update(filtered.len());
568
569 loop {
570 if TERMINATE.load(std::sync::atomic::Ordering::SeqCst) {
572 break;
573 }
574
575 if theme_picker_mode && theme_picker_manager.is_none() {
577 if let Err(e) = ensure_theme_picker_ready(
578 &mut theme_picker_manager,
579 &mut theme_picker_list,
580 &mut theme_filtered,
581 &mut theme_active_idx,
582 ) {
583 tracing::warn!("theme picker init failed: {e}");
584 theme_picker_mode = false;
585 theme_picker_insert_mode = false;
586 theme_filter.clear();
587 theme_input_text.clear();
588 theme_dirty = false;
589 needs_redraw = true;
590 } else {
591 theme_picker_original = Some(theme::get_theme());
594 if apply_theme_from_picker(
595 &theme_picker_manager,
596 &theme_picker_list,
597 &theme_filtered,
598 theme_active_idx,
599 ) {
600 pending_rehighlight = true;
601 }
602 needs_redraw = true;
603 }
604 }
605
606 if pending_rehighlight {
608 highlighted_commands.fill_with(|| None);
609 pending_rehighlight = false;
610 needs_redraw = true;
611 }
612
613 let theme_debounce_elapsed = debounce_elapsed(last_theme_update, THEME_DEBOUNCE_MS);
615 if theme_dirty && theme_debounce_elapsed {
616 theme_filtered = if theme_filter.is_empty() {
617 (0..theme_picker_list.len()).collect()
618 } else {
619 theme_picker_list
620 .iter()
621 .enumerate()
622 .filter_map(|(i, t)| {
623 if MATCHER.fuzzy_match(&t.name, &theme_filter).is_some() {
624 Some(i)
625 } else {
626 None
627 }
628 })
629 .collect()
630 };
631 if theme_active_idx >= theme_filtered.len() {
632 theme_active_idx = theme_filtered.len().saturating_sub(1);
633 }
634 theme_dirty = false;
635 last_theme_update = None;
636 needs_redraw = true;
637 }
638
639 let debounce_elapsed = debounce_elapsed(last_filter_update, FILTER_DEBOUNCE_MS);
641 let should_recompute = if filter_dirty {
642 let filter_is_empty = filter.is_empty() && filter_state.tag_filter_text.is_empty();
645 if filter_is_empty || debounce_elapsed {
646 filter_dirty = false;
647 true
648 } else {
649 false
651 }
652 } else {
653 false
655 };
656
657 if should_recompute {
658 let filter_request = current_filter_request(
659 &filter,
660 &incremental_search,
661 &filter_state,
662 tag_filter_mode,
663 );
664 if last_filter_request.as_ref() != Some(&filter_request) {
665 if last_filter_request
666 .as_ref()
667 .is_some_and(|previous| filter_request.can_narrow_from(previous))
668 {
669 rebuild_filter_candidates(
670 &mut filter_candidates,
671 filtered.iter().copied(),
672 &filter_request,
673 &all_display,
674 &all_display_lower,
675 &all_tags_search,
676 );
677 } else {
678 rebuild_filter_candidates(
679 &mut filter_candidates,
680 0..all_display.len(),
681 &filter_request,
682 &all_display,
683 &all_display_lower,
684 &all_tags_search,
685 );
686 }
687 }
688
689 let has_filter = !filter_request.is_empty();
690 sort_filtered_indices(
691 &mut filter_candidates,
692 &filter_state,
693 snippets,
694 &all_display_lower,
695 has_filter,
696 );
697
698 filtered.clear();
699 filtered.extend(filter_candidates.iter().map(|(i, _)| *i));
700 last_filter_request = Some(filter_request);
701
702 sel.update(filtered.len());
703 needs_redraw = true;
704 } else {
705 sel.update(filtered.len());
706 } let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
709 let terminal_size = ratatui::layout::Rect::new(0, 0, cols, rows);
710 let list_area = {
711 let chunks = Layout::default()
712 .direction(Direction::Vertical)
713 .constraints([
714 Constraint::Length(3),
715 Constraint::Min(3),
716 Constraint::Length(6),
717 Constraint::Length(1),
718 ])
719 .split(terminal_size);
720 chunks[1]
721 };
722 let list_visible_rows = list_area.height.saturating_sub(2) as usize;
723 let list_offset = if list_visible_rows == 0 {
724 0
725 } else {
726 sel.selected
727 .saturating_sub(list_visible_rows.saturating_sub(1))
728 };
729 let list_end = (list_offset + list_visible_rows).min(filtered.len());
730 if needs_redraw {
731 needs_redraw = false;
732
733 let filter_indicator = if tag_filter_mode {
737 format!("[tag: {}]", filter_state.tag_filter_text)
738 } else if !insert_mode && !incremental_search.is_empty() {
739 format!("/{incremental_search}")
740 } else {
741 String::new()
742 };
743
744 let sort_indicator = match filter_state.sort_mode {
745 SortMode::None => String::new(),
746 SortMode::Newest => "[new]".to_string(),
747 SortMode::Oldest => "[old]".to_string(),
748 SortMode::AlphaAsc => "[a-z]".to_string(),
749 SortMode::AlphaDesc => "[z-a]".to_string(),
750 };
751
752 let draw_result = terminal.draw(|f| {
753 let size = f.area();
754 if size.width < 10 || size.height < 10 {
755 let error_msg = "Terminal too small - resize to at least 10x10";
756 let paragraph = Paragraph::new(error_msg)
757 .centered()
758 .block(Block::default().title("Error").borders(Borders::ALL));
759 f.render_widget(paragraph, size);
760 return;
761 }
762 {
763 let picker_mode = theme_picker_mode;
764 let picker_ins = theme_picker_insert_mode;
765 let picker_filter_text: &str = if picker_ins {
766 &theme_input_text
767 } else {
768 &theme_filter
769 };
770 let count = if picker_mode {
771 theme_filtered.len()
772 } else {
773 filtered.len()
774 };
775 let title_part = if picker_mode {
776 let active_name = theme_picker_manager
777 .as_ref()
778 .and_then(|m| m.get_active_theme_name())
779 .unwrap_or_default();
780 let active_str = if active_name.is_empty() {
781 String::new()
782 } else {
783 format!(" [{active_name}]")
784 };
785 format!("Themes [{count}]{active_str} {filter_indicator}")
786 } else {
787 format!("Snippets [{count}] {filter_indicator}{sort_indicator}")
788 };
789 let separator = "─".repeat((size.width as usize).saturating_sub(title_part.len() + 8));
790 let theme = get_theme();
791 let title = Line::from(vec![
792 Span::styled(title_part.clone(), style_fg(theme.secondary)),
793 Span::raw(" "),
794 Span::styled(separator, style_fg(theme.border)),
795 ]);
796 let block = Block::default()
797 .title(title)
798 .borders(Borders::ALL)
799 .border_style(style_fg(theme.border))
800 .style(TuiStyle::default().bg(theme.background));
801 let input_block_title = if picker_mode {
802 "Theme Filter"
803 } else if tag_filter_mode {
804 "Tag Filter"
805 } else {
806 "Filter"
807 };
808 let input_block = Block::default()
809 .title(input_block_title)
810 .borders(Borders::ALL)
811 .border_style(style_fg(theme.border))
812 .title_style(style_fg(theme.secondary))
813 .style(TuiStyle::default().bg(theme.background));
814 let chunks = Layout::default()
815 .direction(Direction::Vertical)
816 .constraints([
817 Constraint::Length(3),
818 Constraint::Min(3),
819 Constraint::Length(6),
820 Constraint::Length(1),
821 ])
822 .split(size);
823
824 f.render_widget(input_block, chunks[0]);
825 let filter_text: &str = if picker_mode {
831 picker_filter_text
832 } else if tag_filter_mode {
833 if filter_state.tag_filter_text.is_empty() {
834 ""
835 } else {
836 &filter_state.tag_filter_text
837 }
838 } else if insert_mode {
839 &input_text
840 } else {
841 &filter
842 };
843 let filter_widget = Paragraph::new(filter_text)
844 .style(style_fg_bg(theme.text, theme.background));
845 f.render_widget(
846 filter_widget,
847 ratatui::layout::Rect::new(
848 chunks[0].x + 1,
849 chunks[0].y + 1,
850 chunks[0].width - 2,
851 1,
852 ),
853 );
854
855 if (picker_mode && picker_ins) || (!picker_mode && insert_mode) {
856 use unicode_width::UnicodeWidthStr;
857 let cursor_text: &str = if picker_mode {
858 &theme_input_text
859 } else {
860 &input_text
861 };
862 let cursor_x = chunks[0].x
863 + 1
864 + cursor_text.width().min(u16::MAX as usize) as u16;
865 let cursor_y = chunks[0].y + 1;
866 f.set_cursor_position((cursor_x, cursor_y));
867 }
868
869 if picker_mode {
870 let saved_name = theme_picker_manager
872 .as_ref()
873 .and_then(|m| m.get_active_theme_name());
874 let picker_items: Vec<ListItem> = theme_filtered
875 .iter()
876 .filter_map(|&idx| theme_picker_list.get(idx))
877 .map(|info| {
878 let is_active = saved_name.as_deref() == Some(info.name.as_str());
879 let prefix = if is_active { "★ " } else { " " };
880 let style = if is_active {
881 style_fg(theme.accent)
882 } else {
883 style_fg(theme.text)
884 };
885 let line = Line::from(vec![
886 ratatui::text::Span::raw(prefix),
887 ratatui::text::Span::styled(info.name.clone(), style),
888 ]);
889 ListItem::new(line)
890 })
891 .collect();
892 let picker_list = List::new(picker_items)
893 .block(block)
894 .style(TuiStyle::default().bg(theme.background))
895 .highlight_style(
896 ratatui::style::Style::default()
897 .fg(theme.text)
898 .bg(theme.selected_bg),
899 )
900 .highlight_symbol("â–¶ ");
901 let mut picker_list_state = ratatui::widgets::ListState::default();
902 picker_list_state.select(Some(theme_active_idx));
903 f.render_stateful_widget(picker_list, chunks[1], &mut picker_list_state);
904 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
905 .thumb_style(ratatui::style::Style::default().bg(theme.secondary));
906 let mut picker_scroll_state = ratatui::widgets::ScrollbarState::default()
907 .content_length(theme_filtered.len())
908 .position(theme_active_idx);
909 f.render_stateful_widget(scrollbar, chunks[1], &mut picker_scroll_state);
910
911 let preview_name = theme_filtered
913 .get(theme_active_idx)
914 .and_then(|&idx| theme_picker_list.get(idx))
915 .map(|info| info.name.clone())
916 .unwrap_or_else(|| "(no selection)".to_string());
917 let is_saved_active = saved_name.as_deref() == Some(preview_name.as_str());
918 let preview_title = format!("Preview: {preview_name}");
919 let preview_block = Block::default()
920 .title(preview_title)
921 .borders(Borders::ALL)
922 .border_style(style_fg(theme.border))
923 .title_style(style_fg(theme.secondary))
924 .style(TuiStyle::default().bg(theme.background));
925
926 let swatch = |c: ratatui::style::Color| {
927 ratatui::text::Span::styled("███", style_fg(c))
928 };
929 let label = |s: &str| -> ratatui::text::Span<'static> {
930 ratatui::text::Span::styled(s.to_string(), style_fg(theme.text))
931 };
932 let dim = |s: &str| -> ratatui::text::Span<'static> {
933 ratatui::text::Span::styled(s.to_string(), style_fg(theme.muted))
934 };
935
936 let mut preview_spans: Vec<ratatui::text::Span<'static>> = vec![
937 swatch(theme.primary),
938 label(" primary "),
939 swatch(theme.secondary),
940 label(" tertiary "),
941 swatch(theme.accent),
942 label(" accent"),
943 ratatui::text::Span::raw("\n"),
944 label("Background: "),
945 swatch(theme.background),
946 ratatui::text::Span::raw("\n"),
947 label("> Selected: "),
948 swatch(theme.selected_bg),
949 ratatui::text::Span::raw("\n"),
950 label("Text: "),
951 swatch(theme.text),
952 label(" Border: "),
953 swatch(theme.border),
954 label(" Muted: "),
955 swatch(theme.muted),
956 ratatui::text::Span::raw("\n"),
957 label("String literal: "),
958 swatch(theme.string_color),
959 label(" Escape: "),
960 swatch(theme.escape_color),
961 ];
962 if is_saved_active {
963 preview_spans.push(ratatui::text::Span::raw("\n"));
964 preview_spans.push(dim("(this is your saved active theme)"));
965 }
966
967 let preview_widget = Paragraph::new(Line::from(preview_spans))
968 .block(preview_block)
969 .style(style_fg_bg(theme.text, theme.background));
970 f.render_widget(preview_widget, chunks[2]);
971 } else {
972 let visible_filtered = &filtered[list_offset..list_end];
974 let items: Vec<ListItem> = visible_filtered
975 .iter()
976 .map(|idx| {
977 let is_fav = *favorites.get(*idx).unwrap_or(&false);
978 let fav_indicator = if is_fav { "★ " } else { " " };
979
980 let line = if list_display_mode == 1 {
981 let desc = &descriptions[*idx];
982 let cmd_spans = command_line_for_display(
983 *idx,
984 commands,
985 &highlighted_commands,
986 style_fg(theme.text),
987 )
988 .spans;
989
990 let mut combined: Vec<ratatui::text::Span<'static>> =
991 vec![ratatui::text::Span::styled(
992 format!("[{desc}] "),
993 style_fg(theme.text),
994 )];
995 combined.extend(cmd_spans);
996 Line::from(combined)
997 } else {
998 command_line_for_display(
999 *idx,
1000 commands,
1001 &highlighted_commands,
1002 style_fg(theme.text),
1003 )
1004 };
1005
1006 let mut spans = vec![ratatui::text::Span::raw(fav_indicator)];
1007 spans.extend(line.spans);
1008 let final_line = Line::from(spans);
1009
1010 ListItem::new(final_line)
1011 })
1012 .collect();
1013 let list = List::new(items)
1014 .block(block)
1015 .style(TuiStyle::default().bg(theme.background))
1016 .highlight_style(
1017 ratatui::style::Style::default()
1018 .fg(theme.text)
1019 .bg(theme.selected_bg),
1020 )
1021 .highlight_symbol(if is_search { "" } else { "â–¶ " });
1022
1023 let mut visible_list_state = ratatui::widgets::ListState::default();
1024 visible_list_state.select(sel.selected.checked_sub(list_offset));
1025 f.render_stateful_widget(list, chunks[1], &mut visible_list_state);
1026 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
1027 .thumb_style(ratatui::style::Style::default().bg(theme.secondary));
1028 f.render_stateful_widget(scrollbar, chunks[1], &mut sel.scroll_state);
1029
1030 if sel.selected < filtered.len() {
1031 let idx = filtered[sel.selected];
1032 let snippet_cmd = &commands[idx];
1033 let snippet_desc = &descriptions[idx];
1034
1035 let preview_title = format!("Preview: {snippet_desc}");
1036 let preview_block = Block::default()
1037 .title(preview_title)
1038 .borders(Borders::ALL)
1039 .border_style(style_fg(theme.border))
1040 .title_style(style_fg(theme.secondary))
1041 .style(TuiStyle::default().bg(theme.background));
1042
1043 let preview = snippet_previews
1044 .get_mut(idx)
1045 .and_then(|slot| {
1046 if slot.is_none() {
1047 *slot = Some(SnippetPreview::from_command(snippet_cmd));
1048 }
1049 slot.as_ref()
1050 });
1051 let preview_content = preview
1052 .map(|preview| preview.content.as_str())
1053 .unwrap_or(snippet_cmd.as_str());
1054
1055 let preview_widget = Paragraph::new(preview_content)
1056 .block(preview_block)
1057 .style(style_fg_bg(theme.text, theme.background));
1058 f.render_widget(preview_widget, chunks[2]);
1059 }
1060 }
1061
1062 let copied_desc = copied_message.as_ref().map(|(desc, _)| desc.clone());
1063 let mode_str = if insert_mode { "INS" } else { "NOR" };
1064 let tag_mode_str = if tag_filter_mode { " TAG" } else { "" };
1065
1066 let status_text: String = if picker_mode {
1067 if picker_ins {
1068 "[THEME-INS] | esc: leave ins | enter: apply | up/down: navigate".to_string()
1069 } else {
1070 "[THEME-NOR] | i: filter | enter: apply | up/down: preview | e/q: back"
1071 .to_string()
1072 }
1073 } else if let Some(desc) = copied_desc {
1074 format!("[{mode_str}]{tag_mode_str} | {desc}")
1075 } else if is_search {
1076 if insert_mode {
1077 format!(
1078 "[{mode_str}]{tag_mode_str} | esc: normal | /: search | ctrl+u/d : page | n/o/a/z: sort | t: tags | x/c: clear | tab: mode"
1079 )
1080 } else {
1081 format!(
1082 "[{mode_str}]{tag_mode_str} | i: insert | y/ctrl+c: copy | d: delete | ctrl+u/d : page | n/o/a/z: sort | t: tags | q: quit | x/c: clear | tab: mode"
1083 )
1084 }
1085 } else if insert_mode {
1086 format!(
1087 "[{mode_str}]{tag_mode_str} | esc: normal | enter: run | ctrl+u/d : page | n/o/a/z: sort | t: tags | x/c: clear | tab: mode"
1088 )
1089 } else {
1090 format!(
1091 "[{mode_str}]{tag_mode_str} | i: insert | y: copy | d: delete | ctrl+u/d : page | n/o/a/z: sort | t: tags | q: quit | x/c: clear | tab: mode | double-click: run"
1092 )
1093 };
1094 let status_widget = Paragraph::new(status_text)
1095 .style(style_fg(theme.muted));
1096 let status_area = ratatui::layout::Rect::new(
1097 chunks[3].x + 1,
1098 chunks[3].y,
1099 chunks[3].width - 2,
1100 1,
1101 );
1102 f.render_widget(status_widget, status_area);
1103
1104 if let Some(idx) = delete_confirmation {
1105 let popup_width = size.width.saturating_sub(4).min(70);
1106 let popup_height = 7.min(size.height.saturating_sub(2));
1107 let popup_area = ratatui::layout::Rect::new(
1108 size.x + (size.width.saturating_sub(popup_width)) / 2,
1109 size.y + (size.height.saturating_sub(popup_height)) / 2,
1110 popup_width,
1111 popup_height,
1112 );
1113 let description = descriptions
1114 .get(idx)
1115 .map(String::as_str)
1116 .unwrap_or("selected snippet");
1117 let confirmation = Paragraph::new(vec![
1118 Line::from("Delete selected snippet?"),
1119 Line::from(format!("\"{description}\"")),
1120 Line::from(""),
1121 Line::from("[y] yes [n] no"),
1122 ])
1123 .centered()
1124 .block(
1125 Block::default()
1126 .title("Confirm delete")
1127 .borders(Borders::ALL)
1128 .border_style(style_fg(theme.accent))
1129 .style(TuiStyle::default().bg(theme.background)),
1130 );
1131 f.render_widget(Clear, popup_area);
1132 f.render_widget(confirmation, popup_area);
1133 }
1134 }
1135 });
1136 if let Err(e) = draw_result {
1137 tracing::warn!("Terminal draw error: {}", e);
1138 }
1139
1140 if !theme_picker_mode
1141 && !event::poll(Duration::from_millis(0)).unwrap_or(false)
1142 && list_offset < list_end
1143 && warm_visible_highlights(
1144 commands,
1145 &mut highlighted_commands,
1146 &filtered[list_offset..list_end],
1147 HIGHLIGHT_WARM_BUDGET,
1148 )
1149 {
1150 needs_redraw = true;
1151 }
1152
1153 if needs_redraw {
1154 continue;
1155 }
1156 }
1157
1158 let poll_timeout = next_event_poll_timeout(
1159 filter_dirty,
1160 last_filter_update,
1161 FILTER_DEBOUNCE_MS,
1162 theme_dirty,
1163 last_theme_update,
1164 THEME_DEBOUNCE_MS,
1165 );
1166 let polled = event::poll(poll_timeout).unwrap_or(false);
1167 if polled {
1168 match event::read() {
1169 Ok(CEvent::Mouse(mouse_event)) => {
1170 needs_redraw = true;
1171 if !theme_picker_mode {
1172 if mouse_event.kind == crossterm::event::MouseEventKind::ScrollDown {
1174 sel.move_down(filtered.len());
1175 } else if mouse_event.kind == crossterm::event::MouseEventKind::ScrollUp {
1176 sel.move_up();
1177 }
1178 else if let crossterm::event::MouseEventKind::Up(
1180 crossterm::event::MouseButton::Left,
1181 ) = mouse_event.kind
1182 {
1183 let list_inner_y = list_area.y.saturating_add(1);
1184 let list_inner_height = list_area.height.saturating_sub(2);
1185 if mouse_event.row >= list_inner_y
1186 && mouse_event.row < list_inner_y + list_inner_height
1187 && mouse_event.row
1188 < list_inner_y
1189 + (list_end - list_offset).min(list_inner_height as usize)
1190 as u16
1191 {
1192 let clicked_row =
1193 list_offset + (mouse_event.row - list_inner_y) as usize;
1194
1195 let now = std::time::Instant::now();
1197 let is_double_click = last_click_row == Some(mouse_event.row)
1198 && last_click_time
1199 .map(|t| {
1200 now.duration_since(t).as_millis()
1201 < DOUBLE_CLICK_DURATION_MS as u128
1202 })
1203 .unwrap_or(false);
1204
1205 if is_double_click {
1206 break;
1208 } else {
1209 sel.selected = clicked_row;
1211 last_click_row = Some(mouse_event.row);
1212 last_click_time = Some(now);
1213 }
1214 } else {
1215 last_click_row = None;
1217 last_click_time = None;
1218 }
1219 }
1220 sel.update(filtered.len());
1221 }
1222 }
1223 Ok(CEvent::Key(key)) => {
1224 if key.kind == KeyEventKind::Press {
1225 needs_redraw = true;
1226 if let Some((_, instant)) = copied_message
1227 && instant.elapsed().as_secs() >= 3
1228 {
1229 copied_message = None;
1230 }
1231
1232 if theme_picker_mode {
1233 if theme_picker_insert_mode {
1234 match key.code {
1235 KeyCode::Esc => {
1236 if !theme_input_text.is_empty() {
1237 theme_filter = theme_input_text.clone();
1238 theme_input_text.clear();
1239 }
1240 theme_picker_insert_mode = false;
1241 }
1242 KeyCode::Down | KeyCode::Char('j')
1243 if theme_active_idx + 1 < theme_filtered.len() =>
1244 {
1245 theme_active_idx += 1;
1246 if apply_theme_from_picker(
1247 &theme_picker_manager,
1248 &theme_picker_list,
1249 &theme_filtered,
1250 theme_active_idx,
1251 ) {
1252 pending_rehighlight = true;
1253 }
1254 }
1255 KeyCode::Up | KeyCode::Char('k') if theme_active_idx > 0 => {
1256 theme_active_idx -= 1;
1257 if apply_theme_from_picker(
1258 &theme_picker_manager,
1259 &theme_picker_list,
1260 &theme_filtered,
1261 theme_active_idx,
1262 ) {
1263 pending_rehighlight = true;
1264 }
1265 }
1266 KeyCode::Backspace => {
1267 if !theme_input_text.is_empty() {
1268 theme_input_text.pop();
1269 theme_filter = theme_input_text.clone();
1270 theme_dirty = true;
1271 last_theme_update = Some(std::time::Instant::now());
1272 }
1273 }
1274 KeyCode::Char(c) => {
1275 theme_input_text.push(c);
1276 theme_filter = theme_input_text.clone();
1277 theme_dirty = true;
1278 last_theme_update = Some(std::time::Instant::now());
1279 }
1280 KeyCode::Enter => {
1281 if let Err(e) = commit_theme_picker(
1282 &mut theme_picker_manager,
1283 &theme_picker_list,
1284 &theme_filtered,
1285 theme_active_idx,
1286 ) {
1287 tracing::warn!("commit theme failed: {e}");
1288 }
1289 theme_picker_mode = false;
1290 theme_picker_insert_mode = false;
1291 theme_filter.clear();
1292 theme_input_text.clear();
1293 theme_dirty = false;
1294 pending_rehighlight = true;
1295 }
1296 _ => {}
1297 }
1298 } else {
1299 match key.code {
1300 KeyCode::Char('i') => {
1301 theme_picker_insert_mode = true;
1302 theme_input_text = theme_filter.clone();
1303 }
1304 KeyCode::Char('q') | KeyCode::Esc => {
1305 cancel_theme_picker(
1306 &theme_picker_manager,
1307 theme_picker_original,
1308 );
1309 theme_picker_mode = false;
1310 theme_picker_insert_mode = false;
1311 theme_filter.clear();
1312 theme_input_text.clear();
1313 theme_dirty = false;
1314 pending_rehighlight = true;
1315 }
1316 KeyCode::Char('e') => {
1317 cancel_theme_picker(
1318 &theme_picker_manager,
1319 theme_picker_original,
1320 );
1321 theme_picker_mode = false;
1322 theme_picker_insert_mode = false;
1323 theme_filter.clear();
1324 theme_input_text.clear();
1325 theme_dirty = false;
1326 pending_rehighlight = true;
1327 }
1328 KeyCode::Enter => {
1329 if let Err(e) = commit_theme_picker(
1330 &mut theme_picker_manager,
1331 &theme_picker_list,
1332 &theme_filtered,
1333 theme_active_idx,
1334 ) {
1335 tracing::warn!("commit theme failed: {e}");
1336 }
1337 theme_picker_mode = false;
1338 theme_picker_insert_mode = false;
1339 theme_filter.clear();
1340 theme_input_text.clear();
1341 theme_dirty = false;
1342 pending_rehighlight = true;
1343 }
1344 KeyCode::Char('j') | KeyCode::Down
1345 if theme_active_idx + 1 < theme_filtered.len() =>
1346 {
1347 theme_active_idx += 1;
1348 if apply_theme_from_picker(
1349 &theme_picker_manager,
1350 &theme_picker_list,
1351 &theme_filtered,
1352 theme_active_idx,
1353 ) {
1354 pending_rehighlight = true;
1355 }
1356 }
1357 KeyCode::Char('k') | KeyCode::Up if theme_active_idx > 0 => {
1358 theme_active_idx -= 1;
1359 if apply_theme_from_picker(
1360 &theme_picker_manager,
1361 &theme_picker_list,
1362 &theme_filtered,
1363 theme_active_idx,
1364 ) {
1365 pending_rehighlight = true;
1366 }
1367 }
1368 KeyCode::Char('g') if is_ctrl_key(&key, 'g') => {
1369 theme_active_idx = 0;
1370 if apply_theme_from_picker(
1371 &theme_picker_manager,
1372 &theme_picker_list,
1373 &theme_filtered,
1374 theme_active_idx,
1375 ) {
1376 pending_rehighlight = true;
1377 }
1378 }
1379 KeyCode::Char('G') => {
1380 theme_active_idx = theme_filtered.len().saturating_sub(1);
1381 if apply_theme_from_picker(
1382 &theme_picker_manager,
1383 &theme_picker_list,
1384 &theme_filtered,
1385 theme_active_idx,
1386 ) {
1387 pending_rehighlight = true;
1388 }
1389 }
1390 KeyCode::PageDown | KeyCode::Char('d')
1391 if is_ctrl_key(&key, 'd') =>
1392 {
1393 theme_active_idx = (theme_active_idx + 10)
1394 .min(theme_filtered.len().saturating_sub(1));
1395 if apply_theme_from_picker(
1396 &theme_picker_manager,
1397 &theme_picker_list,
1398 &theme_filtered,
1399 theme_active_idx,
1400 ) {
1401 pending_rehighlight = true;
1402 }
1403 }
1404 KeyCode::PageUp | KeyCode::Char('u')
1405 if is_ctrl_key(&key, 'u') =>
1406 {
1407 theme_active_idx = theme_active_idx.saturating_sub(10);
1408 if apply_theme_from_picker(
1409 &theme_picker_manager,
1410 &theme_picker_list,
1411 &theme_filtered,
1412 theme_active_idx,
1413 ) {
1414 pending_rehighlight = true;
1415 }
1416 }
1417 _ => {}
1418 }
1419 }
1420 continue;
1421 }
1422
1423 if let Some(idx) = delete_confirmation.take() {
1424 if key.code == KeyCode::Char('y')
1425 && !key.modifiers.contains(KeyModifiers::CONTROL)
1426 {
1427 return Ok(Some(SnippetSelection::Delete(idx)));
1428 }
1429 continue;
1430 }
1431
1432 if is_ctrl_key(&key, 'c') && sel.selected < filtered.len() {
1433 let idx = filtered[sel.selected];
1434 should_copy = Some(descriptions[idx].clone());
1435 if !is_search {
1436 break;
1437 }
1438 }
1439
1440 if !insert_mode && key.code == KeyCode::Char('v') && !visual_mode {
1443 visual_mode = true;
1444 visual_start = sel.selected;
1445 visual_end = sel.selected;
1446 continue;
1447 }
1448
1449 if !insert_mode && key.code == KeyCode::Char('V') && !visual_mode {
1450 visual_mode = true;
1451 visual_start = sel.selected;
1452 visual_end = filtered.len().saturating_sub(1);
1453 sel.selected = visual_end;
1454 continue;
1455 }
1456
1457 if key.code == KeyCode::Esc && visual_mode {
1458 visual_mode = false;
1459 continue;
1460 }
1461
1462 if visual_mode {
1463 match key.code {
1464 KeyCode::Char('j') | KeyCode::Down
1465 if sel.selected + 1 < filtered.len() =>
1466 {
1467 if sel.selected < visual_end {
1468 sel.selected += 1;
1469 } else {
1470 visual_end = (sel.selected + 1)
1471 .min(filtered.len().saturating_sub(1));
1472 sel.selected = visual_end;
1473 }
1474 }
1475 KeyCode::Char('k') | KeyCode::Up if sel.selected > 0 => {
1476 if sel.selected > visual_start {
1477 sel.selected -= 1;
1478 } else if visual_start > 0 {
1479 visual_start -= 1;
1480 sel.selected = visual_start;
1481 }
1482 }
1483 KeyCode::Char('y') => {
1484 if filtered.is_empty() {
1485 visual_mode = false;
1486 if !is_search {
1487 break;
1488 }
1489 continue;
1490 }
1491 let start = std::cmp::min(visual_start, visual_end)
1492 .min(filtered.len().saturating_sub(1));
1493 let end = std::cmp::max(visual_start, visual_end)
1494 .min(filtered.len().saturating_sub(1));
1495 let selected_items: Vec<String> = filtered
1496 .iter()
1497 .skip(start)
1498 .take(end - start + 1)
1499 .map(|idx| strip_escape_sequences(&commands[*idx]))
1500 .collect();
1501 let copy_text = selected_items.join("\n");
1502 match clipboard::copy_to_clipboard_auto(©_text) {
1503 Ok(()) => {
1504 should_copy = Some(format!(
1505 "{} snippets copied",
1506 end - start + 1
1507 ));
1508 }
1509 Err(e) => {
1510 tracing::warn!("Clipboard copy failed: {}", e);
1511 copied_message = Some((
1512 format!("Copy failed: {e}"),
1513 std::time::Instant::now(),
1514 ));
1515 }
1516 }
1517 if let Some(idx) = filtered.get(start) {
1518 let original_idx = *idx;
1519 if original_idx < snippets.len()
1520 && let Err(e) = crate::logging::audit_log(
1521 "copy",
1522 &snippets[original_idx],
1523 None,
1524 )
1525 {
1526 tracing::debug!("Audit log write failed: {}", e);
1527 }
1528 }
1529 visual_mode = false;
1530 if !is_search {
1531 break;
1532 }
1533 }
1534 _ => {}
1535 }
1536 sel.update(filtered.len());
1537 continue;
1538 }
1539
1540 if is_ctrl_key(&key, 'f')
1541 || is_ctrl_key(&key, 'd')
1542 || key.code == KeyCode::PageDown
1543 {
1544 sel.selected =
1545 (sel.selected + 10).min(filtered.len().saturating_sub(1));
1546 }
1547
1548 if is_ctrl_key(&key, 'b')
1549 || is_ctrl_key(&key, 'u')
1550 || key.code == KeyCode::PageUp
1551 {
1552 sel.selected = sel.selected.saturating_sub(10);
1553 }
1554
1555 if insert_mode {
1556 match key.code {
1557 KeyCode::Esc => {
1558 if tag_filter_mode {
1559 tag_filter_mode = false;
1560 } else if !input_text.is_empty() {
1561 filter = input_text.clone();
1562 input_text.clear();
1563 insert_mode = false;
1564 filter_dirty = true;
1565 last_filter_update = Some(std::time::Instant::now());
1566 } else if !incremental_search.is_empty() {
1567 incremental_search.clear();
1568 insert_mode = false;
1569 filter_dirty = true;
1570 last_filter_update = Some(std::time::Instant::now());
1571 } else {
1572 filter.clear();
1575 insert_mode = false;
1576 filter_dirty = true;
1577 last_filter_update = Some(std::time::Instant::now());
1578 }
1579 }
1580 KeyCode::Down if sel.selected + 1 < filtered.len() => {
1581 sel.move_down(filtered.len())
1582 }
1583 KeyCode::Up if sel.selected > 0 => sel.move_up(),
1584 KeyCode::Enter => {
1585 break;
1586 }
1587 KeyCode::Backspace => {
1588 if tag_filter_mode {
1589 filter_state.tag_filter_text.pop();
1590 filter_dirty = true;
1591 last_filter_update = Some(std::time::Instant::now());
1592 } else if insert_mode {
1593 input_text.pop();
1594 filter.pop();
1595 filter_dirty = true;
1596 last_filter_update = Some(std::time::Instant::now());
1597 } else if !incremental_search.is_empty() {
1598 incremental_search.pop();
1599 filter_dirty = true;
1600 last_filter_update = Some(std::time::Instant::now());
1601 } else {
1602 filter.pop();
1603 filter_dirty = true;
1604 last_filter_update = Some(std::time::Instant::now());
1605 }
1606 if !filtered.is_empty() {
1607 sel.move_to_top();
1608 }
1609 }
1610 KeyCode::Char(c)
1611 if !key.modifiers.contains(KeyModifiers::CONTROL) =>
1612 {
1613 if tag_filter_mode {
1614 filter_state.tag_filter_text.push(c);
1615 filter_dirty = true;
1616 } else if insert_mode {
1617 input_text.push(c);
1618 filter.push(c);
1619 filter_dirty = true;
1620 } else if !incremental_search.is_empty() {
1621 incremental_search.push(c);
1622 filter_dirty = true;
1623 } else {
1624 filter.push(c);
1625 filter_dirty = true;
1626 }
1627 last_filter_update = filter_update_deadline_for_insert(
1628 &filter,
1629 &incremental_search,
1630 &filter_state,
1631 tag_filter_mode,
1632 last_filter_request.as_ref(),
1633 );
1634 if !filtered.is_empty() {
1635 sel.move_to_top();
1636 }
1637 }
1638 KeyCode::Tab => {
1639 list_display_mode = (list_display_mode + 1) % 2;
1640 }
1641 _ => {}
1642 }
1643 } else {
1644 if key.code != KeyCode::Char('g') && !is_ctrl_key(&key, 'g') {
1645 pending_gg = false;
1646 }
1647 match key.code {
1648 KeyCode::Char('q') => {
1649 sel.selected = filtered.len();
1650 break;
1651 }
1652 KeyCode::Enter => {
1653 break;
1655 }
1656 KeyCode::Char('i') => {
1657 insert_mode = true;
1658 input_text = filter.clone();
1659 }
1660 KeyCode::Char('y') => {
1661 if sel.selected < filtered.len() {
1662 let idx = filtered[sel.selected];
1663 should_copy = Some(descriptions[idx].clone());
1664 if !is_search {
1665 break;
1666 }
1667 }
1668 }
1669 KeyCode::Char('d')
1670 if !key.modifiers.contains(KeyModifiers::CONTROL)
1671 && sel.selected < filtered.len() =>
1672 {
1673 delete_confirmation = Some(filtered[sel.selected]);
1674 }
1675 KeyCode::Tab => {
1676 list_display_mode = (list_display_mode + 1) % 2;
1677 }
1678 KeyCode::Char('g') if is_ctrl_key(&key, 'g') => {
1679 sel.move_to_top();
1680 pending_gg = false;
1681 }
1682 KeyCode::Char('g') if pending_gg => {
1683 let now = std::time::Instant::now();
1684 let is_expired = pending_gg_time
1685 .map(|t| {
1686 now.duration_since(t).as_millis()
1687 > GG_TIMEOUT_MS as u128
1688 })
1689 .unwrap_or(true);
1690 if is_expired {
1691 pending_gg = true;
1692 pending_gg_time = Some(now);
1693 } else {
1694 sel.move_to_top();
1695 pending_gg = false;
1696 }
1697 }
1698 KeyCode::Char('g') => {
1699 pending_gg = true;
1700 pending_gg_time = Some(std::time::Instant::now());
1701 }
1702 KeyCode::Char('G') => sel.move_to_bottom(filtered.len()),
1703 KeyCode::Char('h') | KeyCode::Left if sel.selected > 0 => {
1704 sel.move_up()
1705 }
1706 KeyCode::Char('j') | KeyCode::Down
1707 if sel.selected + 1 < filtered.len() =>
1708 {
1709 sel.move_down(filtered.len())
1710 }
1711 KeyCode::Char('k') | KeyCode::Up if sel.selected > 0 => {
1712 sel.move_up()
1713 }
1714 KeyCode::Char('l') | KeyCode::Right
1715 if sel.selected + 1 < filtered.len() =>
1716 {
1717 sel.move_down(filtered.len())
1718 }
1719 KeyCode::Char('n') => {
1720 filter_state.toggle_sort_new();
1721 filter_dirty = true;
1722 last_filter_update = None;
1723 }
1724 KeyCode::Char('o') => {
1725 filter_state.toggle_sort_old();
1726 filter_dirty = true;
1727 last_filter_update = None;
1728 }
1729 KeyCode::Char('a') => {
1730 filter_state.toggle_sort_alpha();
1731 filter_dirty = true;
1732 last_filter_update = None;
1733 }
1734 KeyCode::Char('z') => {
1735 filter_state.toggle_sort_alpha_rev();
1736 filter_dirty = true;
1737 last_filter_update = None;
1738 }
1739 KeyCode::Char('x') | KeyCode::Char('c') => {
1740 filter.clear();
1741 incremental_search.clear();
1742 filter_state.tag_filter_text.clear();
1743 tag_filter_mode = false;
1744 filter_dirty = true;
1745 last_filter_update = Some(std::time::Instant::now());
1746 }
1747 KeyCode::Char('t') => {
1748 tag_filter_mode = !tag_filter_mode;
1749 if tag_filter_mode {
1750 filter_state.tag_filter_text.clear();
1751 filter_dirty = true;
1752 last_filter_update = Some(std::time::Instant::now());
1753 }
1754 }
1755 KeyCode::Char('/') => {
1756 insert_mode = true;
1757 incremental_search.clear();
1758 input_text.clear();
1759 filter.clear();
1760 filter_dirty = true;
1761 last_filter_update = Some(std::time::Instant::now());
1762 }
1763 KeyCode::Esc => {
1764 }
1766 KeyCode::Char('e') => {
1767 if !theme_picker_mode {
1768 theme_picker_mode = true;
1769 }
1770 }
1771 _ => {}
1772 }
1773 }
1774 }
1775 }
1776 Ok(CEvent::Resize(_, _)) => {
1777 needs_redraw = true;
1779 }
1780 Ok(_) => {}
1781 Err(e) => {
1782 tracing::warn!("Event read error: {}", e);
1783 }
1784 }
1785 }
1786 }
1787 Ok(if !filtered.is_empty() && sel.selected < filtered.len() {
1788 Some(SnippetSelection::Selected(
1789 filtered[sel.selected],
1790 should_copy,
1791 ))
1792 } else {
1793 None
1794 })
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799 use super::*;
1800
1801 #[test]
1802 fn test_terminate_functionality() {
1803 let terminate = get_terminate();
1804 terminate.store(false, std::sync::atomic::Ordering::SeqCst);
1805 assert!(!terminate.load(std::sync::atomic::Ordering::SeqCst));
1806
1807 terminate.store(true, std::sync::atomic::Ordering::SeqCst);
1808 assert!(terminate.load(std::sync::atomic::Ordering::SeqCst));
1809 }
1810
1811 #[test]
1812 fn filter_request_detects_incremental_narrowing() {
1813 let previous = FilterRequest {
1814 text: "git".to_string(),
1815 text_lower: "git".to_string(),
1816 include_tags: false,
1817 incremental: false,
1818 };
1819 let current = FilterRequest {
1820 text: "git st".to_string(),
1821 text_lower: "git st".to_string(),
1822 include_tags: false,
1823 incremental: false,
1824 };
1825 assert!(current.can_narrow_from(&previous));
1826
1827 let changed_mode = FilterRequest {
1828 text: "git st".to_string(),
1829 text_lower: "git st".to_string(),
1830 include_tags: true,
1831 incremental: false,
1832 };
1833 assert!(!changed_mode.can_narrow_from(&previous));
1834 }
1835
1836 #[test]
1837 fn rebuild_filter_candidates_matches_display_and_tags() {
1838 let all_display = vec![
1839 "[status]: git status".to_string(),
1840 "[logs]: journalctl -u snip".to_string(),
1841 "[deploy]: ./release".to_string(),
1842 ];
1843 let all_display_lower: Vec<String> = all_display.iter().map(|d| d.to_lowercase()).collect();
1844 let all_tags_search = vec![
1845 "git".to_string(),
1846 "systemd".to_string(),
1847 "release".to_string(),
1848 ];
1849 let all_indices = [0, 1, 2];
1850 let mut candidates = Vec::new();
1851
1852 rebuild_filter_candidates(
1853 &mut candidates,
1854 all_indices.iter().copied(),
1855 &FilterRequest {
1856 text: "systemd".to_string(),
1857 text_lower: "systemd".to_string(),
1858 include_tags: true,
1859 incremental: false,
1860 },
1861 &all_display,
1862 &all_display_lower,
1863 &all_tags_search,
1864 );
1865
1866 assert_eq!(candidates, vec![(1, Some(i64::MAX))]);
1867 }
1868
1869 #[test]
1870 fn current_filter_request_prefers_incremental_search() {
1871 let mut filter_state = FilterState {
1872 sort_mode: SortMode::None,
1873 tag_filter_text: "tag".to_string(),
1874 };
1875
1876 let request = current_filter_request("main", "inc", &filter_state, true);
1877 assert_eq!(
1878 request,
1879 FilterRequest {
1880 text: "inc".to_string(),
1881 text_lower: "inc".to_string(),
1882 include_tags: false,
1883 incremental: true,
1884 }
1885 );
1886
1887 filter_state.tag_filter_text.clear();
1888 let request = current_filter_request("main", "", &filter_state, false);
1889 assert_eq!(
1890 request,
1891 FilterRequest {
1892 text: "main".to_string(),
1893 text_lower: "main".to_string(),
1894 include_tags: false,
1895 incremental: false,
1896 }
1897 );
1898 }
1899
1900 #[test]
1901 fn filter_request_would_narrow_detects_typing_extension() {
1902 let filter_state = FilterState::default();
1903 let previous = FilterRequest {
1904 text: "git".to_string(),
1905 text_lower: "git".to_string(),
1906 include_tags: false,
1907 incremental: false,
1908 };
1909
1910 assert!(filter_request_would_narrow(
1911 "git s",
1912 "",
1913 &filter_state,
1914 false,
1915 Some(&previous)
1916 ));
1917 assert!(!filter_request_would_narrow(
1918 "gi",
1919 "",
1920 &filter_state,
1921 false,
1922 Some(&previous)
1923 ));
1924 }
1925
1926 #[test]
1927 fn next_event_poll_timeout_uses_pending_debounce_deadline() {
1928 let last_update = Instant::now();
1929 let timeout = next_event_poll_timeout(true, Some(last_update), 35, false, None, 100);
1930
1931 assert!(timeout <= Duration::from_millis(35));
1932 }
1933
1934 #[test]
1935 fn bundled_themes_decoder_roundtrip() {
1936 use super::_generated_bundled_themes::bundled_themes_decoded;
1937 let decoded = bundled_themes_decoded().unwrap();
1938 assert!(
1939 decoded.len() >= 40,
1940 "expected ~50 bundled themes, got {}",
1941 decoded.len()
1942 );
1943 for (name, content) in &decoded {
1944 let first_line = content.lines().next().unwrap_or("");
1945 assert!(
1946 first_line.trim() == "[general]",
1947 "theme {name:?} does not start with [general]: {first_line:?}"
1948 );
1949 }
1950 let default = super::_generated_bundled_themes::DEFAULT_BUNDLED;
1951 assert!(default.contains("[general]"));
1952 assert!(default.contains("[text]"));
1953 assert!(default.contains("[buffer]"));
1954 }
1955}