1use std::collections::VecDeque;
2use std::time::{Duration, Instant};
3
4use crate::ansi::{GREEN_BOLD, RED_BOLD, RESET};
5use crate::engine::{self, CompiledRegex, EngineFlags, EngineKind, RegexEngine};
6use crate::explain::{self, ExplainNode};
7use crate::input::editor::Editor;
8use crate::input::Action;
9
10const MAX_PATTERN_HISTORY: usize = 100;
11const STATUS_DISPLAY_TICKS: u32 = 40; #[derive(Debug, Clone)]
14pub struct BenchmarkResult {
15 pub engine: EngineKind,
16 pub compile_time: Duration,
17 pub match_time: Duration,
18 pub match_count: usize,
19 pub error: Option<String>,
20}
21
22fn truncate(s: &str, max_chars: usize) -> String {
23 match s.char_indices().nth(max_chars) {
27 Some((end, _)) => format!("{}...", &s[..end]),
28 None => s.to_string(),
29 }
30}
31
32#[derive(Default)]
33pub struct OverlayState {
34 pub help: bool,
35 pub help_page: usize,
36 pub recipes: bool,
37 pub recipe_index: usize,
38 pub benchmark: bool,
39 pub codegen: bool,
40 pub codegen_language_index: usize,
41 pub grex: Option<crate::ui::grex_overlay::GrexOverlayState>,
42}
43
44#[derive(Default)]
45pub struct ScrollState {
46 pub match_scroll: u16,
47 pub replace_scroll: u16,
48 pub explain_scroll: u16,
49}
50
51#[derive(Default)]
52pub struct PatternHistory {
53 pub entries: VecDeque<String>,
54 pub index: Option<usize>,
55 pub temp: Option<String>,
56}
57
58#[derive(Default)]
59pub struct MatchSelection {
60 pub match_index: usize,
61 pub capture_index: Option<usize>,
62}
63
64#[derive(Default)]
65pub struct StatusMessage {
66 pub text: Option<String>,
67 ticks: u32,
68}
69
70impl StatusMessage {
71 pub fn set(&mut self, message: String) {
72 self.text = Some(message);
73 self.ticks = STATUS_DISPLAY_TICKS;
74 }
75
76 pub fn tick(&mut self) -> bool {
77 if self.text.is_some() {
78 if self.ticks > 0 {
79 self.ticks -= 1;
80 } else {
81 self.text = None;
82 return true;
83 }
84 }
85 false
86 }
87}
88
89pub struct App {
90 pub regex_editor: Editor,
91 pub test_editor: Editor,
92 pub replace_editor: Editor,
93 pub focused_panel: u8,
94 pub engine_kind: EngineKind,
95 pub flags: EngineFlags,
96 pub matches: Vec<engine::Match>,
97 pub replace_result: Option<engine::ReplaceResult>,
98 pub explanation: Vec<ExplainNode>,
99 pub error: Option<String>,
100 pub overlay: OverlayState,
101 pub should_quit: bool,
102 pub scroll: ScrollState,
103 pub history: PatternHistory,
104 pub selection: MatchSelection,
105 pub status: StatusMessage,
106 pub show_whitespace: bool,
107 pub rounded_borders: bool,
108 pub vim_mode: bool,
109 pub vim_state: crate::input::vim::VimState,
110 pub compile_time: Option<Duration>,
111 pub match_time: Option<Duration>,
112 pub error_offset: Option<usize>,
113 pub output_on_quit: bool,
114 pub workspace_path: Option<String>,
115 pub benchmark_results: Vec<BenchmarkResult>,
116 pub syntax_tokens: Vec<crate::ui::syntax_highlight::SyntaxToken>,
117 #[cfg(feature = "pcre2-engine")]
118 pub debug_session: Option<crate::engine::pcre2_debug::DebugSession>,
119 #[cfg(feature = "pcre2-engine")]
120 debug_cache: Option<crate::engine::pcre2_debug::DebugSession>,
121 pub grex_result_tx: tokio::sync::mpsc::UnboundedSender<(u64, String)>,
122 grex_result_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, String)>,
123 engine: Box<dyn RegexEngine>,
124 compiled: Option<Box<dyn CompiledRegex>>,
125}
126
127impl App {
128 pub const PANEL_REGEX: u8 = 0;
129 pub const PANEL_TEST: u8 = 1;
130 pub const PANEL_REPLACE: u8 = 2;
131 pub const PANEL_MATCHES: u8 = 3;
132 pub const PANEL_EXPLAIN: u8 = 4;
133 pub const PANEL_COUNT: u8 = 5;
134}
135
136impl App {
137 pub fn new(engine_kind: EngineKind, flags: EngineFlags) -> Self {
138 let engine = engine::create_engine(engine_kind);
139 let (grex_result_tx, grex_result_rx) = tokio::sync::mpsc::unbounded_channel();
140 Self {
141 regex_editor: Editor::new(),
142 test_editor: Editor::new(),
143 replace_editor: Editor::new(),
144 focused_panel: 0,
145 engine_kind,
146 flags,
147 matches: Vec::new(),
148 replace_result: None,
149 explanation: Vec::new(),
150 error: None,
151 overlay: OverlayState::default(),
152 should_quit: false,
153 scroll: ScrollState::default(),
154 history: PatternHistory::default(),
155 selection: MatchSelection::default(),
156 status: StatusMessage::default(),
157 show_whitespace: false,
158 rounded_borders: false,
159 vim_mode: false,
160 vim_state: crate::input::vim::VimState::new(),
161 compile_time: None,
162 match_time: None,
163 error_offset: None,
164 output_on_quit: false,
165 workspace_path: None,
166 benchmark_results: Vec::new(),
167 syntax_tokens: Vec::new(),
168 #[cfg(feature = "pcre2-engine")]
169 debug_session: None,
170 #[cfg(feature = "pcre2-engine")]
171 debug_cache: None,
172 grex_result_tx,
173 grex_result_rx,
174 engine,
175 compiled: None,
176 }
177 }
178
179 pub fn set_replacement(&mut self, text: &str) {
180 self.replace_editor = Editor::with_content(text.to_string());
181 self.rereplace();
182 }
183
184 pub fn scroll_replace_up(&mut self) {
185 self.scroll.replace_scroll = self.scroll.replace_scroll.saturating_sub(1);
186 }
187
188 pub fn scroll_replace_down(&mut self) {
189 self.scroll.replace_scroll = self.scroll.replace_scroll.saturating_add(1);
190 }
191
192 pub fn rereplace(&mut self) {
193 let template = self.replace_editor.content().to_string();
194 if template.is_empty() || self.matches.is_empty() {
195 self.replace_result = None;
196 return;
197 }
198 let text = self.test_editor.content().to_string();
199 self.replace_result = Some(engine::replace_all(&text, &self.matches, &template));
200 }
201
202 pub fn set_pattern(&mut self, pattern: &str) {
203 self.regex_editor = Editor::with_content(pattern.to_string());
204 self.recompute();
205 }
206
207 pub fn set_test_string(&mut self, text: &str) {
208 self.test_editor = Editor::with_content(text.to_string());
209 self.rematch();
210 }
211
212 pub fn switch_engine(&mut self) {
213 self.engine_kind = self.engine_kind.next();
214 self.engine = engine::create_engine(self.engine_kind);
215 self.recompute();
216 }
217
218 pub fn switch_engine_to(&mut self, kind: EngineKind) {
221 self.engine_kind = kind;
222 self.engine = engine::create_engine(kind);
223 }
224
225 pub fn scroll_match_up(&mut self) {
226 self.scroll.match_scroll = self.scroll.match_scroll.saturating_sub(1);
227 }
228
229 pub fn scroll_match_down(&mut self) {
230 self.scroll.match_scroll = self.scroll.match_scroll.saturating_add(1);
231 }
232
233 pub fn scroll_explain_up(&mut self) {
234 self.scroll.explain_scroll = self.scroll.explain_scroll.saturating_sub(1);
235 }
236
237 pub fn scroll_explain_down(&mut self) {
238 self.scroll.explain_scroll = self.scroll.explain_scroll.saturating_add(1);
239 }
240
241 pub fn recompute(&mut self) {
242 let pattern = self.regex_editor.content().to_string();
243 self.scroll.match_scroll = 0;
244 self.scroll.explain_scroll = 0;
245 self.error_offset = None;
246
247 if pattern.is_empty() {
248 self.compiled = None;
249 self.matches.clear();
250 self.explanation.clear();
251 self.error = None;
252 self.compile_time = None;
253 self.match_time = None;
254 self.syntax_tokens.clear();
255 return;
256 }
257
258 let suggested = engine::detect_minimum_engine(&pattern);
261 if engine::is_engine_upgrade(self.engine_kind, suggested) {
262 let prev = self.engine_kind;
263 self.engine_kind = suggested;
264 self.engine = engine::create_engine(suggested);
265 self.status.set(format!(
266 "Auto-switched {} \u{2192} {} for this pattern",
267 prev, suggested,
268 ));
269 }
270
271 let compile_start = Instant::now();
273 match self.engine.compile(&pattern, &self.flags) {
274 Ok(compiled) => {
275 self.compile_time = Some(compile_start.elapsed());
276 self.compiled = Some(compiled);
277 self.error = None;
278 }
279 Err(e) => {
280 self.compile_time = Some(compile_start.elapsed());
281 self.compiled = None;
282 self.matches.clear();
283 self.error = Some(e.to_string());
284 }
285 }
286
287 self.syntax_tokens = crate::ui::syntax_highlight::highlight(&pattern);
289
290 match explain::explain(&pattern) {
301 Ok(nodes) => self.explanation = nodes,
302 Err((msg, offset)) => {
303 self.explanation.clear();
304 if self.error.is_some() {
305 if self.error_offset.is_none() {
308 self.error_offset = offset;
309 }
310 } else {
311 let _ = msg;
316 let _ = offset;
317 }
318 }
319 }
320
321 self.rematch();
323 }
324
325 pub fn rematch(&mut self) {
326 self.scroll.match_scroll = 0;
327 self.selection.match_index = 0;
328 self.selection.capture_index = None;
329 if let Some(compiled) = &self.compiled {
330 let text = self.test_editor.content().to_string();
331 if text.is_empty() {
332 self.matches.clear();
333 self.replace_result = None;
334 self.match_time = None;
335 return;
336 }
337 let match_start = Instant::now();
338 match compiled.find_matches(&text) {
339 Ok(m) => {
340 self.match_time = Some(match_start.elapsed());
341 self.matches = m;
342 }
343 Err(e) => {
344 self.match_time = Some(match_start.elapsed());
345 self.matches.clear();
346 self.error = Some(e.to_string());
347 }
348 }
349 } else {
350 self.matches.clear();
351 self.match_time = None;
352 }
353 self.rereplace();
354 }
355
356 pub fn commit_pattern_to_history(&mut self) {
359 let pattern = self.regex_editor.content().to_string();
360 if pattern.is_empty() {
361 return;
362 }
363 if self.history.entries.back().map(String::as_str) == Some(&pattern) {
364 return;
365 }
366 self.history.entries.push_back(pattern);
367 if self.history.entries.len() > MAX_PATTERN_HISTORY {
368 self.history.entries.pop_front();
369 }
370 self.history.index = None;
371 self.history.temp = None;
372 }
373
374 pub fn history_prev(&mut self) {
375 if self.history.entries.is_empty() {
376 return;
377 }
378 let new_index = match self.history.index {
379 Some(0) => return,
380 Some(idx) => idx - 1,
381 None => {
382 self.history.temp = Some(self.regex_editor.content().to_string());
383 self.history.entries.len() - 1
384 }
385 };
386 self.history.index = Some(new_index);
387 let pattern = self.history.entries[new_index].clone();
388 self.regex_editor = Editor::with_content(pattern);
389 self.recompute();
390 }
391
392 pub fn history_next(&mut self) {
393 let Some(idx) = self.history.index else {
394 return;
395 };
396 if idx + 1 < self.history.entries.len() {
397 let new_index = idx + 1;
398 self.history.index = Some(new_index);
399 let pattern = self.history.entries[new_index].clone();
400 self.regex_editor = Editor::with_content(pattern);
401 self.recompute();
402 } else {
403 self.history.index = None;
405 let content = self.history.temp.take().unwrap_or_default();
406 self.regex_editor = Editor::with_content(content);
407 self.recompute();
408 }
409 }
410
411 pub fn select_match_next(&mut self) {
414 if self.matches.is_empty() {
415 return;
416 }
417 match self.selection.capture_index {
418 None => {
419 let m = &self.matches[self.selection.match_index];
420 if !m.captures.is_empty() {
421 self.selection.capture_index = Some(0);
422 } else if self.selection.match_index + 1 < self.matches.len() {
423 self.selection.match_index += 1;
424 }
425 }
426 Some(ci) => {
427 let m = &self.matches[self.selection.match_index];
428 if ci + 1 < m.captures.len() {
429 self.selection.capture_index = Some(ci + 1);
430 } else if self.selection.match_index + 1 < self.matches.len() {
431 self.selection.match_index += 1;
432 self.selection.capture_index = None;
433 }
434 }
435 }
436 self.scroll_to_selected();
437 }
438
439 pub fn select_match_prev(&mut self) {
440 if self.matches.is_empty() {
441 return;
442 }
443 match self.selection.capture_index {
444 Some(0) => {
445 self.selection.capture_index = None;
446 }
447 Some(ci) => {
448 self.selection.capture_index = Some(ci - 1);
449 }
450 None => {
451 if self.selection.match_index > 0 {
452 self.selection.match_index -= 1;
453 let m = &self.matches[self.selection.match_index];
454 if !m.captures.is_empty() {
455 self.selection.capture_index = Some(m.captures.len() - 1);
456 }
457 }
458 }
459 }
460 self.scroll_to_selected();
461 }
462
463 fn scroll_to_selected(&mut self) {
464 if self.matches.is_empty() || self.selection.match_index >= self.matches.len() {
465 return;
466 }
467 let mut line = 0usize;
468 for i in 0..self.selection.match_index {
469 line += 1 + self.matches[i].captures.len();
470 }
471 if let Some(ci) = self.selection.capture_index {
472 line += 1 + ci;
473 }
474 self.scroll.match_scroll = u16::try_from(line).unwrap_or(u16::MAX);
475 }
476
477 pub fn copy_selected_match(&mut self) {
478 let text = self.selected_text();
479 let Some(text) = text else { return };
480 let msg = format!("Copied: \"{}\"", truncate(&text, 40));
481 self.copy_to_clipboard(&text, &msg);
482 }
483
484 pub fn copy_pattern(&mut self) {
485 let pattern = self.regex_editor.content().to_string();
486 if pattern.is_empty() {
487 return;
488 }
489 let msg = format!("Copied pattern: \"{}\"", truncate(&pattern, 40));
490 self.copy_to_clipboard(&pattern, &msg);
491 }
492
493 fn copy_to_clipboard(&mut self, text: &str, success_msg: &str) {
494 match arboard::Clipboard::new() {
495 Ok(mut cb) => match cb.set_text(text) {
496 Ok(()) => self.status.set(success_msg.to_string()),
497 Err(e) => self.status.set(format!("Clipboard error: {e}")),
498 },
499 Err(e) => self.status.set(format!("Clipboard error: {e}")),
500 }
501 }
502
503 pub fn print_output(&self, group: Option<&str>, count: bool, color: bool) {
505 if count {
506 println!("{}", self.matches.len());
507 return;
508 }
509 if let Some(ref result) = self.replace_result {
510 if color {
511 print_colored_replace(&result.output, &result.segments);
512 } else {
513 print!("{}", result.output);
514 }
515 } else if let Some(group_spec) = group {
516 for m in &self.matches {
517 if let Some(text) = engine::lookup_capture(m, group_spec) {
518 if color {
519 println!("{RED_BOLD}{text}{RESET}");
520 } else {
521 println!("{text}");
522 }
523 } else {
524 eprintln!("rgx: group '{group_spec}' not found in match");
525 }
526 }
527 } else if color {
528 let text = self.test_editor.content();
529 print_colored_matches(text, &self.matches);
530 } else {
531 for m in &self.matches {
532 println!("{}", m.text);
533 }
534 }
535 }
536
537 pub fn print_json_output(&self) {
539 println!(
540 "{}",
541 serde_json::to_string_pretty(&self.matches).unwrap_or_else(|_| "[]".to_string())
542 );
543 }
544
545 fn selected_text(&self) -> Option<String> {
546 let m = self.matches.get(self.selection.match_index)?;
547 match self.selection.capture_index {
548 None => Some(m.text.clone()),
549 Some(ci) => m.captures.get(ci).map(|c| c.text.clone()),
550 }
551 }
552
553 pub fn edit_focused(&mut self, f: impl FnOnce(&mut Editor)) {
556 match self.focused_panel {
557 Self::PANEL_REGEX => {
558 f(&mut self.regex_editor);
559 self.recompute();
560 }
561 Self::PANEL_TEST => {
562 f(&mut self.test_editor);
563 self.rematch();
564 }
565 Self::PANEL_REPLACE => {
566 f(&mut self.replace_editor);
567 self.rereplace();
568 }
569 _ => {}
570 }
571 }
572
573 pub fn move_focused(&mut self, f: impl FnOnce(&mut Editor)) {
575 match self.focused_panel {
576 Self::PANEL_REGEX => f(&mut self.regex_editor),
577 Self::PANEL_TEST => f(&mut self.test_editor),
578 Self::PANEL_REPLACE => f(&mut self.replace_editor),
579 _ => {}
580 }
581 }
582
583 pub fn run_benchmark(&mut self) {
584 let pattern = self.regex_editor.content().to_string();
585 let text = self.test_editor.content().to_string();
586 if pattern.is_empty() || text.is_empty() {
587 return;
588 }
589
590 let mut results = Vec::new();
591 for kind in EngineKind::all() {
592 let eng = engine::create_engine(kind);
593 let compile_start = Instant::now();
594 let compiled = match eng.compile(&pattern, &self.flags) {
595 Ok(c) => c,
596 Err(e) => {
597 results.push(BenchmarkResult {
598 engine: kind,
599 compile_time: compile_start.elapsed(),
600 match_time: Duration::ZERO,
601 match_count: 0,
602 error: Some(e.to_string()),
603 });
604 continue;
605 }
606 };
607 let compile_time = compile_start.elapsed();
608 let match_start = Instant::now();
609 let (match_count, error) = match compiled.find_matches(&text) {
610 Ok(matches) => (matches.len(), None),
611 Err(e) => (0, Some(e.to_string())),
612 };
613 results.push(BenchmarkResult {
614 engine: kind,
615 compile_time,
616 match_time: match_start.elapsed(),
617 match_count,
618 error,
619 });
620 }
621 self.benchmark_results = results;
622 self.overlay.benchmark = true;
623 }
624
625 pub fn regex101_url(&self) -> String {
627 let pattern = self.regex_editor.content();
628 let test_string = self.test_editor.content();
629
630 let flavor = match self.engine_kind {
631 #[cfg(feature = "pcre2-engine")]
632 EngineKind::Pcre2 => "pcre2",
633 _ => "ecmascript",
634 };
635
636 let mut flags = String::from("g");
637 if self.flags.case_insensitive {
638 flags.push('i');
639 }
640 if self.flags.multi_line {
641 flags.push('m');
642 }
643 if self.flags.dot_matches_newline {
644 flags.push('s');
645 }
646 if self.flags.unicode {
647 flags.push('u');
648 }
649 if self.flags.extended {
650 flags.push('x');
651 }
652
653 format!(
654 "https://regex101.com/?regex={}&testString={}&flags={}&flavor={}",
655 url_encode(pattern),
656 url_encode(test_string),
657 url_encode(&flags),
658 flavor,
659 )
660 }
661
662 pub fn copy_regex101_url(&mut self) {
664 let url = self.regex101_url();
665 self.copy_to_clipboard(&url, "regex101 URL copied to clipboard");
666 }
667
668 pub fn generate_code(&mut self, lang: &crate::codegen::Language) {
670 let pattern = self.regex_editor.content().to_string();
671 if pattern.is_empty() {
672 self.status
673 .set("No pattern to generate code for".to_string());
674 return;
675 }
676 let code = crate::codegen::generate_code(lang, &pattern, &self.flags);
677 self.copy_to_clipboard(&code, &format!("{} code copied to clipboard", lang));
678 self.overlay.codegen = false;
679 }
680
681 #[cfg(feature = "pcre2-engine")]
682 pub fn start_debug(&mut self, max_steps: usize) {
683 use crate::engine::pcre2_debug::{self, DebugSession};
684
685 let pattern = self.regex_editor.content().to_string();
686 let subject = self.test_editor.content().to_string();
687 if pattern.is_empty() || subject.is_empty() {
688 self.status
689 .set("Debugger needs both a pattern and test string".to_string());
690 return;
691 }
692
693 if self.engine_kind != EngineKind::Pcre2 {
694 self.switch_engine_to(EngineKind::Pcre2);
695 self.recompute();
696 }
697
698 if let Some(ref cached) = self.debug_cache {
701 if cached.pattern == pattern && cached.subject == subject {
702 self.debug_session = self.debug_cache.take();
703 return;
704 }
705 }
706
707 let start_offset = self.selected_match_start();
708
709 match pcre2_debug::debug_match(&pattern, &subject, &self.flags, max_steps, start_offset) {
710 Ok(trace) => {
711 self.debug_session = Some(DebugSession {
712 trace,
713 step: 0,
714 show_heatmap: false,
715 pattern,
716 subject,
717 });
718 }
719 Err(e) => {
720 self.status.set(format!("Debugger error: {e}"));
721 }
722 }
723 }
724
725 #[cfg(not(feature = "pcre2-engine"))]
726 pub fn start_debug(&mut self, _max_steps: usize) {
727 self.status
728 .set("Debugger requires PCRE2 (build with --features pcre2-engine)".to_string());
729 }
730
731 #[cfg(feature = "pcre2-engine")]
732 fn selected_match_start(&self) -> usize {
733 if !self.matches.is_empty() && self.selection.match_index < self.matches.len() {
734 self.matches[self.selection.match_index].start
735 } else {
736 0
737 }
738 }
739
740 #[cfg(feature = "pcre2-engine")]
741 pub fn close_debug(&mut self) {
742 self.debug_cache = self.debug_session.take();
743 }
744
745 pub fn debug_step_forward(&mut self) {
746 #[cfg(feature = "pcre2-engine")]
747 if let Some(ref mut s) = self.debug_session {
748 if s.step + 1 < s.trace.steps.len() {
749 s.step += 1;
750 }
751 }
752 }
753
754 pub fn debug_step_back(&mut self) {
755 #[cfg(feature = "pcre2-engine")]
756 if let Some(ref mut s) = self.debug_session {
757 s.step = s.step.saturating_sub(1);
758 }
759 }
760
761 pub fn debug_jump_start(&mut self) {
762 #[cfg(feature = "pcre2-engine")]
763 if let Some(ref mut s) = self.debug_session {
764 s.step = 0;
765 }
766 }
767
768 pub fn debug_jump_end(&mut self) {
769 #[cfg(feature = "pcre2-engine")]
770 if let Some(ref mut s) = self.debug_session {
771 if !s.trace.steps.is_empty() {
772 s.step = s.trace.steps.len() - 1;
773 }
774 }
775 }
776
777 pub fn debug_next_match(&mut self) {
778 #[cfg(feature = "pcre2-engine")]
779 if let Some(ref mut s) = self.debug_session {
780 let current_attempt = s
781 .trace
782 .steps
783 .get(s.step)
784 .map(|st| st.match_attempt)
785 .unwrap_or(0);
786 for (i, step) in s.trace.steps.iter().enumerate().skip(s.step + 1) {
787 if step.match_attempt > current_attempt {
788 s.step = i;
789 return;
790 }
791 }
792 }
793 }
794
795 pub fn debug_next_backtrack(&mut self) {
796 #[cfg(feature = "pcre2-engine")]
797 if let Some(ref mut s) = self.debug_session {
798 for (i, step) in s.trace.steps.iter().enumerate().skip(s.step + 1) {
799 if step.is_backtrack {
800 s.step = i;
801 return;
802 }
803 }
804 }
805 }
806
807 pub fn debug_toggle_heatmap(&mut self) {
808 #[cfg(feature = "pcre2-engine")]
809 if let Some(ref mut s) = self.debug_session {
810 s.show_heatmap = !s.show_heatmap;
811 }
812 }
813
814 pub fn handle_action(&mut self, action: Action, debug_max_steps: usize) {
815 match action {
816 Action::Quit => {
817 self.should_quit = true;
818 }
819 Action::OutputAndQuit => {
820 self.output_on_quit = true;
821 self.should_quit = true;
822 }
823 Action::SwitchPanel => {
824 if self.focused_panel == Self::PANEL_REGEX {
825 self.commit_pattern_to_history();
826 }
827 self.focused_panel = (self.focused_panel + 1) % Self::PANEL_COUNT;
828 }
829 Action::SwitchPanelBack => {
830 if self.focused_panel == Self::PANEL_REGEX {
831 self.commit_pattern_to_history();
832 }
833 self.focused_panel =
834 (self.focused_panel + Self::PANEL_COUNT - 1) % Self::PANEL_COUNT;
835 }
836 Action::SwitchEngine => {
837 self.switch_engine();
838 }
839 Action::Undo => {
840 if self.focused_panel == Self::PANEL_REGEX && self.regex_editor.undo() {
841 self.recompute();
842 } else if self.focused_panel == Self::PANEL_TEST && self.test_editor.undo() {
843 self.rematch();
844 } else if self.focused_panel == Self::PANEL_REPLACE && self.replace_editor.undo() {
845 self.rereplace();
846 }
847 }
848 Action::Redo => {
849 if self.focused_panel == Self::PANEL_REGEX && self.regex_editor.redo() {
850 self.recompute();
851 } else if self.focused_panel == Self::PANEL_TEST && self.test_editor.redo() {
852 self.rematch();
853 } else if self.focused_panel == Self::PANEL_REPLACE && self.replace_editor.redo() {
854 self.rereplace();
855 }
856 }
857 Action::HistoryPrev => {
858 if self.focused_panel == Self::PANEL_REGEX {
859 self.history_prev();
860 }
861 }
862 Action::HistoryNext => {
863 if self.focused_panel == Self::PANEL_REGEX {
864 self.history_next();
865 }
866 }
867 Action::CopyMatch => {
868 if self.focused_panel == Self::PANEL_REGEX {
869 self.copy_pattern();
870 } else if self.focused_panel == Self::PANEL_MATCHES {
871 self.copy_selected_match();
872 }
873 }
874 Action::ToggleWhitespace => {
875 self.show_whitespace = !self.show_whitespace;
876 }
877 Action::ToggleCaseInsensitive => {
878 self.flags.toggle_case_insensitive();
879 self.recompute();
880 }
881 Action::ToggleMultiLine => {
882 self.flags.toggle_multi_line();
883 self.recompute();
884 }
885 Action::ToggleDotAll => {
886 self.flags.toggle_dot_matches_newline();
887 self.recompute();
888 }
889 Action::ToggleUnicode => {
890 self.flags.toggle_unicode();
891 self.recompute();
892 }
893 Action::ToggleExtended => {
894 self.flags.toggle_extended();
895 self.recompute();
896 }
897 Action::ShowHelp => {
898 self.overlay.help = true;
899 }
900 Action::OpenRecipes => {
901 self.overlay.recipes = true;
902 self.overlay.recipe_index = 0;
903 }
904 Action::OpenGrex => {
905 self.overlay.grex = Some(crate::ui::grex_overlay::GrexOverlayState::default());
906 }
907 Action::Benchmark => {
908 self.run_benchmark();
909 }
910 Action::ExportRegex101 => {
911 self.copy_regex101_url();
912 }
913 Action::GenerateCode => {
914 self.overlay.codegen = true;
915 self.overlay.codegen_language_index = 0;
916 }
917 Action::InsertChar(c) => self.edit_focused(|ed| ed.insert_char(c)),
918 Action::InsertNewline => {
919 if self.focused_panel == Self::PANEL_TEST {
920 self.test_editor.insert_newline();
921 self.rematch();
922 }
923 }
924 Action::DeleteBack => self.edit_focused(Editor::delete_back),
925 Action::DeleteForward => self.edit_focused(Editor::delete_forward),
926 Action::MoveCursorLeft => self.move_focused(Editor::move_left),
927 Action::MoveCursorRight => self.move_focused(Editor::move_right),
928 Action::MoveCursorWordLeft => self.move_focused(Editor::move_word_left),
929 Action::MoveCursorWordRight => self.move_focused(Editor::move_word_right),
930 Action::ScrollUp => match self.focused_panel {
931 Self::PANEL_TEST => self.test_editor.move_up(),
932 Self::PANEL_MATCHES => self.select_match_prev(),
933 Self::PANEL_EXPLAIN => self.scroll_explain_up(),
934 _ => {}
935 },
936 Action::ScrollDown => match self.focused_panel {
937 Self::PANEL_TEST => self.test_editor.move_down(),
938 Self::PANEL_MATCHES => self.select_match_next(),
939 Self::PANEL_EXPLAIN => self.scroll_explain_down(),
940 _ => {}
941 },
942 Action::MoveCursorHome => self.move_focused(Editor::move_home),
943 Action::MoveCursorEnd => self.move_focused(Editor::move_end),
944 Action::DeleteCharAtCursor => self.edit_focused(Editor::delete_char_at_cursor),
945 Action::DeleteLine => self.edit_focused(Editor::delete_line),
946 Action::ChangeLine => self.edit_focused(Editor::clear_line),
947 Action::OpenLineBelow => {
948 if self.focused_panel == Self::PANEL_TEST {
949 self.test_editor.open_line_below();
950 self.rematch();
951 } else {
952 self.vim_state.cancel_insert();
953 }
954 }
955 Action::OpenLineAbove => {
956 if self.focused_panel == Self::PANEL_TEST {
957 self.test_editor.open_line_above();
958 self.rematch();
959 } else {
960 self.vim_state.cancel_insert();
961 }
962 }
963 Action::MoveToFirstNonBlank => self.move_focused(Editor::move_to_first_non_blank),
964 Action::MoveToFirstLine => self.move_focused(Editor::move_to_first_line),
965 Action::MoveToLastLine => self.move_focused(Editor::move_to_last_line),
966 Action::MoveCursorWordForwardEnd => self.move_focused(Editor::move_word_forward_end),
967 Action::EnterInsertMode => {}
968 Action::EnterInsertModeAppend => self.move_focused(Editor::move_right),
969 Action::EnterInsertModeLineStart => self.move_focused(Editor::move_to_first_non_blank),
970 Action::EnterInsertModeLineEnd => self.move_focused(Editor::move_end),
971 Action::EnterNormalMode => self.move_focused(Editor::move_left_in_line),
972 Action::PasteClipboard => {
973 if let Ok(mut cb) = arboard::Clipboard::new() {
974 if let Ok(text) = cb.get_text() {
975 self.edit_focused(|ed| ed.insert_str(&text));
976 }
977 }
978 }
979 Action::ToggleDebugger => {
980 #[cfg(feature = "pcre2-engine")]
981 if self.debug_session.is_some() {
982 self.close_debug();
983 } else {
984 self.start_debug(debug_max_steps);
985 }
986 #[cfg(not(feature = "pcre2-engine"))]
987 self.start_debug(debug_max_steps);
988 }
989 Action::SaveWorkspace | Action::None => {}
990 }
991 }
992
993 pub fn maybe_run_grex_generation(&mut self) {
997 let Some(overlay) = self.overlay.grex.as_mut() else {
998 return;
999 };
1000 let Some(deadline) = overlay.debounce_deadline else {
1001 return;
1002 };
1003 if std::time::Instant::now() < deadline {
1004 return;
1005 }
1006 overlay.debounce_deadline = None;
1007 overlay.generation_counter += 1;
1008 let counter = overlay.generation_counter;
1009 let examples: Vec<String> = overlay
1010 .editor
1011 .content()
1012 .lines()
1013 .filter(|l| !l.is_empty())
1014 .map(ToString::to_string)
1015 .collect();
1016 let options = overlay.options;
1017 let tx = self.grex_result_tx.clone();
1018
1019 tokio::task::spawn_blocking(move || {
1020 let pattern = crate::grex_integration::generate(&examples, options);
1021 let _ = tx.send((counter, pattern));
1022 });
1023 }
1024
1025 pub fn drain_grex_results(&mut self) {
1028 while let Ok((counter, pattern)) = self.grex_result_rx.try_recv() {
1029 if let Some(overlay) = self.overlay.grex.as_mut() {
1030 if counter == overlay.generation_counter {
1031 overlay.generated_pattern = Some(pattern);
1032 }
1033 }
1034 }
1035 }
1036
1037 pub fn dispatch_grex_overlay_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
1040 use crossterm::event::{KeyCode, KeyModifiers};
1041 const DEBOUNCE_MS: u64 = 150;
1042 let debounce = std::time::Duration::from_millis(DEBOUNCE_MS);
1043
1044 let Some(overlay) = self.overlay.grex.as_mut() else {
1045 return false;
1046 };
1047
1048 match key.code {
1050 KeyCode::Esc => {
1051 self.overlay.grex = None;
1052 return true;
1053 }
1054 KeyCode::Tab => {
1055 let pattern = overlay
1056 .generated_pattern
1057 .as_deref()
1058 .filter(|p| !p.is_empty())
1059 .map(str::to_string);
1060 if let Some(pattern) = pattern {
1061 self.set_pattern(&pattern);
1062 self.overlay.grex = None;
1063 }
1064 return true;
1065 }
1066 _ => {}
1067 }
1068
1069 if key.modifiers.contains(KeyModifiers::ALT) {
1071 match key.code {
1072 KeyCode::Char('d') => {
1073 overlay.options.digit = !overlay.options.digit;
1074 overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1075 return true;
1076 }
1077 KeyCode::Char('a') => {
1078 overlay.options.anchors = !overlay.options.anchors;
1079 overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1080 return true;
1081 }
1082 KeyCode::Char('c') => {
1083 overlay.options.case_insensitive = !overlay.options.case_insensitive;
1084 overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1085 return true;
1086 }
1087 _ => {}
1088 }
1089 }
1090
1091 let mut consumed = true;
1093 match key.code {
1094 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
1095 overlay.editor.insert_char(c);
1096 }
1097 KeyCode::Enter => overlay.editor.insert_newline(),
1098 KeyCode::Backspace => overlay.editor.delete_back(),
1099 KeyCode::Delete => overlay.editor.delete_forward(),
1100 KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => {
1101 overlay.editor.move_word_left();
1102 }
1103 KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => {
1104 overlay.editor.move_word_right();
1105 }
1106 KeyCode::Left => overlay.editor.move_left(),
1107 KeyCode::Right => overlay.editor.move_right(),
1108 KeyCode::Up => overlay.editor.move_up(),
1109 KeyCode::Down => overlay.editor.move_down(),
1110 KeyCode::Home => overlay.editor.move_home(),
1111 KeyCode::End => overlay.editor.move_end(),
1112 _ => consumed = false,
1113 }
1114
1115 if consumed {
1116 overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1117 }
1118 consumed
1119 }
1120}
1121
1122fn url_encode(s: &str) -> String {
1123 let mut out = String::with_capacity(s.len() * 3);
1124 for b in s.bytes() {
1125 match b {
1126 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1127 out.push(b as char);
1128 }
1129 _ => {
1130 out.push_str(&format!("%{b:02X}"));
1131 }
1132 }
1133 }
1134 out
1135}
1136
1137fn print_colored_matches(text: &str, matches: &[engine::Match]) {
1138 let mut pos = 0;
1139 for m in matches {
1140 if m.start > pos {
1141 print!("{}", &text[pos..m.start]);
1142 }
1143 print!("{RED_BOLD}{}{RESET}", &text[m.start..m.end]);
1144 pos = m.end;
1145 }
1146 if pos < text.len() {
1147 print!("{}", &text[pos..]);
1148 }
1149 if !text.ends_with('\n') {
1150 println!();
1151 }
1152}
1153
1154fn print_colored_replace(output: &str, segments: &[engine::ReplaceSegment]) {
1156 for seg in segments {
1157 let chunk = &output[seg.start..seg.end];
1158 if seg.is_replacement {
1159 print!("{GREEN_BOLD}{chunk}{RESET}");
1160 } else {
1161 print!("{chunk}");
1162 }
1163 }
1164 if !output.ends_with('\n') {
1165 println!();
1166 }
1167}