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 std::collections::HashMap;
15
16use ratatui::{
17    layout::{Constraint, Direction, Layout, Rect},
18    style::{Modifier, Style},
19    text::{Line, Span},
20    widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
21    Frame,
22};
23
24use crate::app::{App, BenchmarkResult};
25use crate::codegen;
26use crate::engine::EngineKind;
27use crate::recipe::RECIPES;
28use explanation::ExplanationPanel;
29use match_display::MatchDisplay;
30use regex_input::RegexInput;
31use replace_input::ReplaceInput;
32use status_bar::StatusBar;
33use test_input::TestInput;
34
35/// Returns `BorderType::Rounded` when `rounded` is true, otherwise
36/// `BorderType::Plain`.
37pub(crate) const fn border_type(rounded: bool) -> BorderType {
38    if rounded {
39        BorderType::Rounded
40    } else {
41        BorderType::Plain
42    }
43}
44
45/// Panel layout rectangles for mouse hit-testing.
46pub struct PanelLayout {
47    pub regex_input: Rect,
48    pub test_input: Rect,
49    pub replace_input: Rect,
50    pub match_display: Rect,
51    pub explanation: Rect,
52    pub status_bar: Rect,
53    /// Right-side Quick Reference panel — Some when `show_quickref` is on and
54    /// the terminal is wide enough to host both it and a useful results area.
55    pub quickref: Option<Rect>,
56}
57
58/// Width (cols) of the Quick Reference side panel when `show_quickref` is on.
59pub const QUICKREF_PANEL_WIDTH: u16 = 38;
60
61/// Minimum width left for the results area (Matches + Explanation) before the
62/// Quick Reference side panel is suppressed. Avoids cramping the main view.
63pub const QUICKREF_MIN_RESULTS_WIDTH: u16 = 60;
64
65pub fn compute_layout(size: Rect, show_quickref: bool) -> PanelLayout {
66    // Split the bottom status bar off first so the quickref panel can span
67    // the full height above it (reported in #83 — when zoomed in, the panel
68    // was confined to the results area's height and clipped most of its
69    // content).
70    let outer = Layout::default()
71        .direction(Direction::Vertical)
72        .constraints([Constraint::Min(0), Constraint::Length(1)])
73        .split(size);
74    let main_top = outer[0];
75    let status_bar = outer[1];
76
77    // Carve a Quick Reference strip off the right of the full main area when
78    // toggled on AND the terminal is wide enough to keep the main results area
79    // usable.
80    let quickref_fits =
81        show_quickref && main_top.width >= QUICKREF_PANEL_WIDTH + QUICKREF_MIN_RESULTS_WIDTH;
82    let (left_area, quickref) = if quickref_fits {
83        let chunks = Layout::default()
84            .direction(Direction::Horizontal)
85            .constraints([Constraint::Min(0), Constraint::Length(QUICKREF_PANEL_WIDTH)])
86            .split(main_top);
87        (chunks[0], Some(chunks[1]))
88    } else {
89        (main_top, None)
90    };
91
92    // Stack the editors and results area in the remaining left area.
93    let main_chunks = Layout::default()
94        .direction(Direction::Vertical)
95        .constraints([
96            Constraint::Length(3), // regex input
97            Constraint::Length(8), // test string input
98            Constraint::Length(3), // replacement input
99            Constraint::Min(5),    // results area
100        ])
101        .split(left_area);
102    let results_area = main_chunks[3];
103
104    let results_chunks = if results_area.width > 80 {
105        Layout::default()
106            .direction(Direction::Horizontal)
107            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
108            .split(results_area)
109    } else {
110        Layout::default()
111            .direction(Direction::Vertical)
112            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
113            .split(results_area)
114    };
115
116    PanelLayout {
117        regex_input: main_chunks[0],
118        test_input: main_chunks[1],
119        replace_input: main_chunks[2],
120        match_display: results_chunks[0],
121        explanation: results_chunks[1],
122        status_bar,
123        quickref,
124    }
125}
126
127pub fn render(frame: &mut Frame, app: &App) {
128    let size = frame.area();
129    let layout = compute_layout(size, app.show_quickref);
130
131    let bt = border_type(app.rounded_borders);
132
133    // Overlays
134    if app.overlay.help {
135        render_help_overlay(
136            frame,
137            size,
138            app.engine_kind,
139            app.overlay.help_page,
140            bt,
141            app.help_scroll_offset,
142        );
143        return;
144    }
145    if app.overlay.recipes {
146        render_recipe_overlay(frame, size, app.overlay.recipe_index, bt);
147        return;
148    }
149    if app.overlay.benchmark {
150        render_benchmark_overlay(frame, size, &app.benchmark_results, bt);
151        return;
152    }
153    if app.overlay.codegen {
154        render_codegen_overlay(
155            frame,
156            size,
157            app.overlay.codegen_language_index,
158            app.regex_editor.content(),
159            app.flags,
160            bt,
161        );
162        return;
163    }
164    if let Some(grex_state) = app.overlay.grex.as_ref() {
165        grex_overlay::render_with_border(frame, size, grex_state, bt);
166        return;
167    }
168
169    #[cfg(feature = "pcre2-engine")]
170    if let Some(ref session) = app.debug_session {
171        debugger::render_debugger(frame, size, session, bt);
172        return;
173    }
174
175    let error_str = app.error.as_deref();
176
177    // Regex input
178    frame.render_widget(
179        RegexInput {
180            editor: &app.regex_editor,
181            focused: app.focused_panel == 0,
182            error: error_str,
183            error_offset: app.error_offset,
184            border_type: bt,
185            syntax_tokens: &app.syntax_tokens,
186        },
187        layout.regex_input,
188    );
189
190    // Test string input
191    frame.render_widget(
192        TestInput {
193            editor: &app.test_editor,
194            focused: app.focused_panel == 1,
195            matches: &app.matches,
196            show_whitespace: app.show_whitespace,
197            border_type: bt,
198        },
199        layout.test_input,
200    );
201
202    // Replacement input
203    frame.render_widget(
204        ReplaceInput {
205            editor: &app.replace_editor,
206            focused: app.focused_panel == 2,
207            border_type: bt,
208        },
209        layout.replace_input,
210    );
211
212    // Match display
213    frame.render_widget(
214        MatchDisplay {
215            matches: &app.matches,
216            replace_result: app.replace_result.as_ref(),
217            scroll: app.scroll.match_scroll,
218            focused: app.focused_panel == 3,
219            selected_match: app.selection.match_index,
220            selected_capture: app.selection.capture_index,
221            clipboard_status: app.status.text.as_deref(),
222            border_type: bt,
223        },
224        layout.match_display,
225    );
226
227    // Explanation panel
228    frame.render_widget(
229        ExplanationPanel {
230            nodes: &app.explanation,
231            error: error_str,
232            scroll: app.scroll.explain_scroll,
233            focused: app.focused_panel == 4,
234            border_type: bt,
235        },
236        layout.explanation,
237    );
238
239    // Quick Reference side panel (F3 to toggle; PageUp/PageDown to scroll)
240    // — reuses the F1 help-page-1 content so the two never drift out of sync.
241    if let Some(quickref_area) = layout.quickref {
242        let pages = build_help_pages(app.engine_kind);
243        let mut lines: Vec<Line<'static>> = vec![Line::from("")];
244        lines.extend(pages[1].1.iter().cloned());
245        // Cap the scroll offset at the maximum that keeps content on screen
246        // (accounting for the 2 chrome rows: top + bottom border). Prevents
247        // the user from scrolling into an entirely blank panel.
248        let max_scroll =
249            (lines.len() as u16).saturating_sub(quickref_area.height.saturating_sub(2));
250        let scroll = app.quickref_scroll.min(max_scroll);
251        let block = Block::default()
252            .borders(Borders::ALL)
253            .border_type(bt)
254            .border_style(Style::default().fg(theme::BLUE))
255            .title(Span::styled(
256                " Quick Reference (F3) ",
257                Style::default().fg(theme::TEXT),
258            ));
259        frame.render_widget(
260            Paragraph::new(lines)
261                .block(block)
262                .wrap(Wrap { trim: false })
263                .scroll((scroll, 0)),
264            quickref_area,
265        );
266    }
267
268    // Status bar
269    #[cfg(feature = "pcre2-engine")]
270    let engine_warning: Option<&'static str> =
271        if app.engine_kind == EngineKind::Pcre2 && crate::engine::pcre2::is_pcre2_10_45() {
272            Some("CVE-2025-58050: PCRE2 10.45 linked — upgrade to >= 10.46.")
273        } else {
274            None
275        };
276    #[cfg(not(feature = "pcre2-engine"))]
277    let engine_warning: Option<&'static str> = None;
278
279    frame.render_widget(
280        StatusBar {
281            engine: app.engine_kind,
282            match_count: app.matches.len(),
283            flags: app.flags,
284            show_whitespace: app.show_whitespace,
285            compile_time: app.compile_time,
286            match_time: app.match_time,
287            vim_mode: if app.vim_mode {
288                Some(app.vim_state.mode)
289            } else {
290                None
291            },
292            engine_warning,
293        },
294        layout.status_bar,
295    );
296
297    // Transient status message overlay (e.g. "Copied pattern: …") drawn on
298    // top of the right edge of the status bar so users see Ctrl+Y feedback
299    // at the conventional location. Reported missing in #78. Implemented as
300    // an overlay rather than a StatusBar field to avoid touching that
301    // struct's public API.
302    if let Some(msg) = app.status.text.as_deref() {
303        let label = format!(" {msg} ");
304        let label_width = label.chars().count() as u16;
305        let bar = layout.status_bar;
306        let width = label_width.min(bar.width);
307        let overlay_rect = Rect {
308            x: bar.x + bar.width.saturating_sub(width),
309            y: bar.y,
310            width,
311            height: 1,
312        };
313        frame.render_widget(Clear, overlay_rect);
314        frame.render_widget(
315            Paragraph::new(Span::styled(
316                label,
317                Style::default()
318                    .fg(theme::BASE)
319                    .bg(theme::GREEN)
320                    .add_modifier(Modifier::BOLD),
321            )),
322            overlay_rect,
323        );
324    }
325}
326
327pub const HELP_PAGE_COUNT: usize = 3;
328
329/*
330Below accounts for additional lines from header and footer.
331title(1) + bottom border(1) + navigation help line(1) = 3
332+ additional single padding(1) = total_padding(4)
333*/
334pub const HELP_PAGE_PADDING: u16 = 4;
335
336pub const HELP_PAGE_COL_0_WIDTH: usize = 16;
337fn build_help_pages(engine: EngineKind) -> Vec<(String, Vec<Line<'static>>)> {
338    let shortcut = |key: &'static str, desc: &'static str| -> Line<'static> {
339        Line::from(vec![
340            Span::styled(
341                format!("{key:<HELP_PAGE_COL_0_WIDTH$}"),
342                Style::default().fg(theme::GREEN),
343            ),
344            Span::styled(desc, Style::default().fg(theme::TEXT)),
345        ])
346    };
347
348    // Page 0: Keyboard shortcuts
349    let page0 = vec![
350        shortcut("Tab/Shift+Tab", "Cycle focus forward/backward"),
351        shortcut("Up/Down", "Scroll panel / move cursor / select match"),
352        shortcut("Enter", "Insert newline (test string)"),
353        shortcut("Ctrl+E", "Cycle regex engine"),
354        shortcut("Ctrl+Z", "Undo"),
355        shortcut("Ctrl+Shift+Z", "Redo"),
356        shortcut(
357            "Ctrl+Y",
358            "Copy pattern (regex panel) or match (matches panel)",
359        ),
360        shortcut("Ctrl+O", "Output results to stdout and quit"),
361        shortcut("Ctrl+S", "Save workspace"),
362        shortcut("Ctrl+R", "Open regex recipe library"),
363        shortcut("Ctrl+B", "Benchmark pattern across all engines"),
364        shortcut("Ctrl+U", "Copy regex101.com URL to clipboard"),
365        shortcut("Ctrl+D", "Step-through regex debugger"),
366        shortcut("Ctrl+G", "Generate code for pattern"),
367        shortcut("Ctrl+X", "Generate regex from examples (grex)"),
368        shortcut("Ctrl+W", "Toggle whitespace visualization"),
369        shortcut("Ctrl+Left/Right", "Move cursor by word"),
370        shortcut("Alt+Up/Down", "Browse pattern history"),
371        shortcut("Alt+i", "Toggle case-insensitive"),
372        shortcut("Alt+m", "Toggle multi-line"),
373        shortcut("Alt+s", "Toggle dot-matches-newline"),
374        shortcut("Alt+u", "Toggle unicode mode"),
375        shortcut("Alt+x", "Toggle extended mode"),
376        shortcut(
377            "F1",
378            "Show/hide help (Left(h)/Right(l) to page, Up(k)/Down(j) to scroll)",
379        ),
380        shortcut("F3", "Toggle Quick Reference side panel"),
381        shortcut("PgUp/PgDn", "Scroll Quick Reference side panel"),
382        shortcut("Esc", "Quit"),
383        Line::from(""),
384        Line::from(Span::styled(
385            "Vim: --vim flag | Normal: hjkl wb e 0$^ gg/G x dd cc o/O u p",
386            Style::default().fg(theme::SUBTEXT),
387        )),
388        Line::from(Span::styled(
389            "Mouse: click to focus/position, scroll to navigate",
390            Style::default().fg(theme::SUBTEXT),
391        )),
392    ];
393
394    let header = |text: &'static str| -> Line<'static> {
395        Line::from(Span::styled(text, Style::default().fg(theme::OVERLAY)))
396    };
397
398    // Page 1: Quick Reference
399    let page1 = vec![
400        header("── Sequences ─────────────────────────────────────"),
401        shortcut(".", "Any character (except newline by default)"),
402        shortcut("\\d  \\D", "Digit / non-digit"),
403        shortcut("\\w  \\W", "Word char / non-word char"),
404        shortcut("\\s  \\S", "Whitespace / non-whitespace"),
405        shortcut("\\t  \\n  \\r", "Tab / newline / carriage return"),
406        shortcut("\\b  \\B", "Word boundary / non-boundary"),
407        shortcut("^  $", "Start / end of line"),
408        header("── Classes & Groups ──────────────────────────────"),
409        shortcut("[abc]", "Character class"),
410        shortcut("[^abc]", "Negated character class"),
411        shortcut("[a-z]", "Character range"),
412        shortcut("(group)", "Capturing group"),
413        shortcut("(?:group)", "Non-capturing group"),
414        shortcut("(?P<n>...)", "Named capturing group"),
415        shortcut("(?=...)  (?!...)", "Lookahead pos/neg  (fancy/PCRE2)"),
416        shortcut("a|b", "Alternation (a or b)"),
417        header("── Quantifiers ───────────────────────────────────"),
418        shortcut("*  +  ?", "0+, 1+, 0 or 1 (greedy)"),
419        shortcut("*?  +?  ??", "Lazy variants"),
420        shortcut("{n}  {n,m}", "Exact / range repetition"),
421        Line::from(Span::styled(
422            "Replacement: $1, ${name}, $0/$&, $$ for literal $",
423            Style::default().fg(theme::SUBTEXT),
424        )),
425    ];
426
427    // Page 2: Engine-specific
428    let engine_name = format!("{engine}");
429    let page2 = match engine {
430        EngineKind::RustRegex => vec![
431            Line::from(Span::styled(
432                "Rust regex engine — linear time guarantee",
433                Style::default().fg(theme::BLUE),
434            )),
435            Line::from(""),
436            shortcut("Unicode", "Full Unicode support by default"),
437            shortcut("No lookbehind", "Use fancy-regex or PCRE2 for lookaround"),
438            shortcut("No backrefs", "Use fancy-regex or PCRE2 for backrefs"),
439            shortcut("\\p{Letter}", "Unicode category"),
440            shortcut("(?i)", "Inline case-insensitive flag"),
441            shortcut("(?m)", "Inline multi-line flag"),
442            shortcut("(?s)", "Inline dot-matches-newline flag"),
443            shortcut("(?x)", "Inline extended/verbose flag"),
444        ],
445        EngineKind::FancyRegex => vec![
446            Line::from(Span::styled(
447                "fancy-regex engine — lookaround + backreferences",
448                Style::default().fg(theme::BLUE),
449            )),
450            Line::from(""),
451            shortcut("(?=...)", "Positive lookahead"),
452            shortcut("(?!...)", "Negative lookahead"),
453            shortcut("(?<=...)", "Positive lookbehind"),
454            shortcut("(?<!...)", "Negative lookbehind"),
455            shortcut("\\1  \\2", "Backreferences"),
456            shortcut("(?>...)", "Atomic group"),
457            Line::from(""),
458            Line::from(Span::styled(
459                "Delegates to Rust regex for non-fancy patterns",
460                Style::default().fg(theme::SUBTEXT),
461            )),
462        ],
463        #[cfg(feature = "pcre2-engine")]
464        EngineKind::Pcre2 => vec![
465            Line::from(Span::styled(
466                "PCRE2 engine — full-featured",
467                Style::default().fg(theme::BLUE),
468            )),
469            Line::from(""),
470            shortcut("(?=...)(?!...)", "Lookahead"),
471            shortcut("(?<=...)(?<!..)", "Lookbehind"),
472            shortcut("\\1  \\2", "Backreferences"),
473            shortcut("(?>...)", "Atomic group"),
474            shortcut("(*SKIP)(*FAIL)", "Backtracking control verbs"),
475            shortcut("(?R)  (?1)", "Recursion / subroutine calls"),
476            shortcut("(?(cond)y|n)", "Conditional patterns"),
477            shortcut("\\K", "Reset match start"),
478            shortcut("(*UTF)", "Force UTF-8 mode"),
479        ],
480    };
481
482    vec![
483        ("Keyboard Shortcuts".to_string(), page0),
484        ("Quick Reference".to_string(), page1),
485        (format!("Engine: {engine_name}"), page2),
486    ]
487}
488
489// note: assumes terminal width >= HELP_PAGE_MAX_WIDTH + 4
490pub fn build_lengths_of_help_pages() -> HashMap<EngineKind, Vec<u16>> {
491    let mut map: HashMap<EngineKind, Vec<u16>> = HashMap::new();
492    let engines = EngineKind::all();
493    for engine in engines {
494        let pages_len = (0..HELP_PAGE_COUNT)
495            .map(|page| {
496                let (lines, _) = generate_help_page_content(engine, page);
497                let counts: Vec<u16> = lines
498                    .iter()
499                    .map(|x| {
500                        // + 2 for two vertical lines
501                        let width = (x.width() + 2) as u16;
502                        width.div_ceil(HELP_PAGE_MAX_WIDTH)
503                    })
504                    .collect();
505                counts.iter().sum::<u16>() + HELP_PAGE_PADDING
506            })
507            .collect();
508        map.insert(engine, pages_len);
509    }
510    map
511}
512
513pub(crate) fn centered_overlay(
514    frame: &mut Frame,
515    area: Rect,
516    max_width: u16,
517    content_height: u16,
518) -> Rect {
519    let w = max_width.min(area.width.saturating_sub(4));
520    let h = content_height.min(area.height.saturating_sub(4));
521    let x = (area.width.saturating_sub(w)) / 2;
522    let y = (area.height.saturating_sub(h)) / 2;
523    let rect = Rect::new(x, y, w, h);
524    frame.render_widget(Clear, rect);
525    rect
526}
527
528pub const HELP_PAGE_HEIGHT: u16 = 28;
529pub const HELP_PAGE_MAX_WIDTH: u16 = 64;
530
531fn generate_help_page_content(
532    engine: EngineKind,
533    page: usize,
534) -> (std::vec::Vec<ratatui::prelude::Line<'static>>, usize) {
535    let pages = build_help_pages(engine);
536    let current = page.min(pages.len() - 1);
537    let (title, content) = &pages[current];
538
539    let mut lines: Vec<Line<'static>> = vec![
540        Line::from(Span::styled(
541            title.clone(),
542            Style::default()
543                .fg(theme::BLUE)
544                .add_modifier(Modifier::BOLD),
545        )),
546        Line::from(""),
547    ];
548    lines.extend(content.iter().cloned());
549    (lines, current)
550}
551
552fn render_help_overlay(
553    frame: &mut Frame,
554    area: Rect,
555    engine: EngineKind,
556    page: usize,
557    bt: BorderType,
558    scroll_offset: u16,
559) {
560    let help_area = centered_overlay(frame, area, HELP_PAGE_MAX_WIDTH, HELP_PAGE_HEIGHT);
561
562    let chunks = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).split(help_area);
563
564    let (lines, current) = generate_help_page_content(engine, page);
565
566    let block = Block::default()
567        .borders(Borders::ALL)
568        .border_type(bt)
569        .border_style(Style::default().fg(theme::BLUE))
570        .title(Span::styled(" Help ", Style::default().fg(theme::TEXT)))
571        .title(
572            Line::styled(
573                format!(" Page {}/{} ", current + 1, HELP_PAGE_COUNT),
574                Style::default().fg(theme::BASE).bg(theme::BLUE),
575            )
576            .right_aligned(),
577        )
578        .style(Style::default().bg(theme::BASE));
579
580    let paragraph = Paragraph::new(lines)
581        .block(block)
582        .wrap(Wrap { trim: false })
583        .scroll((scroll_offset, 0));
584    let nav_ui = Paragraph::new(Line::styled(
585        "  Up(k)/Down(j): Scroll | Left(h)/Right(l): Page | Any other key: Close ",
586        Style::default().fg(theme::TEXT),
587    ))
588    .right_aligned()
589    .style(Style::default().bg(theme::BASE));
590
591    frame.render_widget(paragraph, chunks[0]);
592    frame.render_widget(nav_ui, chunks[1]);
593}
594
595fn render_recipe_overlay(frame: &mut Frame, area: Rect, selected: usize, bt: BorderType) {
596    let overlay_area = centered_overlay(frame, area, 70, RECIPES.len() as u16 + 6);
597
598    let mut lines: Vec<Line<'static>> = vec![
599        Line::from(Span::styled(
600            "Select a recipe to load",
601            Style::default()
602                .fg(theme::BLUE)
603                .add_modifier(Modifier::BOLD),
604        )),
605        Line::from(""),
606    ];
607
608    for (i, recipe) in RECIPES.iter().enumerate() {
609        let is_selected = i == selected;
610        let marker = if is_selected { ">" } else { " " };
611        let style = if is_selected {
612            Style::default().fg(theme::BASE).bg(theme::BLUE)
613        } else {
614            Style::default().fg(theme::TEXT)
615        };
616        lines.push(Line::from(Span::styled(
617            format!("{marker} {:<24} {}", recipe.name, recipe.description),
618            style,
619        )));
620    }
621
622    lines.push(Line::from(""));
623    lines.push(Line::from(Span::styled(
624        " Up/Down: select | Enter: load | Esc: cancel ",
625        Style::default().fg(theme::SUBTEXT),
626    )));
627
628    let block = Block::default()
629        .borders(Borders::ALL)
630        .border_type(bt)
631        .border_style(Style::default().fg(theme::GREEN))
632        .title(Span::styled(
633            " Recipes (Ctrl+R) ",
634            Style::default().fg(theme::TEXT),
635        ))
636        .style(Style::default().bg(theme::BASE));
637
638    let paragraph = Paragraph::new(lines)
639        .block(block)
640        .wrap(Wrap { trim: false });
641
642    frame.render_widget(paragraph, overlay_area);
643}
644
645fn render_benchmark_overlay(
646    frame: &mut Frame,
647    area: Rect,
648    results: &[BenchmarkResult],
649    bt: BorderType,
650) {
651    let overlay_area = centered_overlay(frame, area, 70, results.len() as u16 + 8);
652
653    let fastest_idx = results
654        .iter()
655        .enumerate()
656        .filter(|(_, r)| r.error.is_none())
657        .min_by_key(|(_, r)| r.compile_time + r.match_time)
658        .map(|(i, _)| i);
659
660    let mut lines: Vec<Line<'static>> = vec![
661        Line::from(Span::styled(
662            "Performance Comparison",
663            Style::default()
664                .fg(theme::BLUE)
665                .add_modifier(Modifier::BOLD),
666        )),
667        Line::from(""),
668        Line::from(vec![Span::styled(
669            format!(
670                "{:<16} {:>10} {:>10} {:>10} {:>8}",
671                "Engine", "Compile", "Match", "Total", "Matches"
672            ),
673            Style::default()
674                .fg(theme::SUBTEXT)
675                .add_modifier(Modifier::BOLD),
676        )]),
677    ];
678
679    for (i, result) in results.iter().enumerate() {
680        let is_fastest = fastest_idx == Some(i);
681        if let Some(ref err) = result.error {
682            let line_text = format!("{:<16} {}", result.engine, err);
683            lines.push(Line::from(Span::styled(
684                line_text,
685                Style::default().fg(theme::RED),
686            )));
687        } else {
688            let total = result.compile_time + result.match_time;
689            let line_text = format!(
690                "{:<16} {:>10} {:>10} {:>10} {:>8}",
691                result.engine,
692                status_bar::format_duration(result.compile_time),
693                status_bar::format_duration(result.match_time),
694                status_bar::format_duration(total),
695                result.match_count,
696            );
697            let style = if is_fastest {
698                Style::default()
699                    .fg(theme::GREEN)
700                    .add_modifier(Modifier::BOLD)
701            } else {
702                Style::default().fg(theme::TEXT)
703            };
704            let mut spans = vec![Span::styled(line_text, style)];
705            if is_fastest {
706                spans.push(Span::styled(" *", Style::default().fg(theme::GREEN)));
707            }
708            lines.push(Line::from(spans));
709        }
710    }
711
712    lines.push(Line::from(""));
713    if fastest_idx.is_some() {
714        lines.push(Line::from(Span::styled(
715            "* = fastest",
716            Style::default().fg(theme::GREEN),
717        )));
718    }
719    lines.push(Line::from(Span::styled(
720        " Any key: close ",
721        Style::default().fg(theme::SUBTEXT),
722    )));
723
724    let block = Block::default()
725        .borders(Borders::ALL)
726        .border_type(bt)
727        .border_style(Style::default().fg(theme::PEACH))
728        .title(Span::styled(
729            " Benchmark (Ctrl+B) ",
730            Style::default().fg(theme::TEXT),
731        ))
732        .style(Style::default().bg(theme::BASE));
733
734    let paragraph = Paragraph::new(lines)
735        .block(block)
736        .wrap(Wrap { trim: false });
737
738    frame.render_widget(paragraph, overlay_area);
739}
740
741fn render_codegen_overlay(
742    frame: &mut Frame,
743    area: Rect,
744    selected: usize,
745    pattern: &str,
746    flags: crate::engine::EngineFlags,
747    bt: BorderType,
748) {
749    let langs = codegen::Language::all();
750    let preview = if pattern.is_empty() {
751        String::from("(no pattern)")
752    } else {
753        let lang = &langs[selected.min(langs.len() - 1)];
754        codegen::generate_code(lang, pattern, &flags)
755    };
756
757    let preview_lines: Vec<&str> = preview.lines().collect();
758    let preview_height = preview_lines.len() as u16;
759    // Languages list + title + spacing + preview + footer
760    let content_height = langs.len() as u16 + preview_height + 7;
761    let overlay_area = centered_overlay(frame, area, 74, content_height);
762
763    let mut lines: Vec<Line<'static>> = vec![
764        Line::from(Span::styled(
765            "Select a language to generate code",
766            Style::default()
767                .fg(theme::MAUVE)
768                .add_modifier(Modifier::BOLD),
769        )),
770        Line::from(""),
771    ];
772
773    for (i, lang) in langs.iter().enumerate() {
774        let is_selected = i == selected;
775        let marker = if is_selected { ">" } else { " " };
776        let style = if is_selected {
777            Style::default().fg(theme::BASE).bg(theme::MAUVE)
778        } else {
779            Style::default().fg(theme::TEXT)
780        };
781        lines.push(Line::from(Span::styled(format!("{marker} {lang}"), style)));
782    }
783
784    lines.push(Line::from(""));
785    lines.push(Line::from(Span::styled(
786        "Preview:",
787        Style::default()
788            .fg(theme::SUBTEXT)
789            .add_modifier(Modifier::BOLD),
790    )));
791    for pl in preview_lines {
792        lines.push(Line::from(Span::styled(
793            pl.to_string(),
794            Style::default().fg(theme::GREEN),
795        )));
796    }
797
798    lines.push(Line::from(""));
799    lines.push(Line::from(Span::styled(
800        " Up/Down: select | Enter: copy to clipboard | Esc: cancel ",
801        Style::default().fg(theme::SUBTEXT),
802    )));
803
804    let block = Block::default()
805        .borders(Borders::ALL)
806        .border_type(bt)
807        .border_style(Style::default().fg(theme::MAUVE))
808        .title(Span::styled(
809            " Code Generation (Ctrl+G) ",
810            Style::default().fg(theme::TEXT),
811        ))
812        .style(Style::default().bg(theme::BASE));
813
814    let paragraph = Paragraph::new(lines)
815        .block(block)
816        .wrap(Wrap { trim: false });
817
818    frame.render_widget(paragraph, overlay_area);
819}