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}
54
55pub fn compute_layout(size: Rect) -> PanelLayout {
56 let main_chunks = Layout::default()
57 .direction(Direction::Vertical)
58 .constraints([
59 Constraint::Length(3), Constraint::Length(8), Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), ])
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 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 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 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 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 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 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 #[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
233pub 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 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 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 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
391pub 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 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 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}