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}