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 fn copy_to_clipboard(&mut self, text: &str, success_msg: &str) {
485 match arboard::Clipboard::new() {
486 Ok(mut cb) => match cb.set_text(text) {
487 Ok(()) => self.status.set(success_msg.to_string()),
488 Err(e) => self.status.set(format!("Clipboard error: {e}")),
489 },
490 Err(e) => self.status.set(format!("Clipboard error: {e}")),
491 }
492 }
493
494 pub fn print_output(&self, group: Option<&str>, count: bool, color: bool) {
496 if count {
497 println!("{}", self.matches.len());
498 return;
499 }
500 if let Some(ref result) = self.replace_result {
501 if color {
502 print_colored_replace(&result.output, &result.segments);
503 } else {
504 print!("{}", result.output);
505 }
506 } else if let Some(group_spec) = group {
507 for m in &self.matches {
508 if let Some(text) = engine::lookup_capture(m, group_spec) {
509 if color {
510 println!("{RED_BOLD}{text}{RESET}");
511 } else {
512 println!("{text}");
513 }
514 } else {
515 eprintln!("rgx: group '{group_spec}' not found in match");
516 }
517 }
518 } else if color {
519 let text = self.test_editor.content();
520 print_colored_matches(text, &self.matches);
521 } else {
522 for m in &self.matches {
523 println!("{}", m.text);
524 }
525 }
526 }
527
528 pub fn print_json_output(&self) {
530 println!(
531 "{}",
532 serde_json::to_string_pretty(&self.matches).unwrap_or_else(|_| "[]".to_string())
533 );
534 }
535
536 fn selected_text(&self) -> Option<String> {
537 let m = self.matches.get(self.selection.match_index)?;
538 match self.selection.capture_index {
539 None => Some(m.text.clone()),
540 Some(ci) => m.captures.get(ci).map(|c| c.text.clone()),
541 }
542 }
543
544 pub fn edit_focused(&mut self, f: impl FnOnce(&mut Editor)) {
547 match self.focused_panel {
548 Self::PANEL_REGEX => {
549 f(&mut self.regex_editor);
550 self.recompute();
551 }
552 Self::PANEL_TEST => {
553 f(&mut self.test_editor);
554 self.rematch();
555 }
556 Self::PANEL_REPLACE => {
557 f(&mut self.replace_editor);
558 self.rereplace();
559 }
560 _ => {}
561 }
562 }
563
564 pub fn move_focused(&mut self, f: impl FnOnce(&mut Editor)) {
566 match self.focused_panel {
567 Self::PANEL_REGEX => f(&mut self.regex_editor),
568 Self::PANEL_TEST => f(&mut self.test_editor),
569 Self::PANEL_REPLACE => f(&mut self.replace_editor),
570 _ => {}
571 }
572 }
573
574 pub fn run_benchmark(&mut self) {
575 let pattern = self.regex_editor.content().to_string();
576 let text = self.test_editor.content().to_string();
577 if pattern.is_empty() || text.is_empty() {
578 return;
579 }
580
581 let mut results = Vec::new();
582 for kind in EngineKind::all() {
583 let eng = engine::create_engine(kind);
584 let compile_start = Instant::now();
585 let compiled = match eng.compile(&pattern, &self.flags) {
586 Ok(c) => c,
587 Err(e) => {
588 results.push(BenchmarkResult {
589 engine: kind,
590 compile_time: compile_start.elapsed(),
591 match_time: Duration::ZERO,
592 match_count: 0,
593 error: Some(e.to_string()),
594 });
595 continue;
596 }
597 };
598 let compile_time = compile_start.elapsed();
599 let match_start = Instant::now();
600 let (match_count, error) = match compiled.find_matches(&text) {
601 Ok(matches) => (matches.len(), None),
602 Err(e) => (0, Some(e.to_string())),
603 };
604 results.push(BenchmarkResult {
605 engine: kind,
606 compile_time,
607 match_time: match_start.elapsed(),
608 match_count,
609 error,
610 });
611 }
612 self.benchmark_results = results;
613 self.overlay.benchmark = true;
614 }
615
616 pub fn regex101_url(&self) -> String {
618 let pattern = self.regex_editor.content();
619 let test_string = self.test_editor.content();
620
621 let flavor = match self.engine_kind {
622 #[cfg(feature = "pcre2-engine")]
623 EngineKind::Pcre2 => "pcre2",
624 _ => "ecmascript",
625 };
626
627 let mut flags = String::from("g");
628 if self.flags.case_insensitive {
629 flags.push('i');
630 }
631 if self.flags.multi_line {
632 flags.push('m');
633 }
634 if self.flags.dot_matches_newline {
635 flags.push('s');
636 }
637 if self.flags.unicode {
638 flags.push('u');
639 }
640 if self.flags.extended {
641 flags.push('x');
642 }
643
644 format!(
645 "https://regex101.com/?regex={}&testString={}&flags={}&flavor={}",
646 url_encode(pattern),
647 url_encode(test_string),
648 url_encode(&flags),
649 flavor,
650 )
651 }
652
653 pub fn copy_regex101_url(&mut self) {
655 let url = self.regex101_url();
656 self.copy_to_clipboard(&url, "regex101 URL copied to clipboard");
657 }
658
659 pub fn generate_code(&mut self, lang: &crate::codegen::Language) {
661 let pattern = self.regex_editor.content().to_string();
662 if pattern.is_empty() {
663 self.status
664 .set("No pattern to generate code for".to_string());
665 return;
666 }
667 let code = crate::codegen::generate_code(lang, &pattern, &self.flags);
668 self.copy_to_clipboard(&code, &format!("{} code copied to clipboard", lang));
669 self.overlay.codegen = false;
670 }
671
672 #[cfg(feature = "pcre2-engine")]
673 pub fn start_debug(&mut self, max_steps: usize) {
674 use crate::engine::pcre2_debug::{self, DebugSession};
675
676 let pattern = self.regex_editor.content().to_string();
677 let subject = self.test_editor.content().to_string();
678 if pattern.is_empty() || subject.is_empty() {
679 self.status
680 .set("Debugger needs both a pattern and test string".to_string());
681 return;
682 }
683
684 if self.engine_kind != EngineKind::Pcre2 {
685 self.switch_engine_to(EngineKind::Pcre2);
686 self.recompute();
687 }
688
689 if let Some(ref cached) = self.debug_cache {
692 if cached.pattern == pattern && cached.subject == subject {
693 self.debug_session = self.debug_cache.take();
694 return;
695 }
696 }
697
698 let start_offset = self.selected_match_start();
699
700 match pcre2_debug::debug_match(&pattern, &subject, &self.flags, max_steps, start_offset) {
701 Ok(trace) => {
702 self.debug_session = Some(DebugSession {
703 trace,
704 step: 0,
705 show_heatmap: false,
706 pattern,
707 subject,
708 });
709 }
710 Err(e) => {
711 self.status.set(format!("Debugger error: {e}"));
712 }
713 }
714 }
715
716 #[cfg(not(feature = "pcre2-engine"))]
717 pub fn start_debug(&mut self, _max_steps: usize) {
718 self.status
719 .set("Debugger requires PCRE2 (build with --features pcre2-engine)".to_string());
720 }
721
722 #[cfg(feature = "pcre2-engine")]
723 fn selected_match_start(&self) -> usize {
724 if !self.matches.is_empty() && self.selection.match_index < self.matches.len() {
725 self.matches[self.selection.match_index].start
726 } else {
727 0
728 }
729 }
730
731 #[cfg(feature = "pcre2-engine")]
732 pub fn close_debug(&mut self) {
733 self.debug_cache = self.debug_session.take();
734 }
735
736 pub fn debug_step_forward(&mut self) {
737 #[cfg(feature = "pcre2-engine")]
738 if let Some(ref mut s) = self.debug_session {
739 if s.step + 1 < s.trace.steps.len() {
740 s.step += 1;
741 }
742 }
743 }
744
745 pub fn debug_step_back(&mut self) {
746 #[cfg(feature = "pcre2-engine")]
747 if let Some(ref mut s) = self.debug_session {
748 s.step = s.step.saturating_sub(1);
749 }
750 }
751
752 pub fn debug_jump_start(&mut self) {
753 #[cfg(feature = "pcre2-engine")]
754 if let Some(ref mut s) = self.debug_session {
755 s.step = 0;
756 }
757 }
758
759 pub fn debug_jump_end(&mut self) {
760 #[cfg(feature = "pcre2-engine")]
761 if let Some(ref mut s) = self.debug_session {
762 if !s.trace.steps.is_empty() {
763 s.step = s.trace.steps.len() - 1;
764 }
765 }
766 }
767
768 pub fn debug_next_match(&mut self) {
769 #[cfg(feature = "pcre2-engine")]
770 if let Some(ref mut s) = self.debug_session {
771 let current_attempt = s
772 .trace
773 .steps
774 .get(s.step)
775 .map(|st| st.match_attempt)
776 .unwrap_or(0);
777 for (i, step) in s.trace.steps.iter().enumerate().skip(s.step + 1) {
778 if step.match_attempt > current_attempt {
779 s.step = i;
780 return;
781 }
782 }
783 }
784 }
785
786 pub fn debug_next_backtrack(&mut self) {
787 #[cfg(feature = "pcre2-engine")]
788 if let Some(ref mut s) = self.debug_session {
789 for (i, step) in s.trace.steps.iter().enumerate().skip(s.step + 1) {
790 if step.is_backtrack {
791 s.step = i;
792 return;
793 }
794 }
795 }
796 }
797
798 pub fn debug_toggle_heatmap(&mut self) {
799 #[cfg(feature = "pcre2-engine")]
800 if let Some(ref mut s) = self.debug_session {
801 s.show_heatmap = !s.show_heatmap;
802 }
803 }
804
805 pub fn handle_action(&mut self, action: Action, debug_max_steps: usize) {
806 match action {
807 Action::Quit => {
808 self.should_quit = true;
809 }
810 Action::OutputAndQuit => {
811 self.output_on_quit = true;
812 self.should_quit = true;
813 }
814 Action::SwitchPanel => {
815 if self.focused_panel == Self::PANEL_REGEX {
816 self.commit_pattern_to_history();
817 }
818 self.focused_panel = (self.focused_panel + 1) % Self::PANEL_COUNT;
819 }
820 Action::SwitchPanelBack => {
821 if self.focused_panel == Self::PANEL_REGEX {
822 self.commit_pattern_to_history();
823 }
824 self.focused_panel =
825 (self.focused_panel + Self::PANEL_COUNT - 1) % Self::PANEL_COUNT;
826 }
827 Action::SwitchEngine => {
828 self.switch_engine();
829 }
830 Action::Undo => {
831 if self.focused_panel == Self::PANEL_REGEX && self.regex_editor.undo() {
832 self.recompute();
833 } else if self.focused_panel == Self::PANEL_TEST && self.test_editor.undo() {
834 self.rematch();
835 } else if self.focused_panel == Self::PANEL_REPLACE && self.replace_editor.undo() {
836 self.rereplace();
837 }
838 }
839 Action::Redo => {
840 if self.focused_panel == Self::PANEL_REGEX && self.regex_editor.redo() {
841 self.recompute();
842 } else if self.focused_panel == Self::PANEL_TEST && self.test_editor.redo() {
843 self.rematch();
844 } else if self.focused_panel == Self::PANEL_REPLACE && self.replace_editor.redo() {
845 self.rereplace();
846 }
847 }
848 Action::HistoryPrev => {
849 if self.focused_panel == Self::PANEL_REGEX {
850 self.history_prev();
851 }
852 }
853 Action::HistoryNext => {
854 if self.focused_panel == Self::PANEL_REGEX {
855 self.history_next();
856 }
857 }
858 Action::CopyMatch => {
859 if self.focused_panel == Self::PANEL_MATCHES {
860 self.copy_selected_match();
861 }
862 }
863 Action::ToggleWhitespace => {
864 self.show_whitespace = !self.show_whitespace;
865 }
866 Action::ToggleCaseInsensitive => {
867 self.flags.toggle_case_insensitive();
868 self.recompute();
869 }
870 Action::ToggleMultiLine => {
871 self.flags.toggle_multi_line();
872 self.recompute();
873 }
874 Action::ToggleDotAll => {
875 self.flags.toggle_dot_matches_newline();
876 self.recompute();
877 }
878 Action::ToggleUnicode => {
879 self.flags.toggle_unicode();
880 self.recompute();
881 }
882 Action::ToggleExtended => {
883 self.flags.toggle_extended();
884 self.recompute();
885 }
886 Action::ShowHelp => {
887 self.overlay.help = true;
888 }
889 Action::OpenRecipes => {
890 self.overlay.recipes = true;
891 self.overlay.recipe_index = 0;
892 }
893 Action::OpenGrex => {
894 self.overlay.grex = Some(crate::ui::grex_overlay::GrexOverlayState::default());
895 }
896 Action::Benchmark => {
897 self.run_benchmark();
898 }
899 Action::ExportRegex101 => {
900 self.copy_regex101_url();
901 }
902 Action::GenerateCode => {
903 self.overlay.codegen = true;
904 self.overlay.codegen_language_index = 0;
905 }
906 Action::InsertChar(c) => self.edit_focused(|ed| ed.insert_char(c)),
907 Action::InsertNewline => {
908 if self.focused_panel == Self::PANEL_TEST {
909 self.test_editor.insert_newline();
910 self.rematch();
911 }
912 }
913 Action::DeleteBack => self.edit_focused(Editor::delete_back),
914 Action::DeleteForward => self.edit_focused(Editor::delete_forward),
915 Action::MoveCursorLeft => self.move_focused(Editor::move_left),
916 Action::MoveCursorRight => self.move_focused(Editor::move_right),
917 Action::MoveCursorWordLeft => self.move_focused(Editor::move_word_left),
918 Action::MoveCursorWordRight => self.move_focused(Editor::move_word_right),
919 Action::ScrollUp => match self.focused_panel {
920 Self::PANEL_TEST => self.test_editor.move_up(),
921 Self::PANEL_MATCHES => self.select_match_prev(),
922 Self::PANEL_EXPLAIN => self.scroll_explain_up(),
923 _ => {}
924 },
925 Action::ScrollDown => match self.focused_panel {
926 Self::PANEL_TEST => self.test_editor.move_down(),
927 Self::PANEL_MATCHES => self.select_match_next(),
928 Self::PANEL_EXPLAIN => self.scroll_explain_down(),
929 _ => {}
930 },
931 Action::MoveCursorHome => self.move_focused(Editor::move_home),
932 Action::MoveCursorEnd => self.move_focused(Editor::move_end),
933 Action::DeleteCharAtCursor => self.edit_focused(Editor::delete_char_at_cursor),
934 Action::DeleteLine => self.edit_focused(Editor::delete_line),
935 Action::ChangeLine => self.edit_focused(Editor::clear_line),
936 Action::OpenLineBelow => {
937 if self.focused_panel == Self::PANEL_TEST {
938 self.test_editor.open_line_below();
939 self.rematch();
940 } else {
941 self.vim_state.cancel_insert();
942 }
943 }
944 Action::OpenLineAbove => {
945 if self.focused_panel == Self::PANEL_TEST {
946 self.test_editor.open_line_above();
947 self.rematch();
948 } else {
949 self.vim_state.cancel_insert();
950 }
951 }
952 Action::MoveToFirstNonBlank => self.move_focused(Editor::move_to_first_non_blank),
953 Action::MoveToFirstLine => self.move_focused(Editor::move_to_first_line),
954 Action::MoveToLastLine => self.move_focused(Editor::move_to_last_line),
955 Action::MoveCursorWordForwardEnd => self.move_focused(Editor::move_word_forward_end),
956 Action::EnterInsertMode => {}
957 Action::EnterInsertModeAppend => self.move_focused(Editor::move_right),
958 Action::EnterInsertModeLineStart => self.move_focused(Editor::move_to_first_non_blank),
959 Action::EnterInsertModeLineEnd => self.move_focused(Editor::move_end),
960 Action::EnterNormalMode => self.move_focused(Editor::move_left_in_line),
961 Action::PasteClipboard => {
962 if let Ok(mut cb) = arboard::Clipboard::new() {
963 if let Ok(text) = cb.get_text() {
964 self.edit_focused(|ed| ed.insert_str(&text));
965 }
966 }
967 }
968 Action::ToggleDebugger => {
969 #[cfg(feature = "pcre2-engine")]
970 if self.debug_session.is_some() {
971 self.close_debug();
972 } else {
973 self.start_debug(debug_max_steps);
974 }
975 #[cfg(not(feature = "pcre2-engine"))]
976 self.start_debug(debug_max_steps);
977 }
978 Action::SaveWorkspace | Action::None => {}
979 }
980 }
981
982 pub fn maybe_run_grex_generation(&mut self) {
986 let Some(overlay) = self.overlay.grex.as_mut() else {
987 return;
988 };
989 let Some(deadline) = overlay.debounce_deadline else {
990 return;
991 };
992 if std::time::Instant::now() < deadline {
993 return;
994 }
995 overlay.debounce_deadline = None;
996 overlay.generation_counter += 1;
997 let counter = overlay.generation_counter;
998 let examples: Vec<String> = overlay
999 .editor
1000 .content()
1001 .lines()
1002 .filter(|l| !l.is_empty())
1003 .map(ToString::to_string)
1004 .collect();
1005 let options = overlay.options;
1006 let tx = self.grex_result_tx.clone();
1007
1008 tokio::task::spawn_blocking(move || {
1009 let pattern = crate::grex_integration::generate(&examples, options);
1010 let _ = tx.send((counter, pattern));
1011 });
1012 }
1013
1014 pub fn drain_grex_results(&mut self) {
1017 while let Ok((counter, pattern)) = self.grex_result_rx.try_recv() {
1018 if let Some(overlay) = self.overlay.grex.as_mut() {
1019 if counter == overlay.generation_counter {
1020 overlay.generated_pattern = Some(pattern);
1021 }
1022 }
1023 }
1024 }
1025
1026 pub fn dispatch_grex_overlay_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
1029 use crossterm::event::{KeyCode, KeyModifiers};
1030 const DEBOUNCE_MS: u64 = 150;
1031 let debounce = std::time::Duration::from_millis(DEBOUNCE_MS);
1032
1033 let Some(overlay) = self.overlay.grex.as_mut() else {
1034 return false;
1035 };
1036
1037 match key.code {
1039 KeyCode::Esc => {
1040 self.overlay.grex = None;
1041 return true;
1042 }
1043 KeyCode::Tab => {
1044 let pattern = overlay
1045 .generated_pattern
1046 .as_deref()
1047 .filter(|p| !p.is_empty())
1048 .map(str::to_string);
1049 if let Some(pattern) = pattern {
1050 self.set_pattern(&pattern);
1051 self.overlay.grex = None;
1052 }
1053 return true;
1054 }
1055 _ => {}
1056 }
1057
1058 if key.modifiers.contains(KeyModifiers::ALT) {
1060 match key.code {
1061 KeyCode::Char('d') => {
1062 overlay.options.digit = !overlay.options.digit;
1063 overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1064 return true;
1065 }
1066 KeyCode::Char('a') => {
1067 overlay.options.anchors = !overlay.options.anchors;
1068 overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1069 return true;
1070 }
1071 KeyCode::Char('c') => {
1072 overlay.options.case_insensitive = !overlay.options.case_insensitive;
1073 overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1074 return true;
1075 }
1076 _ => {}
1077 }
1078 }
1079
1080 let mut consumed = true;
1082 match key.code {
1083 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
1084 overlay.editor.insert_char(c);
1085 }
1086 KeyCode::Enter => overlay.editor.insert_newline(),
1087 KeyCode::Backspace => overlay.editor.delete_back(),
1088 KeyCode::Delete => overlay.editor.delete_forward(),
1089 KeyCode::Left if key.modifiers.contains(KeyModifiers::CONTROL) => {
1090 overlay.editor.move_word_left();
1091 }
1092 KeyCode::Right if key.modifiers.contains(KeyModifiers::CONTROL) => {
1093 overlay.editor.move_word_right();
1094 }
1095 KeyCode::Left => overlay.editor.move_left(),
1096 KeyCode::Right => overlay.editor.move_right(),
1097 KeyCode::Up => overlay.editor.move_up(),
1098 KeyCode::Down => overlay.editor.move_down(),
1099 KeyCode::Home => overlay.editor.move_home(),
1100 KeyCode::End => overlay.editor.move_end(),
1101 _ => consumed = false,
1102 }
1103
1104 if consumed {
1105 overlay.debounce_deadline = Some(std::time::Instant::now() + debounce);
1106 }
1107 consumed
1108 }
1109}
1110
1111fn url_encode(s: &str) -> String {
1112 let mut out = String::with_capacity(s.len() * 3);
1113 for b in s.bytes() {
1114 match b {
1115 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1116 out.push(b as char);
1117 }
1118 _ => {
1119 out.push_str(&format!("%{b:02X}"));
1120 }
1121 }
1122 }
1123 out
1124}
1125
1126fn print_colored_matches(text: &str, matches: &[engine::Match]) {
1127 let mut pos = 0;
1128 for m in matches {
1129 if m.start > pos {
1130 print!("{}", &text[pos..m.start]);
1131 }
1132 print!("{RED_BOLD}{}{RESET}", &text[m.start..m.end]);
1133 pos = m.end;
1134 }
1135 if pos < text.len() {
1136 print!("{}", &text[pos..]);
1137 }
1138 if !text.ends_with('\n') {
1139 println!();
1140 }
1141}
1142
1143fn print_colored_replace(output: &str, segments: &[engine::ReplaceSegment]) {
1145 for seg in segments {
1146 let chunk = &output[seg.start..seg.end];
1147 if seg.is_replacement {
1148 print!("{GREEN_BOLD}{chunk}{RESET}");
1149 } else {
1150 print!("{chunk}");
1151 }
1152 }
1153 if !output.ends_with('\n') {
1154 println!();
1155 }
1156}