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