1use 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
11pub 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 pub fn write_style_prefix(&mut self, style: TerminalStyle) -> io::Result<()> {
45 write_style_sequence(&mut self.writer, style, false)
46 }
47
48 pub fn write_style_reset(&mut self) -> io::Result<()> {
50 self.writer.write_all(b"\x1b[0m")
51 }
52
53 pub fn write_hyperlink_start(&mut self, link: TerminalHyperlink<'_>) -> io::Result<()> {
55 write_hyperlink_start(&mut self.writer, link)
56 }
57
58 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: ¶meters,
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: ¶meters,
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}