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