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    frame.render_widget(
166        StatusBar {
167            engine: app.engine_kind,
168            match_count: app.matches.len(),
169            flags: app.flags,
170            show_whitespace: app.show_whitespace,
171            compile_time: app.compile_time,
172            match_time: app.match_time,
173            vim_mode: if app.vim_mode {
174                Some(app.vim_state.mode)
175            } else {
176                None
177            },
178        },
179        layout.status_bar,
180    );
181}
182
183pub const HELP_PAGE_COUNT: usize = 3;
184
185fn build_help_pages(engine: EngineKind) -> Vec<(String, Vec<Line<'static>>)> {
186    let shortcut = |key: &'static str, desc: &'static str| -> Line<'static> {
187        Line::from(vec![
188            Span::styled(format!("{key:<14}"), Style::default().fg(theme::GREEN)),
189            Span::styled(desc, Style::default().fg(theme::TEXT)),
190        ])
191    };
192
193    // Page 0: Keyboard shortcuts
194    let page0 = vec![
195        shortcut("Tab/Shift+Tab", "Cycle focus forward/backward"),
196        shortcut("Up/Down", "Scroll panel / move cursor / select match"),
197        shortcut("Enter", "Insert newline (test string)"),
198        shortcut("Ctrl+E", "Cycle regex engine"),
199        shortcut("Ctrl+Z", "Undo"),
200        shortcut("Ctrl+Shift+Z", "Redo"),
201        shortcut("Ctrl+Y", "Copy selected match to clipboard"),
202        shortcut("Ctrl+O", "Output results to stdout and quit"),
203        shortcut("Ctrl+S", "Save workspace"),
204        shortcut("Ctrl+R", "Open regex recipe library"),
205        shortcut("Ctrl+B", "Benchmark pattern across all engines"),
206        shortcut("Ctrl+U", "Copy regex101.com URL to clipboard"),
207        shortcut("Ctrl+W", "Toggle whitespace visualization"),
208        shortcut("Ctrl+Left/Right", "Move cursor by word"),
209        shortcut("Alt+Up/Down", "Browse pattern history"),
210        shortcut("Alt+i", "Toggle case-insensitive"),
211        shortcut("Alt+m", "Toggle multi-line"),
212        shortcut("Alt+s", "Toggle dot-matches-newline"),
213        shortcut("Alt+u", "Toggle unicode mode"),
214        shortcut("Alt+x", "Toggle extended mode"),
215        shortcut("F1", "Show/hide help (Left/Right to page)"),
216        shortcut("Esc", "Quit"),
217        Line::from(""),
218        Line::from(Span::styled(
219            "Vim: --vim flag | Normal: hjkl wb e 0$^ gg/G x dd cc o/O u p",
220            Style::default().fg(theme::SUBTEXT),
221        )),
222        Line::from(Span::styled(
223            "Mouse: click to focus/position, scroll to navigate",
224            Style::default().fg(theme::SUBTEXT),
225        )),
226    ];
227
228    // Page 1: Common regex syntax
229    let page1 = vec![
230        shortcut(".", "Any character (except newline by default)"),
231        shortcut("\\d  \\D", "Digit / non-digit"),
232        shortcut("\\w  \\W", "Word char / non-word char"),
233        shortcut("\\s  \\S", "Whitespace / non-whitespace"),
234        shortcut("\\b  \\B", "Word boundary / non-boundary"),
235        shortcut("^  $", "Start / end of line"),
236        shortcut("[abc]", "Character class"),
237        shortcut("[^abc]", "Negated character class"),
238        shortcut("[a-z]", "Character range"),
239        shortcut("(group)", "Capturing group"),
240        shortcut("(?:group)", "Non-capturing group"),
241        shortcut("(?P<n>...)", "Named capturing group"),
242        shortcut("a|b", "Alternation (a or b)"),
243        shortcut("*  +  ?", "0+, 1+, 0 or 1 (greedy)"),
244        shortcut("*?  +?  ??", "Lazy quantifiers"),
245        shortcut("{n}  {n,m}", "Exact / range repetition"),
246        Line::from(""),
247        Line::from(Span::styled(
248            "Replacement: $1, ${name}, $0/$&, $$ for literal $",
249            Style::default().fg(theme::SUBTEXT),
250        )),
251    ];
252
253    // Page 2: Engine-specific
254    let engine_name = format!("{engine}");
255    let page2 = match engine {
256        EngineKind::RustRegex => vec![
257            Line::from(Span::styled(
258                "Rust regex engine — linear time guarantee",
259                Style::default().fg(theme::BLUE),
260            )),
261            Line::from(""),
262            shortcut("Unicode", "Full Unicode support by default"),
263            shortcut("No lookbehind", "Use fancy-regex or PCRE2 for lookaround"),
264            shortcut("No backrefs", "Use fancy-regex or PCRE2 for backrefs"),
265            shortcut("\\p{Letter}", "Unicode category"),
266            shortcut("(?i)", "Inline case-insensitive flag"),
267            shortcut("(?m)", "Inline multi-line flag"),
268            shortcut("(?s)", "Inline dot-matches-newline flag"),
269            shortcut("(?x)", "Inline extended/verbose flag"),
270        ],
271        EngineKind::FancyRegex => vec![
272            Line::from(Span::styled(
273                "fancy-regex engine — lookaround + backreferences",
274                Style::default().fg(theme::BLUE),
275            )),
276            Line::from(""),
277            shortcut("(?=...)", "Positive lookahead"),
278            shortcut("(?!...)", "Negative lookahead"),
279            shortcut("(?<=...)", "Positive lookbehind"),
280            shortcut("(?<!...)", "Negative lookbehind"),
281            shortcut("\\1  \\2", "Backreferences"),
282            shortcut("(?>...)", "Atomic group"),
283            Line::from(""),
284            Line::from(Span::styled(
285                "Delegates to Rust regex for non-fancy patterns",
286                Style::default().fg(theme::SUBTEXT),
287            )),
288        ],
289        #[cfg(feature = "pcre2-engine")]
290        EngineKind::Pcre2 => vec![
291            Line::from(Span::styled(
292                "PCRE2 engine — full-featured",
293                Style::default().fg(theme::BLUE),
294            )),
295            Line::from(""),
296            shortcut("(?=...)(?!...)", "Lookahead"),
297            shortcut("(?<=...)(?<!..)", "Lookbehind"),
298            shortcut("\\1  \\2", "Backreferences"),
299            shortcut("(?>...)", "Atomic group"),
300            shortcut("(*SKIP)(*FAIL)", "Backtracking control verbs"),
301            shortcut("(?R)  (?1)", "Recursion / subroutine calls"),
302            shortcut("(?(cond)y|n)", "Conditional patterns"),
303            shortcut("\\K", "Reset match start"),
304            shortcut("(*UTF)", "Force UTF-8 mode"),
305        ],
306    };
307
308    vec![
309        ("Keyboard Shortcuts".to_string(), page0),
310        ("Common Regex Syntax".to_string(), page1),
311        (format!("Engine: {engine_name}"), page2),
312    ]
313}
314
315fn centered_overlay(frame: &mut Frame, area: Rect, max_width: u16, content_height: u16) -> Rect {
316    let w = max_width.min(area.width.saturating_sub(4));
317    let h = content_height.min(area.height.saturating_sub(4));
318    let x = (area.width.saturating_sub(w)) / 2;
319    let y = (area.height.saturating_sub(h)) / 2;
320    let rect = Rect::new(x, y, w, h);
321    frame.render_widget(Clear, rect);
322    rect
323}
324
325fn render_help_overlay(
326    frame: &mut Frame,
327    area: Rect,
328    engine: EngineKind,
329    page: usize,
330    bt: BorderType,
331) {
332    let help_area = centered_overlay(frame, area, 64, 24);
333
334    let pages = build_help_pages(engine);
335    let current = page.min(pages.len() - 1);
336    let (title, content) = &pages[current];
337
338    let mut lines: Vec<Line<'static>> = vec![
339        Line::from(Span::styled(
340            title.clone(),
341            Style::default()
342                .fg(theme::BLUE)
343                .add_modifier(Modifier::BOLD),
344        )),
345        Line::from(""),
346    ];
347    lines.extend(content.iter().cloned());
348    lines.push(Line::from(""));
349    lines.push(Line::from(vec![
350        Span::styled(
351            format!(" Page {}/{} ", current + 1, pages.len()),
352            Style::default().fg(theme::BASE).bg(theme::BLUE),
353        ),
354        Span::styled(
355            " Left/Right: page | Any other key: close ",
356            Style::default().fg(theme::SUBTEXT),
357        ),
358    ]));
359
360    let block = Block::default()
361        .borders(Borders::ALL)
362        .border_type(bt)
363        .border_style(Style::default().fg(theme::BLUE))
364        .title(Span::styled(" Help ", Style::default().fg(theme::TEXT)))
365        .style(Style::default().bg(theme::BASE));
366
367    let paragraph = Paragraph::new(lines)
368        .block(block)
369        .wrap(Wrap { trim: false });
370
371    frame.render_widget(paragraph, help_area);
372}
373
374fn render_recipe_overlay(frame: &mut Frame, area: Rect, selected: usize, bt: BorderType) {
375    let overlay_area = centered_overlay(frame, area, 70, RECIPES.len() as u16 + 6);
376
377    let mut lines: Vec<Line<'static>> = vec![
378        Line::from(Span::styled(
379            "Select a recipe to load",
380            Style::default()
381                .fg(theme::BLUE)
382                .add_modifier(Modifier::BOLD),
383        )),
384        Line::from(""),
385    ];
386
387    for (i, recipe) in RECIPES.iter().enumerate() {
388        let is_selected = i == selected;
389        let marker = if is_selected { ">" } else { " " };
390        let style = if is_selected {
391            Style::default().fg(theme::BASE).bg(theme::BLUE)
392        } else {
393            Style::default().fg(theme::TEXT)
394        };
395        lines.push(Line::from(Span::styled(
396            format!("{marker} {:<24} {}", recipe.name, recipe.description),
397            style,
398        )));
399    }
400
401    lines.push(Line::from(""));
402    lines.push(Line::from(Span::styled(
403        " Up/Down: select | Enter: load | Esc: cancel ",
404        Style::default().fg(theme::SUBTEXT),
405    )));
406
407    let block = Block::default()
408        .borders(Borders::ALL)
409        .border_type(bt)
410        .border_style(Style::default().fg(theme::GREEN))
411        .title(Span::styled(
412            " Recipes (Ctrl+R) ",
413            Style::default().fg(theme::TEXT),
414        ))
415        .style(Style::default().bg(theme::BASE));
416
417    let paragraph = Paragraph::new(lines)
418        .block(block)
419        .wrap(Wrap { trim: false });
420
421    frame.render_widget(paragraph, overlay_area);
422}
423
424fn render_benchmark_overlay(
425    frame: &mut Frame,
426    area: Rect,
427    results: &[BenchmarkResult],
428    bt: BorderType,
429) {
430    let overlay_area = centered_overlay(frame, area, 70, results.len() as u16 + 8);
431
432    let fastest_idx = results
433        .iter()
434        .enumerate()
435        .filter(|(_, r)| r.error.is_none())
436        .min_by_key(|(_, r)| r.compile_time + r.match_time)
437        .map(|(i, _)| i);
438
439    let mut lines: Vec<Line<'static>> = vec![
440        Line::from(Span::styled(
441            "Performance Comparison",
442            Style::default()
443                .fg(theme::BLUE)
444                .add_modifier(Modifier::BOLD),
445        )),
446        Line::from(""),
447        Line::from(vec![Span::styled(
448            format!(
449                "{:<16} {:>10} {:>10} {:>10} {:>8}",
450                "Engine", "Compile", "Match", "Total", "Matches"
451            ),
452            Style::default()
453                .fg(theme::SUBTEXT)
454                .add_modifier(Modifier::BOLD),
455        )]),
456    ];
457
458    for (i, result) in results.iter().enumerate() {
459        let is_fastest = fastest_idx == Some(i);
460        if let Some(ref err) = result.error {
461            let line_text = format!("{:<16} {}", result.engine, err);
462            lines.push(Line::from(Span::styled(
463                line_text,
464                Style::default().fg(theme::RED),
465            )));
466        } else {
467            let total = result.compile_time + result.match_time;
468            let line_text = format!(
469                "{:<16} {:>10} {:>10} {:>10} {:>8}",
470                result.engine,
471                status_bar::format_duration(result.compile_time),
472                status_bar::format_duration(result.match_time),
473                status_bar::format_duration(total),
474                result.match_count,
475            );
476            let style = if is_fastest {
477                Style::default()
478                    .fg(theme::GREEN)
479                    .add_modifier(Modifier::BOLD)
480            } else {
481                Style::default().fg(theme::TEXT)
482            };
483            let mut spans = vec![Span::styled(line_text, style)];
484            if is_fastest {
485                spans.push(Span::styled(" *", Style::default().fg(theme::GREEN)));
486            }
487            lines.push(Line::from(spans));
488        }
489    }
490
491    lines.push(Line::from(""));
492    if fastest_idx.is_some() {
493        lines.push(Line::from(Span::styled(
494            "* = fastest",
495            Style::default().fg(theme::GREEN),
496        )));
497    }
498    lines.push(Line::from(Span::styled(
499        " Any key: close ",
500        Style::default().fg(theme::SUBTEXT),
501    )));
502
503    let block = Block::default()
504        .borders(Borders::ALL)
505        .border_type(bt)
506        .border_style(Style::default().fg(theme::PEACH))
507        .title(Span::styled(
508            " Benchmark (Ctrl+B) ",
509            Style::default().fg(theme::TEXT),
510        ))
511        .style(Style::default().bg(theme::BASE));
512
513    let paragraph = Paragraph::new(lines)
514        .block(block)
515        .wrap(Wrap { trim: false });
516
517    frame.render_widget(paragraph, overlay_area);
518}