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