Skip to main content

opendev_tui/event/
handler.rs

1//! Crossterm event reader with scroll debouncing.
2
3use std::time::{Duration, Instant};
4
5use crossterm::event::{
6    Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton,
7    MouseEventKind,
8};
9use tokio::sync::mpsc;
10
11use super::AppEvent;
12
13/// Handles crossterm event reading and dispatches [`AppEvent`]s.
14pub struct EventHandler {
15    /// Channel sender for emitting events.
16    tx: mpsc::UnboundedSender<AppEvent>,
17    /// Channel receiver for consuming events.
18    rx: mpsc::UnboundedReceiver<AppEvent>,
19    /// Tick rate for periodic updates.
20    tick_rate: Duration,
21}
22
23impl EventHandler {
24    /// Create a new event handler with the given tick rate.
25    pub fn new(tick_rate: Duration) -> Self {
26        let (tx, rx) = mpsc::unbounded_channel();
27        Self { tx, rx, tick_rate }
28    }
29
30    /// Get a clone of the sender for external event producers (agent, tools).
31    pub fn sender(&self) -> mpsc::UnboundedSender<AppEvent> {
32        self.tx.clone()
33    }
34
35    /// Start the crossterm event reader loop.
36    ///
37    /// Uses crossterm's async `EventStream` for zero-latency event delivery.
38    ///
39    /// Includes a debounce state machine that distinguishes touchpad/mouse scroll
40    /// (rapid-fire Up/Down arrows via xterm alternate scroll mode `\x1b[?1007h`)
41    /// from keyboard arrow presses. Touchpad scroll generates arrows every 8-16ms
42    /// in bursts; keyboard presses are single events with ~300ms before repeat.
43    /// A 25ms debounce window cleanly separates these two input sources.
44    ///
45    /// Also handles mouse events (click/drag/up for selection, scroll for terminals
46    /// that support mouse reporting) and FocusGained for triggering redraws.
47    pub fn start(&self) {
48        use futures::StreamExt;
49        let tx = self.tx.clone();
50        let tick_rate = self.tick_rate;
51
52        tokio::spawn(async move {
53            let mut reader = crossterm::event::EventStream::new();
54            let mut tick_interval = tokio::time::interval(tick_rate);
55
56            // Debounce state for distinguishing mouse scroll from keyboard arrows
57            let debounce_window = Duration::from_millis(25);
58            let scroll_burst_timeout = Duration::from_millis(100);
59            let mut pending_arrow: Option<(KeyEvent, Instant)> = None;
60            let mut scroll_burst = false;
61            let mut last_arrow_time: Option<Instant> = None;
62
63            loop {
64                // Compute debounce deadline if we have a pending arrow
65                let debounce_deadline = pending_arrow
66                    .as_ref()
67                    .map(|(_, t)| tokio::time::Instant::from_std(*t + debounce_window));
68
69                tokio::select! {
70                    biased;
71
72                    // Debounce timer fires — pending arrow was a keyboard press
73                    _ = async {
74                        match debounce_deadline {
75                            Some(deadline) => tokio::time::sleep_until(deadline).await,
76                            None => std::future::pending().await,
77                        }
78                    } => {
79                        if let Some((key, _)) = pending_arrow.take() {
80                            scroll_burst = false;
81                            if tx.send(AppEvent::Key(key)).is_err() {
82                                break;
83                            }
84                        }
85                    }
86
87                    maybe_event = reader.next() => {
88                        match maybe_event {
89                            Some(Ok(CrosstermEvent::Key(key))) => {
90                                let is_unmodified_arrow = matches!(
91                                    key.code,
92                                    KeyCode::Up | KeyCode::Down
93                                ) && key.modifiers == KeyModifiers::NONE
94                                  && key.kind == KeyEventKind::Press;
95
96                                if is_unmodified_arrow {
97                                    let now = Instant::now();
98
99                                    // Check if we're in a scroll burst
100                                    let in_burst = scroll_burst
101                                        && last_arrow_time.is_some_and(|t| {
102                                            now.duration_since(t) < scroll_burst_timeout
103                                        });
104
105                                    if let Some((prev_key, _)) = pending_arrow.take() {
106                                        // Second arrow arrived within debounce window → mouse scroll
107                                        scroll_burst = true;
108                                        last_arrow_time = Some(now);
109                                        let ev1 = if prev_key.code == KeyCode::Up {
110                                            AppEvent::ScrollUp
111                                        } else {
112                                            AppEvent::ScrollDown
113                                        };
114                                        let ev2 = if key.code == KeyCode::Up {
115                                            AppEvent::ScrollUp
116                                        } else {
117                                            AppEvent::ScrollDown
118                                        };
119                                        if tx.send(ev1).is_err() || tx.send(ev2).is_err() {
120                                            break;
121                                        }
122                                    } else if in_burst {
123                                        // Continuing a scroll burst
124                                        last_arrow_time = Some(now);
125                                        let ev = if key.code == KeyCode::Up {
126                                            AppEvent::ScrollUp
127                                        } else {
128                                            AppEvent::ScrollDown
129                                        };
130                                        if tx.send(ev).is_err() {
131                                            break;
132                                        }
133                                    } else {
134                                        // First arrow — buffer it, wait for debounce
135                                        pending_arrow = Some((key, now));
136                                    }
137                                } else {
138                                    // Non-arrow key or arrow with modifiers/repeat:
139                                    // flush any pending arrow as keyboard first
140                                    if let Some((prev_key, _)) = pending_arrow.take() {
141                                        scroll_burst = false;
142                                        if tx.send(AppEvent::Key(prev_key)).is_err() {
143                                            break;
144                                        }
145                                    }
146                                    // Only forward press and repeat events
147                                    if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
148                                        && tx.send(AppEvent::Key(key)).is_err()
149                                    {
150                                        break;
151                                    }
152                                }
153                            }
154                            Some(Ok(CrosstermEvent::Mouse(mouse))) => {
155                                let ev = match mouse.kind {
156                                    MouseEventKind::ScrollUp => Some(AppEvent::ScrollUp),
157                                    MouseEventKind::ScrollDown => Some(AppEvent::ScrollDown),
158                                    MouseEventKind::Down(MouseButton::Left) => {
159                                        Some(AppEvent::MouseDown { col: mouse.column, row: mouse.row })
160                                    }
161                                    MouseEventKind::Drag(MouseButton::Left) => {
162                                        Some(AppEvent::MouseDrag { col: mouse.column, row: mouse.row })
163                                    }
164                                    MouseEventKind::Up(MouseButton::Left) => {
165                                        Some(AppEvent::MouseUp { col: mouse.column, row: mouse.row })
166                                    }
167                                    _ => None,
168                                };
169                                if let Some(e) = ev {
170                                    // Flush pending arrow before mouse events
171                                    if let Some((prev_key, _)) = pending_arrow.take() {
172                                        scroll_burst = false;
173                                        if tx.send(AppEvent::Key(prev_key)).is_err() {
174                                            break;
175                                        }
176                                    }
177                                    if tx.send(e).is_err() {
178                                        break;
179                                    }
180                                }
181                            }
182                            Some(Ok(CrosstermEvent::Resize(w, h))) => {
183                                // Flush pending arrow before resize
184                                if let Some((prev_key, _)) = pending_arrow.take() {
185                                    scroll_burst = false;
186                                    if tx.send(AppEvent::Key(prev_key)).is_err() {
187                                        break;
188                                    }
189                                }
190                                if tx.send(AppEvent::Resize(w, h)).is_err() {
191                                    break;
192                                }
193                            }
194                            Some(Ok(CrosstermEvent::FocusGained)) => {
195                                // Flush pending arrow before focus events
196                                if let Some((prev_key, _)) = pending_arrow.take() {
197                                    scroll_burst = false;
198                                    if tx.send(AppEvent::Key(prev_key)).is_err() {
199                                        break;
200                                    }
201                                }
202                                if tx.send(AppEvent::FocusGained).is_err() {
203                                    break;
204                                }
205                            }
206                            Some(Ok(other)) => {
207                                // Flush pending arrow before other events
208                                if let Some((prev_key, _)) = pending_arrow.take() {
209                                    scroll_burst = false;
210                                    if tx.send(AppEvent::Key(prev_key)).is_err() {
211                                        break;
212                                    }
213                                }
214                                if tx.send(AppEvent::Terminal(other)).is_err() {
215                                    break;
216                                }
217                            }
218                            Some(Err(_)) => continue,
219                            None => break,
220                        }
221                    }
222
223                    _ = tick_interval.tick() => {
224                        // Don't flush pending arrow on tick — let debounce timer handle it
225                        if tx.send(AppEvent::Tick).is_err() {
226                            break;
227                        }
228                    }
229                }
230            }
231        });
232    }
233
234    /// Receive the next event.
235    pub async fn next(&mut self) -> Option<AppEvent> {
236        self.rx.recv().await
237    }
238
239    /// Try to receive an event without blocking.
240    /// Returns `None` immediately if no event is queued.
241    pub fn try_next(&mut self) -> Option<AppEvent> {
242        self.rx.try_recv().ok()
243    }
244}