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