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