Skip to main content

badterm_core/
ansi.rs

1//! ANSI-BBS / ANSI X3.64 terminal emulation.
2//!
3//! [`Terminal`] consumes the byte stream from the remote system, drives a
4//! [`Screen`], and collects any replies (cursor position reports, etc.) that
5//! must be sent back.
6
7use serde::{Deserialize, Serialize};
8
9use crate::cp437;
10use crate::music::{self, Tone};
11use crate::palette::{ANSI_TO_VGA, rgb};
12use crate::screen::{Cell, Color, DEFAULT_BG, DEFAULT_FG, Look, Screen, flags};
13
14/// How incoming bytes are interpreted: the character set, and the
15/// emulations that go with it.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Encoding {
19    #[default]
20    Cp437,
21    Utf8,
22    /// Commodore 64 (see [`crate::petscii`]).
23    Petscii,
24    /// Atari 8-bit (see [`crate::atascii`]).
25    Atascii,
26    /// CP437 with AVT/0+ codes as well as ANSI (see [`crate::avatar`]).
27    Avatar,
28    /// CP437 ANSI with RIPscrip graphics (see [`crate::rip`]).
29    Rip,
30}
31
32impl Encoding {
33    pub const ALL: [Encoding; 6] =
34        [Encoding::Cp437, Encoding::Utf8, Encoding::Petscii, Encoding::Atascii, Encoding::Avatar, Encoding::Rip];
35
36    pub fn name(self) -> &'static str {
37        match self {
38            Encoding::Cp437 => "CP437",
39            Encoding::Utf8 => "UTF-8",
40            Encoding::Petscii => "PETSCII",
41            Encoding::Atascii => "ATASCII",
42            Encoding::Avatar => "Avatar",
43            Encoding::Rip => "RIPscrip",
44        }
45    }
46
47    /// A C64 or Atari system, with its own screen size and font.
48    pub fn is_retro(self) -> bool {
49        matches!(self, Encoding::Petscii | Encoding::Atascii)
50    }
51
52    /// The screen such a system has, if it has its own.
53    pub fn screen_size(self) -> Option<(usize, usize)> {
54        match self {
55            Encoding::Petscii => Some((crate::petscii::COLS, crate::petscii::ROWS)),
56            Encoding::Atascii => Some((crate::atascii::COLS, crate::atascii::ROWS)),
57            Encoding::Rip => Some((crate::rip::COLS, crate::rip::ROWS)),
58            _ => None,
59        }
60    }
61
62    /// Typed or pasted text as the remote expects it. A CR (Enter) becomes
63    /// the system's own end of line.
64    pub fn encode_text(self, text: &str) -> Vec<u8> {
65        match self {
66            Encoding::Cp437 | Encoding::Avatar | Encoding::Rip => cp437::encode(text),
67            Encoding::Utf8 => text.as_bytes().to_vec(),
68            Encoding::Petscii => text
69                .chars()
70                .filter_map(|c| if c == '\r' { Some(0x0D) } else { crate::petscii::encode_char(c) })
71                .collect(),
72            Encoding::Atascii => text
73                .chars()
74                .filter_map(|c| if c == '\r' { Some(0x9B) } else { crate::atascii::encode_char(c) })
75                .collect(),
76        }
77    }
78}
79
80const MAX_PARAMS: usize = 32;
81const MAX_STRING: usize = 4096;
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84enum State {
85    Ground,
86    Escape,
87    /// ESC followed by an intermediate byte, e.g. `ESC ( B`; waiting for the final byte.
88    EscIntermediate,
89    Csi,
90    /// OSC: collected for the window title.
91    Osc,
92    /// DCS, SOS, PM, APC: ignored until ST.
93    IgnoreString,
94    /// Saw ESC inside a string; `\` terminates it.
95    StringEsc,
96    /// An ANSI music string, up to Ctrl-N.
97    Music,
98}
99
100/// Things the front end may want to react to.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub enum TermEvent {
103    Bell,
104    Title(String),
105    /// ANSI music to play.
106    Music(Vec<Tone>),
107}
108
109pub struct Terminal {
110    pub screen: Screen,
111    pub encoding: Encoding,
112    /// Treat `ESC [ M` with no parameter as the start of ANSI music, as
113    /// many BBS doors expect, instead of "delete one line". `ESC [ N` and
114    /// `ESC [ |` always start music.
115    pub music_on_csi_m: bool,
116    state: State,
117    string_is_osc: bool,
118    private: u8,
119    intermediate: u8,
120    params: Vec<u32>,
121    param_present: bool,
122    osc: Vec<u8>,
123    utf8_buf: [u8; 4],
124    utf8_len: usize,
125    utf8_need: usize,
126    replies: Vec<u8>,
127    events: Vec<TermEvent>,
128    petscii: crate::petscii::Petscii,
129    atascii: crate::atascii::Atascii,
130    avatar: crate::avatar::Avatar,
131    /// The RIPscrip canvas, when the encoding is RIP.
132    rip: Option<Box<crate::rip::Rip>>,
133    /// Nothing has been printed on this line yet (where a RIP line can
134    /// start).
135    line_start: bool,
136}
137
138impl Terminal {
139    pub fn new(cols: usize, rows: usize, scrollback: usize) -> Self {
140        Terminal {
141            screen: Screen::new(cols, rows, scrollback),
142            encoding: Encoding::Cp437,
143            music_on_csi_m: false,
144            state: State::Ground,
145            string_is_osc: false,
146            private: 0,
147            intermediate: 0,
148            params: Vec::with_capacity(MAX_PARAMS),
149            param_present: false,
150            osc: Vec::new(),
151            utf8_buf: [0; 4],
152            utf8_len: 0,
153            utf8_need: 0,
154            replies: Vec::new(),
155            events: Vec::new(),
156            petscii: Default::default(),
157            atascii: Default::default(),
158            avatar: Default::default(),
159            rip: None,
160            line_start: true,
161        }
162    }
163
164    /// Set up the screen for the encoding: a C64 or Atari system gets that
165    /// machine's colors and font.
166    pub fn start_emulation(&mut self) {
167        match self.encoding {
168            Encoding::Petscii => self.petscii.start(&mut self.screen),
169            Encoding::Atascii => self.atascii.start(&mut self.screen),
170            Encoding::Rip => self.rip = Some(Box::default()),
171            _ => {}
172        }
173    }
174
175    /// The RIPscrip canvas, for a RIP system.
176    pub fn rip(&self) -> Option<&crate::rip::Rip> {
177        self.rip.as_deref()
178    }
179
180    pub fn rip_mut(&mut self) -> Option<&mut crate::rip::Rip> {
181        self.rip.as_deref_mut()
182    }
183
184    /// Bytes the terminal wants sent to the remote (e.g. CPR replies).
185    pub fn take_replies(&mut self) -> Vec<u8> {
186        std::mem::take(&mut self.replies)
187    }
188
189    pub fn take_events(&mut self) -> Vec<TermEvent> {
190        std::mem::take(&mut self.events)
191    }
192
193    pub fn feed(&mut self, data: &[u8]) {
194        match self.encoding {
195            Encoding::Petscii => {
196                for &b in data {
197                    self.petscii.byte(&mut self.screen, b, &mut self.events);
198                }
199            }
200            Encoding::Atascii => {
201                for &b in data {
202                    self.atascii.byte(&mut self.screen, b, &mut self.events);
203                }
204            }
205            _ => {
206                for &b in data {
207                    self.byte(b);
208                }
209            }
210        }
211    }
212
213    fn byte(&mut self, b: u8) {
214        if self.state == State::Ground {
215            let taken = match self.encoding {
216                Encoding::Avatar => self.avatar_byte(b),
217                Encoding::Rip => self.rip_byte(b),
218                _ => false,
219            };
220            if taken {
221                return;
222            }
223        }
224        // CAN and SUB abort any sequence in progress (ECMA-48).
225        if self.state != State::Ground && (b == 0x18 || b == 0x1A) {
226            self.state = State::Ground;
227            return;
228        }
229        match self.state {
230            State::Ground => self.ground(b),
231            State::Escape => self.escape(b),
232            State::EscIntermediate => self.state = State::Ground,
233            State::Csi => self.csi(b),
234            State::Osc | State::IgnoreString => match b {
235                0x07 if self.state == State::Osc => self.finish_string(),
236                0x1B => self.state = State::StringEsc,
237                _ if self.state == State::Osc && self.osc.len() < MAX_STRING => self.osc.push(b),
238                _ => {}
239            },
240            State::Music => match b {
241                0x0E => {
242                    self.state = State::Ground;
243                    let tones = music::parse(&self.osc);
244                    if !tones.is_empty() {
245                        self.events.push(TermEvent::Music(tones));
246                    }
247                }
248                _ if self.osc.len() < MAX_STRING => self.osc.push(b),
249                // Too long to be music: give up on it.
250                _ => self.state = State::Ground,
251            },
252            State::StringEsc => {
253                if b == b'\\' {
254                    self.finish_string();
255                } else {
256                    // Not ST: abandon the string and treat this as a new escape.
257                    self.state = State::Escape;
258                    self.escape(b);
259                }
260            }
261        }
262    }
263
264    /// AVATAR codes, in the text stream (see [`crate::avatar`]).
265    fn avatar_byte(&mut self, b: u8) -> bool {
266        use crate::avatar::Outcome;
267        match self.avatar.byte(&mut self.screen, b) {
268            Outcome::Pass => false,
269            Outcome::Done => true,
270            Outcome::Show(bytes) => {
271                for b in bytes {
272                    self.byte(b);
273                }
274                true
275            }
276        }
277    }
278
279    /// RIP lines, which start a line with `!|` (see [`crate::rip`]).
280    fn rip_byte(&mut self, b: u8) -> bool {
281        use crate::rip::Feed;
282        let Some(rip) = self.rip.as_mut() else { return false };
283        match rip.byte(&mut self.screen, b, self.line_start) {
284            Feed::Pass => false,
285            Feed::Done => true,
286            Feed::Text(bytes) => {
287                self.line_start = false;
288                for b in bytes {
289                    self.byte(b);
290                }
291                true
292            }
293        }
294    }
295
296    fn ground(&mut self, b: u8) {
297        if self.encoding == Encoding::Utf8 && self.utf8_need > 0 {
298            if b & 0xC0 == 0x80 {
299                self.utf8_buf[self.utf8_len] = b;
300                self.utf8_len += 1;
301                if self.utf8_len == self.utf8_need {
302                    let ch = std::str::from_utf8(&self.utf8_buf[..self.utf8_len]).ok().and_then(|s| s.chars().next());
303                    self.utf8_need = 0;
304                    self.screen.put(ch.and_then(cp437::from_unicode).unwrap_or(b'?'));
305                }
306                return;
307            }
308            // Truncated sequence: show a replacement and handle this byte normally.
309            self.utf8_need = 0;
310            self.screen.put(b'?');
311        }
312        match b {
313            0x00 | 0x7F => {}
314            0x07 => self.events.push(TermEvent::Bell),
315            0x08 => self.screen.backspace(),
316            0x09 => self.screen.tab(),
317            0x0A => {
318                self.screen.line_feed();
319                self.line_start = true;
320            }
321            0x0C => self.screen.erase_display(2),
322            0x0D => {
323                self.screen.carriage_return();
324                self.line_start = true;
325            }
326            0x1B => self.state = State::Escape,
327            _ => match self.encoding {
328                // Like ANSI.SYS, every other byte is a glyph, including the
329                // "graphic" control characters (☺, ♥, ►, ...).
330                Encoding::Cp437 | Encoding::Petscii | Encoding::Atascii | Encoding::Avatar | Encoding::Rip => {
331                    if self.avatar.insert {
332                        self.screen.insert_chars(1);
333                    }
334                    self.line_start = false;
335                    self.screen.put(b)
336                }
337                Encoding::Utf8 => match b {
338                    0x01..=0x1F => {}
339                    0x20..=0x7E => self.screen.put(b),
340                    0xC2..=0xDF => self.utf8_start(b, 2),
341                    0xE0..=0xEF => self.utf8_start(b, 3),
342                    0xF0..=0xF4 => self.utf8_start(b, 4),
343                    _ => self.screen.put(b'?'),
344                },
345            },
346        }
347    }
348
349    fn utf8_start(&mut self, b: u8, need: usize) {
350        self.utf8_buf[0] = b;
351        self.utf8_len = 1;
352        self.utf8_need = need;
353    }
354
355    fn escape(&mut self, b: u8) {
356        self.state = State::Ground;
357        match b {
358            b'[' => {
359                self.state = State::Csi;
360                self.private = 0;
361                self.intermediate = 0;
362                self.params.clear();
363                self.param_present = false;
364            }
365            b']' => {
366                self.state = State::Osc;
367                self.string_is_osc = true;
368                self.osc.clear();
369            }
370            b'P' | b'X' | b'^' | b'_' => {
371                self.state = State::IgnoreString;
372                self.string_is_osc = false;
373            }
374            0x20..=0x2F => self.state = State::EscIntermediate,
375            b'7' => self.screen.save_cursor(),
376            b'8' => self.screen.restore_cursor(),
377            b'c' => self.screen.reset(),
378            b'D' => self.screen.line_feed(),
379            b'E' => {
380                self.screen.carriage_return();
381                self.screen.line_feed();
382            }
383            b'M' => self.screen.reverse_index(),
384            0x1B => self.state = State::Escape,
385            _ => log::debug!("unhandled ESC {:?}", b as char),
386        }
387    }
388
389    fn finish_string(&mut self) {
390        self.state = State::Ground;
391        if !self.string_is_osc {
392            return;
393        }
394        let s = String::from_utf8_lossy(&self.osc).into_owned();
395        let (code, rest) = s.split_once(';').unwrap_or((s.as_str(), ""));
396        match code {
397            // Set the window title.
398            "0" | "2" => self.events.push(TermEvent::Title(rest.to_string())),
399            "4" => self.set_palette(rest),
400            "104" => self.reset_palette(rest),
401            _ => log::debug!("unhandled OSC {code}"),
402        }
403    }
404
405    /// `OSC 4 ; index ; color [; index ; color ...]`, where a color is
406    /// `rgb:R/G/B` (1-4 hex digits each), `#RRGGBB`, or `?` to ask for it.
407    fn set_palette(&mut self, args: &str) {
408        let mut parts = args.split(';');
409        while let (Some(i), Some(spec)) = (parts.next(), parts.next()) {
410            let Ok(i) = i.trim().parse::<u8>() else { continue };
411            if spec == "?" {
412                let c = self.screen.look.palette[i as usize];
413                let reply = format!("\x1b]4;{i};rgb:{:02x}/{:02x}/{:02x}\x1b\\", c >> 16, (c >> 8) & 0xFF, c & 0xFF);
414                self.replies.extend_from_slice(reply.as_bytes());
415            } else if let Some(c) = parse_color(spec) {
416                self.screen.look.palette[i as usize] = c;
417            }
418        }
419    }
420
421    /// `OSC 104` resets the whole palette; `OSC 104 ; index ...` just those.
422    fn reset_palette(&mut self, args: &str) {
423        let standard = &Look::standard().palette;
424        if args.trim().is_empty() {
425            self.screen.look.palette = *standard;
426            return;
427        }
428        for i in args.split(';').filter_map(|i| i.trim().parse::<u8>().ok()) {
429            self.screen.look.palette[i as usize] = standard[i as usize];
430        }
431    }
432
433    fn csi(&mut self, b: u8) {
434        match b {
435            b'0'..=b'9' => {
436                if self.params.is_empty() {
437                    self.params.push(0);
438                }
439                let p = self.params.last_mut().unwrap();
440                *p = p.saturating_mul(10).saturating_add((b - b'0') as u32);
441                self.param_present = true;
442            }
443            b';' | b':' => {
444                if self.params.is_empty() {
445                    self.params.push(0);
446                }
447                if self.params.len() < MAX_PARAMS {
448                    self.params.push(0);
449                }
450                self.param_present = true;
451            }
452            0x3C..=0x3F => {
453                if !self.param_present && self.private == 0 {
454                    self.private = b;
455                }
456            }
457            // `ESC [ !` asks whether this is a RIPscrip terminal.
458            b'!' if self.encoding == Encoding::Rip && self.params.is_empty() && self.private == 0 => {
459                self.state = State::Ground;
460                self.replies.extend_from_slice(crate::rip::VERSION_REPLY);
461            }
462            0x20..=0x2F => self.intermediate = b,
463            0x40..=0x7E => {
464                self.state = State::Ground;
465                self.dispatch_csi(b);
466            }
467            // C0 controls are executed even in the middle of a sequence.
468            0x00..=0x1F => {
469                if b == 0x1B {
470                    self.state = State::Escape;
471                } else {
472                    self.ground(b);
473                }
474            }
475            _ => self.state = State::Ground,
476        }
477    }
478
479    /// Parameter `i`, with 0 or missing replaced by `default`.
480    fn p(&self, i: usize, default: u32) -> u32 {
481        match self.params.get(i) {
482            Some(&v) if v != 0 => v,
483            _ => default,
484        }
485    }
486
487    fn dispatch_csi(&mut self, b: u8) {
488        if self.intermediate != 0 {
489            // CTerm font selection: `CSI slot ; font SP D`.
490            if self.intermediate == b' ' && b == b'D' && self.private == 0 {
491                let slot = self.params.first().copied().unwrap_or(0) as usize;
492                let font = self.params.get(1).copied().unwrap_or(0);
493                if slot < 4 && font <= 255 {
494                    self.screen.look.fonts[slot] = font as u8;
495                }
496                return;
497            }
498            log::debug!("unhandled CSI with intermediate {:?} {:?}", self.intermediate as char, b as char);
499            return;
500        }
501        match self.private {
502            0 => {}
503            b'?' => return self.dec_mode(b),
504            _ => {
505                log::debug!("unhandled private CSI {:?}{:?} {:?}", self.private as char, b as char, self.params);
506                return;
507            }
508        }
509        let no_params = self.params.is_empty();
510        if b == b'|' || no_params && (b == b'N' || b == b'M' && self.music_on_csi_m) {
511            self.state = State::Music;
512            self.osc.clear();
513            return;
514        }
515        let n = self.p(0, 1) as usize;
516        let s = &mut self.screen;
517        match b {
518            b'@' => s.insert_chars(n),
519            b'A' => s.move_by(0, -(n as isize)),
520            b'B' | b'e' => s.move_by(0, n as isize),
521            b'C' | b'a' => s.move_by(n as isize, 0),
522            b'D' => s.move_by(-(n as isize), 0),
523            b'E' => {
524                s.move_by(0, n as isize);
525                s.carriage_return();
526            }
527            b'F' => {
528                s.move_by(0, -(n as isize));
529                s.carriage_return();
530            }
531            b'G' | b'`' => {
532                let y = s.cursor().1;
533                s.move_to(n - 1, y);
534            }
535            b'd' => {
536                let x = s.cursor().0;
537                s.move_to(x, n - 1);
538            }
539            b'H' | b'f' => {
540                let (row, col) = (self.p(0, 1) as usize, self.p(1, 1) as usize);
541                self.screen.move_to(col - 1, row - 1);
542            }
543            b'J' => s.erase_display(self.params.first().copied().unwrap_or(0)),
544            b'K' => s.erase_line(self.params.first().copied().unwrap_or(0)),
545            b'L' => s.insert_lines(n),
546            b'M' => s.delete_lines(n),
547            b'P' => s.delete_chars(n),
548            b'S' => s.scroll_up(n),
549            b'T' => s.scroll_down(n),
550            b'X' => s.erase_chars(n),
551            b'Z' => s.back_tab(n),
552            // Device Attributes: answer as a plain VT100. Claiming CTerm would
553            // invite font and palette sequences that are not supported yet.
554            b'c' if self.params.first().is_none_or(|&v| v == 0) => self.replies.extend_from_slice(b"\x1b[?1;0c"),
555            b'm' => self.sgr(),
556            b'n' => match self.params.first().copied().unwrap_or(0) {
557                5 => self.replies.extend_from_slice(b"\x1b[0n"),
558                6 => {
559                    let (x, y) = self.screen.cursor();
560                    self.replies.extend_from_slice(format!("\x1b[{};{}R", y + 1, x + 1).as_bytes());
561                }
562                _ => {}
563            },
564            b'r' => {
565                let rows = self.screen.rows() as u32;
566                let (top, bottom) = (self.p(0, 1), self.p(1, rows));
567                self.screen.set_scroll_region(top as usize - 1, bottom as usize - 1);
568            }
569            b's' => s.save_cursor(),
570            b'u' => s.restore_cursor(),
571            // CTerm/PabloDraw 24-bit color: CSI 0;r;g;b t (bg) / CSI 1;r;g;b t (fg).
572            b't' if self.params.len() == 4 => {
573                let c = Color::Rgb(rgb(self.params[1] as u8, self.params[2] as u8, self.params[3] as u8));
574                match self.params[0] {
575                    0 => self.screen.pen.bg = c,
576                    1 => self.screen.pen.fg = c,
577                    _ => {}
578                }
579            }
580            b'h' | b'l' => {}
581            _ => log::debug!("unhandled CSI {:?} {:?}", b as char, self.params),
582        }
583    }
584
585    fn dec_mode(&mut self, b: u8) {
586        let on = match b {
587            b'h' => true,
588            b'l' => false,
589            _ => return,
590        };
591        for &mode in &self.params {
592            match mode {
593                7 => self.screen.autowrap = on,
594                25 => self.screen.cursor_visible = on,
595                // CTerm: blink attribute selects bright background (iCE colors).
596                // CTerm: bright and blink select fonts, and need not
597                // brighten or blink.
598                31 => self.screen.look.bright_font = on,
599                32 => self.screen.look.bright_plain = on,
600                33 => self.screen.ice_colors = on,
601                34 => self.screen.look.blink_font = on,
602                35 => self.screen.look.blink_steady = on,
603                _ => log::debug!("unhandled DEC mode {mode} {on}"),
604            }
605        }
606    }
607
608    fn sgr(&mut self) {
609        if self.params.is_empty() {
610            self.params.push(0);
611        }
612        let pen = &mut self.screen.pen;
613        let mut i = 0;
614        while i < self.params.len() {
615            let p = self.params[i];
616            match p {
617                0 => *pen = Cell { ch: b' ', ..Cell::default() },
618                1 => pen.flags = (pen.flags | flags::BOLD) & !flags::FAINT,
619                2 => pen.flags = (pen.flags | flags::FAINT) & !flags::BOLD,
620                4 => pen.flags |= flags::UNDERLINE,
621                5 | 6 => pen.flags |= flags::BLINK,
622                7 => pen.flags |= flags::REVERSE,
623                8 => pen.flags |= flags::CONCEAL,
624                21 | 22 => pen.flags &= !(flags::BOLD | flags::FAINT),
625                24 => pen.flags &= !flags::UNDERLINE,
626                25 => pen.flags &= !flags::BLINK,
627                27 => pen.flags &= !flags::REVERSE,
628                28 => pen.flags &= !flags::CONCEAL,
629                30..=37 => pen.fg = Color::Pc(ANSI_TO_VGA[(p - 30) as usize]),
630                39 => pen.fg = DEFAULT_FG,
631                40..=47 => pen.bg = Color::Pc(ANSI_TO_VGA[(p - 40) as usize]),
632                49 => pen.bg = DEFAULT_BG,
633                90..=97 => pen.fg = Color::Pc(ANSI_TO_VGA[(p - 90) as usize] + 8),
634                100..=107 => pen.bg = Color::Pc(ANSI_TO_VGA[(p - 100) as usize] + 8),
635                38 | 48 => {
636                    let color = match self.params.get(i + 1) {
637                        Some(5) => {
638                            let c = self.params.get(i + 2).map(|&v| Color::Xterm(v as u8));
639                            i += 2;
640                            c
641                        }
642                        Some(2) => {
643                            let c = (i + 4 < self.params.len()).then(|| {
644                                let v = &self.params[i + 2..i + 5];
645                                Color::Rgb(rgb(v[0] as u8, v[1] as u8, v[2] as u8))
646                            });
647                            i += 4;
648                            c
649                        }
650                        _ => None,
651                    };
652                    if let Some(c) = color {
653                        if p == 38 { pen.fg = c } else { pen.bg = c }
654                    }
655                }
656                _ => log::debug!("unhandled SGR {p}"),
657            }
658            i += 1;
659        }
660    }
661}
662
663/// An X11-style color: `rgb:R/G/B` with 1-4 hex digits per channel, or
664/// `#RRGGBB`.
665fn parse_color(spec: &str) -> Option<crate::palette::Rgb> {
666    let spec = spec.trim();
667    if let Some(hex) = spec.strip_prefix('#') {
668        return (hex.len() == 6).then(|| u32::from_str_radix(hex, 16).ok()).flatten();
669    }
670    let mut channels = spec.strip_prefix("rgb:")?.split('/').map(|c| {
671        let v = u32::from_str_radix(c, 16).ok().filter(|_| (1..=4).contains(&c.len()))?;
672        // Scale to 8 bits: "f" and "ffff" are both full.
673        Some((v * 255 / ((1 << (4 * c.len())) - 1)) as u8)
674    });
675    let (r, g, b) = (channels.next()??, channels.next()??, channels.next()??);
676    channels.next().is_none().then(|| rgb(r, g, b))
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    fn term() -> Terminal {
684        Terminal::new(80, 25, 100)
685    }
686
687    fn line(t: &Terminal, y: usize) -> String {
688        Screen::row_text(t.screen.row(y))
689    }
690
691    #[test]
692    fn cursor_position_and_text() {
693        let mut t = term();
694        t.feed(b"\x1b[3;5HHi");
695        assert_eq!(line(&t, 2), "    Hi");
696        assert_eq!(t.screen.cursor(), (6, 2));
697    }
698
699    #[test]
700    fn sgr_colors() {
701        let mut t = term();
702        t.feed(b"\x1b[1;31;44mX");
703        let c = t.screen.row(0)[0];
704        assert_eq!(c.fg, Color::Pc(4));
705        assert_eq!(c.bg, Color::Pc(1));
706        assert!(c.flags & flags::BOLD != 0);
707        t.feed(b"\x1b[0;38;2;10;20;30mY");
708        assert_eq!(t.screen.row(0)[1].fg, Color::Rgb(0x0A141E));
709        t.feed(b"\x1b[38;5;196mZ");
710        assert_eq!(t.screen.row(0)[2].fg, Color::Xterm(196));
711    }
712
713    #[test]
714    fn cursor_position_report() {
715        let mut t = term();
716        t.feed(b"\x1b[10;20H\x1b[6n");
717        assert_eq!(t.take_replies(), b"\x1b[10;20R");
718    }
719
720    #[test]
721    fn device_attributes() {
722        let mut t = term();
723        t.feed(b"\x1b[c\x1b[0c");
724        assert_eq!(t.take_replies(), b"\x1b[?1;0c\x1b[?1;0c");
725        // Secondary DA (`CSI > c`) and `CSI 1 c` are not answered.
726        t.feed(b"\x1b[>c\x1b[1c");
727        assert!(t.take_replies().is_empty());
728    }
729
730    #[test]
731    fn ice_mode_toggle() {
732        let mut t = term();
733        t.feed(b"\x1b[?33h");
734        assert!(t.screen.ice_colors);
735        t.feed(b"\x1b[?33l");
736        assert!(!t.screen.ice_colors);
737    }
738
739    #[test]
740    fn cp437_glyphs_and_utf8() {
741        let mut t = term();
742        t.feed(&[0xDB, 0x03]);
743        assert_eq!(t.screen.row(0)[0].ch, 0xDB);
744        assert_eq!(t.screen.row(0)[1].ch, 0x03);
745        t.encoding = Encoding::Utf8;
746        t.feed("ۂ".as_bytes());
747        assert_eq!(t.screen.row(0)[2].ch, 0xDB);
748        assert_eq!(t.screen.row(0)[3].ch, 0x82);
749    }
750
751    #[test]
752    fn osc_title_and_strings_ignored() {
753        let mut t = term();
754        t.feed(b"\x1b]0;My BBS\x07\x1bPjunk\x1b\\ok");
755        assert_eq!(t.take_events(), vec![TermEvent::Title("My BBS".into())]);
756        assert_eq!(line(&t, 0), "ok");
757    }
758
759    #[test]
760    fn ansi_art_line_of_exactly_80_columns() {
761        let mut t = term();
762        let mut data = vec![b'#'; 80];
763        data.extend_from_slice(b"\r\nnext");
764        t.feed(&data);
765        assert_eq!(line(&t, 1), "next");
766    }
767
768    #[test]
769    fn save_restore_cursor() {
770        let mut t = term();
771        t.feed(b"\x1b[5;5H\x1b[s\x1b[1;1H\x1b[uX");
772        assert_eq!(t.screen.row(4)[4].ch, b'X');
773    }
774
775    #[test]
776    fn ansi_music() {
777        let mut t = term();
778        t.feed(b"Hi\x1b[NMLO3A\x0e there\x1b[|MLO3A8\x0e");
779        assert_eq!(line(&t, 0), "Hi there");
780        let a = Tone { hz: 440, ms: 500 };
781        assert_eq!(t.take_events(), [TermEvent::Music(vec![a]), TermEvent::Music(vec![Tone { ms: 250, ..a }])]);
782
783        // ESC [ M is delete-line unless music is wanted there.
784        t.feed(b"\x1b[2;1Hx\x1b[1;1H\x1b[M");
785        assert_eq!(line(&t, 0), "x");
786        t.music_on_csi_m = true;
787        t.feed(b"\x1b[MMLO3A\x0e\x1b[1M");
788        assert_eq!(t.take_events(), [TermEvent::Music(vec![a])]);
789        assert_eq!(line(&t, 0), "");
790    }
791
792    #[test]
793    fn cterm_palette_and_fonts() {
794        let mut t = Terminal::new(80, 25, 0);
795        // Red (ANSI 1, VGA 4) becomes orange; bright blue (ANSI 12) too.
796        t.feed(b"\x1b]4;1;rgb:ff/80/00;12;#123456\x1b\\");
797        assert_eq!(t.screen.look.palette[1], 0xFF8000);
798        assert_eq!(t.screen.look.rgb(Color::Pc(4)), 0xFF8000);
799        assert_eq!(t.screen.look.rgb(Color::Pc(9)), 0x123456);
800        t.feed(b"\x1b[31mX");
801        assert_eq!(t.screen.row(0)[0].resolve_with(false, &t.screen.look).0, 0xFF8000);
802        // Queried, then reset.
803        t.feed(b"\x1b]4;1;?\x07");
804        assert_eq!(t.take_replies(), b"\x1b]4;1;rgb:ff/80/00\x1b\\");
805        t.feed(b"\x1b]104;1\x07");
806        assert_eq!(t.screen.look.palette[1], 0xAA0000);
807        assert_eq!(t.screen.look.palette[12], 0x123456);
808        t.feed(b"\x1b]104\x07");
809        assert_eq!(t.screen.look, *Look::standard());
810
811        // Font slots, and bright text in slot 1 without brightening.
812        t.feed(b"\x1b[0;42 D\x1b[1;40 D\x1b[?31h\x1b[?32h\x1b[1;32mB");
813        let cell = t.screen.row(0)[1];
814        assert_eq!(t.screen.look.fonts, [42, 40, 0, 0]);
815        assert_eq!(t.screen.look.font_for(&cell), 40);
816        assert_eq!(cell.resolve_with(false, &t.screen.look).0, 0x00AA00);
817        t.feed(b"\x1b[?31l\x1b[?32l");
818        assert_eq!(t.screen.look.font_for(&cell), 42);
819        assert_eq!(cell.resolve_with(false, &t.screen.look).0, 0x55FF55);
820        assert_eq!(parse_color("rgb:f/0/8"), Some(0xFF0088));
821        assert_eq!(parse_color("rgb:ffff/0000/8080"), Some(0xFF0080));
822        assert_eq!(parse_color("rgb:1/2"), None);
823    }
824
825    #[test]
826    fn reset_returns_to_the_entrys_ice_setting() {
827        let mut t = Terminal::new(80, 25, 0);
828        t.screen.base_ice = true;
829        t.feed(b"\x1b[?33l");
830        assert!(!t.screen.ice_colors);
831        t.feed(b"\x1bc");
832        assert!(t.screen.ice_colors);
833    }
834}