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