Skip to main content

nightshade_api/web/
ansi_terminal.rs

1//! An ANSI/VT escape-sequence terminal emulator and its Leptos view: parses SGR
2//! colors, cursor movement, erasing, scroll regions, and the alternate screen
3//! into a cell grid rendered as styled spans.
4
5use leptos::html;
6use leptos::prelude::*;
7
8#[derive(Clone, Copy, PartialEq, Eq, Debug)]
9enum Color {
10    Default,
11    Indexed(u8),
12    Rgb(u8, u8, u8),
13}
14
15#[derive(Clone, Copy, PartialEq, Eq)]
16struct Cell {
17    glyph: char,
18    fg: Color,
19    bg: Color,
20    bold: bool,
21    inverse: bool,
22}
23
24impl Cell {
25    fn blank() -> Self {
26        Self {
27            glyph: ' ',
28            fg: Color::Default,
29            bg: Color::Default,
30            bold: false,
31            inverse: false,
32        }
33    }
34}
35
36#[derive(Clone, Copy, PartialEq, Eq)]
37enum Parse {
38    Ground,
39    Escape,
40    Csi,
41}
42
43/// A fixed-size character cell grid with an embedded ANSI escape-sequence
44/// parser: feeding it bytes updates the cells, cursor, and graphic attributes.
45#[derive(Clone)]
46pub struct TerminalGrid {
47    cols: usize,
48    rows: usize,
49    cells: Vec<Cell>,
50    row: usize,
51    col: usize,
52    fg: Color,
53    bg: Color,
54    bold: bool,
55    inverse: bool,
56    state: Parse,
57    params: Vec<u16>,
58    acc: Option<u16>,
59    private: bool,
60    saved_cursor: Option<(usize, usize)>,
61    scroll_top: usize,
62    scroll_bottom: usize,
63    alt: Option<(Vec<Cell>, usize, usize)>,
64}
65
66impl TerminalGrid {
67    fn new(cols: usize, rows: usize) -> Self {
68        let cols = cols.max(1);
69        let rows = rows.max(1);
70        Self {
71            cols,
72            rows,
73            cells: vec![Cell::blank(); cols * rows],
74            row: 0,
75            col: 0,
76            fg: Color::Default,
77            bg: Color::Default,
78            bold: false,
79            inverse: false,
80            state: Parse::Ground,
81            params: Vec::new(),
82            acc: None,
83            private: false,
84            saved_cursor: None,
85            scroll_top: 0,
86            scroll_bottom: rows - 1,
87            alt: None,
88        }
89    }
90
91    fn clear_row(&mut self, row: usize) {
92        let start = row * self.cols;
93        for cell in &mut self.cells[start..start + self.cols] {
94            *cell = Cell::blank();
95        }
96    }
97
98    fn scroll_region_up(&mut self) {
99        for row in self.scroll_top..self.scroll_bottom {
100            let (dst, src) = (row * self.cols, (row + 1) * self.cols);
101            self.cells.copy_within(src..src + self.cols, dst);
102        }
103        self.clear_row(self.scroll_bottom);
104    }
105
106    fn newline(&mut self) {
107        if self.row >= self.scroll_bottom {
108            self.scroll_region_up();
109        } else {
110            self.row += 1;
111        }
112    }
113
114    fn put(&mut self, glyph: char) {
115        if self.col >= self.cols {
116            self.col = 0;
117            self.newline();
118        }
119        let index = self.row * self.cols + self.col;
120        self.cells[index] = Cell {
121            glyph,
122            fg: self.fg,
123            bg: self.bg,
124            bold: self.bold,
125            inverse: self.inverse,
126        };
127        self.col += 1;
128    }
129
130    fn param(&self, index: usize, fallback: u16) -> u16 {
131        self.params.get(index).copied().unwrap_or(fallback)
132    }
133
134    fn apply_sgr(&mut self) {
135        if self.params.is_empty() {
136            self.params.push(0);
137        }
138        let params = self.params.clone();
139        let mut index = 0;
140        while index < params.len() {
141            match params[index] {
142                0 => {
143                    self.fg = Color::Default;
144                    self.bg = Color::Default;
145                    self.bold = false;
146                    self.inverse = false;
147                }
148                1 => self.bold = true,
149                7 => self.inverse = true,
150                22 => self.bold = false,
151                27 => self.inverse = false,
152                30..=37 => self.fg = Color::Indexed((params[index] - 30) as u8),
153                39 => self.fg = Color::Default,
154                40..=47 => self.bg = Color::Indexed((params[index] - 40) as u8),
155                49 => self.bg = Color::Default,
156                90..=97 => self.fg = Color::Indexed((params[index] - 90 + 8) as u8),
157                100..=107 => self.bg = Color::Indexed((params[index] - 100 + 8) as u8),
158                38 | 48 => {
159                    let target_fg = params[index] == 38;
160                    let color = match params.get(index + 1) {
161                        Some(5) => {
162                            let value = params.get(index + 2).copied().unwrap_or(0) as u8;
163                            index += 2;
164                            Color::Indexed(value)
165                        }
166                        Some(2) => {
167                            let red = params.get(index + 2).copied().unwrap_or(0) as u8;
168                            let green = params.get(index + 3).copied().unwrap_or(0) as u8;
169                            let blue = params.get(index + 4).copied().unwrap_or(0) as u8;
170                            index += 4;
171                            Color::Rgb(red, green, blue)
172                        }
173                        _ => Color::Default,
174                    };
175                    if target_fg {
176                        self.fg = color;
177                    } else {
178                        self.bg = color;
179                    }
180                }
181                _ => {}
182            }
183            index += 1;
184        }
185    }
186
187    fn erase_line(&mut self) {
188        let start = self.row * self.cols;
189        let len = self.cells.len();
190        let (from, to) = match self.param(0, 0) {
191            1 => (start, start + self.col + 1),
192            2 => (start, start + self.cols),
193            _ => (start + self.col, start + self.cols),
194        };
195        for cell in &mut self.cells[from..to.min(len)] {
196            *cell = Cell::blank();
197        }
198    }
199
200    fn erase_display(&mut self) {
201        let cursor = self.row * self.cols + self.col;
202        let len = self.cells.len();
203        let (from, to) = match self.param(0, 0) {
204            1 => (0, cursor + 1),
205            2 => (0, len),
206            _ => (cursor, len),
207        };
208        for cell in &mut self.cells[from..to.min(len)] {
209            *cell = Cell::blank();
210        }
211    }
212
213    fn enter_alt(&mut self) {
214        if self.alt.is_none() {
215            let saved =
216                std::mem::replace(&mut self.cells, vec![Cell::blank(); self.cols * self.rows]);
217            self.alt = Some((saved, self.row, self.col));
218            self.row = 0;
219            self.col = 0;
220        }
221    }
222
223    fn exit_alt(&mut self) {
224        if let Some((cells, row, col)) = self.alt.take() {
225            self.cells = cells;
226            self.row = row.min(self.rows - 1);
227            self.col = col.min(self.cols - 1);
228        }
229    }
230
231    fn execute(&mut self, final_byte: char) {
232        match final_byte {
233            'm' => self.apply_sgr(),
234            'A' => self.row = self.row.saturating_sub(self.param(0, 1).max(1) as usize),
235            'B' => self.row = (self.row + self.param(0, 1).max(1) as usize).min(self.rows - 1),
236            'C' => self.col = (self.col + self.param(0, 1).max(1) as usize).min(self.cols - 1),
237            'D' => self.col = self.col.saturating_sub(self.param(0, 1).max(1) as usize),
238            'G' => {
239                self.col = (self.param(0, 1) as usize)
240                    .saturating_sub(1)
241                    .min(self.cols - 1)
242            }
243            'H' | 'f' => {
244                self.row = (self.param(0, 1) as usize)
245                    .saturating_sub(1)
246                    .min(self.rows - 1);
247                self.col = (self.param(1, 1) as usize)
248                    .saturating_sub(1)
249                    .min(self.cols - 1);
250            }
251            'J' => self.erase_display(),
252            'K' => self.erase_line(),
253            'r' => {
254                let top = (self.param(0, 1) as usize).saturating_sub(1);
255                let bottom = (self.param(1, self.rows as u16) as usize)
256                    .saturating_sub(1)
257                    .min(self.rows - 1);
258                if top < bottom {
259                    self.scroll_top = top;
260                    self.scroll_bottom = bottom;
261                }
262            }
263            's' => self.saved_cursor = Some((self.row, self.col)),
264            'u' => {
265                if let Some((row, col)) = self.saved_cursor {
266                    self.row = row.min(self.rows - 1);
267                    self.col = col.min(self.cols - 1);
268                }
269            }
270            'h' if self.private => {
271                if matches!(self.param(0, 0), 47 | 1047 | 1049) {
272                    self.enter_alt();
273                }
274            }
275            'l' if self.private => {
276                if matches!(self.param(0, 0), 47 | 1047 | 1049) {
277                    self.exit_alt();
278                }
279            }
280            _ => {}
281        }
282    }
283
284    /// Parses `text` one character at a time, updating the grid, cursor, and
285    /// graphic state for the printable characters and escape sequences it holds.
286    pub fn feed(&mut self, text: &str) {
287        for glyph in text.chars() {
288            match self.state {
289                Parse::Ground => match glyph {
290                    '\u{1b}' => self.state = Parse::Escape,
291                    '\n' => {
292                        self.col = 0;
293                        self.newline();
294                    }
295                    '\r' => self.col = 0,
296                    '\t' => self.col = (((self.col / 8) + 1) * 8).min(self.cols - 1),
297                    '\u{8}' => self.col = self.col.saturating_sub(1),
298                    glyph if !glyph.is_control() => self.put(glyph),
299                    _ => {}
300                },
301                Parse::Escape => match glyph {
302                    '[' => {
303                        self.state = Parse::Csi;
304                        self.params.clear();
305                        self.acc = None;
306                        self.private = false;
307                    }
308                    '7' => {
309                        self.saved_cursor = Some((self.row, self.col));
310                        self.state = Parse::Ground;
311                    }
312                    '8' => {
313                        if let Some((row, col)) = self.saved_cursor {
314                            self.row = row.min(self.rows - 1);
315                            self.col = col.min(self.cols - 1);
316                        }
317                        self.state = Parse::Ground;
318                    }
319                    _ => self.state = Parse::Ground,
320                },
321                Parse::Csi => match glyph {
322                    '?' => self.private = true,
323                    '0'..='9' => {
324                        let digit = glyph as u16 - '0' as u16;
325                        self.acc = Some(self.acc.unwrap_or(0) * 10 + digit);
326                    }
327                    ';' => self.params.push(self.acc.take().unwrap_or(0)),
328                    '\u{40}'..='\u{7e}' => {
329                        if let Some(value) = self.acc.take() {
330                            self.params.push(value);
331                        }
332                        self.execute(glyph);
333                        self.state = Parse::Ground;
334                    }
335                    _ => {}
336                },
337            }
338        }
339    }
340}
341
342/// A cheap, copyable handle to a reactive [`TerminalGrid`], shared between the
343/// code that drives output and the [`AnsiTerminal`] view.
344#[derive(Clone, Copy)]
345pub struct TerminalHandle {
346    grid: RwSignal<TerminalGrid>,
347}
348
349impl TerminalHandle {
350    /// Feeds `text` into the underlying grid, reactively updating the view.
351    pub fn feed(&self, text: &str) {
352        self.grid.update(|grid| grid.feed(text));
353    }
354
355    /// Replaces the grid with a fresh blank one of `cols` by `rows`.
356    pub fn reset(&self, cols: usize, rows: usize) {
357        self.grid.set(TerminalGrid::new(cols, rows));
358    }
359}
360
361/// Creates a new `cols` by `rows` terminal grid and returns a handle to it.
362pub fn terminal_grid(cols: usize, rows: usize) -> TerminalHandle {
363    TerminalHandle {
364        grid: RwSignal::new(TerminalGrid::new(cols, rows)),
365    }
366}
367
368const PALETTE: [&str; 16] = [
369    "#3b3b3b", "#f87171", "#4ade80", "#fbbf24", "#60a5fa", "#c084fc", "#22d3ee", "#d4d4d4",
370    "#6b7280", "#fca5a5", "#86efac", "#fde68a", "#93c5fd", "#d8b4fe", "#67e8f9", "#ffffff",
371];
372
373fn cube_channel(value: u8) -> u8 {
374    if value == 0 { 0 } else { 55 + value * 40 }
375}
376
377fn color(value: Color, default: &str) -> String {
378    match value {
379        Color::Default => default.to_string(),
380        Color::Indexed(index) if index < 16 => PALETTE[index as usize].to_string(),
381        Color::Indexed(index) if index < 232 => {
382            let index = index - 16;
383            let red = cube_channel(index / 36);
384            let green = cube_channel((index / 6) % 6);
385            let blue = cube_channel(index % 6);
386            format!("#{red:02x}{green:02x}{blue:02x}")
387        }
388        Color::Indexed(index) => {
389            let level = 8 + (index - 232) * 10;
390            format!("#{level:02x}{level:02x}{level:02x}")
391        }
392        Color::Rgb(red, green, blue) => format!("#{red:02x}{green:02x}{blue:02x}"),
393    }
394}
395
396fn cell_style(cell: Cell) -> String {
397    let mut foreground = color(cell.fg, "var(--nightshade-text)");
398    let mut background = color(cell.bg, "transparent");
399    if cell.inverse {
400        if background == "transparent" {
401            background = "var(--nightshade-text)".to_string();
402            foreground = "var(--nightshade-bg)".to_string();
403        } else {
404            std::mem::swap(&mut foreground, &mut background);
405        }
406    }
407    let weight = if cell.bold { "font-weight:700;" } else { "" };
408    format!("color:{foreground};background:{background};{weight}")
409}
410
411fn key_to_bytes(event: &web_sys::KeyboardEvent) -> Option<String> {
412    let key = event.key();
413    if event.ctrl_key() && key.len() == 1 {
414        let character = key.chars().next()?.to_ascii_lowercase();
415        if character.is_ascii_alphabetic() {
416            return Some(((character as u8 - b'a' + 1) as char).to_string());
417        }
418    }
419    match key.as_str() {
420        "Enter" => Some("\r".to_string()),
421        "Backspace" => Some("\u{7f}".to_string()),
422        "Tab" => Some("\t".to_string()),
423        "Escape" => Some("\u{1b}".to_string()),
424        "ArrowUp" => Some("\u{1b}[A".to_string()),
425        "ArrowDown" => Some("\u{1b}[B".to_string()),
426        "ArrowRight" => Some("\u{1b}[C".to_string()),
427        "ArrowLeft" => Some("\u{1b}[D".to_string()),
428        other if other.chars().count() == 1 => Some(other.to_string()),
429        _ => None,
430    }
431}
432
433/// Renders the grid behind `handle` as rows of style-merged spans; when a
434/// keyboard `on_key` callback is supplied, key presses are translated to their
435/// terminal byte sequences and forwarded to it.
436#[component]
437pub fn AnsiTerminal(
438    handle: TerminalHandle,
439    #[prop(optional)] on_key: Option<Callback<String>>,
440) -> impl IntoView {
441    let body_ref = NodeRef::<html::Div>::new();
442    let grid = handle.grid;
443
444    view! {
445        <div
446            class="nightshade-ansi"
447            tabindex="0"
448            on:keydown=move |event: web_sys::KeyboardEvent| {
449                if let Some(callback) = on_key
450                    && let Some(bytes) = key_to_bytes(&event)
451                {
452                    event.prevent_default();
453                    callback.run(bytes);
454                }
455            }
456        >
457            <div class="nightshade-ansi-body" node_ref=body_ref>
458                {move || {
459                    let grid = grid.get();
460                    let cols = grid.cols;
461                    (0..grid.rows)
462                        .map(|row| {
463                            let cells = &grid.cells[row * cols..row * cols + cols];
464                            let mut runs: Vec<(String, String)> = Vec::new();
465                            for cell in cells {
466                                let style = cell_style(*cell);
467                                match runs.last_mut() {
468                                    Some((last_style, text)) if *last_style == style => {
469                                        text.push(cell.glyph);
470                                    }
471                                    _ => runs.push((style, cell.glyph.to_string())),
472                                }
473                            }
474                            let spans = runs
475                                .into_iter()
476                                .map(|(style, text)| view! { <span style=style>{text}</span> })
477                                .collect_view();
478                            view! { <div class="nightshade-ansi-row">{spans}</div> }
479                        })
480                        .collect_view()
481                }}
482            </div>
483        </div>
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::{Color, TerminalGrid};
490
491    fn text_at(grid: &TerminalGrid, row: usize) -> String {
492        grid.cells[row * grid.cols..row * grid.cols + grid.cols]
493            .iter()
494            .map(|cell| cell.glyph)
495            .collect::<String>()
496            .trim_end()
497            .to_string()
498    }
499
500    #[test]
501    fn plain_text_and_newlines_write_rows() {
502        let mut grid = TerminalGrid::new(20, 4);
503        grid.feed("hello\nworld");
504        assert_eq!(text_at(&grid, 0), "hello");
505        assert_eq!(text_at(&grid, 1), "world");
506    }
507
508    #[test]
509    fn sgr_sets_and_resets_color() {
510        let mut grid = TerminalGrid::new(20, 4);
511        grid.feed("\u{1b}[32mgreen\u{1b}[0m.");
512        assert_eq!(grid.cells[0].fg, Color::Indexed(2));
513        assert_eq!(grid.cells[5].fg, Color::Default);
514    }
515
516    #[test]
517    fn sgr_256_and_truecolor() {
518        let mut grid = TerminalGrid::new(20, 2);
519        grid.feed("\u{1b}[38;5;196mA\u{1b}[38;2;10;20;30mB");
520        assert_eq!(grid.cells[0].fg, Color::Indexed(196));
521        assert_eq!(grid.cells[1].fg, Color::Rgb(10, 20, 30));
522    }
523
524    #[test]
525    fn cursor_save_restore_returns_to_saved_position() {
526        let mut grid = TerminalGrid::new(10, 4);
527        grid.feed("\u{1b}7abc\u{1b}8Z");
528        assert_eq!(grid.cells[0].glyph, 'Z');
529        assert_eq!(grid.cells[1].glyph, 'b');
530    }
531
532    #[test]
533    fn scroll_region_limits_scrolling() {
534        let mut grid = TerminalGrid::new(4, 4);
535        grid.feed("\u{1b}[1;2r");
536        grid.feed("a\nb\nc\nd");
537        assert_eq!(grid.scroll_top, 0);
538        assert_eq!(grid.scroll_bottom, 1);
539    }
540
541    #[test]
542    fn alt_screen_saves_and_restores() {
543        let mut grid = TerminalGrid::new(6, 2);
544        grid.feed("main");
545        grid.feed("\u{1b}[?1049h");
546        assert_eq!(text_at(&grid, 0), "");
547        grid.feed("\u{1b}[?1049l");
548        assert_eq!(text_at(&grid, 0), "main");
549    }
550}