返回 CodeWhale
paste_burst.rs
根目录 / crates / tui / src / tui / paste_burst.rs
1 //! Paste-burst detection for terminals without reliable bracketed paste.
2
3 use std::time::{Duration, Instant};
4
5 const PASTE_BURST_MIN_CHARS: u16 = 3;
6 const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
7 const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
8 #[cfg(not(windows))]
9 const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8);
10 #[cfg(windows)]
11 const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60);
12
13 #[derive(Default)]
14 pub(crate) struct PasteBurst {
15 last_plain_char_time: Option<Instant>,
16 consecutive_plain_char_burst: u16,
17 burst_window_until: Option<Instant>,
18 buffer: String,
19 active: bool,
20 pending_first_char: Option<(char, Instant)>,
21 }
22
23 pub(crate) enum CharDecision {
24 BeginBuffer { retro_chars: u16 },
25 BufferAppend,
26 RetainFirstChar,
27 BeginBufferFromPending,
28 }
29
30 pub(crate) struct RetroGrab {
31 pub start_byte: usize,
32 pub grabbed: String,
33 }
34
35 pub(crate) enum FlushResult {
36 Paste(String),
37 Typed(char),
38 None,
39 }
40
41 impl PasteBurst {
42 #[cfg(test)]
43 pub fn recommended_flush_delay() -> Duration {
44 PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
45 }
46
47 #[cfg(test)]
48 pub(crate) fn recommended_active_flush_delay() -> Duration {
49 PASTE_BURST_ACTIVE_IDLE_TIMEOUT + Duration::from_millis(1)
50 }
51
52 pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision {
53 self.note_plain_char(now);
54
55 if self.active {
56 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
57 return CharDecision::BufferAppend;
58 }
59
60 if let Some((held, held_at)) = self.pending_first_char
61 && now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL
62 {
63 self.active = true;
64 let _ = self.pending_first_char.take();
65 self.buffer.push(held);
66 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
67 return CharDecision::BeginBufferFromPending;
68 }
69
70 if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
71 return CharDecision::BeginBuffer {
72 retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
73 };
74 }
75
76 self.pending_first_char = Some((ch, now));
77 CharDecision::RetainFirstChar
78 }
79
80 #[allow(dead_code)]
81 pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option<CharDecision> {
82 self.note_plain_char(now);
83
84 if self.active {
85 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
86 return Some(CharDecision::BufferAppend);
87 }
88
89 if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
90 return Some(CharDecision::BeginBuffer {
91 retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
92 });
93 }
94
95 None
96 }
97
98 pub(crate) fn note_plain_char(&mut self, now: Instant) -> u16 {
99 match self.last_plain_char_time {
100 Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
101 self.consecutive_plain_char_burst =
102 self.consecutive_plain_char_burst.saturating_add(1);
103 }
104 _ => self.consecutive_plain_char_burst = 1,
105 }
106 self.last_plain_char_time = Some(now);
107 self.consecutive_plain_char_burst
108 }
109
110 pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
111 let timeout = if self.is_active_internal() {
112 PASTE_BURST_ACTIVE_IDLE_TIMEOUT
113 } else {
114 PASTE_BURST_CHAR_INTERVAL
115 };
116 let timed_out = self
117 .last_plain_char_time
118 .is_some_and(|t| now.duration_since(t) > timeout);
119
120 if timed_out && self.is_active_internal() {
121 self.active = false;
122 let out = std::mem::take(&mut self.buffer);
123 // `burst_window_until` intentionally survives the flush: the idle
124 // timeout is only 8ms, and a paste's trailing newline can land
125 // just after it over a laggy link (SSH/tmux). Dropping the window
126 // here would let that pasted newline submit a partial paste
127 // (#1073). The window stays *bounded* instead: absorbing an Enter
128 // outside an active burst no longer re-arms it, so suppression
129 // always ends `PASTE_ENTER_SUPPRESS_WINDOW` after the last real
130 // keystroke.
131 FlushResult::Paste(out)
132 } else if timed_out {
133 if let Some((ch, _)) = self.pending_first_char.take() {
134 FlushResult::Typed(ch)
135 } else {
136 FlushResult::None
137 }
138 } else {
139 FlushResult::None
140 }
141 }
142
143 /// Return the remaining delay before a pending char/paste buffer must flush.
144 ///
145 /// This lets the UI event loop avoid sleeping past the flush deadline.
146 #[must_use]
147 pub fn next_flush_delay(&self, now: Instant) -> Option<Duration> {
148 let last = self.last_plain_char_time?;
149 let timeout = if self.is_active_internal() {
150 PASTE_BURST_ACTIVE_IDLE_TIMEOUT
151 } else {
152 PASTE_BURST_CHAR_INTERVAL
153 };
154 Some(timeout.saturating_sub(now.duration_since(last)))
155 }
156
157 pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
158 if self.is_active() {
159 self.buffer.push('\n');
160 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
161 true
162 } else {
163 false
164 }
165 }
166
167 pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
168 let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
169 self.is_active() || in_burst_window
170 }
171
172 pub fn extend_window(&mut self, now: Instant) {
173 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
174 }
175
176 pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) {
177 if !grabbed.is_empty() {
178 self.buffer.push_str(&grabbed);
179 }
180 self.active = true;
181 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
182 }
183
184 pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
185 self.buffer.push(ch);
186 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
187 }
188
189 #[allow(dead_code)]
190 pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool {
191 if self.active || !self.buffer.is_empty() {
192 self.append_char_to_buffer(ch, now);
193 true
194 } else {
195 false
196 }
197 }
198
199 pub fn decide_begin_buffer(
200 &mut self,
201 now: Instant,
202 before: &str,
203 retro_chars: usize,
204 ) -> Option<RetroGrab> {
205 let start_byte = retro_start_index(before, retro_chars);
206 let grabbed = before[start_byte..].to_string();
207 // Short CJK first-line pastes (e.g. "请联网搜索:" copied from a web
208 // chat) used to fail the heuristic — no whitespace and under the
209 // 16-char threshold meant the trailing pasted newline fell through
210 // as a real Enter and submitted the first line on its own.
211 // Treating any non-ASCII run as paste-like fixes this without
212 // false-firing on ASCII typing (#1302, PR #1342 from @reidliu41).
213 let looks_pastey = grabbed.chars().any(char::is_whitespace)
214 || !grabbed.is_ascii()
215 || grabbed.chars().count() >= 16;
216 if looks_pastey {
217 self.begin_with_retro_grabbed(grabbed.clone(), now);
218 Some(RetroGrab {
219 start_byte,
220 grabbed,
221 })
222 } else {
223 None
224 }
225 }
226
227 pub fn flush_before_modified_input(&mut self) -> Option<String> {
228 if !self.is_active() {
229 return None;
230 }
231 self.active = false;
232 let mut out = std::mem::take(&mut self.buffer);
233 if let Some((ch, _)) = self.pending_first_char.take() {
234 out.push(ch);
235 }
236 Some(out)
237 }
238
239 /// Reset burst-accumulation state without clearing the suppression window.
240 ///
241 /// Used when a non-char key (Tab, etc.) arrives during an active burst as
242 /// part of table-data paste. The buffer was flushed upstream; only the
243 /// active state is reset so `burst_window_until` stays alive and a trailing
244 /// Enter is still absorbed as a newline (#2134).
245 ///
246 /// # Panics
247 ///
248 /// Panics in debug builds if `buffer` is non-empty — the caller must flush
249 /// via `flush_before_modified_input` first.
250 pub fn deactivate_keep_window(&mut self) {
251 debug_assert!(
252 self.buffer.is_empty(),
253 "buffer must be flushed before deactivating"
254 );
255 self.consecutive_plain_char_burst = 0;
256 self.last_plain_char_time = None;
257 self.active = false;
258 self.pending_first_char = None;
259 // burst_window_until intentionally NOT cleared
260 }
261
262 pub fn is_active(&self) -> bool {
263 self.is_active_internal() || self.pending_first_char.is_some()
264 }
265
266 fn is_active_internal(&self) -> bool {
267 self.active || !self.buffer.is_empty()
268 }
269
270 pub fn clear_after_explicit_paste(&mut self) {
271 self.last_plain_char_time = None;
272 self.consecutive_plain_char_burst = 0;
273 self.burst_window_until = None;
274 self.active = false;
275 self.buffer.clear();
276 self.pending_first_char = None;
277 }
278
279 /// Arm the Enter-suppression window for a non-ASCII character that was
280 /// inserted straight into the composer instead of being buffered (the
281 /// IME / raw-CJK path in `tui::paste`).
282 ///
283 /// `rapid_chars` is the run length reported by [`Self::note_plain_char`].
284 ///
285 /// A *lone* commit only earns a burst-interval window. An IME candidate
286 /// commit is ordinary typing: the user may press Enter to send a
287 /// message ending in a CJK character tens of milliseconds later, and the
288 /// full 120ms window turned that Enter into a stray newline. A real raw
289 /// paste delivers its trailing newline within microseconds of the last
290 /// character, so the short window still absorbs it — including the
291 /// single-character first line of a CJK paste (#1302).
292 ///
293 /// Two or more characters at paste speed mean the stream *is* a paste,
294 /// so the full window applies and later lines stay absorbed.
295 pub fn arm_window_for_direct_char(&mut self, now: Instant, rapid_chars: u16) {
296 if rapid_chars >= 2 {
297 self.extend_window(now);
298 } else {
299 self.burst_window_until = Some(now + PASTE_BURST_CHAR_INTERVAL);
300 }
301 }
302 }
303
304 pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize {
305 if retro_chars == 0 {
306 return before.len();
307 }
308 before
309 .char_indices()
310 .rev()
311 .nth(retro_chars.saturating_sub(1))
312 .map(|(idx, _)| idx)
313 .unwrap_or(0)
314 }
315
316 #[cfg(test)]
317 mod tests {
318 use super::*;
319
320 #[test]
321 fn ascii_first_char_is_held_then_flushes_as_typed() {
322 let mut burst = PasteBurst::default();
323 let t0 = Instant::now();
324 assert!(matches!(
325 burst.on_plain_char('a', t0),
326 CharDecision::RetainFirstChar
327 ));
328
329 let t1 = t0 + PasteBurst::recommended_flush_delay() + Duration::from_millis(1);
330 assert!(matches!(burst.flush_if_due(t1), FlushResult::Typed('a')));
331 assert!(!burst.is_active());
332 }
333
334 #[test]
335 fn ascii_two_fast_chars_start_buffer_from_pending_and_flush_as_paste() {
336 let mut burst = PasteBurst::default();
337 let t0 = Instant::now();
338 assert!(matches!(
339 burst.on_plain_char('a', t0),
340 CharDecision::RetainFirstChar
341 ));
342
343 let t1 = t0 + Duration::from_millis(1);
344 assert!(matches!(
345 burst.on_plain_char('b', t1),
346 CharDecision::BeginBufferFromPending
347 ));
348 burst.append_char_to_buffer('b', t1);
349
350 let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);
351 assert!(matches!(
352 burst.flush_if_due(t2),
353 FlushResult::Paste(ref s) if s == "ab"
354 ));
355 }
356
357 #[test]
358 fn flush_before_modified_input_includes_pending_first_char() {
359 let mut burst = PasteBurst::default();
360 let t0 = Instant::now();
361 assert!(matches!(
362 burst.on_plain_char('a', t0),
363 CharDecision::RetainFirstChar
364 ));
365
366 assert_eq!(burst.flush_before_modified_input(), Some("a".to_string()));
367 assert!(!burst.is_active());
368 }
369
370 #[test]
371 fn next_flush_delay_counts_down_to_zero() {
372 let mut burst = PasteBurst::default();
373 let t0 = Instant::now();
374 let _ = burst.on_plain_char('a', t0);
375
376 let almost_due = t0 + Duration::from_millis(7);
377 let remaining = burst
378 .next_flush_delay(almost_due)
379 .expect("delay should exist");
380 assert!(remaining <= Duration::from_millis(1));
381
382 let due = t0 + Duration::from_millis(20);
383 assert_eq!(burst.next_flush_delay(due), Some(Duration::ZERO));
384 }
385
386 /// Simulate #2134: when a non-char key (Tab) arrives during table-data
387 /// paste, `deactivate_keep_window` resets accumulation state but
388 /// preserves the Enter-suppression window so a trailing newline is still
389 /// absorbed instead of submitting the partial input.
390 #[test]
391 fn deactivate_keep_window_preserves_enter_suppression_window() {
392 let mut burst = PasteBurst::default();
393 let t0 = Instant::now();
394
395 assert!(matches!(
396 burst.on_plain_char('a', t0),
397 CharDecision::RetainFirstChar
398 ));
399 let t1 = t0 + Duration::from_millis(1);
400 assert!(matches!(
401 burst.on_plain_char('b', t1),
402 CharDecision::BeginBufferFromPending
403 ));
404 burst.append_char_to_buffer('b', t1);
405 assert!(burst.is_active());
406 assert!(burst.newline_should_insert_instead_of_submit(t1));
407
408 let flushed = burst.flush_before_modified_input();
409 assert!(flushed.is_some());
410 assert!(!burst.is_active());
411
412 burst.deactivate_keep_window();
413
414 assert!(!burst.is_active());
415
416 let t_tab = t1 + Duration::from_millis(2);
417 assert!(
418 burst.newline_should_insert_instead_of_submit(t_tab),
419 "Enter within suppression window should insert newline, not submit"
420 );
421
422 let t_expired = t_tab + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1);
423 assert!(
424 !burst.newline_should_insert_instead_of_submit(t_expired),
425 "Enter after suppression window expires should submit"
426 );
427 }
428
429 /// The idle flush must NOT drop the Enter-suppression window. The active
430 /// idle timeout is only 8ms, so a paste's trailing newline can easily
431 /// land just after the flush on a laggy link — dropping the window there
432 /// would submit a partial paste (#1073).
433 #[test]
434 fn idle_flush_keeps_enter_suppression_window_alive() {
435 let mut burst = PasteBurst::default();
436 let t0 = Instant::now();
437
438 let _ = burst.on_plain_char('a', t0);
439 let t1 = t0 + Duration::from_millis(1);
440 assert!(matches!(
441 burst.on_plain_char('b', t1),
442 CharDecision::BeginBufferFromPending
443 ));
444 burst.append_char_to_buffer('b', t1);
445
446 let t_flush = t1 + PasteBurst::recommended_active_flush_delay();
447 assert!(matches!(
448 burst.flush_if_due(t_flush),
449 FlushResult::Paste(ref s) if s == "ab"
450 ));
451 assert!(!burst.is_active());
452 assert!(
453 burst.newline_should_insert_instead_of_submit(t_flush),
454 "a trailing pasted newline arriving right after the idle flush \
455 must still be absorbed instead of submitting"
456 );
457 }
458
459 /// …but the window is *bounded*: it expires 120ms after the last real
460 /// keystroke and nothing about the flush re-arms it, so the user's next
461 /// Enter submits.
462 #[test]
463 fn enter_suppression_window_expires_after_the_last_keystroke() {
464 let mut burst = PasteBurst::default();
465 let t0 = Instant::now();
466
467 let _ = burst.on_plain_char('a', t0);
468 let t1 = t0 + Duration::from_millis(1);
469 let _ = burst.on_plain_char('b', t1);
470 burst.append_char_to_buffer('b', t1);
471 let t_flush = t1 + PasteBurst::recommended_active_flush_delay();
472 let _ = burst.flush_if_due(t_flush);
473
474 let t_late = t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1);
475 assert!(
476 !burst.newline_should_insert_instead_of_submit(t_late),
477 "Enter more than the suppression window after the paste must submit"
478 );
479 }
480
481 /// A lone IME candidate commit is ordinary typing: it may only hold Enter
482 /// for one burst interval, so a user finishing a CJK sentence and
483 /// pressing Enter actually sends.
484 #[test]
485 fn lone_non_ascii_commit_arms_only_a_burst_interval_window() {
486 let mut burst = PasteBurst::default();
487 let t0 = Instant::now();
488
489 let rapid = burst.note_plain_char(t0);
490 assert_eq!(rapid, 1, "an isolated commit is a run of one");
491 burst.arm_window_for_direct_char(t0, rapid);
492
493 assert!(
494 burst.newline_should_insert_instead_of_submit(t0 + PASTE_BURST_CHAR_INTERVAL),
495 "a raw paste delivers its trailing newline within the burst \
496 interval and must still be absorbed (#1302)"
497 );
498 assert!(
499 !burst.newline_should_insert_instead_of_submit(
500 t0 + PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
501 ),
502 "an IME commit must not swallow the Enter a human presses \
503 tens of milliseconds later"
504 );
505 }
506
507 /// Two non-ASCII characters at paste speed mean the stream is a paste,
508 /// so the full suppression window applies to later lines.
509 #[test]
510 fn rapid_non_ascii_run_arms_the_full_suppression_window() {
511 let mut burst = PasteBurst::default();
512 let t0 = Instant::now();
513
514 let rapid = burst.note_plain_char(t0);
515 burst.arm_window_for_direct_char(t0, rapid);
516 let t1 = t0 + Duration::from_millis(1);
517 let rapid = burst.note_plain_char(t1);
518 assert_eq!(rapid, 2);
519 burst.arm_window_for_direct_char(t1, rapid);
520
521 assert!(
522 burst.newline_should_insert_instead_of_submit(t1 + PASTE_ENTER_SUPPRESS_WINDOW),
523 "a raw CJK paste must keep absorbing its embedded newlines"
524 );
525 assert!(
526 !burst.newline_should_insert_instead_of_submit(
527 t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1)
528 ),
529 "even a paste-speed run releases Enter once the window lapses"
530 );
531 }
532
533 /// A slow IME sequence never accumulates a rapid run, so every commit
534 /// re-arms only the short window and Enter stays available throughout.
535 #[test]
536 fn slow_ime_sequence_never_holds_enter() {
537 let mut burst = PasteBurst::default();
538 let t0 = Instant::now();
539
540 // "你好世界" committed one character at a time with human gaps.
541 for i in 0..4u64 {
542 let now = t0 + Duration::from_millis(50 * i);
543 let rapid = burst.note_plain_char(now);
544 assert_eq!(rapid, 1, "50ms gaps are never a paste-speed run");
545 burst.arm_window_for_direct_char(now, rapid);
546 }
547
548 let last = t0 + Duration::from_millis(150);
549 assert!(
550 !burst.newline_should_insert_instead_of_submit(last + Duration::from_millis(30)),
551 "Enter after an IME-typed CJK message must submit"
552 );
553 }
554 }
555
555 lines RUST