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