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