Skip to main content

rgx/ui/
mod.rs

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
27/// Panel layout rectangles for mouse hit-testing.
28pub 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), // regex input
42            Constraint::Length(8), // test string input
43            Constraint::Length(3), // replacement input
44            Constraint::Min(5),    // results area
45            Constraint::Length(1), // status bar
46        ])
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    // Help overlay
76    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    // Regex input
84    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    // Test string input
95    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    // Replacement input
106    frame.render_widget(
107        ReplaceInput {
108            editor: &app.replace_editor,
109            focused: app.focused_panel == 2,
110        },
111        layout.replace_input,
112    );
113
114    // Match display
115    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    // Explanation panel
129    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    // Status bar
140    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    // Page 0: Keyboard shortcuts
164    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    // Page 1: Common regex syntax
192    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    // Page 2: Engine-specific
217    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}