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(
241            "Ctrl+Y",
242            "Copy pattern (regex panel) or match (matches panel)",
243        ),
244        shortcut("Ctrl+O", "Output results to stdout and quit"),
245        shortcut("Ctrl+S", "Save workspace"),
246        shortcut("Ctrl+R", "Open regex recipe library"),
247        shortcut("Ctrl+B", "Benchmark pattern across all engines"),
248        shortcut("Ctrl+U", "Copy regex101.com URL to clipboard"),
249        shortcut("Ctrl+D", "Step-through regex debugger"),
250        shortcut("Ctrl+G", "Generate code for pattern"),
251        shortcut("Ctrl+X", "Generate regex from examples (grex)"),
252        shortcut("Ctrl+W", "Toggle whitespace visualization"),
253        shortcut("Ctrl+Left/Right", "Move cursor by word"),
254        shortcut("Alt+Up/Down", "Browse pattern history"),
255        shortcut("Alt+i", "Toggle case-insensitive"),
256        shortcut("Alt+m", "Toggle multi-line"),
257        shortcut("Alt+s", "Toggle dot-matches-newline"),
258        shortcut("Alt+u", "Toggle unicode mode"),
259        shortcut("Alt+x", "Toggle extended mode"),
260        shortcut("F1", "Show/hide help (Left/Right to page)"),
261        shortcut("Esc", "Quit"),
262        Line::from(""),
263        Line::from(Span::styled(
264            "Vim: --vim flag | Normal: hjkl wb e 0$^ gg/G x dd cc o/O u p",
265            Style::default().fg(theme::SUBTEXT),
266        )),
267        Line::from(Span::styled(
268            "Mouse: click to focus/position, scroll to navigate",
269            Style::default().fg(theme::SUBTEXT),
270        )),
271    ];
272
273    let header = |text: &'static str| -> Line<'static> {
274        Line::from(Span::styled(text, Style::default().fg(theme::OVERLAY)))
275    };
276
277    // Page 1: Quick Reference
278    let page1 = vec![
279        header("── Sequences ─────────────────────────────────────"),
280        shortcut(".", "Any character (except newline by default)"),
281        shortcut("\\d  \\D", "Digit / non-digit"),
282        shortcut("\\w  \\W", "Word char / non-word char"),
283        shortcut("\\s  \\S", "Whitespace / non-whitespace"),
284        shortcut("\\t  \\n  \\r", "Tab / newline / carriage return"),
285        shortcut("\\b  \\B", "Word boundary / non-boundary"),
286        shortcut("^  $", "Start / end of line"),
287        header("── Classes & Groups ──────────────────────────────"),
288        shortcut("[abc]", "Character class"),
289        shortcut("[^abc]", "Negated character class"),
290        shortcut("[a-z]", "Character range"),
291        shortcut("(group)", "Capturing group"),
292        shortcut("(?:group)", "Non-capturing group"),
293        shortcut("(?P<n>...)", "Named capturing group"),
294        shortcut("(?=...)  (?!...)", "Lookahead pos/neg  (fancy/PCRE2)"),
295        shortcut("a|b", "Alternation (a or b)"),
296        header("── Quantifiers ───────────────────────────────────"),
297        shortcut("*  +  ?", "0+, 1+, 0 or 1 (greedy)"),
298        shortcut("*?  +?  ??", "Lazy variants"),
299        shortcut("{n}  {n,m}", "Exact / range repetition"),
300        Line::from(Span::styled(
301            "Replacement: $1, ${name}, $0/$&, $$ for literal $",
302            Style::default().fg(theme::SUBTEXT),
303        )),
304    ];
305
306    // Page 2: Engine-specific
307    let engine_name = format!("{engine}");
308    let page2 = match engine {
309        EngineKind::RustRegex => vec![
310            Line::from(Span::styled(
311                "Rust regex engine — linear time guarantee",
312                Style::default().fg(theme::BLUE),
313            )),
314            Line::from(""),
315            shortcut("Unicode", "Full Unicode support by default"),
316            shortcut("No lookbehind", "Use fancy-regex or PCRE2 for lookaround"),
317            shortcut("No backrefs", "Use fancy-regex or PCRE2 for backrefs"),
318            shortcut("\\p{Letter}", "Unicode category"),
319            shortcut("(?i)", "Inline case-insensitive flag"),
320            shortcut("(?m)", "Inline multi-line flag"),
321            shortcut("(?s)", "Inline dot-matches-newline flag"),
322            shortcut("(?x)", "Inline extended/verbose flag"),
323        ],
324        EngineKind::FancyRegex => vec![
325            Line::from(Span::styled(
326                "fancy-regex engine — lookaround + backreferences",
327                Style::default().fg(theme::BLUE),
328            )),
329            Line::from(""),
330            shortcut("(?=...)", "Positive lookahead"),
331            shortcut("(?!...)", "Negative lookahead"),
332            shortcut("(?<=...)", "Positive lookbehind"),
333            shortcut("(?<!...)", "Negative lookbehind"),
334            shortcut("\\1  \\2", "Backreferences"),
335            shortcut("(?>...)", "Atomic group"),
336            Line::from(""),
337            Line::from(Span::styled(
338                "Delegates to Rust regex for non-fancy patterns",
339                Style::default().fg(theme::SUBTEXT),
340            )),
341        ],
342        #[cfg(feature = "pcre2-engine")]
343        EngineKind::Pcre2 => vec![
344            Line::from(Span::styled(
345                "PCRE2 engine — full-featured",
346                Style::default().fg(theme::BLUE),
347            )),
348            Line::from(""),
349            shortcut("(?=...)(?!...)", "Lookahead"),
350            shortcut("(?<=...)(?<!..)", "Lookbehind"),
351            shortcut("\\1  \\2", "Backreferences"),
352            shortcut("(?>...)", "Atomic group"),
353            shortcut("(*SKIP)(*FAIL)", "Backtracking control verbs"),
354            shortcut("(?R)  (?1)", "Recursion / subroutine calls"),
355            shortcut("(?(cond)y|n)", "Conditional patterns"),
356            shortcut("\\K", "Reset match start"),
357            shortcut("(*UTF)", "Force UTF-8 mode"),
358        ],
359    };
360
361    vec![
362        ("Keyboard Shortcuts".to_string(), page0),
363        ("Quick Reference".to_string(), page1),
364        (format!("Engine: {engine_name}"), page2),
365    ]
366}
367
368pub(crate) fn centered_overlay(
369    frame: &mut Frame,
370    area: Rect,
371    max_width: u16,
372    content_height: u16,
373) -> Rect {
374    let w = max_width.min(area.width.saturating_sub(4));
375    let h = content_height.min(area.height.saturating_sub(4));
376    let x = (area.width.saturating_sub(w)) / 2;
377    let y = (area.height.saturating_sub(h)) / 2;
378    let rect = Rect::new(x, y, w, h);
379    frame.render_widget(Clear, rect);
380    rect
381}
382
383fn render_help_overlay(
384    frame: &mut Frame,
385    area: Rect,
386    engine: EngineKind,
387    page: usize,
388    bt: BorderType,
389) {
390    let help_area = centered_overlay(frame, area, 64, 28);
391
392    let pages = build_help_pages(engine);
393    let current = page.min(pages.len() - 1);
394    let (title, content) = &pages[current];
395
396    let mut lines: Vec<Line<'static>> = vec![
397        Line::from(Span::styled(
398            title.clone(),
399            Style::default()
400                .fg(theme::BLUE)
401                .add_modifier(Modifier::BOLD),
402        )),
403        Line::from(""),
404    ];
405    lines.extend(content.iter().cloned());
406    lines.push(Line::from(""));
407    lines.push(Line::from(vec![
408        Span::styled(
409            format!(" Page {}/{} ", current + 1, pages.len()),
410            Style::default().fg(theme::BASE).bg(theme::BLUE),
411        ),
412        Span::styled(
413            " Left/Right: page | Any other key: close ",
414            Style::default().fg(theme::SUBTEXT),
415        ),
416    ]));
417
418    let block = Block::default()
419        .borders(Borders::ALL)
420        .border_type(bt)
421        .border_style(Style::default().fg(theme::BLUE))
422        .title(Span::styled(" Help ", Style::default().fg(theme::TEXT)))
423        .style(Style::default().bg(theme::BASE));
424
425    let paragraph = Paragraph::new(lines)
426        .block(block)
427        .wrap(Wrap { trim: false });
428
429    frame.render_widget(paragraph, help_area);
430}
431
432fn render_recipe_overlay(frame: &mut Frame, area: Rect, selected: usize, bt: BorderType) {
433    let overlay_area = centered_overlay(frame, area, 70, RECIPES.len() as u16 + 6);
434
435    let mut lines: Vec<Line<'static>> = vec![
436        Line::from(Span::styled(
437            "Select a recipe to load",
438            Style::default()
439                .fg(theme::BLUE)
440                .add_modifier(Modifier::BOLD),
441        )),
442        Line::from(""),
443    ];
444
445    for (i, recipe) in RECIPES.iter().enumerate() {
446        let is_selected = i == selected;
447        let marker = if is_selected { ">" } else { " " };
448        let style = if is_selected {
449            Style::default().fg(theme::BASE).bg(theme::BLUE)
450        } else {
451            Style::default().fg(theme::TEXT)
452        };
453        lines.push(Line::from(Span::styled(
454            format!("{marker} {:<24} {}", recipe.name, recipe.description),
455            style,
456        )));
457    }
458
459    lines.push(Line::from(""));
460    lines.push(Line::from(Span::styled(
461        " Up/Down: select | Enter: load | Esc: cancel ",
462        Style::default().fg(theme::SUBTEXT),
463    )));
464
465    let block = Block::default()
466        .borders(Borders::ALL)
467        .border_type(bt)
468        .border_style(Style::default().fg(theme::GREEN))
469        .title(Span::styled(
470            " Recipes (Ctrl+R) ",
471            Style::default().fg(theme::TEXT),
472        ))
473        .style(Style::default().bg(theme::BASE));
474
475    let paragraph = Paragraph::new(lines)
476        .block(block)
477        .wrap(Wrap { trim: false });
478
479    frame.render_widget(paragraph, overlay_area);
480}
481
482fn render_benchmark_overlay(
483    frame: &mut Frame,
484    area: Rect,
485    results: &[BenchmarkResult],
486    bt: BorderType,
487) {
488    let overlay_area = centered_overlay(frame, area, 70, results.len() as u16 + 8);
489
490    let fastest_idx = results
491        .iter()
492        .enumerate()
493        .filter(|(_, r)| r.error.is_none())
494        .min_by_key(|(_, r)| r.compile_time + r.match_time)
495        .map(|(i, _)| i);
496
497    let mut lines: Vec<Line<'static>> = vec![
498        Line::from(Span::styled(
499            "Performance Comparison",
500            Style::default()
501                .fg(theme::BLUE)
502                .add_modifier(Modifier::BOLD),
503        )),
504        Line::from(""),
505        Line::from(vec![Span::styled(
506            format!(
507                "{:<16} {:>10} {:>10} {:>10} {:>8}",
508                "Engine", "Compile", "Match", "Total", "Matches"
509            ),
510            Style::default()
511                .fg(theme::SUBTEXT)
512                .add_modifier(Modifier::BOLD),
513        )]),
514    ];
515
516    for (i, result) in results.iter().enumerate() {
517        let is_fastest = fastest_idx == Some(i);
518        if let Some(ref err) = result.error {
519            let line_text = format!("{:<16} {}", result.engine, err);
520            lines.push(Line::from(Span::styled(
521                line_text,
522                Style::default().fg(theme::RED),
523            )));
524        } else {
525            let total = result.compile_time + result.match_time;
526            let line_text = format!(
527                "{:<16} {:>10} {:>10} {:>10} {:>8}",
528                result.engine,
529                status_bar::format_duration(result.compile_time),
530                status_bar::format_duration(result.match_time),
531                status_bar::format_duration(total),
532                result.match_count,
533            );
534            let style = if is_fastest {
535                Style::default()
536                    .fg(theme::GREEN)
537                    .add_modifier(Modifier::BOLD)
538            } else {
539                Style::default().fg(theme::TEXT)
540            };
541            let mut spans = vec![Span::styled(line_text, style)];
542            if is_fastest {
543                spans.push(Span::styled(" *", Style::default().fg(theme::GREEN)));
544            }
545            lines.push(Line::from(spans));
546        }
547    }
548
549    lines.push(Line::from(""));
550    if fastest_idx.is_some() {
551        lines.push(Line::from(Span::styled(
552            "* = fastest",
553            Style::default().fg(theme::GREEN),
554        )));
555    }
556    lines.push(Line::from(Span::styled(
557        " Any key: close ",
558        Style::default().fg(theme::SUBTEXT),
559    )));
560
561    let block = Block::default()
562        .borders(Borders::ALL)
563        .border_type(bt)
564        .border_style(Style::default().fg(theme::PEACH))
565        .title(Span::styled(
566            " Benchmark (Ctrl+B) ",
567            Style::default().fg(theme::TEXT),
568        ))
569        .style(Style::default().bg(theme::BASE));
570
571    let paragraph = Paragraph::new(lines)
572        .block(block)
573        .wrap(Wrap { trim: false });
574
575    frame.render_widget(paragraph, overlay_area);
576}
577
578fn render_codegen_overlay(
579    frame: &mut Frame,
580    area: Rect,
581    selected: usize,
582    pattern: &str,
583    flags: crate::engine::EngineFlags,
584    bt: BorderType,
585) {
586    let langs = codegen::Language::all();
587    let preview = if pattern.is_empty() {
588        String::from("(no pattern)")
589    } else {
590        let lang = &langs[selected.min(langs.len() - 1)];
591        codegen::generate_code(lang, pattern, &flags)
592    };
593
594    let preview_lines: Vec<&str> = preview.lines().collect();
595    let preview_height = preview_lines.len() as u16;
596    // Languages list + title + spacing + preview + footer
597    let content_height = langs.len() as u16 + preview_height + 7;
598    let overlay_area = centered_overlay(frame, area, 74, content_height);
599
600    let mut lines: Vec<Line<'static>> = vec![
601        Line::from(Span::styled(
602            "Select a language to generate code",
603            Style::default()
604                .fg(theme::MAUVE)
605                .add_modifier(Modifier::BOLD),
606        )),
607        Line::from(""),
608    ];
609
610    for (i, lang) in langs.iter().enumerate() {
611        let is_selected = i == selected;
612        let marker = if is_selected { ">" } else { " " };
613        let style = if is_selected {
614            Style::default().fg(theme::BASE).bg(theme::MAUVE)
615        } else {
616            Style::default().fg(theme::TEXT)
617        };
618        lines.push(Line::from(Span::styled(format!("{marker} {lang}"), style)));
619    }
620
621    lines.push(Line::from(""));
622    lines.push(Line::from(Span::styled(
623        "Preview:",
624        Style::default()
625            .fg(theme::SUBTEXT)
626            .add_modifier(Modifier::BOLD),
627    )));
628    for pl in preview_lines {
629        lines.push(Line::from(Span::styled(
630            pl.to_string(),
631            Style::default().fg(theme::GREEN),
632        )));
633    }
634
635    lines.push(Line::from(""));
636    lines.push(Line::from(Span::styled(
637        " Up/Down: select | Enter: copy to clipboard | Esc: cancel ",
638        Style::default().fg(theme::SUBTEXT),
639    )));
640
641    let block = Block::default()
642        .borders(Borders::ALL)
643        .border_type(bt)
644        .border_style(Style::default().fg(theme::MAUVE))
645        .title(Span::styled(
646            " Code Generation (Ctrl+G) ",
647            Style::default().fg(theme::TEXT),
648        ))
649        .style(Style::default().bg(theme::BASE));
650
651    let paragraph = Paragraph::new(lines)
652        .block(block)
653        .wrap(Wrap { trim: false });
654
655    frame.render_widget(paragraph, overlay_area);
656}