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;
19use crate::engine::EngineKind;
20use explanation::ExplanationPanel;
21use match_display::MatchDisplay;
22use regex_input::RegexInput;
23use replace_input::ReplaceInput;
24use status_bar::StatusBar;
25use test_input::TestInput;
26
27pub struct PanelLayout {
29 pub regex_input: Rect,
30 pub test_input: Rect,
31 pub replace_input: Rect,
32 pub match_display: Rect,
33 pub explanation: Rect,
34 pub status_bar: Rect,
35}
36
37pub fn compute_layout(size: Rect) -> PanelLayout {
38 let main_chunks = Layout::default()
39 .direction(Direction::Vertical)
40 .constraints([
41 Constraint::Length(3), Constraint::Length(8), Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), ])
47 .split(size);
48
49 let results_chunks = if main_chunks[3].width > 80 {
50 Layout::default()
51 .direction(Direction::Horizontal)
52 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
53 .split(main_chunks[3])
54 } else {
55 Layout::default()
56 .direction(Direction::Vertical)
57 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
58 .split(main_chunks[3])
59 };
60
61 PanelLayout {
62 regex_input: main_chunks[0],
63 test_input: main_chunks[1],
64 replace_input: main_chunks[2],
65 match_display: results_chunks[0],
66 explanation: results_chunks[1],
67 status_bar: main_chunks[4],
68 }
69}
70
71pub fn render(frame: &mut Frame, app: &App) {
72 let size = frame.area();
73 let layout = compute_layout(size);
74
75 if app.show_help {
77 render_help_overlay(frame, size, app.engine_kind, app.help_page);
78 return;
79 }
80
81 let error_str = app.error.as_deref();
82
83 frame.render_widget(
85 RegexInput {
86 editor: &app.regex_editor,
87 focused: app.focused_panel == 0,
88 error: error_str,
89 error_offset: app.error_offset,
90 },
91 layout.regex_input,
92 );
93
94 frame.render_widget(
96 TestInput {
97 editor: &app.test_editor,
98 focused: app.focused_panel == 1,
99 matches: &app.matches,
100 show_whitespace: app.show_whitespace,
101 },
102 layout.test_input,
103 );
104
105 frame.render_widget(
107 ReplaceInput {
108 editor: &app.replace_editor,
109 focused: app.focused_panel == 2,
110 },
111 layout.replace_input,
112 );
113
114 frame.render_widget(
116 MatchDisplay {
117 matches: &app.matches,
118 replace_result: app.replace_result.as_ref(),
119 scroll: app.match_scroll,
120 focused: app.focused_panel == 3,
121 selected_match: app.selected_match,
122 selected_capture: app.selected_capture,
123 clipboard_status: app.clipboard_status.as_deref(),
124 },
125 layout.match_display,
126 );
127
128 frame.render_widget(
130 ExplanationPanel {
131 nodes: &app.explanation,
132 error: error_str,
133 scroll: app.explain_scroll,
134 focused: app.focused_panel == 4,
135 },
136 layout.explanation,
137 );
138
139 frame.render_widget(
141 StatusBar {
142 engine: app.engine_kind,
143 match_count: app.matches.len(),
144 flags: app.flags,
145 show_whitespace: app.show_whitespace,
146 compile_time: app.compile_time,
147 match_time: app.match_time,
148 },
149 layout.status_bar,
150 );
151}
152
153pub const HELP_PAGE_COUNT: usize = 3;
154
155fn build_help_pages(engine: EngineKind) -> Vec<(String, Vec<Line<'static>>)> {
156 let shortcut = |key: &'static str, desc: &'static str| -> Line<'static> {
157 Line::from(vec![
158 Span::styled(format!("{key:<14}"), Style::default().fg(theme::GREEN)),
159 Span::styled(desc, Style::default().fg(theme::TEXT)),
160 ])
161 };
162
163 let page0 = vec![
165 shortcut("Tab", "Cycle focus: pattern/test/replace/matches/explain"),
166 shortcut("Up/Down", "Scroll panel / move cursor / select match"),
167 shortcut("Enter", "Insert newline (test string)"),
168 shortcut("Ctrl+E", "Cycle regex engine"),
169 shortcut("Ctrl+Z", "Undo"),
170 shortcut("Ctrl+Shift+Z", "Redo"),
171 shortcut("Ctrl+Y", "Copy selected match to clipboard"),
172 shortcut("Ctrl+O", "Output results to stdout and quit"),
173 shortcut("Ctrl+S", "Save workspace"),
174 shortcut("Ctrl+W", "Toggle whitespace visualization"),
175 shortcut("Ctrl+Left/Right", "Move cursor by word"),
176 shortcut("Alt+Up/Down", "Browse pattern history"),
177 shortcut("Alt+i", "Toggle case-insensitive"),
178 shortcut("Alt+m", "Toggle multi-line"),
179 shortcut("Alt+s", "Toggle dot-matches-newline"),
180 shortcut("Alt+u", "Toggle unicode mode"),
181 shortcut("Alt+x", "Toggle extended mode"),
182 shortcut("F1", "Show/hide help (Left/Right to page)"),
183 shortcut("Esc", "Quit"),
184 Line::from(""),
185 Line::from(Span::styled(
186 "Mouse: click to focus/position, scroll to navigate",
187 Style::default().fg(theme::SUBTEXT),
188 )),
189 ];
190
191 let page1 = vec![
193 shortcut(".", "Any character (except newline by default)"),
194 shortcut("\\d \\D", "Digit / non-digit"),
195 shortcut("\\w \\W", "Word char / non-word char"),
196 shortcut("\\s \\S", "Whitespace / non-whitespace"),
197 shortcut("\\b \\B", "Word boundary / non-boundary"),
198 shortcut("^ $", "Start / end of line"),
199 shortcut("[abc]", "Character class"),
200 shortcut("[^abc]", "Negated character class"),
201 shortcut("[a-z]", "Character range"),
202 shortcut("(group)", "Capturing group"),
203 shortcut("(?:group)", "Non-capturing group"),
204 shortcut("(?P<n>...)", "Named capturing group"),
205 shortcut("a|b", "Alternation (a or b)"),
206 shortcut("* + ?", "0+, 1+, 0 or 1 (greedy)"),
207 shortcut("*? +? ??", "Lazy quantifiers"),
208 shortcut("{n} {n,m}", "Exact / range repetition"),
209 Line::from(""),
210 Line::from(Span::styled(
211 "Replacement: $1, ${name}, $0/$&, $$ for literal $",
212 Style::default().fg(theme::SUBTEXT),
213 )),
214 ];
215
216 let engine_name = format!("{engine}");
218 let page2 = match engine {
219 EngineKind::RustRegex => vec![
220 Line::from(Span::styled(
221 "Rust regex engine — linear time guarantee",
222 Style::default().fg(theme::BLUE),
223 )),
224 Line::from(""),
225 shortcut("Unicode", "Full Unicode support by default"),
226 shortcut("No lookbehind", "Use fancy-regex or PCRE2 for lookaround"),
227 shortcut("No backrefs", "Use fancy-regex or PCRE2 for backrefs"),
228 shortcut("\\p{Letter}", "Unicode category"),
229 shortcut("(?i)", "Inline case-insensitive flag"),
230 shortcut("(?m)", "Inline multi-line flag"),
231 shortcut("(?s)", "Inline dot-matches-newline flag"),
232 shortcut("(?x)", "Inline extended/verbose flag"),
233 ],
234 EngineKind::FancyRegex => vec![
235 Line::from(Span::styled(
236 "fancy-regex engine — lookaround + backreferences",
237 Style::default().fg(theme::BLUE),
238 )),
239 Line::from(""),
240 shortcut("(?=...)", "Positive lookahead"),
241 shortcut("(?!...)", "Negative lookahead"),
242 shortcut("(?<=...)", "Positive lookbehind"),
243 shortcut("(?<!...)", "Negative lookbehind"),
244 shortcut("\\1 \\2", "Backreferences"),
245 shortcut("(?>...)", "Atomic group"),
246 Line::from(""),
247 Line::from(Span::styled(
248 "Delegates to Rust regex for non-fancy patterns",
249 Style::default().fg(theme::SUBTEXT),
250 )),
251 ],
252 #[cfg(feature = "pcre2-engine")]
253 EngineKind::Pcre2 => vec![
254 Line::from(Span::styled(
255 "PCRE2 engine — full-featured",
256 Style::default().fg(theme::BLUE),
257 )),
258 Line::from(""),
259 shortcut("(?=...)(?!...)", "Lookahead"),
260 shortcut("(?<=...)(?<!..)", "Lookbehind"),
261 shortcut("\\1 \\2", "Backreferences"),
262 shortcut("(?>...)", "Atomic group"),
263 shortcut("(*SKIP)(*FAIL)", "Backtracking control verbs"),
264 shortcut("(?R) (?1)", "Recursion / subroutine calls"),
265 shortcut("(?(cond)y|n)", "Conditional patterns"),
266 shortcut("\\K", "Reset match start"),
267 shortcut("(*UTF)", "Force UTF-8 mode"),
268 ],
269 };
270
271 vec![
272 ("Keyboard Shortcuts".to_string(), page0),
273 ("Common Regex Syntax".to_string(), page1),
274 (format!("Engine: {engine_name}"), page2),
275 ]
276}
277
278fn render_help_overlay(frame: &mut Frame, area: Rect, engine: EngineKind, page: usize) {
279 let help_width = 64.min(area.width.saturating_sub(4));
280 let help_height = 24.min(area.height.saturating_sub(4));
281 let x = (area.width.saturating_sub(help_width)) / 2;
282 let y = (area.height.saturating_sub(help_height)) / 2;
283 let help_area = Rect::new(x, y, help_width, help_height);
284
285 frame.render_widget(Clear, help_area);
286
287 let pages = build_help_pages(engine);
288 let current = page.min(pages.len() - 1);
289 let (title, content) = &pages[current];
290
291 let mut lines: Vec<Line<'static>> = vec![
292 Line::from(Span::styled(
293 title.clone(),
294 Style::default()
295 .fg(theme::BLUE)
296 .add_modifier(Modifier::BOLD),
297 )),
298 Line::from(""),
299 ];
300 lines.extend(content.iter().cloned());
301 lines.push(Line::from(""));
302 lines.push(Line::from(vec![
303 Span::styled(
304 format!(" Page {}/{} ", current + 1, pages.len()),
305 Style::default().fg(theme::BASE).bg(theme::BLUE),
306 ),
307 Span::styled(
308 " Left/Right: page | Any other key: close ",
309 Style::default().fg(theme::SUBTEXT),
310 ),
311 ]));
312
313 let block = Block::default()
314 .borders(Borders::ALL)
315 .border_style(Style::default().fg(theme::BLUE))
316 .title(Span::styled(" Help ", Style::default().fg(theme::TEXT)))
317 .style(Style::default().bg(theme::BASE));
318
319 let paragraph = Paragraph::new(lines)
320 .block(block)
321 .wrap(Wrap { trim: false });
322
323 frame.render_widget(paragraph, help_area);
324}