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    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    /// Low-level engine setter. Does NOT call `recompute()` — the caller
223    /// must trigger recompilation separately if needed.
224    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        // Auto-select engine: upgrade (never downgrade) if the pattern
263        // requires a more powerful engine than the currently active one.
264        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        // Compile
276        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        // Rebuild syntax highlight tokens (pattern changed)
292        self.syntax_tokens = crate::ui::syntax_highlight::highlight(&pattern);
293
294        // Explain (uses regex-syntax, independent of engine)
295        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        // Match
309        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    // --- Pattern history ---
344
345    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            // Past end — restore temp
392            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    // --- Match selection + clipboard ---
400
401    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    /// Print match results or replacement output to stdout.
483    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    /// Print matches as structured JSON.
517    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    /// Apply a mutating editor operation to the currently focused editor panel,
533    /// then trigger the appropriate recompute/rematch/rereplace.
534    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    /// Apply a non-mutating cursor movement to the currently focused editor panel.
553    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    /// Generate a regex101.com URL from the current state.
605    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    /// Copy regex101 URL to clipboard.
642    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    /// Generate code for the current pattern in the given language and copy to clipboard.
648    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        // Restore cached session if pattern and subject haven't changed,
678        // preserving the user's step position and heatmap toggle.
679        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    /// If the grex overlay has a pending debounce deadline that has passed, spawn a
971    /// blocking task to regenerate the pattern with the current options. Results are
972    /// delivered via `grex_result_tx` and claimed later by `drain_grex_results`.
973    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    /// Drain any grex generation results that arrived since the last tick, applying
1003    /// only the result that matches the current generation counter.
1004    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    /// Dispatch a key event to the grex overlay. Returns true if the key was consumed.
1015    /// Caller should only invoke this when `self.overlay.grex.is_some()`.
1016    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        // Accept / cancel first — these take precedence regardless of other modifiers.
1026        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        // Flag toggles (Alt+d/a/c). These reset the debounce so the new flags regenerate.
1047        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        // Editor input — dispatch a focused set of keys to the overlay editor.
1069        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
1131/// Print replacement output with replaced segments highlighted.
1132fn 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}