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