Skip to main content

rgx/ui/
mod.rs

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