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