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