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