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