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