Skip to main content

rgx/
app.rs

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; // ~2 seconds at 50ms tick rate
12
13#[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    /// Low-level engine setter. Does NOT call `recompute()` — the caller
217    /// must trigger recompilation separately if needed.
218    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        // Auto-select engine: upgrade (never downgrade) if the pattern
257        // requires a more powerful engine than the currently active one.
258        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        // Compile
270        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        // Rebuild syntax highlight tokens (pattern changed)
286        self.syntax_tokens = crate::ui::syntax_highlight::highlight(&pattern);
287
288        // Explain (uses regex-syntax, independent of engine)
289        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        // Match
303        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    // --- Pattern history ---
338
339    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            // Past end — restore temp
386            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    // --- Match selection + clipboard ---
394
395    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    /// Print match results or replacement output to stdout.
477    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    /// Print matches as structured JSON.
511    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    /// Apply a mutating editor operation to the currently focused editor panel,
527    /// then trigger the appropriate recompute/rematch/rereplace.
528    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    /// Apply a non-mutating cursor movement to the currently focused editor panel.
547    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    /// Generate a regex101.com URL from the current state.
599    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    /// Copy regex101 URL to clipboard.
636    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    /// Generate code for the current pattern in the given language and copy to clipboard.
642    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        // Restore cached session if pattern and subject haven't changed,
672        // preserving the user's step position and heatmap toggle.
673        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
994/// Print replacement output with replaced segments highlighted.
995fn 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}