use crate::buffer::AppMode;
use crate::debouncer::Debouncer;
use crate::widget_traits::DebugInfoProvider;
use crossterm::event::{Event, KeyCode, KeyEvent};
use fuzzy_matcher::skim::SkimMatcherV2;
use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use regex::Regex;
use tui_input::{backend::crossterm::EventHandler, Input};
#[derive(Debug, Clone, PartialEq)]
pub enum SearchMode {
Search,
Filter,
FuzzyFilter,
ColumnSearch,
}
impl SearchMode {
#[must_use]
pub fn to_app_mode(&self) -> AppMode {
match self {
SearchMode::Search => AppMode::Search,
SearchMode::Filter => AppMode::Filter,
SearchMode::FuzzyFilter => AppMode::FuzzyFilter,
SearchMode::ColumnSearch => AppMode::ColumnSearch,
}
}
#[must_use]
pub fn from_app_mode(mode: &AppMode) -> Option<Self> {
match mode {
AppMode::Search => Some(SearchMode::Search),
AppMode::Filter => Some(SearchMode::Filter),
AppMode::FuzzyFilter => Some(SearchMode::FuzzyFilter),
AppMode::ColumnSearch => Some(SearchMode::ColumnSearch),
_ => None,
}
}
#[must_use]
pub fn title(&self) -> &str {
match self {
SearchMode::Search => "Search Pattern",
SearchMode::Filter => "Filter Pattern",
SearchMode::FuzzyFilter => "Fuzzy Filter",
SearchMode::ColumnSearch => "Column Search",
}
}
#[must_use]
pub fn style(&self) -> Style {
match self {
SearchMode::Search => Style::default().fg(Color::Yellow),
SearchMode::Filter => Style::default().fg(Color::Cyan),
SearchMode::FuzzyFilter => Style::default().fg(Color::Magenta),
SearchMode::ColumnSearch => Style::default().fg(Color::Green),
}
}
}
pub struct SearchModesState {
pub mode: SearchMode,
pub input: Input,
pub fuzzy_matcher: SkimMatcherV2,
pub regex: Option<Regex>,
pub matching_columns: Vec<(usize, String)>,
pub current_match_index: usize,
pub saved_sql_text: String,
pub saved_cursor_position: usize,
}
impl Clone for SearchModesState {
fn clone(&self) -> Self {
Self {
mode: self.mode.clone(),
input: self.input.clone(),
fuzzy_matcher: SkimMatcherV2::default(), regex: self.regex.clone(),
matching_columns: self.matching_columns.clone(),
current_match_index: self.current_match_index,
saved_sql_text: self.saved_sql_text.clone(),
saved_cursor_position: self.saved_cursor_position,
}
}
}
impl SearchModesState {
#[must_use]
pub fn new(mode: SearchMode) -> Self {
Self {
mode,
input: Input::default(),
fuzzy_matcher: SkimMatcherV2::default(),
regex: None,
matching_columns: Vec::new(),
current_match_index: 0,
saved_sql_text: String::new(),
saved_cursor_position: 0,
}
}
pub fn reset(&mut self) {
self.input.reset();
self.regex = None;
self.matching_columns.clear();
self.current_match_index = 0;
}
pub fn get_pattern(&self) -> String {
self.input.value().to_string()
}
}
#[derive(Debug, Clone)]
pub enum SearchModesAction {
Continue,
Apply(SearchMode, String),
Cancel,
NextMatch,
PreviousMatch,
PassThrough,
InputChanged(SearchMode, String), ExecuteDebounced(SearchMode, String), }
pub struct SearchModesWidget {
state: Option<SearchModesState>,
debouncer: Debouncer,
last_applied_pattern: Option<String>,
}
impl Default for SearchModesWidget {
fn default() -> Self {
Self::new()
}
}
impl SearchModesWidget {
#[must_use]
pub fn new() -> Self {
Self {
state: None,
debouncer: Debouncer::new(500), last_applied_pattern: None,
}
}
pub fn enter_mode(&mut self, mode: SearchMode, current_sql: String, cursor_pos: usize) {
let mut state = SearchModesState::new(mode);
state.saved_sql_text = current_sql;
state.saved_cursor_position = cursor_pos;
self.state = Some(state);
self.last_applied_pattern = None; }
pub fn exit_mode(&mut self) -> Option<(String, usize)> {
self.debouncer.reset();
self.last_applied_pattern = None; self.state
.take()
.map(|s| (s.saved_sql_text, s.saved_cursor_position))
}
pub fn is_active(&self) -> bool {
self.state.is_some()
}
pub fn current_mode(&self) -> Option<SearchMode> {
self.state.as_ref().map(|s| s.mode.clone())
}
pub fn get_pattern(&self) -> String {
self.state
.as_ref()
.map(SearchModesState::get_pattern)
.unwrap_or_default()
}
pub fn get_cursor_position(&self) -> usize {
self.state.as_ref().map_or(0, |s| s.input.cursor())
}
pub fn handle_key(&mut self, key: KeyEvent) -> SearchModesAction {
let Some(state) = &mut self.state else {
return SearchModesAction::PassThrough;
};
match key.code {
KeyCode::Esc => SearchModesAction::Cancel,
KeyCode::Enter => {
let pattern = state.get_pattern();
if !pattern.is_empty() || state.mode == SearchMode::FuzzyFilter {
SearchModesAction::Apply(state.mode.clone(), pattern)
} else {
SearchModesAction::Cancel
}
}
KeyCode::Tab => {
if state.mode == SearchMode::ColumnSearch {
SearchModesAction::NextMatch
} else {
state.input.handle_event(&Event::Key(key));
SearchModesAction::Continue
}
}
KeyCode::BackTab => {
if state.mode == SearchMode::ColumnSearch {
SearchModesAction::PreviousMatch
} else {
SearchModesAction::Continue
}
}
_ => {
let old_pattern = state.get_pattern();
state.input.handle_event(&Event::Key(key));
let new_pattern = state.get_pattern();
if old_pattern == new_pattern {
SearchModesAction::Continue
} else {
self.debouncer.trigger();
SearchModesAction::InputChanged(state.mode.clone(), new_pattern)
}
}
}
}
pub fn check_debounce(&mut self) -> Option<SearchModesAction> {
if self.debouncer.should_execute() {
if let Some(state) = &self.state {
let pattern = state.get_pattern();
let should_apply = match &self.last_applied_pattern {
Some(last) => last != &pattern,
None => true, };
if should_apply {
let allow_empty =
matches!(state.mode, SearchMode::FuzzyFilter | SearchMode::Filter);
if !pattern.is_empty() || allow_empty {
self.last_applied_pattern = Some(pattern.clone());
return Some(SearchModesAction::ExecuteDebounced(
state.mode.clone(),
pattern,
));
}
}
}
}
None
}
pub fn render(&self, f: &mut Frame, area: Rect) {
let Some(state) = &self.state else {
return;
};
let input_text = state.get_pattern();
let mut title = state.mode.title().to_string();
if self.debouncer.is_pending() {
if let Some(remaining) = self.debouncer.time_remaining() {
let ms = remaining.as_millis();
if ms > 0 {
if ms > 300 {
title.push_str(&format!(" [⏱ {ms}ms]"));
} else if ms > 100 {
title.push_str(&format!(" [⚡ {ms}ms]"));
} else {
title.push_str(&format!(" [🔥 {ms}ms]"));
}
} else {
title.push_str(" [⏳ applying...]");
}
}
}
let style = state.mode.style();
let input_widget = Paragraph::new(input_text.as_str()).style(style).block(
Block::default()
.borders(Borders::ALL)
.title(title.as_str())
.border_style(style),
);
f.render_widget(input_widget, area);
f.set_cursor_position((area.x + state.input.cursor() as u16 + 1, area.y + 1));
}
pub fn render_hint(&self) -> Line<'static> {
if self.state.is_some() {
Line::from(vec![
Span::raw("Enter"),
Span::styled(":Apply", Style::default().fg(Color::Green)),
Span::raw(" | "),
Span::raw("Esc"),
Span::styled(":Cancel", Style::default().fg(Color::Red)),
])
} else {
Line::from("")
}
}
}
impl DebugInfoProvider for SearchModesWidget {
fn debug_info(&self) -> String {
let mut info = String::from("=== SEARCH MODES WIDGET ===\n");
info.push_str("Debouncer: ");
if self.debouncer.is_pending() {
if let Some(remaining) = self.debouncer.time_remaining() {
info.push_str(&format!(
"PENDING ({}ms remaining)\n",
remaining.as_millis()
));
} else {
info.push_str("PENDING\n");
}
} else {
info.push_str("IDLE\n");
}
info.push('\n');
if let Some(state) = &self.state {
info.push_str("State: ACTIVE\n");
info.push_str(&format!("Mode: {:?}\n", state.mode));
info.push_str(&format!("Current Pattern: '{}'\n", state.get_pattern()));
info.push_str(&format!("Pattern Length: {}\n", state.input.value().len()));
info.push_str(&format!("Cursor Position: {}\n", state.input.cursor()));
info.push('\n');
info.push_str("Saved SQL State:\n");
info.push_str(&format!(
" Text: '{}'\n",
if state.saved_sql_text.len() > 50 {
format!(
"{}... ({} chars)",
&state.saved_sql_text[..50],
state.saved_sql_text.len()
)
} else {
state.saved_sql_text.clone()
}
));
info.push_str(&format!(" Cursor: {}\n", state.saved_cursor_position));
info.push_str(&format!(" SQL Length: {}\n", state.saved_sql_text.len()));
if state.mode == SearchMode::ColumnSearch {
info.push_str("\nColumn Search State:\n");
info.push_str(&format!(
" Matching Columns: {} found\n",
state.matching_columns.len()
));
if !state.matching_columns.is_empty() {
info.push_str(&format!(
" Current Match Index: {}\n",
state.current_match_index
));
for (i, (idx, name)) in state.matching_columns.iter().take(5).enumerate() {
info.push_str(&format!(
" [{}] Column {}: '{}'\n",
if i == state.current_match_index {
"*"
} else {
" "
},
idx,
name
));
}
if state.matching_columns.len() > 5 {
info.push_str(&format!(
" ... and {} more\n",
state.matching_columns.len() - 5
));
}
}
}
if state.mode == SearchMode::FuzzyFilter {
info.push_str("\nFuzzy Filter State:\n");
info.push_str(" Matcher: SkimMatcherV2 (ready)\n");
}
if state.regex.is_some() {
info.push_str("\nRegex State:\n");
info.push_str(" Compiled: Yes\n");
}
} else {
info.push_str("State: INACTIVE\n");
info.push_str("No active search mode\n");
}
info
}
}