sql_cli/widgets/
debounced_input.rs

1//! Unified debounced input widget for all search/filter modes
2//!
3//! This widget handles text input with automatic debouncing to prevent
4//! expensive operations (like searching through 20k rows) on every keystroke.
5
6use crate::utils::debouncer::Debouncer;
7use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8use ratatui::{
9    layout::Rect,
10    style::{Color, Style},
11    widgets::{Block, Borders, Paragraph},
12    Frame,
13};
14use tui_input::{backend::crossterm::EventHandler, Input};
15
16/// Result of handling a key in the debounced input
17#[derive(Debug, Clone)]
18pub enum DebouncedInputAction {
19    /// Continue typing, no action needed yet
20    Continue,
21    /// Input changed, may trigger debounced action
22    InputChanged(String),
23    /// Debounced period elapsed, execute the action now
24    ExecuteDebounced(String),
25    /// User pressed Enter to confirm
26    Confirm(String),
27    /// User pressed Esc to cancel
28    Cancel,
29    /// Pass the key through to parent handler
30    PassThrough,
31}
32
33/// Configuration for the debounced input
34#[derive(Debug, Clone)]
35pub struct DebouncedInputConfig {
36    /// Debounce delay in milliseconds
37    pub debounce_ms: u64,
38    /// Title for the input box
39    pub title: String,
40    /// Color style for the input
41    pub style: Style,
42    /// Whether to show debounce indicator
43    pub show_debounce_indicator: bool,
44}
45
46impl Default for DebouncedInputConfig {
47    fn default() -> Self {
48        Self {
49            debounce_ms: 300,
50            title: "Search".to_string(),
51            style: Style::default().fg(Color::Yellow),
52            show_debounce_indicator: true,
53        }
54    }
55}
56
57/// A reusable debounced input widget
58pub struct DebouncedInput {
59    /// The underlying tui_input
60    input: Input,
61    /// Debouncer for the input
62    debouncer: Debouncer,
63    /// Last pattern that was executed
64    last_executed_pattern: Option<String>,
65    /// Configuration
66    config: DebouncedInputConfig,
67    /// Whether the widget is active
68    active: bool,
69}
70
71impl DebouncedInput {
72    /// Create a new debounced input with default config
73    pub fn new() -> Self {
74        Self::with_config(DebouncedInputConfig::default())
75    }
76
77    /// Create a new debounced input with custom config
78    pub fn with_config(config: DebouncedInputConfig) -> Self {
79        Self {
80            input: Input::default(),
81            debouncer: Debouncer::new(config.debounce_ms),
82            last_executed_pattern: None,
83            config,
84            active: false,
85        }
86    }
87
88    /// Activate the input widget
89    pub fn activate(&mut self) {
90        self.active = true;
91        self.input.reset();
92        self.debouncer.reset();
93        self.last_executed_pattern = None;
94    }
95
96    /// Deactivate the input widget
97    pub fn deactivate(&mut self) {
98        self.active = false;
99        self.debouncer.reset();
100    }
101
102    /// Check if the widget is active
103    pub fn is_active(&self) -> bool {
104        self.active
105    }
106
107    /// Get the current input value
108    pub fn value(&self) -> &str {
109        self.input.value()
110    }
111
112    /// Set the input value (useful for restoring state)
113    pub fn set_value(&mut self, value: String) {
114        self.input = Input::default().with_value(value);
115    }
116
117    /// Get the cursor position
118    pub fn cursor(&self) -> usize {
119        self.input.cursor()
120    }
121
122    /// Update configuration
123    pub fn set_config(&mut self, config: DebouncedInputConfig) {
124        self.debouncer = Debouncer::new(config.debounce_ms);
125        self.config = config;
126    }
127
128    /// Handle a key event
129    pub fn handle_key(&mut self, key: KeyEvent) -> DebouncedInputAction {
130        if !self.active {
131            return DebouncedInputAction::PassThrough;
132        }
133
134        match key.code {
135            KeyCode::Esc => {
136                self.deactivate();
137                DebouncedInputAction::Cancel
138            }
139            KeyCode::Enter => {
140                let pattern = self.input.value().to_string();
141                self.deactivate();
142                DebouncedInputAction::Confirm(pattern)
143            }
144            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
145                // Allow Ctrl-C to exit
146                DebouncedInputAction::PassThrough
147            }
148            _ => {
149                // Let tui_input handle the key (char input, backspace, arrows, etc.)
150                self.input.handle_event(&crossterm::event::Event::Key(key));
151                let current_pattern = self.input.value().to_string();
152
153                // Check if pattern actually changed
154                if self.last_executed_pattern.as_ref() != Some(&current_pattern) {
155                    self.debouncer.trigger();
156                    DebouncedInputAction::InputChanged(current_pattern)
157                } else {
158                    DebouncedInputAction::Continue
159                }
160            }
161        }
162    }
163
164    /// Check if the debounced action should execute
165    /// This should be called periodically (e.g., in the main event loop)
166    pub fn check_debounce(&mut self) -> Option<String> {
167        if self.debouncer.should_execute() {
168            let pattern = self.input.value().to_string();
169            // Only execute if pattern changed since last execution
170            if self.last_executed_pattern.as_ref() != Some(&pattern) {
171                self.last_executed_pattern = Some(pattern.clone());
172                Some(pattern)
173            } else {
174                None
175            }
176        } else {
177            None
178        }
179    }
180
181    /// Render the input widget
182    pub fn render(&self, f: &mut Frame, area: Rect) {
183        let title = if self.config.show_debounce_indicator && self.debouncer.is_pending() {
184            format!("{} (typing...)", self.config.title)
185        } else {
186            self.config.title.clone()
187        };
188
189        let block = Block::default()
190            .borders(Borders::ALL)
191            .title(title)
192            .border_style(self.config.style);
193
194        let input_widget = Paragraph::new(self.input.value())
195            .block(block)
196            .style(self.config.style);
197
198        f.render_widget(input_widget, area);
199
200        // Set cursor position if active
201        if self.active {
202            f.set_cursor_position((area.x + self.input.cursor() as u16 + 1, area.y + 1));
203        }
204    }
205
206    /// Create a custom title with mode indicator
207    pub fn set_title(&mut self, title: String) {
208        self.config.title = title;
209    }
210
211    /// Update the style
212    pub fn set_style(&mut self, style: Style) {
213        self.config.style = style;
214    }
215}
216
217/// Builder pattern for DebouncedInput configuration
218pub struct DebouncedInputBuilder {
219    config: DebouncedInputConfig,
220}
221
222impl DebouncedInputBuilder {
223    pub fn new() -> Self {
224        Self {
225            config: DebouncedInputConfig::default(),
226        }
227    }
228
229    pub fn debounce_ms(mut self, ms: u64) -> Self {
230        self.config.debounce_ms = ms;
231        self
232    }
233
234    pub fn title(mut self, title: impl Into<String>) -> Self {
235        self.config.title = title.into();
236        self
237    }
238
239    pub fn style(mut self, style: Style) -> Self {
240        self.config.style = style;
241        self
242    }
243
244    pub fn show_indicator(mut self, show: bool) -> Self {
245        self.config.show_debounce_indicator = show;
246        self
247    }
248
249    pub fn build(self) -> DebouncedInput {
250        DebouncedInput::with_config(self.config)
251    }
252}