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