supercode_frontend_model/paste_burst.rs
1// Derived from OpenAI Codex: codex-rs/tui/src/bottom_pane/paste_burst.rs
2// Pinned source: 8604689ec5e3437eb79802d8d72249b7722fbf5b
3// Copyright 2025 OpenAI
4// Licensed under the Apache License, Version 2.0.
5// Modified by the Supercode contributors; see docs/legal/codex-frontend-extraction.toml.
6
7//! Paste-burst detection for terminals without bracketed paste.
8//!
9//! On some platforms (notably Windows), pastes often arrive as a rapid stream of
10//! `KeyCode::Char` and `KeyCode::Enter` key events rather than as a single "paste" event.
11//! In that mode, the composer needs to:
12//!
13//! - Prevent transient UI side effects (e.g. toggles bound to `?`) from triggering on pasted text.
14//! - Ensure Enter is treated as a newline *inside the paste*, not as "submit the message".
15//! - Avoid flicker caused by inserting a typed prefix and then immediately reclassifying it as
16//! paste once enough chars have arrived.
17//!
18//! This module provides the `PasteBurst` state machine. `ChatComposer` feeds it only "plain"
19//! character events (no Ctrl/Alt) and uses the full buffering decisions to either:
20//!
21//! - briefly hold a first ASCII char (flicker suppression),
22//! - buffer a burst as a single pasted string, or
23//! - let input flow through as normal typing.
24//!
25//! # Call Pattern
26//!
27//! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds
28//! it events and then applies the chosen action:
29//!
30//! - For each plain `KeyCode::Char`, call [`PasteBurst::on_plain_char`] (ASCII) or
31//! [`PasteBurst::on_plain_char_no_hold`] (non-ASCII/IME).
32//! - If the decision indicates buffering, the caller appends to `PasteBurst.buffer` via
33//! [`PasteBurst::append_char_to_buffer`].
34//! - On a UI tick, call [`PasteBurst::flush_if_due`]. If it returns [`FlushResult::Typed`], insert
35//! that char as normal typing. If it returns [`FlushResult::Paste`], treat the returned string as
36//! an explicit paste.
37//! - Before applying non-char input (arrow keys, Ctrl/Alt modifiers, etc.), use
38//! [`PasteBurst::flush_before_modified_input`] to avoid leaving buffered text "stuck", and then
39//! [`PasteBurst::clear_window_after_non_char`] so subsequent typing does not get grouped into a
40//! previous burst.
41//! - Direct-insert callers can skip buffering, use
42//! [`PasteBurst::direct_insert_newline_should_insert`] in their Enter handler, and call
43//! [`PasteBurst::extend_window`] when Enter or [`PasteBurst::on_plain_char_no_hold`] reports a
44//! burst-like stream.
45//!
46//! # State Variables
47//!
48//! This state machine is encoded in a few fields with slightly different meanings:
49//!
50//! - `active`: true while we are still *actively* accepting characters into the current burst.
51//! - `buffer`: accumulated burst text that will eventually flush as a single `Paste(String)`.
52//! A non-empty buffer is treated as "in burst context" even if `active` has been cleared.
53//! - `pending_first_char`: a single held ASCII char used for flicker suppression. The caller must
54//! not render this char until it either becomes part of a burst (`BeginBufferFromPending`) or
55//! flushes as a normal typed char (`FlushResult::Typed`).
56//! - `last_plain_char_time`/`consecutive_plain_char_burst`: the timing/count heuristic for
57//! "paste-like" streams.
58//! - `burst_window_until`: the Enter suppression window ("Enter inserts newline") that outlives the
59//! buffer itself.
60//!
61//! # Timing Model
62//!
63//! There are two timeouts:
64//!
65//! - `PASTE_BURST_CHAR_INTERVAL`: maximum delay between consecutive "plain" chars for them to be
66//! considered part of a single burst. It also bounds how long `pending_first_char` is held.
67//! - `PASTE_BURST_ACTIVE_IDLE_TIMEOUT`: once buffering is active, how long to wait after the last
68//! char before flushing the accumulated buffer as a paste.
69//!
70//! `flush_if_due()` intentionally uses `>` (not `>=`) when comparing elapsed time, so tests and UI
71//! ticks should cross the threshold by at least 1ms (see `recommended_flush_delay()`).
72//!
73//! # Retro Capture Details
74//!
75//! Retro-capture exists to handle the case where we initially inserted characters as "normal
76//! typing", but later decide that the stream is paste-like. When that happens, we retroactively
77//! remove a prefix of already-inserted text from the textarea and move it into the burst buffer so
78//! the eventual `handle_paste(...)` sees a contiguous pasted string.
79//!
80//! Retro-capture mostly matters on paths that do *not* hold the first character (non-ASCII/IME
81//! input, and retro-grab scenarios). The ASCII path usually prefers
82//! `RetainFirstChar -> BeginBufferFromPending`, which avoids needing retro-capture at all.
83//!
84//! Retro-capture is expressed in terms of characters, not bytes:
85//!
86//! - `CharDecision::BeginBuffer { retro_chars }` uses `retro_chars` as a character count.
87//! - `decide_begin_buffer(now, before_cursor, retro_chars)` turns that into a UTF-8 byte range by
88//! calling `retro_start_index()`.
89//! - `RetroGrab.start_byte` is a byte index into the `before_cursor` slice; callers must clamp the
90//! cursor to a char boundary before slicing so `start_byte..cursor` is always valid UTF-8.
91//!
92//! # Clearing vs Flushing
93//!
94//! There are two ways callers end burst handling, and they are not interchangeable:
95//!
96//! - `flush_before_modified_input()` returns the buffered text (and/or a pending first ASCII char)
97//! so the caller can apply it through the normal paste path before handling an unrelated input.
98//! - `clear_window_after_non_char()` clears the *classification window* so subsequent typing does
99//! not get grouped into the previous burst. It assumes the caller has already flushed any buffer
100//! because it clears `last_plain_char_time`, which means `flush_if_due()` will not flush a
101//! non-empty buffer until another plain char updates the timestamp.
102//!
103//! # States (Conceptually)
104//!
105//! - **Idle**: no buffered text, no pending char.
106//! - **Pending first char**: `pending_first_char` holds one ASCII char for up to
107//! `PASTE_BURST_CHAR_INTERVAL` while we wait to see if a burst follows.
108//! - **Active buffer**: `active`/`buffer` holds paste-like content until it times out and flushes.
109//! - **Enter suppress window**: `burst_window_until` keeps Enter treated as newline briefly after
110//! burst activity so multiline pastes stay grouped.
111//!
112//! # ASCII vs Non-ASCII
113//!
114//! - [`PasteBurst::on_plain_char`] may return [`CharDecision::RetainFirstChar`] to hold the first
115//! ASCII char and avoid flicker.
116//! - [`PasteBurst::on_plain_char_no_hold`] never holds (used for IME/non-ASCII paths), since
117//! holding a non-ASCII character can feel like dropped input.
118//!
119//! # Contract With Callers
120//!
121//! `PasteBurst` does not mutate the UI text buffer on its own. Callers must interpret decisions
122//! and apply the corresponding UI edits. `ChatComposer` uses the full buffering contract:
123//!
124//! - For each plain ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char`].
125//! - [`CharDecision::RetainFirstChar`]: do **not** insert the char into the textarea yet.
126//! - [`CharDecision::BeginBufferFromPending`]: call [`PasteBurst::append_char_to_buffer`] for the
127//! current char (the previously-held char is already in the burst buffer).
128//! - [`CharDecision::BeginBuffer { retro_chars }`]: consider retro-capturing the already-inserted
129//! prefix by calling [`PasteBurst::decide_begin_buffer`]. If it returns `Some`, remove the
130//! returned `start_byte..cursor` range from the textarea and then call
131//! [`PasteBurst::append_char_to_buffer`] for the current char. If it returns `None`, fall back
132//! to normal insertion.
133//! - [`CharDecision::BufferAppend`]: call [`PasteBurst::append_char_to_buffer`].
134//!
135//! - For each plain non-ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char_no_hold`] and then:
136//! - If it returns `Some(CharDecision::BufferAppend)`, call
137//! [`PasteBurst::append_char_to_buffer`].
138//! - If it returns `Some(CharDecision::BeginBuffer { retro_chars })`, call
139//! [`PasteBurst::decide_begin_buffer`] as above (and if buffering starts, remove the grabbed
140//! prefix from the textarea and then append the current char to the buffer).
141//! - If it returns `None`, insert normally.
142//!
143//! - Before applying non-char input (or any input that should not join a burst), call
144//! [`PasteBurst::flush_before_modified_input`] and pass the returned string (if any) through the
145//! normal paste path.
146//!
147//! - Periodically (e.g. on a UI tick), call [`PasteBurst::flush_if_due`].
148//! - [`FlushResult::Typed`]: insert that single char as normal typing.
149//! - [`FlushResult::Paste`]: treat the returned string as an explicit paste.
150//!
151//! - When a non-plain key is pressed (Ctrl/Alt-modified input, arrows, etc.), callers should use
152//! [`PasteBurst::clear_window_after_non_char`] to prevent the next keystroke from being
153//! incorrectly grouped into a previous burst.
154
155use std::time::Duration;
156use std::time::Instant;
157
158// Heuristic thresholds for detecting paste-like input bursts.
159// Detect quickly to avoid showing typed prefix before paste is recognized
160const PASTE_BURST_MIN_CHARS: u16 = 3;
161const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
162
163// Maximum delay between consecutive chars to be considered part of a paste burst.
164const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
165
166// Idle timeout before flushing buffered paste content.
167// Slower paste bursts have been observed in Windows environments.
168#[cfg(not(windows))]
169const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8);
170#[cfg(windows)]
171const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60);
172
173#[derive(Clone, Debug, Default)]
174pub struct PasteBurst {
175 last_plain_char_time: Option<Instant>,
176 consecutive_plain_char_burst: u16,
177 burst_window_until: Option<Instant>,
178 buffer: String,
179 active: bool,
180 // Hold first fast char briefly to avoid rendering flicker
181 pending_first_char: Option<(char, Instant)>,
182}
183
184pub enum CharDecision {
185 /// Start buffering and retroactively capture some already-inserted chars.
186 BeginBuffer { retro_chars: u16 },
187 /// We are currently buffering; append the current char into the buffer.
188 BufferAppend,
189 /// Do not insert/render this char yet; temporarily save the first fast
190 /// char while we wait to see if a paste-like burst follows.
191 RetainFirstChar,
192 /// Begin buffering using the previously saved first char (no retro grab needed).
193 BeginBufferFromPending,
194}
195
196pub struct RetroGrab {
197 pub start_byte: usize,
198 pub grabbed: String,
199}
200
201pub enum FlushResult {
202 Paste(String),
203 Typed(char),
204 None,
205}
206
207impl PasteBurst {
208 /// Recommended delay to wait between simulated keypresses (or before
209 /// scheduling a UI tick) so that a pending fast keystroke is flushed
210 /// out of the burst detector as normal typed input.
211 ///
212 /// Primarily used by tests and by the TUI to reliably cross the
213 /// paste-burst timing threshold.
214 pub fn recommended_flush_delay() -> Duration {
215 PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
216 }
217
218 #[cfg(test)]
219 pub fn recommended_active_flush_delay() -> Duration {
220 PASTE_BURST_ACTIVE_IDLE_TIMEOUT + Duration::from_millis(1)
221 }
222
223 /// Entry point: decide how to treat a plain char with current timing.
224 pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision {
225 self.note_plain_char(now);
226
227 if self.active {
228 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
229 return CharDecision::BufferAppend;
230 }
231
232 // If we already held a first char and receive a second fast char,
233 // start buffering without retro-grabbing (we never rendered the first).
234 if let Some((held, held_at)) = self.pending_first_char {
235 if now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL {
236 self.active = true;
237 // take() to clear pending; we already captured the held char above
238 let _ = self.pending_first_char.take();
239 self.buffer.push(held);
240 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
241 return CharDecision::BeginBufferFromPending;
242 }
243 }
244
245 if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
246 return CharDecision::BeginBuffer {
247 retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
248 };
249 }
250
251 // Save the first fast char very briefly to see if a burst follows.
252 self.pending_first_char = Some((ch, now));
253 CharDecision::RetainFirstChar
254 }
255
256 /// Like on_plain_char(), but never holds the first char.
257 ///
258 /// Used for non-ASCII input paths (e.g., IMEs) where holding a character can
259 /// feel like dropped input, while still allowing burst-based paste detection.
260 ///
261 /// Note: This method will only ever return BufferAppend or BeginBuffer.
262 pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option<CharDecision> {
263 self.note_plain_char(now);
264
265 if self.active {
266 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
267 return Some(CharDecision::BufferAppend);
268 }
269
270 if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
271 return Some(CharDecision::BeginBuffer {
272 retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
273 });
274 }
275
276 None
277 }
278
279 fn note_plain_char(&mut self, now: Instant) {
280 match self.last_plain_char_time {
281 Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
282 self.consecutive_plain_char_burst =
283 self.consecutive_plain_char_burst.saturating_add(1)
284 }
285 _ => self.consecutive_plain_char_burst = 1,
286 }
287 self.last_plain_char_time = Some(now);
288 }
289
290 /// Flushes any buffered burst if the inter-key timeout has elapsed.
291 ///
292 /// Returns:
293 ///
294 /// - [`FlushResult::Paste`] when a paste burst was active and buffered text is emitted as one
295 /// pasted string.
296 /// - [`FlushResult::Typed`] when a single fast first ASCII char was being held (flicker
297 /// suppression) and no burst followed before the timeout elapsed.
298 /// - [`FlushResult::None`] when the timeout has not elapsed, or there is nothing to flush.
299 pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
300 let timeout = if self.is_active_internal() {
301 PASTE_BURST_ACTIVE_IDLE_TIMEOUT
302 } else {
303 PASTE_BURST_CHAR_INTERVAL
304 };
305 let timed_out = self
306 .last_plain_char_time
307 .is_some_and(|t| now.duration_since(t) > timeout);
308 if timed_out && self.is_active_internal() {
309 self.active = false;
310 let out = std::mem::take(&mut self.buffer);
311 FlushResult::Paste(out)
312 } else if timed_out {
313 // If we were saving a single fast char and no burst followed,
314 // flush it as normal typed input.
315 if let Some((ch, _at)) = self.pending_first_char.take() {
316 FlushResult::Typed(ch)
317 } else {
318 FlushResult::None
319 }
320 } else {
321 FlushResult::None
322 }
323 }
324
325 /// While bursting: accumulate a newline into the buffer instead of
326 /// submitting the textarea.
327 ///
328 /// Returns true if a newline was appended (we are in a burst context),
329 /// false otherwise.
330 pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
331 if self.is_active() {
332 self.buffer.push('\n');
333 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
334 true
335 } else {
336 false
337 }
338 }
339
340 /// Decide if Enter should insert a newline (burst context) vs submit.
341 pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
342 let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
343 self.is_active() || in_burst_window
344 }
345
346 /// Decide if Enter should insert a newline for callers that insert chars immediately.
347 pub fn direct_insert_newline_should_insert(&self, now: Instant) -> bool {
348 self.newline_should_insert_instead_of_submit(now)
349 || (self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS
350 && self
351 .last_plain_char_time
352 .is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL))
353 }
354
355 /// Keep the burst window alive.
356 pub fn extend_window(&mut self, now: Instant) {
357 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
358 }
359
360 /// Begin buffering with retroactively grabbed text.
361 pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) {
362 if !grabbed.is_empty() {
363 self.buffer.push_str(&grabbed);
364 }
365 self.active = true;
366 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
367 }
368
369 /// Append a char into the burst buffer.
370 pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
371 self.buffer.push(ch);
372 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
373 }
374
375 /// Try to append a char into the burst buffer only if a burst is already active.
376 ///
377 /// Returns true when the char was captured into the existing burst, false otherwise.
378 pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool {
379 if self.active || !self.buffer.is_empty() {
380 self.append_char_to_buffer(ch, now);
381 true
382 } else {
383 false
384 }
385 }
386
387 /// Decide whether to begin buffering by retroactively capturing recent
388 /// chars from the slice before the cursor.
389 ///
390 /// Heuristic: if the retro-grabbed slice contains any whitespace or is
391 /// sufficiently long (>= 16 characters), treat it as paste-like to avoid
392 /// rendering the typed prefix momentarily before the paste is recognized.
393 /// This favors responsiveness and prevents flicker for typical pastes
394 /// (URLs, file paths, multiline text) while not triggering on short words.
395 ///
396 /// Returns Some(RetroGrab) with the start byte and grabbed text when we
397 /// decide to buffer retroactively; otherwise None.
398 pub fn decide_begin_buffer(
399 &mut self,
400 now: Instant,
401 before: &str,
402 retro_chars: usize,
403 ) -> Option<RetroGrab> {
404 let start_byte = retro_start_index(before, retro_chars);
405 let grabbed = before[start_byte..].to_string();
406 let looks_pastey =
407 grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16;
408 if looks_pastey {
409 // Note: caller is responsible for removing this slice from UI text.
410 self.begin_with_retro_grabbed(grabbed.clone(), now);
411 Some(RetroGrab {
412 start_byte,
413 grabbed,
414 })
415 } else {
416 None
417 }
418 }
419
420 /// Before applying modified/non-char input: flush buffered burst immediately.
421 pub fn flush_before_modified_input(&mut self) -> Option<String> {
422 if !self.is_active() {
423 return None;
424 }
425 self.active = false;
426 let mut out = std::mem::take(&mut self.buffer);
427 if let Some((ch, _at)) = self.pending_first_char.take() {
428 out.push(ch);
429 }
430 Some(out)
431 }
432
433 /// Clear only the timing window and any pending first-char.
434 ///
435 /// Does not emit or clear the buffered text itself; callers should have
436 /// already flushed (if needed) via one of the flush methods above.
437 pub fn clear_window_after_non_char(&mut self) {
438 self.consecutive_plain_char_burst = 0;
439 self.last_plain_char_time = None;
440 self.burst_window_until = None;
441 self.active = false;
442 self.pending_first_char = None;
443 }
444
445 /// Returns true if we are in any paste-burst related transient state
446 /// (actively buffering, have a non-empty buffer, or have saved the first
447 /// fast char while waiting for a potential burst).
448 pub fn is_active(&self) -> bool {
449 self.is_active_internal() || self.pending_first_char.is_some()
450 }
451
452 fn is_active_internal(&self) -> bool {
453 self.active || !self.buffer.is_empty()
454 }
455
456 pub fn clear_after_explicit_paste(&mut self) {
457 self.last_plain_char_time = None;
458 self.consecutive_plain_char_burst = 0;
459 self.burst_window_until = None;
460 self.active = false;
461 self.buffer.clear();
462 self.pending_first_char = None;
463 }
464}
465
466pub fn retro_start_index(before: &str, retro_chars: usize) -> usize {
467 if retro_chars == 0 {
468 return before.len();
469 }
470 before
471 .char_indices()
472 .rev()
473 .nth(retro_chars.saturating_sub(1))
474 .map(|(idx, _)| idx)
475 .unwrap_or(0)
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use pretty_assertions::assert_eq;
482
483 /// Behavior: for ASCII input we "hold" the first fast char briefly. If no burst follows,
484 /// that held char should eventually flush as normal typed input (not as a paste).
485 #[test]
486 fn ascii_first_char_is_held_then_flushes_as_typed() {
487 let mut burst = PasteBurst::default();
488 let t0 = Instant::now();
489 assert!(matches!(
490 burst.on_plain_char('a', t0),
491 CharDecision::RetainFirstChar
492 ));
493
494 let t1 = t0 + PasteBurst::recommended_flush_delay() + Duration::from_millis(1);
495 assert!(matches!(burst.flush_if_due(t1), FlushResult::Typed('a')));
496 assert!(!burst.is_active());
497 }
498
499 /// Behavior: if two ASCII chars arrive quickly, we should start buffering without ever
500 /// rendering the first one, then flush the whole buffered payload as a paste.
501 #[test]
502 fn ascii_two_fast_chars_start_buffer_from_pending_and_flush_as_paste() {
503 let mut burst = PasteBurst::default();
504 let t0 = Instant::now();
505 assert!(matches!(
506 burst.on_plain_char('a', t0),
507 CharDecision::RetainFirstChar
508 ));
509
510 let t1 = t0 + Duration::from_millis(1);
511 assert!(matches!(
512 burst.on_plain_char('b', t1),
513 CharDecision::BeginBufferFromPending
514 ));
515 burst.append_char_to_buffer('b', t1);
516
517 let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);
518 assert!(matches!(
519 burst.flush_if_due(t2),
520 FlushResult::Paste(ref s) if s == "ab"
521 ));
522 }
523
524 /// Behavior: when non-char input is about to be applied, we flush any transient burst state
525 /// immediately (including a single pending ASCII char) so state doesn't leak across inputs.
526 #[test]
527 fn flush_before_modified_input_includes_pending_first_char() {
528 let mut burst = PasteBurst::default();
529 let t0 = Instant::now();
530 assert!(matches!(
531 burst.on_plain_char('a', t0),
532 CharDecision::RetainFirstChar
533 ));
534
535 assert_eq!(burst.flush_before_modified_input(), Some("a".to_string()));
536 assert!(!burst.is_active());
537 }
538
539 /// Behavior: retro-grab buffering is only enabled when the already-inserted prefix looks
540 /// paste-like (whitespace or "long enough") so short IME bursts don't get misclassified.
541 #[test]
542 fn decide_begin_buffer_only_triggers_for_pastey_prefixes() {
543 let mut burst = PasteBurst::default();
544 let now = Instant::now();
545
546 assert!(burst
547 .decide_begin_buffer(now, "ab", /*retro_chars*/ 2)
548 .is_none());
549 assert!(!burst.is_active());
550
551 let grab = burst
552 .decide_begin_buffer(now, "a b", /*retro_chars*/ 2)
553 .expect("whitespace should be considered paste-like");
554 assert_eq!(grab.start_byte, 1);
555 assert_eq!(grab.grabbed, " b");
556 assert!(burst.is_active());
557 }
558
559 /// Behavior: after a paste-like burst, we keep an "enter suppression window" alive briefly so
560 /// a slightly-late Enter still inserts a newline instead of submitting.
561 #[test]
562 fn newline_suppression_window_outlives_buffer_flush() {
563 let mut burst = PasteBurst::default();
564 let t0 = Instant::now();
565 assert!(matches!(
566 burst.on_plain_char('a', t0),
567 CharDecision::RetainFirstChar
568 ));
569
570 let t1 = t0 + Duration::from_millis(1);
571 assert!(matches!(
572 burst.on_plain_char('b', t1),
573 CharDecision::BeginBufferFromPending
574 ));
575 burst.append_char_to_buffer('b', t1);
576
577 let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);
578 assert!(matches!(burst.flush_if_due(t2), FlushResult::Paste(ref s) if s == "ab"));
579 assert!(!burst.is_active());
580
581 assert!(burst.newline_should_insert_instead_of_submit(t2));
582 let t3 = t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1);
583 assert!(!burst.newline_should_insert_instead_of_submit(t3));
584 }
585}