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 Default for DebouncedInput {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl DebouncedInput {
78    /// Create a new debounced input with default config
79    #[must_use]
80    pub fn new() -> Self {
81        Self::with_config(DebouncedInputConfig::default())
82    }
83
84    /// Create a new debounced input with custom config
85    #[must_use]
86    pub fn with_config(config: DebouncedInputConfig) -> Self {
87        Self {
88            input: Input::default(),
89            debouncer: Debouncer::new(config.debounce_ms),
90            last_executed_pattern: None,
91            config,
92            active: false,
93        }
94    }
95
96    /// Activate the input widget
97    pub fn activate(&mut self) {
98        self.active = true;
99        self.input.reset();
100        self.debouncer.reset();
101        self.last_executed_pattern = None;
102    }
103
104    /// Deactivate the input widget
105    pub fn deactivate(&mut self) {
106        self.active = false;
107        self.debouncer.reset();
108    }
109
110    /// Check if the widget is active
111    #[must_use]
112    pub fn is_active(&self) -> bool {
113        self.active
114    }
115
116    /// Get the current input value
117    #[must_use]
118    pub fn value(&self) -> &str {
119        self.input.value()
120    }
121
122    /// Set the input value (useful for restoring state)
123    pub fn set_value(&mut self, value: String) {
124        self.input = Input::default().with_value(value);
125    }
126
127    /// Get the cursor position
128    #[must_use]
129    pub fn cursor(&self) -> usize {
130        self.input.cursor()
131    }
132
133    /// Update configuration
134    pub fn set_config(&mut self, config: DebouncedInputConfig) {
135        self.debouncer = Debouncer::new(config.debounce_ms);
136        self.config = config;
137    }
138
139    /// Handle a key event
140    pub fn handle_key(&mut self, key: KeyEvent) -> DebouncedInputAction {
141        if !self.active {
142            return DebouncedInputAction::PassThrough;
143        }
144
145        match key.code {
146            KeyCode::Esc => {
147                self.deactivate();
148                DebouncedInputAction::Cancel
149            }
150            KeyCode::Enter => {
151                let pattern = self.input.value().to_string();
152                self.deactivate();
153                DebouncedInputAction::Confirm(pattern)
154            }
155            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
156                // Allow Ctrl-C to exit
157                DebouncedInputAction::PassThrough
158            }
159            _ => {
160                // Let tui_input handle the key (char input, backspace, arrows, etc.)
161                self.input.handle_event(&crossterm::event::Event::Key(key));
162                let current_pattern = self.input.value().to_string();
163
164                // Check if pattern actually changed
165                if self.last_executed_pattern.as_ref() == Some(&current_pattern) {
166                    DebouncedInputAction::Continue
167                } else {
168                    self.debouncer.trigger();
169                    DebouncedInputAction::InputChanged(current_pattern)
170                }
171            }
172        }
173    }
174
175    /// Check if the debounced action should execute
176    /// This should be called periodically (e.g., in the main event loop)
177    pub fn check_debounce(&mut self) -> Option<String> {
178        if self.debouncer.should_execute() {
179            let pattern = self.input.value().to_string();
180            // Only execute if pattern changed since last execution
181            if self.last_executed_pattern.as_ref() == Some(&pattern) {
182                None
183            } else {
184                self.last_executed_pattern = Some(pattern.clone());
185                Some(pattern)
186            }
187        } else {
188            None
189        }
190    }
191
192    /// Render the input widget
193    pub fn render(&self, f: &mut Frame, area: Rect) {
194        let title = if self.config.show_debounce_indicator && self.debouncer.is_pending() {
195            format!("{} (typing...)", self.config.title)
196        } else {
197            self.config.title.clone()
198        };
199
200        let block = Block::default()
201            .borders(Borders::ALL)
202            .title(title)
203            .border_style(self.config.style);
204
205        let input_widget = Paragraph::new(self.input.value())
206            .block(block)
207            .style(self.config.style);
208
209        f.render_widget(input_widget, area);
210
211        // Set cursor position if active
212        if self.active {
213            f.set_cursor_position((area.x + self.input.cursor() as u16 + 1, area.y + 1));
214        }
215    }
216
217    /// Create a custom title with mode indicator
218    pub fn set_title(&mut self, title: String) {
219        self.config.title = title;
220    }
221
222    /// Update the style
223    pub fn set_style(&mut self, style: Style) {
224        self.config.style = style;
225    }
226}
227
228/// Builder pattern for `DebouncedInput` configuration
229pub struct DebouncedInputBuilder {
230    config: DebouncedInputConfig,
231}
232
233impl Default for DebouncedInputBuilder {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239impl DebouncedInputBuilder {
240    #[must_use]
241    pub fn new() -> Self {
242        Self {
243            config: DebouncedInputConfig::default(),
244        }
245    }
246
247    #[must_use]
248    pub fn debounce_ms(mut self, ms: u64) -> Self {
249        self.config.debounce_ms = ms;
250        self
251    }
252
253    pub fn title(mut self, title: impl Into<String>) -> Self {
254        self.config.title = title.into();
255        self
256    }
257
258    #[must_use]
259    pub fn style(mut self, style: Style) -> Self {
260        self.config.style = style;
261        self
262    }
263
264    #[must_use]
265    pub fn show_indicator(mut self, show: bool) -> Self {
266        self.config.show_debounce_indicator = show;
267        self
268    }
269
270    #[must_use]
271    pub fn build(self) -> DebouncedInput {
272        DebouncedInput::with_config(self.config)
273    }
274}