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) {
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}