use std::time::{Duration, Instant};
pub const CHAT_GAP: Duration = Duration::from_millis(250);
pub const TYPING_GAP: Duration = Duration::from_millis(500);
pub const TYPING_HOLD: Duration = Duration::from_millis(1500);
pub const POLL: Duration = Duration::from_millis(50);
#[derive(Default)]
pub struct ChatGate {
last: Option<Instant>,
key: Option<Instant>,
}
impl ChatGate {
pub fn note_key(&mut self, now: Instant) {
self.key = Some(now);
}
pub fn typing(&self, now: Instant) -> bool {
self.key
.is_some_and(|k| now.saturating_duration_since(k) < TYPING_HOLD)
}
pub fn gap(&self, now: Instant) -> Duration {
if self.typing(now) {
TYPING_GAP
} else {
CHAT_GAP
}
}
pub fn due(&self, now: Instant) -> bool {
self.last
.is_none_or(|l| now.saturating_duration_since(l) >= self.gap(now))
}
pub fn mark(&mut self, now: Instant) {
self.last = Some(now);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(base: Instant, ms: u64) -> Instant {
base + Duration::from_millis(ms)
}
#[test]
fn first_line_after_a_lull_is_immediate() {
let t = Instant::now();
let mut g = ChatGate::default();
assert!(g.due(t));
g.mark(t);
assert!(g.due(at(t, 10_000)));
}
#[test]
fn burst_is_coalesced_to_one_frame_per_gap() {
let t = Instant::now();
let mut g = ChatGate::default();
g.mark(t);
assert!(!g.due(at(t, 1)));
assert!(!g.due(at(t, CHAT_GAP.as_millis() as u64 - 1)));
assert!(g.due(at(t, CHAT_GAP.as_millis() as u64)));
}
#[test]
fn typing_widens_the_gap_then_relaxes() {
let t = Instant::now();
let mut g = ChatGate::default();
g.note_key(t);
g.mark(t);
assert!(g.typing(at(t, 100)));
assert!(!g.due(at(t, CHAT_GAP.as_millis() as u64 + 1)));
assert!(g.due(at(t, TYPING_GAP.as_millis() as u64)));
let later = at(t, TYPING_HOLD.as_millis() as u64);
assert!(!g.typing(later));
assert_eq!(g.gap(later), CHAT_GAP);
}
#[test]
fn keys_never_defer_themselves() {
let t = Instant::now();
let mut g = ChatGate::default();
g.mark(t);
g.note_key(at(t, 5));
assert!(!g.due(at(t, 6)));
assert!(g.typing(at(t, 6)));
}
}