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 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
64pub 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(), 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#[derive(Debug, Clone)]
119pub enum SearchModesAction {
120 Continue,
121 Apply(SearchMode, String),
122 Cancel,
123 NextMatch,
124 PreviousMatch,
125 PassThrough,
126 InputChanged(SearchMode, String), ExecuteDebounced(SearchMode, String), }
129
130pub 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), last_applied_pattern: None,
143 }
144 }
145
146 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; }
154
155 pub fn exit_mode(&mut self) -> Option<(String, usize)> {
157 self.debouncer.reset();
158 self.last_applied_pattern = None; self.state
160 .take()
161 .map(|s| (s.saved_sql_text, s.saved_cursor_position))
162 }
163
164 pub fn is_active(&self) -> bool {
166 self.state.is_some()
167 }
168
169 pub fn current_mode(&self) -> Option<SearchMode> {
171 self.state.as_ref().map(|s| s.mode.clone())
172 }
173
174 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 pub fn get_cursor_position(&self) -> usize {
184 self.state.as_ref().map(|s| s.input.cursor()).unwrap_or(0)
185 }
186
187 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 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 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 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 let should_apply = match &self.last_applied_pattern {
244 Some(last) => last != &pattern,
245 None => true, };
247
248 if should_apply {
249 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 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 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 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 f.set_cursor_position((area.x + state.input.cursor() as u16 + 1, area.y + 1));
307 }
308
309 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 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}