use std::sync::Arc;
use std::time::Duration;
use crossterm::event::{self, Event, KeyEventKind};
use tokio::sync::{Notify, mpsc};
use crate::common::config::AppConfig;
#[inline]
fn is_actionable_key_event(kind: KeyEventKind) -> bool {
matches!(kind, KeyEventKind::Press)
}
#[derive(Debug)]
pub enum UiEvent {
TerminalInput(Event),
Resize(u16, u16),
DataReady,
AnimationTick,
TerminalClosed,
}
pub struct UiEventCoordinator {
term_tx: Option<mpsc::Sender<Event>>,
term_rx: mpsc::Receiver<Event>,
data_notify: Arc<Notify>,
animation_interval: tokio::time::Interval,
animations_active: bool,
}
impl UiEventCoordinator {
pub fn new(data_notify: Arc<Notify>) -> Self {
let (term_tx, term_rx) = mpsc::channel::<Event>(64);
let mut animation_interval =
tokio::time::interval(Duration::from_millis(AppConfig::ANIMATION_TICK_MS));
animation_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
Self {
term_tx: Some(term_tx),
term_rx,
data_notify,
animation_interval,
animations_active: true, }
}
pub fn spawn_terminal_reader(&mut self) {
let tx = self
.term_tx
.take()
.expect("spawn_terminal_reader must be called exactly once");
tokio::task::spawn_blocking(move || {
Self::terminal_reader_loop(tx);
});
}
fn terminal_reader_loop(tx: mpsc::Sender<Event>) {
loop {
match event::poll(Duration::from_millis(AppConfig::TERMINAL_READER_POLL_MS)) {
Ok(true) => match event::read() {
Ok(evt) => {
if let Event::Key(k) = &evt
&& !is_actionable_key_event(k.kind)
{
continue;
}
if tx.blocking_send(evt).is_err() {
break;
}
}
Err(_) => {
break;
}
},
Ok(false) => {
if tx.is_closed() {
break;
}
}
Err(_) => {
break;
}
}
}
}
pub fn set_animations_active(&mut self, active: bool) {
if active != self.animations_active {
self.animations_active = active;
let new_period = if active {
Duration::from_millis(AppConfig::ANIMATION_TICK_MS)
} else {
Duration::from_millis(AppConfig::REFRESH_TICK_MS)
};
self.animation_interval = tokio::time::interval(new_period);
self.animation_interval
.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
}
}
pub fn drain_pending_events(&mut self) -> Vec<UiEvent> {
let mut events = Vec::new();
while let Ok(event) = self.term_rx.try_recv() {
match event {
Event::Resize(w, h) => events.push(UiEvent::Resize(w, h)),
other => events.push(UiEvent::TerminalInput(other)),
}
}
events
}
pub async fn next_event(&mut self) -> UiEvent {
tokio::select! {
result = self.term_rx.recv() => {
match result {
Some(Event::Resize(w, h)) => UiEvent::Resize(w, h),
Some(other) => UiEvent::TerminalInput(other),
None => UiEvent::TerminalClosed,
}
}
_ = self.data_notify.notified() => {
UiEvent::DataReady
}
_ = self.animation_interval.tick() => {
UiEvent::AnimationTick
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
#[test]
fn test_ui_event_debug_variants() {
let events: Vec<UiEvent> = vec![
UiEvent::DataReady,
UiEvent::AnimationTick,
UiEvent::TerminalClosed,
UiEvent::Resize(80, 24),
];
for e in events {
let _ = format!("{e:?}");
}
}
#[test]
fn test_resize_event_carries_dimensions() {
let ev = UiEvent::Resize(120, 40);
match ev {
UiEvent::Resize(w, h) => {
assert_eq!(w, 120);
assert_eq!(h, 40);
}
_ => panic!("Expected Resize variant"),
}
}
#[tokio::test]
async fn test_coordinator_new_does_not_panic() {
let notify = Arc::new(Notify::new());
let _coordinator = UiEventCoordinator::new(notify);
}
#[tokio::test]
async fn test_coordinator_animations_active_by_default() {
let notify = Arc::new(Notify::new());
let coordinator = UiEventCoordinator::new(notify);
drop(coordinator);
}
#[tokio::test]
async fn test_set_animations_active_toggle() {
let notify = Arc::new(Notify::new());
let mut coordinator = UiEventCoordinator::new(notify);
coordinator.set_animations_active(false);
coordinator.set_animations_active(true);
coordinator.set_animations_active(false);
}
#[tokio::test]
async fn test_next_event_data_ready() {
let notify = Arc::new(Notify::new());
let mut coordinator = UiEventCoordinator::new(Arc::clone(¬ify));
coordinator.set_animations_active(false);
notify.notify_one();
let event = tokio::time::timeout(Duration::from_secs(1), coordinator.next_event())
.await
.expect("next_event timed out");
assert!(
matches!(event, UiEvent::DataReady),
"Expected DataReady, got {event:?}"
);
}
#[tokio::test]
async fn test_next_event_terminal_closed_when_channel_drops() {
let notify = Arc::new(Notify::new());
let mut coordinator = UiEventCoordinator::new(Arc::clone(¬ify));
coordinator.set_animations_active(false);
coordinator.spawn_terminal_reader();
let event = tokio::time::timeout(Duration::from_secs(2), async {
loop {
match coordinator.next_event().await {
UiEvent::TerminalClosed => return UiEvent::TerminalClosed,
_ => continue,
}
}
})
.await
.expect("TerminalClosed event never arrived");
assert!(matches!(event, UiEvent::TerminalClosed));
}
#[test]
fn test_resize_event_mapping() {
let crossterm_event = Event::Resize(100, 50);
let ui_event = match crossterm_event {
Event::Resize(w, h) => UiEvent::Resize(w, h),
other => UiEvent::TerminalInput(other),
};
assert!(matches!(ui_event, UiEvent::Resize(100, 50)));
}
#[test]
fn test_terminal_input_wrapping() {
let key_event = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
let ui_event = match key_event {
Event::Resize(w, h) => UiEvent::Resize(w, h),
other => UiEvent::TerminalInput(other),
};
assert!(
matches!(ui_event, UiEvent::TerminalInput(_)),
"Expected TerminalInput, got {ui_event:?}"
);
}
#[test]
fn test_animation_tick_ms_reasonable() {
const { assert!(AppConfig::ANIMATION_TICK_MS > 0) };
const { assert!(AppConfig::ANIMATION_TICK_MS <= 500) };
}
#[test]
fn test_terminal_reader_poll_ms_reasonable() {
const { assert!(AppConfig::TERMINAL_READER_POLL_MS > 0) };
const { assert!(AppConfig::TERMINAL_READER_POLL_MS <= 200) };
}
#[test]
fn test_animation_tick_ms_value() {
assert_eq!(AppConfig::ANIMATION_TICK_MS, 200);
}
#[test]
fn test_terminal_reader_poll_ms_value() {
assert_eq!(AppConfig::TERMINAL_READER_POLL_MS, 50);
}
#[tokio::test]
async fn test_data_collector_notify_wiring() {
use crate::app_state::AppState;
use crate::view::data_collector::DataCollector;
use tokio::sync::Mutex;
let app_state = Arc::new(Mutex::new(AppState::new()));
let notify = Arc::new(Notify::new());
let _collector = DataCollector::with_notify(Arc::clone(&app_state), Arc::clone(¬ify));
let mut coordinator = UiEventCoordinator::new(Arc::clone(¬ify));
coordinator.set_animations_active(false);
notify.notify_one();
let event = tokio::time::timeout(Duration::from_millis(200), coordinator.next_event())
.await
.expect("DataReady event never arrived");
assert!(
matches!(event, UiEvent::DataReady),
"Expected DataReady, got {event:?}"
);
}
fn key_event_with_kind(kind: KeyEventKind) -> Event {
Event::Key(KeyEvent::new_with_kind_and_state(
KeyCode::Char('h'),
KeyModifiers::NONE,
kind,
KeyEventState::NONE,
))
}
#[test]
fn test_is_actionable_key_event_accepts_press() {
assert!(is_actionable_key_event(KeyEventKind::Press));
}
#[test]
fn test_is_actionable_key_event_rejects_release() {
assert!(!is_actionable_key_event(KeyEventKind::Release));
}
#[test]
fn test_is_actionable_key_event_rejects_repeat() {
assert!(!is_actionable_key_event(KeyEventKind::Repeat));
}
#[test]
fn test_reader_filter_drops_release_events() {
let release = key_event_with_kind(KeyEventKind::Release);
let should_forward = match &release {
Event::Key(k) => is_actionable_key_event(k.kind),
_ => true,
};
assert!(
!should_forward,
"Release key events must be filtered out (issue #212)"
);
}
#[test]
fn test_reader_filter_passes_non_key_events() {
let resize = Event::Resize(80, 24);
let should_forward = match &resize {
Event::Key(k) => is_actionable_key_event(k.kind),
_ => true,
};
assert!(
should_forward,
"Non-key events must not be filtered by the key-kind guard"
);
}
#[test]
fn test_reader_filter_passes_press_events() {
let press = key_event_with_kind(KeyEventKind::Press);
let should_forward = match &press {
Event::Key(k) => is_actionable_key_event(k.kind),
_ => true,
};
assert!(
should_forward,
"Press key events must be forwarded (Unix parity)"
);
}
}