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