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
35pub(crate) const fn border_type(rounded: bool) -> BorderType {
38 if rounded {
39 BorderType::Rounded
40 } else {
41 BorderType::Plain
42 }
43}
44
45pub 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 pub quickref: Option<Rect>,
56}
57
58pub const QUICKREF_PANEL_WIDTH: u16 = 38;
60
61pub 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), Constraint::Length(8), Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), ])
75 .split(size);
76
77 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 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 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 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 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 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 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 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 #[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 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
310pub 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 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 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 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
469pub 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 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 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}