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 par_term_config::text::truncate_chars;
15use std::time::Instant;
16
17const SEARCH_DEBOUNCE_MS: u64 = 150;
19
20pub struct SearchUI {
22 pub visible: bool,
24 query: String,
26 case_sensitive: bool,
28 use_regex: bool,
30 whole_word: bool,
32 matches: Vec<SearchMatch>,
34 current_match_index: usize,
36 engine: SearchEngine,
38 last_query_change: Option<Instant>,
40 needs_search: bool,
42 last_searched_query: String,
44 last_searched_case_sensitive: bool,
46 last_searched_use_regex: bool,
48 last_searched_whole_word: bool,
50 request_focus: bool,
52 regex_error: Option<String>,
54}
55
56impl Default for SearchUI {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62impl SearchUI {
63 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 pub fn toggle(&mut self) {
87 self.visible = !self.visible;
88 if self.visible {
89 self.request_focus = true;
90 }
91 }
92
93 pub fn open(&mut self) {
95 self.visible = true;
96 self.request_focus = true;
97 }
98
99 pub fn close(&mut self) {
101 self.visible = false;
102 }
103
104 pub fn query(&self) -> &str {
106 &self.query
107 }
108
109 pub fn matches(&self) -> &[SearchMatch] {
111 &self.matches
112 }
113
114 pub fn current_match_index(&self) -> usize {
116 self.current_match_index
117 }
118
119 pub fn current_match(&self) -> Option<&SearchMatch> {
121 self.matches.get(self.current_match_index)
122 }
123
124 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 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 pub fn update_search<I>(&mut self, lines: I)
158 where
159 I: Iterator<Item = (usize, String)>,
160 {
161 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 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 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 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 if self.current_match_index >= self.matches.len() {
207 self.current_match_index = 0;
208 }
209 }
210
211 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 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 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 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 ui.label(RichText::new("Search:").strong());
278
279 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 if self.request_focus {
289 response.request_focus();
290 self.request_focus = false;
291 }
292
293 if response.changed() {
295 self.last_query_change = Some(Instant::now());
296 self.needs_search = true;
297 }
298
299 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 if ui.input(|i| i.key_pressed(Key::Escape)) {
326 close_requested = true;
327 }
328
329 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 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 if ui
377 .button("\u{f00d}")
378 .on_hover_text("Close (Escape)")
379 .clicked()
380 {
381 close_requested = true;
382 }
383 });
384
385 ui.horizontal(|ui| {
387 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 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 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 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 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 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 fn calculate_scroll_action(
455 &self,
456 match_line: usize,
457 terminal_rows: usize,
458 scrollback_len: usize,
459 ) -> SearchAction {
460 let total_lines = scrollback_len + terminal_rows;
462
463 let lines_from_bottom = total_lines.saturating_sub(match_line + 1);
475
476 let center_offset = terminal_rows / 2;
478
479 let target_offset = lines_from_bottom.saturating_sub(center_offset);
481
482 let clamped_offset = target_offset.min(scrollback_len);
484
485 SearchAction::ScrollToMatch(clamped_offset)
486 }
487
488 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}