Skip to main content

rgx/
app.rs

1use std::collections::VecDeque;
2use std::time::{Duration, Instant};
3
4use crate::engine::{self, CompiledRegex, EngineFlags, EngineKind, RegexEngine};
5use crate::explain::{self, ExplainNode};
6use crate::input::editor::Editor;
7
8fn truncate(s: &str, max_chars: usize) -> String {
9    let char_count = s.chars().count();
10    if char_count <= max_chars {
11        s.to_string()
12    } else {
13        let end = s
14            .char_indices()
15            .nth(max_chars)
16            .map(|(i, _)| i)
17            .unwrap_or(s.len());
18        format!("{}...", &s[..end])
19    }
20}
21
22pub struct App {
23    pub regex_editor: Editor,
24    pub test_editor: Editor,
25    pub replace_editor: Editor,
26    pub focused_panel: u8,
27    pub engine_kind: EngineKind,
28    pub flags: EngineFlags,
29    pub matches: Vec<engine::Match>,
30    pub replace_result: Option<engine::ReplaceResult>,
31    pub explanation: Vec<ExplainNode>,
32    pub error: Option<String>,
33    pub show_help: bool,
34    pub help_page: usize,
35    pub should_quit: bool,
36    pub match_scroll: u16,
37    pub replace_scroll: u16,
38    pub explain_scroll: u16,
39    // Pattern history
40    pub pattern_history: VecDeque<String>,
41    pub history_index: Option<usize>,
42    history_temp: Option<String>,
43    // Match selection + clipboard
44    pub selected_match: usize,
45    pub selected_capture: Option<usize>,
46    pub clipboard_status: Option<String>,
47    clipboard_status_ticks: u32,
48    pub show_whitespace: bool,
49    pub compile_time: Option<Duration>,
50    pub match_time: Option<Duration>,
51    pub error_offset: Option<usize>,
52    pub output_on_quit: bool,
53    pub workspace_path: Option<String>,
54    engine: Box<dyn RegexEngine>,
55    compiled: Option<Box<dyn CompiledRegex>>,
56}
57
58impl App {
59    pub const PANEL_REGEX: u8 = 0;
60    pub const PANEL_TEST: u8 = 1;
61    pub const PANEL_REPLACE: u8 = 2;
62    pub const PANEL_MATCHES: u8 = 3;
63    pub const PANEL_EXPLAIN: u8 = 4;
64}
65
66impl App {
67    pub fn new(engine_kind: EngineKind, flags: EngineFlags) -> Self {
68        let engine = engine::create_engine(engine_kind);
69        Self {
70            regex_editor: Editor::new(),
71            test_editor: Editor::new(),
72            replace_editor: Editor::new(),
73            focused_panel: 0,
74            engine_kind,
75            flags,
76            matches: Vec::new(),
77            replace_result: None,
78            explanation: Vec::new(),
79            error: None,
80            show_help: false,
81            help_page: 0,
82            should_quit: false,
83            match_scroll: 0,
84            replace_scroll: 0,
85            explain_scroll: 0,
86            pattern_history: VecDeque::new(),
87            history_index: None,
88            history_temp: None,
89            selected_match: 0,
90            selected_capture: None,
91            clipboard_status: None,
92            clipboard_status_ticks: 0,
93            show_whitespace: false,
94            compile_time: None,
95            match_time: None,
96            error_offset: None,
97            output_on_quit: false,
98            workspace_path: None,
99            engine,
100            compiled: None,
101        }
102    }
103
104    pub fn set_replacement(&mut self, text: &str) {
105        self.replace_editor = Editor::with_content(text.to_string());
106        self.rereplace();
107    }
108
109    pub fn scroll_replace_up(&mut self) {
110        self.replace_scroll = self.replace_scroll.saturating_sub(1);
111    }
112
113    pub fn scroll_replace_down(&mut self) {
114        self.replace_scroll = self.replace_scroll.saturating_add(1);
115    }
116
117    pub fn rereplace(&mut self) {
118        let template = self.replace_editor.content().to_string();
119        if template.is_empty() || self.matches.is_empty() {
120            self.replace_result = None;
121            return;
122        }
123        let text = self.test_editor.content().to_string();
124        self.replace_result = Some(engine::replace_all(&text, &self.matches, &template));
125    }
126
127    pub fn set_pattern(&mut self, pattern: &str) {
128        self.regex_editor = Editor::with_content(pattern.to_string());
129        self.recompute();
130    }
131
132    pub fn set_test_string(&mut self, text: &str) {
133        self.test_editor = Editor::with_content(text.to_string());
134        self.rematch();
135    }
136
137    pub fn switch_engine(&mut self) {
138        self.engine_kind = self.engine_kind.next();
139        self.engine = engine::create_engine(self.engine_kind);
140        self.recompute();
141    }
142
143    pub fn switch_engine_to(&mut self, kind: EngineKind) {
144        self.engine_kind = kind;
145        self.engine = engine::create_engine(kind);
146    }
147
148    pub fn scroll_match_up(&mut self) {
149        self.match_scroll = self.match_scroll.saturating_sub(1);
150    }
151
152    pub fn scroll_match_down(&mut self) {
153        self.match_scroll = self.match_scroll.saturating_add(1);
154    }
155
156    pub fn scroll_explain_up(&mut self) {
157        self.explain_scroll = self.explain_scroll.saturating_sub(1);
158    }
159
160    pub fn scroll_explain_down(&mut self) {
161        self.explain_scroll = self.explain_scroll.saturating_add(1);
162    }
163
164    pub fn recompute(&mut self) {
165        let pattern = self.regex_editor.content().to_string();
166        self.match_scroll = 0;
167        self.explain_scroll = 0;
168        self.error_offset = None;
169
170        if pattern.is_empty() {
171            self.compiled = None;
172            self.matches.clear();
173            self.explanation.clear();
174            self.error = None;
175            self.compile_time = None;
176            self.match_time = None;
177            return;
178        }
179
180        // Compile
181        let compile_start = Instant::now();
182        match self.engine.compile(&pattern, &self.flags) {
183            Ok(compiled) => {
184                self.compile_time = Some(compile_start.elapsed());
185                self.compiled = Some(compiled);
186                self.error = None;
187            }
188            Err(e) => {
189                self.compile_time = Some(compile_start.elapsed());
190                self.compiled = None;
191                self.matches.clear();
192                self.error = Some(e.to_string());
193            }
194        }
195
196        // Explain (uses regex-syntax, independent of engine)
197        match explain::explain(&pattern) {
198            Ok(nodes) => self.explanation = nodes,
199            Err((msg, offset)) => {
200                self.explanation.clear();
201                if self.error_offset.is_none() {
202                    self.error_offset = offset;
203                }
204                if self.error.is_none() {
205                    self.error = Some(msg);
206                }
207            }
208        }
209
210        // Match
211        self.rematch();
212    }
213
214    pub fn rematch(&mut self) {
215        self.match_scroll = 0;
216        self.selected_match = 0;
217        self.selected_capture = None;
218        if let Some(compiled) = &self.compiled {
219            let text = self.test_editor.content().to_string();
220            if text.is_empty() {
221                self.matches.clear();
222                self.replace_result = None;
223                self.match_time = None;
224                return;
225            }
226            let match_start = Instant::now();
227            match compiled.find_matches(&text) {
228                Ok(m) => {
229                    self.match_time = Some(match_start.elapsed());
230                    self.matches = m;
231                }
232                Err(e) => {
233                    self.match_time = Some(match_start.elapsed());
234                    self.matches.clear();
235                    self.error = Some(e.to_string());
236                }
237            }
238        } else {
239            self.matches.clear();
240            self.match_time = None;
241        }
242        self.rereplace();
243    }
244
245    // --- Pattern history ---
246
247    pub fn commit_pattern_to_history(&mut self) {
248        let pattern = self.regex_editor.content().to_string();
249        if pattern.is_empty() {
250            return;
251        }
252        if self.pattern_history.back().map(|s| s.as_str()) == Some(&pattern) {
253            return;
254        }
255        self.pattern_history.push_back(pattern);
256        if self.pattern_history.len() > 100 {
257            self.pattern_history.pop_front();
258        }
259        self.history_index = None;
260        self.history_temp = None;
261    }
262
263    pub fn history_prev(&mut self) {
264        if self.pattern_history.is_empty() {
265            return;
266        }
267        let new_index = match self.history_index {
268            Some(0) => return,
269            Some(idx) => idx - 1,
270            None => {
271                self.history_temp = Some(self.regex_editor.content().to_string());
272                self.pattern_history.len() - 1
273            }
274        };
275        self.history_index = Some(new_index);
276        let pattern = self.pattern_history[new_index].clone();
277        self.regex_editor = Editor::with_content(pattern);
278        self.recompute();
279    }
280
281    pub fn history_next(&mut self) {
282        let idx = match self.history_index {
283            Some(idx) => idx,
284            None => return,
285        };
286        if idx + 1 < self.pattern_history.len() {
287            let new_index = idx + 1;
288            self.history_index = Some(new_index);
289            let pattern = self.pattern_history[new_index].clone();
290            self.regex_editor = Editor::with_content(pattern);
291            self.recompute();
292        } else {
293            // Past end — restore temp
294            self.history_index = None;
295            let content = self.history_temp.take().unwrap_or_default();
296            self.regex_editor = Editor::with_content(content);
297            self.recompute();
298        }
299    }
300
301    // --- Match selection + clipboard ---
302
303    pub fn select_match_next(&mut self) {
304        if self.matches.is_empty() {
305            return;
306        }
307        match self.selected_capture {
308            None => {
309                let m = &self.matches[self.selected_match];
310                if !m.captures.is_empty() {
311                    self.selected_capture = Some(0);
312                } else if self.selected_match + 1 < self.matches.len() {
313                    self.selected_match += 1;
314                }
315            }
316            Some(ci) => {
317                let m = &self.matches[self.selected_match];
318                if ci + 1 < m.captures.len() {
319                    self.selected_capture = Some(ci + 1);
320                } else if self.selected_match + 1 < self.matches.len() {
321                    self.selected_match += 1;
322                    self.selected_capture = None;
323                }
324            }
325        }
326        self.scroll_to_selected();
327    }
328
329    pub fn select_match_prev(&mut self) {
330        if self.matches.is_empty() {
331            return;
332        }
333        match self.selected_capture {
334            Some(0) => {
335                self.selected_capture = None;
336            }
337            Some(ci) => {
338                self.selected_capture = Some(ci - 1);
339            }
340            None => {
341                if self.selected_match > 0 {
342                    self.selected_match -= 1;
343                    let m = &self.matches[self.selected_match];
344                    if !m.captures.is_empty() {
345                        self.selected_capture = Some(m.captures.len() - 1);
346                    }
347                }
348            }
349        }
350        self.scroll_to_selected();
351    }
352
353    fn scroll_to_selected(&mut self) {
354        if self.matches.is_empty() || self.selected_match >= self.matches.len() {
355            return;
356        }
357        let mut line = 0usize;
358        for i in 0..self.selected_match {
359            line += 1 + self.matches[i].captures.len();
360        }
361        if let Some(ci) = self.selected_capture {
362            line += 1 + ci;
363        }
364        self.match_scroll = u16::try_from(line).unwrap_or(u16::MAX);
365    }
366
367    pub fn copy_selected_match(&mut self) {
368        let text = self.selected_text();
369        let Some(text) = text else { return };
370        match arboard::Clipboard::new() {
371            Ok(mut cb) => match cb.set_text(&text) {
372                Ok(()) => {
373                    self.clipboard_status = Some(format!("Copied: \"{}\"", truncate(&text, 40)));
374                    self.clipboard_status_ticks = 40; // ~2 sec at 50ms tick
375                }
376                Err(e) => {
377                    self.clipboard_status = Some(format!("Clipboard error: {e}"));
378                    self.clipboard_status_ticks = 40;
379                }
380            },
381            Err(e) => {
382                self.clipboard_status = Some(format!("Clipboard error: {e}"));
383                self.clipboard_status_ticks = 40;
384            }
385        }
386    }
387
388    pub fn set_status_message(&mut self, message: String) {
389        self.clipboard_status = Some(message);
390        self.clipboard_status_ticks = 40; // ~2 sec at 50ms tick
391    }
392
393    /// Tick down the clipboard status timer. Returns true if status was cleared.
394    pub fn tick_clipboard_status(&mut self) -> bool {
395        if self.clipboard_status.is_some() {
396            if self.clipboard_status_ticks > 0 {
397                self.clipboard_status_ticks -= 1;
398            } else {
399                self.clipboard_status = None;
400                return true;
401            }
402        }
403        false
404    }
405
406    fn selected_text(&self) -> Option<String> {
407        let m = self.matches.get(self.selected_match)?;
408        match self.selected_capture {
409            None => Some(m.text.clone()),
410            Some(ci) => m.captures.get(ci).map(|c| c.text.clone()),
411        }
412    }
413}