summaryrefslogtreecommitdiffstats
path: root/rsnix/src
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-31 17:19:09 +0200
committersalmonik <salmonik@tuta.io>2026-07-31 17:19:09 +0200
commitfa06681a8418b7e31af96e010bb424e178402f4d (patch)
tree3b2fa8674384078f73b62274c71e8279ca6d0644 /rsnix/src
parentshell: ^C cancels the line / aborts streams, ^D = EOF → poweroff (diff)
downloadrs-dos-fa06681a8418b7e31af96e010bb424e178402f4d.tar.gz
rs-dos-fa06681a8418b7e31af96e010bb424e178402f4d.zip
tty + vt terminal split, rsdosedit editor (1:1 UNIX console)
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 <path>' 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.
Diffstat (limited to 'rsnix/src')
-rw-r--r--rsnix/src/gop.rs152
-rw-r--r--rsnix/src/lib.rs3
-rw-r--r--rsnix/src/ps2.rs31
-rw-r--r--rsnix/src/strbuf.rs71
-rw-r--r--rsnix/src/tty.rs239
-rw-r--r--rsnix/src/vt.rs476
6 files changed, 869 insertions, 103 deletions
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 @@
1/// GOP framebuffer driver — renders text via a bitmap font. 1/// GOP framebuffer driver — renders text via a bitmap font.
2///
3/// Text surface (cursor, scrolling, colours) lives in `crate::vt`; this
4/// module is the low-level rasteriser: glyph blits and pixel shifts on the
5/// linear framebuffer.
2use crate::font; 6use crate::font;
3use crate::uefi::{GraphicsOutputProtocol, PIXEL_BGR, PIXEL_RGB}; 7use crate::uefi::{GraphicsOutputProtocol, PIXEL_BGR, PIXEL_RGB};
4use core::fmt; 8use core::fmt;
@@ -13,11 +17,7 @@ pub struct Framebuffer {
13 pitch: usize, // bytes per scan line 17 pitch: usize, // bytes per scan line
14 bytes_per_pixel: usize, 18 bytes_per_pixel: usize,
15 bg: u32, // packed BGRA or RGBA background colour 19 bg: u32, // packed BGRA or RGBA background colour
16 fg: u32, // packed foreground colour 20 bgr: bool,
17 cursor_x: usize,
18 cursor_y: usize,
19 rows: usize,
20 cols: usize,
21} 21}
22 22
23static mut FB: Option<Framebuffer> = None; 23static mut FB: Option<Framebuffer> = None;
@@ -38,9 +38,8 @@ pub fn init(gop: &mut GraphicsOutputProtocol) {
38 PIXEL_RGB => (0x00_FF_FF_FFu32, 0x00_00_00_00u32), 38 PIXEL_RGB => (0x00_FF_FF_FFu32, 0x00_00_00_00u32),
39 _ => (0x00_FF_FF_FFu32, 0x00_00_00_00u32), 39 _ => (0x00_FF_FF_FFu32, 0x00_00_00_00u32),
40 }; 40 };
41 41 let bgr = info.pixel_format == PIXEL_BGR;
42 let rows = height / 16; 42 let _ = fg;
43 let cols = width / 8;
44 43
45 let fb = Framebuffer { 44 let fb = Framebuffer {
46 base, 45 base,
@@ -49,11 +48,7 @@ pub fn init(gop: &mut GraphicsOutputProtocol) {
49 pitch, 48 pitch,
50 bytes_per_pixel: bpp, 49 bytes_per_pixel: bpp,
51 bg, 50 bg,
52 fg, 51 bgr,
53 cursor_x: 0,
54 cursor_y: 0,
55 rows,
56 cols,
57 }; 52 };
58 53
59 fb.clear(); 54 fb.clear();
@@ -63,79 +58,45 @@ pub fn init(gop: &mut GraphicsOutputProtocol) {
63 } 58 }
64} 59}
65 60
66/// Draw a character at the current cursor position and advance. 61// ---------------------------------------------------------------------------
67pub fn put_char(c: char) { 62// Public text API — the text surface now lives in `crate::vt`; these wrappers
68 let fb = unsafe { 63// keep callers (shell, macros) unchanged.
69 match FB.as_mut() { 64// ---------------------------------------------------------------------------
70 Some(f) => f,
71 None => return,
72 }
73 };
74 65
75 if c == '\x08' { 66/// Draw a character at the current terminal cursor position and advance.
76 // Backspace - move cursor back and erase 67pub fn put_char(c: char) {
77 if fb.cursor_x > 0 { 68 let b = if (c as u32) < 0x100 { c as u8 } else { b'?' };
78 fb.cursor_x -= 1; 69 crate::vt::put_char(b);
79 } else if fb.cursor_y > 0 { 70}
80 fb.cursor_y -= 1;
81 fb.cursor_x = fb.cols - 1;
82 } else {
83 return;
84 }
85 // Draw glyph 32 (space) to erase — glyph 0 is NUL, not blank
86 fb.draw_glyph(32, fb.cursor_x, fb.cursor_y);
87 return;
88 }
89 if c == '\n' {
90 fb.cursor_x = 0;
91 fb.cursor_y += 1;
92 if fb.cursor_y >= fb.rows {
93 fb.scroll();
94 }
95 return;
96 }
97 if c == '\r' {
98 fb.cursor_x = 0;
99 return;
100 }
101 71
102 let idx = c as usize; 72/// Write a string to the terminal.
103 if idx >= 256 { 73pub fn write_str(s: &str) {
104 // Unmapped character: draw a space 74 crate::vt::write_str(s);
105 fb.draw_glyph(0, fb.cursor_x, fb.cursor_y); 75}
106 } else {
107 fb.draw_glyph(idx, fb.cursor_x, fb.cursor_y);
108 }
109 76
110 fb.cursor_x += 1; 77/// Clear the terminal and home the cursor.
111 if fb.cursor_x >= fb.cols { 78pub fn clear_screen() {
112 fb.cursor_x = 0; 79 crate::vt::clear_all();
113 fb.cursor_y += 1;
114 if fb.cursor_y >= fb.rows {
115 fb.scroll();
116 }
117 }
118} 80}
119 81
120/// Write a string to the framebuffer. 82/// (cols, rows) of the text surface, from the current framebuffer mode.
121pub fn write_str(s: &str) { 83pub fn dims() -> (usize, usize) {
122 for c in s.chars() { 84 unsafe {
123 put_char(c); 85 match FB.as_ref() {
86 Some(f) => (f.width / 8, f.height / 16),
87 None => (0, 0),
88 }
124 } 89 }
125} 90}
126 91
127/// Clear the entire screen to background colour and reset cursor to (0, 0). 92/// Pack an (r, g, b) colour for the framebuffer's pixel format.
128pub fn clear_screen() { 93pub fn rgb(r: u8, g: u8, b: u8) -> u32 {
129 let fb = unsafe { 94 unsafe {
130 match FB.as_mut() { 95 match FB.as_ref() {
131 Some(f) => f, 96 Some(f) if f.bgr => ((r as u32) << 16) | ((g as u32) << 8) | b as u32,
132 None => return, 97 _ => ((b as u32) << 16) | ((g as u32) << 8) | r as u32,
133 } 98 }
134 }; 99 }
135 fb.clear();
136 // Reset cursor back to top-left so the next prompt appears at (0, 0)
137 fb.cursor_x = 0;
138 fb.cursor_y = 0;
139} 100}
140 101
141// --------------------------------------------------------------------------- 102// ---------------------------------------------------------------------------
@@ -155,7 +116,10 @@ impl Framebuffer {
155 } 116 }
156 } 117 }
157 118
158 fn scroll(&mut self) { 119 /// Shift the whole framebuffer up one row (16 pixels) and clear the
120 /// exposed bottom row. Called by `vt::scroll_up`; the vt then re-blits
121 /// only the new bottom row.
122 pub(crate) fn shift_up(&self) {
159 // Move everything up one row (16 pixels) 123 // Move everything up one row (16 pixels)
160 let row_bytes = self.pitch * 16; 124 let row_bytes = self.pitch * 16;
161 let total = self.pitch * self.height; 125 let total = self.pitch * self.height;
@@ -167,40 +131,44 @@ impl Framebuffer {
167 ptr::write_volatile(last_row.add(i * 4) as *mut u32, self.bg); 131 ptr::write_volatile(last_row.add(i * 4) as *mut u32, self.bg);
168 } 132 }
169 } 133 }
170 self.cursor_y -= 1;
171 } 134 }
135}
172 136
173 fn draw_glyph(&self, glyph_idx: usize, col: usize, row: usize) { 137/// Blit one glyph cell at (col, row) with explicit fg/bg colours.
138pub(crate) fn blit_glyph(col: usize, row: usize, ch: u16, fg: (u8, u8, u8), bg: (u8, u8, u8)) {
139 if let Some(fb) = unsafe { FB.as_ref() } {
140 let glyph_idx = if ch < 256 { ch as usize } else { 32 };
174 let glyph = font::GLYPH_8X16; 141 let glyph = font::GLYPH_8X16;
175 let data = match glyph.get(glyph_idx) { 142 let data = match glyph.get(glyph_idx) {
176 Some(d) => *d, 143 Some(d) => *d,
177 None => return, 144 None => return,
178 }; 145 };
179 146
147 let fg = rgb(fg.0, fg.1, fg.2);
148 let bg = rgb(bg.0, bg.1, bg.2);
180 let x = col * 8; 149 let x = col * 8;
181 let y = row * 16; 150 let y = row * 16;
182 151
183 for cy in 0..16 { 152 for cy in 0..16 {
184 let row_bits = data[cy]; 153 let row_bits = data[cy];
185 for cx in 0..8 { 154 for cx in 0..8 {
186 if row_bits & (0x80 >> cx) != 0 { 155 let offset = (y + cy) * fb.pitch + (x + cx) * fb.bytes_per_pixel;
187 // Set foreground pixel 156 unsafe {
188 let offset = (y + cy) * self.pitch + (x + cx) * self.bytes_per_pixel; 157 let px = if row_bits & (0x80 >> cx) != 0 { fg } else { bg };
189 unsafe { 158 ptr::write_volatile(fb.base.add(offset) as *mut u32, px);
190 ptr::write_volatile(self.base.add(offset) as *mut u32, self.fg);
191 }
192 } else {
193 // Set background pixel
194 let offset = (y + cy) * self.pitch + (x + cx) * self.bytes_per_pixel;
195 unsafe {
196 ptr::write_volatile(self.base.add(offset) as *mut u32, self.bg);
197 }
198 } 159 }
199 } 160 }
200 } 161 }
201 } 162 }
202} 163}
203 164
165/// Shift the framebuffer up one row and clear the bottom row.
166pub(crate) fn shift_up() {
167 if let Some(fb) = unsafe { FB.as_ref() } {
168 fb.shift_up();
169 }
170}
171
204// --------------------------------------------------------------------------- 172// ---------------------------------------------------------------------------
205// fmt::Write wrapper 173// fmt::Write wrapper
206// --------------------------------------------------------------------------- 174// ---------------------------------------------------------------------------
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;
13pub mod ps2; 13pub mod ps2;
14pub mod rtc; 14pub mod rtc;
15pub mod serial; 15pub mod serial;
16pub mod strbuf;
17pub mod tty;
16pub mod uefi; 18pub mod uefi;
19pub 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 {
22 Backspace, 22 Backspace,
23 Tab, 23 Tab,
24 Escape, 24 Escape,
25 CtrlC, 25 /// Ctrl + key (e.g. Ctrl('c') = ^C, Ctrl('s') = ^S).
26 CtrlD, 26 Ctrl(char),
27 /// Ctrl + arrow (0=up, 1=down, 2=left, 3=right) — word/page nav.
28 CtrlArrow(u8),
27 ArrowUp, 29 ArrowUp,
28 ArrowDown, 30 ArrowDown,
29 ArrowLeft, 31 ArrowLeft,
@@ -306,13 +308,14 @@ fn process_byte(byte: u8) -> Option<KeyEvent> {
306 ch.to_ascii_uppercase() 308 ch.to_ascii_uppercase()
307 }; 309 };
308 } 310 }
309 // Ctrl+letter — the shell uses ^C (interrupt) and ^D (EOF). 311 // Ctrl+key — the terminal control-byte protocol (^C, ^D, ^S, ^X, …).
310 let ctrl = state.modifiers & (MOD_LCTRL | MOD_RCTRL) != 0; 312 let ctrl = state.modifiers & (MOD_LCTRL | MOD_RCTRL) != 0;
311 if ctrl { 313 if ctrl {
312 return match ch.to_ascii_lowercase() { 314 let lc = ch.to_ascii_lowercase();
313 b'c' => Some(KeyEvent::CtrlC), 315 return if lc.is_ascii_graphic() {
314 b'd' => Some(KeyEvent::CtrlD), 316 Some(KeyEvent::Ctrl(lc as char))
315 _ => None, // swallow other Ctrl+key combinations 317 } else {
318 None // swallow non-graphic Ctrl combos
316 }; 319 };
317 } 320 }
318 if ch != 0 { 321 if ch != 0 {
@@ -334,10 +337,10 @@ fn process_e0(byte: u8) -> Option<KeyEvent> {
334 update_modifiers(byte, true, true); 337 update_modifiers(byte, true, true);
335 338
336 match byte { 339 match byte {
337 0x48 => Some(KeyEvent::ArrowUp), 340 0x48 => Some(if ctrl_held() { KeyEvent::CtrlArrow(0) } else { KeyEvent::ArrowUp }),
338 0x50 => Some(KeyEvent::ArrowDown), 341 0x50 => Some(if ctrl_held() { KeyEvent::CtrlArrow(1) } else { KeyEvent::ArrowDown }),
339 0x4B => Some(KeyEvent::ArrowLeft), 342 0x4B => Some(if ctrl_held() { KeyEvent::CtrlArrow(2) } else { KeyEvent::ArrowLeft }),
340 0x4D => Some(KeyEvent::ArrowRight), 343 0x4D => Some(if ctrl_held() { KeyEvent::CtrlArrow(3) } else { KeyEvent::ArrowRight }),
341 0x47 => Some(KeyEvent::Home), 344 0x47 => Some(KeyEvent::Home),
342 0x4F => Some(KeyEvent::End), 345 0x4F => Some(KeyEvent::End),
343 0x49 => Some(KeyEvent::PageUp), 346 0x49 => Some(KeyEvent::PageUp),
@@ -371,3 +374,9 @@ fn set_mod(state: &mut KbdState, bit: u8, pressed: bool) {
371 state.modifiers &= !bit; 374 state.modifiers &= !bit;
372 } 375 }
373} 376}
377
378/// Is either Ctrl key held?
379fn ctrl_held() -> bool {
380 let state = unsafe { &mut KBD };
381 state.modifiers & (MOD_LCTRL | MOD_RCTRL) != 0
382}
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 @@
1//! Fixed-capacity ASCII string, allocated entirely on the stack.
2//! Shared by the line discipline (`tty`) and the shell.
3
4use core::fmt;
5
6/// A fixed-capacity string allocated entirely on the stack.
7pub struct String<const N: usize> {
8 buf: [u8; N],
9 len: usize,
10}
11
12impl<const N: usize> Default for String<N> {
13 fn default() -> Self {
14 Self::new()
15 }
16}
17
18impl<const N: usize> String<N> {
19 pub const fn new() -> Self {
20 String {
21 buf: [0u8; N],
22 len: 0,
23 }
24 }
25
26 pub fn as_str(&self) -> &str {
27 // SAFETY: we only write valid ASCII bytes.
28 unsafe { core::str::from_utf8_unchecked(&self.buf[..self.len]) }
29 }
30
31 pub fn push(&mut self, c: char) -> Result<(), ()> {
32 if self.len >= N {
33 return Err(());
34 }
35 self.buf[self.len] = c as u8;
36 self.len += 1;
37 Ok(())
38 }
39
40 pub fn pop(&mut self) -> Option<char> {
41 if self.len == 0 {
42 return None;
43 }
44 self.len -= 1;
45 Some(self.buf[self.len] as char)
46 }
47
48 pub fn is_empty(&self) -> bool {
49 self.len == 0
50 }
51
52 pub fn clear(&mut self) {
53 self.len = 0;
54 }
55
56 pub fn trim(&self) -> &str {
57 self.as_str().trim()
58 }
59}
60
61impl<const N: usize> fmt::Display for String<N> {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 write!(f, "{}", self.as_str())
64 }
65}
66
67impl<const N: usize> fmt::Debug for String<N> {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 write!(f, "{:?}", self.as_str())
70 }
71}
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 @@
1//! Line discipline over the PS/2 keyboard — the kernel's tty layer.
2//!
3//! Two modes, like a real UNIX terminal:
4//! - **canonical** (`read_line`): input is echoed, buffered until Enter,
5//! ^C cancels the line, ^D on an empty line is EOF.
6//! - **raw** (`read_byte` / `try_read_byte`): keys are translated to the
7//! classic terminal byte protocol (arrows → `ESC [ A–D`, Backspace →
8//! `0x7f`, ^X → `0x18`, …) and delivered one byte at a time, with no echo.
9//! Multi-byte sequences are queued internally so `read_byte` returns them
10//! in order.
11//!
12//! `interrupt_pressed` is the cooperative-interrupt check used by streaming
13//! commands (cat/hexdump): it drains the keyboard non-blockingly and reports
14//! whether ^C was seen.
15
16use crate::ps2::{self, KeyEvent};
17use crate::strbuf;
18use crate::{gop, serial};
19
20pub const LINE_BUF_SIZE: usize = 256;
21
22/// Result of one canonical line-input session.
23pub enum LineInput {
24 Line(strbuf::String<LINE_BUF_SIZE>),
25 Interrupted, // ^C
26 Eof, // ^D on an empty line
27}
28
29// Raw-mode byte queue: holds the tail of a multi-byte key sequence.
30static mut RAW_QUEUE: [u8; 8] = [0; 8];
31static mut RAW_LEN: usize = 0;
32
33// ---------------------------------------------------------------------------
34// canonical mode
35// ---------------------------------------------------------------------------
36
37/// Read one line in canonical mode: echo every key, ^C cancels the line,
38/// ^D on an empty line is EOF. Echo goes to the terminal (GOP) and serial.
39pub fn read_line() -> LineInput {
40 let mut buf = strbuf::String::<LINE_BUF_SIZE>::new();
41 loop {
42 match ps2::poll_key() {
43 KeyEvent::Enter => {
44 gop::put_char('\n');
45 serial::write_str("\r\n");
46 return LineInput::Line(buf);
47 }
48 KeyEvent::Ctrl('c') => {
49 // Cancel the current line — like a real terminal, echo ^C.
50 buf.clear();
51 gop::put_char('^');
52 gop::put_char('C');
53 gop::put_char('\n');
54 serial::write_str("^C\r\n");
55 return LineInput::Interrupted;
56 }
57 KeyEvent::Ctrl('d') => {
58 if buf.is_empty() {
59 // EOF on an empty line — the caller decides what to do.
60 gop::put_char('^');
61 gop::put_char('D');
62 gop::put_char('\n');
63 serial::write_str("^D\r\n");
64 return LineInput::Eof;
65 }
66 // ^D with text on the line: ignored (bash semantics).
67 }
68 KeyEvent::Char(c) if c.is_ascii_graphic() || c == ' ' => {
69 if buf.push(c).is_ok() {
70 gop::put_char(c);
71 let mut s = [0u8; 4];
72 let s = c.encode_utf8(&mut s);
73 serial::write_str(s);
74 }
75 }
76 KeyEvent::Backspace => {
77 if buf.pop().is_some() {
78 gop::put_char('\x08');
79 // Serial backspace: BS SP BS (standard terminal protocol)
80 serial::write_str("\x08 \x08");
81 }
82 }
83 KeyEvent::Tab => {
84 for _ in 0..4 {
85 if buf.push(' ').is_ok() {
86 gop::put_char(' ');
87 serial::write_str(" ");
88 }
89 }
90 }
91 _ => {}
92 }
93 }
94}
95
96/// Non-blocking drain of the keyboard: true if a ^C is pending. Used by
97/// streaming commands (cat/hexdump) so a long dump can be interrupted.
98pub fn interrupt_pressed() -> bool {
99 let mut pressed = false;
100 while let Some(ev) = ps2::try_poll_key() {
101 if matches!(ev, KeyEvent::Ctrl('c')) {
102 pressed = true;
103 }
104 }
105 pressed
106}
107
108// ---------------------------------------------------------------------------
109// raw mode
110// ---------------------------------------------------------------------------
111
112/// Blocking read of the next raw-mode byte. Keys arrive translated to the
113/// terminal byte protocol; multi-byte sequences are delivered in order.
114pub fn read_byte() -> u8 {
115 loop {
116 if let Some(b) = pop_raw() {
117 return b;
118 }
119 let ev = ps2::poll_key();
120 enqueue_key(ev);
121 }
122}
123
124/// Non-blocking raw read: Some(byte) if one is available, else None.
125pub fn try_read_byte() -> Option<u8> {
126 if let Some(b) = pop_raw() {
127 return Some(b);
128 }
129 match ps2::try_poll_key() {
130 Some(ev) => {
131 enqueue_key(ev);
132 pop_raw()
133 }
134 None => None,
135 }
136}
137
138fn pop_raw() -> Option<u8> {
139 unsafe {
140 if RAW_LEN == 0 {
141 return None;
142 }
143 let b = RAW_QUEUE[0];
144 for i in 1..RAW_LEN {
145 RAW_QUEUE[i - 1] = RAW_QUEUE[i];
146 }
147 RAW_LEN -= 1;
148 Some(b)
149 }
150}
151
152fn enqueue_key(ev: KeyEvent) {
153 let mut q = [0u8; 8];
154 let n: usize = match ev {
155 KeyEvent::Char(c) if c.is_ascii() => {
156 q[0] = c as u8;
157 1
158 }
159 KeyEvent::Enter => {
160 q[0] = 0x0d;
161 1
162 }
163 KeyEvent::Backspace => {
164 q[0] = 0x7f;
165 1
166 }
167 KeyEvent::Tab => {
168 q[0] = 0x09;
169 1
170 }
171 KeyEvent::Escape => {
172 q[0] = 0x1b;
173 1
174 }
175 KeyEvent::Ctrl(c) => {
176 q[0] = (c as u8) & 0x1f;
177 1
178 }
179 KeyEvent::ArrowUp => {
180 q[..3].copy_from_slice(b"\x1b[A");
181 3
182 }
183 KeyEvent::ArrowDown => {
184 q[..3].copy_from_slice(b"\x1b[B");
185 3
186 }
187 KeyEvent::ArrowRight => {
188 q[..3].copy_from_slice(b"\x1b[C");
189 3
190 }
191 KeyEvent::ArrowLeft => {
192 q[..3].copy_from_slice(b"\x1b[D");
193 3
194 }
195 KeyEvent::CtrlArrow(0) => {
196 q[..6].copy_from_slice(b"\x1b[1;5A");
197 6
198 }
199 KeyEvent::CtrlArrow(1) => {
200 q[..6].copy_from_slice(b"\x1b[1;5B");
201 6
202 }
203 KeyEvent::CtrlArrow(2) => {
204 q[..6].copy_from_slice(b"\x1b[1;5D");
205 6
206 }
207 KeyEvent::CtrlArrow(3) => {
208 q[..6].copy_from_slice(b"\x1b[1;5C");
209 6
210 }
211 KeyEvent::Home => {
212 q[..3].copy_from_slice(b"\x1b[H");
213 3
214 }
215 KeyEvent::End => {
216 q[..3].copy_from_slice(b"\x1b[F");
217 3
218 }
219 KeyEvent::PageUp => {
220 q[..4].copy_from_slice(b"\x1b[5~");
221 4
222 }
223 KeyEvent::PageDown => {
224 q[..4].copy_from_slice(b"\x1b[6~");
225 4
226 }
227 KeyEvent::Delete => {
228 q[..4].copy_from_slice(b"\x1b[3~");
229 4
230 }
231 _ => return,
232 };
233 unsafe {
234 for (i, b) in q.iter().take(n).enumerate() {
235 RAW_QUEUE[i] = *b;
236 }
237 RAW_LEN = n;
238 }
239}
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 @@
1//! Minimal ANSI terminal emulator over the GOP framebuffer.
2//!
3//! `vt` owns the text screen: a cell grid (char + fg/bg colour), a logical
4//! cursor, scrolling, and a small CSI parser. It renders to the framebuffer
5//! through `gop`, blitting only cells whose content changed since the last
6//! flush.
7//!
8//! This is the kernel's "terminal". Plain text (shell output, typed echo)
9//! flows through here in *ground* state; full-screen apps (rsdosedit) write
10//! ANSI escape sequences that the CSI parser interprets — cursor addressing,
11//! erase, 16-colour SGR, cursor show/hide.
12//!
13//! Known perf note: scrolling shifts the whole framebuffer one row up (a
14//! ~3 MB copy at 1024×768), which dominates under QEMU without KVM. A ring
15//! buffer / deferred-pan approach is the future optimisation; the cell grid
16//! itself never re-rasterises on scroll — only the exposed bottom row is
17//! blitted.
18
19use crate::gop;
20use alloc::boxed::Box;
21use alloc::vec;
22use alloc::vec::Vec;
23
24/// Standard VGA 16-colour palette (r, g, b).
25pub const PALETTE: [(u8, u8, u8); 16] = [
26 (0, 0, 0),
27 (0, 0, 170),
28 (0, 170, 0),
29 (0, 170, 170),
30 (170, 0, 0),
31 (170, 0, 170),
32 (170, 85, 0),
33 (170, 170, 170),
34 (85, 85, 85),
35 (85, 85, 255),
36 (85, 255, 85),
37 (85, 255, 255),
38 (255, 85, 85),
39 (255, 85, 255),
40 (255, 255, 85),
41 (255, 255, 255),
42];
43
44#[derive(Clone, Copy, PartialEq, Eq)]
45struct Cell {
46 ch: u16,
47 fg: u8,
48 bg: u8,
49}
50
51const BLANK: Cell = Cell { ch: 32, fg: 7, bg: 0 };
52
53// ---- screen state ----------------------------------------------------------
54
55static mut CELLS: *mut Vec<Cell> = core::ptr::null_mut();
56/// What is currently on the physical framebuffer (used to diff blits).
57static mut RENDERED: *mut Vec<Cell> = core::ptr::null_mut();
58static mut ROWS: usize = 0;
59static mut COLS: usize = 0;
60
61// ---- cursor ----------------------------------------------------------------
62
63static mut CURSOR_R: usize = 0;
64static mut CURSOR_C: usize = 0;
65static mut LAST_CURSOR: (usize, usize) = (0, 0);
66static mut CURSOR_VISIBLE: bool = true;
67
68// ---- SGR state -------------------------------------------------------------
69
70static mut SGR_FG: u8 = 7;
71static mut SGR_BG: u8 = 0;
72
73// ---- ANSI parser state -----------------------------------------------------
74
75#[derive(Clone, Copy, PartialEq, Eq)]
76enum Parser {
77 Ground,
78 Escape,
79 Csi,
80}
81
82static mut PARSER: Parser = Parser::Ground;
83static mut PRIVATE: bool = false;
84static mut PARAMS: [usize; 4] = [0; 4];
85static mut NPARAMS: usize = 0;
86static mut CUR: usize = 0;
87
88// ---------------------------------------------------------------------------
89// init / dimensions
90// ---------------------------------------------------------------------------
91
92/// Allocate the cell grid. Call once after `gop::init()`.
93pub fn init() {
94 let (cols, rows) = gop::dims();
95 unsafe {
96 ROWS = rows;
97 COLS = cols;
98 CELLS = Box::into_raw(Box::new(vec![BLANK; rows * cols]));
99 RENDERED = Box::into_raw(Box::new(vec![BLANK; rows * cols]));
100 CURSOR_R = 0;
101 CURSOR_C = 0;
102 LAST_CURSOR = (0, 0);
103 CURSOR_VISIBLE = true;
104 SGR_FG = 7;
105 SGR_BG = 0;
106 PARSER = Parser::Ground;
107 NPARAMS = 0;
108 PRIVATE = false;
109 }
110}
111
112/// (rows, cols) — matches the editor's expected (rows, cols) ordering.
113pub fn size() -> (usize, usize) {
114 unsafe { (ROWS, COLS) }
115}
116
117pub fn set_cursor_visible(v: bool) {
118 unsafe { CURSOR_VISIBLE = v; }
119 flush();
120}
121
122// ---------------------------------------------------------------------------
123// cell access
124// ---------------------------------------------------------------------------
125
126fn idx(r: usize, c: usize) -> usize {
127 r * unsafe { COLS } + c
128}
129
130fn cells() -> &'static mut Vec<Cell> {
131 unsafe { &mut *CELLS }
132}
133
134fn rendered_cells() -> &'static mut Vec<Cell> {
135 unsafe { &mut *RENDERED }
136}
137
138fn cell(r: usize, c: usize) -> &'static mut Cell {
139 &mut cells()[idx(r, c)]
140}
141
142fn rendered(r: usize, c: usize) -> &'static mut Cell {
143 &mut rendered_cells()[idx(r, c)]
144}
145
146fn put_cell(r: usize, c: usize, ch: u16) {
147 let cell = cell(r, c);
148 cell.ch = ch;
149 cell.fg = unsafe { SGR_FG };
150 cell.bg = unsafe { SGR_BG };
151}
152
153// ---------------------------------------------------------------------------
154// writing — the ANSI state machine
155// ---------------------------------------------------------------------------
156
157/// Feed one byte through the terminal state machine.
158fn put_byte(b: u8) {
159 unsafe {
160 match PARSER {
161 Parser::Ground => match b {
162 0x1b => PARSER = Parser::Escape,
163 0x0d => CURSOR_C = 0,
164 0x0a => newline(),
165 0x08 => {
166 // Backspace: move cursor back and erase.
167 if CURSOR_C > 0 {
168 CURSOR_C -= 1;
169 } else if CURSOR_R > 0 {
170 CURSOR_R -= 1;
171 CURSOR_C = COLS - 1;
172 } else {
173 return;
174 }
175 let c = cell(CURSOR_R, CURSOR_C);
176 *c = BLANK;
177 }
178 0x09 => {
179 let n = 8 - (CURSOR_C % 8);
180 CURSOR_C = (CURSOR_C + n).min(COLS - 1);
181 }
182 0x00..=0x1f => {} // other control chars: ignored
183 _ => {
184 let ch = if b < 0x80 { b as u16 } else { b'?' as u16 };
185 put_cell(CURSOR_R, CURSOR_C, ch);
186 CURSOR_C += 1;
187 if CURSOR_C >= COLS {
188 CURSOR_C = 0;
189 newline();
190 }
191 }
192 },
193 Parser::Escape => match b {
194 b'[' => {
195 PRIVATE = false;
196 NPARAMS = 0;
197 CUR = 0;
198 PARSER = Parser::Csi;
199 }
200 _ => PARSER = Parser::Ground, // unsupported escape — drop
201 },
202 Parser::Csi => match b {
203 b'?' => PRIVATE = true,
204 b'0'..=b'9' => {
205 CUR = CUR.saturating_mul(10).saturating_add((b - b'0') as usize);
206 }
207 b';' => {
208 if NPARAMS < PARAMS.len() {
209 PARAMS[NPARAMS] = CUR;
210 NPARAMS += 1;
211 }
212 CUR = 0;
213 }
214 _ => {
215 if NPARAMS < PARAMS.len() {
216 PARAMS[NPARAMS] = CUR;
217 NPARAMS += 1;
218 }
219 PARSER = Parser::Ground;
220 csi_final(b);
221 }
222 },
223 }
224 }
225}
226
227fn param(i: usize, default: usize) -> usize {
228 unsafe {
229 if i < NPARAMS {
230 PARAMS[i]
231 } else {
232 default
233 }
234 }
235}
236
237fn csi_final(b: u8) {
238 match b {
239 // Cursor position (1-based; 0 means 1)
240 b'H' | b'f' => {
241 let r = param(0, 1).max(1).saturating_sub(1);
242 let c = param(1, 1).max(1).saturating_sub(1);
243 unsafe {
244 CURSOR_R = r.min(ROWS - 1);
245 CURSOR_C = c.min(COLS - 1);
246 }
247 }
248 // Cursor up / down / forward / back
249 b'A' => unsafe { CURSOR_R = CURSOR_R.saturating_sub(param(0, 1).max(1)) },
250 b'B' => unsafe { CURSOR_R = (CURSOR_R + param(0, 1).max(1)).min(ROWS - 1) },
251 b'C' => unsafe { CURSOR_C = (CURSOR_C + param(0, 1).max(1)).min(COLS - 1) },
252 b'D' => unsafe { CURSOR_C = CURSOR_C.saturating_sub(param(0, 1).max(1)) },
253 // Erase in display: 0 below, 1 above, 2 all
254 b'J' => match param(0, 0) {
255 0 => {
256 erase_eol();
257 unsafe {
258 for r in (CURSOR_R + 1)..ROWS {
259 erase_row(r);
260 }
261 }
262 }
263 1 => {
264 unsafe {
265 for r in 0..CURSOR_R {
266 erase_row(r);
267 }
268 }
269 erase_bol();
270 }
271 _ => erase_all(),
272 },
273 // Erase in line: 0 to EOL, 1 from BOL, 2 whole line
274 b'K' => match param(0, 0) {
275 1 => erase_bol(),
276 2 => {
277 let r = unsafe { CURSOR_R };
278 erase_row(r);
279 }
280 _ => erase_eol(),
281 },
282 // SGR — colours / attributes
283 b'm' => {
284 let n = unsafe { NPARAMS };
285 if n == 0 {
286 apply_sgr(0);
287 } else {
288 for i in 0..n {
289 apply_sgr(unsafe { PARAMS[i] });
290 }
291 }
292 }
293 // Private mode: 25 = cursor show/hide (h = show, l = hide)
294 b'l' | b'h' if unsafe { PRIVATE } => {
295 for i in 0..unsafe { NPARAMS } {
296 if unsafe { PARAMS[i] } == 25 {
297 unsafe { CURSOR_VISIBLE = b == b'h' };
298 }
299 }
300 }
301 _ => {} // unknown / ignored
302 }
303}
304
305/// ANSI SGR colour index (0–7) → VGA palette index. The two orderings
306/// differ: ANSI 1=red, 3=yellow, 4=blue, 6=cyan; VGA 1=blue, 3=cyan,
307/// 4=red, 6=brown. Terminals translate ANSI→VGA.
308const ANSI_TO_VGA: [u8; 8] = [0, 4, 2, 6, 1, 5, 3, 7];
309
310fn apply_sgr(p: usize) {
311 unsafe {
312 match p {
313 0 => {
314 SGR_FG = 7;
315 SGR_BG = 0;
316 }
317 1 => {
318 if SGR_FG < 8 {
319 SGR_FG += 8; // bold ≈ bright
320 }
321 }
322 30..=37 => SGR_FG = ANSI_TO_VGA[p - 30],
323 40..=47 => SGR_BG = ANSI_TO_VGA[p - 40],
324 90..=97 => SGR_FG = ANSI_TO_VGA[p - 90] + 8,
325 100..=107 => SGR_BG = ANSI_TO_VGA[p - 100] + 8,
326 39 => SGR_FG = 7,
327 49 => SGR_BG = 0,
328 _ => {}
329 }
330 }
331}
332
333// ---------------------------------------------------------------------------
334// erase / scroll helpers
335// ---------------------------------------------------------------------------
336
337fn erase_eol() {
338 let r = unsafe { CURSOR_R };
339 let c = unsafe { CURSOR_C };
340 for col in c..unsafe { COLS } {
341 *cell(r, col) = BLANK;
342 }
343}
344
345fn erase_bol() {
346 let r = unsafe { CURSOR_R };
347 let c = unsafe { CURSOR_C };
348 for col in 0..=c {
349 *cell(r, col) = BLANK;
350 }
351}
352
353fn erase_row(r: usize) {
354 for col in 0..unsafe { COLS } {
355 *cell(r, col) = BLANK;
356 }
357}
358
359fn erase_all() {
360 for r in 0..unsafe { ROWS } {
361 erase_row(r);
362 }
363}
364
365/// Advance cursor to the next line (col 0), scrolling if at the bottom.
366fn newline() {
367 unsafe {
368 CURSOR_C = 0;
369 if CURSOR_R + 1 < ROWS {
370 CURSOR_R += 1;
371 } else {
372 scroll_up();
373 }
374 }
375}
376
377/// Shift the whole screen up one row: cell grids in memory, pixels on the
378/// framebuffer. Only the exposed bottom row gets re-rasterised by `flush`.
379fn scroll_up() {
380 let n = unsafe { ROWS * COLS };
381 let cols = unsafe { COLS };
382 cells().copy_within(cols..n, 0);
383 rendered_cells().copy_within(cols..n, 0);
384 for c in 0..cols {
385 cells()[n - cols + c] = BLANK;
386 rendered_cells()[n - cols + c] = BLANK;
387 }
388 gop::shift_up();
389}
390
391// ---------------------------------------------------------------------------
392// public writing API
393// ---------------------------------------------------------------------------
394
395/// Write raw bytes (ANSI-capable). Used by full-screen apps.
396pub fn write_bytes(b: &[u8]) {
397 for &x in b {
398 put_byte(x);
399 }
400 flush();
401}
402
403/// Write a UTF-8 string (plain text; non-ASCII becomes '?').
404pub fn write_str(s: &str) {
405 for c in s.chars() {
406 let b = if (c as u32) < 0x80 { c as u8 } else { b'?' };
407 put_byte(b);
408 }
409 flush();
410}
411
412/// Write a single ASCII byte / char.
413pub fn put_char(b: u8) {
414 put_byte(b);
415 flush();
416}
417
418/// Clear the whole screen, reset colours, home the cursor.
419pub fn clear_all() {
420 unsafe {
421 SGR_FG = 7;
422 SGR_BG = 0;
423 erase_all();
424 CURSOR_R = 0;
425 CURSOR_C = 0;
426 }
427 flush();
428}
429
430// ---------------------------------------------------------------------------
431// rendering
432// ---------------------------------------------------------------------------
433
434/// Blit every cell whose content differs from what the framebuffer shows,
435/// then draw the logical cursor (inverse video) on top.
436fn flush() {
437 let rows = unsafe { ROWS };
438 let cols = unsafe { COLS };
439 let n = rows * cols;
440 {
441 let cells = cells();
442 let rendered = rendered_cells();
443 for i in 0..n {
444 let c = cells[i];
445 let r = rendered[i];
446 if c != r {
447 let row = i / cols;
448 let col = i % cols;
449 let fg = PALETTE[c.fg as usize];
450 let bg = PALETTE[c.bg as usize];
451 gop::blit_glyph(col, row, c.ch, fg, bg);
452 rendered[i] = c;
453 }
454 }
455 }
456
457 unsafe {
458 let (lr, lc) = LAST_CURSOR;
459 let moved = (lr, lc) != (CURSOR_R, CURSOR_C);
460 // Erase the previous cursor position (restore its normal colours).
461 if (moved || !CURSOR_VISIBLE) && lr < rows && lc < cols {
462 let c = cells()[idx(lr, lc)];
463 let fg = PALETTE[c.fg as usize];
464 let bg = PALETTE[c.bg as usize];
465 gop::blit_glyph(lc, lr, c.ch, fg, bg);
466 }
467 LAST_CURSOR = (CURSOR_R, CURSOR_C);
468 // Draw the cursor as an inverse-video cell.
469 if CURSOR_VISIBLE && CURSOR_R < rows && CURSOR_C < cols {
470 let c = cells()[idx(CURSOR_R, CURSOR_C)];
471 let fg = PALETTE[c.fg as usize];
472 let bg = PALETTE[c.bg as usize];
473 gop::blit_glyph(CURSOR_C, CURSOR_R, c.ch, bg, fg);
474 }
475 }
476}