1#![forbid(unsafe_code)]
2use core::time::Duration;
20use std::collections::VecDeque;
21use std::io::{self, Read, Write};
22use std::sync::{Mutex, OnceLock, mpsc};
23use std::time::Instant;
24
25use ftui_backend::{Backend, BackendClock, BackendEventSource, BackendFeatures, BackendPresenter};
26use ftui_core::event::{Event, MouseEventKind};
27use ftui_core::input_parser::InputParser;
28use ftui_core::terminal_capabilities::TerminalCapabilities;
29use ftui_render::buffer::Buffer;
30use ftui_render::diff::BufferDiff;
31use ftui_render::presenter::Presenter;
32
33#[cfg(unix)]
34use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGWINCH};
35#[cfg(unix)]
36use signal_hook::iterator::Signals;
37
38const ALT_SCREEN_ENTER: &[u8] = b"\x1b[?1049h";
41const ALT_SCREEN_LEAVE: &[u8] = b"\x1b[?1049l";
42
43const MOUSE_ENABLE: &[u8] = b"\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?1000;1002;1006h\x1b[?1000h\x1b[?1002h\x1b[?1006h";
50const MOUSE_ENABLE_MUX_SAFE: &[u8] = b"\x1b[?1016l\x1b[?1000h\x1b[?1002h\x1b[?1006h";
51const MOUSE_DISABLE: &[u8] = b"\x1b[?1000;1002;1006l\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l";
52const MOUSE_DISABLE_MUX_SAFE: &[u8] = b"\x1b[?1016l\x1b[?1000l\x1b[?1002l\x1b[?1006l";
53
54const BRACKETED_PASTE_ENABLE: &[u8] = b"\x1b[?2004h";
55const BRACKETED_PASTE_DISABLE: &[u8] = b"\x1b[?2004l";
56
57const FOCUS_ENABLE: &[u8] = b"\x1b[?1004h";
58const FOCUS_DISABLE: &[u8] = b"\x1b[?1004l";
59
60const KITTY_KEYBOARD_ENABLE: &[u8] = b"\x1b[>15u";
61const KITTY_KEYBOARD_DISABLE: &[u8] = b"\x1b[<u";
62
63const CURSOR_SHOW: &[u8] = b"\x1b[?25h";
64#[allow(dead_code)]
65const CURSOR_HIDE: &[u8] = b"\x1b[?25l";
66
67const SYNC_END: &[u8] = b"\x1b[?2026l";
68const RESET_SCROLL_REGION: &[u8] = b"\x1b[r";
69const SGR_RESET: &[u8] = b"\x1b[0m";
70
71#[inline]
72const fn mouse_disable_sequence_for_capabilities(
73 capabilities: TerminalCapabilities,
74) -> &'static [u8] {
75 if capabilities.in_any_mux() {
76 MOUSE_DISABLE_MUX_SAFE
77 } else {
78 MOUSE_DISABLE
79 }
80}
81
82#[inline]
83const fn mouse_enable_sequence_for_capabilities(
84 capabilities: TerminalCapabilities,
85) -> &'static [u8] {
86 if capabilities.in_any_mux() {
87 MOUSE_ENABLE_MUX_SAFE
88 } else {
89 MOUSE_ENABLE
90 }
91}
92
93#[inline]
94const fn sanitize_feature_request(
95 requested: BackendFeatures,
96 capabilities: TerminalCapabilities,
97) -> BackendFeatures {
98 let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
103 let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
104
105 BackendFeatures {
106 mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
107 bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
108 focus_events: requested.focus_events && focus_events_supported,
109 kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
110 }
111}
112
113#[inline]
114const fn conservative_feature_union(a: BackendFeatures, b: BackendFeatures) -> BackendFeatures {
115 BackendFeatures {
116 mouse_capture: a.mouse_capture || b.mouse_capture,
117 bracketed_paste: a.bracketed_paste || b.bracketed_paste,
118 focus_events: a.focus_events || b.focus_events,
119 kitty_keyboard: a.kitty_keyboard || b.kitty_keyboard,
120 }
121}
122
123const CLEAR_SCREEN: &[u8] = b"\x1b[2J";
124const CURSOR_HOME: &[u8] = b"\x1b[H";
125const READ_BUFFER_BYTES: usize = 8192;
126const MAX_DRAIN_BYTES_PER_POLL: usize = READ_BUFFER_BYTES;
127const INFERRED_PIXEL_WIDTH_PER_CELL: u16 = 8;
128const INFERRED_PIXEL_HEIGHT_PER_CELL: u16 = 16;
129const PARSER_TIMEOUT_GRACE: Duration = Duration::from_millis(50);
134
135#[cfg(unix)]
136fn raw_mode_snapshot_slot() -> &'static Mutex<Option<nix::sys::termios::Termios>> {
137 static SLOT: OnceLock<Mutex<Option<nix::sys::termios::Termios>>> = OnceLock::new();
138 SLOT.get_or_init(|| Mutex::new(None))
139}
140
141#[cfg(unix)]
142fn store_raw_mode_snapshot(termios: &nix::sys::termios::Termios) {
143 let slot = raw_mode_snapshot_slot();
144 let mut guard = slot.lock().unwrap_or_else(|poison| poison.into_inner());
145 *guard = Some(termios.clone());
146}
147
148#[cfg(unix)]
149fn clear_raw_mode_snapshot() {
150 let slot = raw_mode_snapshot_slot();
151 let mut guard = slot.lock().unwrap_or_else(|poison| poison.into_inner());
152 *guard = None;
153}
154
155#[cfg(unix)]
156fn restore_raw_mode_snapshot() {
157 let slot = raw_mode_snapshot_slot();
158 let snapshot = {
159 let guard = slot.lock().unwrap_or_else(|poison| poison.into_inner());
160 guard.clone()
161 };
162
163 let Some(original) = snapshot else {
164 return;
165 };
166
167 let Ok(tty) = std::fs::File::open("/dev/tty") else {
168 return;
169 };
170 let _ = nix::sys::termios::tcsetattr(&tty, nix::sys::termios::SetArg::TCSAFLUSH, &original);
171}
172
173#[inline]
174const fn cleanup_features_for_capabilities(capabilities: TerminalCapabilities) -> BackendFeatures {
175 BackendFeatures {
176 mouse_capture: capabilities.mouse_sgr,
177 bracketed_paste: capabilities.bracketed_paste,
178 focus_events: capabilities.focus_events && !capabilities.in_any_mux(),
179 kitty_keyboard: capabilities.kitty_keyboard && !capabilities.in_any_mux(),
180 }
181}
182
183#[cfg(unix)]
184fn write_terminal_state_resets(writer: &mut impl Write) -> io::Result<()> {
185 writer.write_all(RESET_SCROLL_REGION)?;
186 writer.write_all(SGR_RESET)?;
187 Ok(())
188}
189
190#[cfg(unix)]
191fn best_effort_termination_cleanup() {
192 let mut stdout = io::stdout();
193 let caps = TerminalCapabilities::with_overrides();
194 let _ = write_terminal_state_resets(&mut stdout);
195 let emit_sync_end = false;
198 let features = cleanup_features_for_capabilities(caps);
199 let mouse_disable = mouse_disable_sequence_for_capabilities(caps);
200 let _ = write_cleanup_sequence_policy_with_mouse(
201 &features,
202 true,
203 emit_sync_end,
204 mouse_disable,
205 &mut stdout,
206 );
207 let _ = stdout.flush();
208 restore_raw_mode_snapshot();
209}
210
211#[cfg(unix)]
212fn install_abort_panic_hook() {
213 if !cfg!(panic = "abort") {
214 return;
215 }
216 static HOOK: OnceLock<()> = OnceLock::new();
217 HOOK.get_or_init(|| {
218 let previous = std::panic::take_hook();
219 std::panic::set_hook(Box::new(move |info| {
220 best_effort_termination_cleanup();
221 previous(info);
222 }));
223 });
224}
225
226#[cfg(unix)]
227fn install_termination_signal_hook() {
228 static HOOK: OnceLock<()> = OnceLock::new();
229 HOOK.get_or_init(|| {
230 let mut signals = match Signals::new([SIGINT, SIGTERM, SIGHUP, SIGQUIT]) {
231 Ok(signals) => signals,
232 Err(_) => return,
233 };
234 let _ = std::thread::Builder::new()
235 .name("ftui-tty-term-signal".to_string())
236 .spawn(move || {
237 if let Some(signal) = signals.forever().next() {
238 best_effort_termination_cleanup();
239 std::process::exit(128 + signal);
240 }
241 });
242 });
243}
244
245#[cfg(unix)]
256pub struct RawModeGuard {
257 original_termios: nix::sys::termios::Termios,
258 tty: std::fs::File,
259}
260
261#[cfg(unix)]
262impl RawModeGuard {
263 pub fn enter() -> io::Result<Self> {
266 let tty = std::fs::File::open("/dev/tty")?;
267 Self::enter_on(tty)
268 }
269
270 pub fn enter_on(tty: std::fs::File) -> io::Result<Self> {
272 let original_termios = nix::sys::termios::tcgetattr(&tty).map_err(io::Error::other)?;
273
274 let mut raw = original_termios.clone();
275 nix::sys::termios::cfmakeraw(&mut raw);
276 nix::sys::termios::tcsetattr(&tty, nix::sys::termios::SetArg::TCSAFLUSH, &raw)
277 .map_err(io::Error::other)?;
278
279 store_raw_mode_snapshot(&original_termios);
280
281 Ok(Self {
282 original_termios,
283 tty,
284 })
285 }
286}
287
288#[cfg(unix)]
289impl Drop for RawModeGuard {
290 fn drop(&mut self) {
291 let _ = nix::sys::termios::tcsetattr(
293 &self.tty,
294 nix::sys::termios::SetArg::TCSAFLUSH,
295 &self.original_termios,
296 );
297 clear_raw_mode_snapshot();
298 }
299}
300
301#[derive(Debug, Clone, Default)]
305pub struct TtySessionOptions {
306 pub alternate_screen: bool,
308 pub features: BackendFeatures,
310}
311
312pub struct TtyClock {
316 epoch: std::time::Instant,
317}
318
319impl TtyClock {
320 #[must_use]
321 pub fn new() -> Self {
322 Self {
323 epoch: std::time::Instant::now(),
324 }
325 }
326}
327
328impl Default for TtyClock {
329 fn default() -> Self {
330 Self::new()
331 }
332}
333
334impl BackendClock for TtyClock {
335 fn now_mono(&self) -> Duration {
336 self.epoch.elapsed()
337 }
338}
339
340#[cfg(unix)]
347#[derive(Debug)]
348struct ResizeSignalGuard {
349 handle: signal_hook::iterator::Handle,
350 thread: Option<std::thread::JoinHandle<()>>,
351}
352
353#[cfg(unix)]
354impl ResizeSignalGuard {
355 fn new(tx: mpsc::SyncSender<()>) -> io::Result<Self> {
356 let mut signals = Signals::new([SIGWINCH]).map_err(io::Error::other)?;
357 let handle = signals.handle();
358 let thread = std::thread::spawn(move || {
359 for _ in signals.forever() {
360 let _ = tx.try_send(());
363 }
364 });
365
366 Ok(Self {
367 handle,
368 thread: Some(thread),
369 })
370 }
371}
372
373#[cfg(unix)]
374impl Drop for ResizeSignalGuard {
375 fn drop(&mut self) {
376 self.handle.close();
377 if let Some(thread) = self.thread.take() {
378 let _ = thread.join();
379 }
380 }
381}
382
383pub struct TtyEventSource {
389 features: BackendFeatures,
390 capabilities: TerminalCapabilities,
391 width: u16,
392 height: u16,
393 pixel_width: u16,
395 pixel_height: u16,
397 mouse_coords_pixels: bool,
402 inferred_pixel_width: u16,
408 inferred_pixel_height: u16,
410 live: bool,
413 #[cfg(unix)]
415 resize_rx: Option<mpsc::Receiver<()>>,
416 #[cfg(unix)]
418 _resize_guard: Option<ResizeSignalGuard>,
419 parser: InputParser,
421 event_queue: VecDeque<Event>,
423 tty_reader: Option<std::fs::File>,
425 reader_nonblocking: bool,
427 last_input_byte_at: Option<Instant>,
429}
430
431impl TtyEventSource {
432 #[must_use]
434 pub fn new(width: u16, height: u16) -> Self {
435 Self {
436 features: BackendFeatures::default(),
437 capabilities: TerminalCapabilities::basic(),
438 width,
439 height,
440 pixel_width: 0,
441 pixel_height: 0,
442 mouse_coords_pixels: false,
443 inferred_pixel_width: 0,
444 inferred_pixel_height: 0,
445 live: false,
446 #[cfg(unix)]
447 resize_rx: None,
448 #[cfg(unix)]
449 _resize_guard: None,
450 parser: InputParser::new(),
451 event_queue: VecDeque::new(),
452 tty_reader: None,
453 reader_nonblocking: false,
454 last_input_byte_at: None,
455 }
456 }
457
458 fn live(width: u16, height: u16, capabilities: TerminalCapabilities) -> io::Result<Self> {
461 let tty_reader = std::fs::File::open("/dev/tty")?;
462 let reader_nonblocking = Self::try_enable_nonblocking(&tty_reader);
463 let mut w = width;
464 let mut h = height;
465 let mut pw = 0;
466 let mut ph = 0;
467 #[cfg(unix)]
468 if let Ok(ws) = rustix::termios::tcgetwinsize(&tty_reader) {
469 if ws.ws_col > 0 && ws.ws_row > 0 {
470 w = ws.ws_col;
471 h = ws.ws_row;
472 }
473 pw = ws.ws_xpixel;
474 ph = ws.ws_ypixel;
475 }
476
477 #[cfg(unix)]
478 let (resize_guard, resize_rx) = {
479 let (resize_tx, resize_rx) = mpsc::sync_channel(1);
480 match ResizeSignalGuard::new(resize_tx) {
481 Ok(guard) => (Some(guard), Some(resize_rx)),
482 Err(_) => (None, None),
483 }
484 };
485
486 Ok(Self {
487 features: BackendFeatures::default(),
488 capabilities,
489 width: w,
490 height: h,
491 pixel_width: pw,
492 pixel_height: ph,
493 mouse_coords_pixels: false,
494 inferred_pixel_width: 0,
495 inferred_pixel_height: 0,
496 live: true,
497 #[cfg(unix)]
498 resize_rx,
499 #[cfg(unix)]
500 _resize_guard: resize_guard,
501 parser: InputParser::new(),
502 event_queue: VecDeque::new(),
503 tty_reader: Some(tty_reader),
504 reader_nonblocking,
505 last_input_byte_at: None,
506 })
507 }
508
509 #[cfg(test)]
514 fn from_reader(width: u16, height: u16, reader: std::fs::File) -> Self {
515 let reader_nonblocking = Self::try_enable_nonblocking(&reader);
516 Self {
517 features: BackendFeatures::default(),
518 capabilities: TerminalCapabilities::basic(),
519 width,
520 height,
521 pixel_width: 0,
522 pixel_height: 0,
523 mouse_coords_pixels: false,
524 inferred_pixel_width: 0,
525 inferred_pixel_height: 0,
526 live: false,
527 #[cfg(unix)]
528 resize_rx: None,
529 #[cfg(unix)]
530 _resize_guard: None,
531 parser: InputParser::new(),
532 event_queue: VecDeque::new(),
533 tty_reader: Some(reader),
534 reader_nonblocking,
535 last_input_byte_at: None,
536 }
537 }
538
539 #[cfg(unix)]
540 fn try_enable_nonblocking(reader: &std::fs::File) -> bool {
541 use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
542
543 let Ok(flags) = fcntl_getfl(reader) else {
544 return false;
545 };
546 if flags.contains(OFlags::NONBLOCK) {
547 return true;
548 }
549 fcntl_setfl(reader, flags | OFlags::NONBLOCK).is_ok()
550 }
551
552 #[cfg(not(unix))]
553 fn try_enable_nonblocking(_reader: &std::fs::File) -> bool {
554 false
555 }
556
557 #[must_use]
559 pub fn features(&self) -> BackendFeatures {
560 self.features
561 }
562
563 #[inline]
564 fn sanitize_features(&self, requested: BackendFeatures) -> BackendFeatures {
565 if !self.live {
566 return requested;
567 }
568 sanitize_feature_request(requested, self.capabilities)
569 }
570
571 fn apply_feature_state(&mut self, features: BackendFeatures) {
581 self.features = features;
582 if !features.mouse_capture {
583 self.mouse_coords_pixels = false;
584 self.inferred_pixel_width = 0;
585 self.inferred_pixel_height = 0;
586 }
587 self.parser.set_expect_x10_mouse(features.mouse_capture);
588 self.parser.set_allow_legacy_mouse(features.mouse_capture);
591 }
592
593 fn push_resize(&mut self, new_width: u16, new_height: u16) {
594 if new_width == 0 || new_height == 0 {
595 return;
596 }
597 if (new_width, new_height) == (self.width, self.height) {
598 return;
599 }
600 self.width = new_width;
601 self.height = new_height;
602 self.event_queue.push_back(Event::Resize {
603 width: new_width,
604 height: new_height,
605 });
606 }
607
608 fn normalize_event(&mut self, event: Event) -> Event {
615 let Event::Mouse(mut mouse) = event else {
616 return event;
617 };
618
619 let outside_grid = mouse.x >= self.width || mouse.y >= self.height;
620 let strongly_outside =
621 mouse.x >= self.width.saturating_mul(2) || mouse.y >= self.height.saturating_mul(2);
622 let kind_implies_in_viewport = matches!(
623 mouse.kind,
624 MouseEventKind::Down(_)
625 | MouseEventKind::Up(_)
626 | MouseEventKind::ScrollUp
627 | MouseEventKind::ScrollDown
628 | MouseEventKind::ScrollLeft
629 | MouseEventKind::ScrollRight
630 );
631 if !self.mouse_coords_pixels
632 && (strongly_outside || (outside_grid && kind_implies_in_viewport))
633 {
634 self.mouse_coords_pixels = true;
635 }
636 let likely_pixel_space = self.mouse_coords_pixels || strongly_outside;
637 if !self.features.mouse_capture || !self.capabilities.mouse_sgr || !likely_pixel_space {
638 return Event::Mouse(mouse);
639 }
640
641 if self.width == 0 || self.height == 0 {
642 return Event::Mouse(mouse);
643 }
644 if self.pixel_width > 0 && self.pixel_height > 0 {
645 mouse.x = Self::scale_mouse_coord(mouse.x, self.width, self.pixel_width);
646 mouse.y = Self::scale_mouse_coord(mouse.y, self.height, self.pixel_height);
647 } else {
648 if self.inferred_pixel_width == 0 {
652 self.inferred_pixel_width = self
653 .width
654 .saturating_mul(INFERRED_PIXEL_WIDTH_PER_CELL)
655 .max(self.width);
656 }
657 if self.inferred_pixel_height == 0 {
658 self.inferred_pixel_height = self
659 .height
660 .saturating_mul(INFERRED_PIXEL_HEIGHT_PER_CELL)
661 .max(self.height);
662 }
663 self.inferred_pixel_width = self
664 .inferred_pixel_width
665 .max(mouse.x.saturating_add(1))
666 .max(self.width);
667 self.inferred_pixel_height = self
668 .inferred_pixel_height
669 .max(mouse.y.saturating_add(1))
670 .max(self.height);
671
672 mouse.x =
673 Self::scale_mouse_coord(mouse.x, self.width, self.inferred_pixel_width.max(1));
674 mouse.y =
675 Self::scale_mouse_coord(mouse.y, self.height, self.inferred_pixel_height.max(1));
676 }
677 Event::Mouse(mouse)
678 }
679
680 #[inline]
681 fn scale_mouse_coord(coord: u16, cells: u16, pixels: u16) -> u16 {
682 if cells <= 1 {
683 return 0;
684 }
685 if pixels <= 1 {
686 return coord.min(cells.saturating_sub(1));
687 }
688
689 let num = u32::from(coord).saturating_mul(u32::from(cells.saturating_sub(1)));
690 let den = u32::from(pixels.saturating_sub(1));
691 let scaled = num / den.max(1);
692 let scaled_u16 = u16::try_from(scaled).unwrap_or(u16::MAX);
693 scaled_u16.min(cells.saturating_sub(1))
694 }
695
696 #[cfg(unix)]
697 fn query_tty_winsize(&self) -> Option<rustix::termios::Winsize> {
698 if !self.live {
699 return None;
700 }
701 let tty = self.tty_reader.as_ref()?;
702 rustix::termios::tcgetwinsize(tty).ok()
703 }
704
705 #[cfg(unix)]
706 fn query_tty_size(&self) -> Option<(u16, u16)> {
707 let ws = self.query_tty_winsize()?;
708 if ws.ws_col == 0 || ws.ws_row == 0 {
709 return None;
710 }
711 Some((ws.ws_col, ws.ws_row))
712 }
713
714 #[cfg(unix)]
715 fn drain_resize_notifications(&mut self) {
716 if !self.live {
717 return;
718 }
719 let got_resize = if let Some(ref rx) = self.resize_rx {
722 let mut any = false;
723 while rx.try_recv().is_ok() {
724 any = true;
725 }
726 any
727 } else {
728 false
729 };
730 if got_resize && let Some(ws) = self.query_tty_winsize() {
731 self.pixel_width = ws.ws_xpixel;
732 self.pixel_height = ws.ws_ypixel;
733 if ws.ws_col > 0 && ws.ws_row > 0 {
734 self.push_resize(ws.ws_col, ws.ws_row);
735 }
736 }
737 }
738
739 fn drain_available_bytes(&mut self) -> io::Result<()> {
741 if self.tty_reader.is_none() {
742 return Ok(());
743 }
744 let mut buf = [0u8; READ_BUFFER_BYTES];
745 let mut drained_bytes = 0usize;
746 let mut parsed_events = Vec::new();
747 loop {
748 let read_result = {
749 let Some(tty) = self.tty_reader.as_mut() else {
750 return Ok(());
751 };
752 tty.read(&mut buf)
753 };
754 match read_result {
755 Ok(0) => return Ok(()),
756 Ok(n) => {
757 self.last_input_byte_at = Some(Instant::now());
758 parsed_events.clear();
759 self.parser
760 .parse_with(&buf[..n], |event| parsed_events.push(event));
761 for event in parsed_events.drain(..) {
762 let normalized = self.normalize_event(event);
763 self.event_queue.push_back(normalized);
764 }
765 drained_bytes = drained_bytes.saturating_add(n);
766 if !self.reader_nonblocking {
767 return Ok(());
768 }
769 if drained_bytes >= MAX_DRAIN_BYTES_PER_POLL {
770 return Ok(());
771 }
772 }
773 Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(()),
774 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
775 Err(e) => return Err(e),
776 }
777 }
778 }
779
780 #[inline]
781 fn parser_timeout_event_if_due(&mut self) -> Option<Event> {
782 if !self.parser.has_pending_timeout_state() {
783 return None;
784 }
785 if let Some(last) = self.last_input_byte_at
786 && last.elapsed() < PARSER_TIMEOUT_GRACE
787 {
788 return None;
789 }
790 let event = self.parser.timeout();
791 if event.is_some() {
792 self.last_input_byte_at = None;
793 }
794 event
795 }
796
797 #[cfg(unix)]
799 fn poll_tty(&mut self, timeout: Duration) -> io::Result<bool> {
800 use std::os::fd::AsFd;
801 let ready = {
802 let Some(ref tty) = self.tty_reader else {
803 return Ok(false);
804 };
805 let mut poll_fds = [nix::poll::PollFd::new(
806 tty.as_fd(),
807 nix::poll::PollFlags::POLLIN,
808 )];
809 let timeout_ms: i32 = timeout.as_millis().try_into().unwrap_or(i32::MAX);
812 match nix::poll::poll(
813 &mut poll_fds,
814 nix::poll::PollTimeout::try_from(timeout_ms).unwrap_or(nix::poll::PollTimeout::MAX),
815 ) {
816 Ok(n) => n,
817 Err(nix::errno::Errno::EINTR) => return Ok(false),
818 Err(e) => return Err(io::Error::other(e)),
819 }
820 };
821 if ready > 0 {
822 self.drain_available_bytes()?;
823 }
824 Ok(!self.event_queue.is_empty())
825 }
826
827 #[cfg(not(unix))]
829 fn poll_tty(&mut self, _timeout: Duration) -> io::Result<bool> {
830 Ok(false)
831 }
832
833 fn write_feature_delta(
835 current: &BackendFeatures,
836 new: &BackendFeatures,
837 capabilities: TerminalCapabilities,
838 writer: &mut impl Write,
839 ) -> io::Result<()> {
840 let mouse_enable_seq = mouse_enable_sequence_for_capabilities(capabilities);
841 let mouse_disable_seq = mouse_disable_sequence_for_capabilities(capabilities);
842 Self::write_feature_delta_with_mouse(
843 current,
844 new,
845 mouse_enable_seq,
846 mouse_disable_seq,
847 writer,
848 )
849 }
850
851 fn write_feature_delta_with_mouse(
852 current: &BackendFeatures,
853 new: &BackendFeatures,
854 mouse_enable_seq: &[u8],
855 mouse_disable_seq: &[u8],
856 writer: &mut impl Write,
857 ) -> io::Result<()> {
858 if new.mouse_capture != current.mouse_capture {
859 writer.write_all(if new.mouse_capture {
860 mouse_enable_seq
861 } else {
862 mouse_disable_seq
863 })?;
864 }
865 if new.bracketed_paste != current.bracketed_paste {
866 writer.write_all(if new.bracketed_paste {
867 BRACKETED_PASTE_ENABLE
868 } else {
869 BRACKETED_PASTE_DISABLE
870 })?;
871 }
872 if new.focus_events != current.focus_events {
873 writer.write_all(if new.focus_events {
874 FOCUS_ENABLE
875 } else {
876 FOCUS_DISABLE
877 })?;
878 }
879 if new.kitty_keyboard != current.kitty_keyboard {
880 writer.write_all(if new.kitty_keyboard {
881 KITTY_KEYBOARD_ENABLE
882 } else {
883 KITTY_KEYBOARD_DISABLE
884 })?;
885 }
886 Ok(())
887 }
888
889 fn disable_all(&mut self, writer: &mut impl Write) -> io::Result<()> {
891 let off = BackendFeatures::default();
892 Self::write_feature_delta(&self.features, &off, self.capabilities, writer)?;
893 self.apply_feature_state(off);
894 Ok(())
895 }
896}
897
898impl BackendEventSource for TtyEventSource {
899 type Error = io::Error;
900
901 fn size(&self) -> Result<(u16, u16), Self::Error> {
902 #[cfg(unix)]
903 if let Some((w, h)) = self.query_tty_size() {
904 return Ok((w, h));
905 }
906 Ok((self.width, self.height))
907 }
908
909 fn set_features(&mut self, features: BackendFeatures) -> Result<(), Self::Error> {
910 let effective_features = self.sanitize_features(features);
911 if self.live {
912 let mut stdout = io::stdout();
913 if let Err(err) = Self::write_feature_delta(
914 &self.features,
915 &effective_features,
916 self.capabilities,
917 &mut stdout,
918 )
919 .and_then(|_| stdout.flush())
920 {
921 self.apply_feature_state(conservative_feature_union(
925 self.features,
926 effective_features,
927 ));
928 return Err(err);
929 }
930 }
931 self.apply_feature_state(effective_features);
932 Ok(())
933 }
934
935 fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
936 #[cfg(unix)]
937 self.drain_resize_notifications();
938
939 if !self.event_queue.is_empty() {
941 return Ok(true);
942 }
943
944 #[cfg(unix)]
945 if self.resize_rx.is_some() && timeout != Duration::ZERO {
946 let deadline = std::time::Instant::now()
949 .checked_add(timeout)
950 .unwrap_or_else(std::time::Instant::now);
951 let slice_max = Duration::from_millis(50);
952 loop {
953 let now = std::time::Instant::now();
954 if now >= deadline {
955 if let Some(event) = self.parser_timeout_event_if_due() {
957 self.event_queue.push_back(event);
958 return Ok(true);
959 }
960 return Ok(false);
961 }
962 let remaining = deadline.duration_since(now);
963 let poll_for = remaining.min(slice_max);
964 let _ = self.poll_tty(poll_for)?;
965 self.drain_resize_notifications();
966 if !self.event_queue.is_empty() {
967 return Ok(true);
968 }
969 }
970 }
971
972 let ready = self.poll_tty(timeout)?;
973 if !ready {
974 if let Some(event) = self.parser_timeout_event_if_due() {
977 self.event_queue.push_back(event);
978 return Ok(true);
979 }
980 }
981 Ok(!self.event_queue.is_empty())
982 }
983
984 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
985 if let Some(event) = self.event_queue.pop_front() {
986 return Ok(Some(event));
987 }
988
989 if self.reader_nonblocking && self.tty_reader.is_some() {
995 self.drain_available_bytes()?;
996 return Ok(self.event_queue.pop_front());
997 }
998
999 Ok(None)
1000 }
1001}
1002
1003pub struct TtyPresenter<W: Write + Send = io::Stdout> {
1010 capabilities: TerminalCapabilities,
1011 inner: Option<Presenter<W>>,
1012}
1013
1014impl TtyPresenter {
1015 #[must_use]
1017 pub fn new(capabilities: TerminalCapabilities) -> Self {
1018 Self {
1019 capabilities,
1020 inner: None,
1021 }
1022 }
1023
1024 #[must_use]
1026 pub fn live(capabilities: TerminalCapabilities) -> Self {
1027 Self {
1028 capabilities,
1029 inner: Some(Presenter::new(io::stdout(), capabilities)),
1030 }
1031 }
1032}
1033
1034impl<W: Write + Send> TtyPresenter<W> {
1035 pub fn with_writer(writer: W, capabilities: TerminalCapabilities) -> Self {
1037 Self {
1038 capabilities,
1039 inner: Some(Presenter::new(writer, capabilities)),
1040 }
1041 }
1042}
1043
1044impl<W: Write + Send> BackendPresenter for TtyPresenter<W> {
1045 type Error = io::Error;
1046
1047 fn capabilities(&self) -> &TerminalCapabilities {
1048 &self.capabilities
1049 }
1050
1051 fn write_log(&mut self, _text: &str) -> Result<(), Self::Error> {
1052 Ok(())
1057 }
1058
1059 fn present_ui(
1060 &mut self,
1061 buf: &Buffer,
1062 diff: Option<&BufferDiff>,
1063 full_repaint_hint: bool,
1064 ) -> Result<(), Self::Error> {
1065 let Some(ref mut presenter) = self.inner else {
1066 return Ok(());
1067 };
1068 if full_repaint_hint {
1069 let full = BufferDiff::full(buf.width(), buf.height());
1070 presenter.present(buf, &full)?;
1071 } else if let Some(diff) = diff {
1072 presenter.present(buf, diff)?;
1073 } else {
1074 let full = BufferDiff::full(buf.width(), buf.height());
1075 presenter.present(buf, &full)?;
1076 }
1077 Ok(())
1078 }
1079}
1080
1081pub struct TtyBackend {
1095 clock: TtyClock,
1102 events: TtyEventSource,
1103 presenter: TtyPresenter,
1104 alt_screen_active: bool,
1105 #[cfg(unix)]
1106 raw_mode: Option<RawModeGuard>,
1107}
1108
1109impl TtyBackend {
1110 #[must_use]
1112 pub fn new(width: u16, height: u16) -> Self {
1113 Self {
1114 clock: TtyClock::new(),
1115 events: TtyEventSource::new(width, height),
1116 presenter: TtyPresenter::new(TerminalCapabilities::detect()),
1117 alt_screen_active: false,
1118 #[cfg(unix)]
1119 raw_mode: None,
1120 }
1121 }
1122
1123 #[must_use]
1125 pub fn with_capabilities(width: u16, height: u16, capabilities: TerminalCapabilities) -> Self {
1126 Self {
1127 clock: TtyClock::new(),
1128 events: TtyEventSource::new(width, height),
1129 presenter: TtyPresenter::new(capabilities),
1130 alt_screen_active: false,
1131 #[cfg(unix)]
1132 raw_mode: None,
1133 }
1134 }
1135
1136 #[cfg(unix)]
1141 pub fn open(width: u16, height: u16, options: TtySessionOptions) -> io::Result<Self> {
1142 let raw_mode = RawModeGuard::enter()?;
1144 install_abort_panic_hook();
1145 install_termination_signal_hook();
1146 let capabilities = TerminalCapabilities::with_overrides();
1147 let requested_features = options.features;
1148 let effective_features = sanitize_feature_request(requested_features, capabilities);
1149
1150 let mut stdout = io::stdout();
1151 let mut alt_screen_active = false;
1152
1153 let mut events = TtyEventSource::live(width, height, capabilities)?;
1155 let setup: io::Result<()> = (|| {
1156 if options.alternate_screen {
1158 stdout.write_all(ALT_SCREEN_ENTER)?;
1159 stdout.write_all(CLEAR_SCREEN)?;
1160 stdout.write_all(CURSOR_HOME)?;
1161 alt_screen_active = true;
1162 }
1163
1164 TtyEventSource::write_feature_delta(
1165 &BackendFeatures::default(),
1166 &effective_features,
1167 capabilities,
1168 &mut stdout,
1169 )?;
1170
1171 stdout.flush()?;
1172 Ok(())
1173 })();
1174
1175 if let Err(err) = setup {
1176 let mouse_disable_seq = mouse_disable_sequence_for_capabilities(capabilities);
1181 let _ = write_terminal_state_resets(&mut stdout);
1182 let _ = write_cleanup_sequence_policy_with_mouse(
1183 &effective_features,
1184 options.alternate_screen,
1185 false,
1186 mouse_disable_seq,
1187 &mut stdout,
1188 );
1189 let _ = stdout.flush();
1190 return Err(err);
1191 }
1192
1193 events.apply_feature_state(effective_features);
1194
1195 Ok(Self {
1196 clock: TtyClock::new(),
1197 events,
1198 presenter: TtyPresenter::live(capabilities),
1199 alt_screen_active,
1200 raw_mode: Some(raw_mode),
1201 })
1202 }
1203
1204 #[must_use]
1206 pub fn is_live(&self) -> bool {
1207 #[cfg(unix)]
1208 {
1209 self.raw_mode.is_some()
1210 }
1211 #[cfg(not(unix))]
1212 {
1213 false
1214 }
1215 }
1216}
1217
1218impl Drop for TtyBackend {
1219 fn drop(&mut self) {
1220 #[cfg(unix)]
1222 if self.raw_mode.is_some() {
1223 let mut stdout = io::stdout();
1224 let _ = write_terminal_state_resets(&mut stdout);
1225
1226 let _ = self.events.disable_all(&mut stdout);
1228
1229 let _ = stdout.write_all(CURSOR_SHOW);
1231
1232 if self.alt_screen_active {
1234 let _ = stdout.write_all(ALT_SCREEN_LEAVE);
1235 self.alt_screen_active = false;
1236 }
1237
1238 let _ = stdout.flush();
1240
1241 }
1243 }
1244}
1245
1246impl BackendEventSource for TtyBackend {
1251 type Error = io::Error;
1252
1253 fn size(&self) -> Result<(u16, u16), io::Error> {
1254 self.events.size()
1255 }
1256
1257 fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
1258 self.events.set_features(features)
1259 }
1260
1261 fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
1262 self.events.poll_event(timeout)
1263 }
1264
1265 fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
1266 self.events.read_event()
1267 }
1268}
1269
1270impl Backend for TtyBackend {
1271 type Error = io::Error;
1272 type Clock = TtyClock;
1273 type Events = TtyEventSource;
1274 type Presenter = TtyPresenter;
1275
1276 fn clock(&self) -> &Self::Clock {
1277 &self.clock
1278 }
1279
1280 fn events(&mut self) -> &mut Self::Events {
1281 &mut self.events
1282 }
1283
1284 fn presenter(&mut self) -> &mut Self::Presenter {
1285 &mut self.presenter
1286 }
1287}
1288
1289pub fn write_cleanup_sequence(
1297 features: &BackendFeatures,
1298 alt_screen: bool,
1299 writer: &mut impl Write,
1300) -> io::Result<()> {
1301 write_cleanup_sequence_policy(features, alt_screen, false, writer)
1302}
1303
1304pub fn write_cleanup_sequence_with_sync_end(
1308 features: &BackendFeatures,
1309 alt_screen: bool,
1310 writer: &mut impl Write,
1311) -> io::Result<()> {
1312 write_cleanup_sequence_policy(features, alt_screen, true, writer)
1313}
1314
1315fn write_cleanup_sequence_policy(
1316 features: &BackendFeatures,
1317 alt_screen: bool,
1318 emit_sync_end: bool,
1319 writer: &mut impl Write,
1320) -> io::Result<()> {
1321 write_cleanup_sequence_policy_with_mouse(
1322 features,
1323 alt_screen,
1324 emit_sync_end,
1325 MOUSE_DISABLE,
1326 writer,
1327 )
1328}
1329
1330fn write_cleanup_sequence_policy_with_mouse(
1331 features: &BackendFeatures,
1332 alt_screen: bool,
1333 emit_sync_end: bool,
1334 mouse_disable_seq: &[u8],
1335 writer: &mut impl Write,
1336) -> io::Result<()> {
1337 if emit_sync_end {
1338 writer.write_all(SYNC_END)?;
1339 }
1340 if features.kitty_keyboard {
1342 writer.write_all(KITTY_KEYBOARD_DISABLE)?;
1343 }
1344 if features.focus_events {
1345 writer.write_all(FOCUS_DISABLE)?;
1346 }
1347 if features.bracketed_paste {
1348 writer.write_all(BRACKETED_PASTE_DISABLE)?;
1349 }
1350 if features.mouse_capture {
1351 writer.write_all(mouse_disable_seq)?;
1352 }
1353 writer.write_all(CURSOR_SHOW)?;
1354 if alt_screen {
1355 writer.write_all(ALT_SCREEN_LEAVE)?;
1356 }
1357 Ok(())
1358}
1359
1360#[cfg(test)]
1363mod tests {
1364 use super::*;
1365
1366 #[test]
1367 fn clock_is_monotonic() {
1368 let clock = TtyClock::new();
1369 let t1 = clock.now_mono();
1370 std::hint::black_box(0..1000).for_each(|_| {});
1371 let t2 = clock.now_mono();
1372 assert!(t2 >= t1, "clock must be monotonic");
1373 }
1374
1375 #[test]
1376 fn event_source_reports_size() {
1377 let src = TtyEventSource::new(80, 24);
1378 let (w, h) = src.size().unwrap();
1379 assert_eq!(w, 80);
1380 assert_eq!(h, 24);
1381 }
1382
1383 #[test]
1384 fn event_source_set_features_headless() {
1385 let mut src = TtyEventSource::new(80, 24);
1386 let features = BackendFeatures {
1387 mouse_capture: true,
1388 bracketed_paste: true,
1389 focus_events: false,
1390 kitty_keyboard: false,
1391 };
1392 src.set_features(features).unwrap();
1393 assert_eq!(src.features(), features);
1394 }
1395
1396 #[test]
1397 fn poll_returns_false_headless() {
1398 let mut src = TtyEventSource::new(80, 24);
1399 assert!(!src.poll_event(Duration::from_millis(0)).unwrap());
1400 }
1401
1402 #[test]
1403 fn read_returns_none_headless() {
1404 let mut src = TtyEventSource::new(80, 24);
1405 assert!(src.read_event().unwrap().is_none());
1406 }
1407
1408 #[test]
1409 fn push_resize_enqueues_event_and_updates_size() {
1410 let mut src = TtyEventSource::new(80, 24);
1411 src.push_resize(120, 40);
1412 assert_eq!(src.size().unwrap(), (120, 40));
1413 assert_eq!(
1414 src.read_event().unwrap(),
1415 Some(Event::Resize {
1416 width: 120,
1417 height: 40,
1418 })
1419 );
1420 assert!(src.read_event().unwrap().is_none());
1421 }
1422
1423 #[test]
1424 fn push_resize_deduplicates_same_size() {
1425 let mut src = TtyEventSource::new(80, 24);
1426 src.push_resize(80, 24);
1427 assert!(src.event_queue.is_empty(), "no event when size unchanged");
1428 }
1429
1430 #[test]
1431 fn push_resize_ignores_zero_dimensions() {
1432 let mut src = TtyEventSource::new(80, 24);
1433 src.push_resize(0, 24);
1434 assert!(src.event_queue.is_empty());
1435 src.push_resize(80, 0);
1436 assert!(src.event_queue.is_empty());
1437 src.push_resize(0, 0);
1438 assert!(src.event_queue.is_empty());
1439 }
1440
1441 #[test]
1442 fn resize_storm_coalesces_and_no_panic() {
1443 let mut src = TtyEventSource::new(80, 24);
1444 for _ in 0..1000 {
1446 src.push_resize(120, 40);
1447 }
1448 assert_eq!(src.event_queue.len(), 1);
1450 assert_eq!(
1451 src.event_queue.pop_front().unwrap(),
1452 Event::Resize {
1453 width: 120,
1454 height: 40,
1455 }
1456 );
1457 }
1458
1459 #[test]
1460 fn resize_storm_varied_sizes_no_panic() {
1461 let mut src = TtyEventSource::new(80, 24);
1462 for i in 1..=500u16 {
1464 src.push_resize(80 + i, 24 + (i % 50));
1465 }
1466 let mut prev_w = 80u16;
1468 while let Some(Event::Resize { width, .. }) = src.event_queue.pop_front() {
1469 assert!(
1470 width > prev_w || width == prev_w + 1 || width != prev_w,
1471 "events must be in push order"
1472 );
1473 prev_w = width;
1474 }
1475 }
1476
1477 #[cfg(unix)]
1481 fn pipe_pair() -> (std::fs::File, std::os::unix::net::UnixStream) {
1482 use std::os::unix::net::UnixStream;
1483 let (a, b) = UnixStream::pair().unwrap();
1484 let reader: std::fs::File = std::os::fd::OwnedFd::from(a).into();
1486 (reader, b)
1487 }
1488
1489 #[cfg(unix)]
1490 #[test]
1491 fn pipe_ascii_chars() {
1492 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1493 let (reader, mut writer) = pipe_pair();
1494 let mut src = TtyEventSource::from_reader(80, 24, reader);
1495 writer.write_all(b"abc").unwrap();
1496 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1497 let e1 = src.read_event().unwrap().unwrap();
1498 assert_eq!(
1499 e1,
1500 Event::Key(KeyEvent {
1501 code: KeyCode::Char('a'),
1502 modifiers: Modifiers::NONE,
1503 kind: KeyEventKind::Press,
1504 })
1505 );
1506 let e2 = src.read_event().unwrap().unwrap();
1507 assert_eq!(
1508 e2,
1509 Event::Key(KeyEvent {
1510 code: KeyCode::Char('b'),
1511 modifiers: Modifiers::NONE,
1512 kind: KeyEventKind::Press,
1513 })
1514 );
1515 let e3 = src.read_event().unwrap().unwrap();
1516 assert_eq!(
1517 e3,
1518 Event::Key(KeyEvent {
1519 code: KeyCode::Char('c'),
1520 modifiers: Modifiers::NONE,
1521 kind: KeyEventKind::Press,
1522 })
1523 );
1524 assert!(src.read_event().unwrap().is_none());
1526 }
1527
1528 #[cfg(unix)]
1529 #[test]
1530 fn pipe_arrow_keys() {
1531 use ftui_core::event::{KeyCode, KeyEvent};
1532 let (reader, mut writer) = pipe_pair();
1533 let mut src = TtyEventSource::from_reader(80, 24, reader);
1534 writer.write_all(b"\x1b[A\x1b[B\x1b[C\x1b[D").unwrap();
1536 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1537 let codes: Vec<KeyCode> = std::iter::from_fn(|| src.read_event().unwrap())
1538 .map(|e| match e {
1539 Event::Key(KeyEvent { code, .. }) => Ok(code),
1540 other => Err(other),
1541 })
1542 .collect::<Result<Vec<_>, _>>()
1543 .unwrap();
1544 assert_eq!(
1545 codes,
1546 vec![KeyCode::Up, KeyCode::Down, KeyCode::Right, KeyCode::Left]
1547 );
1548 }
1549
1550 #[cfg(unix)]
1551 #[test]
1552 fn pipe_ctrl_keys() {
1553 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1554 let (reader, mut writer) = pipe_pair();
1555 let mut src = TtyEventSource::from_reader(80, 24, reader);
1556 writer.write_all(&[0x01, 0x03]).unwrap();
1558 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1559 let e1 = src.read_event().unwrap().unwrap();
1560 assert_eq!(
1561 e1,
1562 Event::Key(KeyEvent {
1563 code: KeyCode::Char('a'),
1564 modifiers: Modifiers::CTRL,
1565 kind: KeyEventKind::Press,
1566 })
1567 );
1568 let e2 = src.read_event().unwrap().unwrap();
1569 assert_eq!(
1570 e2,
1571 Event::Key(KeyEvent {
1572 code: KeyCode::Char('c'),
1573 modifiers: Modifiers::CTRL,
1574 kind: KeyEventKind::Press,
1575 })
1576 );
1577 }
1578
1579 #[cfg(unix)]
1580 #[test]
1581 fn pipe_function_keys() {
1582 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1583 let (reader, mut writer) = pipe_pair();
1584 let mut src = TtyEventSource::from_reader(80, 24, reader);
1585 writer.write_all(b"\x1bOP\x1b[15~").unwrap();
1587 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1588 let e1 = src.read_event().unwrap().unwrap();
1589 assert_eq!(
1590 e1,
1591 Event::Key(KeyEvent {
1592 code: KeyCode::F(1),
1593 modifiers: Modifiers::NONE,
1594 kind: KeyEventKind::Press,
1595 })
1596 );
1597 let e2 = src.read_event().unwrap().unwrap();
1598 assert_eq!(
1599 e2,
1600 Event::Key(KeyEvent {
1601 code: KeyCode::F(5),
1602 modifiers: Modifiers::NONE,
1603 kind: KeyEventKind::Press,
1604 })
1605 );
1606 }
1607
1608 #[cfg(unix)]
1609 #[test]
1610 fn pipe_mouse_sgr_click() {
1611 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
1612 let (reader, mut writer) = pipe_pair();
1613 let mut src = TtyEventSource::from_reader(80, 24, reader);
1614 writer.write_all(b"\x1b[<0;10;20M").unwrap();
1616 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1617 let e = src.read_event().unwrap().unwrap();
1618 assert_eq!(
1619 e,
1620 Event::Mouse(MouseEvent {
1621 kind: MouseEventKind::Down(MouseButton::Left),
1622 x: 9,
1623 y: 19,
1624 modifiers: Modifiers::NONE,
1625 })
1626 );
1627 }
1628
1629 #[cfg(unix)]
1630 #[test]
1631 fn pipe_mouse_x10_click_when_mouse_capture_enabled() {
1632 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
1633 let (reader, mut writer) = pipe_pair();
1634 let mut src = TtyEventSource::from_reader(80, 24, reader);
1635 src.set_features(BackendFeatures {
1636 mouse_capture: true,
1637 ..BackendFeatures::default()
1638 })
1639 .unwrap();
1640
1641 writer.write_all(&[0x1B, b'[', b'M', 32, 42, 52]).unwrap();
1644 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1645 let e = src.read_event().unwrap().unwrap();
1646 assert_eq!(
1647 e,
1648 Event::Mouse(MouseEvent {
1649 kind: MouseEventKind::Down(MouseButton::Left),
1650 x: 9,
1651 y: 19,
1652 modifiers: Modifiers::NONE,
1653 })
1654 );
1655 }
1656
1657 #[cfg(unix)]
1658 #[test]
1659 fn pipe_mouse_x10_not_decoded_when_mouse_capture_disabled() {
1660 use ftui_core::event::{KeyCode, KeyEvent};
1661 let (reader, mut writer) = pipe_pair();
1662 let mut src = TtyEventSource::from_reader(80, 24, reader);
1663 src.set_features(BackendFeatures::default()).unwrap();
1664
1665 writer.write_all(&[0x1B, b'[', b'M', 32, 42, 52]).unwrap();
1668 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1669 let e = src.read_event().unwrap().unwrap();
1670 assert!(matches!(
1671 e,
1672 Event::Key(KeyEvent {
1673 code: KeyCode::Char(' '),
1674 ..
1675 })
1676 ));
1677 }
1678
1679 #[cfg(unix)]
1680 #[test]
1681 fn pipe_mouse_legacy_1015_click_when_mouse_capture_enabled() {
1682 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
1683 let (reader, mut writer) = pipe_pair();
1684 let mut src = TtyEventSource::from_reader(80, 24, reader);
1685 src.set_features(BackendFeatures {
1686 mouse_capture: true,
1687 ..BackendFeatures::default()
1688 })
1689 .unwrap();
1690
1691 writer.write_all(b"\x1b[0;10;20M").unwrap();
1693 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1694 let e = src.read_event().unwrap().unwrap();
1695 assert_eq!(
1696 e,
1697 Event::Mouse(MouseEvent {
1698 kind: MouseEventKind::Down(MouseButton::Left),
1699 x: 9,
1700 y: 19,
1701 modifiers: Modifiers::NONE,
1702 })
1703 );
1704 }
1705
1706 #[cfg(unix)]
1707 #[test]
1708 fn pipe_mouse_legacy_1015_not_decoded_when_mouse_capture_disabled() {
1709 let (reader, mut writer) = pipe_pair();
1710 let mut src = TtyEventSource::from_reader(80, 24, reader);
1711 src.set_features(BackendFeatures::default()).unwrap();
1712
1713 writer.write_all(b"\x1b[0;10;20M").unwrap();
1714 assert!(!src.poll_event(Duration::from_millis(25)).unwrap());
1715 assert!(src.read_event().unwrap().is_none());
1716 }
1717
1718 #[cfg(unix)]
1719 #[test]
1720 fn pipe_focus_events() {
1721 let (reader, mut writer) = pipe_pair();
1722 let mut src = TtyEventSource::from_reader(80, 24, reader);
1723 writer.write_all(b"\x1b[I\x1b[O").unwrap();
1725 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1726 assert_eq!(src.read_event().unwrap().unwrap(), Event::Focus(true));
1727 assert_eq!(src.read_event().unwrap().unwrap(), Event::Focus(false));
1728 }
1729
1730 #[cfg(unix)]
1731 #[test]
1732 fn pipe_bracketed_paste() {
1733 use ftui_core::event::PasteEvent;
1734 let (reader, mut writer) = pipe_pair();
1735 let mut src = TtyEventSource::from_reader(80, 24, reader);
1736 writer.write_all(b"\x1b[200~hello world\x1b[201~").unwrap();
1737 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1738 let e = src.read_event().unwrap().unwrap();
1739 assert_eq!(
1740 e,
1741 Event::Paste(PasteEvent {
1742 text: "hello world".to_string(),
1743 bracketed: true,
1744 })
1745 );
1746 }
1747
1748 #[cfg(unix)]
1749 #[test]
1750 fn pipe_modified_arrow_key() {
1751 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1752 let (reader, mut writer) = pipe_pair();
1753 let mut src = TtyEventSource::from_reader(80, 24, reader);
1754 writer.write_all(b"\x1b[1;5A").unwrap();
1756 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1757 let e = src.read_event().unwrap().unwrap();
1758 assert_eq!(
1759 e,
1760 Event::Key(KeyEvent {
1761 code: KeyCode::Up,
1762 modifiers: Modifiers::CTRL,
1763 kind: KeyEventKind::Press,
1764 })
1765 );
1766 }
1767
1768 #[cfg(unix)]
1769 #[test]
1770 fn pipe_scroll_events() {
1771 use ftui_core::event::{Modifiers, MouseEvent, MouseEventKind};
1772 let (reader, mut writer) = pipe_pair();
1773 let mut src = TtyEventSource::from_reader(80, 24, reader);
1774 writer.write_all(b"\x1b[<64;5;5M").unwrap();
1776 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1777 let e = src.read_event().unwrap().unwrap();
1778 assert_eq!(
1779 e,
1780 Event::Mouse(MouseEvent {
1781 kind: MouseEventKind::ScrollUp,
1782 x: 4,
1783 y: 4,
1784 modifiers: Modifiers::NONE,
1785 })
1786 );
1787 }
1788
1789 #[cfg(unix)]
1790 #[test]
1791 fn poll_returns_buffered_events_immediately() {
1792 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1793 let (reader, mut writer) = pipe_pair();
1794 let mut src = TtyEventSource::from_reader(80, 24, reader);
1795 writer.write_all(b"xy").unwrap();
1797 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1798 let _ = src.read_event().unwrap().unwrap();
1800 assert!(src.poll_event(Duration::from_millis(0)).unwrap());
1802 let e = src.read_event().unwrap().unwrap();
1803 assert_eq!(
1804 e,
1805 Event::Key(KeyEvent {
1806 code: KeyCode::Char('y'),
1807 modifiers: Modifiers::NONE,
1808 kind: KeyEventKind::Press,
1809 })
1810 );
1811 }
1812
1813 #[cfg(unix)]
1814 #[test]
1815 fn pipe_large_ascii_burst_roundtrips() {
1816 use ftui_core::event::{KeyCode, KeyEvent};
1817
1818 let (reader, mut writer) = pipe_pair();
1819 let mut src = TtyEventSource::from_reader(80, 24, reader);
1820 let payload = vec![b'a'; 4 * 1024 * 1024];
1821 let expected_len = payload.len();
1822 let writer_thread = std::thread::spawn(move || writer.write_all(&payload));
1823
1824 let mut count = 0usize;
1825 let deadline = std::time::Instant::now() + Duration::from_secs(15);
1826 while count < expected_len {
1827 if !src.poll_event(Duration::from_millis(100)).unwrap() {
1828 assert!(
1829 std::time::Instant::now() < deadline,
1830 "timed out waiting for burst events: received {count} / {expected_len}"
1831 );
1832 continue;
1833 }
1834 while let Some(event) = src.read_event().unwrap() {
1835 match event {
1836 Event::Key(KeyEvent {
1837 code: KeyCode::Char('a'),
1838 ..
1839 }) => count += 1,
1840 other => panic!("unexpected event in ascii burst test: {other:?}"),
1841 }
1842 }
1843 }
1844 writer_thread.join().unwrap().unwrap();
1845
1846 assert_eq!(count, expected_len, "all bytes should decode to key events");
1847 }
1848
1849 #[cfg(unix)]
1852 #[test]
1853 fn truncated_csi_followed_by_valid_input() {
1854 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1855 let (reader, mut writer) = pipe_pair();
1856 let mut src = TtyEventSource::from_reader(80, 24, reader);
1857 writer.write_all(b"\x1b[").unwrap();
1862 let _ = src.poll_event(Duration::from_millis(50));
1864 writer.write_all(b"\x1b[Ax").unwrap();
1866 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1867 let mut events = Vec::new();
1869 while let Some(e) = src.read_event().unwrap() {
1870 events.push(e);
1871 }
1872 let has_up = events.iter().any(|e| {
1874 matches!(
1875 e,
1876 Event::Key(KeyEvent {
1877 code: KeyCode::Up,
1878 ..
1879 })
1880 )
1881 });
1882 let has_x = events.iter().any(|e| {
1883 matches!(
1884 e,
1885 Event::Key(KeyEvent {
1886 code: KeyCode::Char('x'),
1887 modifiers: Modifiers::NONE,
1888 kind: KeyEventKind::Press,
1889 })
1890 )
1891 });
1892 assert!(
1893 has_up,
1894 "should parse Up arrow after partial CSI: {events:?}"
1895 );
1896 assert!(has_x, "should parse 'x' after recovery: {events:?}");
1897 }
1898
1899 #[cfg(unix)]
1900 #[test]
1901 fn unknown_csi_sequence_does_not_block_parser() {
1902 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1903 let (reader, mut writer) = pipe_pair();
1904 let mut src = TtyEventSource::from_reader(80, 24, reader);
1905 writer.write_all(b"\x1b[999~z").unwrap();
1908 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1909 let mut events = Vec::new();
1910 while let Some(e) = src.read_event().unwrap() {
1911 events.push(e);
1912 }
1913 let has_z = events.iter().any(|e| {
1914 matches!(
1915 e,
1916 Event::Key(KeyEvent {
1917 code: KeyCode::Char('z'),
1918 modifiers: Modifiers::NONE,
1919 kind: KeyEventKind::Press,
1920 })
1921 )
1922 });
1923 assert!(
1924 has_z,
1925 "valid key after unknown CSI must be parsed: {events:?}"
1926 );
1927 }
1928
1929 #[cfg(unix)]
1930 #[test]
1931 fn eof_on_pipe_does_not_panic() {
1932 let (reader, writer) = pipe_pair();
1933 let mut src = TtyEventSource::from_reader(80, 24, reader);
1934 drop(writer);
1936 let result = src.poll_event(Duration::from_millis(50));
1938 assert!(result.is_ok(), "poll_event after EOF should not error");
1939 let event = src.read_event().unwrap();
1941 assert!(event.is_none(), "read_event after EOF should be None");
1942 }
1943
1944 #[cfg(unix)]
1945 #[test]
1946 fn interleaved_invalid_and_valid_sequences() {
1947 use ftui_core::event::{KeyCode, KeyEvent};
1948 let (reader, mut writer) = pipe_pair();
1949 let mut src = TtyEventSource::from_reader(80, 24, reader);
1950 writer.write_all(b"\xC0a\x1b[999~b\x1b c").unwrap();
1953 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1954 let mut key_chars = Vec::new();
1955 while let Some(e) = src.read_event().unwrap() {
1956 if let Event::Key(KeyEvent {
1957 code: KeyCode::Char(ch),
1958 ..
1959 }) = e
1960 {
1961 key_chars.push(ch);
1962 }
1963 }
1964 assert!(
1967 key_chars.contains(&'a'),
1968 "should parse 'a' amid invalid input: {key_chars:?}"
1969 );
1970 assert!(
1971 key_chars.contains(&'b'),
1972 "should parse 'b' amid invalid input: {key_chars:?}"
1973 );
1974 assert!(
1975 key_chars.contains(&'c'),
1976 "should parse 'c' amid invalid input: {key_chars:?}"
1977 );
1978 }
1979
1980 #[cfg(unix)]
1981 #[test]
1982 fn split_escape_sequence_across_writes() {
1983 use ftui_core::event::{KeyCode, KeyEvent};
1984 let (reader, mut writer) = pipe_pair();
1985 let mut src = TtyEventSource::from_reader(80, 24, reader);
1986 writer.write_all(b"\x1b").unwrap();
1988 let _ = src.poll_event(Duration::from_millis(30));
1991 writer.write_all(b"[B").unwrap();
1993 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1994 let mut events = Vec::new();
1995 while let Some(e) = src.read_event().unwrap() {
1996 events.push(e);
1997 }
1998 let has_down = events.iter().any(|e| {
1999 matches!(
2000 e,
2001 Event::Key(KeyEvent {
2002 code: KeyCode::Down,
2003 ..
2004 })
2005 )
2006 });
2007 assert!(
2008 has_down,
2009 "Down arrow split across writes should be parsed: {events:?}"
2010 );
2011 }
2012
2013 #[cfg(unix)]
2014 #[test]
2015 fn poll_with_zero_timeout_returns_false_on_empty_pipe() {
2016 let (reader, _writer) = pipe_pair();
2017 let mut src = TtyEventSource::from_reader(80, 24, reader);
2018 let ready = src.poll_event(Duration::ZERO).unwrap();
2020 assert!(!ready, "empty pipe with zero timeout should not be ready");
2021 }
2022
2023 #[cfg(unix)]
2024 #[test]
2025 fn zero_timeout_poll_resolves_pending_escape_after_grace() {
2026 use ftui_core::event::{KeyCode, KeyEvent};
2027 let (reader, mut writer) = pipe_pair();
2028 let mut src = TtyEventSource::from_reader(80, 24, reader);
2029
2030 writer.write_all(b"\x1b").unwrap();
2032
2033 let ready = src.poll_event(Duration::ZERO).unwrap();
2035 assert!(!ready, "pending ESC should wait for timeout grace");
2036
2037 std::thread::sleep(PARSER_TIMEOUT_GRACE + Duration::from_millis(10));
2039 let ready = src.poll_event(Duration::ZERO).unwrap();
2040 assert!(ready, "zero-timeout poll should resolve overdue ESC");
2041
2042 let event = src.read_event().unwrap();
2043 assert!(matches!(
2044 event,
2045 Some(Event::Key(KeyEvent {
2046 code: KeyCode::Escape,
2047 ..
2048 }))
2049 ));
2050 }
2051
2052 #[cfg(unix)]
2053 #[test]
2054 fn malformed_sgr_mouse_does_not_block() {
2055 use ftui_core::event::{KeyCode, KeyEvent};
2056 let (reader, mut writer) = pipe_pair();
2057 let mut src = TtyEventSource::from_reader(80, 24, reader);
2058 writer.write_all(b"\x1b[<M q").unwrap();
2060 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
2061 let mut events = Vec::new();
2062 while let Some(e) = src.read_event().unwrap() {
2063 events.push(e);
2064 }
2065 let has_q = events.iter().any(|e| {
2067 matches!(
2068 e,
2069 Event::Key(KeyEvent {
2070 code: KeyCode::Char('q'),
2071 ..
2072 })
2073 )
2074 });
2075 assert!(
2076 has_q,
2077 "should parse 'q' after malformed SGR mouse: {events:?}"
2078 );
2079 }
2080
2081 #[test]
2084 #[should_panic(expected = "buffer width must be > 0")]
2085 fn buffer_rejects_zero_width() {
2086 let _buf = Buffer::new(0, 5);
2087 }
2088
2089 #[test]
2090 #[should_panic(expected = "buffer height must be > 0")]
2091 fn buffer_rejects_zero_height() {
2092 let _buf = Buffer::new(5, 0);
2093 }
2094
2095 #[test]
2096 fn presenter_1x1_buffer_does_not_panic() {
2097 let caps = TerminalCapabilities::detect();
2098 let mut presenter = TtyPresenter::with_writer(Vec::<u8>::new(), caps);
2099 let buf = Buffer::new(1, 1);
2100 let diff = BufferDiff::full(1, 1);
2101 presenter.present_ui(&buf, Some(&diff), false).unwrap();
2102 let bytes = presenter.inner.unwrap().into_inner().unwrap();
2104 assert!(!bytes.is_empty(), "1x1 buffer should produce output");
2105 }
2106
2107 #[test]
2108 fn presenter_capabilities() {
2109 let caps = TerminalCapabilities::detect();
2110 let presenter = TtyPresenter::new(caps);
2111 let _c = presenter.capabilities();
2112 }
2113
2114 #[test]
2117 fn headless_presenter_present_ui_is_noop() {
2118 let caps = TerminalCapabilities::detect();
2119 let mut presenter = TtyPresenter::new(caps);
2120 let buf = Buffer::new(10, 5);
2121 let diff = BufferDiff::full(10, 5);
2122 presenter.present_ui(&buf, Some(&diff), false).unwrap();
2124 presenter.present_ui(&buf, None, false).unwrap();
2125 presenter.present_ui(&buf, Some(&diff), true).unwrap();
2126 }
2127
2128 #[test]
2129 fn live_presenter_emits_ansi() {
2130 use ftui_render::cell::{Cell, CellAttrs, CellContent, PackedRgba, StyleFlags};
2131
2132 let caps = TerminalCapabilities::detect();
2133 let output = Vec::<u8>::new();
2134 let mut presenter = TtyPresenter::with_writer(output, caps);
2135
2136 let mut buf = Buffer::new(10, 2);
2137 let cell = Cell {
2139 content: CellContent::from_char('X'),
2140 fg: PackedRgba::RED,
2141 bg: PackedRgba::BLACK,
2142 attrs: CellAttrs::new(StyleFlags::BOLD, 0),
2143 };
2144 buf.set(0, 0, cell);
2145
2146 let diff = BufferDiff::full(10, 2);
2147 presenter.present_ui(&buf, Some(&diff), false).unwrap();
2148
2149 let inner = presenter.inner.unwrap();
2153 let bytes = inner.into_inner().unwrap();
2154 assert!(!bytes.is_empty(), "live presenter should emit output");
2155 assert!(
2156 bytes.windows(2).any(|w| w == b"\x1b["),
2157 "output should contain CSI escape sequences"
2158 );
2159 }
2160
2161 #[test]
2162 fn full_repaint_when_diff_is_none() {
2163 use ftui_render::cell::Cell;
2164
2165 let caps = TerminalCapabilities::detect();
2166 let output = Vec::<u8>::new();
2167 let mut presenter = TtyPresenter::with_writer(output, caps);
2168
2169 let mut buf = Buffer::new(5, 1);
2170 for x in 0..5 {
2171 buf.set(x, 0, Cell::from_char(b"ABCDE"[x as usize] as char));
2172 }
2173
2174 presenter.present_ui(&buf, None, false).unwrap();
2176
2177 let bytes = presenter.inner.unwrap().into_inner().unwrap();
2178 let output_str = String::from_utf8_lossy(&bytes);
2180 for ch in ['A', 'B', 'C', 'D', 'E'] {
2181 assert!(
2182 output_str.contains(ch),
2183 "full repaint should emit '{ch}', got: {output_str}"
2184 );
2185 }
2186 }
2187
2188 #[test]
2189 fn diff_based_partial_update() {
2190 use ftui_render::cell::Cell;
2191
2192 let caps = TerminalCapabilities::detect();
2193 let output = Vec::<u8>::new();
2194 let mut presenter = TtyPresenter::with_writer(output, caps);
2195
2196 let mut old = Buffer::new(5, 1);
2197 for x in 0..5 {
2198 old.set(x, 0, Cell::from_char(b"ABCDE"[x as usize] as char));
2199 }
2200 let mut new = old.clone();
2201 new.set(2, 0, Cell::from_char('Z'));
2202 let diff = BufferDiff::compute(&old, &new);
2203 presenter.present_ui(&new, Some(&diff), false).unwrap();
2204
2205 let bytes = presenter.inner.unwrap().into_inner().unwrap();
2206 let output_str = String::from_utf8_lossy(&bytes);
2207 assert!(
2209 output_str.contains('Z'),
2210 "diff-based update should emit changed cell 'Z'"
2211 );
2212 assert!(
2213 !output_str.contains('A'),
2214 "diff-based update should not emit unchanged cell 'A'"
2215 );
2216 }
2217
2218 #[test]
2219 fn write_log_headless_does_not_panic() {
2220 let caps = TerminalCapabilities::detect();
2221 let mut presenter = TtyPresenter::new(caps);
2222 presenter.write_log("headless log test").unwrap();
2223 }
2224
2225 #[test]
2226 fn write_log_live_does_not_corrupt_ui_stream() {
2227 let caps = TerminalCapabilities::detect();
2228 let mut presenter = TtyPresenter::with_writer(Vec::<u8>::new(), caps);
2229 presenter.write_log("live log test").unwrap();
2230 let bytes = presenter.inner.unwrap().into_inner().unwrap();
2231 assert!(bytes.is_empty(), "write_log must not emit UI bytes");
2232 }
2233
2234 #[test]
2235 fn backend_headless_construction() {
2236 let backend = TtyBackend::new(120, 40);
2237 assert!(!backend.is_live());
2238 let (w, h) = backend.events.size().unwrap();
2239 assert_eq!(w, 120);
2240 assert_eq!(h, 40);
2241 }
2242
2243 #[test]
2244 fn backend_trait_impl() {
2245 let mut backend = TtyBackend::new(80, 24);
2246 let _t = backend.clock().now_mono();
2247 let (w, h) = backend.events().size().unwrap();
2248 assert_eq!((w, h), (80, 24));
2249 let _c = backend.presenter().capabilities();
2250 }
2251
2252 #[test]
2253 fn feature_delta_writes_enable_sequences() {
2254 let current = BackendFeatures::default();
2255 let new = BackendFeatures {
2256 mouse_capture: true,
2257 bracketed_paste: true,
2258 focus_events: true,
2259 kitty_keyboard: true,
2260 };
2261 let mut buf = Vec::new();
2262 TtyEventSource::write_feature_delta(
2263 ¤t,
2264 &new,
2265 TerminalCapabilities::modern(),
2266 &mut buf,
2267 )
2268 .unwrap();
2269 assert!(
2270 buf.windows(MOUSE_ENABLE.len()).any(|w| w == MOUSE_ENABLE),
2271 "expected mouse enable sequence"
2272 );
2273 assert!(
2274 !buf.windows(b"\x1b[?1003h".len())
2275 .any(|w| w == b"\x1b[?1003h"),
2276 "mouse enable should avoid 1003 any-event mode"
2277 );
2278 assert!(
2279 !buf.ends_with(b"\x1b[?1016l"),
2280 "mouse enable should not end with 1016l (can force X10 fallback on some terminals)"
2281 );
2282 let pos_1016l = buf
2283 .windows(b"\x1b[?1016l".len())
2284 .position(|w| w == b"\x1b[?1016l")
2285 .expect("mouse enable should clear 1016 before enabling SGR");
2286 let pos_1006h = buf
2287 .windows(b"\x1b[?1006h".len())
2288 .position(|w| w == b"\x1b[?1006h")
2289 .expect("mouse enable should include 1006 SGR mode");
2290 assert!(
2291 pos_1016l < pos_1006h,
2292 "1016l must be emitted before 1006h to preserve SGR mode on Ghostty-like terminals"
2293 );
2294 assert!(
2295 buf.windows(BRACKETED_PASTE_ENABLE.len())
2296 .any(|w| w == BRACKETED_PASTE_ENABLE),
2297 "expected bracketed paste enable"
2298 );
2299 assert!(
2300 buf.windows(FOCUS_ENABLE.len()).any(|w| w == FOCUS_ENABLE),
2301 "expected focus enable"
2302 );
2303 assert!(
2304 buf.windows(KITTY_KEYBOARD_ENABLE.len())
2305 .any(|w| w == KITTY_KEYBOARD_ENABLE),
2306 "expected kitty keyboard enable"
2307 );
2308 }
2309
2310 #[test]
2311 fn mouse_enable_sequence_for_mux_capabilities_is_safe() {
2312 let mux_caps = TerminalCapabilities::builder()
2313 .mouse_sgr(true)
2314 .in_wezterm_mux(true)
2315 .build();
2316 assert_eq!(
2317 mouse_enable_sequence_for_capabilities(mux_caps),
2318 MOUSE_ENABLE_MUX_SAFE
2319 );
2320 assert!(
2321 !MOUSE_ENABLE_MUX_SAFE
2322 .windows(b"\x1b[?1001l".len())
2323 .any(|w| w == b"\x1b[?1001l"),
2324 "mux-safe enable should avoid legacy reset bundle"
2325 );
2326 assert!(
2327 MOUSE_ENABLE_MUX_SAFE
2328 .windows(b"\x1b[?1006h".len())
2329 .any(|w| w == b"\x1b[?1006h"),
2330 "mux-safe enable should keep SGR mouse mode"
2331 );
2332 let pos_1016l = MOUSE_ENABLE_MUX_SAFE
2333 .windows(b"\x1b[?1016l".len())
2334 .position(|w| w == b"\x1b[?1016l")
2335 .expect("mux-safe enable should clear 1016 before enabling SGR");
2336 let pos_1006h = MOUSE_ENABLE_MUX_SAFE
2337 .windows(b"\x1b[?1006h".len())
2338 .position(|w| w == b"\x1b[?1006h")
2339 .expect("mux-safe enable should include 1006 SGR mode");
2340 assert!(
2341 pos_1016l < pos_1006h,
2342 "mux-safe enable must emit 1016l before 1006h to preserve SGR mode"
2343 );
2344 }
2345
2346 #[test]
2347 fn mouse_disable_sequence_for_mux_capabilities_clears_1016() {
2348 let mux_caps = TerminalCapabilities::builder()
2349 .mouse_sgr(true)
2350 .in_wezterm_mux(true)
2351 .build();
2352 assert_eq!(
2353 mouse_disable_sequence_for_capabilities(mux_caps),
2354 MOUSE_DISABLE_MUX_SAFE
2355 );
2356 let pos_1016l = MOUSE_DISABLE_MUX_SAFE
2357 .windows(b"\x1b[?1016l".len())
2358 .position(|w| w == b"\x1b[?1016l")
2359 .expect("mux-safe disable should clear 1016");
2360 let pos_1006l = MOUSE_DISABLE_MUX_SAFE
2361 .windows(b"\x1b[?1006l".len())
2362 .position(|w| w == b"\x1b[?1006l")
2363 .expect("mux-safe disable should include 1006 reset");
2364 assert!(
2365 pos_1016l < pos_1006l,
2366 "mux-safe disable should clear 1016 before disabling 1006"
2367 );
2368 }
2369
2370 #[test]
2371 fn feature_delta_uses_mux_safe_mouse_sequence() {
2372 let current = BackendFeatures::default();
2373 let new = BackendFeatures {
2374 mouse_capture: true,
2375 bracketed_paste: false,
2376 focus_events: false,
2377 kitty_keyboard: false,
2378 };
2379 let mux_caps = TerminalCapabilities::builder()
2380 .mouse_sgr(true)
2381 .in_wezterm_mux(true)
2382 .build();
2383 let mut buf = Vec::new();
2384 TtyEventSource::write_feature_delta(¤t, &new, mux_caps, &mut buf).unwrap();
2385 assert!(
2386 buf.windows(MOUSE_ENABLE_MUX_SAFE.len())
2387 .any(|w| w == MOUSE_ENABLE_MUX_SAFE),
2388 "feature delta should use mux-safe mouse enable sequence in mux contexts"
2389 );
2390 assert!(
2391 !buf.windows(b"\x1b[?1001l".len())
2392 .any(|w| w == b"\x1b[?1001l"),
2393 "feature delta must avoid legacy reset bundle in mux contexts"
2394 );
2395 }
2396
2397 #[test]
2398 fn feature_delta_writes_disable_sequences() {
2399 let current = BackendFeatures {
2400 mouse_capture: true,
2401 bracketed_paste: true,
2402 focus_events: true,
2403 kitty_keyboard: true,
2404 };
2405 let new = BackendFeatures::default();
2406 let mut buf = Vec::new();
2407 TtyEventSource::write_feature_delta(
2408 ¤t,
2409 &new,
2410 TerminalCapabilities::modern(),
2411 &mut buf,
2412 )
2413 .unwrap();
2414 assert!(buf.windows(MOUSE_DISABLE.len()).any(|w| w == MOUSE_DISABLE));
2415 assert!(
2416 buf.windows(BRACKETED_PASTE_DISABLE.len())
2417 .any(|w| w == BRACKETED_PASTE_DISABLE)
2418 );
2419 assert!(buf.windows(FOCUS_DISABLE.len()).any(|w| w == FOCUS_DISABLE));
2420 assert!(
2421 buf.windows(KITTY_KEYBOARD_DISABLE.len())
2422 .any(|w| w == KITTY_KEYBOARD_DISABLE)
2423 );
2424 }
2425
2426 #[test]
2427 fn feature_delta_noop_when_unchanged() {
2428 let features = BackendFeatures {
2429 mouse_capture: true,
2430 bracketed_paste: false,
2431 focus_events: true,
2432 kitty_keyboard: false,
2433 };
2434 let mut buf = Vec::new();
2435 TtyEventSource::write_feature_delta(
2436 &features,
2437 &features,
2438 TerminalCapabilities::modern(),
2439 &mut buf,
2440 )
2441 .unwrap();
2442 assert!(buf.is_empty(), "no output expected when features unchanged");
2443 }
2444
2445 #[test]
2446 fn cleanup_sequence_contains_all_disable() {
2447 let features = BackendFeatures {
2448 mouse_capture: true,
2449 bracketed_paste: true,
2450 focus_events: true,
2451 kitty_keyboard: true,
2452 };
2453 let mut buf = Vec::new();
2454 write_cleanup_sequence(&features, true, &mut buf).unwrap();
2455
2456 assert!(
2458 !buf.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2459 "default cleanup utility must not emit standalone sync_end"
2460 );
2461 assert!(buf.windows(MOUSE_DISABLE.len()).any(|w| w == MOUSE_DISABLE));
2462 assert!(
2463 buf.windows(BRACKETED_PASTE_DISABLE.len())
2464 .any(|w| w == BRACKETED_PASTE_DISABLE)
2465 );
2466 assert!(buf.windows(FOCUS_DISABLE.len()).any(|w| w == FOCUS_DISABLE));
2467 assert!(
2468 buf.windows(KITTY_KEYBOARD_DISABLE.len())
2469 .any(|w| w == KITTY_KEYBOARD_DISABLE)
2470 );
2471 assert!(buf.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW));
2472 assert!(
2473 buf.windows(ALT_SCREEN_LEAVE.len())
2474 .any(|w| w == ALT_SCREEN_LEAVE)
2475 );
2476 }
2477
2478 #[test]
2479 fn cleanup_sequence_with_sync_end_opt_in() {
2480 let features = BackendFeatures {
2481 mouse_capture: true,
2482 bracketed_paste: false,
2483 focus_events: false,
2484 kitty_keyboard: false,
2485 };
2486 let mut buf = Vec::new();
2487 write_cleanup_sequence_with_sync_end(&features, true, &mut buf).unwrap();
2488
2489 assert!(
2490 buf.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2491 "opt-in cleanup helper should include sync_end"
2492 );
2493 let sync_pos = buf
2494 .windows(SYNC_END.len())
2495 .position(|w| w == SYNC_END)
2496 .expect("sync_end present");
2497 let cursor_pos = buf
2498 .windows(CURSOR_SHOW.len())
2499 .position(|w| w == CURSOR_SHOW)
2500 .expect("cursor_show present");
2501 assert!(
2502 sync_pos < cursor_pos,
2503 "sync_end should precede cursor_show in opt-in cleanup"
2504 );
2505 }
2506
2507 #[test]
2508 fn cleanup_sequence_policy_can_skip_sync_end() {
2509 let features = BackendFeatures {
2510 mouse_capture: true,
2511 bracketed_paste: false,
2512 focus_events: false,
2513 kitty_keyboard: false,
2514 };
2515 let mut buf = Vec::new();
2516 write_cleanup_sequence_policy(&features, false, false, &mut buf).unwrap();
2517
2518 assert!(
2519 !buf.windows(SYNC_END.len()).any(|w| w == SYNC_END),
2520 "sync_end must be omitted when policy disables synchronized output"
2521 );
2522 assert!(
2523 buf.windows(MOUSE_DISABLE.len()).any(|w| w == MOUSE_DISABLE),
2524 "other cleanup bytes must still be emitted"
2525 );
2526 assert!(buf.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW));
2527 }
2528
2529 #[test]
2530 fn conservative_feature_union_is_over_disabling_superset() {
2531 let a = BackendFeatures {
2532 mouse_capture: false,
2533 bracketed_paste: true,
2534 focus_events: false,
2535 kitty_keyboard: true,
2536 };
2537 let b = BackendFeatures {
2538 mouse_capture: true,
2539 bracketed_paste: false,
2540 focus_events: true,
2541 kitty_keyboard: false,
2542 };
2543
2544 let merged = conservative_feature_union(a, b);
2545 assert!(merged.mouse_capture);
2546 assert!(merged.bracketed_paste);
2547 assert!(merged.focus_events);
2548 assert!(merged.kitty_keyboard);
2549 }
2550
2551 #[test]
2552 fn sanitize_feature_request_disables_unsupported_capabilities() {
2553 let requested = BackendFeatures {
2554 mouse_capture: true,
2555 bracketed_paste: true,
2556 focus_events: true,
2557 kitty_keyboard: true,
2558 };
2559 let sanitized = sanitize_feature_request(requested, TerminalCapabilities::basic());
2560 assert_eq!(sanitized, BackendFeatures::default());
2561 }
2562
2563 #[test]
2564 fn sanitize_feature_request_is_conservative_in_wezterm_mux() {
2565 let requested = BackendFeatures {
2566 mouse_capture: true,
2567 bracketed_paste: true,
2568 focus_events: true,
2569 kitty_keyboard: true,
2570 };
2571 let caps = TerminalCapabilities::builder()
2572 .mouse_sgr(true)
2573 .bracketed_paste(true)
2574 .focus_events(true)
2575 .kitty_keyboard(true)
2576 .in_wezterm_mux(true)
2577 .build();
2578 let sanitized = sanitize_feature_request(requested, caps);
2579
2580 assert!(
2581 sanitized.mouse_capture,
2582 "mouse capture should remain available"
2583 );
2584 assert!(
2585 sanitized.bracketed_paste,
2586 "bracketed paste should remain available"
2587 );
2588 assert!(
2589 !sanitized.focus_events,
2590 "focus events should be disabled in wezterm mux"
2591 );
2592 assert!(
2593 !sanitized.kitty_keyboard,
2594 "kitty keyboard should be disabled in mux sessions"
2595 );
2596 }
2597
2598 #[test]
2599 fn sanitize_feature_request_disables_focus_in_tmux() {
2600 let requested = BackendFeatures {
2601 mouse_capture: true,
2602 bracketed_paste: true,
2603 focus_events: true,
2604 kitty_keyboard: true,
2605 };
2606 let caps = TerminalCapabilities::builder()
2607 .mouse_sgr(true)
2608 .bracketed_paste(true)
2609 .focus_events(true)
2610 .kitty_keyboard(true)
2611 .in_tmux(true)
2612 .build();
2613 let sanitized = sanitize_feature_request(requested, caps);
2614
2615 assert!(sanitized.mouse_capture);
2616 assert!(sanitized.bracketed_paste);
2617 assert!(!sanitized.focus_events);
2618 assert!(!sanitized.kitty_keyboard);
2619 }
2620
2621 #[test]
2622 fn apply_feature_state_enables_legacy_fallbacks_when_mouse_capture_on() {
2623 let mut src = TtyEventSource::new(80, 24);
2624 src.capabilities = TerminalCapabilities::builder().mouse_sgr(true).build();
2625 src.apply_feature_state(BackendFeatures {
2626 mouse_capture: true,
2627 ..BackendFeatures::default()
2628 });
2629
2630 let modern_events = src.parser.parse(b"\x1b[0;10;20M");
2633 assert!(
2634 modern_events.iter().any(|e| matches!(e, Event::Mouse(_))),
2635 "legacy numeric fallback should remain available with mouse capture on"
2636 );
2637 let modern_x10 = src.parser.parse(&[0x1B, b'[', b'M', 32, 42, 52]);
2638 assert!(
2639 modern_x10.iter().any(|e| matches!(e, Event::Mouse(_))),
2640 "raw X10 fallback should stay available with mouse capture on"
2641 );
2642
2643 src.capabilities = TerminalCapabilities::basic();
2644 src.apply_feature_state(BackendFeatures {
2645 mouse_capture: true,
2646 ..BackendFeatures::default()
2647 });
2648
2649 let legacy_events = src.parser.parse(b"\x1b[0;10;20M");
2651 assert!(
2652 legacy_events.iter().any(|e| matches!(e, Event::Mouse(_))),
2653 "legacy mouse fallback should be enabled when SGR is unavailable"
2654 );
2655 let legacy_x10 = src.parser.parse(&[0x1B, b'[', b'M', 32, 42, 52]);
2656 assert!(
2657 legacy_x10.iter().any(|e| matches!(e, Event::Mouse(_))),
2658 "raw X10 decoding should be enabled when SGR is unavailable"
2659 );
2660
2661 src.apply_feature_state(BackendFeatures::default());
2662 let disabled_x10 = src.parser.parse(&[0x1B, b'[', b'M', 32, 42, 52]);
2663 assert!(
2664 disabled_x10.iter().all(|e| !matches!(e, Event::Mouse(_))),
2665 "raw X10 fallback must be disabled when mouse capture is off"
2666 );
2667 }
2668
2669 #[test]
2670 fn normalize_event_maps_pixel_space_mouse_to_cell_grid() {
2671 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
2672
2673 let mut src = TtyEventSource::new(100, 40);
2674 src.capabilities = TerminalCapabilities::builder().mouse_sgr(true).build();
2675 src.features = BackendFeatures {
2676 mouse_capture: true,
2677 ..BackendFeatures::default()
2678 };
2679 src.pixel_width = 1000;
2680 src.pixel_height = 800;
2681
2682 let event = Event::Mouse(MouseEvent {
2683 kind: MouseEventKind::Down(MouseButton::Left),
2684 x: 500,
2685 y: 400,
2686 modifiers: Modifiers::NONE,
2687 });
2688 let normalized = src.normalize_event(event);
2689
2690 let mouse = match normalized {
2691 Event::Mouse(mouse) => mouse,
2692 other => {
2693 panic!("expected mouse event, got {other:?}");
2694 }
2695 };
2696 assert!(mouse.x < src.width, "x should be mapped into cell bounds");
2697 assert!(mouse.y < src.height, "y should be mapped into cell bounds");
2698 assert!(
2699 mouse.x > 0 && mouse.y > 0,
2700 "pixel-space event should not collapse to origin"
2701 );
2702 }
2703
2704 #[test]
2705 fn normalize_event_keeps_cell_space_mouse_unchanged() {
2706 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
2707
2708 let mut src = TtyEventSource::new(100, 40);
2709 src.capabilities = TerminalCapabilities::builder().mouse_sgr(true).build();
2710 src.features = BackendFeatures {
2711 mouse_capture: true,
2712 ..BackendFeatures::default()
2713 };
2714 src.pixel_width = 1000;
2715 src.pixel_height = 800;
2716
2717 let event = Event::Mouse(MouseEvent {
2718 kind: MouseEventKind::Down(MouseButton::Left),
2719 x: 50,
2720 y: 10,
2721 modifiers: Modifiers::NONE,
2722 });
2723 let normalized = src.normalize_event(event.clone());
2724 assert_eq!(
2725 normalized, event,
2726 "cell-space coordinates must be preserved"
2727 );
2728 }
2729
2730 #[test]
2731 fn normalize_event_sticky_pixel_mode_maps_subsequent_low_coordinates() {
2732 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
2733
2734 let mut src = TtyEventSource::new(100, 40);
2735 src.capabilities = TerminalCapabilities::builder().mouse_sgr(true).build();
2736 src.features = BackendFeatures {
2737 mouse_capture: true,
2738 ..BackendFeatures::default()
2739 };
2740 src.pixel_width = 1000;
2741 src.pixel_height = 800;
2742
2743 let first = Event::Mouse(MouseEvent {
2744 kind: MouseEventKind::Down(MouseButton::Left),
2745 x: 500,
2746 y: 400,
2747 modifiers: Modifiers::NONE,
2748 });
2749 let _ = src.normalize_event(first);
2750 assert!(
2751 src.mouse_coords_pixels,
2752 "large out-of-grid mouse event should arm sticky pixel normalization"
2753 );
2754
2755 let second = Event::Mouse(MouseEvent {
2756 kind: MouseEventKind::Down(MouseButton::Left),
2757 x: 100,
2758 y: 20,
2759 modifiers: Modifiers::NONE,
2760 });
2761 let normalized = src.normalize_event(second);
2762 let mouse = match normalized {
2763 Event::Mouse(mouse) => mouse,
2764 other => {
2765 panic!("expected mouse event, got {other:?}");
2766 }
2767 };
2768 assert!(mouse.x < src.width, "sticky mode should normalize x");
2769 assert!(mouse.y < src.height, "sticky mode should normalize y");
2770 }
2771
2772 #[test]
2773 fn apply_feature_state_disabling_mouse_resets_pixel_detector() {
2774 let mut src = TtyEventSource::new(80, 24);
2775 src.mouse_coords_pixels = true;
2776 src.inferred_pixel_width = 1234;
2777 src.inferred_pixel_height = 777;
2778 src.apply_feature_state(BackendFeatures::default());
2779 assert!(
2780 !src.mouse_coords_pixels,
2781 "disabling mouse capture should clear sticky pixel-mode detector"
2782 );
2783 assert_eq!(src.inferred_pixel_width, 0);
2784 assert_eq!(src.inferred_pixel_height, 0);
2785 }
2786
2787 #[test]
2788 fn normalize_event_infers_pixel_grid_when_winsize_pixels_missing() {
2789 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
2790
2791 let mut src = TtyEventSource::new(100, 40);
2792 src.capabilities = TerminalCapabilities::builder().mouse_sgr(true).build();
2793 src.features = BackendFeatures {
2794 mouse_capture: true,
2795 ..BackendFeatures::default()
2796 };
2797 src.pixel_width = 0;
2799 src.pixel_height = 0;
2800
2801 let first = Event::Mouse(MouseEvent {
2802 kind: MouseEventKind::Down(MouseButton::Left),
2803 x: 500,
2804 y: 400,
2805 modifiers: Modifiers::NONE,
2806 });
2807 let normalized_first = src.normalize_event(first);
2808 let first_mouse = match normalized_first {
2809 Event::Mouse(mouse) => mouse,
2810 other => {
2811 panic!("expected mouse event, got {other:?}");
2812 }
2813 };
2814 assert!(first_mouse.x > 0 && first_mouse.x < src.width.saturating_sub(1));
2815 assert!(first_mouse.y > 0 && first_mouse.y < src.height.saturating_sub(1));
2816
2817 let second = Event::Mouse(MouseEvent {
2818 kind: MouseEventKind::Moved,
2819 x: 250,
2820 y: 200,
2821 modifiers: Modifiers::NONE,
2822 });
2823 let normalized = src.normalize_event(second);
2824 let mouse = match normalized {
2825 Event::Mouse(mouse) => mouse,
2826 other => {
2827 panic!("expected mouse event, got {other:?}");
2828 }
2829 };
2830
2831 assert!(mouse.x < src.width);
2832 assert!(mouse.y < src.height);
2833 assert!(mouse.x > 0 && mouse.x < src.width.saturating_sub(1));
2834 assert!(mouse.y > 0 && mouse.y < src.height.saturating_sub(1));
2835 }
2836
2837 #[test]
2838 fn cleanup_sequence_ordering() {
2839 let features = BackendFeatures {
2840 mouse_capture: true,
2841 bracketed_paste: true,
2842 focus_events: true,
2843 kitty_keyboard: true,
2844 };
2845 let mut buf = Vec::new();
2846 write_cleanup_sequence(&features, true, &mut buf).unwrap();
2847
2848 let cursor_pos = buf
2850 .windows(CURSOR_SHOW.len())
2851 .position(|w| w == CURSOR_SHOW)
2852 .expect("cursor_show present");
2853 let alt_pos = buf
2854 .windows(ALT_SCREEN_LEAVE.len())
2855 .position(|w| w == ALT_SCREEN_LEAVE)
2856 .expect("alt_screen_leave present");
2857
2858 assert!(
2859 cursor_pos < alt_pos,
2860 "cursor_show must come before alt_screen_leave"
2861 );
2862 }
2863
2864 #[test]
2865 fn disable_all_resets_feature_state() {
2866 let mut src = TtyEventSource::new(80, 24);
2867 src.features = BackendFeatures {
2868 mouse_capture: true,
2869 bracketed_paste: true,
2870 focus_events: true,
2871 kitty_keyboard: true,
2872 };
2873 let mut buf = Vec::new();
2874 src.disable_all(&mut buf).unwrap();
2875 assert_eq!(src.features(), BackendFeatures::default());
2876 assert!(!buf.is_empty());
2878 }
2879
2880 #[cfg(unix)]
2883 mod pty_tests {
2884 use super::*;
2885 use nix::pty::openpty;
2886 use nix::sys::termios::{self, LocalFlags};
2887 use std::io::Read;
2888
2889 fn pty_pair() -> (std::fs::File, std::fs::File) {
2890 let result = openpty(None, None).expect("openpty failed");
2891 (
2892 std::fs::File::from(result.master),
2893 std::fs::File::from(result.slave),
2894 )
2895 }
2896
2897 #[test]
2898 fn raw_mode_entered_and_restored_on_drop() {
2899 let (_master, slave) = pty_pair();
2900 let slave_dup = slave.try_clone().unwrap();
2901
2902 let before = termios::tcgetattr(&slave_dup).unwrap();
2904 assert!(
2905 before.local_flags.contains(LocalFlags::ECHO),
2906 "default termios should have ECHO"
2907 );
2908 assert!(
2909 before.local_flags.contains(LocalFlags::ICANON),
2910 "default termios should have ICANON"
2911 );
2912
2913 {
2914 let _guard = RawModeGuard::enter_on(slave).unwrap();
2915
2916 let during = termios::tcgetattr(&slave_dup).unwrap();
2918 assert!(
2919 !during.local_flags.contains(LocalFlags::ECHO),
2920 "raw mode should clear ECHO"
2921 );
2922 assert!(
2923 !during.local_flags.contains(LocalFlags::ICANON),
2924 "raw mode should clear ICANON"
2925 );
2926 }
2927
2928 let after = termios::tcgetattr(&slave_dup).unwrap();
2930 assert!(
2931 after.local_flags.contains(LocalFlags::ECHO),
2932 "should restore ECHO after drop"
2933 );
2934 assert!(
2935 after.local_flags.contains(LocalFlags::ICANON),
2936 "should restore ICANON after drop"
2937 );
2938 }
2939
2940 #[test]
2941 fn panic_restores_termios() {
2942 let (_master, slave) = pty_pair();
2943 let slave_dup = slave.try_clone().unwrap();
2944
2945 let handle = std::thread::spawn(move || {
2947 let _guard = RawModeGuard::enter_on(slave).unwrap();
2948 std::panic::panic_any("intentional panic for testing raw mode cleanup");
2949 });
2950
2951 assert!(handle.join().is_err(), "thread should have panicked");
2952
2953 let after = termios::tcgetattr(&slave_dup).unwrap();
2955 assert!(
2956 after.local_flags.contains(LocalFlags::ECHO),
2957 "ECHO should be restored after panic"
2958 );
2959 assert!(
2960 after.local_flags.contains(LocalFlags::ICANON),
2961 "ICANON should be restored after panic"
2962 );
2963 }
2964
2965 #[test]
2966 fn backend_drop_writes_cleanup_sequences() {
2967 let (mut master, slave) = pty_pair();
2968 let slave_dup = slave.try_clone().unwrap();
2969
2970 {
2971 let _guard = RawModeGuard::enter_on(slave).unwrap();
2972
2973 let mut stdout_buf = Vec::new();
2975 let all_on = BackendFeatures {
2976 mouse_capture: true,
2977 bracketed_paste: true,
2978 focus_events: true,
2979 kitty_keyboard: true,
2980 };
2981 TtyEventSource::write_feature_delta(
2982 &BackendFeatures::default(),
2983 &all_on,
2984 TerminalCapabilities::modern(),
2985 &mut stdout_buf,
2986 )
2987 .unwrap();
2988 write_cleanup_sequence(&all_on, true, &mut stdout_buf).unwrap();
2990
2991 use std::io::Write;
2993 let mut slave_writer = slave_dup.try_clone().unwrap();
2994 slave_writer.write_all(&stdout_buf).unwrap();
2995 slave_writer.flush().unwrap();
2996 }
2997
2998 let mut buf = vec![0u8; 2048];
3000 let n = master.read(&mut buf).unwrap();
3001 let output = &buf[..n];
3002
3003 assert!(
3004 output.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW),
3005 "cleanup must show cursor"
3006 );
3007 assert!(
3008 output
3009 .windows(MOUSE_DISABLE.len())
3010 .any(|w| w == MOUSE_DISABLE),
3011 "cleanup must disable mouse"
3012 );
3013 assert!(
3014 output
3015 .windows(ALT_SCREEN_LEAVE.len())
3016 .any(|w| w == ALT_SCREEN_LEAVE),
3017 "cleanup must leave alt-screen"
3018 );
3019 }
3020
3021 fn write_to_slave_and_read_master(
3023 master: &mut std::fs::File,
3024 slave: &std::fs::File,
3025 data: &[u8],
3026 ) -> Vec<u8> {
3027 use std::io::Write;
3028 let mut writer = slave.try_clone().unwrap();
3029 writer.write_all(data).unwrap();
3030 writer.flush().unwrap();
3031 let mut buf = vec![0u8; 4096];
3032 let n = master.read(&mut buf).unwrap();
3033 buf.truncate(n);
3034 buf
3035 }
3036
3037 #[test]
3038 fn cursor_hide_on_enter_show_on_drop() {
3039 let (mut master, slave) = pty_pair();
3040 let slave_dup = slave.try_clone().unwrap();
3041
3042 {
3044 let _guard = RawModeGuard::enter_on(slave).unwrap();
3045 let output = write_to_slave_and_read_master(&mut master, &slave_dup, CURSOR_HIDE);
3046 assert!(
3047 output.windows(CURSOR_HIDE.len()).any(|w| w == CURSOR_HIDE),
3048 "cursor-hide should be written on session enter"
3049 );
3050
3051 let output = write_to_slave_and_read_master(&mut master, &slave_dup, CURSOR_SHOW);
3053 assert!(
3054 output.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW),
3055 "cursor-show should be written on session exit"
3056 );
3057 }
3058 }
3059
3060 #[test]
3061 fn alt_screen_enter_and_leave_via_pty() {
3062 let (mut master, slave) = pty_pair();
3063 let slave_dup = slave.try_clone().unwrap();
3064
3065 {
3066 let _guard = RawModeGuard::enter_on(slave).unwrap();
3067
3068 let output =
3070 write_to_slave_and_read_master(&mut master, &slave_dup, ALT_SCREEN_ENTER);
3071 assert!(
3072 output
3073 .windows(ALT_SCREEN_ENTER.len())
3074 .any(|w| w == ALT_SCREEN_ENTER),
3075 "alt-screen enter should pass through PTY"
3076 );
3077
3078 let output =
3080 write_to_slave_and_read_master(&mut master, &slave_dup, ALT_SCREEN_LEAVE);
3081 assert!(
3082 output
3083 .windows(ALT_SCREEN_LEAVE.len())
3084 .any(|w| w == ALT_SCREEN_LEAVE),
3085 "alt-screen leave should pass through PTY"
3086 );
3087 }
3088 }
3089
3090 #[test]
3091 fn per_feature_disable_on_drop() {
3092 let (mut master, slave) = pty_pair();
3093 let slave_dup = slave.try_clone().unwrap();
3094
3095 {
3096 let _guard = RawModeGuard::enter_on(slave).unwrap();
3097
3098 let all_on = BackendFeatures {
3100 mouse_capture: true,
3101 bracketed_paste: true,
3102 focus_events: true,
3103 kitty_keyboard: true,
3104 };
3105 let mut cleanup = Vec::new();
3106 write_cleanup_sequence(&all_on, false, &mut cleanup).unwrap();
3107
3108 let output = write_to_slave_and_read_master(&mut master, &slave_dup, &cleanup);
3109
3110 assert!(
3112 output
3113 .windows(MOUSE_DISABLE.len())
3114 .any(|w| w == MOUSE_DISABLE),
3115 "mouse must be disabled on drop"
3116 );
3117 assert!(
3118 output
3119 .windows(BRACKETED_PASTE_DISABLE.len())
3120 .any(|w| w == BRACKETED_PASTE_DISABLE),
3121 "bracketed paste must be disabled on drop"
3122 );
3123 assert!(
3124 output
3125 .windows(FOCUS_DISABLE.len())
3126 .any(|w| w == FOCUS_DISABLE),
3127 "focus events must be disabled on drop"
3128 );
3129 assert!(
3130 output
3131 .windows(KITTY_KEYBOARD_DISABLE.len())
3132 .any(|w| w == KITTY_KEYBOARD_DISABLE),
3133 "kitty keyboard must be disabled on drop"
3134 );
3135 assert!(
3136 output.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW),
3137 "cursor must be shown on drop"
3138 );
3139 }
3140 }
3141
3142 #[test]
3143 fn panic_with_features_restores_termios() {
3144 let (_master, slave) = pty_pair();
3145 let slave_dup = slave.try_clone().unwrap();
3146
3147 let handle = std::thread::spawn(move || {
3148 let _guard = RawModeGuard::enter_on(slave).unwrap();
3149 std::panic::panic_any("panic with features enabled");
3153 });
3154
3155 assert!(handle.join().is_err());
3156
3157 let after = termios::tcgetattr(&slave_dup).unwrap();
3158 assert!(
3159 after.local_flags.contains(LocalFlags::ECHO),
3160 "ECHO restored after panic with features"
3161 );
3162 assert!(
3163 after.local_flags.contains(LocalFlags::ICANON),
3164 "ICANON restored after panic with features"
3165 );
3166 }
3167
3168 #[test]
3169 fn repeated_raw_mode_cycles_no_leak() {
3170 let (_master, slave) = pty_pair();
3171 let slave_dup = slave.try_clone().unwrap();
3172
3173 for _ in 0..5 {
3175 let s = slave_dup.try_clone().unwrap();
3176 let guard = RawModeGuard::enter_on(s).unwrap();
3177
3178 let during = termios::tcgetattr(&slave_dup).unwrap();
3180 assert!(!during.local_flags.contains(LocalFlags::ECHO));
3181
3182 drop(guard);
3183
3184 let after = termios::tcgetattr(&slave_dup).unwrap();
3186 assert!(
3187 after.local_flags.contains(LocalFlags::ECHO),
3188 "ECHO must be restored each cycle"
3189 );
3190 }
3191 }
3192
3193 #[test]
3194 fn cleanup_ordering_via_pty() {
3195 let (mut master, slave) = pty_pair();
3196 let slave_dup = slave.try_clone().unwrap();
3197
3198 {
3199 let _guard = RawModeGuard::enter_on(slave).unwrap();
3200
3201 let features = BackendFeatures {
3203 mouse_capture: true,
3204 bracketed_paste: true,
3205 focus_events: true,
3206 kitty_keyboard: true,
3207 };
3208 let mut seq = Vec::new();
3209 write_cleanup_sequence_with_sync_end(&features, true, &mut seq).unwrap();
3210
3211 let output = write_to_slave_and_read_master(&mut master, &slave_dup, &seq);
3212
3213 let sync_pos = output
3215 .windows(SYNC_END.len())
3216 .position(|w| w == SYNC_END)
3217 .expect("sync_end present");
3218 let cursor_pos = output
3219 .windows(CURSOR_SHOW.len())
3220 .position(|w| w == CURSOR_SHOW)
3221 .expect("cursor_show present");
3222 let alt_pos = output
3223 .windows(ALT_SCREEN_LEAVE.len())
3224 .position(|w| w == ALT_SCREEN_LEAVE)
3225 .expect("alt_screen_leave present");
3226
3227 assert!(
3228 sync_pos < cursor_pos,
3229 "sync_end ({sync_pos}) must precede cursor_show ({cursor_pos})"
3230 );
3231 assert!(
3232 cursor_pos < alt_pos,
3233 "cursor_show ({cursor_pos}) must precede alt_screen_leave ({alt_pos})"
3234 );
3235 }
3236 }
3237 }
3238}