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