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