Skip to main content

rgx/
app.rs

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