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