• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jzombie / term-wm / 30667981663

31 Jul 2026 09:50PM UTC coverage: 79.936% (+5.0%) from 74.902%
30667981663

Pull #118

github

web-flow
Merge 9bde46198 into 043b5f8fc
Pull Request #118: v0.9.0-alpha

33697 of 42155 relevant lines covered (79.94%)

160.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.44
/src/unified_event_source.rs
1
use std::collections::{HashSet, VecDeque};
2
use std::io;
3
use std::sync::{
4
    Arc,
5
    atomic::{AtomicBool, Ordering},
6
};
7
use std::thread::{self, JoinHandle};
8
use std::time::{Duration, Instant};
9

10
use crossbeam_channel::{Receiver, Sender, bounded};
11

12
use term_wm_core::events::{Event, KeyEvent, MouseEvent};
13
use term_wm_core::io::EventSource;
14
use term_wm_core::io::frame_pacer::FramePacer;
15
use term_wm_core::power_profile::PowerProfile;
16
use term_wm_core::utils::KeyboardNormalizer;
17
use term_wm_core::window::WindowKey;
18
use term_wm_crossterm_adapter;
19

20
/// Capacity of the crossbeam channel between event producers and the event
21
/// loop. Generous capacity since wakeup gating (dirty.swap) prevents flooding.
22
const EVENT_CHANNEL_CAPACITY: usize = 256;
23

24
/// How often the crossterm input thread polls for new events (100 ms).
25
/// Keeps the thread responsive to shutdown signals while being idle-friendly.
26
const INPUT_THREAD_POLL_INTERVAL: Duration = Duration::from_millis(100);
27

28
/// Events that can flow through the unified event channel.
29
#[derive(Debug, Clone)]
30
pub enum UnifiedEvent {
31
    /// A user-input event from crossterm (key, mouse, resize).
32
    Input(Event),
33
    /// A PTY reader thread has new data available for `WindowKey`.
34
    PtyWakeup(WindowKey),
35
    /// A PTY child process has exited. Sent from the reader thread on EOF.
36
    AppExited(WindowKey),
37
    /// Application direct-input routing state changed (alt screen, mouse tracking, margins).
38
    DirectInputChanged(WindowKey, bool),
39
    /// An OS signal was received (SIGINT, SIGTERM).
40
    Signal,
41
    /// Periodic tick for timing.
42
    Tick,
43
}
44

45
/// A unified event source that multiplexes crossterm input, PTY wakeups,
46
/// and OS signals into a single channel.  The main thread blocks on one
47
/// receiver instead of polling multiple sources.
48
pub struct UnifiedEventSource {
49
    rx: Receiver<UnifiedEvent>,
50
    /// One Sender clone kept here so we can create it before the input thread.
51
    tx: Sender<UnifiedEvent>,
52
    /// Handle to the crossterm input thread.
53
    _input_handle: JoinHandle<()>,
54
    /// When true, the input thread should shut down.
55
    shutdown: Arc<AtomicBool>,
56
    /// Accumulated PTY wakeups since the last idle tick — batch-drained
57
    /// so thousands of wakeups/sec collapse into a single render.
58
    dirty_windows: HashSet<WindowKey>,
59
    /// Accumulated window exit notifications since the last drain.
60
    exited_windows: Vec<WindowKey>,
61
    /// Accumulated direct-input routing transitions since the last drain.
62
    direct_input_changed: Vec<(WindowKey, bool)>,
63
    /// Cached input event (poll returned true, waiting for read).
64
    pending_event: Option<Event>,
65
    /// Buffer for input events drained during `drain_pending` so none are lost.
66
    input_buffer: VecDeque<Event>,
67
    /// Signal received flag.
68
    signal_received: bool,
69
    /// Keyboard normalizer for consistent event handling.
70
    normalizer: KeyboardNormalizer,
71
    /// Timestamp of the last input event (for power profiling).
72
    last_event_at: Option<Instant>,
73
    /// Frame pacing: ensures renders fire at most once per 16ms interval.
74
    frame_pacer: FramePacer,
75
    /// Set by the runner via [`EventSource::set_pending_work`] when there's
76
    /// pending work (e.g. countdown timer) that requires frequent polling
77
    /// regardless of PTY dirty-window state.
78
    pending_work: bool,
79
    /// Maximum duration the next [`poll`] call is allowed to block.
80
    /// Set by the runner via [`EventSource::set_max_sleep_duration`] to
81
    /// clamp the PowerSaver poll interval to the next scheduler deadline.
82
    ///
83
    /// [`poll`]: EventSource::poll
84
    /// [`set_max_sleep_duration`]: EventSource::set_max_sleep_duration
85
    max_sleep_duration: Option<Duration>,
86
    /// Global dirty bit — set by `request_redraw()`, consumed by
87
    /// `take_redraw_request()` in the runner's `None` branch to arm
88
    /// the FramePacer for non-input-driven state changes.
89
    pending_redraw: bool,
90
}
91

92
impl UnifiedEventSource {
93
    /// Create a new unified event source, spawning a background thread
94
    /// that reads crossterm events.
95
    ///
96
    /// The bounded channel (256 slots) provides mechanical backpressure:
97
    /// when the channel is full, PTY reader threads block on `send()` →
98
    /// OS pipe buffer fills → child process `write()` blocks → prevents
99
    /// memory exhaustion under extreme output load.
100
    pub fn new() -> io::Result<Self> {
×
101
        let (tx, rx) = bounded::<UnifiedEvent>(EVENT_CHANNEL_CAPACITY);
×
102
        let shutdown = Arc::new(AtomicBool::new(false));
×
103
        let input_tx = tx.clone();
×
104
        let input_shutdown = Arc::clone(&shutdown);
×
105

106
        let _input_handle = thread::Builder::new()
×
107
            .name("crossterm-input".into())
×
108
            .spawn(move || {
×
109
                // Loop: poll crossterm with 100ms timeout, check shutdown flag.
110
                loop {
111
                    if input_shutdown.load(Ordering::Acquire) {
×
112
                        break;
×
113
                    }
×
114
                    // Blocking poll with short timeout so we can check shutdown.
115
                    if crossterm::event::poll(INPUT_THREAD_POLL_INTERVAL).unwrap_or(false) {
×
116
                        match crossterm::event::read() {
×
117
                            Ok(crossterm_event) => {
×
118
                                // Translate crossterm event to core-owned event
119
                                if let Some(core_event) = translate_crossterm_event(crossterm_event)
×
120
                                    && input_tx.send(UnifiedEvent::Input(core_event)).is_err()
×
121
                                {
122
                                    break; // receiver dropped
×
123
                                }
×
124
                            }
125
                            Err(_) => break,
×
126
                        }
127
                    }
×
128
                }
129
            })
×
130
            .map_err(|e| io::Error::other(e.to_string()))?;
×
131

132
        Ok(Self {
×
133
            rx,
×
134
            tx,
×
135
            _input_handle,
×
136
            shutdown,
×
137
            dirty_windows: HashSet::new(),
×
138
            exited_windows: Vec::new(),
×
139
            direct_input_changed: Vec::new(),
×
140
            pending_redraw: false,
×
141
            pending_event: None,
×
142
            input_buffer: VecDeque::new(),
×
143
            signal_received: false,
×
144
            normalizer: KeyboardNormalizer::new(),
×
145
            last_event_at: None,
×
146
            frame_pacer: FramePacer::new(),
×
147
            pending_work: false,
×
148
            max_sleep_duration: None,
×
149
        })
×
150
    }
×
151

152
    /// Return a sender that PTY reader threads can use to send wakeup pings.
153
    pub fn pty_wakeup_tx(&self) -> Sender<UnifiedEvent> {
×
154
        self.tx.clone()
×
155
    }
×
156

157
    /// Drain all pending events from the channel (non-blocking) into internal
158
    /// state.  Called at the start of each event-loop iteration so PtyWakeup
159
    /// floods don't cause render-backlog.
160
    ///
161
    /// Input events are moved into `input_buffer` so none are lost during
162
    /// bursts (paste, key repeat).  `poll()` checks the buffer first.
163
    fn drain_pending(&mut self) {
6✔
164
        loop {
165
            match self.rx.try_recv() {
27✔
166
                Ok(UnifiedEvent::Input(event)) => {
17✔
167
                    if let Some(normalized) = self.normalizer.normalize(event) {
17✔
168
                        self.input_buffer.push_back(normalized);
13✔
169
                    }
13✔
170
                }
171
                Ok(UnifiedEvent::PtyWakeup(key)) => {
4✔
172
                    self.dirty_windows.insert(key);
4✔
173
                }
4✔
174
                Ok(UnifiedEvent::AppExited(key)) => {
×
175
                    self.exited_windows.push(key);
×
176
                }
×
177
                Ok(UnifiedEvent::DirectInputChanged(key, enabled)) => {
×
178
                    tracing::info!(
×
179
                        "[STAGE 3] drain_pending collected DirectInputChanged({:?}, {})",
180
                        key,
181
                        enabled
182
                    );
183
                    self.dirty_windows.insert(key);
×
184
                    self.direct_input_changed.push((key, enabled));
×
185
                }
186
                Ok(UnifiedEvent::Signal) => {
×
187
                    self.signal_received = true;
×
188
                }
×
189
                Ok(UnifiedEvent::Tick) => {
×
190
                    // No-op — tick is implicit in the event-cycle loop.
×
191
                }
×
192
                Err(crossbeam_channel::TryRecvError::Empty) => break,
6✔
193
                Err(crossbeam_channel::TryRecvError::Disconnected) => break,
×
194
            }
195
        }
196
    }
6✔
197

198
    /// Check if a signal was received and ack it.
199
    pub fn take_signal(&mut self) -> bool {
×
200
        let sig = self.signal_received;
×
201
        self.signal_received = false;
×
202
        sig
×
203
    }
×
204
}
205

206
/// Translate a crossterm event to a core-owned event.
207
fn translate_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
1✔
208
    match evt {
1✔
209
        crossterm::event::Event::Key(key) => {
1✔
210
            term_wm_crossterm_adapter::try_translate_key_event(key).map(Event::Key)
1✔
211
        }
212
        crossterm::event::Event::Mouse(mouse) => Some(Event::Mouse(
×
213
            term_wm_crossterm_adapter::translate_mouse_event(mouse),
×
214
        )),
×
215
        crossterm::event::Event::Resize(w, h) => Some(Event::Resize(w, h)),
×
216
        crossterm::event::Event::FocusGained => Some(Event::FocusGained),
×
217
        crossterm::event::Event::FocusLost => Some(Event::FocusLost),
×
218
        crossterm::event::Event::Paste(text) => Some(Event::Paste(text)),
×
219
    }
220
}
1✔
221

222
impl EventSource for UnifiedEventSource {
223
    fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
4✔
224
        // First drain any pending events non-blocking.
225
        self.drain_pending();
4✔
226

227
        if self.pending_event.is_some() || !self.input_buffer.is_empty() {
4✔
228
            return Ok(true);
1✔
229
        }
3✔
230

231
        // If drain_pending found dirty windows, arm the frame deadline.
232
        // This prevents a 3600s freeze when a PtyWakeup arrives between
233
        // handler(None) and drain_pending (common under heavy streaming):
234
        // the PtyWakeup is consumed by drain_pending but no frame
235
        // deadline is set, so recv_timeout would block for the full
236
        // PowerSaver interval.
237
        if !self.dirty_windows.is_empty() {
3✔
238
            self.frame_pacer.notify_pending(Instant::now());
2✔
239
        }
2✔
240

241
        // Clamp remaining to the frame deadline so we never block
242
        // longer than 16ms when there are unprocessed dirty windows.
243
        if self.frame_pacer.try_expire(Instant::now()) {
3✔
244
            return Ok(false);
×
245
        }
3✔
246
        let mut remaining = timeout;
3✔
247
        if let Some(t) = self.frame_pacer.time_until_deadline(Instant::now()) {
3✔
248
            remaining = remaining.min(t);
2✔
249
        }
2✔
250

251
        while remaining > Duration::ZERO {
3✔
252
            // Check frame deadline before each blocking call.
253
            if self.frame_pacer.try_expire(Instant::now()) {
2✔
254
                return Ok(false);
×
255
            }
2✔
256
            if let Some(t) = self.frame_pacer.time_until_deadline(Instant::now()) {
2✔
257
                remaining = remaining.min(t);
1✔
258
                if remaining <= Duration::ZERO {
1✔
259
                    self.frame_pacer.reset();
×
260
                    return Ok(false);
×
261
                }
1✔
262
            }
1✔
263

264
            match self.rx.recv_timeout(remaining) {
2✔
265
                Ok(UnifiedEvent::Input(event)) => {
×
266
                    self.last_event_at = Some(Instant::now());
×
267
                    if let Some(normalized) = self.normalizer.normalize(event) {
×
268
                        self.frame_pacer.reset();
×
269
                        self.pending_event = Some(normalized);
×
270
                        return Ok(true);
×
271
                    }
×
272
                    // Event filtered out — update remaining and continue.
273
                    if let Some(t) = self.frame_pacer.time_until_deadline(Instant::now()) {
×
274
                        remaining = remaining.min(t);
×
275
                    }
×
276
                    continue;
×
277
                }
278
                Ok(UnifiedEvent::PtyWakeup(key)) => {
×
279
                    self.dirty_windows.insert(key);
×
280
                    self.frame_pacer.notify_pending(Instant::now());
×
281
                    if self.frame_pacer.try_expire(Instant::now()) {
×
282
                        return Ok(false);
×
283
                    }
×
284
                    if let Some(t) = self.frame_pacer.time_until_deadline(Instant::now()) {
×
285
                        remaining = remaining.min(t);
×
286
                    }
×
287
                    continue;
×
288
                }
289
                Ok(UnifiedEvent::AppExited(key)) => {
×
290
                    self.exited_windows.push(key);
×
291
                    self.frame_pacer.notify_pending(Instant::now());
×
292
                    continue;
×
293
                }
294
                Ok(UnifiedEvent::DirectInputChanged(key, enabled)) => {
×
295
                    tracing::info!(
×
296
                        "[STAGE 3] recv_timeout collected DirectInputChanged({:?}, {})",
297
                        key,
298
                        enabled
299
                    );
300
                    self.dirty_windows.insert(key);
×
301
                    self.direct_input_changed.push((key, enabled));
×
302
                    self.frame_pacer.notify_pending(Instant::now());
×
303
                    return Ok(false);
×
304
                }
305
                Ok(UnifiedEvent::Signal) => {
306
                    self.signal_received = true;
×
307
                    return Ok(false);
×
308
                }
309
                Ok(UnifiedEvent::Tick) => {
310
                    return Ok(false);
×
311
                }
312
                Err(_) => {
313
                    // Check if the frame deadline expired during the wait.
314
                    if self.frame_pacer.try_expire(Instant::now()) {
2✔
315
                        return Ok(false);
1✔
316
                    }
1✔
317
                    break;
1✔
318
                }
319
            }
320
        }
321

322
        self.frame_pacer.reset();
2✔
323
        Ok(false)
2✔
324
    }
4✔
325

326
    fn read(&mut self) -> io::Result<Event> {
10✔
327
        // Check pending_event first (set by poll()), then drain input_buffer.
328
        if let Some(event) = self.pending_event.take()
10✔
329
            && let Some(normalized) = self.normalizer.normalize(event)
×
330
        {
331
            return Ok(normalized);
×
332
        }
10✔
333
        // Fallback: check buffer, then block on the channel.
334
        loop {
335
            if let Some(event) = self.input_buffer.pop_front() {
10✔
336
                self.last_event_at = Some(Instant::now());
10✔
337
                if let Some(normalized) = self.normalizer.normalize(event) {
10✔
338
                    return Ok(normalized);
10✔
339
                }
×
340
                continue;
×
341
            }
×
342
            match self.rx.recv() {
×
343
                Ok(UnifiedEvent::Input(event)) => {
×
344
                    self.last_event_at = Some(Instant::now());
×
345
                    if let Some(normalized) = self.normalizer.normalize(event) {
×
346
                        return Ok(normalized);
×
347
                    }
×
348
                }
349
                Ok(UnifiedEvent::PtyWakeup(key)) => {
×
350
                    self.dirty_windows.insert(key);
×
351
                }
×
352
                Ok(UnifiedEvent::AppExited(key)) => {
×
353
                    self.exited_windows.push(key);
×
354
                }
×
355
                Ok(UnifiedEvent::DirectInputChanged(key, enabled)) => {
×
356
                    tracing::info!(
×
357
                        "[STAGE 3] drain_pending collected DirectInputChanged({:?}, {})",
358
                        key,
359
                        enabled
360
                    );
361
                    self.dirty_windows.insert(key);
×
362
                    self.direct_input_changed.push((key, enabled));
×
363
                }
364
                Ok(UnifiedEvent::Signal) => {
×
365
                    self.signal_received = true;
×
366
                }
×
367
                Ok(UnifiedEvent::Tick) => {}
×
368
                Err(_) => {
369
                    return Err(io::Error::new(
×
370
                        io::ErrorKind::BrokenPipe,
×
371
                        "event channel disconnected",
×
372
                    ));
×
373
                }
374
            }
375
        }
376
    }
10✔
377

378
    fn next_key(&mut self) -> io::Result<KeyEvent> {
×
379
        loop {
380
            self.drain_pending();
×
381
            if let Some(Event::Key(key)) = self.pending_event.take() {
×
382
                return Ok(key);
×
383
            }
×
384
            match self.rx.recv() {
×
385
                Ok(UnifiedEvent::Input(Event::Key(key))) => return Ok(key),
×
386
                Ok(UnifiedEvent::Input(event)) => {
×
387
                    self.pending_event = Some(event);
×
388
                }
×
389
                Ok(UnifiedEvent::PtyWakeup(_)) => {}
×
390
                Ok(UnifiedEvent::AppExited(key)) => {
×
391
                    self.exited_windows.push(key);
×
392
                }
×
393
                Ok(UnifiedEvent::DirectInputChanged(key, enabled)) => {
×
394
                    self.dirty_windows.insert(key);
×
395
                    self.direct_input_changed.push((key, enabled));
×
396
                    continue;
×
397
                }
398
                Ok(UnifiedEvent::Signal) => self.signal_received = true,
×
399
                Ok(UnifiedEvent::Tick) => {}
×
400
                Err(_) => {
401
                    return Err(io::Error::new(
×
402
                        io::ErrorKind::BrokenPipe,
×
403
                        "event channel disconnected",
×
404
                    ));
×
405
                }
406
            }
407
        }
408
    }
×
409

410
    fn next_mouse(&mut self) -> io::Result<MouseEvent> {
×
411
        loop {
412
            self.drain_pending();
×
413
            if let Some(Event::Mouse(mouse)) = self.pending_event.take() {
×
414
                return Ok(mouse);
×
415
            }
×
416
            match self.rx.recv() {
×
417
                Ok(UnifiedEvent::Input(Event::Mouse(mouse))) => return Ok(mouse),
×
418
                Ok(UnifiedEvent::Input(event)) => {
×
419
                    self.pending_event = Some(event);
×
420
                }
×
421
                Ok(UnifiedEvent::PtyWakeup(_)) => {}
×
422
                Ok(UnifiedEvent::AppExited(key)) => {
×
423
                    self.exited_windows.push(key);
×
424
                }
×
425
                Ok(UnifiedEvent::DirectInputChanged(key, enabled)) => {
×
426
                    self.dirty_windows.insert(key);
×
427
                    self.direct_input_changed.push((key, enabled));
×
428
                    continue;
×
429
                }
430
                Ok(UnifiedEvent::Signal) => self.signal_received = true,
×
431
                Ok(UnifiedEvent::Tick) => {}
×
432
                Err(_) => {
433
                    return Err(io::Error::new(
×
434
                        io::ErrorKind::BrokenPipe,
×
435
                        "event channel disconnected",
×
436
                    ));
×
437
                }
438
            }
439
        }
440
    }
×
441

442
    fn set_mouse_capture(&mut self, enabled: bool) -> io::Result<()> {
×
443
        if enabled {
×
444
            crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)
×
445
        } else {
446
            crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture)
×
447
        }
448
    }
×
449

450
    /// Called by the runner each cycle to signal whether there's pending
451
    /// work (e.g. a countdown timer).  When true the profile stays at
452
    /// Streaming even if both `dirty_windows` and `last_event_at` are stale.
453
    fn set_pending_work(&mut self, pending: bool) {
×
454
        self.pending_work = pending;
×
455
    }
×
456

457
    fn set_max_sleep_duration(&mut self, duration: Option<Duration>) {
2✔
458
        self.max_sleep_duration = duration;
2✔
459
    }
2✔
460

461
    fn poll_interval(&self) -> Duration {
3✔
462
        let base = self.current_profile().poll_interval();
3✔
463
        match self.max_sleep_duration {
3✔
464
            Some(max_sleep) => base.min(max_sleep),
1✔
465
            None => {
466
                #[cfg(target_os = "windows")]
467
                {
468
                    // Windows ConPTY requires regular event loop ticks to prevent
469
                    // background PTY reader threads from stalling. When no explicit
470
                    // cap is set, clamp to WINDOWS_MAX_POLL_INTERVAL maximum.
471
                    base.min(term_wm_core::power_profile::WINDOWS_MAX_POLL_INTERVAL)
472
                }
473
                #[cfg(not(target_os = "windows"))]
474
                {
475
                    base
2✔
476
                }
477
            }
478
        }
479
    }
3✔
480

481
    fn current_profile(&self) -> PowerProfile {
8✔
482
        crate::power_profile::profile_from_activity(
8✔
483
            self.last_event_at,
8✔
484
            !self.dirty_windows.is_empty() || self.pending_work,
8✔
485
        )
486
    }
8✔
487

488
    fn take_exited_windows(&mut self) -> Vec<WindowKey> {
2✔
489
        std::mem::take(&mut self.exited_windows)
2✔
490
    }
2✔
491

492
    fn take_direct_input_changed(&mut self) -> Vec<(WindowKey, bool)> {
×
493
        std::mem::take(&mut self.direct_input_changed)
×
494
    }
×
495

496
    fn take_dirty_windows(&mut self) -> HashSet<WindowKey> {
3✔
497
        std::mem::take(&mut self.dirty_windows)
3✔
498
    }
3✔
499

500
    fn request_redraw(&mut self) {
×
501
        self.pending_redraw = true;
×
502
    }
×
503

504
    fn take_redraw_request(&mut self) -> bool {
×
505
        std::mem::replace(&mut self.pending_redraw, false)
×
506
    }
×
507
}
508

509
impl Drop for UnifiedEventSource {
510
    fn drop(&mut self) {
10✔
511
        self.shutdown.store(true, Ordering::Release);
10✔
512
    }
10✔
513
}
514

515
#[cfg(test)]
516
mod tests {
517
    use super::*;
518
    use crate::events::{KeyCode, KeyKind, KeyModifiers};
519

520
    #[test]
521
    fn test_translate_crossterm_event_drops_unsupported_keys() {
1✔
522
        let caps_lock = crossterm::event::Event::Key(crossterm::event::KeyEvent {
1✔
523
            code: crossterm::event::KeyCode::CapsLock,
1✔
524
            modifiers: crossterm::event::KeyModifiers::NONE,
1✔
525
            kind: crossterm::event::KeyEventKind::Press,
1✔
526
            state: crossterm::event::KeyEventState::NONE,
1✔
527
        });
1✔
528
        assert!(translate_crossterm_event(caps_lock).is_none());
1✔
529
    }
1✔
530

531
    /// Input events drained by `drain_pending` must be preserved in
532
    /// `input_buffer` so `poll()/read()` can process every event.
533
    #[test]
534
    fn drain_pending_preserves_all_input_events() {
1✔
535
        let (tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
536
        let mut source = UnifiedEventSource {
1✔
537
            rx,
1✔
538
            tx: tx.clone(),
1✔
539
            _input_handle: std::thread::spawn(|| {}),
1✔
540
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
541
            dirty_windows: HashSet::new(),
1✔
542
            exited_windows: Vec::new(),
1✔
543
            direct_input_changed: Vec::new(),
1✔
544
            pending_redraw: false,
545
            pending_event: None,
1✔
546
            input_buffer: VecDeque::new(),
1✔
547
            signal_received: false,
548
            normalizer: KeyboardNormalizer::new(),
1✔
549
            last_event_at: None,
1✔
550
            frame_pacer: FramePacer::new(),
1✔
551
            pending_work: false,
552
            max_sleep_duration: None,
1✔
553
        };
554
        // Prevent the no-op handle from panicking on join in drop
555
        let dummy_handle = std::thread::spawn(|| {});
1✔
556
        source._input_handle = dummy_handle;
1✔
557

558
        // Send 10 input events into the channel
559
        for i in 0..10u8 {
10✔
560
            let evt = Event::Key(KeyEvent {
10✔
561
                code: KeyCode::Char(char::from(b'a' + i)),
10✔
562
                modifiers: KeyModifiers::NONE,
10✔
563
                kind: KeyKind::Press,
10✔
564
            });
10✔
565
            tx.send(UnifiedEvent::Input(evt)).unwrap();
10✔
566
        }
10✔
567
        // Also mix in some PtyWakeups (the reason drain_pending exists)
568
        for _ in 0..3 {
3✔
569
            tx.send(UnifiedEvent::PtyWakeup(WindowKey::default()))
3✔
570
                .unwrap();
3✔
571
        }
3✔
572

573
        // drain_pending must move all Input events into input_buffer
574
        source.drain_pending();
1✔
575

576
        assert_eq!(
1✔
577
            source.input_buffer.len(),
1✔
578
            10,
579
            "all 10 input events must be buffered, not dropped"
580
        );
581

582
        // verify ordering is preserved
583
        for (i, evt) in source.input_buffer.iter().enumerate() {
10✔
584
            let expected = char::from(b'a' + i as u8);
10✔
585
            match evt {
10✔
586
                Event::Key(k) => {
10✔
587
                    assert_eq!(
10✔
588
                        k.code,
589
                        KeyCode::Char(expected),
10✔
590
                        "event {} should be '{}'",
591
                        i,
592
                        expected
593
                    );
594
                }
595
                _ => panic!("expected Key event at position {}", i),
×
596
            }
597
        }
598

599
        // poll should report events available from buffer
600
        assert!(
1✔
601
            source.poll(Duration::ZERO).unwrap(),
1✔
602
            "poll must return true when buffer is non-empty"
603
        );
604

605
        // read should drain buffer in order
606
        for i in 0..10u8 {
10✔
607
            let evt = source.read().unwrap();
10✔
608
            let expected = char::from(b'a' + i);
10✔
609
            match evt {
10✔
610
                Event::Key(k) => assert_eq!(k.code, KeyCode::Char(expected)),
10✔
611
                _ => panic!("expected Key event"),
×
612
            }
613
        }
614

615
        // buffer should now be empty
616
        assert!(source.input_buffer.is_empty());
1✔
617
        assert!(
1✔
618
            !source.poll(Duration::ZERO).unwrap(),
1✔
619
            "poll must return false after buffer drained"
620
        );
621
    }
1✔
622

623
    /// `drain_pending` must filter out Release events through the
624
    /// normalizer, keeping Press and Repeat events in the buffer.
625
    #[test]
626
    fn drain_pending_filters_release_events() {
1✔
627
        let (tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
628
        let mut source = UnifiedEventSource {
1✔
629
            rx,
1✔
630
            tx: tx.clone(),
1✔
631
            _input_handle: std::thread::spawn(|| {}),
1✔
632
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
633
            dirty_windows: HashSet::new(),
1✔
634
            exited_windows: Vec::new(),
1✔
635
            direct_input_changed: Vec::new(),
1✔
636
            pending_redraw: false,
637
            pending_event: None,
1✔
638
            input_buffer: VecDeque::new(),
1✔
639
            signal_received: false,
640
            normalizer: KeyboardNormalizer::new(),
1✔
641
            last_event_at: None,
1✔
642
            frame_pacer: FramePacer::new(),
1✔
643
            pending_work: false,
644
            max_sleep_duration: None,
1✔
645
        };
646
        source._input_handle = std::thread::spawn(|| {});
1✔
647

648
        // Send: Press, Release, Repeat, Press
649
        // On non-Windows: Release filtered, 3 survive (Press, Repeat, Press)
650
        // On Windows: Release + Repeat filtered, 2 survive (Press, Press)
651
        for (i, kind) in [
4✔
652
            KeyKind::Press,
1✔
653
            KeyKind::Release,
1✔
654
            KeyKind::Repeat,
1✔
655
            KeyKind::Press,
1✔
656
        ]
1✔
657
        .into_iter()
1✔
658
        .enumerate()
1✔
659
        {
4✔
660
            let evt = Event::Key(KeyEvent {
4✔
661
                code: KeyCode::Char(char::from(b'a' + i as u8)),
4✔
662
                modifiers: KeyModifiers::NONE,
4✔
663
                kind,
4✔
664
            });
4✔
665
            tx.send(UnifiedEvent::Input(evt)).unwrap();
4✔
666
        }
4✔
667

668
        source.drain_pending();
1✔
669

670
        assert_eq!(
1✔
671
            source.input_buffer.len(),
1✔
672
            3,
673
            "Only Release events should be filtered by normalization"
674
        );
675

676
        // Verify Release event (index 1) is absent
677
        for evt in &source.input_buffer {
3✔
678
            if let Event::Key(k) = evt {
3✔
679
                assert_ne!(
3✔
680
                    k.kind,
681
                    KeyKind::Release,
682
                    "Release events must never survive normalization"
683
                );
684
            }
×
685
        }
686

687
        // Verify first and last events are the Press events
688
        let first = source.input_buffer.front().unwrap();
1✔
689
        let last = source.input_buffer.back().unwrap();
1✔
690
        if let (Event::Key(k1), Event::Key(k2)) = (first, last) {
1✔
691
            assert_eq!(k1.kind, KeyKind::Press);
1✔
692
            assert_eq!(k1.code, KeyCode::Char('a'));
1✔
693
            assert_eq!(k2.kind, KeyKind::Press);
1✔
694
            assert_eq!(k2.code, KeyCode::Char('d'));
1✔
695
        } else {
696
            panic!("expected Key events");
×
697
        }
698
    }
1✔
699

700
    /// `poll` must skip Release events that arrive via recv_timeout,
701
    /// continuing to wait until a valid event arrives or the deadline expires.
702
    #[test]
703
    fn poll_filters_release_events() {
1✔
704
        let (tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
705
        let mut source = UnifiedEventSource {
1✔
706
            rx,
1✔
707
            tx: tx.clone(),
1✔
708
            _input_handle: std::thread::spawn(|| {}),
1✔
709
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
710
            dirty_windows: HashSet::new(),
1✔
711
            exited_windows: Vec::new(),
1✔
712
            direct_input_changed: Vec::new(),
1✔
713
            pending_redraw: false,
714
            pending_event: None,
1✔
715
            input_buffer: VecDeque::new(),
1✔
716
            signal_received: false,
717
            normalizer: KeyboardNormalizer::new(),
1✔
718
            last_event_at: None,
1✔
719
            frame_pacer: FramePacer::new(),
1✔
720
            pending_work: false,
721
            max_sleep_duration: None,
1✔
722
        };
723
        source._input_handle = std::thread::spawn(|| {});
1✔
724

725
        // Send only Release events (filtered on all platforms)
726
        for _ in 0..3 {
3✔
727
            let evt = Event::Key(KeyEvent {
3✔
728
                code: KeyCode::Char('x'),
3✔
729
                modifiers: KeyModifiers::NONE,
3✔
730
                kind: KeyKind::Release,
3✔
731
            });
3✔
732
            tx.send(UnifiedEvent::Input(evt)).unwrap();
3✔
733
        }
3✔
734

735
        // poll with a short timeout — should consume the filtered events
736
        // and return Ok(false) since no valid event is available
737
        let result = source.poll(Duration::from_millis(50));
1✔
738
        assert!(
1✔
739
            !result.unwrap() || source.pending_event.is_none(),
1✔
740
            "poll must not set pending_event for filtered Release events"
741
        );
742
    }
1✔
743

744
    /// Dirty windows must be cleared after `poll()` returns `Ok(false)`,
745
    /// otherwise the power profile stays at `Streaming` (16ms) forever.
746
    #[test]
747
    fn dirty_windows_cleared_after_poll_ok_false() {
1✔
748
        let (tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
749
        let mut source = UnifiedEventSource {
1✔
750
            rx,
1✔
751
            tx: tx.clone(),
1✔
752
            _input_handle: std::thread::spawn(|| {}),
1✔
753
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
754
            dirty_windows: HashSet::new(),
1✔
755
            exited_windows: Vec::new(),
1✔
756
            direct_input_changed: Vec::new(),
1✔
757
            pending_redraw: false,
758
            pending_event: None,
1✔
759
            input_buffer: VecDeque::new(),
1✔
760
            signal_received: false,
761
            normalizer: KeyboardNormalizer::new(),
1✔
762
            last_event_at: None,
1✔
763
            frame_pacer: FramePacer::new(),
1✔
764
            pending_work: false,
765
            max_sleep_duration: None,
1✔
766
        };
767
        // Prevent the no-op handle from panicking on drop
768
        let dummy_handle = std::thread::spawn(|| {});
1✔
769
        source._input_handle = dummy_handle;
1✔
770

771
        // Baseline: no input, no dirty → PowerSaver
772
        assert_eq!(source.current_profile(), PowerProfile::PowerSaver);
1✔
773

774
        // Send a PtyWakeup — drain_pending will pick it up inside poll()
775
        tx.send(UnifiedEvent::PtyWakeup(WindowKey::default()))
1✔
776
            .unwrap();
1✔
777

778
        // poll() should drain the PtyWakeup, arm the 16ms frame pacer, then
779
        // let it expire and return Ok(false) with dirty_windows still set.
780
        assert!(
1✔
781
            !source.poll(Duration::from_secs(1)).unwrap(),
1✔
782
            "poll must return Ok(false) after PtyWakeup expiry"
783
        );
784

785
        // After poll returns, dirty_windows must contain the key
786
        // (coalesce arms the timer but does NOT clear dirty_windows on expiry).
787
        assert!(
1✔
788
            !source.take_dirty_windows().is_empty(),
1✔
789
            "dirty_windows must still contain the key after poll"
790
        );
791

792
        // After taking the dirty windows, profile returns to PowerSaver
793
        // (no input activity, no dirty windows).
794
        assert_eq!(
1✔
795
            source.current_profile(),
1✔
796
            PowerProfile::PowerSaver,
797
            "profile must return to PowerSaver after dirty_windows consumed"
798
        );
799
    }
1✔
800

801
    /// Verify that a non-empty dirty_windows causes Streaming profile,
802
    /// confirming the mechanism the bug fix relies on.
803
    #[test]
804
    fn dirty_windows_causes_streaming_profile() {
1✔
805
        let (_tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
806
        let mut set = HashSet::new();
1✔
807
        set.insert(WindowKey::default());
1✔
808
        let source = UnifiedEventSource {
1✔
809
            rx,
1✔
810
            tx: _tx,
1✔
811
            _input_handle: std::thread::spawn(|| {}),
1✔
812
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
813
            dirty_windows: set,
1✔
814
            exited_windows: Vec::new(),
1✔
815
            direct_input_changed: Vec::new(),
1✔
816
            pending_redraw: false,
817
            pending_event: None,
1✔
818
            input_buffer: VecDeque::new(),
1✔
819
            signal_received: false,
820
            normalizer: KeyboardNormalizer::new(),
1✔
821
            last_event_at: None,
1✔
822
            frame_pacer: FramePacer::new(),
1✔
823
            pending_work: false,
824
            max_sleep_duration: None,
1✔
825
        };
826
        assert_eq!(
1✔
827
            source.current_profile(),
1✔
828
            PowerProfile::Streaming,
829
            "dirty_windows must elevate profile to Streaming"
830
        );
831
    }
1✔
832

833
    #[test]
834
    fn pending_work_causes_streaming_profile() {
1✔
835
        let (tx1, rx1) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
836
        let source = UnifiedEventSource {
1✔
837
            rx: rx1,
1✔
838
            tx: tx1,
1✔
839
            _input_handle: std::thread::spawn(|| {}),
1✔
840
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
841
            dirty_windows: HashSet::new(),
1✔
842
            exited_windows: Vec::new(),
1✔
843
            direct_input_changed: Vec::new(),
1✔
844
            pending_redraw: false,
845
            pending_event: None,
1✔
846
            input_buffer: VecDeque::new(),
1✔
847
            signal_received: false,
848
            normalizer: KeyboardNormalizer::new(),
1✔
849
            last_event_at: None,
1✔
850
            frame_pacer: FramePacer::new(),
1✔
851
            pending_work: true,
852
            max_sleep_duration: None,
1✔
853
        };
854
        assert_eq!(
1✔
855
            source.current_profile(),
1✔
856
            PowerProfile::Streaming,
857
            "pending_work must elevate profile to Streaming even without dirty_windows"
858
        );
859
        // Also verify that stale last_event_at + pending_work still gives Streaming
860
        let (tx2, rx2) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
861
        let stale = Instant::now().checked_sub(Duration::from_secs(3600));
1✔
862
        let source2 = UnifiedEventSource {
1✔
863
            rx: rx2,
1✔
864
            tx: tx2,
1✔
865
            _input_handle: std::thread::spawn(|| {}),
1✔
866
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
867
            dirty_windows: HashSet::new(),
1✔
868
            exited_windows: Vec::new(),
1✔
869
            direct_input_changed: Vec::new(),
1✔
870
            pending_redraw: false,
871
            pending_event: None,
1✔
872
            input_buffer: VecDeque::new(),
1✔
873
            signal_received: false,
874
            normalizer: KeyboardNormalizer::new(),
1✔
875
            last_event_at: stale,
1✔
876
            frame_pacer: FramePacer::new(),
1✔
877
            pending_work: true,
878
            max_sleep_duration: None,
1✔
879
        };
880
        assert_eq!(
1✔
881
            source2.current_profile(),
1✔
882
            PowerProfile::Streaming,
883
            "pending_work must keep Streaming even with stale last_event_at"
884
        );
885
    }
1✔
886

887
    /// Regression: `take_exited_windows` must be reachable through the
888
    /// `EventSource` trait so that generic runner code (`D: EventSource`)
889
    /// actually gets the accumulated exit keys. Before the fix the method
890
    /// was only inherent — the trait override was missing, and the default
891
    /// no-op impl silently returned an empty vec, so exited windows never
892
    /// closed.
893
    #[test]
894
    fn take_exited_windows_returns_accumulated_keys_through_trait() {
1✔
895
        use super::EventSource;
896
        let (_tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
897
        let key = WindowKey::default();
1✔
898
        let mut source = UnifiedEventSource {
1✔
899
            rx,
1✔
900
            tx: _tx,
1✔
901
            _input_handle: std::thread::spawn(|| {}),
1✔
902
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
903
            dirty_windows: HashSet::new(),
1✔
904
            exited_windows: vec![key],
1✔
905
            direct_input_changed: Vec::new(),
1✔
906
            pending_redraw: false,
907
            pending_event: None,
1✔
908
            input_buffer: VecDeque::new(),
1✔
909
            signal_received: false,
910
            normalizer: KeyboardNormalizer::new(),
1✔
911
            last_event_at: None,
1✔
912
            frame_pacer: FramePacer::new(),
1✔
913
            pending_work: false,
914
            max_sleep_duration: None,
1✔
915
        };
916
        let dummy_handle = std::thread::spawn(|| {});
1✔
917
        source._input_handle = dummy_handle;
1✔
918

919
        // Call through the trait, not an inherent method. Would return
920
        // Vec::new() if the trait override were missing.
921
        let exited = EventSource::take_exited_windows(&mut source);
1✔
922
        assert_eq!(exited, vec![key], "must return the pre-populated key");
1✔
923

924
        let again = EventSource::take_exited_windows(&mut source);
1✔
925
        assert!(again.is_empty(), "second call must drain");
1✔
926
    }
1✔
927

928
    /// Regression: `take_dirty_windows` must be reachable through the
929
    /// `EventSource` trait so that generic runner code (`D: EventSource`)
930
    /// actually consumes accumulated dirty keys.  Without the trait
931
    /// override the default no-op impl would silently return an empty
932
    /// set, leaving dirty_windows accumulated forever.
933
    #[test]
934
    fn take_dirty_windows_returns_accumulated_keys_through_trait() {
1✔
935
        use super::EventSource;
936
        let (_tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
937
        let key = WindowKey::default();
1✔
938
        let mut set = HashSet::new();
1✔
939
        set.insert(key);
1✔
940
        let mut source = UnifiedEventSource {
1✔
941
            rx,
1✔
942
            tx: _tx,
1✔
943
            _input_handle: std::thread::spawn(|| {}),
1✔
944
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
945
            dirty_windows: set,
1✔
946
            exited_windows: Vec::new(),
1✔
947
            direct_input_changed: Vec::new(),
1✔
948
            pending_redraw: false,
949
            pending_event: None,
1✔
950
            input_buffer: VecDeque::new(),
1✔
951
            signal_received: false,
952
            normalizer: KeyboardNormalizer::new(),
1✔
953
            last_event_at: None,
1✔
954
            frame_pacer: FramePacer::new(),
1✔
955
            pending_work: false,
956
            max_sleep_duration: None,
1✔
957
        };
958
        let dummy_handle = std::thread::spawn(|| {});
1✔
959
        source._input_handle = dummy_handle;
1✔
960

961
        // Call through the trait, not an inherent method. Would return
962
        // an empty set if the trait override were missing.
963
        let taken = EventSource::take_dirty_windows(&mut source);
1✔
964
        assert_eq!(taken.len(), 1, "must return the pre-populated key");
1✔
965
        assert!(taken.contains(&key), "must contain the dirty key");
1✔
966

967
        let again = EventSource::take_dirty_windows(&mut source);
1✔
968
        assert!(again.is_empty(), "second call must drain");
1✔
969
    }
1✔
970

971
    #[test]
972
    fn poll_interval_clamped_by_max_sleep_duration() {
1✔
973
        let (_tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
974
        let mut source = UnifiedEventSource {
1✔
975
            rx,
1✔
976
            tx: _tx,
1✔
977
            _input_handle: std::thread::spawn(|| {}),
1✔
978
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
979
            dirty_windows: HashSet::new(),
1✔
980
            exited_windows: Vec::new(),
1✔
981
            direct_input_changed: Vec::new(),
1✔
982
            pending_redraw: false,
983
            pending_event: None,
1✔
984
            input_buffer: VecDeque::new(),
1✔
985
            signal_received: false,
986
            normalizer: KeyboardNormalizer::new(),
1✔
987
            last_event_at: None,
1✔
988
            frame_pacer: FramePacer::new(),
1✔
989
            pending_work: false,
990
            max_sleep_duration: None,
1✔
991
        };
992
        let dummy = std::thread::spawn(|| {});
1✔
993
        source._input_handle = dummy;
1✔
994

995
        #[cfg(not(target_os = "windows"))]
996
        assert_eq!(
1✔
997
            source.poll_interval(),
1✔
998
            PowerProfile::PowerSaver.poll_interval()
1✔
999
        );
1000
        #[cfg(target_os = "windows")]
1001
        assert_eq!(
1002
            source.poll_interval(),
1003
            term_wm_core::power_profile::WINDOWS_MAX_POLL_INTERVAL
1004
        );
1005

1006
        source.set_max_sleep_duration(Some(Duration::from_millis(100)));
1✔
1007
        assert_eq!(source.poll_interval(), Duration::from_millis(100));
1✔
1008

1009
        source.set_max_sleep_duration(None);
1✔
1010
        #[cfg(not(target_os = "windows"))]
1011
        assert_eq!(
1✔
1012
            source.poll_interval(),
1✔
1013
            PowerProfile::PowerSaver.poll_interval()
1✔
1014
        );
1015
        #[cfg(target_os = "windows")]
1016
        assert_eq!(
1017
            source.poll_interval(),
1018
            term_wm_core::power_profile::WINDOWS_MAX_POLL_INTERVAL
1019
        );
1020
    }
1✔
1021
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc