返回 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 self.buffer.push('\n');
149 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
150 true
151 } else {
152 false
153 }
154 }
155
156 pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
157 let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
158 self.is_active() || in_burst_window
159 }
160
161 pub fn extend_window(&mut self, now: Instant) {
162 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
163 }
164
165 /// Begin buffering from the current char, leaving already-typed text
166 /// untouched. The burst never rewrites the composer (Y-7): chars before
167 /// the burst stay exactly as the user typed them.
168 pub fn begin_buffer_from_now(&mut self, ch: char, now: Instant) {
169 self.buffer.push(ch);
170 self.active = true;
171 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
172 }
173
174 /// The text the heuristic currently holds on the user's behalf: the
175 /// held first char plus the burst buffer, in arrival order. Command
176 /// context must be judged on this — while a burst is held the composer
177 /// can be empty even though the user is mid-command (Y-7).
178 pub fn held_text(&self) -> String {
179 let mut out = String::new();
180 if let Some((ch, _)) = self.pending_first_char {
181 out.push(ch);
182 }
183 out.push_str(&self.buffer);
184 out
185 }
186
187 pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
188 self.buffer.push(ch);
189 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
190 }
191
192 pub fn flush_before_modified_input(&mut self) -> Option<String> {
193 if !self.is_active() {
194 return None;
195 }
196 self.active = false;
197 let mut out = std::mem::take(&mut self.buffer);
198 if let Some((ch, _)) = self.pending_first_char.take() {
199 out.push(ch);
200 }
201 Some(out)
202 }
203
204 /// Reset burst-accumulation state without clearing the suppression window.
205 ///
206 /// Used when a non-char key (Tab, etc.) arrives during an active burst as
207 /// part of table-data paste. The buffer was flushed upstream; only the
208 /// active state is reset so `burst_window_until` stays alive and a trailing
209 /// Enter is still absorbed as a newline (#2134).
210 ///
211 /// # Panics
212 ///
213 /// Panics in debug builds if `buffer` is non-empty — the caller must flush
214 /// via `flush_before_modified_input` first.
215 pub fn deactivate_keep_window(&mut self) {
216 debug_assert!(
217 self.buffer.is_empty(),
218 "buffer must be flushed before deactivating"
219 );
220 self.consecutive_plain_char_burst = 0;
221 self.last_plain_char_time = None;
222 self.active = false;
223 self.pending_first_char = None;
224 // burst_window_until intentionally NOT cleared
225 }
226
227 pub fn is_active(&self) -> bool {
228 self.is_active_internal() || self.pending_first_char.is_some()
229 }
230
231 fn is_active_internal(&self) -> bool {
232 self.active || !self.buffer.is_empty()
233 }
234
235 pub fn clear_after_explicit_paste(&mut self) {
236 self.last_plain_char_time = None;
237 self.consecutive_plain_char_burst = 0;
238 self.burst_window_until = None;
239 self.active = false;
240 self.buffer.clear();
241 self.pending_first_char = None;
242 }
243
244 /// Arm the Enter-suppression window for a non-ASCII character that was
245 /// inserted straight into the composer instead of being buffered (the
246 /// IME / raw-CJK path in `tui::paste`).
247 ///
248 /// `rapid_chars` is the run length reported by [`Self::note_plain_char`].
249 ///
250 /// A *lone* commit only earns a burst-interval window. An IME candidate
251 /// commit is ordinary typing: the user may press Enter to send a
252 /// message ending in a CJK character tens of milliseconds later, and the
253 /// full 120ms window turned that Enter into a stray newline. A real raw
254 /// paste delivers its trailing newline within microseconds of the last
255 /// character, so the short window still absorbs it — including the
256 /// single-character first line of a CJK paste (#1302).
257 ///
258 /// Two or more characters at paste speed mean the stream *is* a paste,
259 /// so the full window applies and later lines stay absorbed.
260 pub fn arm_window_for_direct_char(&mut self, now: Instant, rapid_chars: u16) {
261 if rapid_chars >= 2 {
262 self.extend_window(now);
263 } else {
264 self.burst_window_until = Some(now + PASTE_BURST_CHAR_INTERVAL);
265 }
266 }
267 }
268
269 #[cfg(test)]
270 mod tests {
271 use super::*;
272
273 #[test]
274 fn ascii_first_char_is_held_then_flushes_as_typed() {
275 let mut burst = PasteBurst::default();
276 let t0 = Instant::now();
277 assert!(matches!(
278 burst.on_plain_char('a', t0),
279 CharDecision::RetainFirstChar
280 ));
281
282 let t1 = t0 + PasteBurst::recommended_flush_delay() + Duration::from_millis(1);
283 assert!(matches!(burst.flush_if_due(t1), FlushResult::Typed('a')));
284 assert!(!burst.is_active());
285 }
286
287 #[test]
288 fn ascii_two_fast_chars_start_buffer_from_pending_and_flush_as_paste() {
289 let mut burst = PasteBurst::default();
290 let t0 = Instant::now();
291 assert!(matches!(
292 burst.on_plain_char('a', t0),
293 CharDecision::RetainFirstChar
294 ));
295
296 let t1 = t0 + Duration::from_millis(1);
297 assert!(matches!(
298 burst.on_plain_char('b', t1),
299 CharDecision::BeginBufferFromPending
300 ));
301 burst.append_char_to_buffer('b', t1);
302
303 let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);
304 assert!(matches!(
305 burst.flush_if_due(t2),
306 FlushResult::Paste(ref s) if s == "ab"
307 ));
308 }
309
310 #[test]
311 fn flush_before_modified_input_includes_pending_first_char() {
312 let mut burst = PasteBurst::default();
313 let t0 = Instant::now();
314 assert!(matches!(
315 burst.on_plain_char('a', t0),
316 CharDecision::RetainFirstChar
317 ));
318
319 assert_eq!(burst.flush_before_modified_input(), Some("a".to_string()));
320 assert!(!burst.is_active());
321 }
322
323 #[test]
324 fn settled_input_stops_polling_and_expiry_requests_one_redraw() {
325 let mut burst = PasteBurst::default();
326 let now = Instant::now();
327 let _ = burst.on_plain_char('a', now);
328 assert!(matches!(
329 burst.flush_if_due(now + Duration::from_millis(20)),
330 FlushResult::Typed('a')
331 ));
332 assert_eq!(
333 burst.next_flush_delay(now + Duration::from_millis(20)),
334 None
335 );
336
337 burst.extend_window(now);
338 let inside = now + Duration::from_millis(100);
339 assert!(burst.newline_should_insert_instead_of_submit(inside));
340 assert_eq!(
341 burst.next_flush_delay(inside),
342 Some(Duration::from_millis(21))
343 );
344 let expired = now + Duration::from_millis(121);
345 assert!(matches!(
346 burst.flush_if_due(expired),
347 FlushResult::SuppressionExpired
348 ));
349 assert!(!burst.newline_should_insert_instead_of_submit(expired));
350 assert_eq!(burst.next_flush_delay(expired), None);
351 assert!(matches!(burst.flush_if_due(expired), FlushResult::None));
352 }
353
354 #[test]
355 fn next_flush_delay_counts_down_to_zero() {
356 let mut burst = PasteBurst::default();
357 let t0 = Instant::now();
358 let _ = burst.on_plain_char('a', t0);
359
360 let almost_due = t0 + Duration::from_millis(7);
361 let remaining = burst
362 .next_flush_delay(almost_due)
363 .expect("delay should exist");
364 assert!(remaining <= Duration::from_millis(1));
365
366 let due = t0 + Duration::from_millis(20);
367 assert_eq!(burst.next_flush_delay(due), Some(Duration::ZERO));
368 }
369
370 /// Simulate #2134: when a non-char key (Tab) arrives during table-data
371 /// paste, `deactivate_keep_window` resets accumulation state but
372 /// preserves the Enter-suppression window so a trailing newline is still
373 /// absorbed instead of submitting the partial input.
374 #[test]
375 fn deactivate_keep_window_preserves_enter_suppression_window() {
376 let mut burst = PasteBurst::default();
377 let t0 = Instant::now();
378
379 assert!(matches!(
380 burst.on_plain_char('a', t0),
381 CharDecision::RetainFirstChar
382 ));
383 let t1 = t0 + Duration::from_millis(1);
384 assert!(matches!(
385 burst.on_plain_char('b', t1),
386 CharDecision::BeginBufferFromPending
387 ));
388 burst.append_char_to_buffer('b', t1);
389 assert!(burst.is_active());
390 assert!(burst.newline_should_insert_instead_of_submit(t1));
391
392 let flushed = burst.flush_before_modified_input();
393 assert!(flushed.is_some());
394 assert!(!burst.is_active());
395
396 burst.deactivate_keep_window();
397
398 assert!(!burst.is_active());
399
400 let t_tab = t1 + Duration::from_millis(2);
401 assert!(
402 burst.newline_should_insert_instead_of_submit(t_tab),
403 "Enter within suppression window should insert newline, not submit"
404 );
405
406 let t_expired = t_tab + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1);
407 assert!(
408 !burst.newline_should_insert_instead_of_submit(t_expired),
409 "Enter after suppression window expires should submit"
410 );
411 }
412
413 /// The idle flush must NOT drop the Enter-suppression window. The active
414 /// idle timeout is only 8ms, so a paste's trailing newline can easily
415 /// land just after the flush on a laggy link — dropping the window there
416 /// would submit a partial paste (#1073).
417 #[test]
418 fn idle_flush_keeps_enter_suppression_window_alive() {
419 let mut burst = PasteBurst::default();
420 let t0 = Instant::now();
421
422 let _ = burst.on_plain_char('a', t0);
423 let t1 = t0 + Duration::from_millis(1);
424 assert!(matches!(
425 burst.on_plain_char('b', t1),
426 CharDecision::BeginBufferFromPending
427 ));
428 burst.append_char_to_buffer('b', t1);
429
430 let t_flush = t1 + PasteBurst::recommended_active_flush_delay();
431 assert!(matches!(
432 burst.flush_if_due(t_flush),
433 FlushResult::Paste(ref s) if s == "ab"
434 ));
435 assert!(!burst.is_active());
436 assert!(
437 burst.newline_should_insert_instead_of_submit(t_flush),
438 "a trailing pasted newline arriving right after the idle flush \
439 must still be absorbed instead of submitting"
440 );
441 }
442
443 /// …but the window is *bounded*: it expires 120ms after the last real
444 /// keystroke and nothing about the flush re-arms it, so the user's next
445 /// Enter submits.
446 #[test]
447 fn enter_suppression_window_expires_after_the_last_keystroke() {
448 let mut burst = PasteBurst::default();
449 let t0 = Instant::now();
450
451 let _ = burst.on_plain_char('a', t0);
452 let t1 = t0 + Duration::from_millis(1);
453 let _ = burst.on_plain_char('b', t1);
454 burst.append_char_to_buffer('b', t1);
455 let t_flush = t1 + PasteBurst::recommended_active_flush_delay();
456 let _ = burst.flush_if_due(t_flush);
457
458 let t_late = t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1);
459 assert!(
460 !burst.newline_should_insert_instead_of_submit(t_late),
461 "Enter more than the suppression window after the paste must submit"
462 );
463 }
464
465 /// A lone IME candidate commit is ordinary typing: it may only hold Enter
466 /// for one burst interval, so a user finishing a CJK sentence and
467 /// pressing Enter actually sends.
468 #[test]
469 fn lone_non_ascii_commit_arms_only_a_burst_interval_window() {
470 let mut burst = PasteBurst::default();
471 let t0 = Instant::now();
472
473 let rapid = burst.note_plain_char(t0);
474 assert_eq!(rapid, 1, "an isolated commit is a run of one");
475 burst.arm_window_for_direct_char(t0, rapid);
476
477 assert!(
478 burst.newline_should_insert_instead_of_submit(t0 + PASTE_BURST_CHAR_INTERVAL),
479 "a raw paste delivers its trailing newline within the burst \
480 interval and must still be absorbed (#1302)"
481 );
482 assert!(
483 !burst.newline_should_insert_instead_of_submit(
484 t0 + PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
485 ),
486 "an IME commit must not swallow the Enter a human presses \
487 tens of milliseconds later"
488 );
489 }
490
491 /// Two non-ASCII characters at paste speed mean the stream is a paste,
492 /// so the full suppression window applies to later lines.
493 #[test]
494 fn rapid_non_ascii_run_arms_the_full_suppression_window() {
495 let mut burst = PasteBurst::default();
496 let t0 = Instant::now();
497
498 let rapid = burst.note_plain_char(t0);
499 burst.arm_window_for_direct_char(t0, rapid);
500 let t1 = t0 + Duration::from_millis(1);
501 let rapid = burst.note_plain_char(t1);
502 assert_eq!(rapid, 2);
503 burst.arm_window_for_direct_char(t1, rapid);
504
505 assert!(
506 burst.newline_should_insert_instead_of_submit(t1 + PASTE_ENTER_SUPPRESS_WINDOW),
507 "a raw CJK paste must keep absorbing its embedded newlines"
508 );
509 assert!(
510 !burst.newline_should_insert_instead_of_submit(
511 t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1)
512 ),
513 "even a paste-speed run releases Enter once the window lapses"
514 );
515 }
516
517 /// A slow IME sequence never accumulates a rapid run, so every commit
518 /// re-arms only the short window and Enter stays available throughout.
519 #[test]
520 fn slow_ime_sequence_never_holds_enter() {
521 let mut burst = PasteBurst::default();
522 let t0 = Instant::now();
523
524 // "你好世界" committed one character at a time with human gaps.
525 for i in 0..4u64 {
526 let now = t0 + Duration::from_millis(50 * i);
527 let rapid = burst.note_plain_char(now);
528 assert_eq!(rapid, 1, "50ms gaps are never a paste-speed run");
529 burst.arm_window_for_direct_char(now, rapid);
530 }
531
532 let last = t0 + Duration::from_millis(150);
533 assert!(
534 !burst.newline_should_insert_instead_of_submit(last + Duration::from_millis(30)),
535 "Enter after an IME-typed CJK message must submit"
536 );
537 }
538 }
539
539 lines RUST