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 = match (key.code, key.modifiers) {
(KeyCode::Char('['), KeyModifiers::ALT) => true,
(KeyCode::Esc | KeyCode::Char('[' | '<'), m) => m.is_empty(),
_ => false,
};
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(&spell(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 spell(keys: &[KeyEvent]) -> Option<String> {
let mut text = String::new();
for (i, key) in keys.iter().enumerate() {
match (key.code, key.modifiers) {
(KeyCode::Char('['), KeyModifiers::ALT) if i == 0 => text.push('['),
(KeyCode::Char(c), m) if (m - KeyModifiers::SHIFT).is_empty() => text.push(c),
_ => return None,
}
}
if text.starts_with('<') {
text.insert(0, '[');
}
Some(text)
}
fn shape(text: &Option<String>) -> Shape {
let Some(text) = text else {
return Shape::Neither;
};
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 a_report_opened_by_alt_bracket_or_a_bare_angle_is_dropped() {
let mut torn = Torn::default();
let now = Instant::now();
let alt = KeyEvent::new(KeyCode::Char('['), KeyModifiers::ALT);
let mut out = torn.feed(alt, now);
out.extend(run(&mut torn, "<35;148;3M", now));
assert!(out.is_empty(), "leaked: {out:?}");
assert!(!torn.holding());
assert!(run(&mut torn, "<35;148;3M<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("<"));
let mut out = torn.feed(alt, now);
out.extend(run(&mut torn, "x", now));
assert_eq!(out, vec![alt, KeyEvent::from(KeyCode::Char('x'))]);
}
#[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")
);
}
}