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