use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant};
pub const WINDOW: Duration = Duration::from_millis(100);
#[derive(Default)]
pub struct Torn {
held: Vec<KeyEvent>,
since: Option<Instant>,
}
impl Torn {
pub fn feed(&mut self, key: KeyEvent, now: Instant) -> Vec<KeyEvent> {
if self.held.is_empty() {
let opens = (key.code == KeyCode::Esc || key.code == KeyCode::Char('['))
&& key.modifiers.is_empty();
if opens {
self.held.push(key);
self.since = Some(now);
return Vec::new();
}
return vec![key];
}
self.held.push(key);
self.since = Some(now);
let after = match self.held[0].code {
KeyCode::Esc => &self.held[1..],
_ => &self.held[..],
};
match shape(after) {
Shape::Report => {
crate::elog::event("tui", "torn-mouse-report", serde_json::json!({}));
self.held.clear();
self.since = None;
Vec::new()
}
Shape::Prefix => Vec::new(),
Shape::Neither => {
let last = self.held.pop().expect("just pushed");
let mut out = std::mem::take(&mut self.held);
self.since = None;
out.extend(self.feed(last, now));
out
}
}
}
pub fn expire(&mut self, now: Instant) -> Vec<KeyEvent> {
match self.since {
Some(at) if now.duration_since(at) >= WINDOW => {
self.since = None;
std::mem::take(&mut self.held)
}
_ => Vec::new(),
}
}
#[cfg(test)]
pub fn holding(&self) -> bool {
!self.held.is_empty()
}
}
enum Shape {
Report,
Prefix,
Neither,
}
fn shape(keys: &[KeyEvent]) -> Shape {
let mut text = String::new();
for key in keys {
let (KeyCode::Char(c), m) = (key.code, key.modifiers) else {
return Shape::Neither;
};
if !(m - KeyModifiers::SHIFT).is_empty() {
return Shape::Neither;
}
text.push(c);
}
if text.is_empty() || text == "[" {
return Shape::Prefix;
}
let Some(body) = text.strip_prefix("[<") else {
return Shape::Neither;
};
let (fields, end) = match body.char_indices().last() {
Some((at, 'M' | 'm')) => (&body[..at], true),
_ => (body, false),
};
let parts: Vec<&str> = fields.split(';').collect();
let digits = |s: &str| s.chars().all(|c| c.is_ascii_digit());
if parts.len() > 3 || !parts.iter().all(|p| digits(p)) {
return Shape::Neither;
}
match end {
true if parts.len() == 3 && parts.iter().all(|p| !p.is_empty()) => Shape::Report,
true => Shape::Neither,
false => Shape::Prefix,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn keys(text: &str) -> Vec<KeyEvent> {
text.chars()
.map(|c| match c {
'\x1b' => KeyEvent::from(KeyCode::Esc),
c if c.is_ascii_uppercase() => KeyEvent::new(KeyCode::Char(c), KeyModifiers::SHIFT),
c => KeyEvent::from(KeyCode::Char(c)),
})
.collect()
}
fn run(torn: &mut Torn, text: &str, now: Instant) -> Vec<KeyEvent> {
keys(text)
.into_iter()
.flat_map(|k| torn.feed(k, now))
.collect()
}
#[test]
fn a_torn_mouse_report_never_reaches_the_pane() {
let mut torn = Torn::default();
let now = Instant::now();
let out = run(&mut torn, "\x1b[<35;141;17M\x1b[<0;97;19m", now);
assert!(out.is_empty(), "leaked: {out:?}");
assert!(!torn.holding());
}
#[test]
fn typing_on_either_side_of_a_report_goes_through() {
let mut torn = Torn::default();
let now = Instant::now();
assert_eq!(run(&mut torn, "hi\x1b[<35;1;1Mthere", now), keys("hithere"));
}
#[test]
fn a_real_esc_still_arrives() {
let mut torn = Torn::default();
let now = Instant::now();
assert!(run(&mut torn, "\x1b", now).is_empty());
assert!(torn.expire(now).is_empty(), "released before the window");
assert_eq!(torn.expire(now + WINDOW), keys("\x1b"));
assert_eq!(run(&mut torn, "\x1bq", now), keys("\x1bq"));
assert_eq!(run(&mut torn, "\x1b[<3x", now), keys("\x1b[<3x"));
assert_eq!(run(&mut torn, "\x1b\x1b", now), keys("\x1b"));
assert_eq!(torn.expire(now + WINDOW), keys("\x1b"));
}
#[test]
fn a_report_tail_without_its_esc_is_dropped_too() {
let mut torn = Torn::default();
let now = Instant::now();
assert!(run(&mut torn, "[<35;141;17M[<0;97;19m", now).is_empty());
assert_eq!(run(&mut torn, "a[b]", now), keys("a[b]"));
assert_eq!(run(&mut torn, "[", now), Vec::<KeyEvent>::new());
assert_eq!(torn.expire(now + WINDOW), keys("["));
}
#[test]
fn only_a_whole_report_is_dropped() {
let mut torn = Torn::default();
let now = Instant::now();
assert_eq!(run(&mut torn, "\x1b[<35;1M", now), keys("\x1b[<35;1M"));
assert_eq!(
run(&mut torn, "\x1b[<1;2;3;4M", now),
keys("\x1b[<1;2;3;4M")
);
}
}