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("Ctrl+Y", "Copy selected match to clipboard"),
241 shortcut("Ctrl+O", "Output results to stdout and quit"),
242 shortcut("Ctrl+S", "Save workspace"),
243 shortcut("Ctrl+R", "Open regex recipe library"),
244 shortcut("Ctrl+B", "Benchmark pattern across all engines"),
245 shortcut("Ctrl+U", "Copy regex101.com URL to clipboard"),
246 shortcut("Ctrl+D", "Step-through regex debugger"),
247 shortcut("Ctrl+G", "Generate code for pattern"),
248 shortcut("Ctrl+X", "Generate regex from examples (grex)"),
249 shortcut("Ctrl+W", "Toggle whitespace visualization"),
250 shortcut("Ctrl+Left/Right", "Move cursor by word"),
251 shortcut("Alt+Up/Down", "Browse pattern history"),
252 shortcut("Alt+i", "Toggle case-insensitive"),
253 shortcut("Alt+m", "Toggle multi-line"),
254 shortcut("Alt+s", "Toggle dot-matches-newline"),
255 shortcut("Alt+u", "Toggle unicode mode"),
256 shortcut("Alt+x", "Toggle extended mode"),
257 shortcut("F1", "Show/hide help (Left/Right to page)"),
258 shortcut("Esc", "Quit"),
259 Line::from(""),
260 Line::from(Span::styled(
261 "Vim: --vim flag | Normal: hjkl wb e 0$^ gg/G x dd cc o/O u p",
262 Style::default().fg(theme::SUBTEXT),
263 )),
264 Line::from(Span::styled(
265 "Mouse: click to focus/position, scroll to navigate",
266 Style::default().fg(theme::SUBTEXT),
267 )),
268 ];
269
270 let page1 = vec![
272 shortcut(".", "Any character (except newline by default)"),
273 shortcut("\\d \\D", "Digit / non-digit"),
274 shortcut("\\w \\W", "Word char / non-word char"),
275 shortcut("\\s \\S", "Whitespace / non-whitespace"),
276 shortcut("\\b \\B", "Word boundary / non-boundary"),
277 shortcut("^ $", "Start / end of line"),
278 shortcut("[abc]", "Character class"),
279 shortcut("[^abc]", "Negated character class"),
280 shortcut("[a-z]", "Character range"),
281 shortcut("(group)", "Capturing group"),
282 shortcut("(?:group)", "Non-capturing group"),
283 shortcut("(?P<n>...)", "Named capturing group"),
284 shortcut("a|b", "Alternation (a or b)"),
285 shortcut("* + ?", "0+, 1+, 0 or 1 (greedy)"),
286 shortcut("*? +? ??", "Lazy quantifiers"),
287 shortcut("{n} {n,m}", "Exact / range repetition"),
288 Line::from(""),
289 Line::from(Span::styled(
290 "Replacement: $1, ${name}, $0/$&, $$ for literal $",
291 Style::default().fg(theme::SUBTEXT),
292 )),
293 ];
294
295 let engine_name = format!("{engine}");
297 let page2 = match engine {
298 EngineKind::RustRegex => vec![
299 Line::from(Span::styled(
300 "Rust regex engine — linear time guarantee",
301 Style::default().fg(theme::BLUE),
302 )),
303 Line::from(""),
304 shortcut("Unicode", "Full Unicode support by default"),
305 shortcut("No lookbehind", "Use fancy-regex or PCRE2 for lookaround"),
306 shortcut("No backrefs", "Use fancy-regex or PCRE2 for backrefs"),
307 shortcut("\\p{Letter}", "Unicode category"),
308 shortcut("(?i)", "Inline case-insensitive flag"),
309 shortcut("(?m)", "Inline multi-line flag"),
310 shortcut("(?s)", "Inline dot-matches-newline flag"),
311 shortcut("(?x)", "Inline extended/verbose flag"),
312 ],
313 EngineKind::FancyRegex => vec![
314 Line::from(Span::styled(
315 "fancy-regex engine — lookaround + backreferences",
316 Style::default().fg(theme::BLUE),
317 )),
318 Line::from(""),
319 shortcut("(?=...)", "Positive lookahead"),
320 shortcut("(?!...)", "Negative lookahead"),
321 shortcut("(?<=...)", "Positive lookbehind"),
322 shortcut("(?<!...)", "Negative lookbehind"),
323 shortcut("\\1 \\2", "Backreferences"),
324 shortcut("(?>...)", "Atomic group"),
325 Line::from(""),
326 Line::from(Span::styled(
327 "Delegates to Rust regex for non-fancy patterns",
328 Style::default().fg(theme::SUBTEXT),
329 )),
330 ],
331 #[cfg(feature = "pcre2-engine")]
332 EngineKind::Pcre2 => vec![
333 Line::from(Span::styled(
334 "PCRE2 engine — full-featured",
335 Style::default().fg(theme::BLUE),
336 )),
337 Line::from(""),
338 shortcut("(?=...)(?!...)", "Lookahead"),
339 shortcut("(?<=...)(?<!..)", "Lookbehind"),
340 shortcut("\\1 \\2", "Backreferences"),
341 shortcut("(?>...)", "Atomic group"),
342 shortcut("(*SKIP)(*FAIL)", "Backtracking control verbs"),
343 shortcut("(?R) (?1)", "Recursion / subroutine calls"),
344 shortcut("(?(cond)y|n)", "Conditional patterns"),
345 shortcut("\\K", "Reset match start"),
346 shortcut("(*UTF)", "Force UTF-8 mode"),
347 ],
348 };
349
350 vec![
351 ("Keyboard Shortcuts".to_string(), page0),
352 ("Common Regex Syntax".to_string(), page1),
353 (format!("Engine: {engine_name}"), page2),
354 ]
355}
356
357pub(crate) fn centered_overlay(
358 frame: &mut Frame,
359 area: Rect,
360 max_width: u16,
361 content_height: u16,
362) -> Rect {
363 let w = max_width.min(area.width.saturating_sub(4));
364 let h = content_height.min(area.height.saturating_sub(4));
365 let x = (area.width.saturating_sub(w)) / 2;
366 let y = (area.height.saturating_sub(h)) / 2;
367 let rect = Rect::new(x, y, w, h);
368 frame.render_widget(Clear, rect);
369 rect
370}
371
372fn render_help_overlay(
373 frame: &mut Frame,
374 area: Rect,
375 engine: EngineKind,
376 page: usize,
377 bt: BorderType,
378) {
379 let help_area = centered_overlay(frame, area, 64, 24);
380
381 let pages = build_help_pages(engine);
382 let current = page.min(pages.len() - 1);
383 let (title, content) = &pages[current];
384
385 let mut lines: Vec<Line<'static>> = vec![
386 Line::from(Span::styled(
387 title.clone(),
388 Style::default()
389 .fg(theme::BLUE)
390 .add_modifier(Modifier::BOLD),
391 )),
392 Line::from(""),
393 ];
394 lines.extend(content.iter().cloned());
395 lines.push(Line::from(""));
396 lines.push(Line::from(vec![
397 Span::styled(
398 format!(" Page {}/{} ", current + 1, pages.len()),
399 Style::default().fg(theme::BASE).bg(theme::BLUE),
400 ),
401 Span::styled(
402 " Left/Right: page | Any other key: close ",
403 Style::default().fg(theme::SUBTEXT),
404 ),
405 ]));
406
407 let block = Block::default()
408 .borders(Borders::ALL)
409 .border_type(bt)
410 .border_style(Style::default().fg(theme::BLUE))
411 .title(Span::styled(" Help ", Style::default().fg(theme::TEXT)))
412 .style(Style::default().bg(theme::BASE));
413
414 let paragraph = Paragraph::new(lines)
415 .block(block)
416 .wrap(Wrap { trim: false });
417
418 frame.render_widget(paragraph, help_area);
419}
420
421fn render_recipe_overlay(frame: &mut Frame, area: Rect, selected: usize, bt: BorderType) {
422 let overlay_area = centered_overlay(frame, area, 70, RECIPES.len() as u16 + 6);
423
424 let mut lines: Vec<Line<'static>> = vec![
425 Line::from(Span::styled(
426 "Select a recipe to load",
427 Style::default()
428 .fg(theme::BLUE)
429 .add_modifier(Modifier::BOLD),
430 )),
431 Line::from(""),
432 ];
433
434 for (i, recipe) in RECIPES.iter().enumerate() {
435 let is_selected = i == selected;
436 let marker = if is_selected { ">" } else { " " };
437 let style = if is_selected {
438 Style::default().fg(theme::BASE).bg(theme::BLUE)
439 } else {
440 Style::default().fg(theme::TEXT)
441 };
442 lines.push(Line::from(Span::styled(
443 format!("{marker} {:<24} {}", recipe.name, recipe.description),
444 style,
445 )));
446 }
447
448 lines.push(Line::from(""));
449 lines.push(Line::from(Span::styled(
450 " Up/Down: select | Enter: load | Esc: cancel ",
451 Style::default().fg(theme::SUBTEXT),
452 )));
453
454 let block = Block::default()
455 .borders(Borders::ALL)
456 .border_type(bt)
457 .border_style(Style::default().fg(theme::GREEN))
458 .title(Span::styled(
459 " Recipes (Ctrl+R) ",
460 Style::default().fg(theme::TEXT),
461 ))
462 .style(Style::default().bg(theme::BASE));
463
464 let paragraph = Paragraph::new(lines)
465 .block(block)
466 .wrap(Wrap { trim: false });
467
468 frame.render_widget(paragraph, overlay_area);
469}
470
471fn render_benchmark_overlay(
472 frame: &mut Frame,
473 area: Rect,
474 results: &[BenchmarkResult],
475 bt: BorderType,
476) {
477 let overlay_area = centered_overlay(frame, area, 70, results.len() as u16 + 8);
478
479 let fastest_idx = results
480 .iter()
481 .enumerate()
482 .filter(|(_, r)| r.error.is_none())
483 .min_by_key(|(_, r)| r.compile_time + r.match_time)
484 .map(|(i, _)| i);
485
486 let mut lines: Vec<Line<'static>> = vec![
487 Line::from(Span::styled(
488 "Performance Comparison",
489 Style::default()
490 .fg(theme::BLUE)
491 .add_modifier(Modifier::BOLD),
492 )),
493 Line::from(""),
494 Line::from(vec![Span::styled(
495 format!(
496 "{:<16} {:>10} {:>10} {:>10} {:>8}",
497 "Engine", "Compile", "Match", "Total", "Matches"
498 ),
499 Style::default()
500 .fg(theme::SUBTEXT)
501 .add_modifier(Modifier::BOLD),
502 )]),
503 ];
504
505 for (i, result) in results.iter().enumerate() {
506 let is_fastest = fastest_idx == Some(i);
507 if let Some(ref err) = result.error {
508 let line_text = format!("{:<16} {}", result.engine, err);
509 lines.push(Line::from(Span::styled(
510 line_text,
511 Style::default().fg(theme::RED),
512 )));
513 } else {
514 let total = result.compile_time + result.match_time;
515 let line_text = format!(
516 "{:<16} {:>10} {:>10} {:>10} {:>8}",
517 result.engine,
518 status_bar::format_duration(result.compile_time),
519 status_bar::format_duration(result.match_time),
520 status_bar::format_duration(total),
521 result.match_count,
522 );
523 let style = if is_fastest {
524 Style::default()
525 .fg(theme::GREEN)
526 .add_modifier(Modifier::BOLD)
527 } else {
528 Style::default().fg(theme::TEXT)
529 };
530 let mut spans = vec![Span::styled(line_text, style)];
531 if is_fastest {
532 spans.push(Span::styled(" *", Style::default().fg(theme::GREEN)));
533 }
534 lines.push(Line::from(spans));
535 }
536 }
537
538 lines.push(Line::from(""));
539 if fastest_idx.is_some() {
540 lines.push(Line::from(Span::styled(
541 "* = fastest",
542 Style::default().fg(theme::GREEN),
543 )));
544 }
545 lines.push(Line::from(Span::styled(
546 " Any key: close ",
547 Style::default().fg(theme::SUBTEXT),
548 )));
549
550 let block = Block::default()
551 .borders(Borders::ALL)
552 .border_type(bt)
553 .border_style(Style::default().fg(theme::PEACH))
554 .title(Span::styled(
555 " Benchmark (Ctrl+B) ",
556 Style::default().fg(theme::TEXT),
557 ))
558 .style(Style::default().bg(theme::BASE));
559
560 let paragraph = Paragraph::new(lines)
561 .block(block)
562 .wrap(Wrap { trim: false });
563
564 frame.render_widget(paragraph, overlay_area);
565}
566
567fn render_codegen_overlay(
568 frame: &mut Frame,
569 area: Rect,
570 selected: usize,
571 pattern: &str,
572 flags: crate::engine::EngineFlags,
573 bt: BorderType,
574) {
575 let langs = codegen::Language::all();
576 let preview = if pattern.is_empty() {
577 String::from("(no pattern)")
578 } else {
579 let lang = &langs[selected.min(langs.len() - 1)];
580 codegen::generate_code(lang, pattern, &flags)
581 };
582
583 let preview_lines: Vec<&str> = preview.lines().collect();
584 let preview_height = preview_lines.len() as u16;
585 let content_height = langs.len() as u16 + preview_height + 7;
587 let overlay_area = centered_overlay(frame, area, 74, content_height);
588
589 let mut lines: Vec<Line<'static>> = vec![
590 Line::from(Span::styled(
591 "Select a language to generate code",
592 Style::default()
593 .fg(theme::MAUVE)
594 .add_modifier(Modifier::BOLD),
595 )),
596 Line::from(""),
597 ];
598
599 for (i, lang) in langs.iter().enumerate() {
600 let is_selected = i == selected;
601 let marker = if is_selected { ">" } else { " " };
602 let style = if is_selected {
603 Style::default().fg(theme::BASE).bg(theme::MAUVE)
604 } else {
605 Style::default().fg(theme::TEXT)
606 };
607 lines.push(Line::from(Span::styled(format!("{marker} {lang}"), style)));
608 }
609
610 lines.push(Line::from(""));
611 lines.push(Line::from(Span::styled(
612 "Preview:",
613 Style::default()
614 .fg(theme::SUBTEXT)
615 .add_modifier(Modifier::BOLD),
616 )));
617 for pl in preview_lines {
618 lines.push(Line::from(Span::styled(
619 pl.to_string(),
620 Style::default().fg(theme::GREEN),
621 )));
622 }
623
624 lines.push(Line::from(""));
625 lines.push(Line::from(Span::styled(
626 " Up/Down: select | Enter: copy to clipboard | Esc: cancel ",
627 Style::default().fg(theme::SUBTEXT),
628 )));
629
630 let block = Block::default()
631 .borders(Borders::ALL)
632 .border_type(bt)
633 .border_style(Style::default().fg(theme::MAUVE))
634 .title(Span::styled(
635 " Code Generation (Ctrl+G) ",
636 Style::default().fg(theme::TEXT),
637 ))
638 .style(Style::default().bg(theme::BASE));
639
640 let paragraph = Paragraph::new(lines)
641 .block(block)
642 .wrap(Wrap { trim: false });
643
644 frame.render_widget(paragraph, overlay_area);
645}