返回 DeepSeek-TUI-2026
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 pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option<CharDecision> {
81 self.note_plain_char(now);
82
83 if self.active {
84 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
85 return Some(CharDecision::BufferAppend);
86 }
87
88 if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
89 return Some(CharDecision::BeginBuffer {
90 retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
91 });
92 }
93
94 None
95 }
96
97 fn note_plain_char(&mut self, now: Instant) {
98 match self.last_plain_char_time {
99 Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
100 self.consecutive_plain_char_burst =
101 self.consecutive_plain_char_burst.saturating_add(1);
102 }
103 _ => self.consecutive_plain_char_burst = 1,
104 }
105 self.last_plain_char_time = Some(now);
106 }
107
108 pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
109 let timeout = if self.is_active_internal() {
110 PASTE_BURST_ACTIVE_IDLE_TIMEOUT
111 } else {
112 PASTE_BURST_CHAR_INTERVAL
113 };
114 let timed_out = self
115 .last_plain_char_time
116 .is_some_and(|t| now.duration_since(t) > timeout);
117
118 if timed_out && self.is_active_internal() {
119 self.active = false;
120 let out = std::mem::take(&mut self.buffer);
121 FlushResult::Paste(out)
122 } else if timed_out {
123 if let Some((ch, _)) = self.pending_first_char.take() {
124 FlushResult::Typed(ch)
125 } else {
126 FlushResult::None
127 }
128 } else {
129 FlushResult::None
130 }
131 }
132
133 /// Return the remaining delay before a pending char/paste buffer must flush.
134 ///
135 /// This lets the UI event loop avoid sleeping past the flush deadline.
136 #[must_use]
137 pub fn next_flush_delay(&self, now: Instant) -> Option<Duration> {
138 let last = self.last_plain_char_time?;
139 let timeout = if self.is_active_internal() {
140 PASTE_BURST_ACTIVE_IDLE_TIMEOUT
141 } else {
142 PASTE_BURST_CHAR_INTERVAL
143 };
144 Some(timeout.saturating_sub(now.duration_since(last)))
145 }
146
147 pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
148 if self.is_active() {
149 self.buffer.push('\n');
150 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
151 true
152 } else {
153 false
154 }
155 }
156
157 pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
158 let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
159 self.is_active() || in_burst_window
160 }
161
162 pub fn extend_window(&mut self, now: Instant) {
163 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
164 }
165
166 pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) {
167 if !grabbed.is_empty() {
168 self.buffer.push_str(&grabbed);
169 }
170 self.active = true;
171 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
172 }
173
174 pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
175 self.buffer.push(ch);
176 self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
177 }
178
179 pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool {
180 if self.active || !self.buffer.is_empty() {
181 self.append_char_to_buffer(ch, now);
182 true
183 } else {
184 false
185 }
186 }
187
188 pub fn decide_begin_buffer(
189 &mut self,
190 now: Instant,
191 before: &str,
192 retro_chars: usize,
193 ) -> Option<RetroGrab> {
194 let start_byte = retro_start_index(before, retro_chars);
195 let grabbed = before[start_byte..].to_string();
196 let looks_pastey =
197 grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16;
198 if looks_pastey {
199 self.begin_with_retro_grabbed(grabbed.clone(), now);
200 Some(RetroGrab {
201 start_byte,
202 grabbed,
203 })
204 } else {
205 None
206 }
207 }
208
209 pub fn flush_before_modified_input(&mut self) -> Option<String> {
210 if !self.is_active() {
211 return None;
212 }
213 self.active = false;
214 let mut out = std::mem::take(&mut self.buffer);
215 if let Some((ch, _)) = self.pending_first_char.take() {
216 out.push(ch);
217 }
218 Some(out)
219 }
220
221 pub fn clear_window_after_non_char(&mut self) {
222 self.consecutive_plain_char_burst = 0;
223 self.last_plain_char_time = None;
224 self.burst_window_until = None;
225 self.active = false;
226 self.pending_first_char = None;
227 }
228
229 pub fn is_active(&self) -> bool {
230 self.is_active_internal() || self.pending_first_char.is_some()
231 }
232
233 fn is_active_internal(&self) -> bool {
234 self.active || !self.buffer.is_empty()
235 }
236
237 pub fn clear_after_explicit_paste(&mut self) {
238 self.last_plain_char_time = None;
239 self.consecutive_plain_char_burst = 0;
240 self.burst_window_until = None;
241 self.active = false;
242 self.buffer.clear();
243 self.pending_first_char = None;
244 }
245 }
246
247 pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize {
248 if retro_chars == 0 {
249 return before.len();
250 }
251 before
252 .char_indices()
253 .rev()
254 .nth(retro_chars.saturating_sub(1))
255 .map(|(idx, _)| idx)
256 .unwrap_or(0)
257 }
258
259 #[cfg(test)]
260 mod tests {
261 use super::*;
262
263 #[test]
264 fn ascii_first_char_is_held_then_flushes_as_typed() {
265 let mut burst = PasteBurst::default();
266 let t0 = Instant::now();
267 assert!(matches!(
268 burst.on_plain_char('a', t0),
269 CharDecision::RetainFirstChar
270 ));
271
272 let t1 = t0 + PasteBurst::recommended_flush_delay() + Duration::from_millis(1);
273 assert!(matches!(burst.flush_if_due(t1), FlushResult::Typed('a')));
274 assert!(!burst.is_active());
275 }
276
277 #[test]
278 fn ascii_two_fast_chars_start_buffer_from_pending_and_flush_as_paste() {
279 let mut burst = PasteBurst::default();
280 let t0 = Instant::now();
281 assert!(matches!(
282 burst.on_plain_char('a', t0),
283 CharDecision::RetainFirstChar
284 ));
285
286 let t1 = t0 + Duration::from_millis(1);
287 assert!(matches!(
288 burst.on_plain_char('b', t1),
289 CharDecision::BeginBufferFromPending
290 ));
291 burst.append_char_to_buffer('b', t1);
292
293 let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);
294 assert!(matches!(
295 burst.flush_if_due(t2),
296 FlushResult::Paste(ref s) if s == "ab"
297 ));
298 }
299
300 #[test]
301 fn flush_before_modified_input_includes_pending_first_char() {
302 let mut burst = PasteBurst::default();
303 let t0 = Instant::now();
304 assert!(matches!(
305 burst.on_plain_char('a', t0),
306 CharDecision::RetainFirstChar
307 ));
308
309 assert_eq!(burst.flush_before_modified_input(), Some("a".to_string()));
310 assert!(!burst.is_active());
311 }
312
313 #[test]
314 fn next_flush_delay_counts_down_to_zero() {
315 let mut burst = PasteBurst::default();
316 let t0 = Instant::now();
317 let _ = burst.on_plain_char('a', t0);
318
319 let almost_due = t0 + Duration::from_millis(7);
320 let remaining = burst
321 .next_flush_delay(almost_due)
322 .expect("delay should exist");
323 assert!(remaining <= Duration::from_millis(1));
324
325 let due = t0 + Duration::from_millis(20);
326 assert_eq!(burst.next_flush_delay(due), Some(Duration::ZERO));
327 }
328 }
329
329 lines RUST