1#![forbid(unsafe_code)]
2use core::time::Duration;
20use std::collections::VecDeque;
21use std::io::{self, Read, Write};
22use std::sync::mpsc;
23
24use ftui_backend::{Backend, BackendClock, BackendEventSource, BackendFeatures, BackendPresenter};
25use ftui_core::event::Event;
26use ftui_core::input_parser::InputParser;
27use ftui_core::terminal_capabilities::TerminalCapabilities;
28use ftui_render::buffer::Buffer;
29use ftui_render::diff::BufferDiff;
30use ftui_render::presenter::Presenter;
31
32#[cfg(unix)]
33use signal_hook::consts::signal::SIGWINCH;
34#[cfg(unix)]
35use signal_hook::iterator::Signals;
36
37const ALT_SCREEN_ENTER: &[u8] = b"\x1b[?1049h";
40const ALT_SCREEN_LEAVE: &[u8] = b"\x1b[?1049l";
41
42const MOUSE_ENABLE: &[u8] = b"\x1b[?1000;1002;1006h";
43const MOUSE_DISABLE: &[u8] = b"\x1b[?1000;1002;1006l";
44
45const BRACKETED_PASTE_ENABLE: &[u8] = b"\x1b[?2004h";
46const BRACKETED_PASTE_DISABLE: &[u8] = b"\x1b[?2004l";
47
48const FOCUS_ENABLE: &[u8] = b"\x1b[?1004h";
49const FOCUS_DISABLE: &[u8] = b"\x1b[?1004l";
50
51const KITTY_KEYBOARD_ENABLE: &[u8] = b"\x1b[>15u";
52const KITTY_KEYBOARD_DISABLE: &[u8] = b"\x1b[<u";
53
54const CURSOR_SHOW: &[u8] = b"\x1b[?25h";
55#[allow(dead_code)]
56const CURSOR_HIDE: &[u8] = b"\x1b[?25l";
57
58const SYNC_END: &[u8] = b"\x1b[?2026l";
59
60const CLEAR_SCREEN: &[u8] = b"\x1b[2J";
61const CURSOR_HOME: &[u8] = b"\x1b[H";
62const READ_BUFFER_BYTES: usize = 8192;
63const MAX_DRAIN_BYTES_PER_POLL: usize = READ_BUFFER_BYTES;
64
65#[cfg(unix)]
76pub struct RawModeGuard {
77 original_termios: nix::sys::termios::Termios,
78 tty: std::fs::File,
79}
80
81#[cfg(unix)]
82impl RawModeGuard {
83 pub fn enter() -> io::Result<Self> {
86 let tty = std::fs::File::open("/dev/tty")?;
87 Self::enter_on(tty)
88 }
89
90 pub fn enter_on(tty: std::fs::File) -> io::Result<Self> {
92 let original_termios = nix::sys::termios::tcgetattr(&tty).map_err(io::Error::other)?;
93
94 let mut raw = original_termios.clone();
95 nix::sys::termios::cfmakeraw(&mut raw);
96 nix::sys::termios::tcsetattr(&tty, nix::sys::termios::SetArg::TCSAFLUSH, &raw)
97 .map_err(io::Error::other)?;
98
99 Ok(Self {
100 original_termios,
101 tty,
102 })
103 }
104}
105
106#[cfg(unix)]
107impl Drop for RawModeGuard {
108 fn drop(&mut self) {
109 let _ = nix::sys::termios::tcsetattr(
111 &self.tty,
112 nix::sys::termios::SetArg::TCSAFLUSH,
113 &self.original_termios,
114 );
115 }
116}
117
118#[derive(Debug, Clone, Default)]
122pub struct TtySessionOptions {
123 pub alternate_screen: bool,
125 pub features: BackendFeatures,
127}
128
129pub struct TtyClock {
133 epoch: std::time::Instant,
134}
135
136impl TtyClock {
137 #[must_use]
138 pub fn new() -> Self {
139 Self {
140 epoch: std::time::Instant::now(),
141 }
142 }
143}
144
145impl Default for TtyClock {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151impl BackendClock for TtyClock {
152 fn now_mono(&self) -> Duration {
153 self.epoch.elapsed()
154 }
155}
156
157#[cfg(unix)]
164#[derive(Debug)]
165struct ResizeSignalGuard {
166 handle: signal_hook::iterator::Handle,
167 thread: Option<std::thread::JoinHandle<()>>,
168}
169
170#[cfg(unix)]
171impl ResizeSignalGuard {
172 fn new(tx: mpsc::SyncSender<()>) -> io::Result<Self> {
173 let mut signals = Signals::new([SIGWINCH]).map_err(io::Error::other)?;
174 let handle = signals.handle();
175 let thread = std::thread::spawn(move || {
176 for _ in signals.forever() {
177 let _ = tx.try_send(());
180 }
181 });
182
183 Ok(Self {
184 handle,
185 thread: Some(thread),
186 })
187 }
188}
189
190#[cfg(unix)]
191impl Drop for ResizeSignalGuard {
192 fn drop(&mut self) {
193 self.handle.close();
194 if let Some(thread) = self.thread.take() {
195 let _ = thread.join();
196 }
197 }
198}
199
200pub struct TtyEventSource {
206 features: BackendFeatures,
207 width: u16,
208 height: u16,
209 live: bool,
212 #[cfg(unix)]
214 resize_rx: Option<mpsc::Receiver<()>>,
215 #[cfg(unix)]
217 _resize_guard: Option<ResizeSignalGuard>,
218 parser: InputParser,
220 event_queue: VecDeque<Event>,
222 tty_reader: Option<std::fs::File>,
224 reader_nonblocking: bool,
226}
227
228impl TtyEventSource {
229 #[must_use]
231 pub fn new(width: u16, height: u16) -> Self {
232 Self {
233 features: BackendFeatures::default(),
234 width,
235 height,
236 live: false,
237 #[cfg(unix)]
238 resize_rx: None,
239 #[cfg(unix)]
240 _resize_guard: None,
241 parser: InputParser::new(),
242 event_queue: VecDeque::new(),
243 tty_reader: None,
244 reader_nonblocking: false,
245 }
246 }
247
248 fn live(width: u16, height: u16) -> io::Result<Self> {
251 let tty_reader = std::fs::File::open("/dev/tty")?;
252 let reader_nonblocking = Self::try_enable_nonblocking(&tty_reader);
253 let mut w = width;
254 let mut h = height;
255 #[cfg(unix)]
256 if let Ok(ws) = rustix::termios::tcgetwinsize(&tty_reader)
257 && ws.ws_col > 0
258 && ws.ws_row > 0
259 {
260 w = ws.ws_col;
261 h = ws.ws_row;
262 }
263
264 #[cfg(unix)]
265 let (resize_guard, resize_rx) = {
266 let (resize_tx, resize_rx) = mpsc::sync_channel(1);
267 match ResizeSignalGuard::new(resize_tx) {
268 Ok(guard) => (Some(guard), Some(resize_rx)),
269 Err(_) => (None, None),
270 }
271 };
272
273 Ok(Self {
274 features: BackendFeatures::default(),
275 width: w,
276 height: h,
277 live: true,
278 #[cfg(unix)]
279 resize_rx,
280 #[cfg(unix)]
281 _resize_guard: resize_guard,
282 parser: InputParser::new(),
283 event_queue: VecDeque::new(),
284 tty_reader: Some(tty_reader),
285 reader_nonblocking,
286 })
287 }
288
289 #[cfg(test)]
294 fn from_reader(width: u16, height: u16, reader: std::fs::File) -> Self {
295 let reader_nonblocking = Self::try_enable_nonblocking(&reader);
296 Self {
297 features: BackendFeatures::default(),
298 width,
299 height,
300 live: false,
301 #[cfg(unix)]
302 resize_rx: None,
303 #[cfg(unix)]
304 _resize_guard: None,
305 parser: InputParser::new(),
306 event_queue: VecDeque::new(),
307 tty_reader: Some(reader),
308 reader_nonblocking,
309 }
310 }
311
312 #[cfg(unix)]
313 fn try_enable_nonblocking(reader: &std::fs::File) -> bool {
314 use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
315
316 let Ok(flags) = fcntl_getfl(reader) else {
317 return false;
318 };
319 if flags.contains(OFlags::NONBLOCK) {
320 return true;
321 }
322 fcntl_setfl(reader, flags | OFlags::NONBLOCK).is_ok()
323 }
324
325 #[cfg(not(unix))]
326 fn try_enable_nonblocking(_reader: &std::fs::File) -> bool {
327 false
328 }
329
330 #[must_use]
332 pub fn features(&self) -> BackendFeatures {
333 self.features
334 }
335
336 fn push_resize(&mut self, new_width: u16, new_height: u16) {
337 if new_width == 0 || new_height == 0 {
338 return;
339 }
340 if (new_width, new_height) == (self.width, self.height) {
341 return;
342 }
343 self.width = new_width;
344 self.height = new_height;
345 self.event_queue.push_back(Event::Resize {
346 width: new_width,
347 height: new_height,
348 });
349 }
350
351 #[cfg(unix)]
352 fn query_tty_size(&self) -> Option<(u16, u16)> {
353 if !self.live {
354 return None;
355 }
356 let tty = self.tty_reader.as_ref()?;
357 let ws = rustix::termios::tcgetwinsize(tty).ok()?;
358 if ws.ws_col == 0 || ws.ws_row == 0 {
359 return None;
360 }
361 Some((ws.ws_col, ws.ws_row))
362 }
363
364 #[cfg(unix)]
365 fn drain_resize_notifications(&mut self) {
366 if !self.live {
367 return;
368 }
369 let got_resize = if let Some(ref rx) = self.resize_rx {
372 let mut any = false;
373 while rx.try_recv().is_ok() {
374 any = true;
375 }
376 any
377 } else {
378 false
379 };
380 if got_resize && let Some((w, h)) = self.query_tty_size() {
381 self.push_resize(w, h);
382 }
383 }
384
385 fn drain_available_bytes(&mut self) -> io::Result<()> {
387 let Some(ref mut tty) = self.tty_reader else {
388 return Ok(());
389 };
390 let mut buf = [0u8; READ_BUFFER_BYTES];
391 let mut drained_bytes = 0usize;
392 loop {
393 match tty.read(&mut buf) {
394 Ok(0) => return Ok(()),
395 Ok(n) => {
396 let queue = &mut self.event_queue;
397 self.parser
398 .parse_with(&buf[..n], |event| queue.push_back(event));
399 drained_bytes = drained_bytes.saturating_add(n);
400 if !self.reader_nonblocking {
401 return Ok(());
402 }
403 if drained_bytes >= MAX_DRAIN_BYTES_PER_POLL {
404 return Ok(());
405 }
406 }
407 Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(()),
408 Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
409 Err(e) => return Err(e),
410 }
411 }
412 }
413
414 #[cfg(unix)]
416 fn poll_tty(&mut self, timeout: Duration) -> io::Result<bool> {
417 use std::os::fd::AsFd;
418 let ready = {
419 let Some(ref tty) = self.tty_reader else {
420 return Ok(false);
421 };
422 let mut poll_fds = [nix::poll::PollFd::new(
423 tty.as_fd(),
424 nix::poll::PollFlags::POLLIN,
425 )];
426 let timeout_ms: u16 = timeout.as_millis().try_into().unwrap_or(u16::MAX);
427 match nix::poll::poll(&mut poll_fds, nix::poll::PollTimeout::from(timeout_ms)) {
428 Ok(n) => n,
429 Err(nix::errno::Errno::EINTR) => return Ok(false),
430 Err(e) => return Err(io::Error::other(e)),
431 }
432 };
433 if ready > 0 {
434 self.drain_available_bytes()?;
435 }
436 Ok(!self.event_queue.is_empty())
437 }
438
439 #[cfg(not(unix))]
441 fn poll_tty(&mut self, _timeout: Duration) -> io::Result<bool> {
442 Ok(false)
443 }
444
445 fn write_feature_delta(
447 current: &BackendFeatures,
448 new: &BackendFeatures,
449 writer: &mut impl Write,
450 ) -> io::Result<()> {
451 if new.mouse_capture != current.mouse_capture {
452 writer.write_all(if new.mouse_capture {
453 MOUSE_ENABLE
454 } else {
455 MOUSE_DISABLE
456 })?;
457 }
458 if new.bracketed_paste != current.bracketed_paste {
459 writer.write_all(if new.bracketed_paste {
460 BRACKETED_PASTE_ENABLE
461 } else {
462 BRACKETED_PASTE_DISABLE
463 })?;
464 }
465 if new.focus_events != current.focus_events {
466 writer.write_all(if new.focus_events {
467 FOCUS_ENABLE
468 } else {
469 FOCUS_DISABLE
470 })?;
471 }
472 if new.kitty_keyboard != current.kitty_keyboard {
473 writer.write_all(if new.kitty_keyboard {
474 KITTY_KEYBOARD_ENABLE
475 } else {
476 KITTY_KEYBOARD_DISABLE
477 })?;
478 }
479 Ok(())
480 }
481
482 fn disable_all(&mut self, writer: &mut impl Write) -> io::Result<()> {
484 let off = BackendFeatures::default();
485 Self::write_feature_delta(&self.features, &off, writer)?;
486 self.features = off;
487 Ok(())
488 }
489}
490
491impl BackendEventSource for TtyEventSource {
492 type Error = io::Error;
493
494 fn size(&self) -> Result<(u16, u16), Self::Error> {
495 #[cfg(unix)]
496 if let Some((w, h)) = self.query_tty_size() {
497 return Ok((w, h));
498 }
499 Ok((self.width, self.height))
500 }
501
502 fn set_features(&mut self, features: BackendFeatures) -> Result<(), Self::Error> {
503 if self.live {
504 let mut stdout = io::stdout();
505 Self::write_feature_delta(&self.features, &features, &mut stdout)?;
506 stdout.flush()?;
507 }
508 self.features = features;
509 Ok(())
510 }
511
512 fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
513 #[cfg(unix)]
514 self.drain_resize_notifications();
515
516 if !self.event_queue.is_empty() {
518 return Ok(true);
519 }
520
521 #[cfg(unix)]
522 if self.resize_rx.is_some() && timeout != Duration::ZERO {
523 let deadline = std::time::Instant::now()
526 .checked_add(timeout)
527 .unwrap_or_else(std::time::Instant::now);
528 let slice_max = Duration::from_millis(50);
529 loop {
530 let now = std::time::Instant::now();
531 if now >= deadline {
532 return Ok(false);
533 }
534 let remaining = deadline.duration_since(now);
535 let poll_for = remaining.min(slice_max);
536 let _ = self.poll_tty(poll_for)?;
537 self.drain_resize_notifications();
538 if !self.event_queue.is_empty() {
539 return Ok(true);
540 }
541 }
542 }
543
544 self.poll_tty(timeout)
545 }
546
547 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
548 if let Some(event) = self.event_queue.pop_front() {
549 return Ok(Some(event));
550 }
551
552 if self.reader_nonblocking && self.tty_reader.is_some() {
558 self.drain_available_bytes()?;
559 return Ok(self.event_queue.pop_front());
560 }
561
562 Ok(None)
563 }
564}
565
566pub struct TtyPresenter<W: Write + Send = io::Stdout> {
573 capabilities: TerminalCapabilities,
574 inner: Option<Presenter<W>>,
575}
576
577impl TtyPresenter {
578 #[must_use]
580 pub fn new(capabilities: TerminalCapabilities) -> Self {
581 Self {
582 capabilities,
583 inner: None,
584 }
585 }
586
587 #[must_use]
589 pub fn live(capabilities: TerminalCapabilities) -> Self {
590 Self {
591 capabilities,
592 inner: Some(Presenter::new(io::stdout(), capabilities)),
593 }
594 }
595}
596
597impl<W: Write + Send> TtyPresenter<W> {
598 pub fn with_writer(writer: W, capabilities: TerminalCapabilities) -> Self {
600 Self {
601 capabilities,
602 inner: Some(Presenter::new(writer, capabilities)),
603 }
604 }
605}
606
607impl<W: Write + Send> BackendPresenter for TtyPresenter<W> {
608 type Error = io::Error;
609
610 fn capabilities(&self) -> &TerminalCapabilities {
611 &self.capabilities
612 }
613
614 fn write_log(&mut self, _text: &str) -> Result<(), Self::Error> {
615 Ok(())
620 }
621
622 fn present_ui(
623 &mut self,
624 buf: &Buffer,
625 diff: Option<&BufferDiff>,
626 full_repaint_hint: bool,
627 ) -> Result<(), Self::Error> {
628 let Some(ref mut presenter) = self.inner else {
629 return Ok(());
630 };
631 if full_repaint_hint {
632 let full = BufferDiff::full(buf.width(), buf.height());
633 presenter.present(buf, &full)?;
634 } else if let Some(diff) = diff {
635 presenter.present(buf, diff)?;
636 } else {
637 let full = BufferDiff::full(buf.width(), buf.height());
638 presenter.present(buf, &full)?;
639 }
640 Ok(())
641 }
642}
643
644pub struct TtyBackend {
658 clock: TtyClock,
665 events: TtyEventSource,
666 presenter: TtyPresenter,
667 alt_screen_active: bool,
668 #[cfg(unix)]
669 raw_mode: Option<RawModeGuard>,
670}
671
672impl TtyBackend {
673 #[must_use]
675 pub fn new(width: u16, height: u16) -> Self {
676 Self {
677 clock: TtyClock::new(),
678 events: TtyEventSource::new(width, height),
679 presenter: TtyPresenter::new(TerminalCapabilities::detect()),
680 alt_screen_active: false,
681 #[cfg(unix)]
682 raw_mode: None,
683 }
684 }
685
686 #[must_use]
688 pub fn with_capabilities(width: u16, height: u16, capabilities: TerminalCapabilities) -> Self {
689 Self {
690 clock: TtyClock::new(),
691 events: TtyEventSource::new(width, height),
692 presenter: TtyPresenter::new(capabilities),
693 alt_screen_active: false,
694 #[cfg(unix)]
695 raw_mode: None,
696 }
697 }
698
699 #[cfg(unix)]
704 pub fn open(width: u16, height: u16, options: TtySessionOptions) -> io::Result<Self> {
705 let raw_mode = RawModeGuard::enter()?;
707
708 let mut stdout = io::stdout();
709 let mut alt_screen_active = false;
710
711 let mut events = TtyEventSource::live(width, height)?;
713 let setup: io::Result<()> = (|| {
714 if options.alternate_screen {
716 stdout.write_all(ALT_SCREEN_ENTER)?;
717 stdout.write_all(CLEAR_SCREEN)?;
718 stdout.write_all(CURSOR_HOME)?;
719 alt_screen_active = true;
720 }
721
722 TtyEventSource::write_feature_delta(
723 &BackendFeatures::default(),
724 &options.features,
725 &mut stdout,
726 )?;
727
728 stdout.flush()?;
729 Ok(())
730 })();
731
732 if let Err(err) = setup {
733 let _ =
735 write_cleanup_sequence(&options.features, options.alternate_screen, &mut stdout);
736 let _ = stdout.flush();
737 return Err(err);
738 }
739
740 events.features = options.features;
741
742 Ok(Self {
743 clock: TtyClock::new(),
744 events,
745 presenter: TtyPresenter::live(TerminalCapabilities::detect()),
746 alt_screen_active,
747 raw_mode: Some(raw_mode),
748 })
749 }
750
751 #[must_use]
753 pub fn is_live(&self) -> bool {
754 #[cfg(unix)]
755 {
756 self.raw_mode.is_some()
757 }
758 #[cfg(not(unix))]
759 {
760 false
761 }
762 }
763}
764
765impl Drop for TtyBackend {
766 fn drop(&mut self) {
767 #[cfg(unix)]
769 if self.raw_mode.is_some() {
770 let mut stdout = io::stdout();
771
772 let _ = stdout.write_all(SYNC_END);
774
775 let _ = self.events.disable_all(&mut stdout);
777
778 let _ = stdout.write_all(CURSOR_SHOW);
780
781 if self.alt_screen_active {
783 let _ = stdout.write_all(ALT_SCREEN_LEAVE);
784 self.alt_screen_active = false;
785 }
786
787 let _ = stdout.flush();
789
790 }
792 }
793}
794
795impl BackendEventSource for TtyBackend {
800 type Error = io::Error;
801
802 fn size(&self) -> Result<(u16, u16), io::Error> {
803 self.events.size()
804 }
805
806 fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
807 self.events.set_features(features)
808 }
809
810 fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
811 self.events.poll_event(timeout)
812 }
813
814 fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
815 self.events.read_event()
816 }
817}
818
819impl Backend for TtyBackend {
820 type Error = io::Error;
821 type Clock = TtyClock;
822 type Events = TtyEventSource;
823 type Presenter = TtyPresenter;
824
825 fn clock(&self) -> &Self::Clock {
826 &self.clock
827 }
828
829 fn events(&mut self) -> &mut Self::Events {
830 &mut self.events
831 }
832
833 fn presenter(&mut self) -> &mut Self::Presenter {
834 &mut self.presenter
835 }
836}
837
838pub fn write_cleanup_sequence(
845 features: &BackendFeatures,
846 alt_screen: bool,
847 writer: &mut impl Write,
848) -> io::Result<()> {
849 writer.write_all(SYNC_END)?;
850 if features.kitty_keyboard {
852 writer.write_all(KITTY_KEYBOARD_DISABLE)?;
853 }
854 if features.focus_events {
855 writer.write_all(FOCUS_DISABLE)?;
856 }
857 if features.bracketed_paste {
858 writer.write_all(BRACKETED_PASTE_DISABLE)?;
859 }
860 if features.mouse_capture {
861 writer.write_all(MOUSE_DISABLE)?;
862 }
863 writer.write_all(CURSOR_SHOW)?;
864 if alt_screen {
865 writer.write_all(ALT_SCREEN_LEAVE)?;
866 }
867 Ok(())
868}
869
870#[cfg(test)]
873mod tests {
874 use super::*;
875
876 #[test]
877 fn clock_is_monotonic() {
878 let clock = TtyClock::new();
879 let t1 = clock.now_mono();
880 std::hint::black_box(0..1000).for_each(|_| {});
881 let t2 = clock.now_mono();
882 assert!(t2 >= t1, "clock must be monotonic");
883 }
884
885 #[test]
886 fn event_source_reports_size() {
887 let src = TtyEventSource::new(80, 24);
888 let (w, h) = src.size().unwrap();
889 assert_eq!(w, 80);
890 assert_eq!(h, 24);
891 }
892
893 #[test]
894 fn event_source_set_features_headless() {
895 let mut src = TtyEventSource::new(80, 24);
896 let features = BackendFeatures {
897 mouse_capture: true,
898 bracketed_paste: true,
899 focus_events: false,
900 kitty_keyboard: false,
901 };
902 src.set_features(features).unwrap();
903 assert_eq!(src.features(), features);
904 }
905
906 #[test]
907 fn poll_returns_false_headless() {
908 let mut src = TtyEventSource::new(80, 24);
909 assert!(!src.poll_event(Duration::from_millis(0)).unwrap());
910 }
911
912 #[test]
913 fn read_returns_none_headless() {
914 let mut src = TtyEventSource::new(80, 24);
915 assert!(src.read_event().unwrap().is_none());
916 }
917
918 #[test]
919 fn push_resize_enqueues_event_and_updates_size() {
920 let mut src = TtyEventSource::new(80, 24);
921 src.push_resize(120, 40);
922 assert_eq!(src.size().unwrap(), (120, 40));
923 assert_eq!(
924 src.read_event().unwrap(),
925 Some(Event::Resize {
926 width: 120,
927 height: 40,
928 })
929 );
930 assert!(src.read_event().unwrap().is_none());
931 }
932
933 #[test]
934 fn push_resize_deduplicates_same_size() {
935 let mut src = TtyEventSource::new(80, 24);
936 src.push_resize(80, 24);
937 assert!(src.event_queue.is_empty(), "no event when size unchanged");
938 }
939
940 #[test]
941 fn push_resize_ignores_zero_dimensions() {
942 let mut src = TtyEventSource::new(80, 24);
943 src.push_resize(0, 24);
944 assert!(src.event_queue.is_empty());
945 src.push_resize(80, 0);
946 assert!(src.event_queue.is_empty());
947 src.push_resize(0, 0);
948 assert!(src.event_queue.is_empty());
949 }
950
951 #[test]
952 fn resize_storm_coalesces_and_no_panic() {
953 let mut src = TtyEventSource::new(80, 24);
954 for _ in 0..1000 {
956 src.push_resize(120, 40);
957 }
958 assert_eq!(src.event_queue.len(), 1);
960 assert_eq!(
961 src.event_queue.pop_front().unwrap(),
962 Event::Resize {
963 width: 120,
964 height: 40,
965 }
966 );
967 }
968
969 #[test]
970 fn resize_storm_varied_sizes_no_panic() {
971 let mut src = TtyEventSource::new(80, 24);
972 for i in 1..=500u16 {
974 src.push_resize(80 + i, 24 + (i % 50));
975 }
976 let mut prev_w = 80u16;
978 while let Some(Event::Resize { width, .. }) = src.event_queue.pop_front() {
979 assert!(
980 width > prev_w || width == prev_w + 1 || width != prev_w,
981 "events must be in push order"
982 );
983 prev_w = width;
984 }
985 }
986
987 #[cfg(unix)]
991 fn pipe_pair() -> (std::fs::File, std::os::unix::net::UnixStream) {
992 use std::os::unix::net::UnixStream;
993 let (a, b) = UnixStream::pair().unwrap();
994 let reader: std::fs::File = std::os::fd::OwnedFd::from(a).into();
996 (reader, b)
997 }
998
999 #[cfg(unix)]
1000 #[test]
1001 fn pipe_ascii_chars() {
1002 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1003 let (reader, mut writer) = pipe_pair();
1004 let mut src = TtyEventSource::from_reader(80, 24, reader);
1005 writer.write_all(b"abc").unwrap();
1006 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1007 let e1 = src.read_event().unwrap().unwrap();
1008 assert_eq!(
1009 e1,
1010 Event::Key(KeyEvent {
1011 code: KeyCode::Char('a'),
1012 modifiers: Modifiers::NONE,
1013 kind: KeyEventKind::Press,
1014 })
1015 );
1016 let e2 = src.read_event().unwrap().unwrap();
1017 assert_eq!(
1018 e2,
1019 Event::Key(KeyEvent {
1020 code: KeyCode::Char('b'),
1021 modifiers: Modifiers::NONE,
1022 kind: KeyEventKind::Press,
1023 })
1024 );
1025 let e3 = src.read_event().unwrap().unwrap();
1026 assert_eq!(
1027 e3,
1028 Event::Key(KeyEvent {
1029 code: KeyCode::Char('c'),
1030 modifiers: Modifiers::NONE,
1031 kind: KeyEventKind::Press,
1032 })
1033 );
1034 assert!(src.read_event().unwrap().is_none());
1036 }
1037
1038 #[cfg(unix)]
1039 #[test]
1040 fn pipe_arrow_keys() {
1041 use ftui_core::event::{KeyCode, KeyEvent};
1042 let (reader, mut writer) = pipe_pair();
1043 let mut src = TtyEventSource::from_reader(80, 24, reader);
1044 writer.write_all(b"\x1b[A\x1b[B\x1b[C\x1b[D").unwrap();
1046 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1047 let codes: Vec<KeyCode> = std::iter::from_fn(|| src.read_event().unwrap())
1048 .map(|e| match e {
1049 Event::Key(KeyEvent { code, .. }) => Ok(code),
1050 other => Err(other),
1051 })
1052 .collect::<Result<Vec<_>, _>>()
1053 .unwrap();
1054 assert_eq!(
1055 codes,
1056 vec![KeyCode::Up, KeyCode::Down, KeyCode::Right, KeyCode::Left]
1057 );
1058 }
1059
1060 #[cfg(unix)]
1061 #[test]
1062 fn pipe_ctrl_keys() {
1063 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1064 let (reader, mut writer) = pipe_pair();
1065 let mut src = TtyEventSource::from_reader(80, 24, reader);
1066 writer.write_all(&[0x01, 0x03]).unwrap();
1068 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1069 let e1 = src.read_event().unwrap().unwrap();
1070 assert_eq!(
1071 e1,
1072 Event::Key(KeyEvent {
1073 code: KeyCode::Char('a'),
1074 modifiers: Modifiers::CTRL,
1075 kind: KeyEventKind::Press,
1076 })
1077 );
1078 let e2 = src.read_event().unwrap().unwrap();
1079 assert_eq!(
1080 e2,
1081 Event::Key(KeyEvent {
1082 code: KeyCode::Char('c'),
1083 modifiers: Modifiers::CTRL,
1084 kind: KeyEventKind::Press,
1085 })
1086 );
1087 }
1088
1089 #[cfg(unix)]
1090 #[test]
1091 fn pipe_function_keys() {
1092 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1093 let (reader, mut writer) = pipe_pair();
1094 let mut src = TtyEventSource::from_reader(80, 24, reader);
1095 writer.write_all(b"\x1bOP\x1b[15~").unwrap();
1097 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1098 let e1 = src.read_event().unwrap().unwrap();
1099 assert_eq!(
1100 e1,
1101 Event::Key(KeyEvent {
1102 code: KeyCode::F(1),
1103 modifiers: Modifiers::NONE,
1104 kind: KeyEventKind::Press,
1105 })
1106 );
1107 let e2 = src.read_event().unwrap().unwrap();
1108 assert_eq!(
1109 e2,
1110 Event::Key(KeyEvent {
1111 code: KeyCode::F(5),
1112 modifiers: Modifiers::NONE,
1113 kind: KeyEventKind::Press,
1114 })
1115 );
1116 }
1117
1118 #[cfg(unix)]
1119 #[test]
1120 fn pipe_mouse_sgr_click() {
1121 use ftui_core::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind};
1122 let (reader, mut writer) = pipe_pair();
1123 let mut src = TtyEventSource::from_reader(80, 24, reader);
1124 writer.write_all(b"\x1b[<0;10;20M").unwrap();
1126 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1127 let e = src.read_event().unwrap().unwrap();
1128 assert_eq!(
1129 e,
1130 Event::Mouse(MouseEvent {
1131 kind: MouseEventKind::Down(MouseButton::Left),
1132 x: 9,
1133 y: 19,
1134 modifiers: Modifiers::NONE,
1135 })
1136 );
1137 }
1138
1139 #[cfg(unix)]
1140 #[test]
1141 fn pipe_focus_events() {
1142 let (reader, mut writer) = pipe_pair();
1143 let mut src = TtyEventSource::from_reader(80, 24, reader);
1144 writer.write_all(b"\x1b[I\x1b[O").unwrap();
1146 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1147 assert_eq!(src.read_event().unwrap().unwrap(), Event::Focus(true));
1148 assert_eq!(src.read_event().unwrap().unwrap(), Event::Focus(false));
1149 }
1150
1151 #[cfg(unix)]
1152 #[test]
1153 fn pipe_bracketed_paste() {
1154 use ftui_core::event::PasteEvent;
1155 let (reader, mut writer) = pipe_pair();
1156 let mut src = TtyEventSource::from_reader(80, 24, reader);
1157 writer.write_all(b"\x1b[200~hello world\x1b[201~").unwrap();
1158 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1159 let e = src.read_event().unwrap().unwrap();
1160 assert_eq!(
1161 e,
1162 Event::Paste(PasteEvent {
1163 text: "hello world".to_string(),
1164 bracketed: true,
1165 })
1166 );
1167 }
1168
1169 #[cfg(unix)]
1170 #[test]
1171 fn pipe_modified_arrow_key() {
1172 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1173 let (reader, mut writer) = pipe_pair();
1174 let mut src = TtyEventSource::from_reader(80, 24, reader);
1175 writer.write_all(b"\x1b[1;5A").unwrap();
1177 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1178 let e = src.read_event().unwrap().unwrap();
1179 assert_eq!(
1180 e,
1181 Event::Key(KeyEvent {
1182 code: KeyCode::Up,
1183 modifiers: Modifiers::CTRL,
1184 kind: KeyEventKind::Press,
1185 })
1186 );
1187 }
1188
1189 #[cfg(unix)]
1190 #[test]
1191 fn pipe_scroll_events() {
1192 use ftui_core::event::{Modifiers, MouseEvent, MouseEventKind};
1193 let (reader, mut writer) = pipe_pair();
1194 let mut src = TtyEventSource::from_reader(80, 24, reader);
1195 writer.write_all(b"\x1b[<64;5;5M").unwrap();
1197 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1198 let e = src.read_event().unwrap().unwrap();
1199 assert_eq!(
1200 e,
1201 Event::Mouse(MouseEvent {
1202 kind: MouseEventKind::ScrollUp,
1203 x: 4,
1204 y: 4,
1205 modifiers: Modifiers::NONE,
1206 })
1207 );
1208 }
1209
1210 #[cfg(unix)]
1211 #[test]
1212 fn poll_returns_buffered_events_immediately() {
1213 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1214 let (reader, mut writer) = pipe_pair();
1215 let mut src = TtyEventSource::from_reader(80, 24, reader);
1216 writer.write_all(b"xy").unwrap();
1218 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1219 let _ = src.read_event().unwrap().unwrap();
1221 assert!(src.poll_event(Duration::from_millis(0)).unwrap());
1223 let e = src.read_event().unwrap().unwrap();
1224 assert_eq!(
1225 e,
1226 Event::Key(KeyEvent {
1227 code: KeyCode::Char('y'),
1228 modifiers: Modifiers::NONE,
1229 kind: KeyEventKind::Press,
1230 })
1231 );
1232 }
1233
1234 #[cfg(unix)]
1235 #[test]
1236 fn pipe_large_ascii_burst_roundtrips() {
1237 use ftui_core::event::{KeyCode, KeyEvent};
1238
1239 let (reader, mut writer) = pipe_pair();
1240 let mut src = TtyEventSource::from_reader(80, 24, reader);
1241 let payload = vec![b'a'; 4 * 1024 * 1024];
1242 let expected_len = payload.len();
1243 let writer_thread = std::thread::spawn(move || writer.write_all(&payload));
1244
1245 let mut count = 0usize;
1246 let deadline = std::time::Instant::now() + Duration::from_secs(15);
1247 while count < expected_len {
1248 if !src.poll_event(Duration::from_millis(100)).unwrap() {
1249 assert!(
1250 std::time::Instant::now() < deadline,
1251 "timed out waiting for burst events: received {count} / {expected_len}"
1252 );
1253 continue;
1254 }
1255 while let Some(event) = src.read_event().unwrap() {
1256 match event {
1257 Event::Key(KeyEvent {
1258 code: KeyCode::Char('a'),
1259 ..
1260 }) => count += 1,
1261 other => panic!("unexpected event in ascii burst test: {other:?}"),
1262 }
1263 }
1264 }
1265 writer_thread.join().unwrap().unwrap();
1266
1267 assert_eq!(count, expected_len, "all bytes should decode to key events");
1268 }
1269
1270 #[cfg(unix)]
1273 #[test]
1274 fn truncated_csi_followed_by_valid_input() {
1275 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1276 let (reader, mut writer) = pipe_pair();
1277 let mut src = TtyEventSource::from_reader(80, 24, reader);
1278 writer.write_all(b"\x1b[").unwrap();
1283 let _ = src.poll_event(Duration::from_millis(50));
1285 writer.write_all(b"\x1b[Ax").unwrap();
1287 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1288 let mut events = Vec::new();
1290 while let Some(e) = src.read_event().unwrap() {
1291 events.push(e);
1292 }
1293 let has_up = events.iter().any(|e| {
1295 matches!(
1296 e,
1297 Event::Key(KeyEvent {
1298 code: KeyCode::Up,
1299 ..
1300 })
1301 )
1302 });
1303 let has_x = events.iter().any(|e| {
1304 matches!(
1305 e,
1306 Event::Key(KeyEvent {
1307 code: KeyCode::Char('x'),
1308 modifiers: Modifiers::NONE,
1309 kind: KeyEventKind::Press,
1310 })
1311 )
1312 });
1313 assert!(
1314 has_up,
1315 "should parse Up arrow after partial CSI: {events:?}"
1316 );
1317 assert!(has_x, "should parse 'x' after recovery: {events:?}");
1318 }
1319
1320 #[cfg(unix)]
1321 #[test]
1322 fn unknown_csi_sequence_does_not_block_parser() {
1323 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
1324 let (reader, mut writer) = pipe_pair();
1325 let mut src = TtyEventSource::from_reader(80, 24, reader);
1326 writer.write_all(b"\x1b[999~z").unwrap();
1329 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1330 let mut events = Vec::new();
1331 while let Some(e) = src.read_event().unwrap() {
1332 events.push(e);
1333 }
1334 let has_z = events.iter().any(|e| {
1335 matches!(
1336 e,
1337 Event::Key(KeyEvent {
1338 code: KeyCode::Char('z'),
1339 modifiers: Modifiers::NONE,
1340 kind: KeyEventKind::Press,
1341 })
1342 )
1343 });
1344 assert!(
1345 has_z,
1346 "valid key after unknown CSI must be parsed: {events:?}"
1347 );
1348 }
1349
1350 #[cfg(unix)]
1351 #[test]
1352 fn eof_on_pipe_does_not_panic() {
1353 let (reader, writer) = pipe_pair();
1354 let mut src = TtyEventSource::from_reader(80, 24, reader);
1355 drop(writer);
1357 let result = src.poll_event(Duration::from_millis(50));
1359 assert!(result.is_ok(), "poll_event after EOF should not error");
1360 let event = src.read_event().unwrap();
1362 assert!(event.is_none(), "read_event after EOF should be None");
1363 }
1364
1365 #[cfg(unix)]
1366 #[test]
1367 fn interleaved_invalid_and_valid_sequences() {
1368 use ftui_core::event::{KeyCode, KeyEvent};
1369 let (reader, mut writer) = pipe_pair();
1370 let mut src = TtyEventSource::from_reader(80, 24, reader);
1371 writer.write_all(b"\xC0a\x1b[999~b\x1b c").unwrap();
1374 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1375 let mut key_chars = Vec::new();
1376 while let Some(e) = src.read_event().unwrap() {
1377 if let Event::Key(KeyEvent {
1378 code: KeyCode::Char(ch),
1379 ..
1380 }) = e
1381 {
1382 key_chars.push(ch);
1383 }
1384 }
1385 assert!(
1388 key_chars.contains(&'a'),
1389 "should parse 'a' amid invalid input: {key_chars:?}"
1390 );
1391 assert!(
1392 key_chars.contains(&'b'),
1393 "should parse 'b' amid invalid input: {key_chars:?}"
1394 );
1395 assert!(
1396 key_chars.contains(&'c'),
1397 "should parse 'c' amid invalid input: {key_chars:?}"
1398 );
1399 }
1400
1401 #[cfg(unix)]
1402 #[test]
1403 fn split_escape_sequence_across_writes() {
1404 use ftui_core::event::{KeyCode, KeyEvent};
1405 let (reader, mut writer) = pipe_pair();
1406 let mut src = TtyEventSource::from_reader(80, 24, reader);
1407 writer.write_all(b"\x1b").unwrap();
1409 let _ = src.poll_event(Duration::from_millis(30));
1412 writer.write_all(b"[B").unwrap();
1414 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1415 let mut events = Vec::new();
1416 while let Some(e) = src.read_event().unwrap() {
1417 events.push(e);
1418 }
1419 let has_down = events.iter().any(|e| {
1420 matches!(
1421 e,
1422 Event::Key(KeyEvent {
1423 code: KeyCode::Down,
1424 ..
1425 })
1426 )
1427 });
1428 assert!(
1429 has_down,
1430 "Down arrow split across writes should be parsed: {events:?}"
1431 );
1432 }
1433
1434 #[cfg(unix)]
1435 #[test]
1436 fn poll_with_zero_timeout_returns_false_on_empty_pipe() {
1437 let (reader, _writer) = pipe_pair();
1438 let mut src = TtyEventSource::from_reader(80, 24, reader);
1439 let ready = src.poll_event(Duration::ZERO).unwrap();
1441 assert!(!ready, "empty pipe with zero timeout should not be ready");
1442 }
1443
1444 #[cfg(unix)]
1445 #[test]
1446 fn malformed_sgr_mouse_does_not_block() {
1447 use ftui_core::event::{KeyCode, KeyEvent};
1448 let (reader, mut writer) = pipe_pair();
1449 let mut src = TtyEventSource::from_reader(80, 24, reader);
1450 writer.write_all(b"\x1b[<M q").unwrap();
1452 assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1453 let mut events = Vec::new();
1454 while let Some(e) = src.read_event().unwrap() {
1455 events.push(e);
1456 }
1457 let has_q = events.iter().any(|e| {
1459 matches!(
1460 e,
1461 Event::Key(KeyEvent {
1462 code: KeyCode::Char('q'),
1463 ..
1464 })
1465 )
1466 });
1467 assert!(
1468 has_q,
1469 "should parse 'q' after malformed SGR mouse: {events:?}"
1470 );
1471 }
1472
1473 #[test]
1476 #[should_panic(expected = "buffer width must be > 0")]
1477 fn buffer_rejects_zero_width() {
1478 let _buf = Buffer::new(0, 5);
1479 }
1480
1481 #[test]
1482 #[should_panic(expected = "buffer height must be > 0")]
1483 fn buffer_rejects_zero_height() {
1484 let _buf = Buffer::new(5, 0);
1485 }
1486
1487 #[test]
1488 fn presenter_1x1_buffer_does_not_panic() {
1489 let caps = TerminalCapabilities::detect();
1490 let mut presenter = TtyPresenter::with_writer(Vec::<u8>::new(), caps);
1491 let buf = Buffer::new(1, 1);
1492 let diff = BufferDiff::full(1, 1);
1493 presenter.present_ui(&buf, Some(&diff), false).unwrap();
1494 let bytes = presenter.inner.unwrap().into_inner().unwrap();
1496 assert!(!bytes.is_empty(), "1x1 buffer should produce output");
1497 }
1498
1499 #[test]
1500 fn presenter_capabilities() {
1501 let caps = TerminalCapabilities::detect();
1502 let presenter = TtyPresenter::new(caps);
1503 let _c = presenter.capabilities();
1504 }
1505
1506 #[test]
1509 fn headless_presenter_present_ui_is_noop() {
1510 let caps = TerminalCapabilities::detect();
1511 let mut presenter = TtyPresenter::new(caps);
1512 let buf = Buffer::new(10, 5);
1513 let diff = BufferDiff::full(10, 5);
1514 presenter.present_ui(&buf, Some(&diff), false).unwrap();
1516 presenter.present_ui(&buf, None, false).unwrap();
1517 presenter.present_ui(&buf, Some(&diff), true).unwrap();
1518 }
1519
1520 #[test]
1521 fn live_presenter_emits_ansi() {
1522 use ftui_render::cell::{Cell, CellAttrs, CellContent, PackedRgba, StyleFlags};
1523
1524 let caps = TerminalCapabilities::detect();
1525 let output = Vec::<u8>::new();
1526 let mut presenter = TtyPresenter::with_writer(output, caps);
1527
1528 let mut buf = Buffer::new(10, 2);
1529 let cell = Cell {
1531 content: CellContent::from_char('X'),
1532 fg: PackedRgba::RED,
1533 bg: PackedRgba::BLACK,
1534 attrs: CellAttrs::new(StyleFlags::BOLD, 0),
1535 };
1536 buf.set(0, 0, cell);
1537
1538 let diff = BufferDiff::full(10, 2);
1539 presenter.present_ui(&buf, Some(&diff), false).unwrap();
1540
1541 let inner = presenter.inner.unwrap();
1545 let bytes = inner.into_inner().unwrap();
1546 assert!(!bytes.is_empty(), "live presenter should emit output");
1547 assert!(
1548 bytes.windows(2).any(|w| w == b"\x1b["),
1549 "output should contain CSI escape sequences"
1550 );
1551 }
1552
1553 #[test]
1554 fn full_repaint_when_diff_is_none() {
1555 use ftui_render::cell::Cell;
1556
1557 let caps = TerminalCapabilities::detect();
1558 let output = Vec::<u8>::new();
1559 let mut presenter = TtyPresenter::with_writer(output, caps);
1560
1561 let mut buf = Buffer::new(5, 1);
1562 for x in 0..5 {
1563 buf.set(x, 0, Cell::from_char(b"ABCDE"[x as usize] as char));
1564 }
1565
1566 presenter.present_ui(&buf, None, false).unwrap();
1568
1569 let bytes = presenter.inner.unwrap().into_inner().unwrap();
1570 let output_str = String::from_utf8_lossy(&bytes);
1572 for ch in ['A', 'B', 'C', 'D', 'E'] {
1573 assert!(
1574 output_str.contains(ch),
1575 "full repaint should emit '{ch}', got: {output_str}"
1576 );
1577 }
1578 }
1579
1580 #[test]
1581 fn diff_based_partial_update() {
1582 use ftui_render::cell::Cell;
1583
1584 let caps = TerminalCapabilities::detect();
1585 let output = Vec::<u8>::new();
1586 let mut presenter = TtyPresenter::with_writer(output, caps);
1587
1588 let mut old = Buffer::new(5, 1);
1589 for x in 0..5 {
1590 old.set(x, 0, Cell::from_char(b"ABCDE"[x as usize] as char));
1591 }
1592 let mut new = old.clone();
1593 new.set(2, 0, Cell::from_char('Z'));
1594 let diff = BufferDiff::compute(&old, &new);
1595 presenter.present_ui(&new, Some(&diff), false).unwrap();
1596
1597 let bytes = presenter.inner.unwrap().into_inner().unwrap();
1598 let output_str = String::from_utf8_lossy(&bytes);
1599 assert!(
1601 output_str.contains('Z'),
1602 "diff-based update should emit changed cell 'Z'"
1603 );
1604 assert!(
1605 !output_str.contains('A'),
1606 "diff-based update should not emit unchanged cell 'A'"
1607 );
1608 }
1609
1610 #[test]
1611 fn write_log_headless_does_not_panic() {
1612 let caps = TerminalCapabilities::detect();
1613 let mut presenter = TtyPresenter::new(caps);
1614 presenter.write_log("headless log test").unwrap();
1615 }
1616
1617 #[test]
1618 fn write_log_live_does_not_corrupt_ui_stream() {
1619 let caps = TerminalCapabilities::detect();
1620 let mut presenter = TtyPresenter::with_writer(Vec::<u8>::new(), caps);
1621 presenter.write_log("live log test").unwrap();
1622 let bytes = presenter.inner.unwrap().into_inner().unwrap();
1623 assert!(bytes.is_empty(), "write_log must not emit UI bytes");
1624 }
1625
1626 #[test]
1627 fn backend_headless_construction() {
1628 let backend = TtyBackend::new(120, 40);
1629 assert!(!backend.is_live());
1630 let (w, h) = backend.events.size().unwrap();
1631 assert_eq!(w, 120);
1632 assert_eq!(h, 40);
1633 }
1634
1635 #[test]
1636 fn backend_trait_impl() {
1637 let mut backend = TtyBackend::new(80, 24);
1638 let _t = backend.clock().now_mono();
1639 let (w, h) = backend.events().size().unwrap();
1640 assert_eq!((w, h), (80, 24));
1641 let _c = backend.presenter().capabilities();
1642 }
1643
1644 #[test]
1645 fn feature_delta_writes_enable_sequences() {
1646 let current = BackendFeatures::default();
1647 let new = BackendFeatures {
1648 mouse_capture: true,
1649 bracketed_paste: true,
1650 focus_events: true,
1651 kitty_keyboard: true,
1652 };
1653 let mut buf = Vec::new();
1654 TtyEventSource::write_feature_delta(¤t, &new, &mut buf).unwrap();
1655 assert!(
1656 buf.windows(MOUSE_ENABLE.len()).any(|w| w == MOUSE_ENABLE),
1657 "expected mouse enable sequence"
1658 );
1659 assert!(
1660 buf.windows(BRACKETED_PASTE_ENABLE.len())
1661 .any(|w| w == BRACKETED_PASTE_ENABLE),
1662 "expected bracketed paste enable"
1663 );
1664 assert!(
1665 buf.windows(FOCUS_ENABLE.len()).any(|w| w == FOCUS_ENABLE),
1666 "expected focus enable"
1667 );
1668 assert!(
1669 buf.windows(KITTY_KEYBOARD_ENABLE.len())
1670 .any(|w| w == KITTY_KEYBOARD_ENABLE),
1671 "expected kitty keyboard enable"
1672 );
1673 }
1674
1675 #[test]
1676 fn feature_delta_writes_disable_sequences() {
1677 let current = BackendFeatures {
1678 mouse_capture: true,
1679 bracketed_paste: true,
1680 focus_events: true,
1681 kitty_keyboard: true,
1682 };
1683 let new = BackendFeatures::default();
1684 let mut buf = Vec::new();
1685 TtyEventSource::write_feature_delta(¤t, &new, &mut buf).unwrap();
1686 assert!(buf.windows(MOUSE_DISABLE.len()).any(|w| w == MOUSE_DISABLE));
1687 assert!(
1688 buf.windows(BRACKETED_PASTE_DISABLE.len())
1689 .any(|w| w == BRACKETED_PASTE_DISABLE)
1690 );
1691 assert!(buf.windows(FOCUS_DISABLE.len()).any(|w| w == FOCUS_DISABLE));
1692 assert!(
1693 buf.windows(KITTY_KEYBOARD_DISABLE.len())
1694 .any(|w| w == KITTY_KEYBOARD_DISABLE)
1695 );
1696 }
1697
1698 #[test]
1699 fn feature_delta_noop_when_unchanged() {
1700 let features = BackendFeatures {
1701 mouse_capture: true,
1702 bracketed_paste: false,
1703 focus_events: true,
1704 kitty_keyboard: false,
1705 };
1706 let mut buf = Vec::new();
1707 TtyEventSource::write_feature_delta(&features, &features, &mut buf).unwrap();
1708 assert!(buf.is_empty(), "no output expected when features unchanged");
1709 }
1710
1711 #[test]
1712 fn cleanup_sequence_contains_all_disable() {
1713 let features = BackendFeatures {
1714 mouse_capture: true,
1715 bracketed_paste: true,
1716 focus_events: true,
1717 kitty_keyboard: true,
1718 };
1719 let mut buf = Vec::new();
1720 write_cleanup_sequence(&features, true, &mut buf).unwrap();
1721
1722 assert!(buf.windows(SYNC_END.len()).any(|w| w == SYNC_END));
1724 assert!(buf.windows(MOUSE_DISABLE.len()).any(|w| w == MOUSE_DISABLE));
1725 assert!(
1726 buf.windows(BRACKETED_PASTE_DISABLE.len())
1727 .any(|w| w == BRACKETED_PASTE_DISABLE)
1728 );
1729 assert!(buf.windows(FOCUS_DISABLE.len()).any(|w| w == FOCUS_DISABLE));
1730 assert!(
1731 buf.windows(KITTY_KEYBOARD_DISABLE.len())
1732 .any(|w| w == KITTY_KEYBOARD_DISABLE)
1733 );
1734 assert!(buf.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW));
1735 assert!(
1736 buf.windows(ALT_SCREEN_LEAVE.len())
1737 .any(|w| w == ALT_SCREEN_LEAVE)
1738 );
1739 }
1740
1741 #[test]
1742 fn cleanup_sequence_ordering() {
1743 let features = BackendFeatures {
1744 mouse_capture: true,
1745 bracketed_paste: true,
1746 focus_events: true,
1747 kitty_keyboard: true,
1748 };
1749 let mut buf = Vec::new();
1750 write_cleanup_sequence(&features, true, &mut buf).unwrap();
1751
1752 let sync_pos = buf
1754 .windows(SYNC_END.len())
1755 .position(|w| w == SYNC_END)
1756 .expect("sync_end present");
1757 let cursor_pos = buf
1758 .windows(CURSOR_SHOW.len())
1759 .position(|w| w == CURSOR_SHOW)
1760 .expect("cursor_show present");
1761 let alt_pos = buf
1762 .windows(ALT_SCREEN_LEAVE.len())
1763 .position(|w| w == ALT_SCREEN_LEAVE)
1764 .expect("alt_screen_leave present");
1765
1766 assert!(
1767 sync_pos < cursor_pos,
1768 "sync_end must come before cursor_show"
1769 );
1770 assert!(
1771 cursor_pos < alt_pos,
1772 "cursor_show must come before alt_screen_leave"
1773 );
1774 }
1775
1776 #[test]
1777 fn disable_all_resets_feature_state() {
1778 let mut src = TtyEventSource::new(80, 24);
1779 src.features = BackendFeatures {
1780 mouse_capture: true,
1781 bracketed_paste: true,
1782 focus_events: true,
1783 kitty_keyboard: true,
1784 };
1785 let mut buf = Vec::new();
1786 src.disable_all(&mut buf).unwrap();
1787 assert_eq!(src.features(), BackendFeatures::default());
1788 assert!(!buf.is_empty());
1790 }
1791
1792 #[cfg(unix)]
1795 mod pty_tests {
1796 use super::*;
1797 use nix::pty::openpty;
1798 use nix::sys::termios::{self, LocalFlags};
1799 use std::io::Read;
1800
1801 fn pty_pair() -> (std::fs::File, std::fs::File) {
1802 let result = openpty(None, None).expect("openpty failed");
1803 (
1804 std::fs::File::from(result.master),
1805 std::fs::File::from(result.slave),
1806 )
1807 }
1808
1809 #[test]
1810 fn raw_mode_entered_and_restored_on_drop() {
1811 let (_master, slave) = pty_pair();
1812 let slave_dup = slave.try_clone().unwrap();
1813
1814 let before = termios::tcgetattr(&slave_dup).unwrap();
1816 assert!(
1817 before.local_flags.contains(LocalFlags::ECHO),
1818 "default termios should have ECHO"
1819 );
1820 assert!(
1821 before.local_flags.contains(LocalFlags::ICANON),
1822 "default termios should have ICANON"
1823 );
1824
1825 {
1826 let _guard = RawModeGuard::enter_on(slave).unwrap();
1827
1828 let during = termios::tcgetattr(&slave_dup).unwrap();
1830 assert!(
1831 !during.local_flags.contains(LocalFlags::ECHO),
1832 "raw mode should clear ECHO"
1833 );
1834 assert!(
1835 !during.local_flags.contains(LocalFlags::ICANON),
1836 "raw mode should clear ICANON"
1837 );
1838 }
1839
1840 let after = termios::tcgetattr(&slave_dup).unwrap();
1842 assert!(
1843 after.local_flags.contains(LocalFlags::ECHO),
1844 "should restore ECHO after drop"
1845 );
1846 assert!(
1847 after.local_flags.contains(LocalFlags::ICANON),
1848 "should restore ICANON after drop"
1849 );
1850 }
1851
1852 #[test]
1853 fn panic_restores_termios() {
1854 let (_master, slave) = pty_pair();
1855 let slave_dup = slave.try_clone().unwrap();
1856
1857 let handle = std::thread::spawn(move || {
1859 let _guard = RawModeGuard::enter_on(slave).unwrap();
1860 std::panic::panic_any("intentional panic for testing raw mode cleanup");
1861 });
1862
1863 assert!(handle.join().is_err(), "thread should have panicked");
1864
1865 let after = termios::tcgetattr(&slave_dup).unwrap();
1867 assert!(
1868 after.local_flags.contains(LocalFlags::ECHO),
1869 "ECHO should be restored after panic"
1870 );
1871 assert!(
1872 after.local_flags.contains(LocalFlags::ICANON),
1873 "ICANON should be restored after panic"
1874 );
1875 }
1876
1877 #[test]
1878 fn backend_drop_writes_cleanup_sequences() {
1879 let (mut master, slave) = pty_pair();
1880 let slave_dup = slave.try_clone().unwrap();
1881
1882 {
1883 let _guard = RawModeGuard::enter_on(slave).unwrap();
1884
1885 let mut stdout_buf = Vec::new();
1887 let all_on = BackendFeatures {
1888 mouse_capture: true,
1889 bracketed_paste: true,
1890 focus_events: true,
1891 kitty_keyboard: true,
1892 };
1893 TtyEventSource::write_feature_delta(
1894 &BackendFeatures::default(),
1895 &all_on,
1896 &mut stdout_buf,
1897 )
1898 .unwrap();
1899 write_cleanup_sequence(&all_on, true, &mut stdout_buf).unwrap();
1901
1902 use std::io::Write;
1904 let mut slave_writer = slave_dup.try_clone().unwrap();
1905 slave_writer.write_all(&stdout_buf).unwrap();
1906 slave_writer.flush().unwrap();
1907 }
1908
1909 let mut buf = vec![0u8; 2048];
1911 let n = master.read(&mut buf).unwrap();
1912 let output = &buf[..n];
1913
1914 assert!(
1915 output.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW),
1916 "cleanup must show cursor"
1917 );
1918 assert!(
1919 output
1920 .windows(MOUSE_DISABLE.len())
1921 .any(|w| w == MOUSE_DISABLE),
1922 "cleanup must disable mouse"
1923 );
1924 assert!(
1925 output
1926 .windows(ALT_SCREEN_LEAVE.len())
1927 .any(|w| w == ALT_SCREEN_LEAVE),
1928 "cleanup must leave alt-screen"
1929 );
1930 }
1931
1932 fn write_to_slave_and_read_master(
1934 master: &mut std::fs::File,
1935 slave: &std::fs::File,
1936 data: &[u8],
1937 ) -> Vec<u8> {
1938 use std::io::Write;
1939 let mut writer = slave.try_clone().unwrap();
1940 writer.write_all(data).unwrap();
1941 writer.flush().unwrap();
1942 let mut buf = vec![0u8; 4096];
1943 let n = master.read(&mut buf).unwrap();
1944 buf.truncate(n);
1945 buf
1946 }
1947
1948 #[test]
1949 fn cursor_hide_on_enter_show_on_drop() {
1950 let (mut master, slave) = pty_pair();
1951 let slave_dup = slave.try_clone().unwrap();
1952
1953 {
1955 let _guard = RawModeGuard::enter_on(slave).unwrap();
1956 let output = write_to_slave_and_read_master(&mut master, &slave_dup, CURSOR_HIDE);
1957 assert!(
1958 output.windows(CURSOR_HIDE.len()).any(|w| w == CURSOR_HIDE),
1959 "cursor-hide should be written on session enter"
1960 );
1961
1962 let output = write_to_slave_and_read_master(&mut master, &slave_dup, CURSOR_SHOW);
1964 assert!(
1965 output.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW),
1966 "cursor-show should be written on session exit"
1967 );
1968 }
1969 }
1970
1971 #[test]
1972 fn alt_screen_enter_and_leave_via_pty() {
1973 let (mut master, slave) = pty_pair();
1974 let slave_dup = slave.try_clone().unwrap();
1975
1976 {
1977 let _guard = RawModeGuard::enter_on(slave).unwrap();
1978
1979 let output =
1981 write_to_slave_and_read_master(&mut master, &slave_dup, ALT_SCREEN_ENTER);
1982 assert!(
1983 output
1984 .windows(ALT_SCREEN_ENTER.len())
1985 .any(|w| w == ALT_SCREEN_ENTER),
1986 "alt-screen enter should pass through PTY"
1987 );
1988
1989 let output =
1991 write_to_slave_and_read_master(&mut master, &slave_dup, ALT_SCREEN_LEAVE);
1992 assert!(
1993 output
1994 .windows(ALT_SCREEN_LEAVE.len())
1995 .any(|w| w == ALT_SCREEN_LEAVE),
1996 "alt-screen leave should pass through PTY"
1997 );
1998 }
1999 }
2000
2001 #[test]
2002 fn per_feature_disable_on_drop() {
2003 let (mut master, slave) = pty_pair();
2004 let slave_dup = slave.try_clone().unwrap();
2005
2006 {
2007 let _guard = RawModeGuard::enter_on(slave).unwrap();
2008
2009 let all_on = BackendFeatures {
2011 mouse_capture: true,
2012 bracketed_paste: true,
2013 focus_events: true,
2014 kitty_keyboard: true,
2015 };
2016 let mut cleanup = Vec::new();
2017 write_cleanup_sequence(&all_on, false, &mut cleanup).unwrap();
2018
2019 let output = write_to_slave_and_read_master(&mut master, &slave_dup, &cleanup);
2020
2021 assert!(
2023 output
2024 .windows(MOUSE_DISABLE.len())
2025 .any(|w| w == MOUSE_DISABLE),
2026 "mouse must be disabled on drop"
2027 );
2028 assert!(
2029 output
2030 .windows(BRACKETED_PASTE_DISABLE.len())
2031 .any(|w| w == BRACKETED_PASTE_DISABLE),
2032 "bracketed paste must be disabled on drop"
2033 );
2034 assert!(
2035 output
2036 .windows(FOCUS_DISABLE.len())
2037 .any(|w| w == FOCUS_DISABLE),
2038 "focus events must be disabled on drop"
2039 );
2040 assert!(
2041 output
2042 .windows(KITTY_KEYBOARD_DISABLE.len())
2043 .any(|w| w == KITTY_KEYBOARD_DISABLE),
2044 "kitty keyboard must be disabled on drop"
2045 );
2046 assert!(
2047 output.windows(CURSOR_SHOW.len()).any(|w| w == CURSOR_SHOW),
2048 "cursor must be shown on drop"
2049 );
2050 }
2051 }
2052
2053 #[test]
2054 fn panic_with_features_restores_termios() {
2055 let (_master, slave) = pty_pair();
2056 let slave_dup = slave.try_clone().unwrap();
2057
2058 let handle = std::thread::spawn(move || {
2059 let _guard = RawModeGuard::enter_on(slave).unwrap();
2060 std::panic::panic_any("panic with features enabled");
2064 });
2065
2066 assert!(handle.join().is_err());
2067
2068 let after = termios::tcgetattr(&slave_dup).unwrap();
2069 assert!(
2070 after.local_flags.contains(LocalFlags::ECHO),
2071 "ECHO restored after panic with features"
2072 );
2073 assert!(
2074 after.local_flags.contains(LocalFlags::ICANON),
2075 "ICANON restored after panic with features"
2076 );
2077 }
2078
2079 #[test]
2080 fn repeated_raw_mode_cycles_no_leak() {
2081 let (_master, slave) = pty_pair();
2082 let slave_dup = slave.try_clone().unwrap();
2083
2084 for _ in 0..5 {
2086 let s = slave_dup.try_clone().unwrap();
2087 let guard = RawModeGuard::enter_on(s).unwrap();
2088
2089 let during = termios::tcgetattr(&slave_dup).unwrap();
2091 assert!(!during.local_flags.contains(LocalFlags::ECHO));
2092
2093 drop(guard);
2094
2095 let after = termios::tcgetattr(&slave_dup).unwrap();
2097 assert!(
2098 after.local_flags.contains(LocalFlags::ECHO),
2099 "ECHO must be restored each cycle"
2100 );
2101 }
2102 }
2103
2104 #[test]
2105 fn cleanup_ordering_via_pty() {
2106 let (mut master, slave) = pty_pair();
2107 let slave_dup = slave.try_clone().unwrap();
2108
2109 {
2110 let _guard = RawModeGuard::enter_on(slave).unwrap();
2111
2112 let features = BackendFeatures {
2114 mouse_capture: true,
2115 bracketed_paste: true,
2116 focus_events: true,
2117 kitty_keyboard: true,
2118 };
2119 let mut seq = Vec::new();
2120 write_cleanup_sequence(&features, true, &mut seq).unwrap();
2121
2122 let output = write_to_slave_and_read_master(&mut master, &slave_dup, &seq);
2123
2124 let sync_pos = output
2126 .windows(SYNC_END.len())
2127 .position(|w| w == SYNC_END)
2128 .expect("sync_end present");
2129 let cursor_pos = output
2130 .windows(CURSOR_SHOW.len())
2131 .position(|w| w == CURSOR_SHOW)
2132 .expect("cursor_show present");
2133 let alt_pos = output
2134 .windows(ALT_SCREEN_LEAVE.len())
2135 .position(|w| w == ALT_SCREEN_LEAVE)
2136 .expect("alt_screen_leave present");
2137
2138 assert!(
2139 sync_pos < cursor_pos,
2140 "sync_end ({sync_pos}) must precede cursor_show ({cursor_pos})"
2141 );
2142 assert!(
2143 cursor_pos < alt_pos,
2144 "cursor_show ({cursor_pos}) must precede alt_screen_leave ({alt_pos})"
2145 );
2146 }
2147 }
2148 }
2149}