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