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