#![allow(dead_code)]
use std::time::Duration;
use std::time::Instant;
const PASTE_BURST_MIN_CHARS: u16 = 3;
const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
#[cfg(not(windows))]
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
#[cfg(windows)]
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(30);
#[cfg(not(windows))]
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8);
#[cfg(windows)]
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60);
#[derive(Default)]
pub(crate) struct PasteBurst {
last_plain_char_time: Option<Instant>,
consecutive_plain_char_burst: u16,
burst_window_until: Option<Instant>,
buffer: String,
active: bool,
pending_first_char: Option<(char, Instant)>,
}
pub(crate) enum CharDecision {
BeginBuffer { retro_chars: u16 },
BufferAppend,
RetainFirstChar,
BeginBufferFromPending,
}
pub(crate) struct RetroGrab {
pub start_byte: usize,
pub grabbed: String,
}
pub(crate) enum FlushResult {
Paste(String),
Typed(char),
None,
}
impl PasteBurst {
pub fn recommended_flush_delay() -> Duration {
PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
}
#[cfg(test)]
pub(crate) fn recommended_active_flush_delay() -> Duration {
PASTE_BURST_ACTIVE_IDLE_TIMEOUT + Duration::from_millis(1)
}
pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision {
self.note_plain_char(now);
if self.active {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return CharDecision::BufferAppend;
}
if let Some((held, held_at)) = self.pending_first_char
&& now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL
{
self.active = true;
let _ = self.pending_first_char.take();
self.buffer.push(held);
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return CharDecision::BeginBufferFromPending;
}
if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
return CharDecision::BeginBuffer {
retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
};
}
self.pending_first_char = Some((ch, now));
CharDecision::RetainFirstChar
}
pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option<CharDecision> {
self.note_plain_char(now);
if self.active {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return Some(CharDecision::BufferAppend);
}
if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
return Some(CharDecision::BeginBuffer {
retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
});
}
None
}
fn note_plain_char(&mut self, now: Instant) {
match self.last_plain_char_time {
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
self.consecutive_plain_char_burst =
self.consecutive_plain_char_burst.saturating_add(1)
}
_ => self.consecutive_plain_char_burst = 1,
}
self.last_plain_char_time = Some(now);
}
pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
let timeout = if self.is_active_internal() {
PASTE_BURST_ACTIVE_IDLE_TIMEOUT
} else {
PASTE_BURST_CHAR_INTERVAL
};
let timed_out = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) > timeout);
if timed_out && self.is_active_internal() {
self.active = false;
let out = std::mem::take(&mut self.buffer);
FlushResult::Paste(out)
} else if timed_out {
if let Some((ch, _at)) = self.pending_first_char.take() {
FlushResult::Typed(ch)
} else {
FlushResult::None
}
} else {
FlushResult::None
}
}
pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
if self.is_active() {
self.buffer.push('\n');
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
true
} else {
false
}
}
pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
self.is_active() || in_burst_window
}
pub fn extend_window(&mut self, now: Instant) {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
}
pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) {
if !grabbed.is_empty() {
self.buffer.push_str(&grabbed);
}
self.active = true;
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
}
pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
self.buffer.push(ch);
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
}
pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool {
if self.active || !self.buffer.is_empty() {
self.append_char_to_buffer(ch, now);
true
} else {
false
}
}
pub fn decide_begin_buffer(
&mut self,
now: Instant,
before: &str,
retro_chars: usize,
) -> Option<RetroGrab> {
let start_byte = retro_start_index(before, retro_chars);
let grabbed = before[start_byte..].to_string();
let looks_pastey =
grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16;
if looks_pastey {
self.begin_with_retro_grabbed(grabbed.clone(), now);
Some(RetroGrab {
start_byte,
grabbed,
})
} else {
None
}
}
pub fn flush_before_modified_input(&mut self) -> Option<String> {
if !self.is_active() {
return None;
}
self.active = false;
let mut out = std::mem::take(&mut self.buffer);
if let Some((ch, _at)) = self.pending_first_char.take() {
out.push(ch);
}
Some(out)
}
pub fn clear_window_after_non_char(&mut self) {
self.consecutive_plain_char_burst = 0;
self.last_plain_char_time = None;
self.burst_window_until = None;
self.active = false;
self.pending_first_char = None;
}
pub fn is_active(&self) -> bool {
self.is_active_internal() || self.pending_first_char.is_some()
}
fn is_active_internal(&self) -> bool {
self.active || !self.buffer.is_empty()
}
pub fn clear_after_explicit_paste(&mut self) {
self.last_plain_char_time = None;
self.consecutive_plain_char_burst = 0;
self.burst_window_until = None;
self.active = false;
self.buffer.clear();
self.pending_first_char = None;
}
}
pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize {
if retro_chars == 0 {
return before.len();
}
before
.char_indices()
.rev()
.nth(retro_chars.saturating_sub(1))
.map(|(idx, _)| idx)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn ascii_first_char_is_held_then_flushes_as_typed() {
let mut burst = PasteBurst::default();
let t0 = Instant::now();
assert!(matches!(
burst.on_plain_char('a', t0),
CharDecision::RetainFirstChar
));
let t1 = t0 + PasteBurst::recommended_flush_delay() + Duration::from_millis(1);
assert!(matches!(burst.flush_if_due(t1), FlushResult::Typed('a')));
assert!(!burst.is_active());
}
#[test]
fn ascii_two_fast_chars_start_buffer_from_pending_and_flush_as_paste() {
let mut burst = PasteBurst::default();
let t0 = Instant::now();
assert!(matches!(
burst.on_plain_char('a', t0),
CharDecision::RetainFirstChar
));
let t1 = t0 + Duration::from_millis(1);
assert!(matches!(
burst.on_plain_char('b', t1),
CharDecision::BeginBufferFromPending
));
burst.append_char_to_buffer('b', t1);
let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);
assert!(matches!(
burst.flush_if_due(t2),
FlushResult::Paste(ref s) if s == "ab"
));
}
#[test]
fn flush_before_modified_input_includes_pending_first_char() {
let mut burst = PasteBurst::default();
let t0 = Instant::now();
assert!(matches!(
burst.on_plain_char('a', t0),
CharDecision::RetainFirstChar
));
assert_eq!(burst.flush_before_modified_input(), Some("a".to_string()));
assert!(!burst.is_active());
}
#[test]
fn decide_begin_buffer_only_triggers_for_pastey_prefixes() {
let mut burst = PasteBurst::default();
let now = Instant::now();
assert!(
burst
.decide_begin_buffer(now, "ab", 2)
.is_none()
);
assert!(!burst.is_active());
let grab = burst
.decide_begin_buffer(now, "a b", 2)
.expect("whitespace should be considered paste-like");
assert_eq!(grab.start_byte, 1);
assert_eq!(grab.grabbed, " b");
assert!(burst.is_active());
}
#[test]
fn newline_suppression_window_outlives_buffer_flush() {
let mut burst = PasteBurst::default();
let t0 = Instant::now();
assert!(matches!(
burst.on_plain_char('a', t0),
CharDecision::RetainFirstChar
));
let t1 = t0 + Duration::from_millis(1);
assert!(matches!(
burst.on_plain_char('b', t1),
CharDecision::BeginBufferFromPending
));
burst.append_char_to_buffer('b', t1);
let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);
assert!(matches!(burst.flush_if_due(t2), FlushResult::Paste(ref s) if s == "ab"));
assert!(!burst.is_active());
assert!(burst.newline_should_insert_instead_of_submit(t2));
let t3 = t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1);
assert!(!burst.newline_should_insert_instead_of_submit(t3));
}
}