Skip to main content

flywheel/actor/
input.rs

1//! Input Actor: Dedicated thread for polling terminal events.
2//!
3//! This actor runs in its own thread and uses crossterm's event polling
4//! to capture keyboard, mouse, and resize events without blocking the
5//! main application.
6
7use super::messages::{InputEvent, KeyCode, KeyModifiers, MouseButton, MouseEvent};
8use crossbeam_channel::Sender;
9use crossterm::event::{self, Event, KeyEventKind};
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12use std::thread::{self, JoinHandle};
13use std::time::Duration;
14
15/// Input actor that polls terminal events.
16pub struct InputActor {
17    /// Handle to the input thread.
18    handle: Option<JoinHandle<()>>,
19    /// Flag to signal shutdown.
20    shutdown: Arc<AtomicBool>,
21}
22
23impl InputActor {
24    /// Spawn the input actor thread.
25    ///
26    /// # Arguments
27    ///
28    /// * `sender` - Channel to send input events to the main loop.
29    /// * `poll_timeout` - How long to wait for events before checking shutdown.
30    ///
31    /// # Returns
32    ///
33    /// The input actor handle.
34    #[allow(clippy::missing_panics_doc)]
35    pub fn spawn(sender: Sender<InputEvent>, poll_timeout: Duration) -> Self {
36        let shutdown = Arc::new(AtomicBool::new(false));
37        let shutdown_clone = shutdown.clone();
38
39        let handle = thread::Builder::new()
40            .name("flywheel-input".to_string())
41            .spawn(move || {
42                Self::run_loop(&sender, &shutdown_clone, poll_timeout);
43            })
44            .expect("Failed to spawn input thread");
45
46        Self {
47            handle: Some(handle),
48            shutdown,
49        }
50    }
51
52    /// Signal the input thread to shutdown.
53    pub fn shutdown(&self) {
54        self.shutdown.store(true, Ordering::Relaxed);
55    }
56
57    /// Wait for the input thread to finish.
58    pub fn join(mut self) {
59        self.shutdown();
60        if let Some(handle) = self.handle.take() {
61            let _ = handle.join();
62        }
63    }
64
65    /// Main input polling loop.
66    #[allow(clippy::collapsible_if)]
67    fn run_loop(sender: &Sender<InputEvent>, shutdown: &Arc<AtomicBool>, poll_timeout: Duration) {
68        loop {
69            // Check for shutdown
70            if shutdown.load(Ordering::Relaxed) {
71                let _ = sender.send(InputEvent::Shutdown);
72                break;
73            }
74
75            // Poll for events with timeout
76            match event::poll(poll_timeout) {
77                Ok(true) => {
78                    // Event available, read it
79                    match event::read() {
80                        Ok(event) => {
81                            if let Some(input_event) = Self::convert_event(event) {
82                                if sender.send(input_event).is_err() {
83                                    // Receiver dropped, exit
84                                    break;
85                                }
86                            }
87                        }
88                        Err(e) => {
89                            let _ = sender.send(InputEvent::Error(e.to_string()));
90                        }
91                    }
92                }
93                Ok(false) => {
94                    // No event, continue loop (will check shutdown)
95                }
96                Err(e) => {
97                    let _ = sender.send(InputEvent::Error(e.to_string()));
98                }
99            }
100        }
101    }
102
103    /// Convert a crossterm event to our `InputEvent`.
104    fn convert_event(event: Event) -> Option<InputEvent> {
105        match event {
106            Event::Key(key_event) => {
107                // Only process key press events (not release or repeat)
108                if key_event.kind != KeyEventKind::Press {
109                    return None;
110                }
111
112                let code = Self::convert_key_code(key_event.code)?;
113                let modifiers = Self::convert_modifiers(key_event.modifiers);
114
115                Some(InputEvent::Key { code, modifiers })
116            }
117
118            Event::Mouse(mouse_event) => Self::convert_mouse_event(mouse_event),
119
120            Event::Resize(width, height) => Some(InputEvent::Resize { width, height }),
121
122            Event::FocusGained => Some(InputEvent::FocusGained),
123
124            Event::FocusLost => Some(InputEvent::FocusLost),
125
126            Event::Paste(text) => Some(InputEvent::Paste(text)),
127        }
128    }
129
130    /// Convert crossterm `KeyCode` to our `KeyCode`.
131    const fn convert_key_code(code: event::KeyCode) -> Option<KeyCode> {
132        Some(match code {
133            event::KeyCode::Char(c) => KeyCode::Char(c),
134            event::KeyCode::F(n) => KeyCode::F(n),
135            event::KeyCode::Backspace => KeyCode::Backspace,
136            event::KeyCode::Enter => KeyCode::Enter,
137            event::KeyCode::Left => KeyCode::Left,
138            event::KeyCode::Right => KeyCode::Right,
139            event::KeyCode::Up => KeyCode::Up,
140            event::KeyCode::Down => KeyCode::Down,
141            event::KeyCode::Home => KeyCode::Home,
142            event::KeyCode::End => KeyCode::End,
143            event::KeyCode::PageUp => KeyCode::PageUp,
144            event::KeyCode::PageDown => KeyCode::PageDown,
145            event::KeyCode::Tab => KeyCode::Tab,
146            event::KeyCode::BackTab => KeyCode::BackTab,
147            event::KeyCode::Delete => KeyCode::Delete,
148            event::KeyCode::Insert => KeyCode::Insert,
149            event::KeyCode::Esc => KeyCode::Esc,
150            event::KeyCode::Null => KeyCode::Null,
151            _ => return None, // Ignore other key codes
152        })
153    }
154
155    /// Convert crossterm `KeyModifiers` to our `KeyModifiers`.
156    const fn convert_modifiers(mods: event::KeyModifiers) -> KeyModifiers {
157        KeyModifiers {
158            shift: mods.contains(event::KeyModifiers::SHIFT),
159            control: mods.contains(event::KeyModifiers::CONTROL),
160            alt: mods.contains(event::KeyModifiers::ALT),
161            super_key: mods.contains(event::KeyModifiers::SUPER),
162        }
163    }
164
165    /// Convert crossterm `MouseEvent` to our `InputEvent`.
166    const fn convert_mouse_event(mouse: event::MouseEvent) -> Option<InputEvent> {
167        let modifiers = Self::convert_modifiers(mouse.modifiers);
168
169        match mouse.kind {
170            event::MouseEventKind::Down(button) => {
171                let button = Self::convert_mouse_button(button);
172                Some(InputEvent::MouseDown(MouseEvent {
173                    x: mouse.column,
174                    y: mouse.row,
175                    button: Some(button),
176                    modifiers,
177                }))
178            }
179            event::MouseEventKind::Up(button) => {
180                let button = Self::convert_mouse_button(button);
181                Some(InputEvent::MouseUp(MouseEvent {
182                    x: mouse.column,
183                    y: mouse.row,
184                    button: Some(button),
185                    modifiers,
186                }))
187            }
188            event::MouseEventKind::Moved => Some(InputEvent::MouseMove(MouseEvent {
189                x: mouse.column,
190                y: mouse.row,
191                button: None,
192                modifiers,
193            })),
194            event::MouseEventKind::Drag(button) => {
195                let button = Self::convert_mouse_button(button);
196                Some(InputEvent::MouseMove(MouseEvent {
197                    x: mouse.column,
198                    y: mouse.row,
199                    button: Some(button),
200                    modifiers,
201                }))
202            }
203            event::MouseEventKind::ScrollUp => Some(InputEvent::MouseScroll {
204                x: mouse.column,
205                y: mouse.row,
206                delta: 1,
207            }),
208            event::MouseEventKind::ScrollDown => Some(InputEvent::MouseScroll {
209                x: mouse.column,
210                y: mouse.row,
211                delta: -1,
212            }),
213            _ => None,
214        }
215    }
216
217    /// Convert crossterm `MouseButton` to our `MouseButton`.
218    const fn convert_mouse_button(button: event::MouseButton) -> MouseButton {
219        match button {
220            event::MouseButton::Left => MouseButton::Left,
221            event::MouseButton::Right => MouseButton::Right,
222            event::MouseButton::Middle => MouseButton::Middle,
223        }
224    }
225}
226
227impl Drop for InputActor {
228    fn drop(&mut self) {
229        self.shutdown();
230    }
231}