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
231pub const HELP_PAGE_COUNT: usize = 3;
232
233/*
234Below accounts for additional lines from header and footer.
235title(1) + bottom border(1) + navigation help line(1) = 3
236+ additional single padding(1) = total_padding(4)
237*/
238pub const HELP_PAGE_PADDING: u16 = 4;
239
240pub const HELP_PAGE_COL_0_WIDTH: usize = 16;
241fn build_help_pages(engine: EngineKind) -> Vec<(String, Vec<Line<'static>>)> {
242    let shortcut = |key: &'static str, desc: &'static str| -> Line<'static> {
243        Line::from(vec![
244            Span::styled(
245                format!("{key:<width$}", width = HELP_PAGE_COL_0_WIDTH),
246                Style::default().fg(theme::GREEN),
247            ),
248            Span::styled(desc, Style::default().fg(theme::TEXT)),
249        ])
250    };
251
252    // Page 0: Keyboard shortcuts
253    let page0 = vec![
254        shortcut("Tab/Shift+Tab", "Cycle focus forward/backward"),
255        shortcut("Up/Down", "Scroll panel / move cursor / select match"),
256        shortcut("Enter", "Insert newline (test string)"),
257        shortcut("Ctrl+E", "Cycle regex engine"),
258        shortcut("Ctrl+Z", "Undo"),
259        shortcut("Ctrl+Shift+Z", "Redo"),
260        shortcut(
261            "Ctrl+Y",
262            "Copy pattern (regex panel) or match (matches panel)",
263        ),
264        shortcut("Ctrl+O", "Output results to stdout and quit"),
265        shortcut("Ctrl+S", "Save workspace"),
266        shortcut("Ctrl+R", "Open regex recipe library"),
267        shortcut("Ctrl+B", "Benchmark pattern across all engines"),
268        shortcut("Ctrl+U", "Copy regex101.com URL to clipboard"),
269        shortcut("Ctrl+D", "Step-through regex debugger"),
270        shortcut("Ctrl+G", "Generate code for pattern"),
271        shortcut("Ctrl+X", "Generate regex from examples (grex)"),
272        shortcut("Ctrl+W", "Toggle whitespace visualization"),
273        shortcut("Ctrl+Left/Right", "Move cursor by word"),
274        shortcut("Alt+Up/Down", "Browse pattern history"),
275        shortcut("Alt+i", "Toggle case-insensitive"),
276        shortcut("Alt+m", "Toggle multi-line"),
277        shortcut("Alt+s", "Toggle dot-matches-newline"),
278        shortcut("Alt+u", "Toggle unicode mode"),
279        shortcut("Alt+x", "Toggle extended mode"),
280        shortcut(
281            "F1",
282            "Show/hide help (Left(h)/Right(l) to page, Up(k)/Down(j) to scroll)",
283        ),
284        shortcut("Esc", "Quit"),
285        Line::from(""),
286        Line::from(Span::styled(
287            "Vim: --vim flag | Normal: hjkl wb e 0$^ gg/G x dd cc o/O u p",
288            Style::default().fg(theme::SUBTEXT),
289        )),
290        Line::from(Span::styled(
291            "Mouse: click to focus/position, scroll to navigate",
292            Style::default().fg(theme::SUBTEXT),
293        )),
294    ];
295
296    let header = |text: &'static str| -> Line<'static> {
297        Line::from(Span::styled(text, Style::default().fg(theme::OVERLAY)))
298    };
299
300    // Page 1: Quick Reference
301    let page1 = vec![
302        header("── Sequences ─────────────────────────────────────"),
303        shortcut(".", "Any character (except newline by default)"),
304        shortcut("\\d  \\D", "Digit / non-digit"),
305        shortcut("\\w  \\W", "Word char / non-word char"),
306        shortcut("\\s  \\S", "Whitespace / non-whitespace"),
307        shortcut("\\t  \\n  \\r", "Tab / newline / carriage return"),
308        shortcut("\\b  \\B", "Word boundary / non-boundary"),
309        shortcut("^  $", "Start / end of line"),
310        header("── Classes & Groups ──────────────────────────────"),
311        shortcut("[abc]", "Character class"),
312        shortcut("[^abc]", "Negated character class"),
313        shortcut("[a-z]", "Character range"),
314        shortcut("(group)", "Capturing group"),
315        shortcut("(?:group)", "Non-capturing group"),
316        shortcut("(?P<n>...)", "Named capturing group"),
317        shortcut("(?=...)  (?!...)", "Lookahead pos/neg  (fancy/PCRE2)"),
318        shortcut("a|b", "Alternation (a or b)"),
319        header("── Quantifiers ───────────────────────────────────"),
320        shortcut("*  +  ?", "0+, 1+, 0 or 1 (greedy)"),
321        shortcut("*?  +?  ??", "Lazy variants"),
322        shortcut("{n}  {n,m}", "Exact / range repetition"),
323        Line::from(Span::styled(
324            "Replacement: $1, ${name}, $0/$&, $$ for literal $",
325            Style::default().fg(theme::SUBTEXT),
326        )),
327    ];
328
329    // Page 2: Engine-specific
330    let engine_name = format!("{engine}");
331    let page2 = match engine {
332        EngineKind::RustRegex => vec![
333            Line::from(Span::styled(
334                "Rust regex engine — linear time guarantee",
335                Style::default().fg(theme::BLUE),
336            )),
337            Line::from(""),
338            shortcut("Unicode", "Full Unicode support by default"),
339            shortcut("No lookbehind", "Use fancy-regex or PCRE2 for lookaround"),
340            shortcut("No backrefs", "Use fancy-regex or PCRE2 for backrefs"),
341            shortcut("\\p{Letter}", "Unicode category"),
342            shortcut("(?i)", "Inline case-insensitive flag"),
343            shortcut("(?m)", "Inline multi-line flag"),
344            shortcut("(?s)", "Inline dot-matches-newline flag"),
345            shortcut("(?x)", "Inline extended/verbose flag"),
346        ],
347        EngineKind::FancyRegex => vec![
348            Line::from(Span::styled(
349                "fancy-regex engine — lookaround + backreferences",
350                Style::default().fg(theme::BLUE),
351            )),
352            Line::from(""),
353            shortcut("(?=...)", "Positive lookahead"),
354            shortcut("(?!...)", "Negative lookahead"),
355            shortcut("(?<=...)", "Positive lookbehind"),
356            shortcut("(?<!...)", "Negative lookbehind"),
357            shortcut("\\1  \\2", "Backreferences"),
358            shortcut("(?>...)", "Atomic group"),
359            Line::from(""),
360            Line::from(Span::styled(
361                "Delegates to Rust regex for non-fancy patterns",
362                Style::default().fg(theme::SUBTEXT),
363            )),
364        ],
365        #[cfg(feature = "pcre2-engine")]
366        EngineKind::Pcre2 => vec![
367            Line::from(Span::styled(
368                "PCRE2 engine — full-featured",
369                Style::default().fg(theme::BLUE),
370            )),
371            Line::from(""),
372            shortcut("(?=...)(?!...)", "Lookahead"),
373            shortcut("(?<=...)(?<!..)", "Lookbehind"),
374            shortcut("\\1  \\2", "Backreferences"),
375            shortcut("(?>...)", "Atomic group"),
376            shortcut("(*SKIP)(*FAIL)", "Backtracking control verbs"),
377            shortcut("(?R)  (?1)", "Recursion / subroutine calls"),
378            shortcut("(?(cond)y|n)", "Conditional patterns"),
379            shortcut("\\K", "Reset match start"),
380            shortcut("(*UTF)", "Force UTF-8 mode"),
381        ],
382    };
383
384    vec![
385        ("Keyboard Shortcuts".to_string(), page0),
386        ("Quick Reference".to_string(), page1),
387        (format!("Engine: {engine_name}"), page2),
388    ]
389}
390
391// note: assumes terminal width >= HELP_PAGE_MAX_WIDTH + 4
392pub fn build_lengths_of_help_pages() -> HashMap<EngineKind, Vec<u16>> {
393    let mut map: HashMap<EngineKind, Vec<u16>> = HashMap::new();
394    let engines = EngineKind::all();
395    for engine in engines {
396        let pages_len = (0..HELP_PAGE_COUNT)
397            .map(|page| {
398                let (lines, _) = generate_help_page_content(engine, page);
399                let counts: Vec<u16> = lines
400                    .iter()
401                    .map(|x| {
402                        // + 2 for two vertical lines
403                        let width = (x.width() + 2) as u16;
404                        width.div_ceil(HELP_PAGE_MAX_WIDTH)
405                    })
406                    .collect();
407                counts.iter().sum::<u16>() + HELP_PAGE_PADDING
408            })
409            .collect();
410        map.insert(engine, pages_len);
411    }
412    map
413}
414
415pub(crate) fn centered_overlay(
416    frame: &mut Frame,
417    area: Rect,
418    max_width: u16,
419    content_height: u16,
420) -> Rect {
421    let w = max_width.min(area.width.saturating_sub(4));
422    let h = content_height.min(area.height.saturating_sub(4));
423    let x = (area.width.saturating_sub(w)) / 2;
424    let y = (area.height.saturating_sub(h)) / 2;
425    let rect = Rect::new(x, y, w, h);
426    frame.render_widget(Clear, rect);
427    rect
428}
429
430pub const HELP_PAGE_HEIGHT: u16 = 28;
431pub const HELP_PAGE_MAX_WIDTH: u16 = 64;
432
433fn generate_help_page_content(
434    engine: EngineKind,
435    page: usize,
436) -> (std::vec::Vec<ratatui::prelude::Line<'static>>, usize) {
437    let pages = build_help_pages(engine);
438    let current = page.min(pages.len() - 1);
439    let (title, content) = &pages[current];
440
441    let mut lines: Vec<Line<'static>> = vec![
442        Line::from(Span::styled(
443            title.clone(),
444            Style::default()
445                .fg(theme::BLUE)
446                .add_modifier(Modifier::BOLD),
447        )),
448        Line::from(""),
449    ];
450    lines.extend(content.iter().cloned());
451    (lines, current)
452}
453
454fn render_help_overlay(
455    frame: &mut Frame,
456    area: Rect,
457    engine: EngineKind,
458    page: usize,
459    bt: BorderType,
460    scroll_offset: u16,
461) {
462    let help_area = centered_overlay(frame, area, HELP_PAGE_MAX_WIDTH, HELP_PAGE_HEIGHT);
463
464    let chunks = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).split(help_area);
465
466    let (lines, current) = generate_help_page_content(engine, page);
467
468    let block = Block::default()
469        .borders(Borders::ALL)
470        .border_type(bt)
471        .border_style(Style::default().fg(theme::BLUE))
472        .title(Span::styled(" Help ", Style::default().fg(theme::TEXT)))
473        .title(
474            Line::styled(
475                format!(" Page {}/{} ", current + 1, HELP_PAGE_COUNT),
476                Style::default().fg(theme::BASE).bg(theme::BLUE),
477            )
478            .right_aligned(),
479        )
480        .style(Style::default().bg(theme::BASE));
481
482    let paragraph = Paragraph::new(lines)
483        .block(block)
484        .wrap(Wrap { trim: false })
485        .scroll((scroll_offset, 0));
486    let nav_ui = Paragraph::new(Line::styled(
487        "  Up(k)/Down(j): Scroll | Left(h)/Right(l): Page | Any other key: Close ",
488        Style::default().fg(theme::TEXT),
489    ))
490    .right_aligned()
491    .style(Style::default().bg(theme::BASE));
492
493    frame.render_widget(paragraph, chunks[0]);
494    frame.render_widget(nav_ui, chunks[1]);
495}
496
497fn render_recipe_overlay(frame: &mut Frame, area: Rect, selected: usize, bt: BorderType) {
498    let overlay_area = centered_overlay(frame, area, 70, RECIPES.len() as u16 + 6);
499
500    let mut lines: Vec<Line<'static>> = vec![
501        Line::from(Span::styled(
502            "Select a recipe to load",
503            Style::default()
504                .fg(theme::BLUE)
505                .add_modifier(Modifier::BOLD),
506        )),
507        Line::from(""),
508    ];
509
510    for (i, recipe) in RECIPES.iter().enumerate() {
511        let is_selected = i == selected;
512        let marker = if is_selected { ">" } else { " " };
513        let style = if is_selected {
514            Style::default().fg(theme::BASE).bg(theme::BLUE)
515        } else {
516            Style::default().fg(theme::TEXT)
517        };
518        lines.push(Line::from(Span::styled(
519            format!("{marker} {:<24} {}", recipe.name, recipe.description),
520            style,
521        )));
522    }
523
524    lines.push(Line::from(""));
525    lines.push(Line::from(Span::styled(
526        " Up/Down: select | Enter: load | Esc: cancel ",
527        Style::default().fg(theme::SUBTEXT),
528    )));
529
530    let block = Block::default()
531        .borders(Borders::ALL)
532        .border_type(bt)
533        .border_style(Style::default().fg(theme::GREEN))
534        .title(Span::styled(
535            " Recipes (Ctrl+R) ",
536            Style::default().fg(theme::TEXT),
537        ))
538        .style(Style::default().bg(theme::BASE));
539
540    let paragraph = Paragraph::new(lines)
541        .block(block)
542        .wrap(Wrap { trim: false });
543
544    frame.render_widget(paragraph, overlay_area);
545}
546
547fn render_benchmark_overlay(
548    frame: &mut Frame,
549    area: Rect,
550    results: &[BenchmarkResult],
551    bt: BorderType,
552) {
553    let overlay_area = centered_overlay(frame, area, 70, results.len() as u16 + 8);
554
555    let fastest_idx = results
556        .iter()
557        .enumerate()
558        .filter(|(_, r)| r.error.is_none())
559        .min_by_key(|(_, r)| r.compile_time + r.match_time)
560        .map(|(i, _)| i);
561
562    let mut lines: Vec<Line<'static>> = vec![
563        Line::from(Span::styled(
564            "Performance Comparison",
565            Style::default()
566                .fg(theme::BLUE)
567                .add_modifier(Modifier::BOLD),
568        )),
569        Line::from(""),
570        Line::from(vec![Span::styled(
571            format!(
572                "{:<16} {:>10} {:>10} {:>10} {:>8}",
573                "Engine", "Compile", "Match", "Total", "Matches"
574            ),
575            Style::default()
576                .fg(theme::SUBTEXT)
577                .add_modifier(Modifier::BOLD),
578        )]),
579    ];
580
581    for (i, result) in results.iter().enumerate() {
582        let is_fastest = fastest_idx == Some(i);
583        if let Some(ref err) = result.error {
584            let line_text = format!("{:<16} {}", result.engine, err);
585            lines.push(Line::from(Span::styled(
586                line_text,
587                Style::default().fg(theme::RED),
588            )));
589        } else {
590            let total = result.compile_time + result.match_time;
591            let line_text = format!(
592                "{:<16} {:>10} {:>10} {:>10} {:>8}",
593                result.engine,
594                status_bar::format_duration(result.compile_time),
595                status_bar::format_duration(result.match_time),
596                status_bar::format_duration(total),
597                result.match_count,
598            );
599            let style = if is_fastest {
600                Style::default()
601                    .fg(theme::GREEN)
602                    .add_modifier(Modifier::BOLD)
603            } else {
604                Style::default().fg(theme::TEXT)
605            };
606            let mut spans = vec![Span::styled(line_text, style)];
607            if is_fastest {
608                spans.push(Span::styled(" *", Style::default().fg(theme::GREEN)));
609            }
610            lines.push(Line::from(spans));
611        }
612    }
613
614    lines.push(Line::from(""));
615    if fastest_idx.is_some() {
616        lines.push(Line::from(Span::styled(
617            "* = fastest",
618            Style::default().fg(theme::GREEN),
619        )));
620    }
621    lines.push(Line::from(Span::styled(
622        " Any key: close ",
623        Style::default().fg(theme::SUBTEXT),
624    )));
625
626    let block = Block::default()
627        .borders(Borders::ALL)
628        .border_type(bt)
629        .border_style(Style::default().fg(theme::PEACH))
630        .title(Span::styled(
631            " Benchmark (Ctrl+B) ",
632            Style::default().fg(theme::TEXT),
633        ))
634        .style(Style::default().bg(theme::BASE));
635
636    let paragraph = Paragraph::new(lines)
637        .block(block)
638        .wrap(Wrap { trim: false });
639
640    frame.render_widget(paragraph, overlay_area);
641}
642
643fn render_codegen_overlay(
644    frame: &mut Frame,
645    area: Rect,
646    selected: usize,
647    pattern: &str,
648    flags: crate::engine::EngineFlags,
649    bt: BorderType,
650) {
651    let langs = codegen::Language::all();
652    let preview = if pattern.is_empty() {
653        String::from("(no pattern)")
654    } else {
655        let lang = &langs[selected.min(langs.len() - 1)];
656        codegen::generate_code(lang, pattern, &flags)
657    };
658
659    let preview_lines: Vec<&str> = preview.lines().collect();
660    let preview_height = preview_lines.len() as u16;
661    // Languages list + title + spacing + preview + footer
662    let content_height = langs.len() as u16 + preview_height + 7;
663    let overlay_area = centered_overlay(frame, area, 74, content_height);
664
665    let mut lines: Vec<Line<'static>> = vec![
666        Line::from(Span::styled(
667            "Select a language to generate code",
668            Style::default()
669                .fg(theme::MAUVE)
670                .add_modifier(Modifier::BOLD),
671        )),
672        Line::from(""),
673    ];
674
675    for (i, lang) in langs.iter().enumerate() {
676        let is_selected = i == selected;
677        let marker = if is_selected { ">" } else { " " };
678        let style = if is_selected {
679            Style::default().fg(theme::BASE).bg(theme::MAUVE)
680        } else {
681            Style::default().fg(theme::TEXT)
682        };
683        lines.push(Line::from(Span::styled(format!("{marker} {lang}"), style)));
684    }
685
686    lines.push(Line::from(""));
687    lines.push(Line::from(Span::styled(
688        "Preview:",
689        Style::default()
690            .fg(theme::SUBTEXT)
691            .add_modifier(Modifier::BOLD),
692    )));
693    for pl in preview_lines {
694        lines.push(Line::from(Span::styled(
695            pl.to_string(),
696            Style::default().fg(theme::GREEN),
697        )));
698    }
699
700    lines.push(Line::from(""));
701    lines.push(Line::from(Span::styled(
702        " Up/Down: select | Enter: copy to clipboard | Esc: cancel ",
703        Style::default().fg(theme::SUBTEXT),
704    )));
705
706    let block = Block::default()
707        .borders(Borders::ALL)
708        .border_type(bt)
709        .border_style(Style::default().fg(theme::MAUVE))
710        .title(Span::styled(
711            " Code Generation (Ctrl+G) ",
712            Style::default().fg(theme::TEXT),
713        ))
714        .style(Style::default().bg(theme::BASE));
715
716    let paragraph = Paragraph::new(lines)
717        .block(block)
718        .wrap(Wrap { trim: false });
719
720    frame.render_widget(paragraph, overlay_area);
721}