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