1mod 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
16const SEARCH_DEBOUNCE_MS: u64 = 150;
18
19pub struct SearchUI {
21 pub visible: bool,
23 query: String,
25 case_sensitive: bool,
27 use_regex: bool,
29 whole_word: bool,
31 matches: Vec<SearchMatch>,
33 current_match_index: usize,
35 engine: SearchEngine,
37 last_query_change: Option<Instant>,
39 needs_search: bool,
41 last_searched_query: String,
43 last_searched_case_sensitive: bool,
45 last_searched_use_regex: bool,
47 last_searched_whole_word: bool,
49 request_focus: bool,
51 regex_error: Option<String>,
53}
54
55impl Default for SearchUI {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61impl SearchUI {
62 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 pub fn toggle(&mut self) {
86 self.visible = !self.visible;
87 if self.visible {
88 self.request_focus = true;
89 }
90 }
91
92 pub fn open(&mut self) {
94 self.visible = true;
95 self.request_focus = true;
96 }
97
98 pub fn close(&mut self) {
100 self.visible = false;
101 }
102
103 pub fn query(&self) -> &str {
105 &self.query
106 }
107
108 pub fn matches(&self) -> &[SearchMatch] {
110 &self.matches
111 }
112
113 pub fn current_match_index(&self) -> usize {
115 self.current_match_index
116 }
117
118 pub fn current_match(&self) -> Option<&SearchMatch> {
120 self.matches.get(self.current_match_index)
121 }
122
123 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 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 pub fn update_search<I>(&mut self, lines: I)
157 where
158 I: Iterator<Item = (usize, String)>,
159 {
160 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 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 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 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 if self.current_match_index >= self.matches.len() {
206 self.current_match_index = 0;
207 }
208 }
209
210 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 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 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 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 ui.label(RichText::new("Search:").strong());
277
278 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 if self.request_focus {
288 response.request_focus();
289 self.request_focus = false;
290 }
291
292 if response.changed() {
294 self.last_query_change = Some(Instant::now());
295 self.needs_search = true;
296 }
297
298 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 if ui.input(|i| i.key_pressed(Key::Escape)) {
325 close_requested = true;
326 }
327
328 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 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 if ui
376 .button("\u{2715}")
377 .on_hover_text("Close (Escape)")
378 .clicked()
379 {
380 close_requested = true;
381 }
382 });
383
384 ui.horizontal(|ui| {
386 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 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 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 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 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 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 fn calculate_scroll_action(
454 &self,
455 match_line: usize,
456 terminal_rows: usize,
457 scrollback_len: usize,
458 ) -> SearchAction {
459 let total_lines = scrollback_len + terminal_rows;
461
462 let lines_from_bottom = total_lines.saturating_sub(match_line + 1);
474
475 let center_offset = terminal_rows / 2;
477
478 let target_offset = lines_from_bottom.saturating_sub(center_offset);
480
481 let clamped_offset = target_offset.min(scrollback_len);
483
484 SearchAction::ScrollToMatch(clamped_offset)
485 }
486
487 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
494fn truncate_error(error: &str, max_len: usize) -> &str {
496 if error.len() <= max_len {
497 error
498 } else {
499 &error[..max_len]
500 }
501}