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