Skip to main content

badterm_core/
avatar.rs

1//! AVATAR (AVT/0 and AVT/0+): the compact screen codes of Opus, Maximus
2//! and other FidoNet-era BBSes, interpreted alongside ANSI.
3//!
4//! - `^L` clears the screen to cyan on black and homes the cursor.
5//! - `^Y c n` repeats the character `c` `n` times.
6//! - `^V` starts a command: `^A attr` sets the color (a PC attribute byte),
7//!   `^B` blinks, `^C`/`^D`/`^E`/`^F` move up/down/left/right, `^G` clears
8//!   to the end of the line, `^H row col` moves the cursor (from 1, 1).
9//! - AVT/0+ adds: `^I` insert mode (until the next command), `^J n t l b r`
10//!   and `^K ...` scroll an area up or down, `^L attr rows cols` clears an
11//!   area at the cursor, `^M attr c rows cols` fills one with a character,
12//!   `^N` deletes the character at the cursor, and `^Y n pattern... count`
13//!   repeats a pattern.
14
15use crate::screen::{Cell, Color, Screen, flags};
16
17/// The longest a repeated pattern may expand to, so a hostile one cannot
18/// make the screen do much work.
19const MAX_REPEAT: usize = 64 * 1024;
20
21#[derive(Debug, Default)]
22pub struct Avatar {
23    /// The bytes of the command so far (after `^V` or `^Y`), or None.
24    command: Option<Vec<u8>>,
25    /// AVT/0+ insert mode: characters push the rest of the line right.
26    pub insert: bool,
27}
28
29/// What the terminal should do with a byte.
30pub enum Outcome {
31    /// Not an Avatar code: handle it as usual.
32    Pass,
33    /// Taken.
34    Done,
35    /// Show these bytes (a repeat), as if they had arrived.
36    Show(Vec<u8>),
37}
38
39impl Avatar {
40    /// Whether a command is half read.
41    pub fn busy(&self) -> bool {
42        self.command.is_some()
43    }
44
45    pub fn byte(&mut self, s: &mut Screen, b: u8) -> Outcome {
46        let Some(cmd) = self.command.as_mut() else {
47            return match b {
48                0x0C => {
49                    self.insert = false;
50                    set_attr(s, 3);
51                    s.erase_display(2);
52                    s.move_to(0, 0);
53                    Outcome::Done
54                }
55                0x16 | 0x19 => {
56                    self.command = Some(vec![b]);
57                    Outcome::Done
58                }
59                _ => Outcome::Pass,
60            };
61        };
62        cmd.push(b);
63        let cmd = cmd.clone();
64        match self.complete(&cmd) {
65            Some(n) if n <= cmd.len() => {
66                self.command = None;
67                self.run(s, &cmd)
68            }
69            _ => Outcome::Done,
70        }
71    }
72
73    /// How long the command is, once enough of it has arrived to tell.
74    fn complete(&self, cmd: &[u8]) -> Option<usize> {
75        match cmd {
76            [0x19, ..] => Some(3),
77            [0x16] => None,
78            [0x16, op, rest @ ..] => match op {
79                0x01 => Some(3),
80                0x08 => Some(4),
81                0x0A | 0x0B => Some(7),
82                0x0C => Some(5),
83                0x0D => Some(6),
84                // ^V ^Y n pattern(n) count
85                0x19 => rest.first().map(|&n| 3 + n as usize + 1),
86                _ => Some(2),
87            },
88            _ => Some(cmd.len()),
89        }
90    }
91
92    fn run(&mut self, s: &mut Screen, cmd: &[u8]) -> Outcome {
93        if cmd[0] == 0x19 {
94            return Outcome::Show(vec![cmd[1]; cmd[2] as usize]);
95        }
96        let (op, a) = (cmd[1], &cmd[2..]);
97        // Any command ends insert mode; ^V ^I turns it back on.
98        self.insert = op == 0x09;
99        let (x, y) = s.cursor();
100        match op {
101            0x01 => set_attr(s, a[0]),
102            0x02 => s.pen.flags |= flags::BLINK,
103            0x03 => s.move_by(0, -1),
104            0x04 => s.move_by(0, 1),
105            0x05 => s.move_by(-1, 0),
106            0x06 => s.move_by(1, 0),
107            0x07 => s.erase_line(0),
108            0x08 => s.move_to((a[1] as usize).saturating_sub(1), (a[0] as usize).saturating_sub(1)),
109            0x0A | 0x0B => {
110                let (n, top, left, bottom, right) = (a[0] as usize, a[1], a[2], a[3], a[4]);
111                let area = Area::from_corners(s, top, left, bottom, right);
112                scroll_area(s, area, n, op == 0x0A);
113            }
114            0x0C => {
115                let blank = Cell { ch: b' ', ..attr_cell(s, a[0]) };
116                set_attr(s, a[0]);
117                fill(s, Area { x, y, w: a[2] as usize, h: a[1] as usize }, blank);
118            }
119            0x0D => {
120                let cell = Cell { ch: a[1], ..attr_cell(s, a[0]) };
121                set_attr(s, a[0]);
122                fill(s, Area { x, y, w: a[3] as usize, h: a[2] as usize }, cell);
123            }
124            0x0E => s.delete_chars(1),
125            0x19 => {
126                let n = a[0] as usize;
127                let (pattern, count) = (&a[1..1 + n], a[1 + n] as usize);
128                let total = (pattern.len() * count).min(MAX_REPEAT);
129                return Outcome::Show(pattern.iter().copied().cycle().take(total).collect());
130            }
131            _ => log::debug!("unhandled AVATAR ^V {op:#04x}"),
132        }
133        Outcome::Done
134    }
135}
136
137/// The pen for a PC attribute byte: foreground in the low nibble, the
138/// background in the next three bits, and blink in the top one.
139fn attr_cell(s: &Screen, attr: u8) -> Cell {
140    let mut flags_ = s.pen.flags & !(flags::BOLD | flags::BLINK | flags::REVERSE | flags::FAINT);
141    if attr & 0x80 != 0 {
142        flags_ |= flags::BLINK;
143    }
144    Cell { ch: b' ', fg: Color::Pc(attr & 0x0F), bg: Color::Pc((attr >> 4) & 0x07), flags: flags_, tile: 0 }
145}
146
147fn set_attr(s: &mut Screen, attr: u8) {
148    s.pen = attr_cell(s, attr);
149}
150
151/// A rectangle of cells, clipped to the screen.
152#[derive(Clone, Copy, Debug)]
153struct Area {
154    x: usize,
155    y: usize,
156    w: usize,
157    h: usize,
158}
159
160impl Area {
161    /// From 1-based corners, as AVT/0+ gives them.
162    fn from_corners(s: &Screen, top: u8, left: u8, bottom: u8, right: u8) -> Area {
163        let (t, l) = ((top as usize).max(1) - 1, (left as usize).max(1) - 1);
164        let (b, r) = ((bottom as usize).clamp(1, s.rows()) - 1, (right as usize).clamp(1, s.cols()) - 1);
165        Area { x: l, y: t, w: (r + 1).saturating_sub(l), h: (b + 1).saturating_sub(t) }
166    }
167
168    fn clip(self, s: &Screen) -> Area {
169        let x = self.x.min(s.cols());
170        let y = self.y.min(s.rows());
171        Area { x, y, w: self.w.min(s.cols() - x), h: self.h.min(s.rows() - y) }
172    }
173}
174
175fn fill(s: &mut Screen, area: Area, cell: Cell) {
176    let a = area.clip(s);
177    s.fill_rect(a.x, a.y, a.w, a.h, cell);
178}
179
180/// Scroll the cells of `area` up (or down) `n` lines, blanking the lines
181/// that open up in the current color. 0 lines clears the area.
182fn scroll_area(s: &mut Screen, area: Area, n: usize, up: bool) {
183    let a = area.clip(s);
184    let blank = Cell { ch: b' ', ..s.pen };
185    let n = if n == 0 { a.h } else { n.min(a.h) };
186    let rows: Vec<Vec<Cell>> = (a.y..a.y + a.h).map(|y| s.row(y)[a.x..a.x + a.w].to_vec()).collect();
187    for i in 0..a.h {
188        let from = if up { i.checked_add(n).filter(|&f| f < a.h) } else { i.checked_sub(n) };
189        let line = from.map(|f| rows[f].clone()).unwrap_or_else(|| vec![blank; a.w]);
190        for (dx, cell) in line.into_iter().enumerate() {
191            if let Some(c) = s.cell_mut(a.x + dx, a.y + i) {
192                *c = cell;
193            }
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use crate::ansi::{Encoding, Terminal};
201    use crate::screen::Color;
202
203    fn term(data: &[u8]) -> Terminal {
204        let mut t = Terminal::new(80, 25, 0);
205        t.encoding = Encoding::Avatar;
206        t.feed(data);
207        t
208    }
209
210    fn line(t: &Terminal, y: usize) -> String {
211        crate::screen::Screen::row_text(t.screen.row(y)).trim_end().to_string()
212    }
213
214    #[test]
215    fn avt0_codes() {
216        // Clear, yellow on blue, a repeat, goto row 3 col 5, then text.
217        let t = term(b"junk\x0c\x16\x01\x1eHi\x19-\x05\x16\x08\x03\x05X");
218        assert_eq!(line(&t, 0), "Hi-----");
219        let c = t.screen.row(0)[0];
220        assert_eq!((c.fg, c.bg), (Color::Pc(14), Color::Pc(1)));
221        assert_eq!(t.screen.row(2)[4].ch, b'X');
222        // ^L leaves cyan on black.
223        let t = term(b"\x0cA");
224        assert_eq!(t.screen.row(0)[0].fg, Color::Pc(3));
225    }
226
227    #[test]
228    fn avt0_plus_codes() {
229        // Insert mode pushes the line right; any command ends it.
230        let t = term(b"ABC\x16\x08\x01\x01\x16\x09XY\x16\x06Z");
231        assert_eq!(line(&t, 0), "XYAZC");
232        // A pattern repeat, and ANSI still works alongside.
233        let t = term(b"\x16\x19\x02ab\x03\x1b[2;1HOK");
234        assert_eq!((line(&t, 0), line(&t, 1)), ("ababab".into(), "OK".into()));
235        // Fill an area, then scroll it up a line.
236        let t = term(b"\x16\x08\x02\x02\x16\x0d\x07#\x02\x03\x16\x0a\x01\x02\x02\x03\x04");
237        assert_eq!((line(&t, 1), line(&t, 2)), (" ###".into(), "".into()));
238        // Delete a character.
239        let t = term(b"ABC\x16\x08\x01\x01\x16\x0e");
240        assert_eq!(line(&t, 0), "BC");
241    }
242}