sql_cli/widgets/
search_modes_widget.rs

1use crate::buffer::AppMode;
2use crate::debouncer::Debouncer;
3use crate::widget_traits::DebugInfoProvider;
4use crossterm::event::{Event, KeyCode, KeyEvent};
5use fuzzy_matcher::skim::SkimMatcherV2;
6use ratatui::{
7    layout::Rect,
8    style::{Color, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, Paragraph},
11    Frame,
12};
13use regex::Regex;
14use tui_input::{backend::crossterm::EventHandler, Input};
15
16/// Represents the different search/filter modes
17#[derive(Debug, Clone, PartialEq)]
18pub enum SearchMode {
19    Search,
20    Filter,
21    FuzzyFilter,
22    ColumnSearch,
23}
24
25impl SearchMode {
26    #[must_use]
27    pub fn to_app_mode(&self) -> AppMode {
28        match self {
29            SearchMode::Search => AppMode::Search,
30            SearchMode::Filter => AppMode::Filter,
31            SearchMode::FuzzyFilter => AppMode::FuzzyFilter,
32            SearchMode::ColumnSearch => AppMode::ColumnSearch,
33        }
34    }
35
36    #[must_use]
37    pub fn from_app_mode(mode: &AppMode) -> Option<Self> {
38        match mode {
39            AppMode::Search => Some(SearchMode::Search),
40            AppMode::Filter => Some(SearchMode::Filter),
41            AppMode::FuzzyFilter => Some(SearchMode::FuzzyFilter),
42            AppMode::ColumnSearch => Some(SearchMode::ColumnSearch),
43            _ => None,
44        }
45    }
46
47    #[must_use]
48    pub fn title(&self) -> &str {
49        match self {
50            SearchMode::Search => "Search Pattern",
51            SearchMode::Filter => "Filter Pattern",
52            SearchMode::FuzzyFilter => "Fuzzy Filter",
53            SearchMode::ColumnSearch => "Column Search",
54        }
55    }
56
57    #[must_use]
58    pub fn style(&self) -> Style {
59        match self {
60            SearchMode::Search => Style::default().fg(Color::Yellow),
61            SearchMode::Filter => Style::default().fg(Color::Cyan),
62            SearchMode::FuzzyFilter => Style::default().fg(Color::Magenta),
63            SearchMode::ColumnSearch => Style::default().fg(Color::Green),
64        }
65    }
66}
67
68/// State for search/filter operations
69pub struct SearchModesState {
70    pub mode: SearchMode,
71    pub input: Input,
72    pub fuzzy_matcher: SkimMatcherV2,
73    pub regex: Option<Regex>,
74    pub matching_columns: Vec<(usize, String)>,
75    pub current_match_index: usize,
76    pub saved_sql_text: String,
77    pub saved_cursor_position: usize,
78}
79
80impl Clone for SearchModesState {
81    fn clone(&self) -> Self {
82        Self {
83            mode: self.mode.clone(),
84            input: self.input.clone(),
85            fuzzy_matcher: SkimMatcherV2::default(), // Create new matcher
86            regex: self.regex.clone(),
87            matching_columns: self.matching_columns.clone(),
88            current_match_index: self.current_match_index,
89            saved_sql_text: self.saved_sql_text.clone(),
90            saved_cursor_position: self.saved_cursor_position,
91        }
92    }
93}
94
95impl SearchModesState {
96    #[must_use]
97    pub fn new(mode: SearchMode) -> Self {
98        Self {
99            mode,
100            input: Input::default(),
101            fuzzy_matcher: SkimMatcherV2::default(),
102            regex: None,
103            matching_columns: Vec::new(),
104            current_match_index: 0,
105            saved_sql_text: String::new(),
106            saved_cursor_position: 0,
107        }
108    }
109
110    pub fn reset(&mut self) {
111        self.input.reset();
112        self.regex = None;
113        self.matching_columns.clear();
114        self.current_match_index = 0;
115    }
116
117    pub fn get_pattern(&self) -> String {
118        self.input.value().to_string()
119    }
120}
121
122/// Actions that can be returned from the search modes widget
123#[derive(Debug, Clone)]
124pub enum SearchModesAction {
125    Continue,
126    Apply(SearchMode, String),
127    Cancel,
128    NextMatch,
129    PreviousMatch,
130    PassThrough,
131    InputChanged(SearchMode, String), // Signal that input changed (for debouncing)
132    ExecuteDebounced(SearchMode, String), // Execute the debounced action
133}
134
135/// A widget for handling all search/filter modes
136pub struct SearchModesWidget {
137    state: Option<SearchModesState>,
138    debouncer: Debouncer,
139    last_applied_pattern: Option<String>,
140}
141
142impl Default for SearchModesWidget {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148impl SearchModesWidget {
149    #[must_use]
150    pub fn new() -> Self {
151        Self {
152            state: None,
153            debouncer: Debouncer::new(500), // 500ms debounce delay
154            last_applied_pattern: None,
155        }
156    }
157
158    /// Initialize the widget for a specific search mode
159    pub fn enter_mode(&mut self, mode: SearchMode, current_sql: String, cursor_pos: usize) {
160        let mut state = SearchModesState::new(mode);
161        state.saved_sql_text = current_sql;
162        state.saved_cursor_position = cursor_pos;
163        self.state = Some(state);
164        self.last_applied_pattern = None; // Reset when entering a new mode
165    }
166
167    /// Exit the current mode and return saved state
168    pub fn exit_mode(&mut self) -> Option<(String, usize)> {
169        self.debouncer.reset();
170        self.last_applied_pattern = None; // Reset when exiting
171        self.state
172            .take()
173            .map(|s| (s.saved_sql_text, s.saved_cursor_position))
174    }
175
176    /// Check if widget is active
177    pub fn is_active(&self) -> bool {
178        self.state.is_some()
179    }
180
181    /// Get the current mode if active
182    pub fn current_mode(&self) -> Option<SearchMode> {
183        self.state.as_ref().map(|s| s.mode.clone())
184    }
185
186    /// Get the current pattern
187    pub fn get_pattern(&self) -> String {
188        self.state
189            .as_ref()
190            .map(SearchModesState::get_pattern)
191            .unwrap_or_default()
192    }
193
194    /// Get cursor position for rendering
195    pub fn get_cursor_position(&self) -> usize {
196        self.state.as_ref().map_or(0, |s| s.input.cursor())
197    }
198
199    /// Handle key input
200    pub fn handle_key(&mut self, key: KeyEvent) -> SearchModesAction {
201        let Some(state) = &mut self.state else {
202            return SearchModesAction::PassThrough;
203        };
204
205        match key.code {
206            KeyCode::Esc => SearchModesAction::Cancel,
207            KeyCode::Enter => {
208                let pattern = state.get_pattern();
209                // For FuzzyFilter, allow empty pattern to clear the filter
210                // For other modes, treat empty as Cancel
211                if !pattern.is_empty() || state.mode == SearchMode::FuzzyFilter {
212                    SearchModesAction::Apply(state.mode.clone(), pattern)
213                } else {
214                    SearchModesAction::Cancel
215                }
216            }
217            KeyCode::Tab => {
218                if state.mode == SearchMode::ColumnSearch {
219                    SearchModesAction::NextMatch
220                } else {
221                    state.input.handle_event(&Event::Key(key));
222                    SearchModesAction::Continue
223                }
224            }
225            KeyCode::BackTab => {
226                if state.mode == SearchMode::ColumnSearch {
227                    SearchModesAction::PreviousMatch
228                } else {
229                    SearchModesAction::Continue
230                }
231            }
232            _ => {
233                let old_pattern = state.get_pattern();
234                state.input.handle_event(&Event::Key(key));
235                let new_pattern = state.get_pattern();
236
237                // If the pattern changed, trigger debouncing
238                if old_pattern == new_pattern {
239                    SearchModesAction::Continue
240                } else {
241                    self.debouncer.trigger();
242                    SearchModesAction::InputChanged(state.mode.clone(), new_pattern)
243                }
244            }
245        }
246    }
247
248    /// Check if debounced action should execute
249    pub fn check_debounce(&mut self) -> Option<SearchModesAction> {
250        if self.debouncer.should_execute() {
251            if let Some(state) = &self.state {
252                let pattern = state.get_pattern();
253
254                // Check if pattern is different from last applied
255                let should_apply = match &self.last_applied_pattern {
256                    Some(last) => last != &pattern,
257                    None => true, // First pattern always applies
258                };
259
260                if should_apply {
261                    // For filter modes, we need to apply even empty patterns to clear
262                    let allow_empty =
263                        matches!(state.mode, SearchMode::FuzzyFilter | SearchMode::Filter);
264
265                    if !pattern.is_empty() || allow_empty {
266                        self.last_applied_pattern = Some(pattern.clone());
267                        return Some(SearchModesAction::ExecuteDebounced(
268                            state.mode.clone(),
269                            pattern,
270                        ));
271                    }
272                }
273            }
274        }
275        None
276    }
277
278    /// Render the search input field
279    pub fn render(&self, f: &mut Frame, area: Rect) {
280        let Some(state) = &self.state else {
281            return;
282        };
283
284        let input_text = state.get_pattern();
285        let mut title = state.mode.title().to_string();
286
287        // Add debounce indicator to title with color coding
288        if self.debouncer.is_pending() {
289            if let Some(remaining) = self.debouncer.time_remaining() {
290                let ms = remaining.as_millis();
291                if ms > 0 {
292                    // Add visual indicator with countdown
293                    if ms > 300 {
294                        title.push_str(&format!(" [⏱ {ms}ms]"));
295                    } else if ms > 100 {
296                        title.push_str(&format!(" [⚡ {ms}ms]"));
297                    } else {
298                        title.push_str(&format!(" [🔥 {ms}ms]"));
299                    }
300                } else {
301                    title.push_str(" [⏳ applying...]");
302                }
303            }
304        }
305
306        let style = state.mode.style();
307
308        let input_widget = Paragraph::new(input_text.as_str()).style(style).block(
309            Block::default()
310                .borders(Borders::ALL)
311                .title(title.as_str())
312                .border_style(style),
313        );
314
315        f.render_widget(input_widget, area);
316
317        // Set cursor position
318        f.set_cursor_position((area.x + state.input.cursor() as u16 + 1, area.y + 1));
319    }
320
321    /// Render inline hint in status bar
322    pub fn render_hint(&self) -> Line<'static> {
323        if self.state.is_some() {
324            Line::from(vec![
325                Span::raw("Enter"),
326                Span::styled(":Apply", Style::default().fg(Color::Green)),
327                Span::raw(" | "),
328                Span::raw("Esc"),
329                Span::styled(":Cancel", Style::default().fg(Color::Red)),
330            ])
331        } else {
332            Line::from("")
333        }
334    }
335}
336
337impl DebugInfoProvider for SearchModesWidget {
338    fn debug_info(&self) -> String {
339        let mut info = String::from("=== SEARCH MODES WIDGET ===\n");
340
341        // Add debouncer state
342        info.push_str("Debouncer: ");
343        if self.debouncer.is_pending() {
344            if let Some(remaining) = self.debouncer.time_remaining() {
345                info.push_str(&format!(
346                    "PENDING ({}ms remaining)\n",
347                    remaining.as_millis()
348                ));
349            } else {
350                info.push_str("PENDING\n");
351            }
352        } else {
353            info.push_str("IDLE\n");
354        }
355        info.push('\n');
356
357        if let Some(state) = &self.state {
358            info.push_str("State: ACTIVE\n");
359            info.push_str(&format!("Mode: {:?}\n", state.mode));
360            info.push_str(&format!("Current Pattern: '{}'\n", state.get_pattern()));
361            info.push_str(&format!("Pattern Length: {}\n", state.input.value().len()));
362            info.push_str(&format!("Cursor Position: {}\n", state.input.cursor()));
363            info.push('\n');
364
365            info.push_str("Saved SQL State:\n");
366            info.push_str(&format!(
367                "  Text: '{}'\n",
368                if state.saved_sql_text.len() > 50 {
369                    format!(
370                        "{}... ({} chars)",
371                        &state.saved_sql_text[..50],
372                        state.saved_sql_text.len()
373                    )
374                } else {
375                    state.saved_sql_text.clone()
376                }
377            ));
378            info.push_str(&format!("  Cursor: {}\n", state.saved_cursor_position));
379            info.push_str(&format!("  SQL Length: {}\n", state.saved_sql_text.len()));
380
381            if state.mode == SearchMode::ColumnSearch {
382                info.push_str("\nColumn Search State:\n");
383                info.push_str(&format!(
384                    "  Matching Columns: {} found\n",
385                    state.matching_columns.len()
386                ));
387                if !state.matching_columns.is_empty() {
388                    info.push_str(&format!(
389                        "  Current Match Index: {}\n",
390                        state.current_match_index
391                    ));
392                    for (i, (idx, name)) in state.matching_columns.iter().take(5).enumerate() {
393                        info.push_str(&format!(
394                            "    [{}] Column {}: '{}'\n",
395                            if i == state.current_match_index {
396                                "*"
397                            } else {
398                                " "
399                            },
400                            idx,
401                            name
402                        ));
403                    }
404                    if state.matching_columns.len() > 5 {
405                        info.push_str(&format!(
406                            "    ... and {} more\n",
407                            state.matching_columns.len() - 5
408                        ));
409                    }
410                }
411            }
412
413            if state.mode == SearchMode::FuzzyFilter {
414                info.push_str("\nFuzzy Filter State:\n");
415                info.push_str("  Matcher: SkimMatcherV2 (ready)\n");
416            }
417
418            if state.regex.is_some() {
419                info.push_str("\nRegex State:\n");
420                info.push_str("  Compiled: Yes\n");
421            }
422        } else {
423            info.push_str("State: INACTIVE\n");
424            info.push_str("No active search mode\n");
425        }
426
427        info
428    }
429}