1#![forbid(unsafe_code)]
2use core::time::Duration;
20use std::collections::VecDeque;
21use std::io::{self, BufWriter, Read, Write};
22#[cfg(unix)]
23use std::os::unix::net::UnixStream;
24use std::sync::atomic::{AtomicUsize, Ordering};
25use std::sync::{Mutex, OnceLock};
26use std::time::Instant;
27
28use ftui_backend::{Backend, BackendClock, BackendEventSource, BackendFeatures, BackendPresenter};
29use ftui_core::event::{Event, MouseEventKind};
30use ftui_core::input_parser::InputParser;
31use ftui_core::terminal_capabilities::TerminalCapabilities;
32use ftui_render::buffer::Buffer;
33use ftui_render::diff::BufferDiff;
34use ftui_render::presenter::Presenter;
35
36#[cfg(unix)]
37use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGWINCH};
38#[cfg(unix)]
39use signal_hook::iterator::Signals;
40
41const ALT_SCREEN_ENTER: &[u8] = b"\x1b[?1049h";
44const ALT_SCREEN_LEAVE: &[u8] = b"\x1b[?1049l";
45
46const MOUSE_ENABLE: &[u8] = b"\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?1006;1000;1002h\x1b[?1006h\x1b[?1000h\x1b[?1002h";
57const MOUSE_ENABLE_MUX_SAFE: &[u8] =
58 b"\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?1006h\x1b[?1000h\x1b[?1002h";
59const MOUSE_DISABLE: &[u8] = b"\x1b[?1000;1002;1006l\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l";
60const MOUSE_DISABLE_MUX_SAFE: &[u8] =
61 b"\x1b[?1016l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1001l\x1b[?1005l\x1b[?1015l";
62
63const BRACKETED_PASTE_ENABLE: &[u8] = b"\x1b[?2004h";
64const BRACKETED_PASTE_DISABLE: &[u8] = b"\x1b[?2004l";
65
66const FOCUS_ENABLE: &[u8] = b"\x1b[?1004h";
67const FOCUS_DISABLE: &[u8] = b"\x1b[?1004l";
68
69const KITTY_KEYBOARD_ENABLE: &[u8] = b"\x1b[>15u";
70const KITTY_KEYBOARD_DISABLE: &[u8] = b"\x1b[<u";
71
72const CURSOR_SHOW: &[u8] = b"\x1b[?25h";
73#[allow(dead_code)]
74const CURSOR_HIDE: &[u8] = b"\x1b[?25l";
75
76const SYNC_END: &[u8] = b"\x1b[?2026l";
77const RESET_SCROLL_REGION: &[u8] = b"\x1b[r";
78const SGR_RESET: &[u8] = b"\x1b[0m";
79
80const INPUT_TRACE_ENV: &str = "FTUI_TTY_INPUT_TRACE";
83const SIGNAL_SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
84const SIGNAL_SHUTDOWN_POLL: Duration = Duration::from_millis(10);
85static LIVE_SIGNAL_INTERCEPT_SESSIONS: AtomicUsize = AtomicUsize::new(0);
86
87#[cfg(unix)]
88#[derive(Debug)]
89struct SignalInterceptGuard {
90 active: bool,
91}
92
93#[cfg(unix)]
94impl SignalInterceptGuard {
95 fn new(enabled: bool) -> Self {
96 if enabled {
97 LIVE_SIGNAL_INTERCEPT_SESSIONS.fetch_add(1, Ordering::SeqCst);
98 install_termination_signal_hook();
99 }
100 Self { active: enabled }
101 }
102
103 fn disarm(&mut self) -> bool {
104 let was_active = self.active;
105 self.active = false;
106 was_active
107 }
108}
109
110#[cfg(unix)]
111impl Drop for SignalInterceptGuard {
112 fn drop(&mut self) {
113 if self.active {
114 LIVE_SIGNAL_INTERCEPT_SESSIONS.fetch_sub(1, Ordering::SeqCst);
115 }
116 }
117}
118
119#[derive(Debug)]
120struct InputTrace {
121 seq: u64,
122 writer: BufWriter<std::fs::File>,
123}
124
125impl InputTrace {
126 fn from_env() -> Option<Self> {
127 let path = std::env::var(INPUT_TRACE_ENV).ok()?;
128 let trimmed = path.trim();
129 if trimmed.is_empty() {
130 return None;
131 }
132 let file = std::fs::OpenOptions::new()
133 .create(true)
134 .append(true)
135 .open(trimmed)
136 .ok()?;
137 Some(Self {
138 seq: 0,
139 writer: BufWriter::new(file),
140 })
141 }
142
143 fn record(&mut self, bytes: &[u8], parsed: &[Event]) {
144 self.seq = self.seq.saturating_add(1);
145 let _ = write!(self.writer, "seq={} n={} hex=", self.seq, bytes.len());
146 let _ = write_hex(&mut self.writer, bytes);
147 let _ = writeln!(self.writer);
148 for ev in parsed {
149 let _ = writeln!(self.writer, " {:?}", ev);
150 }
151 let _ = writeln!(self.writer, "---");
152 let _ = self.writer.flush();
153 }
154}
155
156fn write_hex(w: &mut impl Write, bytes: &[u8]) -> io::Result<()> {
157 const HEX: &[u8; 16] = b"0123456789abcdef";
158 for &b in bytes {
159 w.write_all(&[HEX[(b >> 4) as usize], HEX[(b & 0x0f) as usize]])?;
160 }
161 Ok(())
162}
163
164#[inline]
165const fn mouse_disable_sequence_for_capabilities(
166 capabilities: TerminalCapabilities,
167) -> &'static [u8] {
168 if capabilities.in_any_mux() {
169 MOUSE_DISABLE_MUX_SAFE
170 } else {
171 MOUSE_DISABLE
172 }
173}
174
175#[inline]
176const fn mouse_enable_sequence_for_capabilities(
177 capabilities: TerminalCapabilities,
178) -> &'static [u8] {
179 if capabilities.in_any_mux() {
180 MOUSE_ENABLE_MUX_SAFE
181 } else {
182 MOUSE_ENABLE
183 }
184}
185
186#[inline]
187const fn sanitize_feature_request(
188 requested: BackendFeatures,
189 capabilities: TerminalCapabilities,
190) -> BackendFeatures {
191 let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
196 let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
197
198 BackendFeatures {
199 mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
200 bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
201 focus_events: requested.focus_events && focus_events_supported,
202 kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
203 }
204}
205
206#[inline]
207const fn conservative_feature_union(a: BackendFeatures, b: BackendFeatures) -> BackendFeatures {
208 BackendFeatures {
209 mouse_capture: a.mouse_capture || b.mouse_capture,
210 bracketed_paste: a.bracketed_paste || b.bracketed_paste,
211 focus_events: a.focus_events || b.focus_events,
212 kitty_keyboard: a.kitty_keyboard || b.kitty_keyboard,
213 }
214}
215
216const CLEAR_SCREEN: &[u8] = b"\x1b[2J";
217const CURSOR_HOME: &[u8] = b"\x1b[H";
218const READ_BUFFER_BYTES: usize = 8192;
219const MAX_DRAIN_BYTES_PER_POLL: usize = READ_BUFFER_BYTES;
220const INFERRED_PIXEL_WIDTH_PER_CELL: u16 = 8;
221const INFERRED_PIXEL_HEIGHT_PER_CELL: u16 = 16;
222const PARSER_TIMEOUT_GRACE: Duration = Duration::from_millis(50);
227
228#[cfg(unix)]
229fn raw_mode_snapshot_slot() -> &'static Mutex<Option<nix::sys::termios::Termios>> {
230 static SLOT: OnceLock<Mutex<Option<nix::sys::termios::Termios>>> = OnceLock::new();
231 SLOT.get_or_init(|| Mutex::new(None))
232}
233
234#[cfg(unix)]
235fn store_raw_mode_snapshot(termios: &nix::sys::termios::Termios) {
236 let slot = raw_mode_snapshot_slot();
237 let mut guard = slot.lock().unwrap_or_else(|poison| poison.into_inner());
238 *guard = Some(termios.clone());
239}
240
241#[cfg(unix)]
242fn clear_raw_mode_snapshot() {
243 let slot = raw_mode_snapshot_slot();
244 let mut guard = slot.lock().unwrap_or_else(|poison| poison.into_inner());
245 *guard = None;
246}
247
248#[cfg(unix)]
249fn restore_raw_mode_snapshot() {
250 let slot = raw_mode_snapshot_slot();
251 let snapshot = {
252 let guard = slot.lock().unwrap_or_else(|poison| poison.into_inner());
253 guard.clone()
254 };
255
256 let Some(original) = snapshot else {
257 return;
258 };
259
260 let Ok(tty) = std::fs::File::open("/dev/tty") else {
261 return;
262 };
263 let _ = nix::sys::termios::tcsetattr(&tty, nix::sys::termios::SetArg::TCSAFLUSH, &original);
264}
265
266#[inline]
267const fn cleanup_features_for_capabilities(capabilities: TerminalCapabilities) -> BackendFeatures {
268 BackendFeatures {
269 mouse_capture: capabilities.mouse_sgr,
270 bracketed_paste: capabilities.bracketed_paste,
271 focus_events: capabilities.focus_events && !capabilities.in_any_mux(),
272 kitty_keyboard: capabilities.kitty_keyboard && !capabilities.in_any_mux(),
273 }
274}
275
276#[cfg(unix)]
277fn write_terminal_state_resets(writer: &mut impl Write) -> io::Result<()> {
278 writer.write_all(RESET_SCROLL_REGION)?;
279 writer.write_all(SGR_RESET)?;
280 Ok(())
281}
282
283#[cfg(unix)]
284fn best_effort_termination_cleanup() {
285 let mut stdout = io::stdout();
286 let caps = TerminalCapabilities::with_overrides();
287 let _ = write_terminal_state_resets(&mut stdout);
288 let emit_sync_end = false;
291 let features = cleanup_features_for_capabilities(caps);
292 let mouse_disable = mouse_disable_sequence_for_capabilities(caps);
293 let _ = write_cleanup_sequence_policy_with_mouse(
294 &features,
295 true,
296 emit_sync_end,
297 mouse_disable,
298 &mut stdout,
299 );
300 let _ = stdout.flush();
301 restore_raw_mode_snapshot();
302}
303
304#[cfg(unix)]
305fn install_abort_panic_hook() {
306 if !cfg!(panic = "abort") {
307 return;
308 }
309 static HOOK: OnceLock<()> = OnceLock::new();
310 HOOK.get_or_init(|| {
311 let previous = std::panic::take_hook();
312 std::panic::set_hook(Box::new(move |info| {
313 best_effort_termination_cleanup();
314 previous(info);
315 }));
316 });
317}
318
319#[cfg(unix)]
320fn install_termination_signal_hook() {
321 static HOOK: OnceLock<()> = OnceLock::new();
322 HOOK.get_or_init(|| {
323 let mut signals = match Signals::new([SIGINT, SIGTERM, SIGHUP, SIGQUIT]) {
324 Ok(signals) => signals,
325 Err(_) => return,
326 };
327 let _ = std::thread::Builder::new()
328 .name("ftui-tty-term-signal".to_string())
329 .spawn(move || {
330 for signal in signals.forever() {
331 if LIVE_SIGNAL_INTERCEPT_SESSIONS.load(Ordering::SeqCst) == 0 {
332 std::process::exit(128 + signal);
333 }
334
335 ftui_core::shutdown_signal::record_pending_termination_signal(signal);
336 best_effort_termination_cleanup();
337 let deadline = std::time::Instant::now()
338 .checked_add(SIGNAL_SHUTDOWN_GRACE)
339 .unwrap_or_else(std::time::Instant::now);
340 loop {
341 if ftui_core::shutdown_signal::pending_termination_signal().is_none() {
342 break;
343 }
344 if std::time::Instant::now() >= deadline {
345 std::process::exit(128 + signal);
346 }
347 std::thread::sleep(SIGNAL_SHUTDOWN_POLL);
348 }
349 }
350 });
351 });
352}
353
354#[cfg(unix)]
365pub struct RawModeGuard {
366 original_termios: nix::sys::termios::Termios,
367 tty: std::fs::File,
368}
369
370#[cfg(unix)]
371impl RawModeGuard {
372 pub fn enter() -> io::Result<Self> {
375 let tty = std::fs::File::open("/dev/tty")?;
376 Self::enter_on(tty)
377 }
378
379 pub fn enter_on(tty: std::fs::File) -> io::Result<Self> {
381 let original_termios = nix::sys::termios::tcgetattr(&tty).map_err(io::Error::other)?;
382
383 let mut raw = original_termios.clone();
384 nix::sys::termios::cfmakeraw(&mut raw);
385 nix::sys::termios::tcsetattr(&tty, nix::sys::termios::SetArg::TCSAFLUSH, &raw)
386 .map_err(io::Error::other)?;
387
388 store_raw_mode_snapshot(&original_termios);
389
390 Ok(Self {
391 original_termios,
392 tty,
393 })
394 }
395}
396
397#[cfg(unix)]
398impl Drop for RawModeGuard {
399 fn drop(&mut self) {
400 let _ = nix::sys::termios::tcsetattr(
402 &self.tty,
403 nix::sys::termios::SetArg::TCSAFLUSH,
404 &self.original_termios,
405 );
406 clear_raw_mode_snapshot();
407 }
408}
409
410#[derive(Debug, Clone)]
414pub struct TtySessionOptions {
415 pub alternate_screen: bool,
417 pub features: BackendFeatures,
419 pub intercept_signals: bool,
421}
422
423impl Default for TtySessionOptions {
424 fn default() -> Self {
425 Self {
426 alternate_screen: false,
427 features: BackendFeatures::default(),
428 intercept_signals: true,
429 }
430 }
431}
432
433pub struct TtyClock {
437 epoch: std::time::Instant,
438}
439
440impl TtyClock {
441 #[must_use]
442 pub fn new() -> Self {
443 Self {
444 epoch: std::time::Instant::now(),
445 }
446 }
447}
448
449impl Default for TtyClock {
450 fn default() -> Self {
451 Self::new()
452 }
453}
454
455impl BackendClock for TtyClock {
456 fn now_mono(&self) -> Duration {
457 self.epoch.elapsed()
458 }
459}
460
461#[cfg(unix)]
468#[derive(Debug)]
469struct ResizeSignalGuard {
470 handle: signal_hook::iterator::Handle,
471 thread: Option<std::thread::JoinHandle<()>>,
472}
473
474#[cfg(unix)]
475impl ResizeSignalGuard {
476 fn new(mut wake_writer: UnixStream) -> io::Result<Self> {
477 wake_writer.set_nonblocking(true)?;
478 let mut signals = Signals::new([SIGWINCH]).map_err(io::Error::other)?;
479 let handle = signals.handle();
480 let thread = std::thread::spawn(move || {
481 let pulse = [1u8; 1];
482 for _ in signals.forever() {
483 match wake_writer.write(&pulse) {
484 Ok(_) => {}
487 Err(err)
488 if matches!(
489 err.kind(),
490 io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
491 ) => {}
492 Err(_) => break,
493 }
494 }
495 });
496
497 Ok(Self {
498 handle,
499 thread: Some(thread),
500 })
501 }
502}
503
504#[cfg(unix)]
505impl Drop for ResizeSignalGuard {
506 fn drop(&mut self) {
507 self.handle.close();
508 if let Some(thread) = self.thread.take() {
509 let _ = thread.join();
510 }
511 }
512}
513
514pub struct TtyEventSource {
520 features: BackendFeatures,
521 capabilities: TerminalCapabilities,
522 width: u16,
523 height: u16,
524 pixel_width: u16,
526 pixel_height: u16,
528 mouse_coords_pixels: bool,
533 inferred_pixel_width: u16,
539 inferred_pixel_height: u16,
541 live: bool,
544 #[cfg(unix)]
549 resize_reader: Option<UnixStream>,
550 #[cfg(unix)]
552 _resize_guard: Option<ResizeSignalGuard>,
553 parser: InputParser,
555 event_queue: VecDeque<Event>,
557 tty_reader: Option<std::fs::File>,
559 reader_nonblocking: bool,
561 last_input_byte_at: Option<Instant>,
563 input_trace: Option<InputTrace>,
565}
566
567impl TtyEventSource {
568 #[must_use]
570 pub fn new(width: u16, height: u16) -> Self {
571 Self {
572 features: BackendFeatures::default(),
573 capabilities: TerminalCapabilities::basic(),
574 width,
575 height,
576 pixel_width: 0,
577 pixel_height: 0,
578 mouse_coords_pixels: false,
579 inferred_pixel_width: 0,
580 inferred_pixel_height: 0,
581 live: false,
582 #[cfg(unix)]
583 resize_reader: None,
584 #[cfg(unix)]
585 _resize_guard: None,
586 parser: InputParser::new(),
587 event_queue: VecDeque::new(),
588 tty_reader: None,
589 reader_nonblocking: false,
590 last_input_byte_at: None,
591 input_trace: None,
592 }
593 }
594
595 fn live(width: u16, height: u16, capabilities: TerminalCapabilities) -> io::Result<Self> {
598 let tty_reader = std::fs::File::open("/dev/tty")?;
599 let reader_nonblocking = Self::try_enable_nonblocking(&tty_reader);
600 let mut w = width;
601 let mut h = height;
602 let mut pw = 0;
603 let mut ph = 0;
604 #[cfg(unix)]
605 if let Ok(ws) = rustix::termios::tcgetwinsize(&tty_reader) {
606 if ws.ws_col > 0 && ws.ws_row > 0 {
607 w = ws.ws_col;
608 h = ws.ws_row;
609 }
610 pw = ws.ws_xpixel;
611 ph = ws.ws_ypixel;
612 }
613
614 #[cfg(unix)]
615 let (resize_guard, resize_reader) = match UnixStream::pair() {
616 Ok((resize_reader, resize_writer)) => {
617 if resize_reader.set_nonblocking(true).is_ok() {
618 match ResizeSignalGuard::new(resize_writer) {
619 Ok(guard) => (Some(guard), Some(resize_reader)),
620 Err(_) => (None, None),
621 }
622 } else {
623 (None, None)
624 }
625 }
626 Err(_) => (None, None),
627 };
628
629 Ok(Self {
630 features: BackendFeatures::default(),
631 capabilities,
632 width: w,
633 height: h,
634 pixel_width: pw,
635 pixel_height: ph,
636 mouse_coords_pixels: false,
637 inferred_pixel_width: 0,
638 inferred_pixel_height: 0,
639 live: true,
640 #[cfg(unix)]
641 resize_reader,
642 #[cfg(unix)]
643 _resize_guard: resize_guard,
644 parser: InputParser::new(),
645 event_queue: VecDeque::new(),
646 tty_reader: Some(tty_reader),
647 reader_nonblocking,
648 last_input_byte_at: None,
649 input_trace: InputTrace::from_env(),
650 })
651 }
652
653 #[cfg(test)]
658 fn from_reader(width: u16, height: u16, reader: std::fs::File) -> Self {
659 let reader_nonblocking = Self::try_enable_nonblocking(&reader);
660 Self {
661 features: BackendFeatures::default(),
662 capabilities: TerminalCapabilities::basic(),
663 width,
664 height,
665 pixel_width: 0,
666 pixel_height: 0,
667 mouse_coords_pixels: false,
668 inferred_pixel_width: 0,
669 inferred_pixel_height: 0,
670 live: false,
671 #[cfg(unix)]
672 resize_reader: None,
673 #[cfg(unix)]
674 _resize_guard: None,
675 parser: InputParser::new(),
676 event_queue: VecDeque::new(),
677 tty_reader: Some(reader),
678 reader_nonblocking,
679 last_input_byte_at: None,
680 input_trace: None,
681 }
682 }
683
684 #[cfg(unix)]
685 fn try_enable_nonblocking(reader: &std::fs::File) -> bool {
686 use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
687
688 let Ok(flags) = fcntl_getfl(reader) else {
689 return false;
690 };
691 if flags.contains(OFlags::NONBLOCK) {
692 return true;
693 }
694 fcntl_setfl(reader, flags | OFlags::NONBLOCK).is_ok()
695 }
696
697 #[cfg(not(unix))]
698 fn try_enable_nonblocking(_reader: &std::fs::File) -> bool {
699 false
700 }
701
702 #[must_use]
704 pub fn features(&self) -> BackendFeatures {
705 self.features
706 }
707
708 #[inline]
709 fn sanitize_features(&self, requested: BackendFeatures) -> BackendFeatures {
710 if !self.live {
711 return requested;
712 }
713 sanitize_feature_request(requested, self.capabilities)
714 }
715
716 fn apply_feature_state(&mut self, features: BackendFeatures) {
726 self.features = features;
727 if !features.mouse_capture {
728 self.mouse_coords_pixels = false;
729 self.inferred_pixel_width = 0;
730 self.inferred_pixel_height = 0;
731 }
732 self.parser.set_expect_x10_mouse(features.mouse_capture);
733 self.parser.set_allow_legacy_mouse(features.mouse_capture);
736 }
737
738 fn push_resize(&mut self, new_width: u16, new_height: u16) {
739 if new_width == 0 || new_height == 0 {
740 return;
741 }
742 if (new_width, new_height) == (self.width, self.height) {
743 return;
744 }
745 self.width = new_width;
746 self.height = new_height;
747 self.mouse_coords_pixels = false;
752 self.inferred_pixel_width = 0;
753 self.inferred_pixel_height = 0;
754 self.event_queue.push_back(Event::Resize {
755 width: new_width,
756 height: new_height,
757 });
758 }
759
760 fn normalize_event(&mut self, event: Event) -> Event {
767 let Event::Mouse(mut mouse) = event else {
768 return event;
769 };
770
771 let outside_grid = mouse.x >= self.width || mouse.y >= self.height;
772 let strongly_outside = (mouse.x >= self.width.saturating_mul(2)
777 || mouse.y >= self.height.saturating_mul(2))
778 && (mouse.x > 600 || mouse.y > 400);
779
780 if !self.mouse_coords_pixels && strongly_outside {
781 self.mouse_coords_pixels = true;
782 }
783 let likely_pixel_space = self.mouse_coords_pixels || strongly_outside;
784 if !self.features.mouse_capture || !self.capabilities.mouse_sgr {
785 return Event::Mouse(mouse);
786 }
787 if !likely_pixel_space {
788 if outside_grid {
791 mouse.x = mouse.x.min(self.width.saturating_sub(1));
792 mouse.y = mouse.y.min(self.height.saturating_sub(1));
793 }
794 return Event::Mouse(mouse);
795 }
796
797 if self.width == 0 || self.height == 0 {
798 return Event::Mouse(mouse);
799 }
800 if self.pixel_width > 0 && self.pixel_height > 0 {
801 mouse.x = Self::scale_mouse_coord(mouse.x, self.width, self.pixel_width);
802 mouse.y = Self::scale_mouse_coord(mouse.y, self.height, self.pixel_height);
803 } else {
804 if self.inferred_pixel_width == 0 {
808 self.inferred_pixel_width = self
809 .width
810 .saturating_mul(INFERRED_PIXEL_WIDTH_PER_CELL)
811 .max(self.width);
812 }
813 if self.inferred_pixel_height == 0 {
814 self.inferred_pixel_height = self
815 .height
816 .saturating_mul(INFERRED_PIXEL_HEIGHT_PER_CELL)
817 .max(self.height);
818 }
819 self.inferred_pixel_width = self
820 .inferred_pixel_width
821 .max(mouse.x.saturating_add(1))
822 .max(self.width);
823 self.inferred_pixel_height = self
824 .inferred_pixel_height
825 .max(mouse.y.saturating_add(1))
826 .max(self.height);
827
828 mouse.x =
829 Self::scale_mouse_coord(mouse.x, self.width, self.inferred_pixel_width.max(1));
830 mouse.y =
831 Self::scale_mouse_coord(mouse.y, self.height, self.inferred_pixel_height.max(1));
832 }
833 Event::Mouse(mouse)
834 }
835
836 #[inline]
837 fn scale_mouse_coord(coord: u16, cells: u16, pixels: u16) -> u16 {
838 if cells <= 1 {
839 return 0;
840 }
841 if pixels <= 1 {
842 return coord.min(cells.saturating_sub(1));
843 }
844
845 let num = u32::from(coord).saturating_mul(u32::from(cells.saturating_sub(1)));
846 let den = u32::from(pixels.saturating_sub(1));
847 let scaled = num / den.max(1);
848 let scaled_u16 = u16::try_from(scaled).unwrap_or(u16::MAX);
849 scaled_u16.min(cells.saturating_sub(1))
850 }
851
852 #[cfg(unix)]
853 fn query_tty_winsize(&self) -> Option<rustix::termios::Winsize> {
854 if !self.live {
855 return None;
856 }
857 let tty = self.tty_reader.as_ref()?;
858 rustix::termios::tcgetwinsize(tty).ok()
859 }
860
861 #[cfg(unix)]
862 fn query_tty_size(&self) -> Option<(u16, u16)> {
863 let ws = self.query_tty_winsize()?;
864 if ws.ws_col == 0 || ws.ws_row == 0 {
865 return None;
866 }
867 Some((ws.ws_col, ws.ws_row))
868 }
869
870 #[cfg(unix)]
871 fn drain_resize_wake_bytes(&mut self) -> bool {
872 let Some(reader) = self.resize_reader.as_mut() else {
873 return false;
874 };
875 let mut any = false;
876 let mut retire_reader = false;
877 let mut buf = [0u8; 64];
878 loop {
879 match reader.read(&mut buf) {
880 Ok(0) => {
881 retire_reader = true;
882 break;
883 }
884 Ok(_) => any = true,
885 Err(err) if err.kind() == io::ErrorKind::WouldBlock => break,
886 Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
887 Err(_) => {
888 retire_reader = true;
889 break;
890 }
891 }
892 }
893 if retire_reader {
894 self.resize_reader = None;
895 }
896 any
897 }
898
899 #[cfg(unix)]
900 fn drain_resize_notifications(&mut self) {
901 if !self.live {
902 return;
903 }
904 let got_resize = self.drain_resize_wake_bytes();
907 if got_resize && let Some(ws) = self.query_tty_winsize() {
908 self.pixel_width = ws.ws_xpixel;
909 self.pixel_height = ws.ws_ypixel;
910 if ws.ws_col > 0 && ws.ws_row > 0 {
911 self.push_resize(ws.ws_col, ws.ws_row);
912 }
913 }
914 }
915
916 fn drain_available_bytes(&mut self) -> io::Result<()> {
918 if self.tty_reader.is_none() {
919 return Ok(());
920 }
921 let mut buf = [0u8; READ_BUFFER_BYTES];
922 let mut drained_bytes = 0usize;
923 let mut parsed_events = Vec::new();
924 loop {
925 let read_result = {
926 let Some(tty) = self.tty_reader.as_mut() else {
927 return Ok(());
928 };
929 tty.read(&mut buf)
930 };
931 match read_result {
932 Ok(0) => {
933 self.tty_reader = None;
937 self.reader_nonblocking = false;
938 return Ok(());
939 }
940 Ok(n) => {
941 self.last_input_byte_at = Some(Instant::now());
942 parsed_events.clear();
943 self.parser
944 .parse_with(&buf[..n], |event| parsed_events.push(event));
945 if let Some(ref mut trace) = self.input_trace {
946 trace.record(&buf[..n], &parsed_events);
947 }
948 for event in parsed_events.drain(..) {
949 let normalized = self.normalize_event(event);
950 self.push_event_coalescing(normalized);
951 }
952 drained_bytes = drained_bytes.saturating_add(n);
953 if !self.reader_nonblocking {
954 return Ok(());
955 }
956 if drained_bytes >= MAX_DRAIN_BYTES_PER_POLL {
957 return Ok(());
958 }
959 }
960 Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(()),
961 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
962 Err(e) => return Err(e),
963 }
964 }
965 }
966
967 fn push_event_coalescing(&mut self, event: Event) {
973 if let Event::Mouse(m) = event
974 && matches!(m.kind, MouseEventKind::Moved)
975 && matches!(
976 self.event_queue.back(),
977 Some(Event::Mouse(prev)) if matches!(prev.kind, MouseEventKind::Moved)
978 )
979 {
980 let _ = self.event_queue.pop_back();
981 }
982 self.event_queue.push_back(event);
983 }
984
985 #[inline]
986 fn parser_timeout_event_if_due(&mut self) -> Option<Event> {
987 if !self.parser.has_pending_timeout_state() {
988 return None;
989 }
990 if let Some(last) = self.last_input_byte_at
991 && last.elapsed() < PARSER_TIMEOUT_GRACE
992 {
993 return None;
994 }
995 let event = self.parser.timeout();
996 if event.is_some() {
997 self.last_input_byte_at = None;
998 }
999 event
1000 }
1001
1002 #[inline]
1003 fn parser_timeout_wait_budget(&self) -> Option<Duration> {
1004 if !self.parser.has_pending_timeout_state() {
1005 return None;
1006 }
1007 Some(
1008 self.last_input_byte_at
1009 .map(|last| PARSER_TIMEOUT_GRACE.saturating_sub(last.elapsed()))
1010 .unwrap_or(Duration::ZERO),
1011 )
1012 }
1013
1014 fn drain_ready_bytes_before_parser_timeout(&mut self) -> io::Result<bool> {
1017 if self.tty_reader.is_none() {
1018 return Ok(false);
1019 }
1020 if self.reader_nonblocking {
1021 self.drain_available_bytes()?;
1022 } else {
1023 let _ = self.poll_tty(Duration::ZERO)?;
1024 }
1025 Ok(!self.event_queue.is_empty())
1026 }
1027
1028 #[cfg(unix)]
1036 fn poll_tty(&mut self, timeout: Duration) -> io::Result<bool> {
1037 use std::os::fd::AsFd;
1038
1039 const TTY_UNAVAILABLE_BACKOFF: Duration = Duration::from_millis(8);
1041
1042 let (tty_ready, tty_unavailable, resize_ready) = {
1043 let Some(ref tty) = self.tty_reader else {
1044 return Ok(false);
1045 };
1046 let mut poll_fds = Vec::with_capacity(2);
1047 poll_fds.push(nix::poll::PollFd::new(
1048 tty.as_fd(),
1049 nix::poll::PollFlags::POLLIN,
1050 ));
1051 let resize_index = if let Some(ref resize_reader) = self.resize_reader {
1052 poll_fds.push(nix::poll::PollFd::new(
1053 resize_reader.as_fd(),
1054 nix::poll::PollFlags::POLLIN,
1055 ));
1056 Some(1usize)
1057 } else {
1058 None
1059 };
1060 let timeout_ms: i32 = timeout.as_millis().try_into().unwrap_or(i32::MAX);
1063 let _ = match nix::poll::poll(
1064 &mut poll_fds,
1065 nix::poll::PollTimeout::try_from(timeout_ms).unwrap_or(nix::poll::PollTimeout::MAX),
1066 ) {
1067 Ok(n) => n,
1068 Err(nix::errno::Errno::EINTR) => return Ok(false),
1069 Err(e) => return Err(io::Error::other(e)),
1070 };
1071 let tty_revents = poll_fds.first().and_then(nix::poll::PollFd::revents);
1072 let tty_ready = tty_revents.is_some_and(|revents| {
1073 revents.intersects(
1074 nix::poll::PollFlags::POLLIN
1075 | nix::poll::PollFlags::POLLERR
1076 | nix::poll::PollFlags::POLLHUP,
1077 )
1078 });
1079 let tty_unavailable = tty_revents
1080 .is_some_and(|revents| revents.intersects(nix::poll::PollFlags::POLLNVAL));
1081 let resize_ready = resize_index
1082 .and_then(|idx| poll_fds.get(idx))
1083 .and_then(nix::poll::PollFd::revents)
1084 .is_some_and(|revents| {
1085 revents.intersects(
1086 nix::poll::PollFlags::POLLIN
1087 | nix::poll::PollFlags::POLLERR
1088 | nix::poll::PollFlags::POLLHUP,
1089 )
1090 });
1091 (tty_ready, tty_unavailable, resize_ready)
1092 };
1093 if tty_ready {
1094 self.drain_available_bytes()?;
1095 } else if tty_unavailable {
1096 if self.reader_nonblocking {
1099 self.drain_available_bytes()?;
1100 }
1101 if resize_ready {
1104 self.drain_resize_notifications();
1105 }
1106 if !self.event_queue.is_empty() {
1107 return Ok(true);
1108 }
1109 if timeout != Duration::ZERO {
1110 std::thread::sleep(timeout.min(TTY_UNAVAILABLE_BACKOFF));
1111 }
1112 return Ok(!self.event_queue.is_empty());
1113 }
1114 if resize_ready {
1115 self.drain_resize_notifications();
1116 }
1117 Ok(!self.event_queue.is_empty())
1118 }
1119
1120 #[cfg(not(unix))]
1122 fn poll_tty(&mut self, _timeout: Duration) -> io::Result<bool> {
1123 Ok(false)
1124 }
1125
1126 fn write_feature_delta(
1128 current: &BackendFeatures,
1129 new: &BackendFeatures,
1130 capabilities: TerminalCapabilities,
1131 writer: &mut impl Write,
1132 ) -> io::Result<()> {
1133 let mouse_enable_seq = mouse_enable_sequence_for_capabilities(capabilities);
1134 let mouse_disable_seq = mouse_disable_sequence_for_capabilities(capabilities);
1135 Self::write_feature_delta_with_mouse(
1136 current,
1137 new,
1138 mouse_enable_seq,
1139 mouse_disable_seq,
1140 writer,
1141 )
1142 }
1143
1144 fn write_feature_delta_with_mouse(
1145 current: &BackendFeatures,
1146 new: &BackendFeatures,
1147 mouse_enable_seq: &[u8],
1148 mouse_disable_seq: &[u8],
1149 writer: &mut impl Write,
1150 ) -> io::Result<()> {
1151 if new.mouse_capture != current.mouse_capture {
1152 writer.write_all(if new.mouse_capture {
1153 mouse_enable_seq
1154 } else {
1155 mouse_disable_seq
1156 })?;
1157 }
1158 if new.bracketed_paste != current.bracketed_paste {
1159 writer.write_all(if new.bracketed_paste {
1160 BRACKETED_PASTE_ENABLE
1161 } else {
1162 BRACKETED_PASTE_DISABLE
1163 })?;
1164 }
1165 if new.focus_events != current.focus_events {
1166 writer.write_all(if new.focus_events {
1167 FOCUS_ENABLE
1168 } else {
1169 FOCUS_DISABLE
1170 })?;
1171 }
1172 if new.kitty_keyboard != current.kitty_keyboard {
1173 writer.write_all(if new.kitty_keyboard {
1174 KITTY_KEYBOARD_ENABLE
1175 } else {
1176 KITTY_KEYBOARD_DISABLE
1177 })?;
1178 }
1179 Ok(())
1180 }
1181
1182 fn disable_all(&mut self, writer: &mut impl Write) -> io::Result<()> {
1184 let off = BackendFeatures::default();
1185 Self::write_feature_delta(&self.features, &off, self.capabilities, writer)?;
1186 self.apply_feature_state(off);
1187 Ok(())
1188 }
1189}
1190
1191impl BackendEventSource for TtyEventSource {
1192 type Error = io::Error;
1193
1194 fn size(&self) -> Result<(u16, u16), Self::Error> {
1195 #[cfg(unix)]
1196 if let Some((w, h)) = self.query_tty_size() {
1197 return Ok((w, h));
1198 }
1199 Ok((self.width, self.height))
1200 }
1201
1202 fn set_features(&mut self, features: BackendFeatures) -> Result<(), Self::Error> {
1203 let effective_features = self.sanitize_features(features);
1204 if self.live {
1205 let mut stdout = io::stdout();
1206 if let Err(err) = Self::write_feature_delta(
1207 &self.features,
1208 &effective_features,
1209 self.capabilities,
1210 &mut stdout,
1211 )
1212 .and_then(|_| stdout.flush())
1213 {
1214 self.apply_feature_state(conservative_feature_union(
1218 self.features,
1219 effective_features,
1220 ));
1221 return Err(err);
1222 }
1223 }
1224 self.apply_feature_state(effective_features);
1225 Ok(())
1226 }
1227
1228 fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
1229 #[cfg(unix)]
1230 self.drain_resize_notifications();
1231
1232 if !self.event_queue.is_empty() {
1234 return Ok(true);
1235 }
1236
1237 if timeout == Duration::ZERO {
1238 let ready = self.poll_tty(Duration::ZERO)?;
1239 if !ready && self.drain_ready_bytes_before_parser_timeout()? {
1240 return Ok(true);
1241 }
1242 if !ready && let Some(event) = self.parser_timeout_event_if_due() {
1243 self.event_queue.push_back(event);
1244 return Ok(true);
1245 }
1246 return Ok(!self.event_queue.is_empty());
1247 }
1248
1249 let deadline = std::time::Instant::now()
1250 .checked_add(timeout)
1251 .unwrap_or_else(std::time::Instant::now);
1252
1253 loop {
1254 if !self.event_queue.is_empty() {
1255 return Ok(true);
1256 }
1257
1258 if self.tty_reader.is_none() && !self.parser.has_pending_timeout_state() {
1259 return Ok(false);
1260 }
1261
1262 if self.parser.has_pending_timeout_state() {
1263 if self.drain_ready_bytes_before_parser_timeout()? {
1264 return Ok(true);
1265 }
1266 if let Some(event) = self.parser_timeout_event_if_due() {
1267 self.event_queue.push_back(event);
1268 return Ok(true);
1269 }
1270 }
1271
1272 let now = std::time::Instant::now();
1273 if now >= deadline {
1274 return Ok(false);
1275 }
1276
1277 let mut poll_for = deadline.saturating_duration_since(now);
1278 if let Some(parser_wait_budget) = self.parser_timeout_wait_budget() {
1279 poll_for = poll_for.min(parser_wait_budget);
1280 }
1281
1282 let _ = self.poll_tty(poll_for)?;
1283 #[cfg(unix)]
1284 self.drain_resize_notifications();
1285 }
1286 }
1287
1288 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
1289 if let Some(event) = self.event_queue.pop_front() {
1290 return Ok(Some(event));
1291 }
1292
1293 #[cfg(unix)]
1294 {
1295 self.drain_resize_notifications();
1296 if let Some(event) = self.event_queue.pop_front() {
1297 return Ok(Some(event));
1298 }
1299 }
1300
1301 if self.drain_ready_bytes_before_parser_timeout()?
1307 && let Some(event) = self.event_queue.pop_front()
1308 {
1309 return Ok(Some(event));
1310 }
1311
1312 Ok(self.parser_timeout_event_if_due())
1313 }
1314}
1315
1316pub struct TtyPresenter<W: Write + Send = io::Stdout> {
1323 capabilities: TerminalCapabilities,
1324 inner: Option<Presenter<W>>,
1325}
1326
1327impl TtyPresenter {
1328 #[must_use]
1330 pub fn new(capabilities: TerminalCapabilities) -> Self {
1331 Self {
1332 capabilities,
1333 inner: None,
1334 }
1335 }
1336
1337 #[must_use]
1339 pub fn live(capabilities: TerminalCapabilities) -> Self {
1340 Self {
1341 capabilities,
1342 inner: Some(Presenter::new(io::stdout(), capabilities)),
1343 }
1344 }
1345}
1346
1347impl<W: Write + Send> TtyPresenter<W> {
1348 pub fn with_writer(writer: W, capabilities: TerminalCapabilities) -> Self {
1350 Self {
1351 capabilities,
1352 inner: Some(Presenter::new(writer, capabilities)),
1353 }
1354 }
1355}
1356
1357impl<W: Write + Send> BackendPresenter for TtyPresenter<W> {
1358 type Error = io::Error;
1359
1360 fn capabilities(&self) -> &TerminalCapabilities {
1361 &self.capabilities
1362 }
1363
1364 fn write_log(&mut self, _text: &str) -> Result<(), Self::Error> {
1365 Ok(())
1370 }
1371
1372 fn present_ui(
1373 &mut self,
1374 buf: &Buffer,
1375 diff: Option<&BufferDiff>,
1376 full_repaint_hint: bool,
1377 ) -> Result<(), Self::Error> {
1378 let Some(ref mut presenter) = self.inner else {
1379 return Ok(());
1380 };
1381 if full_repaint_hint {
1382 let full = BufferDiff::full(buf.width(), buf.height());
1383 presenter.present(buf, &full)?;
1384 } else if let Some(diff) = diff {
1385 presenter.present(buf, diff)?;
1386 } else {
1387 let full = BufferDiff::full(buf.width(), buf.height());
1388 presenter.present(buf, &full)?;
1389 }
1390 Ok(())
1391 }
1392}
1393
1394pub struct TtyBackend {
1408 clock: TtyClock,
1415 events: TtyEventSource,
1416 presenter: TtyPresenter,
1417 alt_screen_active: bool,
1418 #[cfg(unix)]
1419 signal_interception_active: bool,
1420 #[cfg(unix)]
1421 raw_mode: Option<RawModeGuard>,
1422}
1423
1424impl TtyBackend {
1425 #[must_use]
1427 pub fn new(width: u16, height: u16) -> Self {
1428 Self {
1429 clock: TtyClock::new(),
1430 events: TtyEventSource::new(width, height),
1431 presenter: TtyPresenter::new(TerminalCapabilities::detect()),
1432 alt_screen_active: false,
1433 #[cfg(unix)]
1434 signal_interception_active: false,
1435 #[cfg(unix)]
1436 raw_mode: None,
1437 }
1438 }
1439
1440 #[must_use]
1442 pub fn with_capabilities(width: u16, height: u16, capabilities: TerminalCapabilities) -> Self {
1443 Self {
1444 clock: TtyClock::new(),
1445 events: TtyEventSource::new(width, height),
1446 presenter: TtyPresenter::new(capabilities),
1447 alt_screen_active: false,
1448 #[cfg(unix)]
1449 signal_interception_active: false,
1450 #[cfg(unix)]
1451 raw_mode: None,
1452 }
1453 }
1454
1455 #[cfg(unix)]
1460 pub fn open(width: u16, height: u16, options: TtySessionOptions) -> io::Result<Self> {
1461 let raw_mode = RawModeGuard::enter()?;
1463 install_abort_panic_hook();
1464 let mut signal_guard = SignalInterceptGuard::new(options.intercept_signals);
1465 let capabilities = TerminalCapabilities::with_overrides();
1466 let requested_features = options.features;
1467 let effective_features = sanitize_feature_request(requested_features, capabilities);
1468
1469 let mut stdout = io::stdout();
1470 let mut alt_screen_active = false;
1471
1472 let mut events = TtyEventSource::live(width, height, capabilities)?;
1474 let setup: io::Result<()> = (|| {
1475 if options.alternate_screen {
1477 stdout.write_all(ALT_SCREEN_ENTER)?;
1478 stdout.write_all(CLEAR_SCREEN)?;
1479 stdout.write_all(CURSOR_HOME)?;
1480 alt_screen_active = true;
1481 }
1482
1483 TtyEventSource::write_feature_delta(
1484 &BackendFeatures::default(),
1485 &effective_features,
1486 capabilities,
1487 &mut stdout,
1488 )?;
1489
1490 stdout.flush()?;
1491 Ok(())
1492 })();
1493
1494 if let Err(err) = setup {
1495 let mouse_disable_seq = mouse_disable_sequence_for_capabilities(capabilities);
1500 let _ = write_terminal_state_resets(&mut stdout);
1501 let _ = write_cleanup_sequence_policy_with_mouse(
1502 &effective_features,
1503 options.alternate_screen,
1504 false,
1505 mouse_disable_seq,
1506 &mut stdout,
1507 );
1508 let _ = stdout.flush();
1509 return Err(err);
1510 }
1511
1512 events.apply_feature_state(effective_features);
1513
1514 Ok(Self {
1515 clock: TtyClock::new(),
1516 events,
1517 presenter: TtyPresenter::live(capabilities),
1518 alt_screen_active,
1519 signal_interception_active: signal_guard.disarm(),
1520 raw_mode: Some(raw_mode),
1521 })
1522 }
1523
1524 #[must_use]
1526 pub fn is_live(&self) -> bool {
1527 #[cfg(unix)]
1528 {
1529 self.raw_mode.is_some()
1530 }
1531 #[cfg(not(unix))]
1532 {
1533 false
1534 }
1535 }
1536}
1537
1538impl Drop for TtyBackend {
1539 fn drop(&mut self) {
1540 #[cfg(unix)]
1542 if self.raw_mode.is_some() {
1543 let mut stdout = io::stdout();
1544 let _ = write_terminal_state_resets(&mut stdout);
1545
1546 let _ = self.events.disable_all(&mut stdout);
1548
1549 let _ = stdout.write_all(CURSOR_SHOW);
1551
1552 if self.alt_screen_active {
1554 let _ = stdout.write_all(ALT_SCREEN_LEAVE);
1555 self.alt_screen_active = false;
1556 }
1557
1558 let _ = stdout.flush();
1560
1561 if self.signal_interception_active {
1562 LIVE_SIGNAL_INTERCEPT_SESSIONS.fetch_sub(1, Ordering::SeqCst);
1563 self.signal_interception_active = false;
1564 }
1565
1566 }
1568 }
1569}
1570
1571impl BackendEventSource for TtyBackend {
1576 type Error = io::Error;
1577
1578 fn size(&self) -> Result<(u16, u16), io::Error> {
1579 self.events.size()
1580 }
1581
1582 fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
1583 self.events.set_features(features)
1584 }
1585
1586 fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
1587 self.events.poll_event(timeout)
1588 }
1589
1590 fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
1591 self.events.read_event()
1592 }
1593}
1594
1595impl Backend for TtyBackend {
1596 type Error = io::Error;
1597 type Clock = TtyClock;
1598 type Events = TtyEventSource;
1599 type Presenter = TtyPresenter;
1600
1601 fn clock(&self) -> &Self::Clock {
1602 &self.clock
1603 }
1604
1605 fn events(&mut self) -> &mut Self::Events {
1606 &mut self.events
1607 }
1608
1609 fn presenter(&mut self) -> &mut Self::Presenter {
1610 &mut self.presenter
1611 }
1612}
1613
1614pub fn write_cleanup_sequence(
1622 features: &BackendFeatures,
1623 alt_screen: bool,
1624 writer: &mut impl Write,
1625) -> io::Result<()> {
1626 write_cleanup_sequence_policy(features, alt_screen, false, writer)
1627}
1628
1629pub fn write_cleanup_sequence_with_sync_end(
1633 features: &BackendFeatures,
1634 alt_screen: bool,
1635 writer: &mut impl Write,
1636) -> io::Result<()> {
1637 write_cleanup_sequence_policy(features, alt_screen, true, writer)
1638}
1639
1640fn write_cleanup_sequence_policy(
1641 features: &BackendFeatures,
1642 alt_screen: bool,
1643 emit_sync_end: bool,
1644 writer: &mut impl Write,
1645) -> io::Result<()> {
1646 write_cleanup_sequence_policy_with_mouse(
1647 features,
1648 alt_screen,
1649 emit_sync_end,
1650 MOUSE_DISABLE,
1651 writer,
1652 )
1653}
1654
1655fn write_cleanup_sequence_policy_with_mouse(
1656 features: &BackendFeatures,
1657 alt_screen: bool,
1658 emit_sync_end: bool,
1659 mouse_disable_seq: &[u8],
1660 writer: &mut impl Write,
1661) -> io::Result<()> {
1662 if emit_sync_end {
1663 writer.write_all(SYNC_END)?;
1664 }
1665 if features.kitty_keyboard {
1667 writer.write_all(KITTY_KEYBOARD_DISABLE)?;
1668 }
1669 if features.focus_events {
1670 writer.write_all(FOCUS_DISABLE)?;
1671 }
1672 if features.bracketed_paste {
1673 writer.write_all(BRACKETED_PASTE_DISABLE)?;
1674 }
1675 if features.mouse_capture {
1676 writer.write_all(mouse_disable_seq)?;
1677 }
1678 writer.write_all(CURSOR_SHOW)?;
1679 if alt_screen {
1680 writer.write_all(ALT_SCREEN_LEAVE)?;
1681 }
1682 Ok(())
1683}
1684
1685#[cfg(test)]
1688mod tests {
1689 use super::*;
1690
1691 #[test]
1692 fn clock_is_monotonic() {
1693 let clock = TtyClock::new();
1694 let t1 = clock.now_mono();
1695 std::hint::black_box(0..1000).for_each(|_| {});
1696 let t2 = clock.now_mono();
1697 assert!(t2 >= t1, "clock must be monotonic");
1698 }
1699
1700 #[test]
1701 fn event_source_reports_size() {
1702 let src = TtyEventSource::new(80, 24);
1703 let (w, h) = src.size().unwrap();
1704 assert_eq!(w, 80);
1705 assert_eq!(h, 24);
1706 }
1707
1708 #[test]
1709 fn event_source_set_features_headless() {
1710 let mut src = TtyEventSource::new(80, 24);
1711 let features = BackendFeatures {
1712 mouse_capture: true,
1713 bracketed_paste: true,
1714 focus_events: false,
1715 kitty_keyboard: false,
1716 };
1717 src.set_features(features).unwrap();
1718 assert_eq!(src.features(), features);
1719 }
1720
1721 #[test]
1722 fn poll_returns_false_headless() {
1723 let mut src = TtyEventSource::new(80, 24);
1724 assert!(!src.poll_event(Duration::from_millis(0)).unwrap());
1725 }
1726
1727 #[test]
1728 fn read_returns_none_headless() {
1729 let mut src = TtyEventSource::new(80, 24);
1730 assert!(src.read_event().unwrap().is_none());
1731 }
1732
1733 #[test]
1734 fn push_resize_enqueues_event_and_updates_size() {
1735 let mut src = TtyEventSource::new(80, 24);
1736 src.push_resize(120, 40);
1737 assert_eq!(src.size().unwrap(), (120, 40));
1738 assert_eq!(
1739 src.read_event().unwrap(),
1740 Some(Event::Resize {
1741 width: 120,
1742 height: 40,
1743 })
1744 );
1745 assert!(src.read_event().unwrap().is_none());
1746 }
1747
1748 #[test]
1749 fn push_resize_deduplicates_same_size() {
1750 let mut src = TtyEventSource::new(80, 24);
1751 src.push_resize(80, 24);
1752 assert!(src.event_queue.is_empty(), "no event when size unchanged");
1753 }
1754
1755 #[test]
1756 fn push_resize_ignores_zero_dimensions() {
1757 let mut src = TtyEventSource::new(80, 24);
1758 src.push_resize(0, 24);
1759 assert!(src.event_queue.is_empty());
1760 src.push_resize(80, 0);
1761 assert!(src.event_queue.is_empty());
1762 src.push_resize(0, 0);
1763 assert!(src.event_queue.is_empty());
1764 }
1765
1766 #[test]
1767 fn resize_storm_coalesces_and_no_panic() {
1768 let mut src = TtyEventSource::new(80, 24);
1769 for _ in 0..1000 {
1771 src.push_resize(120, 40);
1772 }
1773 assert_eq!(src.event_queue.len(), 1);
1775 assert_eq!(
1776 src.event_queue.pop_front().unwrap(),
1777 Event::Resize {
1778 width: 120,
1779 height: 40,
1780 }
1781 );
1782 }
1783
1784 #[test]
1785 fn resize_storm_varied_sizes_no_panic() {
1786 let mut src = TtyEventSource::new(80, 24);
1787 for i in 1..=500u16 {
1789 src.push_resize(80 + i, 24 + (i % 50));
1790 }
1791 let mut prev_w = 80u16;
1793 while let Some(Event::Resize { width, .. }) = src.event_queue.pop_front() {
1794 assert!(
1795 width > prev_w || width == prev_w + 1 || width != prev_w,
1796 "events must be in push order"
1797 );
1798 prev_w = width;
1799 }
1800 }
1801
1802 #[cfg(unix)]
1806 fn pipe_pair() -> (std::fs::File, std::os::unix::net::UnixStream) {
1807 use std::os::unix::net::UnixStream;
1808 let (a, b) = UnixStream::pair().unwrap();
1809 let reader: std::fs::File = std::os::fd::OwnedFd::from(a).into();
1811 (reader, b)
1812 }
1813
1814 #[cfg(unix)]
1815 #[test]
1816 fn pipe_ascii_chars() {
1817 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1818 let (reader, mut writer) = pipe_pair();
1819 let mut src = TtyEventSource::from_reader(80, 24, reader);
1820 writer.write_all(b"abc").unwrap();
1821 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1822 let e1 = src.read_event().unwrap().unwrap();
1823 assert_eq!(
1824 e1,
1825 Event::Key(KeyEvent {
1826 code: KeyCode::Char('a'),
1827 modifiers: Modifiers::NONE,
1828 kind: KeyEventKind::Press,
1829 })
1830 );
1831 let e2 = src.read_event().unwrap().unwrap();
1832 assert_eq!(
1833 e2,
1834 Event::Key(KeyEvent {
1835 code: KeyCode::Char('b'),
1836 modifiers: Modifiers::NONE,
1837 kind: KeyEventKind::Press,
1838 })
1839 );
1840 let e3 = src.read_event().unwrap().unwrap();
1841 assert_eq!(
1842 e3,
1843 Event::Key(KeyEvent {
1844 code: KeyCode::Char('c'),
1845 modifiers: Modifiers::NONE,
1846 kind: KeyEventKind::Press,
1847 })
1848 );
1849 assert!(src.read_event().unwrap().is_none());
1851 }
1852
1853 #[cfg(unix)]
1854 #[test]
1855 fn pipe_arrow_keys() {
1856 use ftui_core::event::{KeyCode, KeyEvent};
1857 let (reader, mut writer) = pipe_pair();
1858 let mut src = TtyEventSource::from_reader(80, 24, reader);
1859 writer.write_all(b"\x1b[A\x1b[B\x1b[C\x1b[D").unwrap();
1861 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1862 let codes: Vec<KeyCode> = std::iter::from_fn(|| src.read_event().unwrap())
1863 .map(|e| match e {
1864 Event::Key(KeyEvent { code, .. }) => Ok(code),
1865 other => Err(other),
1866 })
1867 .collect::<Result<Vec<_>, _>>()
1868 .unwrap();
1869 assert_eq!(
1870 codes,
1871 vec![KeyCode::Up, KeyCode::Down, KeyCode::Right, KeyCode::Left]
1872 );
1873 }
1874
1875 #[cfg(unix)]
1876 #[test]
1877 fn pipe_ctrl_keys() {
1878 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1879 let (reader, mut writer) = pipe_pair();
1880 let mut src = TtyEventSource::from_reader(80, 24, reader);
1881 writer.write_all(&[0x01, 0x03]).unwrap();
1883 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1884 let e1 = src.read_event().unwrap().unwrap();
1885 assert_eq!(
1886 e1,
1887 Event::Key(KeyEvent {
1888 code: KeyCode::Char('a'),
1889 modifiers: Modifiers::CTRL,
1890 kind: KeyEventKind::Press,
1891 })
1892 );
1893 let e2 = src.read_event().unwrap().unwrap();
1894 assert_eq!(
1895 e2,
1896 Event::Key(KeyEvent {
1897 code: KeyCode::Char('c'),
1898 modifiers: Modifiers::CTRL,
1899 kind: KeyEventKind::Press,
1900 })
1901 );
1902 }
1903
1904 #[cfg(unix)]
1905 #[test]
1906 fn pipe_function_keys() {
1907 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1908 let (reader, mut writer) = pipe_pair();
1909 let mut src = TtyEventSource::from_reader(80, 24, reader);
1910 writer.write_all(b"\x1bOP\x1b[15~").unwrap();
1912 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1913 let e1 = src.read_event().unwrap().unwrap();
1914 assert_eq!(
1915 e1,
1916 Event::Key(KeyEvent {
1917 code: KeyCode::F(1),
1918 modifiers: Modifiers::NONE,
1919 kind: KeyEventKind::Press,
1920 })
1921 );
1922 let e2 = src.read_event().unwrap().unwrap();
1923 assert_eq!(
1924 e2,
1925 Event::Key(KeyEvent {
1926 code: KeyCode::F(5),
1927 modifiers: Modifiers::NONE,
1928 kind: KeyEventKind::Press,
1929 })
1930 );
1931 }
1932
1933 #[cfg(unix)]
1934 #[test]
1935 fn pipe_mouse_sgr_click() {
1936 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
1937 let (reader, mut writer) = pipe_pair();
1938 let mut src = TtyEventSource::from_reader(80, 24, reader);
1939 writer.write_all(b"\x1b[<0;10;20M").unwrap();
1941 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1942 let e = src.read_event().unwrap().unwrap();
1943 assert_eq!(
1944 e,
1945 Event::Mouse(MouseEvent {
1946 kind: MouseEventKind::Down(MouseButton::Left),
1947 x: 9,
1948 y: 19,
1949 modifiers: Modifiers::NONE,
1950 })
1951 );
1952 }
1953
1954 #[cfg(unix)]
1955 #[test]
1956 fn pipe_mouse_x10_click_when_mouse_capture_enabled() {
1957 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
1958 let (reader, mut writer) = pipe_pair();
1959 let mut src = TtyEventSource::from_reader(80, 24, reader);
1960 src.set_features(BackendFeatures {
1961 mouse_capture: true,
1962 ..BackendFeatures::default()
1963 })
1964 .unwrap();
1965
1966 writer.write_all(&[0x1B, b'[', b'M', 32, 42, 52]).unwrap();
1969 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1970 let e = src.read_event().unwrap().unwrap();
1971 assert_eq!(
1972 e,
1973 Event::Mouse(MouseEvent {
1974 kind: MouseEventKind::Down(MouseButton::Left),
1975 x: 9,
1976 y: 19,
1977 modifiers: Modifiers::NONE,
1978 })
1979 );
1980 }
1981
1982 #[cfg(unix)]
1983 #[test]
1984 fn pipe_mouse_x10_not_decoded_when_mouse_capture_disabled() {
1985 use ftui_core::event::{KeyCode, KeyEvent};
1986 let (reader, mut writer) = pipe_pair();
1987 let mut src = TtyEventSource::from_reader(80, 24, reader);
1988 src.set_features(BackendFeatures::default()).unwrap();
1989
1990 writer.write_all(&[0x1B, b'[', b'M', 32, 42, 52]).unwrap();
1993 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1994 let e = src.read_event().unwrap().unwrap();
1995 assert!(matches!(
1996 e,
1997 Event::Key(KeyEvent {
1998 code: KeyCode::Char(' '),
1999 ..
2000 })
2001 ));
2002 }
2003
2004 #[cfg(unix)]
2005 #[test]
2006 fn pipe_mouse_legacy_1015_click_when_mouse_capture_enabled() {
2007 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
2008 let (reader, mut writer) = pipe_pair();
2009 let mut src = TtyEventSource::from_reader(80, 24, reader);
2010 src.set_features(BackendFeatures {
2011 mouse_capture: true,
2012 ..BackendFeatures::default()
2013 })
2014 .unwrap();
2015
2016 writer.write_all(b"\x1b[0;10;20M").unwrap();
2018 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2019 let e = src.read_event().unwrap().unwrap();
2020 assert_eq!(
2021 e,
2022 Event::Mouse(MouseEvent {
2023 kind: MouseEventKind::Down(MouseButton::Left),
2024 x: 9,
2025 y: 19,
2026 modifiers: Modifiers::NONE,
2027 })
2028 );
2029 }
2030
2031 #[cfg(unix)]
2032 #[test]
2033 fn pipe_mouse_legacy_1015_not_decoded_when_mouse_capture_disabled() {
2034 let (reader, mut writer) = pipe_pair();
2035 let mut src = TtyEventSource::from_reader(80, 24, reader);
2036 src.set_features(BackendFeatures::default()).unwrap();
2037
2038 writer.write_all(b"\x1b[0;10;20M").unwrap();
2039 assert!(!src.poll_event(Duration::from_millis(25)).unwrap());
2040 assert!(src.read_event().unwrap().is_none());
2041 }
2042
2043 #[cfg(unix)]
2044 #[test]
2045 fn pipe_focus_events() {
2046 let (reader, mut writer) = pipe_pair();
2047 let mut src = TtyEventSource::from_reader(80, 24, reader);
2048 writer.write_all(b"\x1b[I\x1b[O").unwrap();
2050 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2051 assert_eq!(src.read_event().unwrap().unwrap(), Event::Focus(true));
2052 assert_eq!(src.read_event().unwrap().unwrap(), Event::Focus(false));
2053 }
2054
2055 #[cfg(unix)]
2056 #[test]
2057 fn pipe_bracketed_paste() {
2058 use ftui_core::event::PasteEvent;
2059 let (reader, mut writer) = pipe_pair();
2060 let mut src = TtyEventSource::from_reader(80, 24, reader);
2061 writer.write_all(b"\x1b[200~hello world\x1b[201~").unwrap();
2062 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2063 let e = src.read_event().unwrap().unwrap();
2064 assert_eq!(
2065 e,
2066 Event::Paste(PasteEvent {
2067 text: "hello world".to_string(),
2068 bracketed: true,
2069 })
2070 );
2071 }
2072
2073 #[cfg(unix)]
2074 #[test]
2075 fn pipe_modified_arrow_key() {
2076 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
2077 let (reader, mut writer) = pipe_pair();
2078 let mut src = TtyEventSource::from_reader(80, 24, reader);
2079 writer.write_all(b"\x1b[1;5A").unwrap();
2081 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2082 let e = src.read_event().unwrap().unwrap();
2083 assert_eq!(
2084 e,
2085 Event::Key(KeyEvent {
2086 code: KeyCode::Up,
2087 modifiers: Modifiers::CTRL,
2088 kind: KeyEventKind::Press,
2089 })
2090 );
2091 }
2092
2093 #[cfg(unix)]
2094 #[test]
2095 fn pipe_scroll_events() {
2096 use ftui_core::event::{Modifiers, MouseEvent, MouseEventKind};
2097 let (reader, mut writer) = pipe_pair();
2098 let mut src = TtyEventSource::from_reader(80, 24, reader);
2099 writer.write_all(b"\x1b[<64;5;5M").unwrap();
2101 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2102 let e = src.read_event().unwrap().unwrap();
2103 assert_eq!(
2104 e,
2105 Event::Mouse(MouseEvent {
2106 kind: MouseEventKind::ScrollUp,
2107 x: 4,
2108 y: 4,
2109 modifiers: Modifiers::NONE,
2110 })
2111 );
2112 }
2113
2114 #[cfg(unix)]
2115 #[test]
2116 fn poll_returns_buffered_events_immediately() {
2117 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
2118 let (reader, mut writer) = pipe_pair();
2119 let mut src = TtyEventSource::from_reader(80, 24, reader);
2120 writer.write_all(b"xy").unwrap();
2122 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2123 let _ = src.read_event().unwrap().unwrap();
2125 assert!(src.poll_event(Duration::from_millis(0)).unwrap());
2127 let e = src.read_event().unwrap().unwrap();
2128 assert_eq!(
2129 e,
2130 Event::Key(KeyEvent {
2131 code: KeyCode::Char('y'),
2132 modifiers: Modifiers::NONE,
2133 kind: KeyEventKind::Press,
2134 })
2135 );
2136 }
2137
2138 #[cfg(unix)]
2139 #[test]
2140 fn pipe_large_ascii_burst_roundtrips() {
2141 use ftui_core::event::{KeyCode, KeyEvent};
2142
2143 let (reader, mut writer) = pipe_pair();
2144 let mut src = TtyEventSource::from_reader(80, 24, reader);
2145 let payload = vec![b'a'; 4 * 1024 * 1024];
2146 let expected_len = payload.len();
2147 let writer_thread = std::thread::spawn(move || writer.write_all(&payload));
2148
2149 let mut count = 0usize;
2150 let deadline = std::time::Instant::now() + Duration::from_secs(15);
2151 while count < expected_len {
2152 if !src.poll_event(Duration::from_millis(100)).unwrap() {
2153 assert!(
2154 std::time::Instant::now() < deadline,
2155 "timed out waiting for burst events: received {count} / {expected_len}"
2156 );
2157 continue;
2158 }
2159 while let Some(event) = src.read_event().unwrap() {
2160 match event {
2161 Event::Key(KeyEvent {
2162 code: KeyCode::Char('a'),
2163 ..
2164 }) => count += 1,
2165 other => panic!("unexpected event in ascii burst test: {other:?}"),
2166 }
2167 }
2168 }
2169 writer_thread.join().unwrap().unwrap();
2170
2171 assert_eq!(count, expected_len, "all bytes should decode to key events");
2172 }
2173
2174 #[cfg(unix)]
2177 #[test]
2178 fn truncated_csi_followed_by_valid_input() {
2179 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
2180 let (reader, mut writer) = pipe_pair();
2181 let mut src = TtyEventSource::from_reader(80, 24, reader);
2182 writer.write_all(b"\x1b[").unwrap();
2187 let _ = src.poll_event(Duration::from_millis(50));
2189 writer.write_all(b"\x1b[Ax").unwrap();
2191 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2192 let mut events = Vec::new();
2194 while let Some(e) = src.read_event().unwrap() {
2195 events.push(e);
2196 }
2197 let has_up = events.iter().any(|e| {
2199 matches!(
2200 e,
2201 Event::Key(KeyEvent {
2202 code: KeyCode::Up,
2203 ..
2204 })
2205 )
2206 });
2207 let has_x = events.iter().any(|e| {
2208 matches!(
2209 e,
2210 Event::Key(KeyEvent {
2211 code: KeyCode::Char('x'),
2212 modifiers: Modifiers::NONE,
2213 kind: KeyEventKind::Press,
2214 })
2215 )
2216 });
2217 assert!(
2218 has_up,
2219 "should parse Up arrow after partial CSI: {events:?}"
2220 );
2221 assert!(has_x, "should parse 'x' after recovery: {events:?}");
2222 }
2223
2224 #[cfg(unix)]
2225 #[test]
2226 fn unknown_csi_sequence_does_not_block_parser() {
2227 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
2228 let (reader, mut writer) = pipe_pair();
2229 let mut src = TtyEventSource::from_reader(80, 24, reader);
2230 writer.write_all(b"\x1b[999~z").unwrap();
2233 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2234 let mut events = Vec::new();
2235 while let Some(e) = src.read_event().unwrap() {
2236 events.push(e);
2237 }
2238 let has_z = events.iter().any(|e| {
2239 matches!(
2240 e,
2241 Event::Key(KeyEvent {
2242 code: KeyCode::Char('z'),
2243 modifiers: Modifiers::NONE,
2244 kind: KeyEventKind::Press,
2245 })
2246 )
2247 });
2248 assert!(
2249 has_z,
2250 "valid key after unknown CSI must be parsed: {events:?}"
2251 );
2252 }
2253
2254 #[cfg(unix)]
2255 #[test]
2256 fn eof_on_pipe_does_not_panic() {
2257 let (reader, writer) = pipe_pair();
2258 let mut src = TtyEventSource::from_reader(80, 24, reader);
2259 drop(writer);
2261 let result = src.poll_event(Duration::from_millis(50));
2263 assert!(result.is_ok(), "poll_event after EOF should not error");
2264 assert!(
2265 src.tty_reader.is_none(),
2266 "EOF should retire the exhausted reader"
2267 );
2268 let event = src.read_event().unwrap();
2270 assert!(event.is_none(), "read_event after EOF should be None");
2271 }
2272
2273 #[cfg(unix)]
2274 #[test]
2275 fn eof_disables_reader_for_future_polls() {
2276 let (reader, writer) = pipe_pair();
2277 let mut src = TtyEventSource::from_reader(80, 24, reader);
2278 drop(writer);
2279
2280 assert!(!src.poll_event(Duration::from_millis(20)).unwrap());
2281 assert!(src.tty_reader.is_none(), "EOF should clear the reader");
2282
2283 let start = Instant::now();
2284 assert!(!src.poll_event(Duration::from_millis(200)).unwrap());
2285 assert!(
2286 start.elapsed() < Duration::from_millis(50),
2287 "polls after EOF should return immediately once the reader is retired"
2288 );
2289 }
2290
2291 #[cfg(unix)]
2292 #[test]
2293 fn interleaved_invalid_and_valid_sequences() {
2294 use ftui_core::event::{KeyCode, KeyEvent};
2295 let (reader, mut writer) = pipe_pair();
2296 let mut src = TtyEventSource::from_reader(80, 24, reader);
2297 writer.write_all(b"\xC0a\x1b[999~b\x1b c").unwrap();
2300 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2301 let mut key_chars = Vec::new();
2302 while let Some(e) = src.read_event().unwrap() {
2303 if let Event::Key(KeyEvent {
2304 code: KeyCode::Char(ch),
2305 ..
2306 }) = e
2307 {
2308 key_chars.push(ch);
2309 }
2310 }
2311 assert!(
2314 key_chars.contains(&'a'),
2315 "should parse 'a' amid invalid input: {key_chars:?}"
2316 );
2317 assert!(
2318 key_chars.contains(&'b'),
2319 "should parse 'b' amid invalid input: {key_chars:?}"
2320 );
2321 assert!(
2322 key_chars.contains(&'c'),
2323 "should parse 'c' amid invalid input: {key_chars:?}"
2324 );
2325 }
2326
2327 #[cfg(unix)]
2328 #[test]
2329 fn split_escape_sequence_across_writes() {
2330 use ftui_core::event::{KeyCode, KeyEvent};
2331 let (reader, mut writer) = pipe_pair();
2332 let mut src = TtyEventSource::from_reader(80, 24, reader);
2333 writer.write_all(b"\x1b").unwrap();
2335 let _ = src.poll_event(Duration::from_millis(30));
2338 writer.write_all(b"[B").unwrap();
2340 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2341 let mut events = Vec::new();
2342 while let Some(e) = src.read_event().unwrap() {
2343 events.push(e);
2344 }
2345 let has_down = events.iter().any(|e| {
2346 matches!(
2347 e,
2348 Event::Key(KeyEvent {
2349 code: KeyCode::Down,
2350 ..
2351 })
2352 )
2353 });
2354 assert!(
2355 has_down,
2356 "Down arrow split across writes should be parsed: {events:?}"
2357 );
2358 }
2359
2360 #[cfg(unix)]
2361 #[test]
2362 fn poll_with_zero_timeout_returns_false_on_empty_pipe() {
2363 let (reader, _writer) = pipe_pair();
2364 let mut src = TtyEventSource::from_reader(80, 24, reader);
2365 let ready = src.poll_event(Duration::ZERO).unwrap();
2367 assert!(!ready, "empty pipe with zero timeout should not be ready");
2368 }
2369
2370 #[cfg(unix)]
2371 #[test]
2372 fn zero_timeout_poll_resolves_pending_escape_after_grace() {
2373 use ftui_core::event::{KeyCode, KeyEvent};
2374 let (reader, mut writer) = pipe_pair();
2375 let mut src = TtyEventSource::from_reader(80, 24, reader);
2376
2377 writer.write_all(b"\x1b").unwrap();
2379
2380 let ready = src.poll_event(Duration::ZERO).unwrap();
2382 assert!(!ready, "pending ESC should wait for timeout grace");
2383
2384 std::thread::sleep(PARSER_TIMEOUT_GRACE + Duration::from_millis(10));
2386 let ready = src.poll_event(Duration::ZERO).unwrap();
2387 assert!(ready, "zero-timeout poll should resolve overdue ESC");
2388
2389 let event = src.read_event().unwrap();
2390 assert!(matches!(
2391 event,
2392 Some(Event::Key(KeyEvent {
2393 code: KeyCode::Escape,
2394 ..
2395 }))
2396 ));
2397 }
2398
2399 #[cfg(unix)]
2400 #[test]
2401 fn nonzero_poll_waits_for_pending_escape_to_become_ready() {
2402 use ftui_core::event::{KeyCode, KeyEvent};
2403 let (reader, mut writer) = pipe_pair();
2404 let mut src = TtyEventSource::from_reader(80, 24, reader);
2405
2406 writer.write_all(b"\x1b").unwrap();
2407
2408 let ready = src.poll_event(Duration::from_millis(200)).unwrap();
2409 assert!(
2410 ready,
2411 "poll should wait for pending ESC to resolve within timeout"
2412 );
2413 assert!(matches!(
2414 src.read_event().unwrap(),
2415 Some(Event::Key(KeyEvent {
2416 code: KeyCode::Escape,
2417 ..
2418 }))
2419 ));
2420 }
2421
2422 #[cfg(unix)]
2423 #[test]
2424 fn resize_aware_poll_resolves_pending_escape_before_outer_timeout() {
2425 use ftui_core::event::{KeyCode, KeyEvent};
2426 let (reader, mut writer) = pipe_pair();
2427 let (resize_reader, _resize_writer) = UnixStream::pair().unwrap();
2428 resize_reader.set_nonblocking(true).unwrap();
2429 let mut src = TtyEventSource::from_reader(80, 24, reader);
2430 src.live = true;
2431 src.resize_reader = Some(resize_reader);
2432
2433 writer.write_all(b"\x1b").unwrap();
2434
2435 let start = Instant::now();
2436 let ready = src.poll_event(Duration::from_millis(250)).unwrap();
2437 let elapsed = start.elapsed();
2438 assert!(
2439 ready,
2440 "poll should resolve pending ESC while timeout budget remains"
2441 );
2442 assert!(
2443 elapsed < Duration::from_millis(200),
2444 "pending ESC should resolve near parser grace, not at outer deadline: {elapsed:?}"
2445 );
2446 assert!(matches!(
2447 src.read_event().unwrap(),
2448 Some(Event::Key(KeyEvent {
2449 code: KeyCode::Escape,
2450 ..
2451 }))
2452 ));
2453 }
2454
2455 #[cfg(unix)]
2456 #[test]
2457 fn resize_wake_bytes_are_drained_and_coalesced() {
2458 let (resize_reader, mut resize_writer) = UnixStream::pair().unwrap();
2459 resize_reader.set_nonblocking(true).unwrap();
2460 resize_writer.set_nonblocking(true).unwrap();
2461
2462 let mut src = TtyEventSource::new(80, 24);
2463 src.live = true;
2464 src.resize_reader = Some(resize_reader);
2465
2466 resize_writer.write_all(&[1, 1, 1]).unwrap();
2467
2468 assert!(
2469 src.drain_resize_wake_bytes(),
2470 "pending wake bytes should be observed"
2471 );
2472 assert!(
2473 !src.drain_resize_wake_bytes(),
2474 "draining should coalesce all pending wake bytes"
2475 );
2476 }
2477
2478 #[cfg(unix)]
2479 #[test]
2480 fn speculative_read_resolves_pending_escape_after_grace() {
2481 use ftui_core::event::{KeyCode, KeyEvent};
2482 let (reader, mut writer) = pipe_pair();
2483 let mut src = TtyEventSource::from_reader(80, 24, reader);
2484
2485 writer.write_all(b"\x1b").unwrap();
2486
2487 let ready = src.poll_event(Duration::ZERO).unwrap();
2488 assert!(!ready, "pending ESC should wait for timeout grace");
2489
2490 std::thread::sleep(PARSER_TIMEOUT_GRACE + Duration::from_millis(10));
2491
2492 let event = src.read_event().unwrap();
2493 assert!(matches!(
2494 event,
2495 Some(Event::Key(KeyEvent {
2496 code: KeyCode::Escape,
2497 ..
2498 }))
2499 ));
2500 }
2501
2502 #[cfg(unix)]
2503 #[test]
2504 fn speculative_read_prefers_ready_bytes_over_timeout_resolution_on_blocking_reader() {
2505 use ftui_core::event::{KeyCode, KeyEvent};
2506 let (reader, mut writer) = pipe_pair();
2507 let mut src = TtyEventSource::from_reader(80, 24, reader);
2508 src.reader_nonblocking = false;
2509
2510 writer.write_all(b"\x1b").unwrap();
2511
2512 let ready = src.poll_event(Duration::ZERO).unwrap();
2513 assert!(!ready, "pending ESC should wait for timeout grace");
2514
2515 writer.write_all(b"[B").unwrap();
2516 std::thread::sleep(PARSER_TIMEOUT_GRACE + Duration::from_millis(10));
2517
2518 let event = src.read_event().unwrap();
2519 assert!(matches!(
2520 event,
2521 Some(Event::Key(KeyEvent {
2522 code: KeyCode::Down,
2523 ..
2524 }))
2525 ));
2526 }
2527
2528 #[cfg(unix)]
2529 #[test]
2530 fn malformed_sgr_mouse_does_not_block() {
2531 use ftui_core::event::{KeyCode, KeyEvent};
2532 let (reader, mut writer) = pipe_pair();
2533 let mut src = TtyEventSource::from_reader(80, 24, reader);
2534 writer.write_all(b"\x1b[<M q").unwrap();
2536 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2537 let mut events = Vec::new();
2538 while let Some(e) = src.read_event().unwrap() {
2539 events.push(e);
2540 }
2541 let has_q = events.iter().any(|e| {
2543 matches!(
2544 e,
2545 Event::Key(KeyEvent {
2546 code: KeyCode::Char('q'),
2547 ..
2548 })
2549 )
2550 });
2551 assert!(
2552 has_q,
2553 "should parse 'q' after malformed SGR mouse: {events:?}"
2554 );
2555 }
2556
2557 #[test]
2560 fn buffer_zero_width_clamped_to_one() {
2561 let buf = Buffer::new(0, 5);
2562 assert_eq!(buf.width(), 1);
2563 assert_eq!(buf.height(), 5);
2564 }
2565
2566 #[test]
2567 fn buffer_zero_height_clamped_to_one() {
2568 let buf = Buffer::new(5, 0);
2569 assert_eq!(buf.width(), 5);
2570 assert_eq!(buf.height(), 1);
2571 }
2572
2573 #[test]
2574 fn presenter_1x1_buffer_does_not_panic() {
2575 let caps = TerminalCapabilities::detect();
2576 let mut presenter = TtyPresenter::with_writer(Vec::<u8>::new(), caps);
2577 let buf = Buffer::new(1, 1);
2578 let diff = BufferDiff::full(1, 1);
2579 presenter.present_ui(&buf, Some(&diff), false).unwrap();
2580 let bytes = presenter.inner.unwrap().into_inner().unwrap();
2582 assert!(!bytes.is_empty(), "1x1 buffer should produce output");
2583 }
2584
2585 #[test]
2586 fn presenter_capabilities() {
2587 let caps = TerminalCapabilities::detect();
2588 let presenter = TtyPresenter::new(caps);
2589 let _c = presenter.capabilities();
2590 }
2591
2592 #[test]
2595 fn headless_presenter_present_ui_is_noop() {
2596 let caps = TerminalCapabilities::detect();
2597 let mut presenter = TtyPresenter::new(caps);
2598 let buf = Buffer::new(10, 5);
2599 let diff = BufferDiff::full(10, 5);
2600 presenter.present_ui(&buf, Some(&diff), false).unwrap();
2602 presenter.present_ui(&buf, None, false).unwrap();
2603 presenter.present_ui(&buf, Some(&diff), true).unwrap();
2604 }
2605
2606 #[test]
2607 fn live_presenter_emits_ansi() {
2608 use ftui_render::cell::{Cell, CellAttrs, CellContent, PackedRgba, StyleFlags};
2609
2610 let caps = TerminalCapabilities::detect();
2611 let output = Vec::<u8>::new();
2612 let mut presenter = TtyPresenter::with_writer(output, caps);
2613
2614 let mut buf = Buffer::new(10, 2);
2615 let cell = Cell {
2617 content: CellContent::from_char('X'),
2618 fg: PackedRgba::RED,
2619 bg: PackedRgba::BLACK,
2620 attrs: CellAttrs::new(StyleFlags::BOLD, 0),
2621 };
2622 buf.set(0, 0, cell);
2623
2624 let diff = BufferDiff::full(10, 2);
2625 presenter.present_ui(&buf, Some(&diff), false).unwrap();
2626
2627 let inner = presenter.inner.unwrap();
2631 let bytes = inner.into_inner().unwrap();
2632 assert!(!bytes.is_empty(), "live presenter should emit output");
2633 assert!(
2634 bytes.windows(2).any(|w| w == b"\x1b["),
2635 "output should contain CSI escape sequences"
2636 );
2637 }
2638
2639 #[test]
2640 fn full_repaint_when_diff_is_none() {
2641 use ftui_render::cell::Cell;
2642
2643 let caps = TerminalCapabilities::detect();
2644 let output = Vec::<u8>::new();
2645 let mut presenter = TtyPresenter::with_writer(output, caps);
2646
2647 let mut buf = Buffer::new(5, 1);
2648 for x in 0..5 {
2649 buf.set(x, 0, Cell::from_char(b"ABCDE"[x as usize] as char));
2650 }
2651
2652 presenter.present_ui(&buf, None, false).unwrap();
2654
2655 let bytes = presenter.inner.unwrap().into_inner().unwrap();
2656 let output_str = String::from_utf8_lossy(&bytes);
2658 for ch in ['A', 'B', 'C', 'D', 'E'] {
2659 assert!(
2660 output_str.contains(ch),
2661 "full repaint should emit '{ch}', got: {output_str}"
2662 );
2663 }
2664 }
2665
2666 #[test]
2667 fn diff_based_partial_update() {
2668 use ftui_render::cell::Cell;
2669
2670 let caps = TerminalCapabilities::detect();
2671 let output = Vec::<u8>::new();
2672 let mut presenter = TtyPresenter::with_writer(output, caps);
2673
2674 let mut old = Buffer::new(5, 1);
2675 for x in 0..5 {
2676 old.set(x, 0, Cell::from_char(b"ABCDE"[x as usize] as char));
2677 }
2678 let mut new = old.clone();
2679 new.set(2, 0, Cell::from_char('Z'));
2680 let diff = BufferDiff::compute(&old, &new);
2681 presenter.present_ui(&new, Some(&diff), false).unwrap();
2682
2683 let bytes = presenter.inner.unwrap().into_inner().unwrap();
2684 let output_str = String::from_utf8_lossy(&bytes);
2685 assert!(
2687 output_str.contains('Z'),
2688 "diff-based update should emit changed cell 'Z'"
2689 );
2690 assert!(
2691 !output_str.contains('A'),
2692 "diff-based update should not emit unchanged cell 'A'"
2693 );
2694 }
2695
2696 #[test]
2697 fn write_log_headless_does_not_panic() {
2698 let caps = TerminalCapabilities::detect();
2699 let mut presenter = TtyPresenter::new(caps);
2700 presenter.write_log("headless log test").unwrap();
2701 }
2702
2703 #[test]
2704 fn write_log_live_does_not_corrupt_ui_stream() {
2705 let caps = TerminalCapabilities::detect();
2706 let mut presenter = TtyPresenter::with_writer(Vec::<u8>::new(), caps);
2707 presenter.write_log("live log test").unwrap();
2708 let bytes = presenter.inner.unwrap().into_inner().unwrap();
2709 assert!(bytes.is_empty(), "write_log must not emit UI bytes");
2710 }
2711
2712 #[test]
2713 fn backend_headless_construction() {
2714 let backend = TtyBackend::new(120, 40);
2715 assert!(!backend.is_live());
2716 let (w, h) = backend.events.size().unwrap();
2717 assert_eq!(w, 120);
2718 assert_eq!(h, 40);
2719 }
2720
2721 #[test]
2722 fn backend_trait_impl() {
2723 let mut backend = TtyBackend::new(80, 24);
2724 let _t = backend.clock().now_mono();
2725 let (w, h) = backend.events().size().unwrap();
2726 assert_eq!((w, h), (80, 24));
2727 let _c = backend.presenter().capabilities();
2728 }
2729
2730 #[test]
2731 fn feature_delta_writes_enable_sequences() {
2732 let current = BackendFeatures::default();
2733 let new = BackendFeatures {
2734 mouse_capture: true,
2735 bracketed_paste: true,
2736 focus_events: true,
2737 kitty_keyboard: true,
2738 };
2739 let mut buf = Vec::new();
2740 TtyEventSource::write_feature_delta(
2741 ¤t,
2742 &new,
2743 TerminalCapabilities::modern(),
2744 &mut buf,
2745 )
2746 .unwrap();
2747 assert!(
2748 buf.windows(MOUSE_ENABLE.len()).any(|w| w == MOUSE_ENABLE),
2749 "expected mouse enable sequence"
2750 );
2751 assert!(
2752 !buf.windows(b"\x1b[?1003h".len())
2753 .any(|w| w == b"\x1b[?1003h"),
2754 "mouse enable should avoid 1003 any-event mode"
2755 );
2756 assert!(
2757 !buf.ends_with(b"\x1b[?1016l"),
2758 "mouse enable should not end with 1016l (can force X10 fallback on some terminals)"
2759 );
2760 let pos_1016l = buf
2761 .windows(b"\x1b[?1016l".len())
2762 .position(|w| w == b"\x1b[?1016l")
2763 .expect("mouse enable should clear 1016 before enabling SGR");
2764 let pos_1006h = buf
2765 .windows(b"\x1b[?1006h".len())
2766 .position(|w| w == b"\x1b[?1006h")
2767 .expect("mouse enable should include 1006 SGR mode");
2768 assert!(
2769 pos_1016l < pos_1006h,
2770 "1016l must be emitted before 1006h to preserve SGR mode on Ghostty-like terminals"
2771 );
2772 assert!(
2773 buf.windows(BRACKETED_PASTE_ENABLE.len())
2774 .any(|w| w == BRACKETED_PASTE_ENABLE),
2775 "expected bracketed paste enable"
2776 );
2777 assert!(
2778 buf.windows(FOCUS_ENABLE.len()).any(|w| w == FOCUS_ENABLE),
2779 "expected focus enable"
2780 );
2781 assert!(
2782 buf.windows(KITTY_KEYBOARD_ENABLE.len())
2783 .any(|w| w == KITTY_KEYBOARD_ENABLE),
2784 "expected kitty keyboard enable"
2785 );
2786 }
2787
2788 #[test]
2789 fn mouse_enable_sequence_for_mux_capabilities_is_safe() {
2790 let mux_caps = TerminalCapabilities::builder()
2791 .mouse_sgr(true)
2792 .in_wezterm_mux(true)
2793 .build();
2794 assert_eq!(
2795 mouse_enable_sequence_for_capabilities(mux_caps),
2796 MOUSE_ENABLE_MUX_SAFE
2797 );
2798 assert!(
2799 MOUSE_ENABLE_MUX_SAFE
2800 .windows(b"\x1b[?1005l".len())
2801 .any(|w| w == b"\x1b[?1005l"),
2802 "mux-safe enable should clear UTF-8 mouse encoding (1005)"
2803 );
2804 assert!(
2805 MOUSE_ENABLE_MUX_SAFE
2806 .windows(b"\x1b[?1015l".len())
2807 .any(|w| w == b"\x1b[?1015l"),
2808 "mux-safe enable should clear urxvt mouse encoding (1015)"
2809 );
2810 assert!(
2811 MOUSE_ENABLE_MUX_SAFE
2812 .windows(b"\x1b[?1006h".len())
2813 .any(|w| w == b"\x1b[?1006h"),
2814 "mux-safe enable should keep SGR mouse mode"
2815 );
2816 assert!(
2817 !MOUSE_ENABLE_MUX_SAFE
2818 .windows(b"\x1b[?1003h".len())
2819 .any(|w| w == b"\x1b[?1003h"),
2820 "mux-safe enable should avoid 1003 any-event mode"
2821 );
2822 let pos_1016l = MOUSE_ENABLE_MUX_SAFE
2823 .windows(b"\x1b[?1016l".len())
2824 .position(|w| w == b"\x1b[?1016l")
2825 .expect("mux-safe enable should clear 1016 before enabling SGR");
2826 let pos_1006h = MOUSE_ENABLE_MUX_SAFE
2827 .windows(b"\x1b[?1006h".len())
2828 .position(|w| w == b"\x1b[?1006h")
2829 .expect("mux-safe enable should include 1006 SGR mode");
2830 assert!(
2831 pos_1016l < pos_1006h,
2832 "mux-safe enable must emit 1016l before 1006h to preserve SGR mode"
2833 );
2834 }
2835
2836 #[test]
2837 fn mouse_disable_sequence_for_mux_capabilities_clears_1016() {
2838 let mux_caps = TerminalCapabilities::builder()
2839 .mouse_sgr(true)
2840 .in_wezterm_mux(true)
2841 .build();
2842 assert_eq!(
2843 mouse_disable_sequence_for_capabilities(mux_caps),
2844 MOUSE_DISABLE_MUX_SAFE
2845 );
2846 let pos_1016l = MOUSE_DISABLE_MUX_SAFE
2847 .windows(b"\x1b[?1016l".len())
2848 .position(|w| w == b"\x1b[?1016l")
2849 .expect("mux-safe disable should clear 1016");
2850 let pos_1006l = MOUSE_DISABLE_MUX_SAFE
2851 .windows(b"\x1b[?1006l".len())
2852 .position(|w| w == b"\x1b[?1006l")
2853 .expect("mux-safe disable should include 1006 reset");
2854 assert!(
2855 pos_1016l < pos_1006l,
2856 "mux-safe disable should clear 1016 before disabling 1006"
2857 );
2858 }
2859
2860 #[test]
2861 fn feature_delta_uses_mux_safe_mouse_sequence() {
2862 let current = BackendFeatures::default();
2863 let new = BackendFeatures {
2864 mouse_capture: true,
2865 bracketed_paste: false,
2866 focus_events: false,
2867 kitty_keyboard: false,
2868 };
2869 let mux_caps = TerminalCapabilities::builder()
2870 .mouse_sgr(true)
2871 .in_wezterm_mux(true)
2872 .build();
2873 let mut buf = Vec::new();
2874 TtyEventSource::write_feature_delta(¤t, &new, mux_caps, &mut buf).unwrap();
2875 assert!(
2876 buf.windows(MOUSE_ENABLE_MUX_SAFE.len())
2877 .any(|w| w == MOUSE_ENABLE_MUX_SAFE),
2878 "feature delta should use mux-safe mouse enable sequence in mux contexts"
2879 );
2880 assert!(
2881 buf.windows(b"\x1b[?1005l".len())
2882 .any(|w| w == b"\x1b[?1005l"),
2883 "feature delta should clear UTF-8 mouse encoding (1005) in mux contexts"
2884 );
2885 }
2886
2887 #[test]
2888 fn feature_delta_writes_disable_sequences() {
2889 let current = BackendFeatures {
2890 mouse_capture: true,
2891 bracketed_paste: true,
2892 focus_events: true,
2893 kitty_keyboard: true,
2894 };
2895 let new = BackendFeatures::default();
2896 let mut buf = Vec::new();
2897 TtyEventSource::write_feature_delta(
2898 ¤t,
2899 &new,
2900 TerminalCapabilities::modern(),
2901 &mut buf,
2902 )
2903 .unwrap();
2904 assert!(buf.windows(MOUSE_DISABLE.len()).any(|w| w == MOUSE_DISABLE));
2905 assert!(
2906 buf.windows(BRACKETED_PASTE_DISABLE.len())
2907 .any(|w| w == BRACKETED_PASTE_DISABLE)
2908 );
2909 assert!(buf.windows(FOCUS_DISABLE.len()).any(|w| w == FOCUS_DISABLE));
2910 assert!(
2911 buf.windows(KITTY_KEYBOARD_DISABLE.len())
2912 .any(|w| w == KITTY_KEYBOARD_DISABLE)
2913 );
2914 }
2915
2916 #[test]
2917 fn feature_delta_noop_when_unchanged() {
2918 let features = BackendFeatures {
2919 mouse_capture: true,
2920 bracketed_paste: false,
2921 focus_events: true,
2922 kitty_keyboard: false,
2923 };
2924 let mut buf = Vec::new();
2925 TtyEventSource::write_feature_delta(
2926 &features,
2927 &features,
2928 TerminalCapabilities::modern(),
2929 &mut buf,
2930 )
2931 .unwrap();
2932 assert!(buf.is_empty(), "no output expected when features unchanged");
2933 }
2934
2935 #[test]
2936 fn cleanup_sequence_contains_all_disable() {
2937 let features = BackendFeatures {
2938 mouse_capture: true,
2939 bracketed_paste: true,
2940 focus_events: true,
2941 kitty_keyboard: true,
2942 };
2943 let mut buf = Vec::new();
2944 write_cleanup_sequence(&features, true, &mut buf).unwrap();
2945
2946 assert!(
2948 !buf.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2949 "default cleanup utility must not emit standalone sync_end"
2950 );
2951 assert!(buf.windows(MOUSE_DISABLE.len()).any(|w| w == MOUSE_DISABLE));
2952 assert!(
2953 buf.windows(BRACKETED_PASTE_DISABLE.len())
2954 .any(|w| w == BRACKETED_PASTE_DISABLE)
2955 );
2956 assert!(buf.windows(FOCUS_DISABLE.len()).any(|w| w == FOCUS_DISABLE));
2957 assert!(
2958 buf.windows(KITTY_KEYBOARD_DISABLE.len())
2959 .any(|w| w == KITTY_KEYBOARD_DISABLE)
2960 );
2961 assert!(buf.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW));
2962 assert!(
2963 buf.windows(ALT_SCREEN_LEAVE.len())
2964 .any(|w| w == ALT_SCREEN_LEAVE)
2965 );
2966 }
2967
2968 #[test]
2969 fn cleanup_sequence_with_sync_end_opt_in() {
2970 let features = BackendFeatures {
2971 mouse_capture: true,
2972 bracketed_paste: false,
2973 focus_events: false,
2974 kitty_keyboard: false,
2975 };
2976 let mut buf = Vec::new();
2977 write_cleanup_sequence_with_sync_end(&features, true, &mut buf).unwrap();
2978
2979 assert!(
2980 buf.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2981 "opt-in cleanup helper should include sync_end"
2982 );
2983 let sync_pos = buf
2984 .windows(SYNC_END.len())
2985 .position(|w| w == SYNC_END)
2986 .expect("sync_end present");
2987 let cursor_pos = buf
2988 .windows(CURSOR_SHOW.len())
2989 .position(|w| w == CURSOR_SHOW)
2990 .expect("cursor_show present");
2991 assert!(
2992 sync_pos < cursor_pos,
2993 "sync_end should precede cursor_show in opt-in cleanup"
2994 );
2995 }
2996
2997 #[test]
2998 fn cleanup_sequence_policy_can_skip_sync_end() {
2999 let features = BackendFeatures {
3000 mouse_capture: true,
3001 bracketed_paste: false,
3002 focus_events: false,
3003 kitty_keyboard: false,
3004 };
3005 let mut buf = Vec::new();
3006 write_cleanup_sequence_policy(&features, false, false, &mut buf).unwrap();
3007
3008 assert!(
3009 !buf.windows(SYNC_END.len()).any(|w| w == SYNC_END),
3010 "sync_end must be omitted when policy disables synchronized output"
3011 );
3012 assert!(
3013 buf.windows(MOUSE_DISABLE.len()).any(|w| w == MOUSE_DISABLE),
3014 "other cleanup bytes must still be emitted"
3015 );
3016 assert!(buf.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW));
3017 }
3018
3019 #[test]
3020 fn conservative_feature_union_is_over_disabling_superset() {
3021 let a = BackendFeatures {
3022 mouse_capture: false,
3023 bracketed_paste: true,
3024 focus_events: false,
3025 kitty_keyboard: true,
3026 };
3027 let b = BackendFeatures {
3028 mouse_capture: true,
3029 bracketed_paste: false,
3030 focus_events: true,
3031 kitty_keyboard: false,
3032 };
3033
3034 let merged = conservative_feature_union(a, b);
3035 assert!(merged.mouse_capture);
3036 assert!(merged.bracketed_paste);
3037 assert!(merged.focus_events);
3038 assert!(merged.kitty_keyboard);
3039 }
3040
3041 #[test]
3042 fn sanitize_feature_request_disables_unsupported_capabilities() {
3043 let requested = BackendFeatures {
3044 mouse_capture: true,
3045 bracketed_paste: true,
3046 focus_events: true,
3047 kitty_keyboard: true,
3048 };
3049 let sanitized = sanitize_feature_request(requested, TerminalCapabilities::basic());
3050 assert_eq!(sanitized, BackendFeatures::default());
3051 }
3052
3053 #[test]
3054 fn sanitize_feature_request_is_conservative_in_wezterm_mux() {
3055 let requested = BackendFeatures {
3056 mouse_capture: true,
3057 bracketed_paste: true,
3058 focus_events: true,
3059 kitty_keyboard: true,
3060 };
3061 let caps = TerminalCapabilities::builder()
3062 .mouse_sgr(true)
3063 .bracketed_paste(true)
3064 .focus_events(true)
3065 .kitty_keyboard(true)
3066 .in_wezterm_mux(true)
3067 .build();
3068 let sanitized = sanitize_feature_request(requested, caps);
3069
3070 assert!(
3071 sanitized.mouse_capture,
3072 "mouse capture should remain available"
3073 );
3074 assert!(
3075 sanitized.bracketed_paste,
3076 "bracketed paste should remain available"
3077 );
3078 assert!(
3079 !sanitized.focus_events,
3080 "focus events should be disabled in wezterm mux"
3081 );
3082 assert!(
3083 !sanitized.kitty_keyboard,
3084 "kitty keyboard should be disabled in mux sessions"
3085 );
3086 }
3087
3088 #[test]
3089 fn sanitize_feature_request_disables_focus_in_tmux() {
3090 let requested = BackendFeatures {
3091 mouse_capture: true,
3092 bracketed_paste: true,
3093 focus_events: true,
3094 kitty_keyboard: true,
3095 };
3096 let caps = TerminalCapabilities::builder()
3097 .mouse_sgr(true)
3098 .bracketed_paste(true)
3099 .focus_events(true)
3100 .kitty_keyboard(true)
3101 .in_tmux(true)
3102 .build();
3103 let sanitized = sanitize_feature_request(requested, caps);
3104
3105 assert!(sanitized.mouse_capture);
3106 assert!(sanitized.bracketed_paste);
3107 assert!(!sanitized.focus_events);
3108 assert!(!sanitized.kitty_keyboard);
3109 }
3110
3111 #[cfg(unix)]
3112 #[test]
3113 fn signal_intercept_guard_disabled_reports_inactive() {
3114 let mut guard = SignalInterceptGuard::new(false);
3115 assert!(
3116 !guard.disarm(),
3117 "disabled guard should report inactive ownership"
3118 );
3119 }
3120
3121 #[cfg(unix)]
3122 #[test]
3123 fn signal_intercept_guard_disarm_transfers_ownership() {
3124 let mut guard = SignalInterceptGuard::new(true);
3125 assert!(
3126 guard.disarm(),
3127 "enabled guard should report transferred ownership on disarm"
3128 );
3129 LIVE_SIGNAL_INTERCEPT_SESSIONS.fetch_sub(1, Ordering::SeqCst);
3132 }
3133
3134 #[test]
3135 fn apply_feature_state_enables_legacy_fallbacks_when_mouse_capture_on() {
3136 let mut src = TtyEventSource::new(80, 24);
3137 src.capabilities = TerminalCapabilities::builder().mouse_sgr(true).build();
3138 src.apply_feature_state(BackendFeatures {
3139 mouse_capture: true,
3140 ..BackendFeatures::default()
3141 });
3142
3143 let modern_events = src.parser.parse(b"\x1b[0;10;20M");
3146 assert!(
3147 modern_events.iter().any(|e| matches!(e, Event::Mouse(_))),
3148 "legacy numeric fallback should remain available with mouse capture on"
3149 );
3150 let modern_x10 = src.parser.parse(&[0x1B, b'[', b'M', 32, 42, 52]);
3151 assert!(
3152 modern_x10.iter().any(|e| matches!(e, Event::Mouse(_))),
3153 "raw X10 fallback should stay available with mouse capture on"
3154 );
3155
3156 src.capabilities = TerminalCapabilities::basic();
3157 src.apply_feature_state(BackendFeatures {
3158 mouse_capture: true,
3159 ..BackendFeatures::default()
3160 });
3161
3162 let legacy_events = src.parser.parse(b"\x1b[0;10;20M");
3164 assert!(
3165 legacy_events.iter().any(|e| matches!(e, Event::Mouse(_))),
3166 "legacy mouse fallback should be enabled when SGR is unavailable"
3167 );
3168 let legacy_x10 = src.parser.parse(&[0x1B, b'[', b'M', 32, 42, 52]);
3169 assert!(
3170 legacy_x10.iter().any(|e| matches!(e, Event::Mouse(_))),
3171 "raw X10 decoding should be enabled when SGR is unavailable"
3172 );
3173
3174 src.apply_feature_state(BackendFeatures::default());
3175 let disabled_x10 = src.parser.parse(&[0x1B, b'[', b'M', 32, 42, 52]);
3176 assert!(
3177 disabled_x10.iter().all(|e| !matches!(e, Event::Mouse(_))),
3178 "raw X10 fallback must be disabled when mouse capture is off"
3179 );
3180 }
3181
3182 #[test]
3183 fn normalize_event_maps_pixel_space_mouse_to_cell_grid() {
3184 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
3185
3186 let mut src = TtyEventSource::new(100, 40);
3187 src.capabilities = TerminalCapabilities::builder().mouse_sgr(true).build();
3188 src.features = BackendFeatures {
3189 mouse_capture: true,
3190 ..BackendFeatures::default()
3191 };
3192 src.pixel_width = 1000;
3193 src.pixel_height = 800;
3194
3195 let event = Event::Mouse(MouseEvent {
3196 kind: MouseEventKind::Down(MouseButton::Left),
3197 x: 500,
3198 y: 400,
3199 modifiers: Modifiers::NONE,
3200 });
3201 let normalized = src.normalize_event(event);
3202
3203 let mouse = match normalized {
3204 Event::Mouse(mouse) => mouse,
3205 other => {
3206 panic!("expected mouse event, got {other:?}");
3207 }
3208 };
3209 assert!(mouse.x < src.width, "x should be mapped into cell bounds");
3210 assert!(mouse.y < src.height, "y should be mapped into cell bounds");
3211 assert!(
3212 mouse.x > 0 && mouse.y > 0,
3213 "pixel-space event should not collapse to origin"
3214 );
3215 }
3216
3217 #[test]
3218 fn normalize_event_keeps_cell_space_mouse_unchanged() {
3219 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
3220
3221 let mut src = TtyEventSource::new(100, 40);
3222 src.capabilities = TerminalCapabilities::builder().mouse_sgr(true).build();
3223 src.features = BackendFeatures {
3224 mouse_capture: true,
3225 ..BackendFeatures::default()
3226 };
3227 src.pixel_width = 1000;
3228 src.pixel_height = 800;
3229
3230 let event = Event::Mouse(MouseEvent {
3231 kind: MouseEventKind::Down(MouseButton::Left),
3232 x: 50,
3233 y: 10,
3234 modifiers: Modifiers::NONE,
3235 });
3236 let normalized = src.normalize_event(event.clone());
3237 assert_eq!(
3238 normalized, event,
3239 "cell-space coordinates must be preserved"
3240 );
3241 }
3242
3243 #[test]
3244 fn normalize_event_sticky_pixel_mode_maps_subsequent_low_coordinates() {
3245 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
3246
3247 let mut src = TtyEventSource::new(100, 40);
3248 src.capabilities = TerminalCapabilities::builder().mouse_sgr(true).build();
3249 src.features = BackendFeatures {
3250 mouse_capture: true,
3251 ..BackendFeatures::default()
3252 };
3253 src.pixel_width = 1000;
3254 src.pixel_height = 800;
3255
3256 let first = Event::Mouse(MouseEvent {
3257 kind: MouseEventKind::Down(MouseButton::Left),
3258 x: 700,
3259 y: 500,
3260 modifiers: Modifiers::NONE,
3261 });
3262 let _ = src.normalize_event(first);
3263 assert!(
3264 src.mouse_coords_pixels,
3265 "large out-of-grid mouse event should arm sticky pixel normalization"
3266 );
3267
3268 let second = Event::Mouse(MouseEvent {
3269 kind: MouseEventKind::Down(MouseButton::Left),
3270 x: 100,
3271 y: 20,
3272 modifiers: Modifiers::NONE,
3273 });
3274 let normalized = src.normalize_event(second);
3275 let mouse = match normalized {
3276 Event::Mouse(mouse) => mouse,
3277 other => {
3278 panic!("expected mouse event, got {other:?}");
3279 }
3280 };
3281 assert!(mouse.x < src.width, "sticky mode should normalize x");
3282 assert!(mouse.y < src.height, "sticky mode should normalize y");
3283 }
3284
3285 #[test]
3286 fn apply_feature_state_disabling_mouse_resets_pixel_detector() {
3287 let mut src = TtyEventSource::new(80, 24);
3288 src.mouse_coords_pixels = true;
3289 src.inferred_pixel_width = 1234;
3290 src.inferred_pixel_height = 777;
3291 src.apply_feature_state(BackendFeatures::default());
3292 assert!(
3293 !src.mouse_coords_pixels,
3294 "disabling mouse capture should clear sticky pixel-mode detector"
3295 );
3296 assert_eq!(src.inferred_pixel_width, 0);
3297 assert_eq!(src.inferred_pixel_height, 0);
3298 }
3299
3300 #[test]
3301 fn normalize_event_infers_pixel_grid_when_winsize_pixels_missing() {
3302 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
3303
3304 let mut src = TtyEventSource::new(100, 40);
3305 src.capabilities = TerminalCapabilities::builder().mouse_sgr(true).build();
3306 src.features = BackendFeatures {
3307 mouse_capture: true,
3308 ..BackendFeatures::default()
3309 };
3310 src.pixel_width = 0;
3312 src.pixel_height = 0;
3313
3314 let first = Event::Mouse(MouseEvent {
3315 kind: MouseEventKind::Down(MouseButton::Left),
3316 x: 700,
3317 y: 500,
3318 modifiers: Modifiers::NONE,
3319 });
3320 let normalized_first = src.normalize_event(first);
3321 let first_mouse = match normalized_first {
3322 Event::Mouse(mouse) => mouse,
3323 other => {
3324 panic!("expected mouse event, got {other:?}");
3325 }
3326 };
3327 assert!(first_mouse.x > 0 && first_mouse.x < src.width.saturating_sub(1));
3328 assert!(first_mouse.y > 0 && first_mouse.y < src.height.saturating_sub(1));
3329
3330 let second = Event::Mouse(MouseEvent {
3331 kind: MouseEventKind::Moved,
3332 x: 250,
3333 y: 200,
3334 modifiers: Modifiers::NONE,
3335 });
3336 let normalized = src.normalize_event(second);
3337 let mouse = match normalized {
3338 Event::Mouse(mouse) => mouse,
3339 other => {
3340 panic!("expected mouse event, got {other:?}");
3341 }
3342 };
3343
3344 assert!(mouse.x < src.width);
3345 assert!(mouse.y < src.height);
3346 assert!(mouse.x > 0 && mouse.x < src.width.saturating_sub(1));
3347 assert!(mouse.y > 0 && mouse.y < src.height.saturating_sub(1));
3348 }
3349
3350 #[test]
3351 fn normalize_event_near_edge_outside_grid_clamps_without_sticky_pixel_mode() {
3352 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
3353
3354 let mut src = TtyEventSource::new(100, 40);
3355 src.capabilities = TerminalCapabilities::builder().mouse_sgr(true).build();
3356 src.features = BackendFeatures {
3357 mouse_capture: true,
3358 ..BackendFeatures::default()
3359 };
3360 src.pixel_width = 1000;
3361 src.pixel_height = 800;
3362
3363 let near_edge = Event::Mouse(MouseEvent {
3364 kind: MouseEventKind::Down(MouseButton::Left),
3365 x: 100,
3366 y: 40,
3367 modifiers: Modifiers::NONE,
3368 });
3369 let normalized = src.normalize_event(near_edge);
3370 let mouse = match normalized {
3371 Event::Mouse(mouse) => mouse,
3372 other => {
3373 panic!("expected mouse event, got {other:?}");
3374 }
3375 };
3376 assert_eq!(mouse.x, 99);
3377 assert_eq!(mouse.y, 39);
3378 assert!(
3379 !src.mouse_coords_pixels,
3380 "edge clamp must not arm sticky pixel normalization"
3381 );
3382
3383 let follow_up = Event::Mouse(MouseEvent {
3384 kind: MouseEventKind::Moved,
3385 x: 50,
3386 y: 20,
3387 modifiers: Modifiers::NONE,
3388 });
3389 let normalized_follow_up = src.normalize_event(follow_up);
3390 assert_eq!(
3391 normalized_follow_up,
3392 Event::Mouse(MouseEvent {
3393 kind: MouseEventKind::Moved,
3394 x: 50,
3395 y: 20,
3396 modifiers: Modifiers::NONE,
3397 }),
3398 "normal cell-space events should remain unchanged after edge clamp"
3399 );
3400 }
3401
3402 #[test]
3403 fn cleanup_sequence_ordering() {
3404 let features = BackendFeatures {
3405 mouse_capture: true,
3406 bracketed_paste: true,
3407 focus_events: true,
3408 kitty_keyboard: true,
3409 };
3410 let mut buf = Vec::new();
3411 write_cleanup_sequence(&features, true, &mut buf).unwrap();
3412
3413 let cursor_pos = buf
3415 .windows(CURSOR_SHOW.len())
3416 .position(|w| w == CURSOR_SHOW)
3417 .expect("cursor_show present");
3418 let alt_pos = buf
3419 .windows(ALT_SCREEN_LEAVE.len())
3420 .position(|w| w == ALT_SCREEN_LEAVE)
3421 .expect("alt_screen_leave present");
3422
3423 assert!(
3424 cursor_pos < alt_pos,
3425 "cursor_show must come before alt_screen_leave"
3426 );
3427 }
3428
3429 #[test]
3430 fn disable_all_resets_feature_state() {
3431 let mut src = TtyEventSource::new(80, 24);
3432 src.features = BackendFeatures {
3433 mouse_capture: true,
3434 bracketed_paste: true,
3435 focus_events: true,
3436 kitty_keyboard: true,
3437 };
3438 let mut buf = Vec::new();
3439 src.disable_all(&mut buf).unwrap();
3440 assert_eq!(src.features(), BackendFeatures::default());
3441 assert!(!buf.is_empty());
3443 }
3444
3445 #[cfg(unix)]
3448 mod pty_tests {
3449 use super::*;
3450 use nix::pty::openpty;
3451 use nix::sys::termios::{self, LocalFlags};
3452 use std::io::Read;
3453
3454 fn pty_pair() -> (std::fs::File, std::fs::File) {
3455 let result = openpty(None, None).expect("openpty failed");
3456 (
3457 std::fs::File::from(result.master),
3458 std::fs::File::from(result.slave),
3459 )
3460 }
3461
3462 #[test]
3463 fn raw_mode_entered_and_restored_on_drop() {
3464 let (_master, slave) = pty_pair();
3465 let slave_dup = slave.try_clone().unwrap();
3466
3467 let before = termios::tcgetattr(&slave_dup).unwrap();
3469 assert!(
3470 before.local_flags.contains(LocalFlags::ECHO),
3471 "default termios should have ECHO"
3472 );
3473 assert!(
3474 before.local_flags.contains(LocalFlags::ICANON),
3475 "default termios should have ICANON"
3476 );
3477
3478 {
3479 let _guard = RawModeGuard::enter_on(slave).unwrap();
3480
3481 let during = termios::tcgetattr(&slave_dup).unwrap();
3483 assert!(
3484 !during.local_flags.contains(LocalFlags::ECHO),
3485 "raw mode should clear ECHO"
3486 );
3487 assert!(
3488 !during.local_flags.contains(LocalFlags::ICANON),
3489 "raw mode should clear ICANON"
3490 );
3491 }
3492
3493 let after = termios::tcgetattr(&slave_dup).unwrap();
3495 assert!(
3496 after.local_flags.contains(LocalFlags::ECHO),
3497 "should restore ECHO after drop"
3498 );
3499 assert!(
3500 after.local_flags.contains(LocalFlags::ICANON),
3501 "should restore ICANON after drop"
3502 );
3503 }
3504
3505 #[test]
3506 fn panic_restores_termios() {
3507 let (_master, slave) = pty_pair();
3508 let slave_dup = slave.try_clone().unwrap();
3509
3510 let handle = std::thread::spawn(move || {
3512 let _guard = RawModeGuard::enter_on(slave).unwrap();
3513 std::panic::panic_any("intentional panic for testing raw mode cleanup");
3514 });
3515
3516 assert!(handle.join().is_err(), "thread should have panicked");
3517
3518 let after = termios::tcgetattr(&slave_dup).unwrap();
3520 assert!(
3521 after.local_flags.contains(LocalFlags::ECHO),
3522 "ECHO should be restored after panic"
3523 );
3524 assert!(
3525 after.local_flags.contains(LocalFlags::ICANON),
3526 "ICANON should be restored after panic"
3527 );
3528 }
3529
3530 #[test]
3531 fn backend_drop_writes_cleanup_sequences() {
3532 let (mut master, slave) = pty_pair();
3533 let slave_dup = slave.try_clone().unwrap();
3534
3535 {
3536 let _guard = RawModeGuard::enter_on(slave).unwrap();
3537
3538 let mut stdout_buf = Vec::new();
3540 let all_on = BackendFeatures {
3541 mouse_capture: true,
3542 bracketed_paste: true,
3543 focus_events: true,
3544 kitty_keyboard: true,
3545 };
3546 TtyEventSource::write_feature_delta(
3547 &BackendFeatures::default(),
3548 &all_on,
3549 TerminalCapabilities::modern(),
3550 &mut stdout_buf,
3551 )
3552 .unwrap();
3553 write_cleanup_sequence(&all_on, true, &mut stdout_buf).unwrap();
3555
3556 use std::io::Write;
3558 let mut slave_writer = slave_dup.try_clone().unwrap();
3559 slave_writer.write_all(&stdout_buf).unwrap();
3560 slave_writer.flush().unwrap();
3561 }
3562
3563 let mut buf = vec![0u8; 2048];
3565 let n = master.read(&mut buf).unwrap();
3566 let output = &buf[..n];
3567
3568 assert!(
3569 output.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW),
3570 "cleanup must show cursor"
3571 );
3572 assert!(
3573 output
3574 .windows(MOUSE_DISABLE.len())
3575 .any(|w| w == MOUSE_DISABLE),
3576 "cleanup must disable mouse"
3577 );
3578 assert!(
3579 output
3580 .windows(ALT_SCREEN_LEAVE.len())
3581 .any(|w| w == ALT_SCREEN_LEAVE),
3582 "cleanup must leave alt-screen"
3583 );
3584 }
3585
3586 fn write_to_slave_and_read_master(
3588 master: &mut std::fs::File,
3589 slave: &std::fs::File,
3590 data: &[u8],
3591 ) -> Vec<u8> {
3592 use std::io::Write;
3593 let mut writer = slave.try_clone().unwrap();
3594 writer.write_all(data).unwrap();
3595 writer.flush().unwrap();
3596 let mut buf = vec![0u8; 4096];
3597 let n = master.read(&mut buf).unwrap();
3598 buf.truncate(n);
3599 buf
3600 }
3601
3602 #[test]
3603 fn cursor_hide_on_enter_show_on_drop() {
3604 let (mut master, slave) = pty_pair();
3605 let slave_dup = slave.try_clone().unwrap();
3606
3607 {
3609 let _guard = RawModeGuard::enter_on(slave).unwrap();
3610 let output = write_to_slave_and_read_master(&mut master, &slave_dup, CURSOR_HIDE);
3611 assert!(
3612 output.windows(CURSOR_HIDE.len()).any(|w| w == CURSOR_HIDE),
3613 "cursor-hide should be written on session enter"
3614 );
3615
3616 let output = write_to_slave_and_read_master(&mut master, &slave_dup, CURSOR_SHOW);
3618 assert!(
3619 output.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW),
3620 "cursor-show should be written on session exit"
3621 );
3622 }
3623 }
3624
3625 #[test]
3626 fn alt_screen_enter_and_leave_via_pty() {
3627 let (mut master, slave) = pty_pair();
3628 let slave_dup = slave.try_clone().unwrap();
3629
3630 {
3631 let _guard = RawModeGuard::enter_on(slave).unwrap();
3632
3633 let output =
3635 write_to_slave_and_read_master(&mut master, &slave_dup, ALT_SCREEN_ENTER);
3636 assert!(
3637 output
3638 .windows(ALT_SCREEN_ENTER.len())
3639 .any(|w| w == ALT_SCREEN_ENTER),
3640 "alt-screen enter should pass through PTY"
3641 );
3642
3643 let output =
3645 write_to_slave_and_read_master(&mut master, &slave_dup, ALT_SCREEN_LEAVE);
3646 assert!(
3647 output
3648 .windows(ALT_SCREEN_LEAVE.len())
3649 .any(|w| w == ALT_SCREEN_LEAVE),
3650 "alt-screen leave should pass through PTY"
3651 );
3652 }
3653 }
3654
3655 #[test]
3656 fn per_feature_disable_on_drop() {
3657 let (mut master, slave) = pty_pair();
3658 let slave_dup = slave.try_clone().unwrap();
3659
3660 {
3661 let _guard = RawModeGuard::enter_on(slave).unwrap();
3662
3663 let all_on = BackendFeatures {
3665 mouse_capture: true,
3666 bracketed_paste: true,
3667 focus_events: true,
3668 kitty_keyboard: true,
3669 };
3670 let mut cleanup = Vec::new();
3671 write_cleanup_sequence(&all_on, false, &mut cleanup).unwrap();
3672
3673 let output = write_to_slave_and_read_master(&mut master, &slave_dup, &cleanup);
3674
3675 assert!(
3677 output
3678 .windows(MOUSE_DISABLE.len())
3679 .any(|w| w == MOUSE_DISABLE),
3680 "mouse must be disabled on drop"
3681 );
3682 assert!(
3683 output
3684 .windows(BRACKETED_PASTE_DISABLE.len())
3685 .any(|w| w == BRACKETED_PASTE_DISABLE),
3686 "bracketed paste must be disabled on drop"
3687 );
3688 assert!(
3689 output
3690 .windows(FOCUS_DISABLE.len())
3691 .any(|w| w == FOCUS_DISABLE),
3692 "focus events must be disabled on drop"
3693 );
3694 assert!(
3695 output
3696 .windows(KITTY_KEYBOARD_DISABLE.len())
3697 .any(|w| w == KITTY_KEYBOARD_DISABLE),
3698 "kitty keyboard must be disabled on drop"
3699 );
3700 assert!(
3701 output.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW),
3702 "cursor must be shown on drop"
3703 );
3704 }
3705 }
3706
3707 #[test]
3708 fn panic_with_features_restores_termios() {
3709 let (_master, slave) = pty_pair();
3710 let slave_dup = slave.try_clone().unwrap();
3711
3712 let handle = std::thread::spawn(move || {
3713 let _guard = RawModeGuard::enter_on(slave).unwrap();
3714 std::panic::panic_any("panic with features enabled");
3718 });
3719
3720 assert!(handle.join().is_err());
3721
3722 let after = termios::tcgetattr(&slave_dup).unwrap();
3723 assert!(
3724 after.local_flags.contains(LocalFlags::ECHO),
3725 "ECHO restored after panic with features"
3726 );
3727 assert!(
3728 after.local_flags.contains(LocalFlags::ICANON),
3729 "ICANON restored after panic with features"
3730 );
3731 }
3732
3733 #[test]
3734 fn repeated_raw_mode_cycles_no_leak() {
3735 let (_master, slave) = pty_pair();
3736 let slave_dup = slave.try_clone().unwrap();
3737
3738 for _ in 0..5 {
3740 let s = slave_dup.try_clone().unwrap();
3741 let guard = RawModeGuard::enter_on(s).unwrap();
3742
3743 let during = termios::tcgetattr(&slave_dup).unwrap();
3745 assert!(!during.local_flags.contains(LocalFlags::ECHO));
3746
3747 drop(guard);
3748
3749 let after = termios::tcgetattr(&slave_dup).unwrap();
3751 assert!(
3752 after.local_flags.contains(LocalFlags::ECHO),
3753 "ECHO must be restored each cycle"
3754 );
3755 }
3756 }
3757
3758 #[test]
3759 fn cleanup_ordering_via_pty() {
3760 let (mut master, slave) = pty_pair();
3761 let slave_dup = slave.try_clone().unwrap();
3762
3763 {
3764 let _guard = RawModeGuard::enter_on(slave).unwrap();
3765
3766 let features = BackendFeatures {
3768 mouse_capture: true,
3769 bracketed_paste: true,
3770 focus_events: true,
3771 kitty_keyboard: true,
3772 };
3773 let mut seq = Vec::new();
3774 write_cleanup_sequence_with_sync_end(&features, true, &mut seq).unwrap();
3775
3776 let output = write_to_slave_and_read_master(&mut master, &slave_dup, &seq);
3777
3778 let sync_pos = output
3780 .windows(SYNC_END.len())
3781 .position(|w| w == SYNC_END)
3782 .expect("sync_end present");
3783 let cursor_pos = output
3784 .windows(CURSOR_SHOW.len())
3785 .position(|w| w == CURSOR_SHOW)
3786 .expect("cursor_show present");
3787 let alt_pos = output
3788 .windows(ALT_SCREEN_LEAVE.len())
3789 .position(|w| w == ALT_SCREEN_LEAVE)
3790 .expect("alt_screen_leave present");
3791
3792 assert!(
3793 sync_pos < cursor_pos,
3794 "sync_end ({sync_pos}) must precede cursor_show ({cursor_pos})"
3795 );
3796 assert!(
3797 cursor_pos < alt_pos,
3798 "cursor_show ({cursor_pos}) must precede alt_screen_leave ({alt_pos})"
3799 );
3800 }
3801 }
3802 }
3803}