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