Skip to main content

urushi_terminal/backend/
ansi.rs

1//! ANSI/ECMA-48 encoding of Urushi terminal commands.
2
3use std::io::{self, Write};
4
5use crate::{
6    ClearRegion, Color, Command, CommandWriter, CursorAppearance, CursorMove, TerminalHyperlink,
7    TerminalOutput, TerminalStyle, TextAttribute, Underline, UnderlineStyle,
8    command::write_control_string,
9};
10
11/// A backend that writes Urushi commands as ANSI, CSI, OSC, and C0 bytes.
12///
13/// This type owns only the output half of a terminal connection. Input parsing,
14/// process-side raw mode, and platform queries are deliberately separate: an
15/// interactive backend composes them around the same physical endpoint.
16pub struct AnsiWriter<W> {
17    writer: W,
18}
19
20impl<W> AnsiWriter<W> {
21    pub const fn new(writer: W) -> Self {
22        Self { writer }
23    }
24
25    pub const fn writer(&self) -> &W {
26        &self.writer
27    }
28
29    pub fn writer_mut(&mut self) -> &mut W {
30        &mut self.writer
31    }
32
33    pub fn into_inner(self) -> W {
34        self.writer
35    }
36}
37
38impl<W: Write> AnsiWriter<W> {
39    /// Writes the SGR sequence that enables `style`, if it has any active part.
40    ///
41    /// Unlike [`Command::SetStyle`], this does not reset prior terminal state.
42    /// It is intended for a caller that owns a self-contained style scope and
43    /// closes it with [`Self::write_style_reset`].
44    pub fn write_style_prefix(&mut self, style: TerminalStyle) -> io::Result<()> {
45        write_style_sequence(&mut self.writer, style, false)
46    }
47
48    /// Writes the SGR sequence that restores the default style.
49    pub fn write_style_reset(&mut self) -> io::Result<()> {
50        self.writer.write_all(b"\x1b[0m")
51    }
52
53    /// Writes an OSC 8 sequence that starts `link`.
54    pub fn write_hyperlink_start(&mut self, link: TerminalHyperlink<'_>) -> io::Result<()> {
55        write_hyperlink_start(&mut self.writer, link)
56    }
57
58    /// Writes the OSC 8 sequence that closes the active hyperlink.
59    pub fn write_hyperlink_end(&mut self) -> io::Result<()> {
60        write_hyperlink_end(&mut self.writer)
61    }
62}
63
64impl<W: Write> TerminalOutput for AnsiWriter<W> {
65    fn flush(&mut self) -> io::Result<()> {
66        self.writer.flush()
67    }
68}
69
70impl<W: Write> CommandWriter for AnsiWriter<W> {
71    fn write_command(&mut self, command: Command<'_>) -> io::Result<()> {
72        match command {
73            Command::MoveCursor(movement) => write_cursor_move(&mut self.writer, movement),
74            Command::SaveCursorPosition => self.writer.write_all(b"\x1b7"),
75            Command::RestoreCursorPosition => self.writer.write_all(b"\x1b8"),
76            Command::SetCursorVisible(enabled) => private_mode(&mut self.writer, 25, enabled),
77            Command::SetCursorBlinking(enabled) => private_mode(&mut self.writer, 12, enabled),
78            Command::SetCursorAppearance(appearance) => write!(
79                self.writer,
80                "\x1b[{} q",
81                match appearance {
82                    CursorAppearance::UserDefault => 0,
83                    CursorAppearance::BlinkingBlock => 1,
84                    CursorAppearance::SteadyBlock => 2,
85                    CursorAppearance::BlinkingUnderline => 3,
86                    CursorAppearance::SteadyUnderline => 4,
87                    CursorAppearance::BlinkingBar => 5,
88                    CursorAppearance::SteadyBar => 6,
89                }
90            ),
91            Command::SetAlternateScreen(enabled) => private_mode(&mut self.writer, 1049, enabled),
92            Command::SetBracketedPaste(enabled) => private_mode(&mut self.writer, 2004, enabled),
93            Command::SetFocusReporting(enabled) => private_mode(&mut self.writer, 1004, enabled),
94            Command::SetMouseCapture(enabled) => write_mouse_capture(&mut self.writer, enabled),
95            Command::PushKeyboardEnhancement(flags) => {
96                write!(self.writer, "\x1b[>{}u", flags.bits())
97            }
98            Command::PopKeyboardEnhancement => self.writer.write_all(b"\x1b[<1u"),
99            Command::Clear(region) => write!(self.writer, "\x1b[{}", clear_sequence(region)),
100            Command::Scroll(rows) if rows > 0 => write!(self.writer, "\x1b[{rows}S"),
101            Command::Scroll(rows) if rows < 0 => {
102                write!(self.writer, "\x1b[{}T", rows.unsigned_abs())
103            }
104            Command::Scroll(_) => Ok(()),
105            Command::SetSize(size) => {
106                write!(self.writer, "\x1b[8;{};{}t", size.rows(), size.columns())
107            }
108            Command::SetTitle(title) => {
109                self.writer.write_all(b"\x1b]0;")?;
110                self.writer.write_all(title.as_str().as_bytes())?;
111                self.writer.write_all(b"\x1b\\")
112            }
113            Command::SetLineWrap(enabled) => private_mode(&mut self.writer, 7, enabled),
114            Command::SetSynchronizedUpdate(enabled) => {
115                private_mode(&mut self.writer, 2026, enabled)
116            }
117            Command::SetStyle(style) => write_style_sequence(&mut self.writer, style, true),
118            Command::ResetStyle => self.write_style_reset(),
119            Command::SetHyperlink(Some(link)) => self.write_hyperlink_start(link),
120            Command::SetHyperlink(None) => self.write_hyperlink_end(),
121            Command::ApplicationProgram(payload) => {
122                write_control_string(&mut self.writer, b"\x1b_", payload)
123            }
124            Command::DeviceControl(payload) => {
125                write_control_string(&mut self.writer, b"\x1bP", payload)
126            }
127            Command::Print(text) => self.writer.write_all(text.as_str().as_bytes()),
128            Command::LineFeed => self.writer.write_all(b"\n"),
129            Command::CarriageReturnLineFeed => self.writer.write_all(b"\r\n"),
130        }
131    }
132}
133
134fn private_mode(writer: &mut impl Write, mode: u16, enabled: bool) -> io::Result<()> {
135    write!(writer, "\x1b[?{mode}{}", if enabled { 'h' } else { 'l' })
136}
137
138fn write_mouse_capture(writer: &mut impl Write, enabled: bool) -> io::Result<()> {
139    let suffix = if enabled { 'h' } else { 'l' };
140    for mode in [1000, 1002, 1003, 1006] {
141        write!(writer, "\x1b[?{mode}{suffix}")?;
142    }
143    Ok(())
144}
145
146fn write_cursor_move(writer: &mut impl Write, movement: CursorMove) -> io::Result<()> {
147    match movement {
148        CursorMove::To(position) => write!(
149            writer,
150            "\x1b[{};{}H",
151            position.row().saturating_add(1),
152            position.column().saturating_add(1)
153        ),
154        CursorMove::ToColumn(column) => write!(writer, "\x1b[{}G", column.saturating_add(1)),
155        CursorMove::ToRow(row) => write!(writer, "\x1b[{}d", row.saturating_add(1)),
156        CursorMove::By { columns, rows } => {
157            write_signed_move(writer, rows, 'B', 'A')?;
158            write_signed_move(writer, columns, 'C', 'D')
159        }
160        CursorMove::ToNextLine(lines) => write!(writer, "\x1b[{lines}E"),
161        CursorMove::ToPreviousLine(lines) => write!(writer, "\x1b[{lines}F"),
162    }
163}
164
165fn write_signed_move(
166    writer: &mut impl Write,
167    amount: i32,
168    positive: char,
169    negative: char,
170) -> io::Result<()> {
171    match amount.cmp(&0) {
172        std::cmp::Ordering::Greater => write!(writer, "\x1b[{amount}{positive}"),
173        std::cmp::Ordering::Less => write!(writer, "\x1b[{}{negative}", amount.unsigned_abs()),
174        std::cmp::Ordering::Equal => Ok(()),
175    }
176}
177
178const fn clear_sequence(region: ClearRegion) -> &'static str {
179    match region {
180        ClearRegion::Screen => "2J",
181        ClearRegion::ScreenAndScrollback => "3J",
182        ClearRegion::BeforeCursor => "1J",
183        ClearRegion::AfterCursor => "0J",
184        ClearRegion::Line => "2K",
185        ClearRegion::AfterCursorInLine => "0K",
186    }
187}
188
189fn write_style_sequence(
190    writer: &mut impl Write,
191    style: TerminalStyle,
192    reset: bool,
193) -> io::Result<()> {
194    let mut sequence = io::Cursor::new([0_u8; 128]);
195    sequence.write_all(b"\x1b[")?;
196    let mut has_parameter = reset;
197    if reset {
198        sequence.write_all(b"0")?;
199    }
200    let mut underline = style.underline;
201    for attribute in style.attributes {
202        if matches!(
203            attribute,
204            TextAttribute::SlowBlink
205                | TextAttribute::RapidBlink
206                | TextAttribute::Reversed
207                | TextAttribute::Hidden
208                | TextAttribute::CrossedOut
209                | TextAttribute::Fraktur
210                | TextAttribute::Framed
211                | TextAttribute::Encircled
212                | TextAttribute::Overlined
213        ) && let Some(underline) = underline.take()
214        {
215            write_underline_parameter(&mut sequence, &mut has_parameter, underline)?;
216        }
217        write_parameter(
218            &mut sequence,
219            &mut has_parameter,
220            match attribute {
221                TextAttribute::Bold => "1",
222                TextAttribute::Dim => "2",
223                TextAttribute::Italic => "3",
224                TextAttribute::SlowBlink => "5",
225                TextAttribute::RapidBlink => "6",
226                TextAttribute::Reversed => "7",
227                TextAttribute::Hidden => "8",
228                TextAttribute::CrossedOut => "9",
229                TextAttribute::Fraktur => "20",
230                TextAttribute::Framed => "51",
231                TextAttribute::Encircled => "52",
232                TextAttribute::Overlined => "53",
233            },
234        )?;
235    }
236    if let Some(underline) = underline {
237        write_underline_parameter(&mut sequence, &mut has_parameter, underline)?;
238    }
239    if let Some(color) = style.foreground {
240        write_color_parameter(&mut sequence, &mut has_parameter, 38, color)?;
241    }
242    if let Some(color) = style.background {
243        write_color_parameter(&mut sequence, &mut has_parameter, 48, color)?;
244    }
245    if let Some(color) = style.underline.and_then(Underline::get_color) {
246        write_color_parameter(&mut sequence, &mut has_parameter, 58, color)?;
247    }
248    if !has_parameter {
249        return Ok(());
250    }
251    sequence.write_all(b"m")?;
252
253    let length = sequence.position() as usize;
254    writer.write_all(&sequence.get_ref()[..length])
255}
256
257fn write_parameter(
258    writer: &mut impl Write,
259    has_parameter: &mut bool,
260    parameter: &str,
261) -> io::Result<()> {
262    if *has_parameter {
263        writer.write_all(b";")?;
264    }
265    writer.write_all(parameter.as_bytes())?;
266    *has_parameter = true;
267    Ok(())
268}
269
270fn write_underline_parameter(
271    writer: &mut impl Write,
272    has_parameter: &mut bool,
273    underline: Underline,
274) -> io::Result<()> {
275    let sgr = match underline.get_style() {
276        UnderlineStyle::Single => "4",
277        UnderlineStyle::Double => "4:2",
278        UnderlineStyle::Curly => "4:3",
279        UnderlineStyle::Dotted => "4:4",
280        UnderlineStyle::Dashed => "4:5",
281    };
282    write_parameter(writer, has_parameter, sgr)
283}
284
285fn write_color_parameter(
286    writer: &mut impl Write,
287    has_parameter: &mut bool,
288    channel: u8,
289    color: Color,
290) -> io::Result<()> {
291    if *has_parameter {
292        writer.write_all(b";")?;
293    }
294    *has_parameter = true;
295    match color {
296        Color::Ansi(index @ 0..=7) => {
297            let base = if channel == 38 {
298                30
299            } else if channel == 48 {
300                40
301            } else {
302                channel
303            };
304            if channel == 58 {
305                write!(writer, "58;5;{index}")
306            } else {
307                write!(writer, "{}", base + index)
308            }
309        }
310        Color::Ansi(index @ 8..=15) if channel != 58 => {
311            let base = if channel == 38 { 90 } else { 100 };
312            write!(writer, "{}", base + index - 8)
313        }
314        Color::Ansi(index) | Color::Ansi256(index) => {
315            write!(writer, "{channel};5;{index}")
316        }
317        Color::Rgb(red, green, blue) => {
318            write!(writer, "{channel};2;{red};{green};{blue}")
319        }
320    }
321}
322
323pub(crate) fn write_hyperlink_start(
324    writer: &mut impl Write,
325    link: TerminalHyperlink<'_>,
326) -> io::Result<()> {
327    writer.write_all(b"\x1b]8;")?;
328    for (index, parameter) in link.parameters.iter().enumerate() {
329        if index != 0 {
330            writer.write_all(b":")?;
331        }
332        write_osc_field(writer, parameter.key, b":;=")?;
333        writer.write_all(b"=")?;
334        write_osc_field(writer, parameter.value, b":;=")?;
335    }
336    writer.write_all(b";")?;
337    write_osc_field(writer, link.uri, b"")?;
338    writer.write_all(b"\x1b\\")
339}
340
341pub(crate) fn write_hyperlink_end(writer: &mut impl Write) -> io::Result<()> {
342    writer.write_all(b"\x1b]8;;\x1b\\")
343}
344
345fn write_osc_field(writer: &mut impl Write, value: &str, separators: &[u8]) -> io::Result<()> {
346    for character in value.chars() {
347        let mut encoded = [0; 4];
348        let bytes = character.encode_utf8(&mut encoded).as_bytes();
349        if character.is_control() || bytes.len() == 1 && separators.contains(&bytes[0]) {
350            for byte in bytes {
351                write!(writer, "%{byte:02X}")?;
352            }
353        } else {
354            writer.write_all(bytes)?;
355        }
356    }
357    Ok(())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::{
364        ControlString, HyperlinkParameter, Position, TerminalHyperlink, TerminalText,
365        TextAttributes,
366    };
367
368    #[derive(Default)]
369    struct CountingWriter {
370        bytes: Vec<u8>,
371        writes: usize,
372        vectored_writes: usize,
373    }
374
375    impl Write for CountingWriter {
376        fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
377            self.writes += 1;
378            self.bytes.extend_from_slice(buffer);
379            Ok(buffer.len())
380        }
381
382        fn flush(&mut self) -> io::Result<()> {
383            Ok(())
384        }
385
386        fn write_vectored(&mut self, buffers: &[io::IoSlice<'_>]) -> io::Result<usize> {
387            self.vectored_writes += 1;
388            let length = buffers.iter().map(|buffer| buffer.len()).sum();
389            for buffer in buffers {
390                self.bytes.extend_from_slice(buffer);
391            }
392            Ok(length)
393        }
394    }
395
396    #[derive(Default)]
397    struct InterruptedOnceWriter {
398        bytes: Vec<u8>,
399        interrupted: bool,
400    }
401
402    impl Write for InterruptedOnceWriter {
403        fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
404            unreachable!("control strings use vectored writes")
405        }
406
407        fn flush(&mut self) -> io::Result<()> {
408            Ok(())
409        }
410
411        fn write_vectored(&mut self, buffers: &[io::IoSlice<'_>]) -> io::Result<usize> {
412            if !self.interrupted {
413                self.interrupted = true;
414                return Err(io::Error::from(io::ErrorKind::Interrupted));
415            }
416            let length = buffers.iter().map(|buffer| buffer.len()).sum();
417            for buffer in buffers {
418                self.bytes.extend_from_slice(buffer);
419            }
420            Ok(length)
421        }
422    }
423
424    #[test]
425    fn commands_encode_without_crossterm_types() {
426        let mut output = AnsiWriter::new(Vec::new());
427        output
428            .write_command(Command::MoveCursor(CursorMove::To(Position::new(3, 2))))
429            .expect("move encodes");
430        output
431            .write_command(Command::SetAlternateScreen(true))
432            .expect("mode encodes");
433        output
434            .write_command(Command::Print(
435                TerminalText::try_from("漆").expect("printable text"),
436            ))
437            .expect("text encodes");
438
439        assert_eq!(output.into_inner(), b"\x1b[3;4H\x1b[?1049h\xe6\xbc\x86");
440    }
441
442    #[test]
443    fn control_strings_receive_backend_owned_framing() {
444        let mut output = AnsiWriter::new(CountingWriter::default());
445        output
446            .write_command(Command::ApplicationProgram(
447                ControlString::try_from("Ga=T;AAAA").expect("valid APC payload"),
448            ))
449            .expect("APC encodes");
450        output
451            .write_command(Command::DeviceControl(
452                ControlString::try_from("q#0;2;0;0;0").expect("valid DCS payload"),
453            ))
454            .expect("DCS encodes");
455
456        let output = output.into_inner();
457        assert_eq!(output.bytes, b"\x1b_Ga=T;AAAA\x1b\\\x1bPq#0;2;0;0;0\x1b\\");
458        assert_eq!(output.vectored_writes, 2);
459    }
460
461    #[test]
462    fn control_string_writes_retry_after_interruption() {
463        let mut output = AnsiWriter::new(InterruptedOnceWriter::default());
464        output
465            .write_command(Command::ApplicationProgram(
466                ControlString::try_from("Ga=T;AAAA").expect("valid APC payload"),
467            ))
468            .expect("interrupted APC write is retried");
469
470        assert_eq!(output.into_inner().bytes, b"\x1b_Ga=T;AAAA\x1b\\");
471    }
472
473    #[test]
474    fn hyperlink_fields_cannot_break_the_osc_structure() {
475        let parameters = [HyperlinkParameter {
476            key: "id:semicolon",
477            value: "a=b",
478        }];
479        let mut output = AnsiWriter::new(Vec::new());
480        output
481            .write_command(Command::SetHyperlink(Some(TerminalHyperlink {
482                uri: "https://example.test/a;b",
483                parameters: &parameters,
484            })))
485            .expect("hyperlink encodes");
486
487        assert_eq!(
488            output.into_inner(),
489            b"\x1b]8;id%3Asemicolon=a%3Db;https://example.test/a;b\x1b\\"
490        );
491    }
492
493    #[test]
494    fn complete_style_is_one_sgr_sequence_and_one_output_write() {
495        let style = TerminalStyle {
496            foreground: Some(Color::Rgb(1, 2, 3)),
497            background: Some(Color::Rgb(4, 5, 6)),
498            attributes: TextAttributes::all(),
499            underline: Some(Underline::new(UnderlineStyle::Dashed).color((7, 8, 9))),
500        };
501        let mut output = AnsiWriter::new(CountingWriter::default());
502
503        output
504            .write_command(Command::SetStyle(style))
505            .expect("style encodes");
506
507        let writer = output.into_inner();
508        assert_eq!(writer.writes, 1);
509        assert_eq!(
510            writer.bytes,
511            b"\x1b[0;1;2;3;4:5;5;6;7;8;9;20;51;52;53;38;2;1;2;3;48;2;4;5;6;58;2;7;8;9m"
512        );
513    }
514
515    #[test]
516    fn scoped_style_omits_an_empty_prefix_and_writes_an_explicit_reset() {
517        let mut output = AnsiWriter::new(Vec::new());
518
519        output
520            .write_style_prefix(TerminalStyle::default())
521            .expect("empty style encodes");
522        output.write_style_reset().expect("reset encodes");
523
524        assert_eq!(output.into_inner(), b"\x1b[0m");
525    }
526
527    #[test]
528    fn hyperlink_encoding_escapes_delimiters_and_unicode_controls_at_the_boundary() {
529        let parameters = [HyperlinkParameter {
530            key: "i:d=;\u{9d}",
531            value: "v:a=l;ue\n\u{9b}",
532        }];
533        let mut output = AnsiWriter::new(Vec::new());
534
535        output
536            .write_hyperlink_start(TerminalHyperlink {
537                uri: "https://example.test/a\u{1b}\\b\u{7}\u{9c}",
538                parameters: &parameters,
539            })
540            .expect("hyperlink encodes");
541        output.write_hyperlink_end().expect("hyperlink closes");
542
543        assert_eq!(
544            output.into_inner(),
545            concat!(
546                "\x1b]8;i%3Ad%3D%3B%C2%9D=v%3Aa%3Dl%3Bue%0A%C2%9B;",
547                "https://example.test/a%1B\\b%07%C2%9C\x1b\\",
548                "\x1b]8;;\x1b\\",
549            )
550            .as_bytes()
551        );
552    }
553}