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/gop.rs | 152 +++++++---------- rsnix/src/lib.rs | 3 + rsnix/src/ps2.rs | 31 ++-- rsnix/src/strbuf.rs | 71 ++++++++ rsnix/src/tty.rs | 239 ++++++++++++++++++++++++++ rsnix/src/vt.rs | 476 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 869 insertions(+), 103 deletions(-) create mode 100644 rsnix/src/strbuf.rs create mode 100644 rsnix/src/tty.rs create mode 100644 rsnix/src/vt.rs (limited to 'rsnix/src') diff --git a/rsnix/src/gop.rs b/rsnix/src/gop.rs index 9645bb0..81b8945 100644 --- a/rsnix/src/gop.rs +++ b/rsnix/src/gop.rs @@ -1,4 +1,8 @@ /// GOP framebuffer driver — renders text via a bitmap font. +/// +/// Text surface (cursor, scrolling, colours) lives in `crate::vt`; this +/// module is the low-level rasteriser: glyph blits and pixel shifts on the +/// linear framebuffer. use crate::font; use crate::uefi::{GraphicsOutputProtocol, PIXEL_BGR, PIXEL_RGB}; use core::fmt; @@ -13,11 +17,7 @@ pub struct Framebuffer { pitch: usize, // bytes per scan line bytes_per_pixel: usize, bg: u32, // packed BGRA or RGBA background colour - fg: u32, // packed foreground colour - cursor_x: usize, - cursor_y: usize, - rows: usize, - cols: usize, + bgr: bool, } static mut FB: Option = None; @@ -38,9 +38,8 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { PIXEL_RGB => (0x00_FF_FF_FFu32, 0x00_00_00_00u32), _ => (0x00_FF_FF_FFu32, 0x00_00_00_00u32), }; - - let rows = height / 16; - let cols = width / 8; + let bgr = info.pixel_format == PIXEL_BGR; + let _ = fg; let fb = Framebuffer { base, @@ -49,11 +48,7 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { pitch, bytes_per_pixel: bpp, bg, - fg, - cursor_x: 0, - cursor_y: 0, - rows, - cols, + bgr, }; fb.clear(); @@ -63,79 +58,45 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { } } -/// Draw a character at the current cursor position and advance. -pub fn put_char(c: char) { - let fb = unsafe { - match FB.as_mut() { - Some(f) => f, - None => return, - } - }; +// --------------------------------------------------------------------------- +// Public text API — the text surface now lives in `crate::vt`; these wrappers +// keep callers (shell, macros) unchanged. +// --------------------------------------------------------------------------- - if c == '\x08' { - // Backspace - move cursor back and erase - if fb.cursor_x > 0 { - fb.cursor_x -= 1; - } else if fb.cursor_y > 0 { - fb.cursor_y -= 1; - fb.cursor_x = fb.cols - 1; - } else { - return; - } - // Draw glyph 32 (space) to erase — glyph 0 is NUL, not blank - fb.draw_glyph(32, fb.cursor_x, fb.cursor_y); - return; - } - if c == '\n' { - fb.cursor_x = 0; - fb.cursor_y += 1; - if fb.cursor_y >= fb.rows { - fb.scroll(); - } - return; - } - if c == '\r' { - fb.cursor_x = 0; - return; - } +/// Draw a character at the current terminal cursor position and advance. +pub fn put_char(c: char) { + let b = if (c as u32) < 0x100 { c as u8 } else { b'?' }; + crate::vt::put_char(b); +} - let idx = c as usize; - if idx >= 256 { - // Unmapped character: draw a space - fb.draw_glyph(0, fb.cursor_x, fb.cursor_y); - } else { - fb.draw_glyph(idx, fb.cursor_x, fb.cursor_y); - } +/// Write a string to the terminal. +pub fn write_str(s: &str) { + crate::vt::write_str(s); +} - fb.cursor_x += 1; - if fb.cursor_x >= fb.cols { - fb.cursor_x = 0; - fb.cursor_y += 1; - if fb.cursor_y >= fb.rows { - fb.scroll(); - } - } +/// Clear the terminal and home the cursor. +pub fn clear_screen() { + crate::vt::clear_all(); } -/// Write a string to the framebuffer. -pub fn write_str(s: &str) { - for c in s.chars() { - put_char(c); +/// (cols, rows) of the text surface, from the current framebuffer mode. +pub fn dims() -> (usize, usize) { + unsafe { + match FB.as_ref() { + Some(f) => (f.width / 8, f.height / 16), + None => (0, 0), + } } } -/// Clear the entire screen to background colour and reset cursor to (0, 0). -pub fn clear_screen() { - let fb = unsafe { - match FB.as_mut() { - Some(f) => f, - None => return, +/// Pack an (r, g, b) colour for the framebuffer's pixel format. +pub fn rgb(r: u8, g: u8, b: u8) -> u32 { + unsafe { + match FB.as_ref() { + Some(f) if f.bgr => ((r as u32) << 16) | ((g as u32) << 8) | b as u32, + _ => ((b as u32) << 16) | ((g as u32) << 8) | r as u32, } - }; - fb.clear(); - // Reset cursor back to top-left so the next prompt appears at (0, 0) - fb.cursor_x = 0; - fb.cursor_y = 0; + } } // --------------------------------------------------------------------------- @@ -155,7 +116,10 @@ impl Framebuffer { } } - fn scroll(&mut self) { + /// Shift the whole framebuffer up one row (16 pixels) and clear the + /// exposed bottom row. Called by `vt::scroll_up`; the vt then re-blits + /// only the new bottom row. + pub(crate) fn shift_up(&self) { // Move everything up one row (16 pixels) let row_bytes = self.pitch * 16; let total = self.pitch * self.height; @@ -167,40 +131,44 @@ impl Framebuffer { ptr::write_volatile(last_row.add(i * 4) as *mut u32, self.bg); } } - self.cursor_y -= 1; } +} - fn draw_glyph(&self, glyph_idx: usize, col: usize, row: usize) { +/// Blit one glyph cell at (col, row) with explicit fg/bg colours. +pub(crate) fn blit_glyph(col: usize, row: usize, ch: u16, fg: (u8, u8, u8), bg: (u8, u8, u8)) { + if let Some(fb) = unsafe { FB.as_ref() } { + let glyph_idx = if ch < 256 { ch as usize } else { 32 }; let glyph = font::GLYPH_8X16; let data = match glyph.get(glyph_idx) { Some(d) => *d, None => return, }; + let fg = rgb(fg.0, fg.1, fg.2); + let bg = rgb(bg.0, bg.1, bg.2); let x = col * 8; let y = row * 16; for cy in 0..16 { let row_bits = data[cy]; for cx in 0..8 { - if row_bits & (0x80 >> cx) != 0 { - // Set foreground pixel - let offset = (y + cy) * self.pitch + (x + cx) * self.bytes_per_pixel; - unsafe { - ptr::write_volatile(self.base.add(offset) as *mut u32, self.fg); - } - } else { - // Set background pixel - let offset = (y + cy) * self.pitch + (x + cx) * self.bytes_per_pixel; - unsafe { - ptr::write_volatile(self.base.add(offset) as *mut u32, self.bg); - } + let offset = (y + cy) * fb.pitch + (x + cx) * fb.bytes_per_pixel; + unsafe { + let px = if row_bits & (0x80 >> cx) != 0 { fg } else { bg }; + ptr::write_volatile(fb.base.add(offset) as *mut u32, px); } } } } } +/// Shift the framebuffer up one row and clear the bottom row. +pub(crate) fn shift_up() { + if let Some(fb) = unsafe { FB.as_ref() } { + fb.shift_up(); + } +} + // --------------------------------------------------------------------------- // fmt::Write wrapper // --------------------------------------------------------------------------- diff --git a/rsnix/src/lib.rs b/rsnix/src/lib.rs index bb0a3eb..a8e279a 100644 --- a/rsnix/src/lib.rs +++ b/rsnix/src/lib.rs @@ -13,4 +13,7 @@ pub mod power; pub mod ps2; pub mod rtc; pub mod serial; +pub mod strbuf; +pub mod tty; pub mod uefi; +pub mod vt; diff --git a/rsnix/src/ps2.rs b/rsnix/src/ps2.rs index 1b10b3b..8dd58a0 100644 --- a/rsnix/src/ps2.rs +++ b/rsnix/src/ps2.rs @@ -22,8 +22,10 @@ pub enum KeyEvent { Backspace, Tab, Escape, - CtrlC, - CtrlD, + /// Ctrl + key (e.g. Ctrl('c') = ^C, Ctrl('s') = ^S). + Ctrl(char), + /// Ctrl + arrow (0=up, 1=down, 2=left, 3=right) — word/page nav. + CtrlArrow(u8), ArrowUp, ArrowDown, ArrowLeft, @@ -306,13 +308,14 @@ fn process_byte(byte: u8) -> Option { ch.to_ascii_uppercase() }; } - // Ctrl+letter — the shell uses ^C (interrupt) and ^D (EOF). + // Ctrl+key — the terminal control-byte protocol (^C, ^D, ^S, ^X, …). let ctrl = state.modifiers & (MOD_LCTRL | MOD_RCTRL) != 0; if ctrl { - return match ch.to_ascii_lowercase() { - b'c' => Some(KeyEvent::CtrlC), - b'd' => Some(KeyEvent::CtrlD), - _ => None, // swallow other Ctrl+key combinations + let lc = ch.to_ascii_lowercase(); + return if lc.is_ascii_graphic() { + Some(KeyEvent::Ctrl(lc as char)) + } else { + None // swallow non-graphic Ctrl combos }; } if ch != 0 { @@ -334,10 +337,10 @@ fn process_e0(byte: u8) -> Option { update_modifiers(byte, true, true); match byte { - 0x48 => Some(KeyEvent::ArrowUp), - 0x50 => Some(KeyEvent::ArrowDown), - 0x4B => Some(KeyEvent::ArrowLeft), - 0x4D => Some(KeyEvent::ArrowRight), + 0x48 => Some(if ctrl_held() { KeyEvent::CtrlArrow(0) } else { KeyEvent::ArrowUp }), + 0x50 => Some(if ctrl_held() { KeyEvent::CtrlArrow(1) } else { KeyEvent::ArrowDown }), + 0x4B => Some(if ctrl_held() { KeyEvent::CtrlArrow(2) } else { KeyEvent::ArrowLeft }), + 0x4D => Some(if ctrl_held() { KeyEvent::CtrlArrow(3) } else { KeyEvent::ArrowRight }), 0x47 => Some(KeyEvent::Home), 0x4F => Some(KeyEvent::End), 0x49 => Some(KeyEvent::PageUp), @@ -371,3 +374,9 @@ fn set_mod(state: &mut KbdState, bit: u8, pressed: bool) { state.modifiers &= !bit; } } + +/// Is either Ctrl key held? +fn ctrl_held() -> bool { + let state = unsafe { &mut KBD }; + state.modifiers & (MOD_LCTRL | MOD_RCTRL) != 0 +} diff --git a/rsnix/src/strbuf.rs b/rsnix/src/strbuf.rs new file mode 100644 index 0000000..262720d --- /dev/null +++ b/rsnix/src/strbuf.rs @@ -0,0 +1,71 @@ +//! Fixed-capacity ASCII string, allocated entirely on the stack. +//! Shared by the line discipline (`tty`) and the shell. + +use core::fmt; + +/// A fixed-capacity string allocated entirely on the stack. +pub struct String { + buf: [u8; N], + len: usize, +} + +impl Default for String { + fn default() -> Self { + Self::new() + } +} + +impl String { + pub const fn new() -> Self { + String { + buf: [0u8; N], + len: 0, + } + } + + pub fn as_str(&self) -> &str { + // SAFETY: we only write valid ASCII bytes. + unsafe { core::str::from_utf8_unchecked(&self.buf[..self.len]) } + } + + pub fn push(&mut self, c: char) -> Result<(), ()> { + if self.len >= N { + return Err(()); + } + self.buf[self.len] = c as u8; + self.len += 1; + Ok(()) + } + + pub fn pop(&mut self) -> Option { + if self.len == 0 { + return None; + } + self.len -= 1; + Some(self.buf[self.len] as char) + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + pub fn clear(&mut self) { + self.len = 0; + } + + pub fn trim(&self) -> &str { + self.as_str().trim() + } +} + +impl fmt::Display for String { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl fmt::Debug for String { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self.as_str()) + } +} diff --git a/rsnix/src/tty.rs b/rsnix/src/tty.rs new file mode 100644 index 0000000..8d745b3 --- /dev/null +++ b/rsnix/src/tty.rs @@ -0,0 +1,239 @@ +//! Line discipline over the PS/2 keyboard — the kernel's tty layer. +//! +//! Two modes, like a real UNIX terminal: +//! - **canonical** (`read_line`): input is echoed, buffered until Enter, +//! ^C cancels the line, ^D on an empty line is EOF. +//! - **raw** (`read_byte` / `try_read_byte`): keys are translated to the +//! classic terminal byte protocol (arrows → `ESC [ A–D`, Backspace → +//! `0x7f`, ^X → `0x18`, …) and delivered one byte at a time, with no echo. +//! Multi-byte sequences are queued internally so `read_byte` returns them +//! in order. +//! +//! `interrupt_pressed` is the cooperative-interrupt check used by streaming +//! commands (cat/hexdump): it drains the keyboard non-blockingly and reports +//! whether ^C was seen. + +use crate::ps2::{self, KeyEvent}; +use crate::strbuf; +use crate::{gop, serial}; + +pub const LINE_BUF_SIZE: usize = 256; + +/// Result of one canonical line-input session. +pub enum LineInput { + Line(strbuf::String), + Interrupted, // ^C + Eof, // ^D on an empty line +} + +// Raw-mode byte queue: holds the tail of a multi-byte key sequence. +static mut RAW_QUEUE: [u8; 8] = [0; 8]; +static mut RAW_LEN: usize = 0; + +// --------------------------------------------------------------------------- +// canonical mode +// --------------------------------------------------------------------------- + +/// Read one line in canonical mode: echo every key, ^C cancels the line, +/// ^D on an empty line is EOF. Echo goes to the terminal (GOP) and serial. +pub fn read_line() -> LineInput { + let mut buf = strbuf::String::::new(); + loop { + match ps2::poll_key() { + KeyEvent::Enter => { + gop::put_char('\n'); + serial::write_str("\r\n"); + return LineInput::Line(buf); + } + KeyEvent::Ctrl('c') => { + // Cancel the current line — like a real terminal, echo ^C. + buf.clear(); + gop::put_char('^'); + gop::put_char('C'); + gop::put_char('\n'); + serial::write_str("^C\r\n"); + return LineInput::Interrupted; + } + KeyEvent::Ctrl('d') => { + if buf.is_empty() { + // EOF on an empty line — the caller decides what to do. + gop::put_char('^'); + gop::put_char('D'); + gop::put_char('\n'); + serial::write_str("^D\r\n"); + return LineInput::Eof; + } + // ^D with text on the line: ignored (bash semantics). + } + KeyEvent::Char(c) if c.is_ascii_graphic() || c == ' ' => { + if buf.push(c).is_ok() { + gop::put_char(c); + let mut s = [0u8; 4]; + let s = c.encode_utf8(&mut s); + serial::write_str(s); + } + } + KeyEvent::Backspace => { + if buf.pop().is_some() { + gop::put_char('\x08'); + // Serial backspace: BS SP BS (standard terminal protocol) + serial::write_str("\x08 \x08"); + } + } + KeyEvent::Tab => { + for _ in 0..4 { + if buf.push(' ').is_ok() { + gop::put_char(' '); + serial::write_str(" "); + } + } + } + _ => {} + } + } +} + +/// Non-blocking drain of the keyboard: true if a ^C is pending. Used by +/// streaming commands (cat/hexdump) so a long dump can be interrupted. +pub fn interrupt_pressed() -> bool { + let mut pressed = false; + while let Some(ev) = ps2::try_poll_key() { + if matches!(ev, KeyEvent::Ctrl('c')) { + pressed = true; + } + } + pressed +} + +// --------------------------------------------------------------------------- +// raw mode +// --------------------------------------------------------------------------- + +/// Blocking read of the next raw-mode byte. Keys arrive translated to the +/// terminal byte protocol; multi-byte sequences are delivered in order. +pub fn read_byte() -> u8 { + loop { + if let Some(b) = pop_raw() { + return b; + } + let ev = ps2::poll_key(); + enqueue_key(ev); + } +} + +/// Non-blocking raw read: Some(byte) if one is available, else None. +pub fn try_read_byte() -> Option { + if let Some(b) = pop_raw() { + return Some(b); + } + match ps2::try_poll_key() { + Some(ev) => { + enqueue_key(ev); + pop_raw() + } + None => None, + } +} + +fn pop_raw() -> Option { + unsafe { + if RAW_LEN == 0 { + return None; + } + let b = RAW_QUEUE[0]; + for i in 1..RAW_LEN { + RAW_QUEUE[i - 1] = RAW_QUEUE[i]; + } + RAW_LEN -= 1; + Some(b) + } +} + +fn enqueue_key(ev: KeyEvent) { + let mut q = [0u8; 8]; + let n: usize = match ev { + KeyEvent::Char(c) if c.is_ascii() => { + q[0] = c as u8; + 1 + } + KeyEvent::Enter => { + q[0] = 0x0d; + 1 + } + KeyEvent::Backspace => { + q[0] = 0x7f; + 1 + } + KeyEvent::Tab => { + q[0] = 0x09; + 1 + } + KeyEvent::Escape => { + q[0] = 0x1b; + 1 + } + KeyEvent::Ctrl(c) => { + q[0] = (c as u8) & 0x1f; + 1 + } + KeyEvent::ArrowUp => { + q[..3].copy_from_slice(b"\x1b[A"); + 3 + } + KeyEvent::ArrowDown => { + q[..3].copy_from_slice(b"\x1b[B"); + 3 + } + KeyEvent::ArrowRight => { + q[..3].copy_from_slice(b"\x1b[C"); + 3 + } + KeyEvent::ArrowLeft => { + q[..3].copy_from_slice(b"\x1b[D"); + 3 + } + KeyEvent::CtrlArrow(0) => { + q[..6].copy_from_slice(b"\x1b[1;5A"); + 6 + } + KeyEvent::CtrlArrow(1) => { + q[..6].copy_from_slice(b"\x1b[1;5B"); + 6 + } + KeyEvent::CtrlArrow(2) => { + q[..6].copy_from_slice(b"\x1b[1;5D"); + 6 + } + KeyEvent::CtrlArrow(3) => { + q[..6].copy_from_slice(b"\x1b[1;5C"); + 6 + } + KeyEvent::Home => { + q[..3].copy_from_slice(b"\x1b[H"); + 3 + } + KeyEvent::End => { + q[..3].copy_from_slice(b"\x1b[F"); + 3 + } + KeyEvent::PageUp => { + q[..4].copy_from_slice(b"\x1b[5~"); + 4 + } + KeyEvent::PageDown => { + q[..4].copy_from_slice(b"\x1b[6~"); + 4 + } + KeyEvent::Delete => { + q[..4].copy_from_slice(b"\x1b[3~"); + 4 + } + _ => return, + }; + unsafe { + for (i, b) in q.iter().take(n).enumerate() { + RAW_QUEUE[i] = *b; + } + RAW_LEN = n; + } +} 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