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