Skip to main content

badterm_core/
screen.rs

1//! The character-cell screen model: a grid of CP437 glyphs with attributes,
2//! a cursor, a scrolling region and a scrollback buffer.
3
4use std::collections::VecDeque;
5
6use crate::palette::{self, Rgb, VGA};
7
8/// A cell color. PC colors keep ANSI-BBS semantics (bold brightens the
9/// foreground, blink brightens the background in iCE mode) by being resolved
10/// at render time rather than when written.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum Color {
13    /// VGA palette index 0-15.
14    Pc(u8),
15    /// xterm 256-color index.
16    Xterm(u8),
17    /// 24-bit color.
18    Rgb(Rgb),
19}
20
21pub mod flags {
22    pub const BOLD: u8 = 1 << 0;
23    pub const BLINK: u8 = 1 << 1;
24    pub const UNDERLINE: u8 = 1 << 2;
25    pub const REVERSE: u8 = 1 << 3;
26    pub const CONCEAL: u8 = 1 << 4;
27    pub const FAINT: u8 = 1 << 5;
28}
29
30pub const DEFAULT_FG: Color = Color::Pc(7);
31pub const DEFAULT_BG: Color = Color::Pc(0);
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct Cell {
35    /// CP437 glyph index.
36    pub ch: u8,
37    pub fg: Color,
38    pub bg: Color,
39    pub flags: u8,
40}
41
42impl Default for Cell {
43    fn default() -> Self {
44        Cell { ch: b' ', fg: DEFAULT_FG, bg: DEFAULT_BG, flags: 0 }
45    }
46}
47
48impl Cell {
49    pub fn new(ch: u8, fg: Color, bg: Color) -> Self {
50        Cell { ch, fg, bg, flags: 0 }
51    }
52
53    pub fn is_blank(&self) -> bool {
54        (self.ch == b' ' || self.ch == 0) && self.bg == DEFAULT_BG && self.flags & flags::REVERSE == 0
55    }
56
57    /// Resolve to concrete (foreground, background, blinks) colors.
58    ///
59    /// With `ice` set, the blink attribute selects a high-intensity background
60    /// instead of blinking text, as on a VGA with blink disabled.
61    pub fn resolve(&self, ice: bool) -> (Rgb, Rgb, bool) {
62        self.resolve_with(ice, Look::standard())
63    }
64
65    /// [`Cell::resolve`] with the palette and modes the BBS has set.
66    pub fn resolve_with(&self, ice: bool, look: &Look) -> (Rgb, Rgb, bool) {
67        let bold = self.flags & flags::BOLD != 0 && !look.bright_plain;
68        let blink = self.flags & flags::BLINK != 0;
69        let mut fg = match self.fg {
70            Color::Pc(i) if bold && i < 8 => look.rgb(Color::Pc(i + 8)),
71            c => look.rgb(c),
72        };
73        let mut bg = match self.bg {
74            Color::Pc(i) if blink && ice && i < 8 => look.rgb(Color::Pc(i + 8)),
75            c => look.rgb(c),
76        };
77        if self.flags & flags::REVERSE != 0 {
78            std::mem::swap(&mut fg, &mut bg);
79        }
80        if self.flags & flags::CONCEAL != 0 {
81            fg = bg;
82        }
83        (fg, bg, blink && !ice && !look.blink_steady)
84    }
85}
86
87/// What a BBS can change about how cells are drawn, beyond their own
88/// attributes: CTerm's palette (`OSC 4`) and font slots (`CSI n;m SP D`).
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct Look {
91    /// In xterm order: the 16 ANSI colors (black, red, green, ...), the
92    /// 6x6x6 cube and the grays.
93    pub palette: [Rgb; 256],
94    /// CTerm font IDs for the normal, bright, blink and bright-blink slots.
95    pub fonts: [u8; 4],
96    /// `CSI ?31h`: bright text is drawn in font slot 1.
97    pub bright_font: bool,
98    /// `CSI ?32h`: bold does not brighten the color.
99    pub bright_plain: bool,
100    /// `CSI ?34h`: blinking text is drawn in font slot 2.
101    pub blink_font: bool,
102    /// `CSI ?35h`: blink does not make text blink.
103    pub blink_steady: bool,
104}
105
106impl Default for Look {
107    fn default() -> Look {
108        Look::standard().clone()
109    }
110}
111
112impl Look {
113    /// The VGA palette and font, with no CTerm modes set.
114    pub fn standard() -> &'static Look {
115        static STANDARD: std::sync::LazyLock<Look> = std::sync::LazyLock::new(|| Look {
116            palette: std::array::from_fn(|i| palette::xterm256(i as u8)),
117            fonts: [0; 4],
118            bright_font: false,
119            bright_plain: false,
120            blink_font: false,
121            blink_steady: false,
122        });
123        &STANDARD
124    }
125
126    pub fn rgb(&self, c: Color) -> Rgb {
127        match c {
128            // VGA order to xterm order.
129            Color::Pc(i) => {
130                let i = i & 15;
131                let ansi = palette::ANSI_TO_VGA.iter().position(|&v| v == i & 7).unwrap_or(0) as u8;
132                self.palette[(ansi + (i & 8)) as usize]
133            }
134            Color::Xterm(i) => self.palette[i as usize],
135            Color::Rgb(rgb) => rgb,
136        }
137    }
138
139    /// The CTerm font a cell is drawn in.
140    pub fn font_for(&self, cell: &Cell) -> u8 {
141        let bright = self.bright_font && cell.flags & flags::BOLD != 0;
142        let blink = self.blink_font && cell.flags & flags::BLINK != 0;
143        self.fonts[bright as usize | (blink as usize) << 1]
144    }
145}
146
147pub fn color_rgb(c: Color) -> Rgb {
148    match c {
149        Color::Pc(i) => VGA[(i & 15) as usize],
150        Color::Xterm(i) => palette::xterm256(i),
151        Color::Rgb(rgb) => rgb,
152    }
153}
154
155#[derive(Clone, Copy, Debug)]
156struct SavedCursor {
157    x: usize,
158    y: usize,
159    pen: Cell,
160}
161
162pub struct Screen {
163    cols: usize,
164    rows: usize,
165    cells: Vec<Cell>,
166    x: usize,
167    y: usize,
168    /// Set when a glyph was written to the last column; the wrap happens on the
169    /// next printable character (VT100 "last column flag").
170    pending_wrap: bool,
171    /// Attributes applied to newly written glyphs (`ch` is unused).
172    pub pen: Cell,
173    saved: Option<SavedCursor>,
174    scroll_top: usize,
175    scroll_bottom: usize,
176    pub autowrap: bool,
177    pub cursor_visible: bool,
178    pub ice_colors: bool,
179    /// The palette and fonts the BBS has chosen.
180    pub look: Look,
181    /// The font (CTerm ID) a reset returns to: the entry's choice.
182    pub base_font: u8,
183    /// Whether a reset leaves iCE colors on: the entry's choice.
184    pub base_ice: bool,
185    scrollback: VecDeque<Vec<Cell>>,
186    scrollback_limit: usize,
187}
188
189impl Screen {
190    pub fn new(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
191        let cols = cols.max(1);
192        let rows = rows.max(1);
193        Screen {
194            cols,
195            rows,
196            cells: vec![Cell::default(); cols * rows],
197            x: 0,
198            y: 0,
199            pending_wrap: false,
200            pen: Cell::default(),
201            saved: None,
202            scroll_top: 0,
203            scroll_bottom: rows - 1,
204            autowrap: true,
205            cursor_visible: true,
206            ice_colors: false,
207            look: Look::default(),
208            base_font: 0,
209            base_ice: false,
210            scrollback: VecDeque::new(),
211            scrollback_limit,
212        }
213    }
214
215    pub fn cols(&self) -> usize {
216        self.cols
217    }
218
219    pub fn rows(&self) -> usize {
220        self.rows
221    }
222
223    pub fn cursor(&self) -> (usize, usize) {
224        (self.x, self.y)
225    }
226
227    pub fn row(&self, y: usize) -> &[Cell] {
228        &self.cells[y * self.cols..(y + 1) * self.cols]
229    }
230
231    pub fn cell_mut(&mut self, x: usize, y: usize) -> Option<&mut Cell> {
232        if x < self.cols && y < self.rows { Some(&mut self.cells[y * self.cols + x]) } else { None }
233    }
234
235    pub fn scrollback_len(&self) -> usize {
236        self.scrollback.len()
237    }
238
239    /// Line `i` of the scrollback, 0 being the oldest.
240    pub fn scrollback_line(&self, i: usize) -> &[Cell] {
241        &self.scrollback[i]
242    }
243
244    pub fn clear_scrollback(&mut self) {
245        self.scrollback.clear();
246    }
247
248    /// Full reset (RIS): clears the screen and modes but keeps scrollback.
249    pub fn reset(&mut self) {
250        self.pen = Cell::default();
251        self.cells.fill(Cell::default());
252        self.x = 0;
253        self.y = 0;
254        self.pending_wrap = false;
255        self.saved = None;
256        self.scroll_top = 0;
257        self.scroll_bottom = self.rows - 1;
258        self.autowrap = true;
259        self.cursor_visible = true;
260        self.ice_colors = self.base_ice;
261        self.look = Look::default();
262        self.look.fonts[0] = self.base_font;
263    }
264
265    pub fn resize(&mut self, cols: usize, rows: usize) {
266        let cols = cols.max(1);
267        let rows = rows.max(1);
268        if cols == self.cols && rows == self.rows {
269            return;
270        }
271        let mut cells = vec![Cell::default(); cols * rows];
272        for y in 0..rows.min(self.rows) {
273            let n = cols.min(self.cols);
274            cells[y * cols..y * cols + n].copy_from_slice(&self.row(y)[..n]);
275        }
276        self.cells = cells;
277        self.cols = cols;
278        self.rows = rows;
279        self.x = self.x.min(cols - 1);
280        self.y = self.y.min(rows - 1);
281        self.scroll_top = 0;
282        self.scroll_bottom = rows - 1;
283        self.pending_wrap = false;
284    }
285
286    /// The cell used when erasing: a space in the current colors (ANSI.SYS
287    /// and ANSI-BBS erase with the current background).
288    fn blank(&self) -> Cell {
289        Cell { ch: b' ', flags: self.pen.flags & (flags::BLINK | flags::REVERSE), ..self.pen }
290    }
291
292    /// Write a glyph at the cursor and advance.
293    pub fn put(&mut self, ch: u8) {
294        if self.pending_wrap {
295            self.pending_wrap = false;
296            self.x = 0;
297            self.line_feed();
298        }
299        let idx = self.y * self.cols + self.x;
300        self.cells[idx] = Cell { ch, ..self.pen };
301        if self.x + 1 < self.cols {
302            self.x += 1;
303        } else if self.autowrap {
304            self.pending_wrap = true;
305        }
306    }
307
308    pub fn carriage_return(&mut self) {
309        self.x = 0;
310        self.pending_wrap = false;
311    }
312
313    /// Move down one line, scrolling the region if at its bottom.
314    pub fn line_feed(&mut self) {
315        self.pending_wrap = false;
316        if self.y == self.scroll_bottom {
317            self.scroll_up(1);
318        } else if self.y + 1 < self.rows {
319            self.y += 1;
320        }
321    }
322
323    /// Move up one line, scrolling the region down if at its top (ESC M).
324    pub fn reverse_index(&mut self) {
325        self.pending_wrap = false;
326        if self.y == self.scroll_top {
327            self.scroll_down(1);
328        } else if self.y > 0 {
329            self.y -= 1;
330        }
331    }
332
333    pub fn backspace(&mut self) {
334        self.pending_wrap = false;
335        self.x = self.x.saturating_sub(1);
336    }
337
338    pub fn tab(&mut self) {
339        self.pending_wrap = false;
340        self.x = ((self.x / 8 + 1) * 8).min(self.cols - 1);
341    }
342
343    pub fn back_tab(&mut self, n: usize) {
344        for _ in 0..n {
345            self.x = self.x.saturating_sub(1) / 8 * 8;
346        }
347        self.pending_wrap = false;
348    }
349
350    /// Absolute move, 0-based, clamped to the screen.
351    pub fn move_to(&mut self, x: usize, y: usize) {
352        self.x = x.min(self.cols - 1);
353        self.y = y.min(self.rows - 1);
354        self.pending_wrap = false;
355    }
356
357    pub fn move_by(&mut self, dx: isize, dy: isize) {
358        let x = (self.x as isize + dx).max(0) as usize;
359        let y = (self.y as isize + dy).max(0) as usize;
360        self.move_to(x, y);
361    }
362
363    pub fn set_scroll_region(&mut self, top: usize, bottom: usize) {
364        let bottom = bottom.min(self.rows - 1);
365        if top < bottom {
366            self.scroll_top = top;
367            self.scroll_bottom = bottom;
368        } else {
369            self.scroll_top = 0;
370            self.scroll_bottom = self.rows - 1;
371        }
372        self.move_to(0, 0);
373    }
374
375    pub fn save_cursor(&mut self) {
376        self.saved = Some(SavedCursor { x: self.x, y: self.y, pen: self.pen });
377    }
378
379    pub fn restore_cursor(&mut self) {
380        if let Some(s) = self.saved {
381            self.move_to(s.x, s.y);
382            self.pen = s.pen;
383        }
384    }
385
386    fn push_scrollback(&mut self, line: Vec<Cell>) {
387        if self.scrollback_limit == 0 {
388            return;
389        }
390        if self.scrollback.len() >= self.scrollback_limit {
391            self.scrollback.pop_front();
392        }
393        self.scrollback.push_back(line);
394    }
395
396    /// Scroll the region up `n` lines. Lines leaving the top of a full-screen
397    /// region go to scrollback.
398    pub fn scroll_up(&mut self, n: usize) {
399        let (top, bottom) = (self.scroll_top, self.scroll_bottom);
400        let n = n.min(bottom - top + 1);
401        if top == 0 && bottom == self.rows - 1 {
402            for y in 0..n {
403                let line = self.row(y).to_vec();
404                self.push_scrollback(line);
405            }
406        }
407        let c = self.cols;
408        self.cells.copy_within((top + n) * c..(bottom + 1) * c, top * c);
409        let blank = self.blank();
410        self.cells[(bottom + 1 - n) * c..(bottom + 1) * c].fill(blank);
411    }
412
413    pub fn scroll_down(&mut self, n: usize) {
414        let (top, bottom) = (self.scroll_top, self.scroll_bottom);
415        let n = n.min(bottom - top + 1);
416        let c = self.cols;
417        self.cells.copy_within(top * c..(bottom + 1 - n) * c, (top + n) * c);
418        let blank = self.blank();
419        self.cells[top * c..(top + n) * c].fill(blank);
420    }
421
422    /// ED: 0 = cursor to end, 1 = start to cursor, 2/3 = whole screen.
423    ///
424    /// Clearing the whole screen saves its content to scrollback first, so
425    /// BBS screens that are wiped between menus can still be reviewed, and,
426    /// like ANSI.SYS, homes the cursor.
427    pub fn erase_display(&mut self, mode: u32) {
428        let blank = self.blank();
429        let pos = self.y * self.cols + self.x;
430        match mode {
431            0 => self.cells[pos..].fill(blank),
432            1 => self.cells[..=pos].fill(blank),
433            _ => {
434                let used = (0..self.rows).rev().find(|&y| self.row(y).iter().any(|c| !c.is_blank()));
435                if let Some(last) = used {
436                    for y in 0..=last {
437                        let line = self.row(y).to_vec();
438                        self.push_scrollback(line);
439                    }
440                }
441                self.cells.fill(blank);
442                self.move_to(0, 0);
443            }
444        }
445        self.pending_wrap = false;
446    }
447
448    /// EL: 0 = cursor to end of line, 1 = start to cursor, 2 = whole line.
449    pub fn erase_line(&mut self, mode: u32) {
450        let blank = self.blank();
451        let start = self.y * self.cols;
452        let (a, b) = match mode {
453            0 => (self.x, self.cols),
454            1 => (0, self.x + 1),
455            _ => (0, self.cols),
456        };
457        self.cells[start + a..start + b].fill(blank);
458        self.pending_wrap = false;
459    }
460
461    /// ECH: blank `n` cells from the cursor without moving it.
462    pub fn erase_chars(&mut self, n: usize) {
463        let blank = self.blank();
464        let start = self.y * self.cols;
465        let end = (self.x + n).min(self.cols);
466        self.cells[start + self.x..start + end].fill(blank);
467    }
468
469    /// ICH: insert `n` blanks at the cursor, shifting the rest of the line right.
470    pub fn insert_chars(&mut self, n: usize) {
471        let blank = self.blank();
472        let start = self.y * self.cols;
473        let n = n.min(self.cols - self.x);
474        let line = &mut self.cells[start..start + self.cols];
475        line.copy_within(self.x..self.cols - n, self.x + n);
476        line[self.x..self.x + n].fill(blank);
477    }
478
479    /// DCH: delete `n` cells at the cursor, shifting the rest of the line left.
480    pub fn delete_chars(&mut self, n: usize) {
481        let blank = self.blank();
482        let start = self.y * self.cols;
483        let n = n.min(self.cols - self.x);
484        let line = &mut self.cells[start..start + self.cols];
485        line.copy_within(self.x + n.., self.x);
486        let cols = self.cols;
487        line[cols - n..].fill(blank);
488    }
489
490    /// IL: insert `n` lines at the cursor row (within the scroll region).
491    pub fn insert_lines(&mut self, n: usize) {
492        if self.y < self.scroll_top || self.y > self.scroll_bottom {
493            return;
494        }
495        let top = self.scroll_top;
496        self.scroll_top = self.y;
497        self.scroll_down(n);
498        self.scroll_top = top;
499        self.x = 0;
500    }
501
502    /// DL: delete `n` lines at the cursor row (within the scroll region).
503    pub fn delete_lines(&mut self, n: usize) {
504        if self.y < self.scroll_top || self.y > self.scroll_bottom {
505            return;
506        }
507        let (top, bottom) = (self.scroll_top, self.scroll_bottom);
508        // Temporarily narrow the region so scroll_up never feeds scrollback.
509        self.scroll_top = self.y;
510        let n = n.min(bottom - self.y + 1);
511        let c = self.cols;
512        self.cells.copy_within((self.y + n) * c..(bottom + 1) * c, self.y * c);
513        let blank = self.blank();
514        self.cells[(bottom + 1 - n) * c..(bottom + 1) * c].fill(blank);
515        self.scroll_top = top;
516        self.x = 0;
517    }
518
519    /// Write a string of CP437 glyphs directly at a position without moving
520    /// the cursor; used by the built-in text-mode UI.
521    pub fn draw_text(&mut self, x: usize, y: usize, text: &[u8], fg: Color, bg: Color) {
522        for (i, &ch) in text.iter().enumerate() {
523            if let Some(c) = self.cell_mut(x + i, y) {
524                *c = Cell::new(ch, fg, bg);
525            }
526        }
527    }
528
529    pub fn fill_rect(&mut self, x: usize, y: usize, w: usize, h: usize, cell: Cell) {
530        for yy in y..(y + h).min(self.rows) {
531            for xx in x..(x + w).min(self.cols) {
532                self.cells[yy * self.cols + xx] = cell;
533            }
534        }
535    }
536
537    /// Plain-text contents of a row, trailing blanks trimmed.
538    pub fn row_text(row: &[Cell]) -> String {
539        let s: String = row.iter().map(|c| crate::cp437::to_unicode(c.ch.max(b' '))).collect();
540        s.trim_end().to_string()
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    fn text(s: &Screen, y: usize) -> String {
549        Screen::row_text(s.row(y))
550    }
551
552    #[test]
553    fn delayed_wrap_at_last_column() {
554        let mut s = Screen::new(4, 3, 10);
555        for &b in b"abcd" {
556            s.put(b);
557        }
558        assert_eq!(s.cursor(), (3, 0));
559        s.carriage_return();
560        s.line_feed();
561        s.put(b'e');
562        assert_eq!(text(&s, 0), "abcd");
563        assert_eq!(text(&s, 1), "e");
564    }
565
566    #[test]
567    fn scrolling_feeds_scrollback() {
568        let mut s = Screen::new(4, 2, 10);
569        for &b in b"ab" {
570            s.put(b);
571            s.carriage_return();
572            s.line_feed();
573        }
574        assert_eq!(s.scrollback_len(), 1);
575        assert_eq!(Screen::row_text(s.scrollback_line(0)), "a");
576        assert_eq!(text(&s, 0), "b");
577    }
578
579    #[test]
580    fn clear_screen_saves_to_scrollback() {
581        let mut s = Screen::new(4, 3, 10);
582        s.put(b'x');
583        s.erase_display(2);
584        assert_eq!(s.scrollback_len(), 1);
585        assert_eq!(s.cursor(), (0, 0));
586    }
587
588    #[test]
589    fn insert_delete_chars() {
590        let mut s = Screen::new(5, 1, 0);
591        for &b in b"abcde" {
592            s.put(b);
593        }
594        s.move_to(1, 0);
595        s.delete_chars(2);
596        assert_eq!(text(&s, 0), "ade");
597        s.insert_chars(1);
598        assert_eq!(text(&s, 0), "a de");
599    }
600
601    #[test]
602    fn ice_colors_resolve() {
603        let c = Cell { ch: b'A', fg: Color::Pc(1), bg: Color::Pc(4), flags: flags::BOLD | flags::BLINK };
604        assert_eq!(c.resolve(false), (VGA[9], VGA[4], true));
605        assert_eq!(c.resolve(true), (VGA[9], VGA[12], false));
606    }
607}