Skip to main content

ftui_render/
ansi.rs

1#![forbid(unsafe_code)]
2
3//! ANSI escape sequence generation helpers.
4//!
5//! This module provides pure byte-generation functions for ANSI/VT control sequences.
6//! It handles the encoding details so the Presenter can focus on state tracking and diffing.
7//!
8//! # Design Principles
9//!
10//! - **Pure functions**: No state tracking, just byte generation
11//! - **Zero allocation**: Use stack buffers for common sequences
12//! - **Explicit**: Readable helpers over clever formatting
13//!
14//! # Sequence Reference
15//!
16//! | Category | Sequence | Description |
17//! |----------|----------|-------------|
18//! | CSI | `ESC [ n m` | SGR (Select Graphic Rendition) |
19//! | CSI | `ESC [ row ; col H` | CUP (Cursor Position, 1-indexed) |
20//! | CSI | `ESC [ n K` | EL (Erase Line) |
21//! | CSI | `ESC [ n J` | ED (Erase Display) |
22//! | CSI | `ESC [ top ; bottom r` | DECSTBM (Set Scroll Region) |
23//! | CSI | `ESC [ ? 2026 h/l` | Synchronized Output (DEC) |
24//! | OSC | `ESC ] 8 ; ; url ST` | Hyperlink (OSC 8) |
25//! | DEC | `ESC 7` / `ESC 8` | Cursor save/restore (DECSC/DECRC) |
26
27use std::io::{self, Write};
28
29use crate::cell::{PackedRgba, StyleFlags};
30
31const MAX_OSC8_FIELD_BYTES: usize = 4096;
32
33#[inline]
34fn osc8_field_is_safe(value: &str) -> bool {
35    value.len() <= MAX_OSC8_FIELD_BYTES && !value.chars().any(char::is_control)
36}
37
38// =============================================================================
39// SGR (Select Graphic Rendition)
40// =============================================================================
41
42/// SGR reset: `CSI 0 m`
43pub const SGR_RESET: &[u8] = b"\x1b[0m";
44
45/// Write SGR reset sequence.
46#[inline]
47pub fn sgr_reset<W: Write>(w: &mut W) -> io::Result<()> {
48    w.write_all(SGR_RESET)
49}
50
51/// SGR attribute codes for style flags.
52#[derive(Debug, Clone, Copy)]
53pub struct SgrCodes {
54    /// Enable code
55    pub on: u8,
56    /// Disable code
57    pub off: u8,
58}
59
60/// SGR codes for bold (on=1, off=22).
61pub const SGR_BOLD: SgrCodes = SgrCodes { on: 1, off: 22 };
62/// SGR codes for dim (on=2, off=22).
63pub const SGR_DIM: SgrCodes = SgrCodes { on: 2, off: 22 };
64/// SGR codes for italic (on=3, off=23).
65pub const SGR_ITALIC: SgrCodes = SgrCodes { on: 3, off: 23 };
66/// SGR codes for underline (on=4, off=24).
67pub const SGR_UNDERLINE: SgrCodes = SgrCodes { on: 4, off: 24 };
68/// SGR codes for blink (on=5, off=25).
69pub const SGR_BLINK: SgrCodes = SgrCodes { on: 5, off: 25 };
70/// SGR codes for reverse video (on=7, off=27).
71pub const SGR_REVERSE: SgrCodes = SgrCodes { on: 7, off: 27 };
72/// SGR codes for hidden text (on=8, off=28).
73pub const SGR_HIDDEN: SgrCodes = SgrCodes { on: 8, off: 28 };
74/// SGR codes for strikethrough (on=9, off=29).
75pub const SGR_STRIKETHROUGH: SgrCodes = SgrCodes { on: 9, off: 29 };
76
77/// Get SGR codes for a style flag.
78#[must_use]
79pub const fn sgr_codes_for_flag(flag: StyleFlags) -> Option<SgrCodes> {
80    match flag.bits() {
81        0b0000_0001 => Some(SGR_BOLD),
82        0b0000_0010 => Some(SGR_DIM),
83        0b0000_0100 => Some(SGR_ITALIC),
84        0b0000_1000 => Some(SGR_UNDERLINE),
85        0b0001_0000 => Some(SGR_BLINK),
86        0b0010_0000 => Some(SGR_REVERSE),
87        0b1000_0000 => Some(SGR_HIDDEN),
88        0b0100_0000 => Some(SGR_STRIKETHROUGH),
89        _ => None,
90    }
91}
92
93#[inline]
94fn write_u8_dec(buf: &mut [u8], n: u8) -> usize {
95    if n >= 100 {
96        let hundreds = n / 100;
97        let tens = (n / 10) % 10;
98        let ones = n % 10;
99        buf[0] = b'0' + hundreds;
100        buf[1] = b'0' + tens;
101        buf[2] = b'0' + ones;
102        3
103    } else if n >= 10 {
104        let tens = n / 10;
105        let ones = n % 10;
106        buf[0] = b'0' + tens;
107        buf[1] = b'0' + ones;
108        2
109    } else {
110        buf[0] = b'0' + n;
111        1
112    }
113}
114
115#[inline]
116fn write_sgr_code<W: Write>(w: &mut W, code: u8) -> io::Result<()> {
117    let mut buf = [0u8; 6];
118    buf[0] = 0x1b;
119    buf[1] = b'[';
120    let len = write_u8_dec(&mut buf[2..], code);
121    buf[2 + len] = b'm';
122    w.write_all(&buf[..2 + len + 1])
123}
124
125/// Write SGR sequence for style flags (all set flags).
126///
127/// Emits `CSI n ; n ; ... m` for each enabled flag.
128/// Does not emit reset first - caller is responsible for state management.
129pub fn sgr_flags<W: Write>(w: &mut W, flags: StyleFlags) -> io::Result<()> {
130    if flags.is_empty() {
131        return Ok(());
132    }
133
134    let bits = flags.bits();
135    if bits.is_power_of_two()
136        && let Some(seq) = sgr_single_flag_seq(bits)
137    {
138        return w.write_all(seq);
139    }
140
141    let mut buf = [0u8; 32];
142    let mut idx = 0usize;
143    buf[idx] = 0x1b;
144    buf[idx + 1] = b'[';
145    idx += 2;
146    let mut first = true;
147
148    for (flag, codes) in FLAG_TABLE {
149        if flags.contains(flag) {
150            if !first {
151                buf[idx] = b';';
152                idx += 1;
153            }
154            idx += write_u8_dec(&mut buf[idx..], codes.on);
155            first = false;
156        }
157    }
158
159    buf[idx] = b'm';
160    idx += 1;
161    w.write_all(&buf[..idx])
162}
163
164/// Ordered table of (flag, on/off codes) for iteration.
165pub const FLAG_TABLE: [(StyleFlags, SgrCodes); 8] = [
166    (StyleFlags::BOLD, SGR_BOLD),
167    (StyleFlags::DIM, SGR_DIM),
168    (StyleFlags::ITALIC, SGR_ITALIC),
169    (StyleFlags::UNDERLINE, SGR_UNDERLINE),
170    (StyleFlags::BLINK, SGR_BLINK),
171    (StyleFlags::REVERSE, SGR_REVERSE),
172    (StyleFlags::HIDDEN, SGR_HIDDEN),
173    (StyleFlags::STRIKETHROUGH, SGR_STRIKETHROUGH),
174];
175
176#[inline]
177fn sgr_single_flag_seq(bits: u8) -> Option<&'static [u8]> {
178    match bits {
179        0b0000_0001 => Some(b"\x1b[1m"), // bold
180        0b0000_0010 => Some(b"\x1b[2m"), // dim
181        0b0000_0100 => Some(b"\x1b[3m"), // italic
182        0b0000_1000 => Some(b"\x1b[4m"), // underline
183        0b0001_0000 => Some(b"\x1b[5m"), // blink
184        0b0010_0000 => Some(b"\x1b[7m"), // reverse
185        0b0100_0000 => Some(b"\x1b[9m"), // strikethrough
186        0b1000_0000 => Some(b"\x1b[8m"), // hidden
187        _ => None,
188    }
189}
190
191#[inline]
192fn sgr_single_flag_off_seq(bits: u8) -> Option<&'static [u8]> {
193    match bits {
194        0b0000_0001 => Some(b"\x1b[22m"), // bold off
195        0b0000_0010 => Some(b"\x1b[22m"), // dim off
196        0b0000_0100 => Some(b"\x1b[23m"), // italic off
197        0b0000_1000 => Some(b"\x1b[24m"), // underline off
198        0b0001_0000 => Some(b"\x1b[25m"), // blink off
199        0b0010_0000 => Some(b"\x1b[27m"), // reverse off
200        0b0100_0000 => Some(b"\x1b[29m"), // strikethrough off
201        0b1000_0000 => Some(b"\x1b[28m"), // hidden off
202        _ => None,
203    }
204}
205
206/// Write SGR sequence to turn off specific style flags.
207///
208/// Emits the individual "off" codes for each flag in `flags_to_disable`.
209/// Handles the Bold/Dim shared off code (22): if only one of Bold/Dim needs
210/// to be disabled while the other must stay on, the caller must re-enable
211/// the survivor separately. This function returns the set of flags that were
212/// collaterally disabled (i.e., flags that share an off code with a disabled flag
213/// but should remain enabled according to `flags_to_keep`).
214///
215/// Returns the set of flags that need to be re-enabled due to shared off codes.
216pub fn sgr_flags_off<W: Write>(
217    w: &mut W,
218    flags_to_disable: StyleFlags,
219    flags_to_keep: StyleFlags,
220) -> io::Result<StyleFlags> {
221    if flags_to_disable.is_empty() {
222        return Ok(StyleFlags::empty());
223    }
224
225    let disable_bits = flags_to_disable.bits();
226    if disable_bits.is_power_of_two()
227        && let Some(seq) = sgr_single_flag_off_seq(disable_bits)
228    {
229        w.write_all(seq)?;
230        if disable_bits == StyleFlags::BOLD.bits() && flags_to_keep.contains(StyleFlags::DIM) {
231            return Ok(StyleFlags::DIM);
232        }
233        if disable_bits == StyleFlags::DIM.bits() && flags_to_keep.contains(StyleFlags::BOLD) {
234            return Ok(StyleFlags::BOLD);
235        }
236        return Ok(StyleFlags::empty());
237    }
238
239    let mut collateral = StyleFlags::empty();
240
241    for (flag, codes) in FLAG_TABLE {
242        if !flags_to_disable.contains(flag) {
243            continue;
244        }
245        // Emit the off code
246        write_sgr_code(w, codes.off)?;
247        // Check for collateral damage: Bold (off=22) and Dim (off=22) share the same off code
248        if codes.off == 22 {
249            // Off code 22 disables both Bold and Dim
250            let other = if flag == StyleFlags::BOLD {
251                StyleFlags::DIM
252            } else {
253                StyleFlags::BOLD
254            };
255            if flags_to_keep.contains(other) && !flags_to_disable.contains(other) {
256                collateral |= other;
257            }
258        }
259    }
260
261    Ok(collateral)
262}
263
264/// Write SGR sequence for true color foreground: `CSI 38;2;r;g;b m`
265pub fn sgr_fg_rgb<W: Write>(w: &mut W, r: u8, g: u8, b: u8) -> io::Result<()> {
266    write!(w, "\x1b[38;2;{r};{g};{b}m")
267}
268
269/// Write SGR sequence for true color background: `CSI 48;2;r;g;b m`
270pub fn sgr_bg_rgb<W: Write>(w: &mut W, r: u8, g: u8, b: u8) -> io::Result<()> {
271    write!(w, "\x1b[48;2;{r};{g};{b}m")
272}
273
274/// Write SGR sequence for 256-color foreground: `CSI 38;5;n m`
275pub fn sgr_fg_256<W: Write>(w: &mut W, index: u8) -> io::Result<()> {
276    write!(w, "\x1b[38;5;{index}m")
277}
278
279/// Write SGR sequence for 256-color background: `CSI 48;5;n m`
280pub fn sgr_bg_256<W: Write>(w: &mut W, index: u8) -> io::Result<()> {
281    write!(w, "\x1b[48;5;{index}m")
282}
283
284/// Write SGR sequence for 16-color foreground.
285///
286/// Uses codes 30-37 for normal colors, 90-97 for bright colors.
287pub fn sgr_fg_16<W: Write>(w: &mut W, index: u8) -> io::Result<()> {
288    let code = if index < 8 {
289        30 + index
290    } else {
291        90 + index - 8
292    };
293    write!(w, "\x1b[{code}m")
294}
295
296/// Write SGR sequence for 16-color background.
297///
298/// Uses codes 40-47 for normal colors, 100-107 for bright colors.
299pub fn sgr_bg_16<W: Write>(w: &mut W, index: u8) -> io::Result<()> {
300    let code = if index < 8 {
301        40 + index
302    } else {
303        100 + index - 8
304    };
305    write!(w, "\x1b[{code}m")
306}
307
308/// Write SGR default foreground: `CSI 39 m`
309pub fn sgr_fg_default<W: Write>(w: &mut W) -> io::Result<()> {
310    w.write_all(b"\x1b[39m")
311}
312
313/// Write SGR default background: `CSI 49 m`
314pub fn sgr_bg_default<W: Write>(w: &mut W) -> io::Result<()> {
315    w.write_all(b"\x1b[49m")
316}
317
318/// Write SGR for a PackedRgba color as foreground (true color).
319///
320/// Skips if alpha is 0 (transparent).
321pub fn sgr_fg_packed<W: Write>(w: &mut W, color: PackedRgba) -> io::Result<()> {
322    if color.a() == 0 {
323        return sgr_fg_default(w);
324    }
325    sgr_fg_rgb(w, color.r(), color.g(), color.b())
326}
327
328/// Write SGR for a PackedRgba color as background (true color).
329///
330/// Skips if alpha is 0 (transparent).
331pub fn sgr_bg_packed<W: Write>(w: &mut W, color: PackedRgba) -> io::Result<()> {
332    if color.a() == 0 {
333        return sgr_bg_default(w);
334    }
335    sgr_bg_rgb(w, color.r(), color.g(), color.b())
336}
337
338// =============================================================================
339// Cursor Positioning
340// =============================================================================
341
342/// CUP (Cursor Position): `CSI row ; col H` (1-indexed)
343///
344/// Moves cursor to absolute position. Row and col are 0-indexed input,
345/// converted to 1-indexed for ANSI.
346pub fn cup<W: Write>(w: &mut W, row: u16, col: u16) -> io::Result<()> {
347    write!(
348        w,
349        "\x1b[{};{}H",
350        row.saturating_add(1),
351        col.saturating_add(1)
352    )
353}
354
355/// CUP to column only: `CSI col G` (1-indexed)
356///
357/// Moves cursor to column on current row.
358pub fn cha<W: Write>(w: &mut W, col: u16) -> io::Result<()> {
359    write!(w, "\x1b[{}G", col.saturating_add(1))
360}
361
362/// Move cursor up: `CSI n A`
363pub fn cuu<W: Write>(w: &mut W, n: u16) -> io::Result<()> {
364    if n == 0 {
365        return Ok(());
366    }
367    if n == 1 {
368        w.write_all(b"\x1b[A")
369    } else {
370        write!(w, "\x1b[{n}A")
371    }
372}
373
374/// Move cursor down: `CSI n B`
375pub fn cud<W: Write>(w: &mut W, n: u16) -> io::Result<()> {
376    if n == 0 {
377        return Ok(());
378    }
379    if n == 1 {
380        w.write_all(b"\x1b[B")
381    } else {
382        write!(w, "\x1b[{n}B")
383    }
384}
385
386/// Move cursor forward (right): `CSI n C`
387pub fn cuf<W: Write>(w: &mut W, n: u16) -> io::Result<()> {
388    if n == 0 {
389        return Ok(());
390    }
391    if n == 1 {
392        w.write_all(b"\x1b[C")
393    } else {
394        write!(w, "\x1b[{n}C")
395    }
396}
397
398/// Move cursor back (left): `CSI n D`
399pub fn cub<W: Write>(w: &mut W, n: u16) -> io::Result<()> {
400    if n == 0 {
401        return Ok(());
402    }
403    if n == 1 {
404        w.write_all(b"\x1b[D")
405    } else {
406        write!(w, "\x1b[{n}D")
407    }
408}
409
410/// Move cursor to start of line: `\r` (CR)
411#[inline]
412pub fn cr<W: Write>(w: &mut W) -> io::Result<()> {
413    w.write_all(b"\r")
414}
415
416/// Move cursor down one line: `\n` (LF)
417///
418/// Note: In raw mode (OPOST disabled), this moves y+1 but preserves x.
419#[inline]
420pub fn lf<W: Write>(w: &mut W) -> io::Result<()> {
421    w.write_all(b"\n")
422}
423
424/// DEC cursor save: `ESC 7` (DECSC)
425pub const CURSOR_SAVE: &[u8] = b"\x1b7";
426
427/// DEC cursor restore: `ESC 8` (DECRC)
428pub const CURSOR_RESTORE: &[u8] = b"\x1b8";
429
430/// Write cursor save (DECSC).
431#[inline]
432pub fn cursor_save<W: Write>(w: &mut W) -> io::Result<()> {
433    w.write_all(CURSOR_SAVE)
434}
435
436/// Write cursor restore (DECRC).
437#[inline]
438pub fn cursor_restore<W: Write>(w: &mut W) -> io::Result<()> {
439    w.write_all(CURSOR_RESTORE)
440}
441
442/// Hide cursor: `CSI ? 25 l`
443pub const CURSOR_HIDE: &[u8] = b"\x1b[?25l";
444
445/// Show cursor: `CSI ? 25 h`
446pub const CURSOR_SHOW: &[u8] = b"\x1b[?25h";
447
448/// Write hide cursor.
449#[inline]
450pub fn cursor_hide<W: Write>(w: &mut W) -> io::Result<()> {
451    w.write_all(CURSOR_HIDE)
452}
453
454/// Write show cursor.
455#[inline]
456pub fn cursor_show<W: Write>(w: &mut W) -> io::Result<()> {
457    w.write_all(CURSOR_SHOW)
458}
459
460// =============================================================================
461// Erase Operations
462// =============================================================================
463
464/// EL (Erase Line) mode.
465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
466pub enum EraseLineMode {
467    /// Erase from cursor to end of line.
468    ToEnd = 0,
469    /// Erase from start of line to cursor.
470    ToStart = 1,
471    /// Erase entire line.
472    All = 2,
473}
474
475/// EL (Erase Line): `CSI n K`
476pub fn erase_line<W: Write>(w: &mut W, mode: EraseLineMode) -> io::Result<()> {
477    match mode {
478        EraseLineMode::ToEnd => w.write_all(b"\x1b[K"),
479        EraseLineMode::ToStart => w.write_all(b"\x1b[1K"),
480        EraseLineMode::All => w.write_all(b"\x1b[2K"),
481    }
482}
483
484/// ED (Erase Display) mode.
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub enum EraseDisplayMode {
487    /// Erase from cursor to end of screen.
488    ToEnd = 0,
489    /// Erase from start of screen to cursor.
490    ToStart = 1,
491    /// Erase entire screen.
492    All = 2,
493    /// Erase scrollback buffer (xterm extension).
494    Scrollback = 3,
495}
496
497/// ED (Erase Display): `CSI n J`
498pub fn erase_display<W: Write>(w: &mut W, mode: EraseDisplayMode) -> io::Result<()> {
499    match mode {
500        EraseDisplayMode::ToEnd => w.write_all(b"\x1b[J"),
501        EraseDisplayMode::ToStart => w.write_all(b"\x1b[1J"),
502        EraseDisplayMode::All => w.write_all(b"\x1b[2J"),
503        EraseDisplayMode::Scrollback => w.write_all(b"\x1b[3J"),
504    }
505}
506
507// =============================================================================
508// Scroll Region
509// =============================================================================
510
511/// DECSTBM (Set Top and Bottom Margins): `CSI top ; bottom r`
512///
513/// Sets the scroll region. Top and bottom are 0-indexed, converted to 1-indexed.
514pub fn set_scroll_region<W: Write>(w: &mut W, top: u16, bottom: u16) -> io::Result<()> {
515    write!(
516        w,
517        "\x1b[{};{}r",
518        top.saturating_add(1),
519        bottom.saturating_add(1)
520    )
521}
522
523/// Reset scroll region to full screen: `CSI r`
524pub const RESET_SCROLL_REGION: &[u8] = b"\x1b[r";
525
526/// Write reset scroll region.
527#[inline]
528pub fn reset_scroll_region<W: Write>(w: &mut W) -> io::Result<()> {
529    w.write_all(RESET_SCROLL_REGION)
530}
531
532// =============================================================================
533// Synchronized Output (DEC 2026)
534// =============================================================================
535
536/// Begin synchronized output: `CSI ? 2026 h`
537pub const SYNC_BEGIN: &[u8] = b"\x1b[?2026h";
538
539/// End synchronized output: `CSI ? 2026 l`
540pub const SYNC_END: &[u8] = b"\x1b[?2026l";
541
542/// Write synchronized output begin.
543#[inline]
544pub fn sync_begin<W: Write>(w: &mut W) -> io::Result<()> {
545    w.write_all(SYNC_BEGIN)
546}
547
548/// Write synchronized output end.
549#[inline]
550pub fn sync_end<W: Write>(w: &mut W) -> io::Result<()> {
551    w.write_all(SYNC_END)
552}
553
554// =============================================================================
555// OSC 8 Hyperlinks
556// =============================================================================
557
558/// Open an OSC 8 hyperlink.
559///
560/// Format: `OSC 8 ; params ; uri ST`
561/// Uses ST (String Terminator) = `ESC \`
562pub fn hyperlink_start<W: Write>(w: &mut W, url: &str) -> io::Result<()> {
563    if !osc8_field_is_safe(url) {
564        return Ok(());
565    }
566    write!(w, "\x1b]8;;{url}\x1b\\")
567}
568
569/// Close an OSC 8 hyperlink.
570///
571/// Format: `OSC 8 ; ; ST`
572pub fn hyperlink_end<W: Write>(w: &mut W) -> io::Result<()> {
573    w.write_all(b"\x1b]8;;\x1b\\")
574}
575
576/// Open an OSC 8 hyperlink with an ID parameter.
577///
578/// The ID allows grouping multiple link spans.
579/// Format: `OSC 8 ; id=ID ; uri ST`
580pub fn hyperlink_start_with_id<W: Write>(w: &mut W, id: &str, url: &str) -> io::Result<()> {
581    if !osc8_field_is_safe(url) || !osc8_field_is_safe(id) || id.contains(';') {
582        return Ok(());
583    }
584    write!(w, "\x1b]8;id={id};{url}\x1b\\")
585}
586
587// =============================================================================
588// Mode Control
589// =============================================================================
590
591/// Enable alternate screen: `CSI ? 1049 h`
592pub const ALT_SCREEN_ENTER: &[u8] = b"\x1b[?1049h";
593
594/// Disable alternate screen: `CSI ? 1049 l`
595pub const ALT_SCREEN_LEAVE: &[u8] = b"\x1b[?1049l";
596
597/// Enable bracketed paste: `CSI ? 2004 h`
598pub const BRACKETED_PASTE_ENABLE: &[u8] = b"\x1b[?2004h";
599
600/// Disable bracketed paste: `CSI ? 2004 l`
601pub const BRACKETED_PASTE_DISABLE: &[u8] = b"\x1b[?2004l";
602
603/// Enable SGR mouse reporting with mode-hygiene pre-reset:
604/// - reset legacy/alternate encodings (`1001/1003/1005/1015/1016`)
605/// - enable canonical SGR modes (`1000 + 1002 + 1006`)
606/// - emit `1016l` before `1006h` so SGR mode remains active on terminals
607///   where trailing `1016l` forces X10 fallback.
608///
609/// Enables:
610/// - 1000: Normal mouse tracking
611/// - 1002: Button event tracking (motion while pressed)
612/// - 1006: SGR extended coordinates (supports > 223)
613pub const MOUSE_ENABLE: &[u8] = b"\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?1000;1002;1006h\x1b[?1000h\x1b[?1002h\x1b[?1006h";
614
615/// Disable mouse reporting and clear legacy/alternate modes.
616pub const MOUSE_DISABLE: &[u8] = b"\x1b[?1000;1002;1006l\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l";
617
618/// Enable focus reporting: `CSI ? 1004 h`
619pub const FOCUS_ENABLE: &[u8] = b"\x1b[?1004h";
620
621/// Disable focus reporting: `CSI ? 1004 l`
622pub const FOCUS_DISABLE: &[u8] = b"\x1b[?1004l";
623
624/// Write alternate screen enter.
625#[inline]
626pub fn alt_screen_enter<W: Write>(w: &mut W) -> io::Result<()> {
627    w.write_all(ALT_SCREEN_ENTER)
628}
629
630/// Write alternate screen leave.
631#[inline]
632pub fn alt_screen_leave<W: Write>(w: &mut W) -> io::Result<()> {
633    w.write_all(ALT_SCREEN_LEAVE)
634}
635
636/// Write bracketed paste enable.
637#[inline]
638pub fn bracketed_paste_enable<W: Write>(w: &mut W) -> io::Result<()> {
639    w.write_all(BRACKETED_PASTE_ENABLE)
640}
641
642/// Write bracketed paste disable.
643#[inline]
644pub fn bracketed_paste_disable<W: Write>(w: &mut W) -> io::Result<()> {
645    w.write_all(BRACKETED_PASTE_DISABLE)
646}
647
648/// Write mouse enable.
649#[inline]
650pub fn mouse_enable<W: Write>(w: &mut W) -> io::Result<()> {
651    w.write_all(MOUSE_ENABLE)
652}
653
654/// Write mouse disable.
655#[inline]
656pub fn mouse_disable<W: Write>(w: &mut W) -> io::Result<()> {
657    w.write_all(MOUSE_DISABLE)
658}
659
660/// Write focus enable.
661#[inline]
662pub fn focus_enable<W: Write>(w: &mut W) -> io::Result<()> {
663    w.write_all(FOCUS_ENABLE)
664}
665
666/// Write focus disable.
667#[inline]
668pub fn focus_disable<W: Write>(w: &mut W) -> io::Result<()> {
669    w.write_all(FOCUS_DISABLE)
670}
671
672// =============================================================================
673// Tests
674// =============================================================================
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    fn to_bytes<F: FnOnce(&mut Vec<u8>) -> io::Result<()>>(f: F) -> Vec<u8> {
681        let mut buf = Vec::new();
682        f(&mut buf).unwrap();
683        buf
684    }
685
686    // SGR Tests
687
688    #[test]
689    fn sgr_reset_bytes() {
690        assert_eq!(to_bytes(sgr_reset), b"\x1b[0m");
691    }
692
693    #[test]
694    fn sgr_flags_bold() {
695        assert_eq!(to_bytes(|w| sgr_flags(w, StyleFlags::BOLD)), b"\x1b[1m");
696    }
697
698    #[test]
699    fn sgr_flags_multiple() {
700        let flags = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
701        assert_eq!(to_bytes(|w| sgr_flags(w, flags)), b"\x1b[1;3;4m");
702    }
703
704    #[test]
705    fn sgr_flags_empty() {
706        assert_eq!(to_bytes(|w| sgr_flags(w, StyleFlags::empty())), b"");
707    }
708
709    #[test]
710    fn sgr_fg_rgb_bytes() {
711        assert_eq!(
712            to_bytes(|w| sgr_fg_rgb(w, 255, 128, 0)),
713            b"\x1b[38;2;255;128;0m"
714        );
715    }
716
717    #[test]
718    fn sgr_bg_rgb_bytes() {
719        assert_eq!(to_bytes(|w| sgr_bg_rgb(w, 0, 0, 0)), b"\x1b[48;2;0;0;0m");
720    }
721
722    #[test]
723    fn sgr_fg_256_bytes() {
724        assert_eq!(to_bytes(|w| sgr_fg_256(w, 196)), b"\x1b[38;5;196m");
725    }
726
727    #[test]
728    fn sgr_bg_256_bytes() {
729        assert_eq!(to_bytes(|w| sgr_bg_256(w, 232)), b"\x1b[48;5;232m");
730    }
731
732    #[test]
733    fn sgr_fg_16_normal() {
734        assert_eq!(to_bytes(|w| sgr_fg_16(w, 1)), b"\x1b[31m"); // Red
735        assert_eq!(to_bytes(|w| sgr_fg_16(w, 7)), b"\x1b[37m"); // White
736    }
737
738    #[test]
739    fn sgr_fg_16_bright() {
740        assert_eq!(to_bytes(|w| sgr_fg_16(w, 9)), b"\x1b[91m"); // Bright red
741        assert_eq!(to_bytes(|w| sgr_fg_16(w, 15)), b"\x1b[97m"); // Bright white
742    }
743
744    #[test]
745    fn sgr_bg_16_normal() {
746        assert_eq!(to_bytes(|w| sgr_bg_16(w, 0)), b"\x1b[40m"); // Black
747        assert_eq!(to_bytes(|w| sgr_bg_16(w, 4)), b"\x1b[44m"); // Blue
748    }
749
750    #[test]
751    fn sgr_bg_16_bright() {
752        assert_eq!(to_bytes(|w| sgr_bg_16(w, 8)), b"\x1b[100m"); // Bright black
753        assert_eq!(to_bytes(|w| sgr_bg_16(w, 12)), b"\x1b[104m"); // Bright blue
754    }
755
756    #[test]
757    fn sgr_default_colors() {
758        assert_eq!(to_bytes(sgr_fg_default), b"\x1b[39m");
759        assert_eq!(to_bytes(sgr_bg_default), b"\x1b[49m");
760    }
761
762    #[test]
763    fn sgr_packed_transparent_uses_default() {
764        assert_eq!(
765            to_bytes(|w| sgr_fg_packed(w, PackedRgba::TRANSPARENT)),
766            b"\x1b[39m"
767        );
768        assert_eq!(
769            to_bytes(|w| sgr_bg_packed(w, PackedRgba::TRANSPARENT)),
770            b"\x1b[49m"
771        );
772    }
773
774    #[test]
775    fn sgr_packed_opaque() {
776        let color = PackedRgba::rgb(10, 20, 30);
777        assert_eq!(
778            to_bytes(|w| sgr_fg_packed(w, color)),
779            b"\x1b[38;2;10;20;30m"
780        );
781    }
782
783    // Cursor Tests
784
785    #[test]
786    fn cup_1_indexed() {
787        assert_eq!(to_bytes(|w| cup(w, 0, 0)), b"\x1b[1;1H");
788        assert_eq!(to_bytes(|w| cup(w, 23, 79)), b"\x1b[24;80H");
789    }
790
791    #[test]
792    fn cha_1_indexed() {
793        assert_eq!(to_bytes(|w| cha(w, 0)), b"\x1b[1G");
794        assert_eq!(to_bytes(|w| cha(w, 79)), b"\x1b[80G");
795    }
796
797    #[test]
798    fn cursor_relative_moves() {
799        assert_eq!(to_bytes(|w| cuu(w, 1)), b"\x1b[A");
800        assert_eq!(to_bytes(|w| cuu(w, 5)), b"\x1b[5A");
801        assert_eq!(to_bytes(|w| cud(w, 1)), b"\x1b[B");
802        assert_eq!(to_bytes(|w| cud(w, 3)), b"\x1b[3B");
803        assert_eq!(to_bytes(|w| cuf(w, 1)), b"\x1b[C");
804        assert_eq!(to_bytes(|w| cuf(w, 10)), b"\x1b[10C");
805        assert_eq!(to_bytes(|w| cub(w, 1)), b"\x1b[D");
806        assert_eq!(to_bytes(|w| cub(w, 2)), b"\x1b[2D");
807    }
808
809    #[test]
810    fn cursor_relative_zero_is_noop() {
811        assert_eq!(to_bytes(|w| cuu(w, 0)), b"");
812        assert_eq!(to_bytes(|w| cud(w, 0)), b"");
813        assert_eq!(to_bytes(|w| cuf(w, 0)), b"");
814        assert_eq!(to_bytes(|w| cub(w, 0)), b"");
815    }
816
817    #[test]
818    fn cursor_save_restore() {
819        assert_eq!(to_bytes(cursor_save), b"\x1b7");
820        assert_eq!(to_bytes(cursor_restore), b"\x1b8");
821    }
822
823    #[test]
824    fn cursor_visibility() {
825        assert_eq!(to_bytes(cursor_hide), b"\x1b[?25l");
826        assert_eq!(to_bytes(cursor_show), b"\x1b[?25h");
827    }
828
829    // Erase Tests
830
831    #[test]
832    fn erase_line_modes() {
833        assert_eq!(to_bytes(|w| erase_line(w, EraseLineMode::ToEnd)), b"\x1b[K");
834        assert_eq!(
835            to_bytes(|w| erase_line(w, EraseLineMode::ToStart)),
836            b"\x1b[1K"
837        );
838        assert_eq!(to_bytes(|w| erase_line(w, EraseLineMode::All)), b"\x1b[2K");
839    }
840
841    #[test]
842    fn erase_display_modes() {
843        assert_eq!(
844            to_bytes(|w| erase_display(w, EraseDisplayMode::ToEnd)),
845            b"\x1b[J"
846        );
847        assert_eq!(
848            to_bytes(|w| erase_display(w, EraseDisplayMode::ToStart)),
849            b"\x1b[1J"
850        );
851        assert_eq!(
852            to_bytes(|w| erase_display(w, EraseDisplayMode::All)),
853            b"\x1b[2J"
854        );
855        assert_eq!(
856            to_bytes(|w| erase_display(w, EraseDisplayMode::Scrollback)),
857            b"\x1b[3J"
858        );
859    }
860
861    // Scroll Region Tests
862
863    #[test]
864    fn scroll_region_1_indexed() {
865        assert_eq!(to_bytes(|w| set_scroll_region(w, 0, 23)), b"\x1b[1;24r");
866        assert_eq!(to_bytes(|w| set_scroll_region(w, 5, 20)), b"\x1b[6;21r");
867    }
868
869    #[test]
870    fn scroll_region_reset() {
871        assert_eq!(to_bytes(reset_scroll_region), b"\x1b[r");
872    }
873
874    // Sync Output Tests
875
876    #[test]
877    fn sync_output() {
878        assert_eq!(to_bytes(sync_begin), b"\x1b[?2026h");
879        assert_eq!(to_bytes(sync_end), b"\x1b[?2026l");
880    }
881
882    // OSC 8 Hyperlink Tests
883
884    #[test]
885    fn hyperlink_basic() {
886        assert_eq!(
887            to_bytes(|w| hyperlink_start(w, "https://example.com")),
888            b"\x1b]8;;https://example.com\x1b\\"
889        );
890        assert_eq!(to_bytes(hyperlink_end), b"\x1b]8;;\x1b\\");
891    }
892
893    #[test]
894    fn hyperlink_with_id() {
895        assert_eq!(
896            to_bytes(|w| hyperlink_start_with_id(w, "link1", "https://example.com")),
897            b"\x1b]8;id=link1;https://example.com\x1b\\"
898        );
899    }
900
901    #[test]
902    fn hyperlink_rejects_control_chars() {
903        assert_eq!(
904            to_bytes(|w| hyperlink_start(w, "https://exa\x1bmple.com")),
905            b""
906        );
907        assert_eq!(
908            to_bytes(|w| hyperlink_start_with_id(w, "id", "https://exa\u{009d}mple.com")),
909            b""
910        );
911    }
912
913    #[test]
914    fn hyperlink_with_id_rejects_parameter_breakout() {
915        assert_eq!(
916            to_bytes(|w| hyperlink_start_with_id(w, "id;malicious=1", "https://example.com")),
917            b""
918        );
919    }
920
921    #[test]
922    fn hyperlink_rejects_overlong_fields() {
923        let long_url = "x".repeat(MAX_OSC8_FIELD_BYTES + 1);
924        assert_eq!(to_bytes(|w| hyperlink_start(w, &long_url)), b"");
925
926        let long_id = "x".repeat(MAX_OSC8_FIELD_BYTES + 1);
927        assert_eq!(
928            to_bytes(|w| hyperlink_start_with_id(w, &long_id, "https://example.com")),
929            b""
930        );
931    }
932
933    // Mode Control Tests
934
935    #[test]
936    fn alt_screen() {
937        assert_eq!(to_bytes(alt_screen_enter), b"\x1b[?1049h");
938        assert_eq!(to_bytes(alt_screen_leave), b"\x1b[?1049l");
939    }
940
941    #[test]
942    fn bracketed_paste() {
943        assert_eq!(to_bytes(bracketed_paste_enable), b"\x1b[?2004h");
944        assert_eq!(to_bytes(bracketed_paste_disable), b"\x1b[?2004l");
945    }
946
947    #[test]
948    fn mouse_mode() {
949        assert_eq!(
950            to_bytes(mouse_enable),
951            b"\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?1000;1002;1006h\x1b[?1000h\x1b[?1002h\x1b[?1006h"
952        );
953        assert_eq!(
954            to_bytes(mouse_disable),
955            b"\x1b[?1000;1002;1006l\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1001l\x1b[?1003l\x1b[?1005l\x1b[?1015l\x1b[?1016l"
956        );
957
958        let enabled = to_bytes(mouse_enable);
959        assert!(
960            !enabled.ends_with(b"\x1b[?1016l"),
961            "mouse enable should not end with 1016l (can force X10 fallback)"
962        );
963        let pos_1016l = enabled
964            .windows(b"\x1b[?1016l".len())
965            .position(|w| w == b"\x1b[?1016l")
966            .expect("mouse enable should clear 1016 before enabling SGR");
967        let pos_1006h = enabled
968            .windows(b"\x1b[?1006h".len())
969            .position(|w| w == b"\x1b[?1006h")
970            .expect("mouse enable should include 1006h");
971        assert!(
972            pos_1016l < pos_1006h,
973            "1016l must be emitted before 1006h to preserve SGR mode"
974        );
975    }
976
977    #[test]
978    fn focus_mode() {
979        assert_eq!(to_bytes(focus_enable), b"\x1b[?1004h");
980        assert_eq!(to_bytes(focus_disable), b"\x1b[?1004l");
981    }
982
983    // Property tests
984
985    #[test]
986    fn all_sequences_are_ascii() {
987        // Verify no high bytes in any constant sequences
988        for seq in [
989            SGR_RESET,
990            CURSOR_SAVE,
991            CURSOR_RESTORE,
992            CURSOR_HIDE,
993            CURSOR_SHOW,
994            RESET_SCROLL_REGION,
995            SYNC_BEGIN,
996            SYNC_END,
997            ALT_SCREEN_ENTER,
998            ALT_SCREEN_LEAVE,
999            BRACKETED_PASTE_ENABLE,
1000            BRACKETED_PASTE_DISABLE,
1001            MOUSE_ENABLE,
1002            MOUSE_DISABLE,
1003            FOCUS_ENABLE,
1004            FOCUS_DISABLE,
1005        ] {
1006            for &byte in seq {
1007                assert!(byte < 128, "Non-ASCII byte {byte:#x} in sequence");
1008            }
1009        }
1010    }
1011
1012    #[test]
1013    fn osc_sequences_are_terminated() {
1014        // All OSC 8 sequences must end with ST (ESC \)
1015        let link_start = to_bytes(|w| hyperlink_start(w, "test"));
1016        assert!(
1017            link_start.ends_with(b"\x1b\\"),
1018            "hyperlink_start not terminated with ST"
1019        );
1020
1021        let link_end = to_bytes(hyperlink_end);
1022        assert!(
1023            link_end.ends_with(b"\x1b\\"),
1024            "hyperlink_end not terminated with ST"
1025        );
1026
1027        let link_id = to_bytes(|w| hyperlink_start_with_id(w, "id", "url"));
1028        assert!(
1029            link_id.ends_with(b"\x1b\\"),
1030            "hyperlink_start_with_id not terminated with ST"
1031        );
1032    }
1033
1034    // ---- sgr_flags_off tests ----
1035
1036    #[test]
1037    fn sgr_flags_off_empty_is_noop() {
1038        let bytes = to_bytes(|w| {
1039            sgr_flags_off(w, StyleFlags::empty(), StyleFlags::empty()).unwrap();
1040            Ok(())
1041        });
1042        assert!(bytes.is_empty(), "disabling no flags should emit nothing");
1043    }
1044
1045    #[test]
1046    fn sgr_flags_off_single_bold() {
1047        let mut buf = Vec::new();
1048        let collateral = sgr_flags_off(&mut buf, StyleFlags::BOLD, StyleFlags::empty()).unwrap();
1049        assert_eq!(buf, b"\x1b[22m");
1050        assert!(collateral.is_empty(), "no collateral when DIM is not kept");
1051    }
1052
1053    #[test]
1054    fn sgr_flags_off_single_dim() {
1055        let mut buf = Vec::new();
1056        let collateral = sgr_flags_off(&mut buf, StyleFlags::DIM, StyleFlags::empty()).unwrap();
1057        assert_eq!(buf, b"\x1b[22m");
1058        assert!(collateral.is_empty(), "no collateral when BOLD is not kept");
1059    }
1060
1061    #[test]
1062    fn sgr_flags_off_bold_collateral_dim() {
1063        // Disabling BOLD while DIM should stay → collateral = DIM
1064        let mut buf = Vec::new();
1065        let collateral = sgr_flags_off(&mut buf, StyleFlags::BOLD, StyleFlags::DIM).unwrap();
1066        assert_eq!(buf, b"\x1b[22m");
1067        assert_eq!(collateral, StyleFlags::DIM);
1068    }
1069
1070    #[test]
1071    fn sgr_flags_off_dim_collateral_bold() {
1072        // Disabling DIM while BOLD should stay → collateral = BOLD
1073        let mut buf = Vec::new();
1074        let collateral = sgr_flags_off(&mut buf, StyleFlags::DIM, StyleFlags::BOLD).unwrap();
1075        assert_eq!(buf, b"\x1b[22m");
1076        assert_eq!(collateral, StyleFlags::BOLD);
1077    }
1078
1079    #[test]
1080    fn sgr_flags_off_italic() {
1081        let mut buf = Vec::new();
1082        let collateral = sgr_flags_off(&mut buf, StyleFlags::ITALIC, StyleFlags::empty()).unwrap();
1083        assert_eq!(buf, b"\x1b[23m");
1084        assert!(collateral.is_empty());
1085    }
1086
1087    #[test]
1088    fn sgr_flags_off_underline() {
1089        let mut buf = Vec::new();
1090        let collateral =
1091            sgr_flags_off(&mut buf, StyleFlags::UNDERLINE, StyleFlags::empty()).unwrap();
1092        assert_eq!(buf, b"\x1b[24m");
1093        assert!(collateral.is_empty());
1094    }
1095
1096    #[test]
1097    fn sgr_flags_off_blink() {
1098        let mut buf = Vec::new();
1099        let collateral = sgr_flags_off(&mut buf, StyleFlags::BLINK, StyleFlags::empty()).unwrap();
1100        assert_eq!(buf, b"\x1b[25m");
1101        assert!(collateral.is_empty());
1102    }
1103
1104    #[test]
1105    fn sgr_flags_off_reverse() {
1106        let mut buf = Vec::new();
1107        let collateral = sgr_flags_off(&mut buf, StyleFlags::REVERSE, StyleFlags::empty()).unwrap();
1108        assert_eq!(buf, b"\x1b[27m");
1109        assert!(collateral.is_empty());
1110    }
1111
1112    #[test]
1113    fn sgr_flags_off_hidden() {
1114        let mut buf = Vec::new();
1115        let collateral = sgr_flags_off(&mut buf, StyleFlags::HIDDEN, StyleFlags::empty()).unwrap();
1116        assert_eq!(buf, b"\x1b[28m");
1117        assert!(collateral.is_empty());
1118    }
1119
1120    #[test]
1121    fn sgr_flags_off_strikethrough() {
1122        let mut buf = Vec::new();
1123        let collateral =
1124            sgr_flags_off(&mut buf, StyleFlags::STRIKETHROUGH, StyleFlags::empty()).unwrap();
1125        assert_eq!(buf, b"\x1b[29m");
1126        assert!(collateral.is_empty());
1127    }
1128
1129    #[test]
1130    fn sgr_flags_off_multi_no_bold_dim_overlap() {
1131        // Disable ITALIC + UNDERLINE (no shared off codes)
1132        let mut buf = Vec::new();
1133        let collateral = sgr_flags_off(
1134            &mut buf,
1135            StyleFlags::ITALIC | StyleFlags::UNDERLINE,
1136            StyleFlags::empty(),
1137        )
1138        .unwrap();
1139        // Multi-flag path emits individual off codes in FLAG_TABLE order.
1140        assert_eq!(buf, b"\x1b[23m\x1b[24m");
1141        assert!(collateral.is_empty());
1142    }
1143
1144    #[test]
1145    fn sgr_flags_off_bold_and_dim_together() {
1146        // Disabling both BOLD and DIM: off=22 emitted for each, but no collateral
1147        // since both are being disabled (neither needs to stay)
1148        let mut buf = Vec::new();
1149        let collateral = sgr_flags_off(
1150            &mut buf,
1151            StyleFlags::BOLD | StyleFlags::DIM,
1152            StyleFlags::empty(),
1153        )
1154        .unwrap();
1155        assert_eq!(buf, b"\x1b[22m\x1b[22m");
1156        assert!(
1157            collateral.is_empty(),
1158            "no collateral when both are disabled"
1159        );
1160    }
1161
1162    #[test]
1163    fn sgr_flags_off_overlap_keep_and_disable_does_not_report_collateral() {
1164        // Overlapping keep/disable can happen in defensive callers; disabling should win.
1165        let mut buf = Vec::new();
1166        let collateral = sgr_flags_off(
1167            &mut buf,
1168            StyleFlags::BOLD | StyleFlags::DIM,
1169            StyleFlags::DIM,
1170        )
1171        .unwrap();
1172        assert_eq!(buf, b"\x1b[22m\x1b[22m");
1173        assert!(
1174            collateral.is_empty(),
1175            "DIM is explicitly disabled, so it must not be reported as collateral"
1176        );
1177    }
1178
1179    #[test]
1180    fn sgr_flags_off_bold_dim_with_dim_kept() {
1181        // Disabling BOLD + ITALIC while DIM should stay
1182        let mut buf = Vec::new();
1183        let collateral = sgr_flags_off(
1184            &mut buf,
1185            StyleFlags::BOLD | StyleFlags::ITALIC,
1186            StyleFlags::DIM,
1187        )
1188        .unwrap();
1189        assert_eq!(
1190            collateral,
1191            StyleFlags::DIM,
1192            "DIM should be collateral damage from BOLD off (code 22)"
1193        );
1194    }
1195
1196    // ---- sgr_codes_for_flag tests ----
1197
1198    #[test]
1199    fn sgr_codes_for_all_single_flags() {
1200        let cases = [
1201            (StyleFlags::BOLD, 1, 22),
1202            (StyleFlags::DIM, 2, 22),
1203            (StyleFlags::ITALIC, 3, 23),
1204            (StyleFlags::UNDERLINE, 4, 24),
1205            (StyleFlags::BLINK, 5, 25),
1206            (StyleFlags::REVERSE, 7, 27),
1207            (StyleFlags::HIDDEN, 8, 28),
1208            (StyleFlags::STRIKETHROUGH, 9, 29),
1209        ];
1210        for (flag, expected_on, expected_off) in cases {
1211            let codes = sgr_codes_for_flag(flag)
1212                .unwrap_or_else(|| panic!("should return codes for {flag:?}"));
1213            assert_eq!(codes.on, expected_on, "on code for {flag:?}");
1214            assert_eq!(codes.off, expected_off, "off code for {flag:?}");
1215        }
1216    }
1217
1218    #[test]
1219    fn sgr_codes_for_composite_flag_returns_none() {
1220        let composite = StyleFlags::BOLD | StyleFlags::ITALIC;
1221        assert!(
1222            sgr_codes_for_flag(composite).is_none(),
1223            "composite flags should return None"
1224        );
1225    }
1226
1227    #[test]
1228    fn sgr_codes_for_empty_flag_returns_none() {
1229        assert!(
1230            sgr_codes_for_flag(StyleFlags::empty()).is_none(),
1231            "empty flags should return None"
1232        );
1233    }
1234
1235    #[test]
1236    fn sgr_codes_for_flag_matches_flag_table_entries() {
1237        for (flag, expected) in FLAG_TABLE {
1238            let actual = sgr_codes_for_flag(flag).expect("single-bit FLAG_TABLE entry");
1239            assert_eq!(actual.on, expected.on, "{flag:?} on code");
1240            assert_eq!(actual.off, expected.off, "{flag:?} off code");
1241        }
1242    }
1243
1244    // ---- cr / lf tests ----
1245
1246    #[test]
1247    fn cr_emits_carriage_return() {
1248        assert_eq!(to_bytes(cr), b"\r");
1249    }
1250
1251    #[test]
1252    fn lf_emits_line_feed() {
1253        assert_eq!(to_bytes(lf), b"\n");
1254    }
1255
1256    // ---- sgr_flags individual fast-path verification ----
1257
1258    #[test]
1259    fn sgr_flags_each_single_flag_fast_path() {
1260        let cases: &[(StyleFlags, &[u8])] = &[
1261            (StyleFlags::BOLD, b"\x1b[1m"),
1262            (StyleFlags::DIM, b"\x1b[2m"),
1263            (StyleFlags::ITALIC, b"\x1b[3m"),
1264            (StyleFlags::UNDERLINE, b"\x1b[4m"),
1265            (StyleFlags::BLINK, b"\x1b[5m"),
1266            (StyleFlags::REVERSE, b"\x1b[7m"),
1267            (StyleFlags::STRIKETHROUGH, b"\x1b[9m"),
1268            (StyleFlags::HIDDEN, b"\x1b[8m"),
1269        ];
1270        for &(flag, expected) in cases {
1271            assert_eq!(
1272                to_bytes(|w| sgr_flags(w, flag)),
1273                expected,
1274                "single-flag fast path for {flag:?}"
1275            );
1276        }
1277    }
1278
1279    #[test]
1280    fn sgr_flags_all_eight() {
1281        let all = StyleFlags::BOLD
1282            | StyleFlags::DIM
1283            | StyleFlags::ITALIC
1284            | StyleFlags::UNDERLINE
1285            | StyleFlags::BLINK
1286            | StyleFlags::REVERSE
1287            | StyleFlags::HIDDEN
1288            | StyleFlags::STRIKETHROUGH;
1289        let bytes = to_bytes(|w| sgr_flags(w, all));
1290        // Should emit CSI with codes in FLAG_TABLE order: 1;2;3;4;5;7;8;9
1291        assert_eq!(bytes, b"\x1b[1;2;3;4;5;7;8;9m");
1292    }
1293
1294    // ---- write_u8_dec boundary verification (via sgr_code) ----
1295
1296    #[test]
1297    fn sgr_code_single_digit() {
1298        // code=1 → "\x1b[1m" (1 digit)
1299        let mut buf = Vec::new();
1300        write_sgr_code(&mut buf, 1).unwrap();
1301        assert_eq!(buf, b"\x1b[1m");
1302    }
1303
1304    #[test]
1305    fn sgr_code_two_digits() {
1306        // code=22 → "\x1b[22m" (2 digits)
1307        let mut buf = Vec::new();
1308        write_sgr_code(&mut buf, 22).unwrap();
1309        assert_eq!(buf, b"\x1b[22m");
1310    }
1311
1312    #[test]
1313    fn sgr_code_three_digits() {
1314        // code=100 → "\x1b[100m" (3 digits)
1315        let mut buf = Vec::new();
1316        write_sgr_code(&mut buf, 100).unwrap();
1317        assert_eq!(buf, b"\x1b[100m");
1318    }
1319
1320    #[test]
1321    fn sgr_code_max_u8() {
1322        // code=255 → "\x1b[255m"
1323        let mut buf = Vec::new();
1324        write_sgr_code(&mut buf, 255).unwrap();
1325        assert_eq!(buf, b"\x1b[255m");
1326    }
1327
1328    #[test]
1329    fn sgr_code_zero() {
1330        let mut buf = Vec::new();
1331        write_sgr_code(&mut buf, 0).unwrap();
1332        assert_eq!(buf, b"\x1b[0m");
1333    }
1334
1335    // ---- 16-color boundary tests ----
1336
1337    #[test]
1338    fn sgr_fg_16_boundary_7_to_8() {
1339        // Index 7 is the last normal color, 8 is first bright
1340        assert_eq!(to_bytes(|w| sgr_fg_16(w, 7)), b"\x1b[37m");
1341        assert_eq!(to_bytes(|w| sgr_fg_16(w, 8)), b"\x1b[90m");
1342    }
1343
1344    #[test]
1345    fn sgr_bg_16_boundary_7_to_8() {
1346        assert_eq!(to_bytes(|w| sgr_bg_16(w, 7)), b"\x1b[47m");
1347        assert_eq!(to_bytes(|w| sgr_bg_16(w, 8)), b"\x1b[100m");
1348    }
1349
1350    #[test]
1351    fn sgr_fg_16_first_color() {
1352        assert_eq!(to_bytes(|w| sgr_fg_16(w, 0)), b"\x1b[30m"); // Black
1353    }
1354
1355    #[test]
1356    fn sgr_bg_16_last_bright() {
1357        assert_eq!(to_bytes(|w| sgr_bg_16(w, 15)), b"\x1b[107m"); // Bright white
1358    }
1359
1360    // ---- 256-color boundary tests ----
1361
1362    #[test]
1363    fn sgr_fg_256_zero() {
1364        assert_eq!(to_bytes(|w| sgr_fg_256(w, 0)), b"\x1b[38;5;0m");
1365    }
1366
1367    #[test]
1368    fn sgr_fg_256_max() {
1369        assert_eq!(to_bytes(|w| sgr_fg_256(w, 255)), b"\x1b[38;5;255m");
1370    }
1371
1372    #[test]
1373    fn sgr_bg_256_zero() {
1374        assert_eq!(to_bytes(|w| sgr_bg_256(w, 0)), b"\x1b[48;5;0m");
1375    }
1376
1377    #[test]
1378    fn sgr_bg_256_max() {
1379        assert_eq!(to_bytes(|w| sgr_bg_256(w, 255)), b"\x1b[48;5;255m");
1380    }
1381
1382    // ---- cursor positioning edge cases ----
1383
1384    #[test]
1385    fn cup_max_u16() {
1386        // u16::MAX saturating_add(1) wraps correctly
1387        let bytes = to_bytes(|w| cup(w, u16::MAX, u16::MAX));
1388        let s = String::from_utf8(bytes).unwrap();
1389        assert!(s.starts_with("\x1b["));
1390        assert!(s.ends_with("H"));
1391    }
1392
1393    #[test]
1394    fn cha_max_u16() {
1395        let bytes = to_bytes(|w| cha(w, u16::MAX));
1396        let s = String::from_utf8(bytes).unwrap();
1397        assert!(s.starts_with("\x1b["));
1398        assert!(s.ends_with("G"));
1399    }
1400
1401    #[test]
1402    fn cursor_up_max() {
1403        let bytes = to_bytes(|w| cuu(w, u16::MAX));
1404        let s = String::from_utf8(bytes).unwrap();
1405        assert!(s.contains("65535"));
1406        assert!(s.ends_with("A"));
1407    }
1408
1409    // ---- scroll region edge cases ----
1410
1411    #[test]
1412    fn scroll_region_same_top_bottom() {
1413        assert_eq!(to_bytes(|w| set_scroll_region(w, 5, 5)), b"\x1b[6;6r");
1414    }
1415
1416    // ---- sgr_flags_off single-flag off-seq fast path (all 8 flags) ----
1417
1418    #[test]
1419    fn sgr_flags_off_each_single_flag_fast_path() {
1420        let cases: &[(StyleFlags, &[u8])] = &[
1421            (StyleFlags::BOLD, b"\x1b[22m"),
1422            (StyleFlags::DIM, b"\x1b[22m"),
1423            (StyleFlags::ITALIC, b"\x1b[23m"),
1424            (StyleFlags::UNDERLINE, b"\x1b[24m"),
1425            (StyleFlags::BLINK, b"\x1b[25m"),
1426            (StyleFlags::REVERSE, b"\x1b[27m"),
1427            (StyleFlags::STRIKETHROUGH, b"\x1b[29m"),
1428            (StyleFlags::HIDDEN, b"\x1b[28m"),
1429        ];
1430        for &(flag, expected) in cases {
1431            let mut buf = Vec::new();
1432            let collateral = sgr_flags_off(&mut buf, flag, StyleFlags::empty()).unwrap();
1433            assert_eq!(buf, expected, "off sequence for {flag:?}");
1434            assert!(collateral.is_empty(), "no collateral for {flag:?}");
1435        }
1436    }
1437
1438    // ---- sgr_packed with non-zero alpha ----
1439
1440    #[test]
1441    fn sgr_bg_packed_opaque() {
1442        let color = PackedRgba::rgb(100, 200, 50);
1443        assert_eq!(
1444            to_bytes(|w| sgr_bg_packed(w, color)),
1445            b"\x1b[48;2;100;200;50m"
1446        );
1447    }
1448
1449    // ---- hyperlink with empty url/id ----
1450
1451    #[test]
1452    fn hyperlink_empty_url() {
1453        assert_eq!(to_bytes(|w| hyperlink_start(w, "")), b"\x1b]8;;\x1b\\");
1454    }
1455
1456    #[test]
1457    fn hyperlink_with_empty_id() {
1458        assert_eq!(
1459            to_bytes(|w| hyperlink_start_with_id(w, "", "https://x.com")),
1460            b"\x1b]8;id=;https://x.com\x1b\\"
1461        );
1462    }
1463
1464    // ---- all dynamic sequences start with ESC ----
1465
1466    #[test]
1467    fn all_dynamic_sequences_start_with_esc() {
1468        let sequences: Vec<Vec<u8>> = vec![
1469            to_bytes(sgr_reset),
1470            to_bytes(|w| sgr_flags(w, StyleFlags::BOLD)),
1471            to_bytes(|w| sgr_fg_rgb(w, 1, 2, 3)),
1472            to_bytes(|w| sgr_bg_rgb(w, 1, 2, 3)),
1473            to_bytes(|w| sgr_fg_256(w, 42)),
1474            to_bytes(|w| sgr_bg_256(w, 42)),
1475            to_bytes(|w| sgr_fg_16(w, 5)),
1476            to_bytes(|w| sgr_bg_16(w, 5)),
1477            to_bytes(sgr_fg_default),
1478            to_bytes(sgr_bg_default),
1479            to_bytes(|w| cup(w, 0, 0)),
1480            to_bytes(|w| cha(w, 0)),
1481            to_bytes(|w| cuu(w, 1)),
1482            to_bytes(|w| cud(w, 1)),
1483            to_bytes(|w| cuf(w, 1)),
1484            to_bytes(|w| cub(w, 1)),
1485            to_bytes(cursor_save),
1486            to_bytes(cursor_restore),
1487            to_bytes(cursor_hide),
1488            to_bytes(cursor_show),
1489            to_bytes(|w| erase_line(w, EraseLineMode::All)),
1490            to_bytes(|w| erase_display(w, EraseDisplayMode::All)),
1491            to_bytes(|w| set_scroll_region(w, 0, 23)),
1492            to_bytes(reset_scroll_region),
1493            to_bytes(sync_begin),
1494            to_bytes(sync_end),
1495            to_bytes(|w| hyperlink_start(w, "test")),
1496            to_bytes(hyperlink_end),
1497            to_bytes(alt_screen_enter),
1498            to_bytes(alt_screen_leave),
1499            to_bytes(bracketed_paste_enable),
1500            to_bytes(bracketed_paste_disable),
1501            to_bytes(mouse_enable),
1502            to_bytes(mouse_disable),
1503            to_bytes(focus_enable),
1504            to_bytes(focus_disable),
1505        ];
1506        for (i, seq) in sequences.iter().enumerate() {
1507            assert!(
1508                seq.starts_with(b"\x1b"),
1509                "sequence {i} should start with ESC, got {seq:?}"
1510            );
1511        }
1512    }
1513}