Skip to main content

ftui_tty/
lib.rs

1#![forbid(unsafe_code)]
2//! Native Unix terminal backend for FrankenTUI.
3//!
4//! This crate implements the `ftui-backend` traits for native Unix/macOS terminals.
5//! It replaces Crossterm as the terminal I/O layer (Unix-first; Windows deferred).
6//!
7//! ## Escape Sequence Reference
8//!
9//! | Feature           | Enable                    | Disable                   |
10//! |-------------------|---------------------------|---------------------------|
11//! | Alternate screen  | `CSI ? 1049 h`            | `CSI ? 1049 l`            |
12//! | Mouse (SGR)       | `CSI ? 1000;1002;1006 h`  | `CSI ? 1000;1002;1006 l`  |
13//! | Bracketed paste   | `CSI ? 2004 h`            | `CSI ? 2004 l`            |
14//! | Focus events      | `CSI ? 1004 h`            | `CSI ? 1004 l`            |
15//! | Kitty keyboard    | `CSI > 15 u`              | `CSI < u`                 |
16//! | Cursor show/hide  | `CSI ? 25 h`              | `CSI ? 25 l`              |
17//! | Sync output       | `CSI ? 2026 h`            | `CSI ? 2026 l`            |
18
19use 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
37// ── Escape Sequences ─────────────────────────────────────────────────────
38
39const 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// ── Raw Mode Guard ───────────────────────────────────────────────────────
66
67/// RAII guard that saves the original termios and restores it on drop.
68///
69/// This is the foundation for panic-safe terminal cleanup: even if the
70/// application panics, the Drop impl runs (unless `panic = "abort"`) and
71/// the terminal returns to its original state.
72///
73/// The guard opens `/dev/tty` to get an owned fd that is valid for the
74/// lifetime of the guard, avoiding unsafe `BorrowedFd` construction.
75#[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    /// Enter raw mode on the controlling terminal, returning a guard that
84    /// restores the original termios on drop.
85    pub fn enter() -> io::Result<Self> {
86        let tty = std::fs::File::open("/dev/tty")?;
87        Self::enter_on(tty)
88    }
89
90    /// Enter raw mode on a specific terminal file (e.g., a PTY slave for testing).
91    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        // Best-effort restore — ignore errors during cleanup.
110        let _ = nix::sys::termios::tcsetattr(
111            &self.tty,
112            nix::sys::termios::SetArg::TCSAFLUSH,
113            &self.original_termios,
114        );
115    }
116}
117
118// ── Session Options ──────────────────────────────────────────────────────
119
120/// Configuration for opening a terminal session.
121#[derive(Debug, Clone, Default)]
122pub struct TtySessionOptions {
123    /// Enter the alternate screen buffer on open.
124    pub alternate_screen: bool,
125    /// Initial feature toggles to enable.
126    pub features: BackendFeatures,
127}
128
129// ── Clock ────────────────────────────────────────────────────────────────
130
131/// Monotonic clock backed by `std::time::Instant`.
132pub 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// ── Event Source ──────────────────────────────────────────────────────────
158
159// Resize notifications are produced via SIGWINCH on Unix.
160//
161// We use a dedicated signal thread to avoid unsafe `sigaction` calls in-tree
162// (unsafe is forbidden) while still delivering low-latency resize events.
163#[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                // Coalesce storms: a single pending notification is enough since we
178                // query the authoritative size via ioctl when generating the Event.
179                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
200/// Native Unix event source (raw terminal bytes → `Event`).
201///
202/// Manages terminal feature toggles by emitting the appropriate escape
203/// sequences. Reads raw bytes from the tty fd, feeds them through
204/// `InputParser`, and serves parsed events via `poll_event`/`read_event`.
205pub struct TtyEventSource {
206    features: BackendFeatures,
207    width: u16,
208    height: u16,
209    /// When true, escape sequences are actually written to stdout.
210    /// False in test/headless mode.
211    live: bool,
212    /// Resize notifications (SIGWINCH) are delivered through this channel.
213    #[cfg(unix)]
214    resize_rx: Option<mpsc::Receiver<()>>,
215    /// Owns the SIGWINCH handler thread (kept alive by this field).
216    #[cfg(unix)]
217    _resize_guard: Option<ResizeSignalGuard>,
218    /// Parser state machine: decodes terminal byte sequences into Events.
219    parser: InputParser,
220    /// Buffered events from the most recent parse.
221    event_queue: VecDeque<Event>,
222    /// Tty file handle for reading input (None in headless mode).
223    tty_reader: Option<std::fs::File>,
224    /// True when tty_reader is configured as nonblocking and may be drained in a loop.
225    reader_nonblocking: bool,
226}
227
228impl TtyEventSource {
229    /// Create an event source in headless mode (no escape sequence output, no I/O).
230    #[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    /// Create an event source in live mode (reads from /dev/tty, writes
249    /// escape sequences to stdout).
250    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    /// Create an event source that reads from an arbitrary file descriptor.
290    ///
291    /// Escape sequences are NOT written to stdout (headless feature toggle
292    /// behavior). This is primarily useful for testing with pipes.
293    #[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    /// Current feature state.
331    #[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        // Drain all pending SIGWINCH notifications, coalescing into a single
370        // resize query (the authoritative size comes from ioctl, not the signal).
371        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    /// Read available bytes from the tty reader and feed them to the parser.
386    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    /// Poll the tty fd for available data using `poll(2)`.
415    #[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    /// Stub for non-Unix platforms.
440    #[cfg(not(unix))]
441    fn poll_tty(&mut self, _timeout: Duration) -> io::Result<bool> {
442        Ok(false)
443    }
444
445    /// Write the escape sequences needed to transition from current to new features.
446    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    /// Disable all active features, writing escape sequences to `writer`.
483    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 we already have buffered events, return immediately.
517        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            // `poll(2)` won't reliably wake on SIGWINCH (signal handlers are installed
524            // with SA_RESTART). Time-slice to bound resize latency without busy looping.
525            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        // Opportunistically drain any newly-arrived bytes when the reader is nonblocking.
553        //
554        // This reduces poll(2) syscalls in bursty workloads by allowing the consumer's
555        // `while let Some(e) = read_event()` drain loop to pick up additional input
556        // without requiring another `poll_event()` round-trip.
557        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
566// ── Presenter ────────────────────────────────────────────────────────────
567
568/// Native ANSI presenter (Buffer → escape sequences → stdout).
569///
570/// Wraps `ftui_render::presenter::Presenter<W>` for real ANSI output.
571/// In headless mode (`inner = None`), all operations are no-ops.
572pub struct TtyPresenter<W: Write + Send = io::Stdout> {
573    capabilities: TerminalCapabilities,
574    inner: Option<Presenter<W>>,
575}
576
577impl TtyPresenter {
578    /// Create a headless presenter (no output). Used for tests and headless backends.
579    #[must_use]
580    pub fn new(capabilities: TerminalCapabilities) -> Self {
581        Self {
582            capabilities,
583            inner: None,
584        }
585    }
586
587    /// Create a live presenter that writes ANSI escape sequences to stdout.
588    #[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    /// Create a presenter that writes to an arbitrary `Write` sink.
599    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        // The runtime's terminal path routes logs through `TerminalWriter`, which
616        // positions output in the inline scrollback region safely. Emitting from
617        // here risks interleaving with UI ANSI output on the same terminal stream.
618        // Until this backend owns a dedicated safe log channel, keep this a no-op.
619        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
644// ── Backend ──────────────────────────────────────────────────────────────
645
646/// Native Unix terminal backend.
647///
648/// Combines `TtyClock`, `TtyEventSource`, and `TtyPresenter` into a single
649/// `Backend` implementation that the ftui runtime can drive.
650///
651/// When created with [`TtyBackend::open`], the backend enters raw mode and
652/// manages the terminal lifecycle via RAII. On drop (including panics),
653/// all features are disabled, the cursor is shown, the alt screen is exited,
654/// and raw mode is restored — in that order.
655///
656/// When created with [`TtyBackend::new`] (headless), no terminal I/O occurs.
657pub struct TtyBackend {
658    // Fields are ordered for correct drop sequence:
659    // 1. clock (no cleanup needed)
660    // 2. events (feature state tracking)
661    // 3. presenter (BufWriter flush on drop; benign — present() always flushes)
662    // 4. alt_screen_active (tracked for cleanup)
663    // 5. raw_mode — MUST be last: termios is restored after escape sequences
664    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    /// Create a headless backend (no terminal I/O). Useful for testing.
674    #[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    /// Create a headless backend with explicit capabilities.
687    #[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    /// Open a live terminal session: enter raw mode, enable requested features.
700    ///
701    /// The terminal is fully restored on drop (even during panics, unless
702    /// `panic = "abort"`).
703    #[cfg(unix)]
704    pub fn open(width: u16, height: u16, options: TtySessionOptions) -> io::Result<Self> {
705        // Enter raw mode first — if this fails, nothing to clean up.
706        let raw_mode = RawModeGuard::enter()?;
707
708        let mut stdout = io::stdout();
709        let mut alt_screen_active = false;
710
711        // Enable initial features.
712        let mut events = TtyEventSource::live(width, height)?;
713        let setup: io::Result<()> = (|| {
714            // Enter alt screen if requested.
715            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            // Best-effort cleanup: we may have partially enabled features or entered alt screen.
734            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    /// Whether this backend has an active terminal session (raw mode).
752    #[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        // Only run cleanup if we have an active session.
768        #[cfg(unix)]
769        if self.raw_mode.is_some() {
770            let mut stdout = io::stdout();
771
772            // End any in-progress synchronized output.
773            let _ = stdout.write_all(SYNC_END);
774
775            // Disable features in reverse order of typical enable.
776            let _ = self.events.disable_all(&mut stdout);
777
778            // Always show cursor.
779            let _ = stdout.write_all(CURSOR_SHOW);
780
781            // Leave alt screen.
782            if self.alt_screen_active {
783                let _ = stdout.write_all(ALT_SCREEN_LEAVE);
784                self.alt_screen_active = false;
785            }
786
787            // Flush everything before RawModeGuard restores termios.
788            let _ = stdout.flush();
789
790            // RawModeGuard::drop() runs after this, restoring original termios.
791        }
792    }
793}
794
795/// Allow `TtyBackend` to be used directly as a `BackendEventSource` in
796/// `Program<M, TtyBackend, W>`.  Delegates to the inner `TtyEventSource`.
797/// This is the primary integration point: the runtime owns a `TtyBackend`
798/// as its event source, which also provides RAII terminal cleanup on drop.
799impl 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
838// ── Utility: write cleanup sequence to a byte buffer (for testing) ───────
839
840/// Write the full cleanup sequence for the given feature state to `writer`.
841///
842/// This is useful for verifying cleanup in PTY tests without needing
843/// a real terminal session.
844pub 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    // Disable features in reverse order.
851    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// ── Tests ────────────────────────────────────────────────────────────────
871
872#[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        // Simulate a rapid resize storm: 1000 identical resize signals.
955        for _ in 0..1000 {
956            src.push_resize(120, 40);
957        }
958        // First push changes 80x24→120x40, rest are deduped.
959        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        // Rapidly varying sizes — all should produce events.
973        for i in 1..=500u16 {
974            src.push_resize(80 + i, 24 + (i % 50));
975        }
976        // No panics, events are in order.
977        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    // ── Pipe-based input parity tests ─────────────────────────────────
988
989    /// Create a (reader_file, writer_stream) pair using Unix sockets.
990    #[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        // Convert reader to File via OwnedFd for compatibility with TtyEventSource.
995        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        // Queue should now be empty.
1035        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        // Up (A), Down (B), Right (C), Left (D)
1045        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        // Ctrl+A = 0x01, Ctrl+C = 0x03
1067        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        // F1 (SS3 P) and F5 (CSI 15~)
1096        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        // SGR mouse: left click at (10, 20) — 1-indexed in protocol, 0-indexed in Event.
1125        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        // Focus in (CSI I) and focus out (CSI O)
1145        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        // Ctrl+Up: CSI 1;5A
1176        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        // SGR scroll up at (5, 5): button=64 (scroll bit + up)
1196        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        // Write multiple chars to produce multiple events.
1217        writer.write_all(b"xy").unwrap();
1218        assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1219        // Consume only one event.
1220        let _ = src.read_event().unwrap().unwrap();
1221        // Second poll should return true immediately (buffered event).
1222        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    // ── Edge-case input parser tests ─────────────────────────────────
1271
1272    #[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        // Write an incomplete CSI sequence followed by a valid character.
1279        // The incomplete `\x1b[` should be buffered; when `a` arrives
1280        // (not a valid CSI final byte when directly after `[`), the parser
1281        // should eventually recover. We follow with a clear valid sequence.
1282        writer.write_all(b"\x1b[").unwrap();
1283        // Give the poll a chance to consume the partial sequence.
1284        let _ = src.poll_event(Duration::from_millis(50));
1285        // Now send a valid key to force the parser forward.
1286        writer.write_all(b"\x1b[Ax").unwrap();
1287        assert!(src.poll_event(Duration::from_millis(100)).unwrap());
1288        // Drain all events and verify we get at least the valid ones.
1289        let mut events = Vec::new();
1290        while let Some(e) = src.read_event().unwrap() {
1291            events.push(e);
1292        }
1293        // The Up arrow (\x1b[A) and the 'x' key should both be parsed.
1294        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        // \x1b[999~ is an unknown tilde-code; the parser should silently
1327        // drop it and still parse the subsequent 'z' key event.
1328        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        // Close the writer end immediately to simulate EOF.
1356        drop(writer);
1357        // poll_event should return false (no data) without panicking.
1358        let result = src.poll_event(Duration::from_millis(50));
1359        assert!(result.is_ok(), "poll_event after EOF should not error");
1360        // read_event should also return None cleanly.
1361        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        // Mix of: invalid UTF-8 lead byte, valid 'a', unknown CSI, valid 'b',
1372        // bare ESC followed by valid char, valid 'c'.
1373        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        // 'a', 'b', and 'c' must all appear (possibly with Alt modifier for 'c'
1386        // since \x1b followed by space+c could parse as Alt+Space then 'c').
1387        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        // Write the escape sequence for Down arrow (\x1b[B) in two separate writes.
1408        writer.write_all(b"\x1b").unwrap();
1409        // First poll: the lone ESC may or may not produce an event depending
1410        // on whether the parser waits for more bytes.
1411        let _ = src.poll_event(Duration::from_millis(30));
1412        // Complete the sequence.
1413        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        // Zero-timeout poll with no data should return false immediately.
1440        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        // Malformed SGR mouse: missing coordinates followed by valid 'q'.
1451        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        // Parser must recover; 'q' should appear somewhere in the events.
1458        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    // ── Presenter edge-case tests ────────────────────────────────────
1474
1475    #[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        // Verify output was emitted for the single cell.
1495        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    // ── TtyPresenter rendering tests ─────────────────────────────────
1507
1508    #[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        // All variants should return Ok without panicking.
1515        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        // Place a bold red 'X' at (0, 0).
1530        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        // Extract the written bytes from the inner Presenter's writer.
1542        // The Presenter wraps writer in BufWriter<CountingWriter<W>>,
1543        // so we just check the output isn't empty and contains CSI (ESC[).
1544        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        // Pass diff=None — should trigger full repaint.
1567        presenter.present_ui(&buf, None, false).unwrap();
1568
1569        let bytes = presenter.inner.unwrap().into_inner().unwrap();
1570        // All 5 characters should appear in the output.
1571        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        // The changed cell should appear; unchanged leading cell should not.
1600        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(&current, &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(&current, &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        // Verify all expected sequences are present.
1723        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        // Verify ordering: sync_end first, cursor_show before alt_screen_leave.
1753        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        // Verify disable sequences were written.
1789        assert!(!buf.is_empty());
1790    }
1791
1792    // ── PTY-based raw mode tests ─────────────────────────────────────
1793
1794    #[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            // Before: canonical mode with ECHO.
1815            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                // During: raw mode — no echo, no canonical.
1829                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            // After drop: original termios restored.
1841            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            // Spawn a thread that panics with the guard held.
1858            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            // Verify termios restored despite the panic.
1866            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                // Write feature-enable sequences to the PTY.
1886                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                // Also write cleanup as if TtyBackend::drop ran.
1900                write_cleanup_sequence(&all_on, true, &mut stdout_buf).unwrap();
1901
1902                // Write it all to the slave so master can read it.
1903                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            // Read from master to verify cleanup sequences were written.
1910            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        /// Helper: write bytes to the PTY slave and read them back from master.
1933        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            // Simulate entering a session: raw mode + hide cursor.
1954            {
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                // Simulate drop cleanup: show cursor.
1963                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                // Enter alt-screen.
1980                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                // Leave alt-screen.
1990                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                // Enable all features, then write cleanup (simulating TtyBackend::drop).
2010                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                // Verify each feature's disable sequence individually.
2022                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                // Simulate having features enabled — the guard tracks termios, and
2061                // TtyBackend::drop would disable features. Here we just verify
2062                // the termios restoration happens even when features were "active".
2063                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            // Enter and exit raw mode multiple times.
2085            for _ in 0..5 {
2086                let s = slave_dup.try_clone().unwrap();
2087                let guard = RawModeGuard::enter_on(s).unwrap();
2088
2089                // Verify raw mode active.
2090                let during = termios::tcgetattr(&slave_dup).unwrap();
2091                assert!(!during.local_flags.contains(LocalFlags::ECHO));
2092
2093                drop(guard);
2094
2095                // Verify restored.
2096                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                // Write a full cleanup sequence and verify ordering.
2113                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                // Verify ordering: sync_end before cursor_show before alt_screen_leave.
2125                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}