Skip to main content

rgx/ui/
mod.rs

1pub mod explanation;
2pub mod match_display;
3pub mod regex_input;
4pub mod replace_input;
5pub mod status_bar;
6pub mod syntax_highlight;
7pub mod test_input;
8pub mod theme;
9
10#[cfg(feature = "pcre2-engine")]
11pub mod debugger;
12
13use ratatui::{
14    layout::{Constraint, Direction, Layout, Rect},
15    style::{Modifier, Style},
16    text::{Line, Span},
17    widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
18    Frame,
19};
20
21use crate::app::{App, BenchmarkResult};
22use crate::codegen;
23use crate::engine::EngineKind;
24use crate::recipe::RECIPES;
25use explanation::ExplanationPanel;
26use match_display::MatchDisplay;
27use regex_input::RegexInput;
28use replace_input::ReplaceInput;
29use status_bar::StatusBar;
30use test_input::TestInput;
31
32/// Returns the border type based on the rounded_borders flag.
33pub(crate) fn border_type(rounded: bool) -> BorderType {
34    if rounded {
35        BorderType::Rounded
36    } else {
37        BorderType::Plain
38    }
39}
40
41/// Panel layout rectangles for mouse hit-testing.
42pub struct PanelLayout {
43    pub regex_input: Rect,
44    pub test_input: Rect,
45    pub replace_input: Rect,
46    pub match_display: Rect,
47    pub explanation: Rect,
48    pub status_bar: Rect,
49}
50
51pub fn compute_layout(size: Rect) -> PanelLayout {
52    let main_chunks = Layout::default()
53        .direction(Direction::Vertical)
54        .constraints([
55            Constraint::Length(3), // regex input
56            Constraint::Length(8), // test string input
57            Constraint::Length(3), // replacement input
58            Constraint::Min(5),    // results area
59            Constraint::Length(1), // status bar
60        ])
61        .split(size);
62
63    let results_chunks = if main_chunks[3].width > 80 {
64        Layout::default()
65            .direction(Direction::Horizontal)
66            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
67            .split(main_chunks[3])
68    } else {
69        Layout::default()
70            .direction(Direction::Vertical)
71            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
72            .split(main_chunks[3])
73    };
74
75    PanelLayout {
76        regex_input: main_chunks[0],
77        test_input: main_chunks[1],
78        replace_input: main_chunks[2],
79        match_display: results_chunks[0],
80        explanation: results_chunks[1],
81        status_bar: main_chunks[4],
82    }
83}
84
85pub fn render(frame: &mut Frame, app: &App) {
86    let size = frame.area();
87    let layout = compute_layout(size);
88
89    let bt = border_type(app.rounded_borders);
90
91    // Overlays
92    if app.overlay.help {
93        render_help_overlay(frame, size, app.engine_kind, app.overlay.help_page, bt);
94        return;
95    }
96    if app.overlay.recipes {
97        render_recipe_overlay(frame, size, app.overlay.recipe_index, bt);
98        return;
99    }
100    if app.overlay.benchmark {
101        render_benchmark_overlay(frame, size, &app.benchmark_results, bt);
102        return;
103    }
104    if app.overlay.codegen {
105        render_codegen_overlay(
106            frame,
107            size,
108            app.overlay.codegen_language_index,
109            app.regex_editor.content(),
110            &app.flags,
111            bt,
112        );
113        return;
114    }
115
116    #[cfg(feature = "pcre2-engine")]
117    if let Some(ref session) = app.debug_session {
118        debugger::render_debugger(frame, size, session, bt);
119        return;
120    }
121
122    let error_str = app.error.as_deref();
123
124    // Regex input
125    frame.render_widget(
126        RegexInput {
127            editor: &app.regex_editor,
128            focused: app.focused_panel == 0,
129            error: error_str,
130            error_offset: app.error_offset,
131            border_type: bt,
132            syntax_tokens: &app.syntax_tokens,
133        },
134        layout.regex_input,
135    );
136
137    // Test string input
138    frame.render_widget(
139        TestInput {
140            editor: &app.test_editor,
141            focused: app.focused_panel == 1,
142            matches: &app.matches,
143            show_whitespace: app.show_whitespace,
144            border_type: bt,
145        },
146        layout.test_input,
147    );
148
149    // Replacement input
150    frame.render_widget(
151        ReplaceInput {
152            editor: &app.replace_editor,
153            focused: app.focused_panel == 2,
154            border_type: bt,
155        },
156        layout.replace_input,
157    );
158
159    // Match display
160    frame.render_widget(
161        MatchDisplay {
162            matches: &app.matches,
163            replace_result: app.replace_result.as_ref(),
164            scroll: app.scroll.match_scroll,
165            focused: app.focused_panel == 3,
166            selected_match: app.selection.match_index,
167            selected_capture: app.selection.capture_index,
168            clipboard_status: app.status.text.as_deref(),
169            border_type: bt,
170        },
171        layout.match_display,
172    );
173
174    // Explanation panel
175    frame.render_widget(
176        ExplanationPanel {
177            nodes: &app.explanation,
178            error: error_str,
179            scroll: app.scroll.explain_scroll,
180            focused: app.focused_panel == 4,
181            border_type: bt,
182        },
183        layout.explanation,
184    );
185
186    // Status bar
187    #[cfg(feature = "pcre2-engine")]
188    let engine_warning: Option<&'static str> =
189        if app.engine_kind == EngineKind::Pcre2 && crate::engine::pcre2::is_pcre2_10_45() {
190            Some("CVE-2025-58050: PCRE2 10.45 linked — upgrade to >= 10.46.")
191        } else {
192            None
193        };
194    #[cfg(not(feature = "pcre2-engine"))]
195    let engine_warning: Option<&'static str> = None;
196
197    frame.render_widget(
198        StatusBar {
199            engine: app.engine_kind,
200            match_count: app.matches.len(),
201            flags: app.flags,
202            show_whitespace: app.show_whitespace,
203            compile_time: app.compile_time,
204            match_time: app.match_time,
205            vim_mode: if app.vim_mode {
206                Some(app.vim_state.mode)
207            } else {
208                None
209            },
210            engine_warning,
211        },
212        layout.status_bar,
213    );
214}
215
216pub const HELP_PAGE_COUNT: usize = 3;
217
218fn build_help_pages(engine: EngineKind) -> Vec<(String, Vec<Line<'static>>)> {
219    let shortcut = |key: &'static str, desc: &'static str| -> Line<'static> {
220        Line::from(vec![
221            Span::styled(format!("{key:<14}"), Style::default().fg(theme::GREEN)),
222            Span::styled(desc, Style::default().fg(theme::TEXT)),
223        ])
224    };
225
226    // Page 0: Keyboard shortcuts
227    let page0 = vec![
228        shortcut("Tab/Shift+Tab", "Cycle focus forward/backward"),
229        shortcut("Up/Down", "Scroll panel / move cursor / select match"),
230        shortcut("Enter", "Insert newline (test string)"),
231        shortcut("Ctrl+E", "Cycle regex engine"),
232        shortcut("Ctrl+Z", "Undo"),
233        shortcut("Ctrl+Shift+Z", "Redo"),
234        shortcut("Ctrl+Y", "Copy selected match to clipboard"),
235        shortcut("Ctrl+O", "Output results to stdout and quit"),
236        shortcut("Ctrl+S", "Save workspace"),
237        shortcut("Ctrl+R", "Open regex recipe library"),
238        shortcut("Ctrl+B", "Benchmark pattern across all engines"),
239        shortcut("Ctrl+U", "Copy regex101.com URL to clipboard"),
240        shortcut("Ctrl+D", "Step-through regex debugger"),
241        shortcut("Ctrl+G", "Generate code for pattern"),
242        shortcut("Ctrl+W", "Toggle whitespace visualization"),
243        shortcut("Ctrl+Left/Right", "Move cursor by word"),
244        shortcut("Alt+Up/Down", "Browse pattern history"),
245        shortcut("Alt+i", "Toggle case-insensitive"),
246        shortcut("Alt+m", "Toggle multi-line"),
247        shortcut("Alt+s", "Toggle dot-matches-newline"),
248        shortcut("Alt+u", "Toggle unicode mode"),
249        shortcut("Alt+x", "Toggle extended mode"),
250        shortcut("F1", "Show/hide help (Left/Right to page)"),
251        shortcut("Esc", "Quit"),
252        Line::from(""),
253        Line::from(Span::styled(
254            "Vim: --vim flag | Normal: hjkl wb e 0$^ gg/G x dd cc o/O u p",
255            Style::default().fg(theme::SUBTEXT),
256        )),
257        Line::from(Span::styled(
258            "Mouse: click to focus/position, scroll to navigate",
259            Style::default().fg(theme::SUBTEXT),
260        )),
261    ];
262
263    // Page 1: Common regex syntax
264    let page1 = vec![
265        shortcut(".", "Any character (except newline by default)"),
266        shortcut("\\d  \\D", "Digit / non-digit"),
267        shortcut("\\w  \\W", "Word char / non-word char"),
268        shortcut("\\s  \\S", "Whitespace / non-whitespace"),
269        shortcut("\\b  \\B", "Word boundary / non-boundary"),
270        shortcut("^  $", "Start / end of line"),
271        shortcut("[abc]", "Character class"),
272        shortcut("[^abc]", "Negated character class"),
273        shortcut("[a-z]", "Character range"),
274        shortcut("(group)", "Capturing group"),
275        shortcut("(?:group)", "Non-capturing group"),
276        shortcut("(?P<n>...)", "Named capturing group"),
277        shortcut("a|b", "Alternation (a or b)"),
278        shortcut("*  +  ?", "0+, 1+, 0 or 1 (greedy)"),
279        shortcut("*?  +?  ??", "Lazy quantifiers"),
280        shortcut("{n}  {n,m}", "Exact / range repetition"),
281        Line::from(""),
282        Line::from(Span::styled(
283            "Replacement: $1, ${name}, $0/$&, $$ for literal $",
284            Style::default().fg(theme::SUBTEXT),
285        )),
286    ];
287
288    // Page 2: Engine-specific
289    let engine_name = format!("{engine}");
290    let page2 = match engine {
291        EngineKind::RustRegex => vec![
292            Line::from(Span::styled(
293                "Rust regex engine — linear time guarantee",
294                Style::default().fg(theme::BLUE),
295            )),
296            Line::from(""),
297            shortcut("Unicode", "Full Unicode support by default"),
298            shortcut("No lookbehind", "Use fancy-regex or PCRE2 for lookaround"),
299            shortcut("No backrefs", "Use fancy-regex or PCRE2 for backrefs"),
300            shortcut("\\p{Letter}", "Unicode category"),
301            shortcut("(?i)", "Inline case-insensitive flag"),
302            shortcut("(?m)", "Inline multi-line flag"),
303            shortcut("(?s)", "Inline dot-matches-newline flag"),
304            shortcut("(?x)", "Inline extended/verbose flag"),
305        ],
306        EngineKind::FancyRegex => vec![
307            Line::from(Span::styled(
308                "fancy-regex engine — lookaround + backreferences",
309                Style::default().fg(theme::BLUE),
310            )),
311            Line::from(""),
312            shortcut("(?=...)", "Positive lookahead"),
313            shortcut("(?!...)", "Negative lookahead"),
314            shortcut("(?<=...)", "Positive lookbehind"),
315            shortcut("(?<!...)", "Negative lookbehind"),
316            shortcut("\\1  \\2", "Backreferences"),
317            shortcut("(?>...)", "Atomic group"),
318            Line::from(""),
319            Line::from(Span::styled(
320                "Delegates to Rust regex for non-fancy patterns",
321                Style::default().fg(theme::SUBTEXT),
322            )),
323        ],
324        #[cfg(feature = "pcre2-engine")]
325        EngineKind::Pcre2 => vec![
326            Line::from(Span::styled(
327                "PCRE2 engine — full-featured",
328                Style::default().fg(theme::BLUE),
329            )),
330            Line::from(""),
331            shortcut("(?=...)(?!...)", "Lookahead"),
332            shortcut("(?<=...)(?<!..)", "Lookbehind"),
333            shortcut("\\1  \\2", "Backreferences"),
334            shortcut("(?>...)", "Atomic group"),
335            shortcut("(*SKIP)(*FAIL)", "Backtracking control verbs"),
336            shortcut("(?R)  (?1)", "Recursion / subroutine calls"),
337            shortcut("(?(cond)y|n)", "Conditional patterns"),
338            shortcut("\\K", "Reset match start"),
339            shortcut("(*UTF)", "Force UTF-8 mode"),
340        ],
341    };
342
343    vec![
344        ("Keyboard Shortcuts".to_string(), page0),
345        ("Common Regex Syntax".to_string(), page1),
346        (format!("Engine: {engine_name}"), page2),
347    ]
348}
349
350pub(crate) fn centered_overlay(
351    frame: &mut Frame,
352    area: Rect,
353    max_width: u16,
354    content_height: u16,
355) -> Rect {
356    let w = max_width.min(area.width.saturating_sub(4));
357    let h = content_height.min(area.height.saturating_sub(4));
358    let x = (area.width.saturating_sub(w)) / 2;
359    let y = (area.height.saturating_sub(h)) / 2;
360    let rect = Rect::new(x, y, w, h);
361    frame.render_widget(Clear, rect);
362    rect
363}
364
365fn render_help_overlay(
366    frame: &mut Frame,
367    area: Rect,
368    engine: EngineKind,
369    page: usize,
370    bt: BorderType,
371) {
372    let help_area = centered_overlay(frame, area, 64, 24);
373
374    let pages = build_help_pages(engine);
375    let current = page.min(pages.len() - 1);
376    let (title, content) = &pages[current];
377
378    let mut lines: Vec<Line<'static>> = vec![
379        Line::from(Span::styled(
380            title.clone(),
381            Style::default()
382                .fg(theme::BLUE)
383                .add_modifier(Modifier::BOLD),
384        )),
385        Line::from(""),
386    ];
387    lines.extend(content.iter().cloned());
388    lines.push(Line::from(""));
389    lines.push(Line::from(vec![
390        Span::styled(
391            format!(" Page {}/{} ", current + 1, pages.len()),
392            Style::default().fg(theme::BASE).bg(theme::BLUE),
393        ),
394        Span::styled(
395            " Left/Right: page | Any other key: close ",
396            Style::default().fg(theme::SUBTEXT),
397        ),
398    ]));
399
400    let block = Block::default()
401        .borders(Borders::ALL)
402        .border_type(bt)
403        .border_style(Style::default().fg(theme::BLUE))
404        .title(Span::styled(" Help ", Style::default().fg(theme::TEXT)))
405        .style(Style::default().bg(theme::BASE));
406
407    let paragraph = Paragraph::new(lines)
408        .block(block)
409        .wrap(Wrap { trim: false });
410
411    frame.render_widget(paragraph, help_area);
412}
413
414fn render_recipe_overlay(frame: &mut Frame, area: Rect, selected: usize, bt: BorderType) {
415    let overlay_area = centered_overlay(frame, area, 70, RECIPES.len() as u16 + 6);
416
417    let mut lines: Vec<Line<'static>> = vec![
418        Line::from(Span::styled(
419            "Select a recipe to load",
420            Style::default()
421                .fg(theme::BLUE)
422                .add_modifier(Modifier::BOLD),
423        )),
424        Line::from(""),
425    ];
426
427    for (i, recipe) in RECIPES.iter().enumerate() {
428        let is_selected = i == selected;
429        let marker = if is_selected { ">" } else { " " };
430        let style = if is_selected {
431            Style::default().fg(theme::BASE).bg(theme::BLUE)
432        } else {
433            Style::default().fg(theme::TEXT)
434        };
435        lines.push(Line::from(Span::styled(
436            format!("{marker} {:<24} {}", recipe.name, recipe.description),
437            style,
438        )));
439    }
440
441    lines.push(Line::from(""));
442    lines.push(Line::from(Span::styled(
443        " Up/Down: select | Enter: load | Esc: cancel ",
444        Style::default().fg(theme::SUBTEXT),
445    )));
446
447    let block = Block::default()
448        .borders(Borders::ALL)
449        .border_type(bt)
450        .border_style(Style::default().fg(theme::GREEN))
451        .title(Span::styled(
452            " Recipes (Ctrl+R) ",
453            Style::default().fg(theme::TEXT),
454        ))
455        .style(Style::default().bg(theme::BASE));
456
457    let paragraph = Paragraph::new(lines)
458        .block(block)
459        .wrap(Wrap { trim: false });
460
461    frame.render_widget(paragraph, overlay_area);
462}
463
464fn render_benchmark_overlay(
465    frame: &mut Frame,
466    area: Rect,
467    results: &[BenchmarkResult],
468    bt: BorderType,
469) {
470    let overlay_area = centered_overlay(frame, area, 70, results.len() as u16 + 8);
471
472    let fastest_idx = results
473        .iter()
474        .enumerate()
475        .filter(|(_, r)| r.error.is_none())
476        .min_by_key(|(_, r)| r.compile_time + r.match_time)
477        .map(|(i, _)| i);
478
479    let mut lines: Vec<Line<'static>> = vec![
480        Line::from(Span::styled(
481            "Performance Comparison",
482            Style::default()
483                .fg(theme::BLUE)
484                .add_modifier(Modifier::BOLD),
485        )),
486        Line::from(""),
487        Line::from(vec![Span::styled(
488            format!(
489                "{:<16} {:>10} {:>10} {:>10} {:>8}",
490                "Engine", "Compile", "Match", "Total", "Matches"
491            ),
492            Style::default()
493                .fg(theme::SUBTEXT)
494                .add_modifier(Modifier::BOLD),
495        )]),
496    ];
497
498    for (i, result) in results.iter().enumerate() {
499        let is_fastest = fastest_idx == Some(i);
500        if let Some(ref err) = result.error {
501            let line_text = format!("{:<16} {}", result.engine, err);
502            lines.push(Line::from(Span::styled(
503                line_text,
504                Style::default().fg(theme::RED),
505            )));
506        } else {
507            let total = result.compile_time + result.match_time;
508            let line_text = format!(
509                "{:<16} {:>10} {:>10} {:>10} {:>8}",
510                result.engine,
511                status_bar::format_duration(result.compile_time),
512                status_bar::format_duration(result.match_time),
513                status_bar::format_duration(total),
514                result.match_count,
515            );
516            let style = if is_fastest {
517                Style::default()
518                    .fg(theme::GREEN)
519                    .add_modifier(Modifier::BOLD)
520            } else {
521                Style::default().fg(theme::TEXT)
522            };
523            let mut spans = vec![Span::styled(line_text, style)];
524            if is_fastest {
525                spans.push(Span::styled(" *", Style::default().fg(theme::GREEN)));
526            }
527            lines.push(Line::from(spans));
528        }
529    }
530
531    lines.push(Line::from(""));
532    if fastest_idx.is_some() {
533        lines.push(Line::from(Span::styled(
534            "* = fastest",
535            Style::default().fg(theme::GREEN),
536        )));
537    }
538    lines.push(Line::from(Span::styled(
539        " Any key: close ",
540        Style::default().fg(theme::SUBTEXT),
541    )));
542
543    let block = Block::default()
544        .borders(Borders::ALL)
545        .border_type(bt)
546        .border_style(Style::default().fg(theme::PEACH))
547        .title(Span::styled(
548            " Benchmark (Ctrl+B) ",
549            Style::default().fg(theme::TEXT),
550        ))
551        .style(Style::default().bg(theme::BASE));
552
553    let paragraph = Paragraph::new(lines)
554        .block(block)
555        .wrap(Wrap { trim: false });
556
557    frame.render_widget(paragraph, overlay_area);
558}
559
560fn render_codegen_overlay(
561    frame: &mut Frame,
562    area: Rect,
563    selected: usize,
564    pattern: &str,
565    flags: &crate::engine::EngineFlags,
566    bt: BorderType,
567) {
568    let langs = codegen::Language::all();
569    let preview = if pattern.is_empty() {
570        String::from("(no pattern)")
571    } else {
572        let lang = &langs[selected.min(langs.len() - 1)];
573        codegen::generate_code(lang, pattern, flags)
574    };
575
576    let preview_lines: Vec<&str> = preview.lines().collect();
577    let preview_height = preview_lines.len() as u16;
578    // Languages list + title + spacing + preview + footer
579    let content_height = langs.len() as u16 + preview_height + 7;
580    let overlay_area = centered_overlay(frame, area, 74, content_height);
581
582    let mut lines: Vec<Line<'static>> = vec![
583        Line::from(Span::styled(
584            "Select a language to generate code",
585            Style::default()
586                .fg(theme::MAUVE)
587                .add_modifier(Modifier::BOLD),
588        )),
589        Line::from(""),
590    ];
591
592    for (i, lang) in langs.iter().enumerate() {
593        let is_selected = i == selected;
594        let marker = if is_selected { ">" } else { " " };
595        let style = if is_selected {
596            Style::default().fg(theme::BASE).bg(theme::MAUVE)
597        } else {
598            Style::default().fg(theme::TEXT)
599        };
600        lines.push(Line::from(Span::styled(format!("{marker} {lang}"), style)));
601    }
602
603    lines.push(Line::from(""));
604    lines.push(Line::from(Span::styled(
605        "Preview:",
606        Style::default()
607            .fg(theme::SUBTEXT)
608            .add_modifier(Modifier::BOLD),
609    )));
610    for pl in preview_lines {
611        lines.push(Line::from(Span::styled(
612            pl.to_string(),
613            Style::default().fg(theme::GREEN),
614        )));
615    }
616
617    lines.push(Line::from(""));
618    lines.push(Line::from(Span::styled(
619        " Up/Down: select | Enter: copy to clipboard | Esc: cancel ",
620        Style::default().fg(theme::SUBTEXT),
621    )));
622
623    let block = Block::default()
624        .borders(Borders::ALL)
625        .border_type(bt)
626        .border_style(Style::default().fg(theme::MAUVE))
627        .title(Span::styled(
628            " Code Generation (Ctrl+G) ",
629            Style::default().fg(theme::TEXT),
630        ))
631        .style(Style::default().bg(theme::BASE));
632
633    let paragraph = Paragraph::new(lines)
634        .block(block)
635        .wrap(Wrap { trim: false });
636
637    frame.render_widget(paragraph, overlay_area);
638}