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