From fa06681a8418b7e31af96e010bb424e178402f4d Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 31 Jul 2026 17:19:09 +0200 Subject: tty + vt terminal split, rsdosedit editor (1:1 UNIX console) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kernel now has the classic UNIX console split, so full-screen ANSI apps can run in ring 0: - vt.rs: terminal emulator owning the text screen — cell grid (char + fg/bg), logical cursor (inverse-video), scrolling, and a small ANSI CSI parser (cursor position H/f, erase J/K, moves A-D, SGR 16-colour with correct ANSI→VGA mapping, cursor show/hide ?25). Blits only changed cells; scroll shifts cell grids + pixels and re-rasterises only the exposed bottom row. gop_println!/clear_screen now render through the vt; gop.rs is the raw rasteriser (colored glyph blits, pixel shift). - tty.rs: line discipline — canonical read_line (echo, line buffer, ^C cancel, ^D EOF) moved out of the shell, and raw read_byte that translates KeyEvents to the terminal byte protocol (arrows → ESC[A-D, Backspace → 0x7f, ^X → 0x18, Ctrl+arrows → ESC[1;5A-D, Home/End/PgUp/ PgDn/Delete). interrupt_pressed() stays for streaming commands. - ps2: generic Ctrl(char) + CtrlArrow events (was: only CtrlC/CtrlD). - rsdosedit: no_std port of the RNG rsedit editor as the first real app — undo, syntax highlighting (verified green/gray/yellow on the framebuffer), search, word nav, smart indent, ^S save / ^X exit / ^X again to discard. termios → tty raw, stdout → vt, std::fs → VFS (ramfs), SIGWINCH → fixed geometry, force-exit window → key counter. - shell: 'edit ' command; read_line/strbuf moved to rsnix. - CONTEXT.md: layout + milestones + known-issue perf notes. Verified in QEMU: full edit → type → arrow-move → insert → save → exit round trip with byte-exact file content; ANSI colors via screendump. --- rsnix/src/vt.rs | 476 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 rsnix/src/vt.rs (limited to 'rsnix/src/vt.rs') diff --git a/rsnix/src/vt.rs b/rsnix/src/vt.rs new file mode 100644 index 0000000..eb60e11 --- /dev/null +++ b/rsnix/src/vt.rs @@ -0,0 +1,476 @@ +//! Minimal ANSI terminal emulator over the GOP framebuffer. +//! +//! `vt` owns the text screen: a cell grid (char + fg/bg colour), a logical +//! cursor, scrolling, and a small CSI parser. It renders to the framebuffer +//! through `gop`, blitting only cells whose content changed since the last +//! flush. +//! +//! This is the kernel's "terminal". Plain text (shell output, typed echo) +//! flows through here in *ground* state; full-screen apps (rsdosedit) write +//! ANSI escape sequences that the CSI parser interprets — cursor addressing, +//! erase, 16-colour SGR, cursor show/hide. +//! +//! Known perf note: scrolling shifts the whole framebuffer one row up (a +//! ~3 MB copy at 1024×768), which dominates under QEMU without KVM. A ring +//! buffer / deferred-pan approach is the future optimisation; the cell grid +//! itself never re-rasterises on scroll — only the exposed bottom row is +//! blitted. + +use crate::gop; +use alloc::boxed::Box; +use alloc::vec; +use alloc::vec::Vec; + +/// Standard VGA 16-colour palette (r, g, b). +pub const PALETTE: [(u8, u8, u8); 16] = [ + (0, 0, 0), + (0, 0, 170), + (0, 170, 0), + (0, 170, 170), + (170, 0, 0), + (170, 0, 170), + (170, 85, 0), + (170, 170, 170), + (85, 85, 85), + (85, 85, 255), + (85, 255, 85), + (85, 255, 255), + (255, 85, 85), + (255, 85, 255), + (255, 255, 85), + (255, 255, 255), +]; + +#[derive(Clone, Copy, PartialEq, Eq)] +struct Cell { + ch: u16, + fg: u8, + bg: u8, +} + +const BLANK: Cell = Cell { ch: 32, fg: 7, bg: 0 }; + +// ---- screen state ---------------------------------------------------------- + +static mut CELLS: *mut Vec = core::ptr::null_mut(); +/// What is currently on the physical framebuffer (used to diff blits). +static mut RENDERED: *mut Vec = core::ptr::null_mut(); +static mut ROWS: usize = 0; +static mut COLS: usize = 0; + +// ---- cursor ---------------------------------------------------------------- + +static mut CURSOR_R: usize = 0; +static mut CURSOR_C: usize = 0; +static mut LAST_CURSOR: (usize, usize) = (0, 0); +static mut CURSOR_VISIBLE: bool = true; + +// ---- SGR state ------------------------------------------------------------- + +static mut SGR_FG: u8 = 7; +static mut SGR_BG: u8 = 0; + +// ---- ANSI parser state ----------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Parser { + Ground, + Escape, + Csi, +} + +static mut PARSER: Parser = Parser::Ground; +static mut PRIVATE: bool = false; +static mut PARAMS: [usize; 4] = [0; 4]; +static mut NPARAMS: usize = 0; +static mut CUR: usize = 0; + +// --------------------------------------------------------------------------- +// init / dimensions +// --------------------------------------------------------------------------- + +/// Allocate the cell grid. Call once after `gop::init()`. +pub fn init() { + let (cols, rows) = gop::dims(); + unsafe { + ROWS = rows; + COLS = cols; + CELLS = Box::into_raw(Box::new(vec![BLANK; rows * cols])); + RENDERED = Box::into_raw(Box::new(vec![BLANK; rows * cols])); + CURSOR_R = 0; + CURSOR_C = 0; + LAST_CURSOR = (0, 0); + CURSOR_VISIBLE = true; + SGR_FG = 7; + SGR_BG = 0; + PARSER = Parser::Ground; + NPARAMS = 0; + PRIVATE = false; + } +} + +/// (rows, cols) — matches the editor's expected (rows, cols) ordering. +pub fn size() -> (usize, usize) { + unsafe { (ROWS, COLS) } +} + +pub fn set_cursor_visible(v: bool) { + unsafe { CURSOR_VISIBLE = v; } + flush(); +} + +// --------------------------------------------------------------------------- +// cell access +// --------------------------------------------------------------------------- + +fn idx(r: usize, c: usize) -> usize { + r * unsafe { COLS } + c +} + +fn cells() -> &'static mut Vec { + unsafe { &mut *CELLS } +} + +fn rendered_cells() -> &'static mut Vec { + unsafe { &mut *RENDERED } +} + +fn cell(r: usize, c: usize) -> &'static mut Cell { + &mut cells()[idx(r, c)] +} + +fn rendered(r: usize, c: usize) -> &'static mut Cell { + &mut rendered_cells()[idx(r, c)] +} + +fn put_cell(r: usize, c: usize, ch: u16) { + let cell = cell(r, c); + cell.ch = ch; + cell.fg = unsafe { SGR_FG }; + cell.bg = unsafe { SGR_BG }; +} + +// --------------------------------------------------------------------------- +// writing — the ANSI state machine +// --------------------------------------------------------------------------- + +/// Feed one byte through the terminal state machine. +fn put_byte(b: u8) { + unsafe { + match PARSER { + Parser::Ground => match b { + 0x1b => PARSER = Parser::Escape, + 0x0d => CURSOR_C = 0, + 0x0a => newline(), + 0x08 => { + // Backspace: move cursor back and erase. + if CURSOR_C > 0 { + CURSOR_C -= 1; + } else if CURSOR_R > 0 { + CURSOR_R -= 1; + CURSOR_C = COLS - 1; + } else { + return; + } + let c = cell(CURSOR_R, CURSOR_C); + *c = BLANK; + } + 0x09 => { + let n = 8 - (CURSOR_C % 8); + CURSOR_C = (CURSOR_C + n).min(COLS - 1); + } + 0x00..=0x1f => {} // other control chars: ignored + _ => { + let ch = if b < 0x80 { b as u16 } else { b'?' as u16 }; + put_cell(CURSOR_R, CURSOR_C, ch); + CURSOR_C += 1; + if CURSOR_C >= COLS { + CURSOR_C = 0; + newline(); + } + } + }, + Parser::Escape => match b { + b'[' => { + PRIVATE = false; + NPARAMS = 0; + CUR = 0; + PARSER = Parser::Csi; + } + _ => PARSER = Parser::Ground, // unsupported escape — drop + }, + Parser::Csi => match b { + b'?' => PRIVATE = true, + b'0'..=b'9' => { + CUR = CUR.saturating_mul(10).saturating_add((b - b'0') as usize); + } + b';' => { + if NPARAMS < PARAMS.len() { + PARAMS[NPARAMS] = CUR; + NPARAMS += 1; + } + CUR = 0; + } + _ => { + if NPARAMS < PARAMS.len() { + PARAMS[NPARAMS] = CUR; + NPARAMS += 1; + } + PARSER = Parser::Ground; + csi_final(b); + } + }, + } + } +} + +fn param(i: usize, default: usize) -> usize { + unsafe { + if i < NPARAMS { + PARAMS[i] + } else { + default + } + } +} + +fn csi_final(b: u8) { + match b { + // Cursor position (1-based; 0 means 1) + b'H' | b'f' => { + let r = param(0, 1).max(1).saturating_sub(1); + let c = param(1, 1).max(1).saturating_sub(1); + unsafe { + CURSOR_R = r.min(ROWS - 1); + CURSOR_C = c.min(COLS - 1); + } + } + // Cursor up / down / forward / back + b'A' => unsafe { CURSOR_R = CURSOR_R.saturating_sub(param(0, 1).max(1)) }, + b'B' => unsafe { CURSOR_R = (CURSOR_R + param(0, 1).max(1)).min(ROWS - 1) }, + b'C' => unsafe { CURSOR_C = (CURSOR_C + param(0, 1).max(1)).min(COLS - 1) }, + b'D' => unsafe { CURSOR_C = CURSOR_C.saturating_sub(param(0, 1).max(1)) }, + // Erase in display: 0 below, 1 above, 2 all + b'J' => match param(0, 0) { + 0 => { + erase_eol(); + unsafe { + for r in (CURSOR_R + 1)..ROWS { + erase_row(r); + } + } + } + 1 => { + unsafe { + for r in 0..CURSOR_R { + erase_row(r); + } + } + erase_bol(); + } + _ => erase_all(), + }, + // Erase in line: 0 to EOL, 1 from BOL, 2 whole line + b'K' => match param(0, 0) { + 1 => erase_bol(), + 2 => { + let r = unsafe { CURSOR_R }; + erase_row(r); + } + _ => erase_eol(), + }, + // SGR — colours / attributes + b'm' => { + let n = unsafe { NPARAMS }; + if n == 0 { + apply_sgr(0); + } else { + for i in 0..n { + apply_sgr(unsafe { PARAMS[i] }); + } + } + } + // Private mode: 25 = cursor show/hide (h = show, l = hide) + b'l' | b'h' if unsafe { PRIVATE } => { + for i in 0..unsafe { NPARAMS } { + if unsafe { PARAMS[i] } == 25 { + unsafe { CURSOR_VISIBLE = b == b'h' }; + } + } + } + _ => {} // unknown / ignored + } +} + +/// ANSI SGR colour index (0–7) → VGA palette index. The two orderings +/// differ: ANSI 1=red, 3=yellow, 4=blue, 6=cyan; VGA 1=blue, 3=cyan, +/// 4=red, 6=brown. Terminals translate ANSI→VGA. +const ANSI_TO_VGA: [u8; 8] = [0, 4, 2, 6, 1, 5, 3, 7]; + +fn apply_sgr(p: usize) { + unsafe { + match p { + 0 => { + SGR_FG = 7; + SGR_BG = 0; + } + 1 => { + if SGR_FG < 8 { + SGR_FG += 8; // bold ≈ bright + } + } + 30..=37 => SGR_FG = ANSI_TO_VGA[p - 30], + 40..=47 => SGR_BG = ANSI_TO_VGA[p - 40], + 90..=97 => SGR_FG = ANSI_TO_VGA[p - 90] + 8, + 100..=107 => SGR_BG = ANSI_TO_VGA[p - 100] + 8, + 39 => SGR_FG = 7, + 49 => SGR_BG = 0, + _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// erase / scroll helpers +// --------------------------------------------------------------------------- + +fn erase_eol() { + let r = unsafe { CURSOR_R }; + let c = unsafe { CURSOR_C }; + for col in c..unsafe { COLS } { + *cell(r, col) = BLANK; + } +} + +fn erase_bol() { + let r = unsafe { CURSOR_R }; + let c = unsafe { CURSOR_C }; + for col in 0..=c { + *cell(r, col) = BLANK; + } +} + +fn erase_row(r: usize) { + for col in 0..unsafe { COLS } { + *cell(r, col) = BLANK; + } +} + +fn erase_all() { + for r in 0..unsafe { ROWS } { + erase_row(r); + } +} + +/// Advance cursor to the next line (col 0), scrolling if at the bottom. +fn newline() { + unsafe { + CURSOR_C = 0; + if CURSOR_R + 1 < ROWS { + CURSOR_R += 1; + } else { + scroll_up(); + } + } +} + +/// Shift the whole screen up one row: cell grids in memory, pixels on the +/// framebuffer. Only the exposed bottom row gets re-rasterised by `flush`. +fn scroll_up() { + let n = unsafe { ROWS * COLS }; + let cols = unsafe { COLS }; + cells().copy_within(cols..n, 0); + rendered_cells().copy_within(cols..n, 0); + for c in 0..cols { + cells()[n - cols + c] = BLANK; + rendered_cells()[n - cols + c] = BLANK; + } + gop::shift_up(); +} + +// --------------------------------------------------------------------------- +// public writing API +// --------------------------------------------------------------------------- + +/// Write raw bytes (ANSI-capable). Used by full-screen apps. +pub fn write_bytes(b: &[u8]) { + for &x in b { + put_byte(x); + } + flush(); +} + +/// Write a UTF-8 string (plain text; non-ASCII becomes '?'). +pub fn write_str(s: &str) { + for c in s.chars() { + let b = if (c as u32) < 0x80 { c as u8 } else { b'?' }; + put_byte(b); + } + flush(); +} + +/// Write a single ASCII byte / char. +pub fn put_char(b: u8) { + put_byte(b); + flush(); +} + +/// Clear the whole screen, reset colours, home the cursor. +pub fn clear_all() { + unsafe { + SGR_FG = 7; + SGR_BG = 0; + erase_all(); + CURSOR_R = 0; + CURSOR_C = 0; + } + flush(); +} + +// --------------------------------------------------------------------------- +// rendering +// --------------------------------------------------------------------------- + +/// Blit every cell whose content differs from what the framebuffer shows, +/// then draw the logical cursor (inverse video) on top. +fn flush() { + let rows = unsafe { ROWS }; + let cols = unsafe { COLS }; + let n = rows * cols; + { + let cells = cells(); + let rendered = rendered_cells(); + for i in 0..n { + let c = cells[i]; + let r = rendered[i]; + if c != r { + let row = i / cols; + let col = i % cols; + let fg = PALETTE[c.fg as usize]; + let bg = PALETTE[c.bg as usize]; + gop::blit_glyph(col, row, c.ch, fg, bg); + rendered[i] = c; + } + } + } + + unsafe { + let (lr, lc) = LAST_CURSOR; + let moved = (lr, lc) != (CURSOR_R, CURSOR_C); + // Erase the previous cursor position (restore its normal colours). + if (moved || !CURSOR_VISIBLE) && lr < rows && lc < cols { + let c = cells()[idx(lr, lc)]; + let fg = PALETTE[c.fg as usize]; + let bg = PALETTE[c.bg as usize]; + gop::blit_glyph(lc, lr, c.ch, fg, bg); + } + LAST_CURSOR = (CURSOR_R, CURSOR_C); + // Draw the cursor as an inverse-video cell. + if CURSOR_VISIBLE && CURSOR_R < rows && CURSOR_C < cols { + let c = cells()[idx(CURSOR_R, CURSOR_C)]; + let fg = PALETTE[c.fg as usize]; + let bg = PALETTE[c.bg as usize]; + gop::blit_glyph(CURSOR_C, CURSOR_R, c.ch, bg, fg); + } + } +} -- cgit v1.2.3