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

jzombie / term-wm / 28972063561

08 Jul 2026 08:05PM UTC coverage: 72.181% (+1.7%) from 70.435%
28972063561

push

github

web-flow
refactor: Decouple rendering backend from core via trait abstraction (#86)

* refactor: Decouple rendering backend from core via trait abstraction

Extract ratatui-specific rendering into two new crates:

- term-wm-render: Defines RenderBackend and RenderTarget traits so
  core and UI components depend only on trait objects, not ratatui
  directly. Enables backend independence and testability.

- term-wm-console: Concrete crossterm/ratatui implementation with
  ConsoleEventSource, ConsoleRenderTarget, DrawPlanRenderer, and
  RatatuiBackend. Moves platform-specific I/O out of core.

Core changes:
- Replace UiFrame<'_> with &mut dyn RenderBackend across all
  Component::render signatures
- Introduce core-owned event types (KeyEvent, MouseEvent, etc.) in
  term_wm_core::events, replacing direct crossterm dependencies
- Add CoreEngine and draw_plan module for draw plan generation
- Move unified_event_source from term-wm-core to root crate
- Update all component tests to use new rendering/event APIs

The ratatui dependency is now confined to term-wm-console and
ui-components, while core compiles without any terminal framework.

* Rename internal event loop launcher

* Improve main app init

3047 of 4928 new or added lines in 59 files covered. (61.83%)

46 existing lines in 19 files now uncovered.

18308 of 25364 relevant lines covered (72.18%)

156.78 hits per line

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

46.74
/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::{
13
    Event, KeyCode, KeyEvent, KeyKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
14
};
15
use term_wm_core::io::EventSource;
16
use term_wm_core::io::frame_pacer::FramePacer;
17
use term_wm_core::power_profile::PowerProfile;
18
use term_wm_core::utils::KeyboardNormalizer;
19
use term_wm_core::window::WindowKey;
20

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

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

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

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

78
impl UnifiedEventSource {
79
    /// Create a new unified event source, spawning a background thread
80
    /// that reads crossterm events.
81
    ///
82
    /// The bounded channel (256 slots) provides mechanical backpressure:
83
    /// when the channel is full, PTY reader threads block on `send()` →
84
    /// OS pipe buffer fills → child process `write()` blocks → prevents
85
    /// memory exhaustion under extreme output load.
86
    pub fn new() -> io::Result<Self> {
×
87
        let (tx, rx) = bounded::<UnifiedEvent>(EVENT_CHANNEL_CAPACITY);
×
88
        let shutdown = Arc::new(AtomicBool::new(false));
×
89
        let input_tx = tx.clone();
×
90
        let input_shutdown = Arc::clone(&shutdown);
×
91

92
        let _input_handle = thread::Builder::new()
×
93
            .name("crossterm-input".into())
×
94
            .spawn(move || {
×
95
                // Loop: poll crossterm with 100ms timeout, check shutdown flag.
96
                loop {
97
                    if input_shutdown.load(Ordering::Acquire) {
×
98
                        break;
×
99
                    }
×
100
                    // Blocking poll with short timeout so we can check shutdown.
101
                    if crossterm::event::poll(INPUT_THREAD_POLL_INTERVAL).unwrap_or(false) {
×
102
                        match crossterm::event::read() {
×
NEW
103
                            Ok(crossterm_event) => {
×
104
                                // Translate crossterm event to core-owned event
NEW
105
                                if let Some(core_event) = translate_crossterm_event(crossterm_event)
×
NEW
106
                                    && input_tx.send(UnifiedEvent::Input(core_event)).is_err()
×
107
                                {
108
                                    break; // receiver dropped
×
109
                                }
×
110
                            }
111
                            Err(_) => break,
×
112
                        }
113
                    }
×
114
                }
115
            })
×
116
            .map_err(|e| io::Error::other(e.to_string()))?;
×
117

118
        Ok(Self {
×
119
            rx,
×
120
            tx,
×
121
            _input_handle,
×
122
            shutdown,
×
123
            dirty_windows: HashSet::new(),
×
124
            exited_windows: Vec::new(),
×
125
            pending_event: None,
×
126
            input_buffer: VecDeque::new(),
×
127
            signal_received: false,
×
128
            normalizer: KeyboardNormalizer::new(),
×
129
            last_event_at: None,
×
130
            frame_pacer: FramePacer::new(),
×
131
            pending_work: false,
×
132
        })
×
133
    }
×
134

135
    /// Return a sender that PTY reader threads can use to send wakeup pings.
136
    pub fn pty_wakeup_tx(&self) -> Sender<UnifiedEvent> {
×
137
        self.tx.clone()
×
138
    }
×
139

140
    /// Drain all pending events from the channel (non-blocking) into internal
141
    /// state.  Called at the start of each event-loop iteration so PtyWakeup
142
    /// floods don't cause render-backlog.
143
    ///
144
    /// Input events are moved into `input_buffer` so none are lost during
145
    /// bursts (paste, key repeat).  `poll()` checks the buffer first.
146
    fn drain_pending(&mut self) {
4✔
147
        loop {
148
            match self.rx.try_recv() {
18✔
149
                Ok(UnifiedEvent::Input(event)) => {
10✔
150
                    self.input_buffer.push_back(event);
10✔
151
                }
10✔
152
                Ok(UnifiedEvent::PtyWakeup(key)) => {
4✔
153
                    self.dirty_windows.insert(key);
4✔
154
                }
4✔
155
                Ok(UnifiedEvent::AppExited(key)) => {
×
156
                    self.exited_windows.push(key);
×
157
                }
×
158
                Ok(UnifiedEvent::Signal) => {
×
159
                    self.signal_received = true;
×
160
                }
×
161
                Ok(UnifiedEvent::Tick) => {
×
162
                    // No-op — tick is implicit in the event-cycle loop.
×
163
                }
×
164
                Err(crossbeam_channel::TryRecvError::Empty) => break,
4✔
165
                Err(crossbeam_channel::TryRecvError::Disconnected) => break,
×
166
            }
167
        }
168
    }
4✔
169

170
    /// Check if a signal was received and ack it.
171
    pub fn take_signal(&mut self) -> bool {
×
172
        let sig = self.signal_received;
×
173
        self.signal_received = false;
×
174
        sig
×
175
    }
×
176

177
    /// Take accumulated dirty windows and reset.
178
    pub fn take_dirty_windows(&mut self) -> HashSet<WindowKey> {
1✔
179
        std::mem::take(&mut self.dirty_windows)
1✔
180
    }
1✔
181
}
182

183
/// Translate a crossterm event to a core-owned event.
NEW
184
fn translate_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
×
NEW
185
    match evt {
×
NEW
186
        crossterm::event::Event::Key(key) => Some(Event::Key(translate_key_event(key))),
×
NEW
187
        crossterm::event::Event::Mouse(mouse) => Some(Event::Mouse(translate_mouse_event(mouse))),
×
NEW
188
        crossterm::event::Event::Resize(w, h) => Some(Event::Resize(w, h)),
×
NEW
189
        crossterm::event::Event::FocusGained => Some(Event::FocusGained),
×
NEW
190
        crossterm::event::Event::FocusLost => Some(Event::FocusLost),
×
NEW
191
        crossterm::event::Event::Paste(text) => Some(Event::Paste(text)),
×
192
    }
NEW
193
}
×
194

NEW
195
fn translate_key_event(key: crossterm::event::KeyEvent) -> KeyEvent {
×
NEW
196
    let mut modifiers = translate_key_modifiers(key.modifiers);
×
197
    // macOS sends BackTab for Shift+Tab — set shift
NEW
198
    if matches!(key.code, crossterm::event::KeyCode::BackTab) {
×
NEW
199
        modifiers.shift = true;
×
NEW
200
    }
×
201
    KeyEvent {
NEW
202
        code: translate_key_code(key.code),
×
NEW
203
        modifiers,
×
NEW
204
        kind: match key.kind {
×
NEW
205
            crossterm::event::KeyEventKind::Press => KeyKind::Press,
×
NEW
206
            crossterm::event::KeyEventKind::Repeat => KeyKind::Repeat,
×
NEW
207
            crossterm::event::KeyEventKind::Release => KeyKind::Release,
×
208
        },
209
    }
NEW
210
}
×
211

NEW
212
fn translate_key_code(code: crossterm::event::KeyCode) -> KeyCode {
×
NEW
213
    match code {
×
NEW
214
        crossterm::event::KeyCode::Char(c) => KeyCode::Char(c),
×
NEW
215
        crossterm::event::KeyCode::Enter => KeyCode::Enter,
×
NEW
216
        crossterm::event::KeyCode::Tab => KeyCode::Tab,
×
NEW
217
        crossterm::event::KeyCode::BackTab => KeyCode::Tab,
×
NEW
218
        crossterm::event::KeyCode::Backspace => KeyCode::Backspace,
×
NEW
219
        crossterm::event::KeyCode::Esc => KeyCode::Esc,
×
NEW
220
        crossterm::event::KeyCode::Left => KeyCode::Left,
×
NEW
221
        crossterm::event::KeyCode::Right => KeyCode::Right,
×
NEW
222
        crossterm::event::KeyCode::Up => KeyCode::Up,
×
NEW
223
        crossterm::event::KeyCode::Down => KeyCode::Down,
×
NEW
224
        crossterm::event::KeyCode::Home => KeyCode::Home,
×
NEW
225
        crossterm::event::KeyCode::End => KeyCode::End,
×
NEW
226
        crossterm::event::KeyCode::PageUp => KeyCode::PageUp,
×
NEW
227
        crossterm::event::KeyCode::PageDown => KeyCode::PageDown,
×
NEW
228
        crossterm::event::KeyCode::Delete => KeyCode::Delete,
×
NEW
229
        crossterm::event::KeyCode::Insert => KeyCode::Insert,
×
NEW
230
        crossterm::event::KeyCode::F(n) => KeyCode::F(n),
×
NEW
231
        _ => KeyCode::Esc, // Fallback for unrecognized keys
×
232
    }
NEW
233
}
×
234

NEW
235
fn translate_key_modifiers(mods: crossterm::event::KeyModifiers) -> KeyModifiers {
×
NEW
236
    KeyModifiers {
×
NEW
237
        shift: mods.contains(crossterm::event::KeyModifiers::SHIFT),
×
NEW
238
        control: mods.contains(crossterm::event::KeyModifiers::CONTROL),
×
NEW
239
        alt: mods.contains(crossterm::event::KeyModifiers::ALT),
×
NEW
240
    }
×
NEW
241
}
×
242

NEW
243
fn translate_mouse_event(mouse: crossterm::event::MouseEvent) -> MouseEvent {
×
244
    MouseEvent {
NEW
245
        kind: match mouse.kind {
×
NEW
246
            crossterm::event::MouseEventKind::Down(btn) => {
×
NEW
247
                MouseEventKind::Press(translate_button(btn))
×
248
            }
NEW
249
            crossterm::event::MouseEventKind::Up(btn) => {
×
NEW
250
                MouseEventKind::Release(translate_button(btn))
×
251
            }
NEW
252
            crossterm::event::MouseEventKind::Drag(btn) => {
×
NEW
253
                MouseEventKind::Drag(translate_button(btn))
×
254
            }
NEW
255
            crossterm::event::MouseEventKind::Moved => MouseEventKind::Moved,
×
NEW
256
            crossterm::event::MouseEventKind::ScrollUp => MouseEventKind::ScrollUp,
×
NEW
257
            crossterm::event::MouseEventKind::ScrollDown => MouseEventKind::ScrollDown,
×
NEW
258
            crossterm::event::MouseEventKind::ScrollLeft => MouseEventKind::ScrollLeft,
×
NEW
259
            crossterm::event::MouseEventKind::ScrollRight => MouseEventKind::ScrollRight,
×
260
        },
NEW
261
        modifiers: translate_key_modifiers(mouse.modifiers),
×
NEW
262
        column: mouse.column,
×
NEW
263
        row: mouse.row,
×
264
    }
NEW
265
}
×
266

NEW
267
fn translate_button(btn: crossterm::event::MouseButton) -> MouseButton {
×
NEW
268
    match btn {
×
NEW
269
        crossterm::event::MouseButton::Left => MouseButton::Left,
×
NEW
270
        crossterm::event::MouseButton::Right => MouseButton::Right,
×
NEW
271
        crossterm::event::MouseButton::Middle => MouseButton::Middle,
×
272
    }
NEW
273
}
×
274

275
impl EventSource for UnifiedEventSource {
276
    fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
3✔
277
        // First drain any pending events non-blocking.
278
        self.drain_pending();
3✔
279

280
        if self.pending_event.is_some() || !self.input_buffer.is_empty() {
3✔
281
            return Ok(true);
1✔
282
        }
2✔
283

284
        // If drain_pending found dirty windows, arm the frame deadline.
285
        // This prevents a 3600s freeze when a PtyWakeup arrives between
286
        // handler(None) and drain_pending (common under heavy streaming):
287
        // the PtyWakeup is consumed by drain_pending but no frame
288
        // deadline is set, so recv_timeout would block for the full
289
        // PowerSaver interval.
290
        if !self.dirty_windows.is_empty() {
2✔
291
            self.frame_pacer.notify_pending();
2✔
292
        }
2✔
293

294
        // Clamp remaining to the frame deadline so we never block
295
        // longer than 16ms when there are unprocessed dirty windows.
296
        if self.frame_pacer.try_expire() {
2✔
297
            return Ok(false);
×
298
        }
2✔
299
        let mut remaining = timeout;
2✔
300
        if let Some(t) = self.frame_pacer.time_until_deadline() {
2✔
301
            remaining = remaining.min(t);
2✔
302
        }
2✔
303

304
        while remaining > Duration::ZERO {
2✔
305
            // Check frame deadline before each blocking call.
306
            if self.frame_pacer.try_expire() {
1✔
307
                return Ok(false);
×
308
            }
1✔
309
            if let Some(t) = self.frame_pacer.time_until_deadline() {
1✔
310
                remaining = remaining.min(t);
1✔
311
                if remaining <= Duration::ZERO {
1✔
312
                    self.frame_pacer.reset();
×
313
                    return Ok(false);
×
314
                }
1✔
315
            }
×
316

317
            match self.rx.recv_timeout(remaining) {
1✔
318
                Ok(UnifiedEvent::Input(event)) => {
×
319
                    self.last_event_at = Some(Instant::now());
×
320
                    self.frame_pacer.reset();
×
321
                    self.pending_event = Some(event);
×
322
                    return Ok(true);
×
323
                }
324
                Ok(UnifiedEvent::PtyWakeup(key)) => {
×
325
                    self.dirty_windows.insert(key);
×
326
                    self.frame_pacer.notify_pending();
×
327
                    if self.frame_pacer.try_expire() {
×
328
                        return Ok(false);
×
329
                    }
×
330
                    if let Some(t) = self.frame_pacer.time_until_deadline() {
×
331
                        remaining = remaining.min(t);
×
332
                    }
×
333
                    continue;
×
334
                }
335
                Ok(UnifiedEvent::AppExited(key)) => {
×
336
                    self.exited_windows.push(key);
×
337
                    self.frame_pacer.notify_pending();
×
338
                    continue;
×
339
                }
340
                Ok(UnifiedEvent::Signal) => {
341
                    self.signal_received = true;
×
342
                    return Ok(false);
×
343
                }
344
                Ok(UnifiedEvent::Tick) => {
345
                    return Ok(false);
×
346
                }
347
                Err(_) => {
348
                    // Check if the frame deadline expired during the wait.
349
                    if self.frame_pacer.try_expire() {
1✔
350
                        return Ok(false);
1✔
351
                    }
×
352
                    break;
×
353
                }
354
            }
355
        }
356

357
        self.frame_pacer.reset();
1✔
358
        self.dirty_windows.clear();
1✔
359
        Ok(false)
1✔
360
    }
3✔
361

362
    fn read(&mut self) -> io::Result<Event> {
10✔
363
        // Check pending_event first (set by poll()), then drain input_buffer.
364
        if let Some(event) = self.pending_event.take()
10✔
365
            && let Some(normalized) = self.normalizer.normalize(event)
×
366
        {
367
            return Ok(normalized);
×
368
        }
10✔
369
        // Fallback: check buffer, then block on the channel.
370
        loop {
371
            if let Some(event) = self.input_buffer.pop_front() {
10✔
372
                self.last_event_at = Some(Instant::now());
10✔
373
                if let Some(normalized) = self.normalizer.normalize(event) {
10✔
374
                    return Ok(normalized);
10✔
375
                }
×
376
                continue;
×
377
            }
×
378
            match self.rx.recv() {
×
379
                Ok(UnifiedEvent::Input(event)) => {
×
380
                    self.last_event_at = Some(Instant::now());
×
381
                    if let Some(normalized) = self.normalizer.normalize(event) {
×
382
                        return Ok(normalized);
×
383
                    }
×
384
                }
385
                Ok(UnifiedEvent::PtyWakeup(key)) => {
×
386
                    self.dirty_windows.insert(key);
×
387
                }
×
388
                Ok(UnifiedEvent::AppExited(key)) => {
×
389
                    self.exited_windows.push(key);
×
390
                }
×
391
                Ok(UnifiedEvent::Signal) => {
×
392
                    self.signal_received = true;
×
393
                }
×
394
                Ok(UnifiedEvent::Tick) => {}
×
395
                Err(_) => {
396
                    return Err(io::Error::new(
×
397
                        io::ErrorKind::BrokenPipe,
×
398
                        "event channel disconnected",
×
399
                    ));
×
400
                }
401
            }
402
        }
403
    }
10✔
404

405
    fn next_key(&mut self) -> io::Result<KeyEvent> {
×
406
        loop {
407
            self.drain_pending();
×
408
            if let Some(Event::Key(key)) = self.pending_event.take() {
×
409
                return Ok(key);
×
410
            }
×
411
            match self.rx.recv() {
×
412
                Ok(UnifiedEvent::Input(Event::Key(key))) => return Ok(key),
×
413
                Ok(UnifiedEvent::Input(event)) => {
×
414
                    self.pending_event = Some(event);
×
415
                }
×
416
                Ok(UnifiedEvent::PtyWakeup(_)) => {}
×
417
                Ok(UnifiedEvent::AppExited(key)) => {
×
418
                    self.exited_windows.push(key);
×
419
                }
×
420
                Ok(UnifiedEvent::Signal) => self.signal_received = true,
×
421
                Ok(UnifiedEvent::Tick) => {}
×
422
                Err(_) => {
423
                    return Err(io::Error::new(
×
424
                        io::ErrorKind::BrokenPipe,
×
425
                        "event channel disconnected",
×
426
                    ));
×
427
                }
428
            }
429
        }
430
    }
×
431

432
    fn next_mouse(&mut self) -> io::Result<MouseEvent> {
×
433
        loop {
434
            self.drain_pending();
×
435
            if let Some(Event::Mouse(mouse)) = self.pending_event.take() {
×
436
                return Ok(mouse);
×
437
            }
×
438
            match self.rx.recv() {
×
439
                Ok(UnifiedEvent::Input(Event::Mouse(mouse))) => return Ok(mouse),
×
440
                Ok(UnifiedEvent::Input(event)) => {
×
441
                    self.pending_event = Some(event);
×
442
                }
×
443
                Ok(UnifiedEvent::PtyWakeup(_)) => {}
×
444
                Ok(UnifiedEvent::AppExited(key)) => {
×
445
                    self.exited_windows.push(key);
×
446
                }
×
447
                Ok(UnifiedEvent::Signal) => self.signal_received = true,
×
448
                Ok(UnifiedEvent::Tick) => {}
×
449
                Err(_) => {
450
                    return Err(io::Error::new(
×
451
                        io::ErrorKind::BrokenPipe,
×
452
                        "event channel disconnected",
×
453
                    ));
×
454
                }
455
            }
456
        }
457
    }
×
458

459
    fn set_mouse_capture(&mut self, enabled: bool) -> io::Result<()> {
×
460
        if enabled {
×
461
            crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)
×
462
        } else {
463
            crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture)
×
464
        }
465
    }
×
466

467
    /// Called by the runner each cycle to signal whether there's pending
468
    /// work (e.g. a countdown timer).  When true the profile stays at
469
    /// Streaming even if both `dirty_windows` and `last_event_at` are stale.
470
    fn set_pending_work(&mut self, pending: bool) {
×
471
        self.pending_work = pending;
×
472
    }
×
473

474
    fn poll_interval(&self) -> Duration {
×
475
        self.current_profile().poll_interval()
×
476
    }
×
477

478
    fn current_profile(&self) -> PowerProfile {
5✔
479
        crate::power_profile::profile_from_activity(
5✔
480
            self.last_event_at,
5✔
481
            !self.dirty_windows.is_empty() || self.pending_work,
5✔
482
        )
483
    }
5✔
484

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

490
impl Drop for UnifiedEventSource {
491
    fn drop(&mut self) {
6✔
492
        self.shutdown.store(true, Ordering::Release);
6✔
493
    }
6✔
494
}
495

496
#[cfg(test)]
497
mod tests {
498
    use super::*;
499
    use crate::events::{KeyCode, KeyKind, KeyModifiers};
500

501
    /// Input events drained by `drain_pending` must be preserved in
502
    /// `input_buffer` so `poll()/read()` can process every event.
503
    #[test]
504
    fn drain_pending_preserves_all_input_events() {
1✔
505
        let (tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
506
        let mut source = UnifiedEventSource {
1✔
507
            rx,
1✔
508
            tx: tx.clone(),
1✔
509
            _input_handle: std::thread::spawn(|| {}),
1✔
510
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
511
            dirty_windows: HashSet::new(),
1✔
512
            exited_windows: Vec::new(),
1✔
513
            pending_event: None,
1✔
514
            input_buffer: VecDeque::new(),
1✔
515
            signal_received: false,
516
            normalizer: KeyboardNormalizer::new(),
1✔
517
            last_event_at: None,
1✔
518
            frame_pacer: FramePacer::new(),
1✔
519
            pending_work: false,
520
        };
521
        // Prevent the no-op handle from panicking on join in drop
522
        let dummy_handle = std::thread::spawn(|| {});
1✔
523
        source._input_handle = dummy_handle;
1✔
524

525
        // Send 10 input events into the channel
526
        for i in 0..10u8 {
10✔
527
            let evt = Event::Key(KeyEvent {
10✔
528
                code: KeyCode::Char(char::from(b'a' + i)),
10✔
529
                modifiers: KeyModifiers::NONE,
10✔
530
                kind: KeyKind::Press,
10✔
531
            });
10✔
532
            tx.send(UnifiedEvent::Input(evt)).unwrap();
10✔
533
        }
10✔
534
        // Also mix in some PtyWakeups (the reason drain_pending exists)
535
        for _ in 0..3 {
3✔
536
            tx.send(UnifiedEvent::PtyWakeup(WindowKey::default()))
3✔
537
                .unwrap();
3✔
538
        }
3✔
539

540
        // drain_pending must move all Input events into input_buffer
541
        source.drain_pending();
1✔
542

543
        assert_eq!(
1✔
544
            source.input_buffer.len(),
1✔
545
            10,
546
            "all 10 input events must be buffered, not dropped"
547
        );
548

549
        // verify ordering is preserved
550
        for (i, evt) in source.input_buffer.iter().enumerate() {
10✔
551
            let expected = char::from(b'a' + i as u8);
10✔
552
            match evt {
10✔
553
                Event::Key(k) => {
10✔
554
                    assert_eq!(
10✔
555
                        k.code,
556
                        KeyCode::Char(expected),
10✔
557
                        "event {} should be '{}'",
558
                        i,
559
                        expected
560
                    );
561
                }
562
                _ => panic!("expected Key event at position {}", i),
×
563
            }
564
        }
565

566
        // poll should report events available from buffer
567
        assert!(
1✔
568
            source.poll(Duration::ZERO).unwrap(),
1✔
569
            "poll must return true when buffer is non-empty"
570
        );
571

572
        // read should drain buffer in order
573
        for i in 0..10u8 {
10✔
574
            let evt = source.read().unwrap();
10✔
575
            let expected = char::from(b'a' + i);
10✔
576
            match evt {
10✔
577
                Event::Key(k) => assert_eq!(k.code, KeyCode::Char(expected)),
10✔
578
                _ => panic!("expected Key event"),
×
579
            }
580
        }
581

582
        // buffer should now be empty
583
        assert!(source.input_buffer.is_empty());
1✔
584
        assert!(
1✔
585
            !source.poll(Duration::ZERO).unwrap(),
1✔
586
            "poll must return false after buffer drained"
587
        );
588
    }
1✔
589

590
    /// Dirty windows must be cleared after `poll()` returns `Ok(false)`,
591
    /// otherwise the power profile stays at `Streaming` (16ms) forever.
592
    #[test]
593
    fn dirty_windows_cleared_after_poll_ok_false() {
1✔
594
        let (tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
595
        let mut source = UnifiedEventSource {
1✔
596
            rx,
1✔
597
            tx: tx.clone(),
1✔
598
            _input_handle: std::thread::spawn(|| {}),
1✔
599
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
600
            dirty_windows: HashSet::new(),
1✔
601
            exited_windows: Vec::new(),
1✔
602
            pending_event: None,
1✔
603
            input_buffer: VecDeque::new(),
1✔
604
            signal_received: false,
605
            normalizer: KeyboardNormalizer::new(),
1✔
606
            last_event_at: None,
1✔
607
            frame_pacer: FramePacer::new(),
1✔
608
            pending_work: false,
609
        };
610
        // Prevent the no-op handle from panicking on drop
611
        let dummy_handle = std::thread::spawn(|| {});
1✔
612
        source._input_handle = dummy_handle;
1✔
613

614
        // Baseline: no input, no dirty → PowerSaver
615
        assert_eq!(source.current_profile(), PowerProfile::PowerSaver);
1✔
616

617
        // Send a PtyWakeup — drain_pending will pick it up inside poll()
618
        tx.send(UnifiedEvent::PtyWakeup(WindowKey::default()))
1✔
619
            .unwrap();
1✔
620

621
        // poll() should drain the PtyWakeup, arm the 16ms frame pacer, then
622
        // let it expire and return Ok(false) with dirty_windows still set.
623
        assert!(
1✔
624
            !source.poll(Duration::from_secs(1)).unwrap(),
1✔
625
            "poll must return Ok(false) after PtyWakeup expiry"
626
        );
627

628
        // After poll returns, dirty_windows must contain the key
629
        // (coalesce arms the timer but does NOT clear dirty_windows on expiry).
630
        assert!(
1✔
631
            !source.take_dirty_windows().is_empty(),
1✔
632
            "dirty_windows must still contain the key after poll"
633
        );
634

635
        // After taking the dirty windows, profile returns to PowerSaver
636
        // (no input activity, no dirty windows).
637
        assert_eq!(
1✔
638
            source.current_profile(),
1✔
639
            PowerProfile::PowerSaver,
640
            "profile must return to PowerSaver after dirty_windows consumed"
641
        );
642
    }
1✔
643

644
    /// Verify that a non-empty dirty_windows causes Streaming profile,
645
    /// confirming the mechanism the bug fix relies on.
646
    #[test]
647
    fn dirty_windows_causes_streaming_profile() {
1✔
648
        let (_tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
649
        let mut set = HashSet::new();
1✔
650
        set.insert(WindowKey::default());
1✔
651
        let source = UnifiedEventSource {
1✔
652
            rx,
1✔
653
            tx: _tx,
1✔
654
            _input_handle: std::thread::spawn(|| {}),
1✔
655
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
656
            dirty_windows: set,
1✔
657
            exited_windows: Vec::new(),
1✔
658
            pending_event: None,
1✔
659
            input_buffer: VecDeque::new(),
1✔
660
            signal_received: false,
661
            normalizer: KeyboardNormalizer::new(),
1✔
662
            last_event_at: None,
1✔
663
            frame_pacer: FramePacer::new(),
1✔
664
            pending_work: false,
665
        };
666
        assert_eq!(
1✔
667
            source.current_profile(),
1✔
668
            PowerProfile::Streaming,
669
            "dirty_windows must elevate profile to Streaming"
670
        );
671
    }
1✔
672

673
    #[test]
674
    fn pending_work_causes_streaming_profile() {
1✔
675
        let (tx1, rx1) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
676
        let source = UnifiedEventSource {
1✔
677
            rx: rx1,
1✔
678
            tx: tx1,
1✔
679
            _input_handle: std::thread::spawn(|| {}),
1✔
680
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
681
            dirty_windows: HashSet::new(),
1✔
682
            exited_windows: Vec::new(),
1✔
683
            pending_event: None,
1✔
684
            input_buffer: VecDeque::new(),
1✔
685
            signal_received: false,
686
            normalizer: KeyboardNormalizer::new(),
1✔
687
            last_event_at: None,
1✔
688
            frame_pacer: FramePacer::new(),
1✔
689
            pending_work: true,
690
        };
691
        assert_eq!(
1✔
692
            source.current_profile(),
1✔
693
            PowerProfile::Streaming,
694
            "pending_work must elevate profile to Streaming even without dirty_windows"
695
        );
696
        // Also verify that stale last_event_at + pending_work still gives Streaming
697
        let (tx2, rx2) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
698
        let stale = Instant::now().checked_sub(Duration::from_secs(3600));
1✔
699
        let source2 = UnifiedEventSource {
1✔
700
            rx: rx2,
1✔
701
            tx: tx2,
1✔
702
            _input_handle: std::thread::spawn(|| {}),
1✔
703
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
704
            dirty_windows: HashSet::new(),
1✔
705
            exited_windows: Vec::new(),
1✔
706
            pending_event: None,
1✔
707
            input_buffer: VecDeque::new(),
1✔
708
            signal_received: false,
709
            normalizer: KeyboardNormalizer::new(),
1✔
710
            last_event_at: stale,
1✔
711
            frame_pacer: FramePacer::new(),
1✔
712
            pending_work: true,
713
        };
714
        assert_eq!(
1✔
715
            source2.current_profile(),
1✔
716
            PowerProfile::Streaming,
717
            "pending_work must keep Streaming even with stale last_event_at"
718
        );
719
    }
1✔
720

721
    /// Regression: `take_exited_windows` must be reachable through the
722
    /// `EventSource` trait so that generic runner code (`D: EventSource`)
723
    /// actually gets the accumulated exit keys. Before the fix the method
724
    /// was only inherent — the trait override was missing, and the default
725
    /// no-op impl silently returned an empty vec, so exited windows never
726
    /// closed.
727
    #[test]
728
    fn take_exited_windows_returns_accumulated_keys_through_trait() {
1✔
729
        use super::EventSource;
730
        let (_tx, rx) = bounded(EVENT_CHANNEL_CAPACITY);
1✔
731
        let key = WindowKey::default();
1✔
732
        let mut source = UnifiedEventSource {
1✔
733
            rx,
1✔
734
            tx: _tx,
1✔
735
            _input_handle: std::thread::spawn(|| {}),
1✔
736
            shutdown: Arc::new(AtomicBool::new(false)),
1✔
737
            dirty_windows: HashSet::new(),
1✔
738
            exited_windows: vec![key],
1✔
739
            pending_event: None,
1✔
740
            input_buffer: VecDeque::new(),
1✔
741
            signal_received: false,
742
            normalizer: KeyboardNormalizer::new(),
1✔
743
            last_event_at: None,
1✔
744
            frame_pacer: FramePacer::new(),
1✔
745
            pending_work: false,
746
        };
747
        let dummy_handle = std::thread::spawn(|| {});
1✔
748
        source._input_handle = dummy_handle;
1✔
749

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

755
        let again = EventSource::take_exited_windows(&mut source);
1✔
756
        assert!(again.is_empty(), "second call must drain");
1✔
757
    }
1✔
758
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc