open_sesame/input/
processor.rs

1//! Input processing pipeline
2//!
3//! Converts raw keyboard events into application actions.
4
5use crate::core::{HintMatcher, MatchResult, WindowId};
6use crate::input::InputBuffer;
7use crate::util::TimeoutTracker;
8use smithay_client_toolkit::seat::keyboard::Keysym;
9
10/// Actions that result from input processing
11#[derive(Debug, Clone)]
12pub enum InputAction {
13    /// No action needed (ignored key)
14    Ignore,
15    /// Buffer changed, update display
16    BufferChanged,
17    /// Selection changed via arrow keys
18    SelectionChanged {
19        /// Direction of selection change
20        direction: SelectionDirection,
21    },
22    /// Exact match found, pending activation with timeout
23    PendingActivation {
24        /// The window ID to activate
25        window_id: WindowId,
26        /// Index of the matched window hint
27        index: usize,
28    },
29    /// Activate immediately (Enter pressed)
30    ActivateNow {
31        /// The window ID to activate
32        window_id: WindowId,
33        /// Index of the matched window hint
34        index: usize,
35    },
36    /// Activate the currently selected item
37    ActivateSelected,
38    /// No window match, try launching app with this key
39    TryLaunch {
40        /// The key to use for launch lookup
41        key: char,
42    },
43    /// Cancel and exit
44    Cancel,
45}
46
47/// Direction of selection movement
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum SelectionDirection {
50    /// Move selection up (previous item)
51    Up,
52    /// Move selection down (next item)
53    Down,
54}
55
56/// Processes keyboard input into actions
57pub struct InputProcessor {
58    /// Current input buffer
59    buffer: InputBuffer,
60    /// Timeout tracker for activation delay
61    timeout: TimeoutTracker,
62    /// Pending match index (if any)
63    pending_index: Option<usize>,
64    /// Pending window ID (if any)
65    pending_window_id: Option<WindowId>,
66}
67
68impl InputProcessor {
69    /// Creates a new input processor with the given activation delay.
70    pub fn new(activation_delay_ms: u64) -> Self {
71        Self {
72            buffer: InputBuffer::new(),
73            timeout: TimeoutTracker::new(activation_delay_ms),
74            pending_index: None,
75            pending_window_id: None,
76        }
77    }
78
79    /// Returns the current input buffer.
80    pub fn buffer(&self) -> &InputBuffer {
81        &self.buffer
82    }
83
84    /// Returns the input as a string.
85    pub fn input_string(&self) -> String {
86        self.buffer.as_str()
87    }
88
89    /// Returns true if there is a pending match.
90    pub fn has_pending(&self) -> bool {
91        self.pending_index.is_some()
92    }
93
94    /// Returns pending match information.
95    pub fn pending(&self) -> Option<(usize, &WindowId)> {
96        match (&self.pending_index, &self.pending_window_id) {
97            (Some(idx), Some(id)) => Some((*idx, id)),
98            _ => None,
99        }
100    }
101
102    /// Returns true if timeout has elapsed for pending match.
103    pub fn timeout_elapsed(&self) -> bool {
104        self.timeout.has_elapsed()
105    }
106
107    /// Returns the pending activation if timeout has elapsed.
108    pub fn check_timeout(&mut self) -> Option<(usize, WindowId)> {
109        if self.timeout.has_elapsed()
110            && let (Some(idx), Some(id)) =
111                (self.pending_index.take(), self.pending_window_id.take())
112        {
113            self.timeout.cancel();
114            return Some((idx, id));
115        }
116        None
117    }
118
119    /// Processes a key press and returns the resulting action.
120    pub fn process_key<'a>(
121        &mut self,
122        key: Keysym,
123        matcher: &HintMatcher<'a>,
124        has_launch_config: impl Fn(&str) -> bool,
125    ) -> InputAction {
126        match key {
127            Keysym::Escape => {
128                tracing::debug!("Escape pressed, canceling");
129                InputAction::Cancel
130            }
131            Keysym::BackSpace => {
132                self.buffer.pop();
133                self.clear_pending();
134                self.timeout.reset();
135                tracing::debug!("Input: '{}'", self.buffer);
136                InputAction::BufferChanged
137            }
138            Keysym::Return | Keysym::KP_Enter => {
139                // Activates pending match or current exact match immediately
140                if let Some((idx, id)) = self.pending().map(|(i, id)| (i, id.clone())) {
141                    self.clear_pending();
142                    return InputAction::ActivateNow {
143                        window_id: id,
144                        index: idx,
145                    };
146                }
147
148                // Attempts to match current input exactly
149                if let MatchResult::Exact { index, window_id } =
150                    matcher.match_input(&self.buffer.as_str())
151                {
152                    return InputAction::ActivateNow { window_id, index };
153                }
154
155                // Activates current selection if nothing is pending or matched
156                InputAction::ActivateSelected
157            }
158            Keysym::Up | Keysym::KP_Up => InputAction::SelectionChanged {
159                direction: SelectionDirection::Up,
160            },
161            Keysym::Down | Keysym::KP_Down => InputAction::SelectionChanged {
162                direction: SelectionDirection::Down,
163            },
164            // Tab is handled by App for proper Shift+Tab support
165            _ => {
166                // Attempts to convert keysym to character
167                let Some(c) = keysym_to_char(key) else {
168                    return InputAction::Ignore;
169                };
170
171                self.buffer.push(c);
172                self.timeout.reset();
173                tracing::debug!("Input: '{}'", self.buffer);
174
175                // Matches input against available hints
176                match matcher.match_input(&self.buffer.as_str()) {
177                    MatchResult::Exact { index, window_id } => {
178                        tracing::debug!("Pending match: index={}", index);
179                        self.pending_index = Some(index);
180                        self.pending_window_id = Some(window_id.clone());
181                        self.timeout.start();
182                        InputAction::PendingActivation { window_id, index }
183                    }
184                    MatchResult::None => {
185                        // Checks if an application should be launched when no window matches
186                        let base_key = self.buffer.first_char();
187
188                        if let Some(key) = base_key {
189                            let key_str = key.to_string();
190                            if has_launch_config(&key_str) {
191                                tracing::info!("No window match, will launch: {}", key);
192                                return InputAction::TryLaunch { key };
193                            }
194                        }
195
196                        // Reverts input when no launch config is available
197                        self.buffer.pop();
198                        tracing::debug!("No match, reverting to: '{}'", self.buffer);
199                        InputAction::BufferChanged
200                    }
201                    MatchResult::Partial(_) => {
202                        // Clears pending state when multiple matches are possible
203                        self.clear_pending();
204                        InputAction::BufferChanged
205                    }
206                }
207            }
208        }
209    }
210
211    /// Clears pending match state.
212    fn clear_pending(&mut self) {
213        self.pending_index = None;
214        self.pending_window_id = None;
215        self.timeout.cancel();
216    }
217}
218
219/// Converts a keysym to a character (alphanumeric only).
220fn keysym_to_char(key: Keysym) -> Option<char> {
221    key.key_char()
222        .filter(|c| c.is_ascii_alphanumeric())
223        .map(|c| c.to_ascii_lowercase())
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_keysym_to_char() {
232        // Tests alphanumeric key conversion
233        assert_eq!(keysym_to_char(Keysym::a), Some('a'));
234        assert_eq!(keysym_to_char(Keysym::A), Some('a'));
235        assert_eq!(keysym_to_char(Keysym::_1), Some('1'));
236
237        // Non-alphanumeric keys return None
238        assert_eq!(keysym_to_char(Keysym::space), None);
239        assert_eq!(keysym_to_char(Keysym::Return), None);
240    }
241
242    #[test]
243    fn test_input_processor_basic() {
244        let processor = InputProcessor::new(200);
245        assert!(processor.buffer().is_empty());
246        assert!(!processor.has_pending());
247    }
248}