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#[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
68pub 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(), 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#[derive(Debug, Clone)]
124pub enum SearchModesAction {
125 Continue,
126 Apply(SearchMode, String),
127 Cancel,
128 NextMatch,
129 PreviousMatch,
130 PassThrough,
131 InputChanged(SearchMode, String), ExecuteDebounced(SearchMode, String), }
134
135pub 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), last_applied_pattern: None,
155 }
156 }
157
158 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; }
166
167 pub fn exit_mode(&mut self) -> Option<(String, usize)> {
169 self.debouncer.reset();
170 self.last_applied_pattern = None; self.state
172 .take()
173 .map(|s| (s.saved_sql_text, s.saved_cursor_position))
174 }
175
176 pub fn is_active(&self) -> bool {
178 self.state.is_some()
179 }
180
181 pub fn current_mode(&self) -> Option<SearchMode> {
183 self.state.as_ref().map(|s| s.mode.clone())
184 }
185
186 pub fn get_pattern(&self) -> String {
188 self.state
189 .as_ref()
190 .map(SearchModesState::get_pattern)
191 .unwrap_or_default()
192 }
193
194 pub fn get_cursor_position(&self) -> usize {
196 self.state.as_ref().map_or(0, |s| s.input.cursor())
197 }
198
199 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 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 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 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 let should_apply = match &self.last_applied_pattern {
256 Some(last) => last != &pattern,
257 None => true, };
259
260 if should_apply {
261 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 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 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 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 f.set_cursor_position((area.x + state.input.cursor() as u16 + 1, area.y + 1));
319 }
320
321 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 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}