Skip to main content

par_term/search/
mod.rs

1//! Terminal search functionality.
2//!
3//! This module provides search functionality for the terminal scrollback buffer,
4//! including an egui-based search bar overlay, search engine with regex support,
5//! and match highlighting.
6
7mod engine;
8pub mod types;
9
10pub use engine::SearchEngine;
11pub use types::{SearchAction, SearchConfig, SearchMatch};
12
13use egui::{Color32, Context, Frame, Key, RichText, Window, epaint::Shadow};
14use std::time::Instant;
15
16/// Search debounce delay in milliseconds.
17const SEARCH_DEBOUNCE_MS: u64 = 150;
18
19/// Search UI overlay for terminal.
20pub struct SearchUI {
21    /// Whether the search UI is currently visible.
22    pub visible: bool,
23    /// Current search query.
24    query: String,
25    /// Whether search is case-sensitive.
26    case_sensitive: bool,
27    /// Whether to use regex matching.
28    use_regex: bool,
29    /// Whether to match whole words only.
30    whole_word: bool,
31    /// All matches found.
32    matches: Vec<SearchMatch>,
33    /// Index of the currently highlighted match.
34    current_match_index: usize,
35    /// Search engine instance.
36    engine: SearchEngine,
37    /// Last time the query changed (for debouncing).
38    last_query_change: Option<Instant>,
39    /// Whether search needs to be re-run.
40    needs_search: bool,
41    /// Last query that was actually searched.
42    last_searched_query: String,
43    /// Last case sensitivity setting that was searched.
44    last_searched_case_sensitive: bool,
45    /// Last regex setting that was searched.
46    last_searched_use_regex: bool,
47    /// Last whole word setting that was searched.
48    last_searched_whole_word: bool,
49    /// Whether the text input should request focus.
50    request_focus: bool,
51    /// Regex error message (if any).
52    regex_error: Option<String>,
53}
54
55impl Default for SearchUI {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl SearchUI {
62    /// Create a new search UI.
63    pub fn new() -> Self {
64        Self {
65            visible: false,
66            query: String::new(),
67            case_sensitive: false,
68            use_regex: false,
69            whole_word: false,
70            matches: Vec::new(),
71            current_match_index: 0,
72            engine: SearchEngine::new(),
73            last_query_change: None,
74            needs_search: false,
75            last_searched_query: String::new(),
76            last_searched_case_sensitive: false,
77            last_searched_use_regex: false,
78            last_searched_whole_word: false,
79            request_focus: false,
80            regex_error: None,
81        }
82    }
83
84    /// Toggle search UI visibility.
85    pub fn toggle(&mut self) {
86        self.visible = !self.visible;
87        if self.visible {
88            self.request_focus = true;
89        }
90    }
91
92    /// Open the search UI (ensuring it's visible).
93    pub fn open(&mut self) {
94        self.visible = true;
95        self.request_focus = true;
96    }
97
98    /// Close the search UI.
99    pub fn close(&mut self) {
100        self.visible = false;
101    }
102
103    /// Get the current search query.
104    pub fn query(&self) -> &str {
105        &self.query
106    }
107
108    /// Get all current matches.
109    pub fn matches(&self) -> &[SearchMatch] {
110        &self.matches
111    }
112
113    /// Get the current match index.
114    pub fn current_match_index(&self) -> usize {
115        self.current_match_index
116    }
117
118    /// Get the current match (if any).
119    pub fn current_match(&self) -> Option<&SearchMatch> {
120        self.matches.get(self.current_match_index)
121    }
122
123    /// Move to the next match.
124    ///
125    /// Returns the new current match if navigation succeeded.
126    pub fn next_match(&mut self) -> Option<&SearchMatch> {
127        if self.matches.is_empty() {
128            return None;
129        }
130
131        self.current_match_index = (self.current_match_index + 1) % self.matches.len();
132        self.matches.get(self.current_match_index)
133    }
134
135    /// Move to the previous match.
136    ///
137    /// Returns the new current match if navigation succeeded.
138    pub fn prev_match(&mut self) -> Option<&SearchMatch> {
139        if self.matches.is_empty() {
140            return None;
141        }
142
143        if self.current_match_index == 0 {
144            self.current_match_index = self.matches.len() - 1;
145        } else {
146            self.current_match_index -= 1;
147        }
148
149        self.matches.get(self.current_match_index)
150    }
151
152    /// Update search results with new terminal content.
153    ///
154    /// # Arguments
155    /// * `lines` - Iterator of (line_index, line_text) pairs from scrollback
156    pub fn update_search<I>(&mut self, lines: I)
157    where
158        I: Iterator<Item = (usize, String)>,
159    {
160        // Check if we need to search based on debounce timing
161        if let Some(last_change) = self.last_query_change
162            && last_change.elapsed().as_millis() < SEARCH_DEBOUNCE_MS as u128
163        {
164            return;
165        }
166
167        // Check if settings changed
168        let settings_changed = self.case_sensitive != self.last_searched_case_sensitive
169            || self.use_regex != self.last_searched_use_regex
170            || self.whole_word != self.last_searched_whole_word;
171
172        // Only re-search if query or settings changed
173        if !self.needs_search && self.query == self.last_searched_query && !settings_changed {
174            return;
175        }
176
177        self.needs_search = false;
178        self.last_searched_query = self.query.clone();
179        self.last_searched_case_sensitive = self.case_sensitive;
180        self.last_searched_use_regex = self.use_regex;
181        self.last_searched_whole_word = self.whole_word;
182        self.regex_error = None;
183
184        let config = SearchConfig {
185            case_sensitive: self.case_sensitive,
186            use_regex: self.use_regex,
187            whole_word: self.whole_word,
188            wrap_around: true,
189        };
190
191        // Validate regex before searching
192        if self.use_regex
193            && !self.query.is_empty()
194            && let Err(e) = regex::Regex::new(&self.query)
195        {
196            self.regex_error = Some(e.to_string());
197            self.matches.clear();
198            self.current_match_index = 0;
199            return;
200        }
201
202        self.matches = self.engine.search(lines, &self.query, &config);
203
204        // Reset current match index if it's out of bounds
205        if self.current_match_index >= self.matches.len() {
206            self.current_match_index = 0;
207        }
208    }
209
210    /// Clear search results.
211    pub fn clear(&mut self) {
212        self.query.clear();
213        self.matches.clear();
214        self.current_match_index = 0;
215        self.needs_search = false;
216        self.last_searched_query.clear();
217        self.regex_error = None;
218    }
219
220    /// Show the search UI and return any action to take.
221    ///
222    /// # Arguments
223    /// * `ctx` - egui Context
224    /// * `terminal_rows` - Number of visible terminal rows (for scroll calculation)
225    /// * `scrollback_len` - Total scrollback length
226    ///
227    /// # Returns
228    /// A SearchAction indicating what the caller should do.
229    pub fn show(
230        &mut self,
231        ctx: &Context,
232        terminal_rows: usize,
233        scrollback_len: usize,
234    ) -> SearchAction {
235        if !self.visible {
236            return SearchAction::None;
237        }
238
239        let mut action = SearchAction::None;
240        let mut close_requested = false;
241
242        // Ensure search bar is fully opaque regardless of terminal opacity
243        let mut style = (*ctx.style()).clone();
244        let solid_bg = Color32::from_rgba_unmultiplied(30, 30, 30, 255);
245        style.visuals.window_fill = solid_bg;
246        style.visuals.panel_fill = solid_bg;
247        style.visuals.widgets.noninteractive.bg_fill = solid_bg;
248        ctx.set_style(style);
249
250        let viewport = ctx.input(|i| i.viewport_rect());
251
252        // Position at top of window
253        let window_width = 500.0_f32.min(viewport.width() - 20.0);
254
255        Window::new("Search")
256            .title_bar(false)
257            .resizable(false)
258            .collapsible(false)
259            .fixed_size([window_width, 0.0])
260            .fixed_pos([viewport.center().x - window_width / 2.0, 10.0])
261            .frame(
262                Frame::window(&ctx.style())
263                    .fill(solid_bg)
264                    .stroke(egui::Stroke::new(1.0, Color32::from_gray(60)))
265                    .shadow(Shadow {
266                        offset: [0, 2],
267                        blur: 8,
268                        spread: 0,
269                        color: Color32::from_black_alpha(100),
270                    })
271                    .inner_margin(8.0),
272            )
273            .show(ctx, |ui| {
274                ui.horizontal(|ui| {
275                    // Search icon/label
276                    ui.label(RichText::new("Search:").strong());
277
278                    // Search text input
279                    let response = ui.add_sized(
280                        [ui.available_width() - 180.0, 20.0],
281                        egui::TextEdit::singleline(&mut self.query)
282                            .hint_text("Enter search term...")
283                            .desired_width(f32::INFINITY),
284                    );
285
286                    // Request focus when first opened
287                    if self.request_focus {
288                        response.request_focus();
289                        self.request_focus = false;
290                    }
291
292                    // Track query changes for debouncing
293                    if response.changed() {
294                        self.last_query_change = Some(Instant::now());
295                        self.needs_search = true;
296                    }
297
298                    // Handle Enter key for next match
299                    if response.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) {
300                        let shift = ui.input(|i| i.modifiers.shift);
301                        if shift {
302                            let match_line = self.prev_match().map(|m| m.line);
303                            if let Some(line) = match_line {
304                                action = self.calculate_scroll_action(
305                                    line,
306                                    terminal_rows,
307                                    scrollback_len,
308                                );
309                            }
310                        } else {
311                            let match_line = self.next_match().map(|m| m.line);
312                            if let Some(line) = match_line {
313                                action = self.calculate_scroll_action(
314                                    line,
315                                    terminal_rows,
316                                    scrollback_len,
317                                );
318                            }
319                        }
320                        response.request_focus();
321                    }
322
323                    // Handle Escape key
324                    if ui.input(|i| i.key_pressed(Key::Escape)) {
325                        close_requested = true;
326                    }
327
328                    // Match count display
329                    let match_text = if self.matches.is_empty() {
330                        if self.query.is_empty() {
331                            String::new()
332                        } else if self.regex_error.is_some() {
333                            "Invalid".to_string()
334                        } else {
335                            "No matches".to_string()
336                        }
337                    } else {
338                        format!("{} of {}", self.current_match_index + 1, self.matches.len())
339                    };
340                    ui.label(match_text);
341
342                    // Navigation buttons
343                    ui.add_enabled_ui(!self.matches.is_empty(), |ui| {
344                        if ui
345                            .button("\u{25B2}")
346                            .on_hover_text("Previous (Shift+Enter)")
347                            .clicked()
348                        {
349                            let match_line = self.prev_match().map(|m| m.line);
350                            if let Some(line) = match_line {
351                                action = self.calculate_scroll_action(
352                                    line,
353                                    terminal_rows,
354                                    scrollback_len,
355                                );
356                            }
357                        }
358                        if ui
359                            .button("\u{25BC}")
360                            .on_hover_text("Next (Enter)")
361                            .clicked()
362                        {
363                            let match_line = self.next_match().map(|m| m.line);
364                            if let Some(line) = match_line {
365                                action = self.calculate_scroll_action(
366                                    line,
367                                    terminal_rows,
368                                    scrollback_len,
369                                );
370                            }
371                        }
372                    });
373
374                    // Close button
375                    if ui
376                        .button("\u{2715}")
377                        .on_hover_text("Close (Escape)")
378                        .clicked()
379                    {
380                        close_requested = true;
381                    }
382                });
383
384                // Second row: options
385                ui.horizontal(|ui| {
386                    // Case sensitivity toggle
387                    let case_btn = ui.selectable_label(self.case_sensitive, "Aa");
388                    if case_btn.on_hover_text("Case sensitive").clicked() {
389                        self.case_sensitive = !self.case_sensitive;
390                        self.needs_search = true;
391                    }
392
393                    // Regex toggle
394                    let regex_btn = ui.selectable_label(self.use_regex, ".*");
395                    if regex_btn.on_hover_text("Regular expression").clicked() {
396                        self.use_regex = !self.use_regex;
397                        self.needs_search = true;
398                    }
399
400                    // Whole word toggle
401                    let word_btn = ui.selectable_label(self.whole_word, "\\b");
402                    if word_btn.on_hover_text("Whole word").clicked() {
403                        self.whole_word = !self.whole_word;
404                        self.needs_search = true;
405                    }
406
407                    // Show regex error if present
408                    if let Some(ref error) = self.regex_error {
409                        ui.colored_label(
410                            Color32::from_rgb(255, 100, 100),
411                            format!("Regex error: {}", truncate_error(error, 40)),
412                        );
413                    }
414                });
415
416                // Keyboard hints
417                ui.horizontal(|ui| {
418                    ui.label(
419                        RichText::new("Enter: Next | Shift+Enter: Prev | Escape: Close")
420                            .weak()
421                            .small(),
422                    );
423                });
424            });
425
426        // Handle keyboard shortcuts outside the UI
427        let cmd_g_shift =
428            ctx.input(|i| i.modifiers.command && i.modifiers.shift && i.key_pressed(Key::G));
429        let cmd_g =
430            ctx.input(|i| i.modifiers.command && !i.modifiers.shift && i.key_pressed(Key::G));
431
432        if cmd_g_shift {
433            let match_line = self.prev_match().map(|m| m.line);
434            if let Some(line) = match_line {
435                action = self.calculate_scroll_action(line, terminal_rows, scrollback_len);
436            }
437        } else if cmd_g {
438            let match_line = self.next_match().map(|m| m.line);
439            if let Some(line) = match_line {
440                action = self.calculate_scroll_action(line, terminal_rows, scrollback_len);
441            }
442        }
443
444        if close_requested {
445            self.visible = false;
446            return SearchAction::Close;
447        }
448
449        action
450    }
451
452    /// Calculate the scroll offset needed to show a match at the given line.
453    fn calculate_scroll_action(
454        &self,
455        match_line: usize,
456        terminal_rows: usize,
457        scrollback_len: usize,
458    ) -> SearchAction {
459        // Total lines = scrollback + visible screen
460        let total_lines = scrollback_len + terminal_rows;
461
462        // Calculate scroll offset to center the match on screen
463        // scroll_offset = 0 means we're at the bottom (showing most recent content)
464        // scroll_offset = scrollback_len means we're at the top
465
466        // The match line is in terms of absolute line index (0 = oldest scrollback)
467        // We need to convert this to a scroll_offset
468
469        // If match is in the visible area at the bottom (most recent), scroll_offset = 0
470        // If match is at the very top of scrollback, scroll_offset = scrollback_len
471
472        // Calculate how far from the bottom the match line is
473        let lines_from_bottom = total_lines.saturating_sub(match_line + 1);
474
475        // We want to show the match near the center of the viewport
476        let center_offset = terminal_rows / 2;
477
478        // Scroll offset to put the match at the center
479        let target_offset = lines_from_bottom.saturating_sub(center_offset);
480
481        // Clamp to valid range
482        let clamped_offset = target_offset.min(scrollback_len);
483
484        SearchAction::ScrollToMatch(clamped_offset)
485    }
486
487    /// Initialize search settings from config.
488    pub fn init_from_config(&mut self, case_sensitive: bool, use_regex: bool) {
489        self.case_sensitive = case_sensitive;
490        self.use_regex = use_regex;
491    }
492}
493
494/// Truncate error message for display.
495fn truncate_error(error: &str, max_len: usize) -> &str {
496    if error.len() <= max_len {
497        error
498    } else {
499        &error[..max_len]
500    }
501}