summaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--CONTEXT.md38
-rw-r--r--Cargo.lock8
-rw-r--r--Cargo.toml2
-rw-r--r--rsdos/src/main.rs8
-rw-r--r--rsdosedit/Cargo.toml7
-rw-r--r--rsdosedit/src/lib.rs1251
-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
-rw-r--r--shell/Cargo.toml1
-rw-r--r--shell/src/lib.rs180
14 files changed, 2209 insertions, 258 deletions
diff --git a/CONTEXT.md b/CONTEXT.md
index 66ab320..b557192 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -101,10 +101,20 @@ Single Rust crate (`x86_64-unknown-uefi` target). Module breakdown:
101- `fs.rs` — VFS: dirs, ramfs files, char devices (`/dev/mem`, `rtc`, `pci`, 101- `fs.rs` — VFS: dirs, ramfs files, char devices (`/dev/mem`, `rtc`, `pci`,
102 `version`, `meminfo`, `null`, `zero`, `console`, `serial`; `/tmp` ramfs; 102 `version`, `meminfo`, `null`, `zero`, `console`, `serial`; `/tmp` ramfs;
103 `/etc/motd`) 103 `/etc/motd`)
104- `gop.rs` — framebuffer access and text rendering 104- `gop.rs` — framebuffer rasteriser (glyph blits, pixel shifts); the text
105 surface itself lives in `vt`
106- `vt.rs` — terminal emulator: cell grid, cursor, ANSI CSI parser (H/J/K,
107 cursor moves, SGR 16-colour, `?25l/h`), dirty-cell blitting
108- `tty.rs` — line discipline: canonical `read_line` (echo, line buffer,
109 ^C/^D) and raw `read_byte` (terminal byte protocol: arrows → `ESC [ A–D`,
110 ^X → `0x18`, …)
111- `strbuf.rs` — stack fixed-capacity ASCII string (moved from shell)
105- `font.rs` — 8×16 bitmap font data 112- `font.rs` — 8×16 bitmap font data
106- `ps2.rs` — PS/2 keyboard driver (polling) 113- `ps2.rs` — PS/2 keyboard driver (polling); generic `Ctrl(char)` and
114 `CtrlArrow` events
107- `shell.rs` — command loop and built-in commands 115- `shell.rs` — command loop and built-in commands
116- `rsdosedit/` — kernel-native port of the RNG `rsedit` terminal editor
117 (no_std; talks to `tty` raw mode + `vt` ANSI; saves via the VFS)
108 118
109## Milestone status (v0.1) 119## Milestone status (v0.1)
110 120
@@ -133,6 +143,30 @@ Single Rust crate (`x86_64-unknown-uefi` target). Module breakdown:
133✅ **^C / ^D** — Ctrl+C cancels the line or aborts a running stream 143✅ **^C / ^D** — Ctrl+C cancels the line or aborts a running stream
134 (`cat`/`hexdump` check between chunks), Ctrl+D on an empty line 144 (`cat`/`hexdump` check between chunks), Ctrl+D on an empty line
135 powers off (EOF) 145 powers off (EOF)
146✅ **tty + vt (terminal) — the 1:1 UNIX console split**: `tty` owns the
147 line discipline (canonical echo/buffer/^C/^D for the shell; raw mode
148 translates keys to the classic terminal byte protocol), `vt` owns the
149 screen (cell grid + ANSI CSI parser + 16-colour SGR + dirty-cell blits
150 into the GOP framebuffer). `gop_println!` etc. now render through `vt`.
151✅ **rsdosedit** — the RNG `rsedit` editor ported to the kernel (no_std,
152 no libc): `edit <path>` opens a full-screen editor with undo, syntax
153 highlighting, search (^W), save (^S), exit (^X, twice to discard), word
154 nav (Ctrl+arrows), smart indent. Files live in the ramfs. Termios →
155 `tty` raw mode, stdout → `vt`, `std::fs` → VFS, SIGWINCH → fixed
156 geometry. Verified in QEMU: edit/type/navigate/insert/save/exit, exact
157 bytes via `cat`/`hexdump`, syntax colours via framebuffer dump.
158
159## Known issues / perf notes
160
161- **Scroll is slow under QEMU without KVM**: each scroll shifts the whole
162 framebuffer up (~3 MB copy at 1280×800) with TCG-emulated stores.
163 Deferred optimisation: ring-buffer rows / deferred pan so scrolling
164 never touches the whole framebuffer. The vt's cell grid already avoids
165 re-rasterising on scroll (only the exposed bottom row is blitted).
166- **Editor redraw** re-blits changed cells per keystroke (fine at 1280×800);
167 the "optimize drawing someday" item.
168- **ramfs only**: edited files live in memory, lost on reboot until disk
169 support (AHCI/FAT32) lands.
136 170
137## Target audience 171## Target audience
138 172
diff --git a/Cargo.lock b/Cargo.lock
index 1d697fc..7b1ced6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11,6 +11,13 @@ dependencies = [
11] 11]
12 12
13[[package]] 13[[package]]
14name = "rsdosedit"
15version = "0.1.0"
16dependencies = [
17 "rsnix",
18]
19
20[[package]]
14name = "rsnix" 21name = "rsnix"
15version = "0.2.0" 22version = "0.2.0"
16 23
@@ -18,5 +25,6 @@ version = "0.2.0"
18name = "shell" 25name = "shell"
19version = "0.2.0" 26version = "0.2.0"
20dependencies = [ 27dependencies = [
28 "rsdosedit",
21 "rsnix", 29 "rsnix",
22] 30]
diff --git a/Cargo.toml b/Cargo.toml
index 14eaeff..cf18339 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[workspace] 1[workspace]
2resolver = "2" 2resolver = "2"
3members = ["rsnix", "shell", "rsdos"] 3members = ["rsnix", "shell", "rsdos", "rsdosedit"]
4 4
5[workspace.package] 5[workspace.package]
6version = "0.2.0" 6version = "0.2.0"
diff --git a/rsdos/src/main.rs b/rsdos/src/main.rs
index 8e49937..dc59e8e 100644
--- a/rsdos/src/main.rs
+++ b/rsdos/src/main.rs
@@ -82,6 +82,14 @@ pub extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut SystemT
82 } else { 82 } else {
83 rsnix::serial_println!("gop: not found (0x{:x})", gop_result); 83 rsnix::serial_println!("gop: not found (0x{:x})", gop_result);
84 } 84 }
85 rsnix::vt::init();
86 rsnix::serial_println!("vt: terminal ready ({}x{})", {
87 let (rows, cols) = rsnix::vt::size();
88 rows
89 }, {
90 let (rows, cols) = rsnix::vt::size();
91 cols
92 });
85 93
86 // ExitBootServices — we now own the machine 94 // ExitBootServices — we now own the machine
87 if unsafe { (bs.exit_boot_services)(image_handle, map_key) } != EFI_SUCCESS { 95 if unsafe { (bs.exit_boot_services)(image_handle, map_key) } != EFI_SUCCESS {
diff --git a/rsdosedit/Cargo.toml b/rsdosedit/Cargo.toml
new file mode 100644
index 0000000..a15375c
--- /dev/null
+++ b/rsdosedit/Cargo.toml
@@ -0,0 +1,7 @@
1[package]
2name = "rsdosedit"
3version = "0.1.0"
4edition = "2021"
5
6[dependencies]
7rsnix = { path = "../rsnix" }
diff --git a/rsdosedit/src/lib.rs b/rsdosedit/src/lib.rs
new file mode 100644
index 0000000..2d04da2
--- /dev/null
+++ b/rsdosedit/src/lib.rs
@@ -0,0 +1,1251 @@
1#![no_std]
2
3extern crate alloc;
4
5use alloc::format;
6use alloc::string::String;
7use alloc::string::ToString;
8use alloc::vec;
9use alloc::vec::Vec;
10
11use rsnix::{tty, vt};
12
13// ── Undo ────────────────────────────────────────────────────────────────────
14
15struct UndoEntry {
16 lines: Vec<String>,
17 cx: usize,
18 cy: usize,
19 scroll_y: usize,
20}
21
22// ── Entry point ─────────────────────────────────────────────────────────────
23
24/// Run the editor on `filename` (empty = new file). Blocks until the user
25/// exits; the caller (shell) is expected to clear the screen afterwards.
26pub fn run(filename: &str) {
27 let mut editor = Editor::new(String::from(filename));
28 editor.run();
29}
30
31// ── Editor ──────────────────────────────────────────────────────────────────
32
33struct Editor {
34 filename: String,
35 lines: Vec<String>,
36 cx: usize,
37 cy: usize,
38 scroll_y: usize,
39 modified: bool,
40 term_rows: usize,
41 term_cols: usize,
42 status_msg: String,
43 read_buf: Vec<u8>,
44 read_pos: usize,
45 /// Key count of the last ^X press that was blocked by unsaved changes.
46 /// A second ^X within FORCE_EXIT_KEYS exits even with unsaved work.
47 last_x_count: u64,
48 /// Total keys processed (a monotonic proxy for time — rsdos has no clock
49 /// visible to apps; FORCE_EXIT_KEYS ≈ 2 s of typing).
50 key_count: u64,
51 undo_stack: Vec<UndoEntry>,
52 /// First syntax error found after the last modification, if any.
53 syntax_error: Option<String>,
54}
55
56impl Editor {
57 /// How many processed keys after a blocked ^X a second ^X will force-exit
58 /// despite unsaved changes.
59 const FORCE_EXIT_KEYS: u64 = 40;
60 /// Maximum number of undo entries kept.
61 const MAX_UNDO: usize = 100;
62
63 // ── Construction ────────────────────────────────────────────────────────
64
65 fn new(filename: String) -> Self {
66 let lines = if !filename.is_empty() {
67 match load_lines(&filename) {
68 Some(v) => v,
69 None => vec![String::new()],
70 }
71 } else {
72 vec![String::new()]
73 };
74
75 let (rows, cols) = vt::size();
76
77 Editor {
78 filename,
79 lines,
80 cx: 0,
81 cy: 0,
82 scroll_y: 0,
83 modified: false,
84 term_rows: rows.saturating_sub(2),
85 term_cols: cols,
86 status_msg: String::new(),
87 read_buf: Vec::new(),
88 read_pos: 0,
89 last_x_count: 0,
90 key_count: 0,
91 undo_stack: Vec::new(),
92 syntax_error: None,
93 }
94 }
95
96 // ── Gutter helpers ──────────────────────────────────────────────────────
97
98 /// Total width of the line-number gutter including its separator.
99 fn gutter_width(&self) -> usize {
100 let digits = self.lines.len().max(1).to_string().len();
101 digits.max(2) + 3 // e.g. " 123 │ " = 6 for 3-digit numbers
102 }
103
104 /// Whether the current filename extension is a recognised source-code
105 /// type that should get syntax highlighting and checking.
106 fn is_source_file(&self) -> bool {
107 let exts: &[&str] = &[
108 "c", "h", "cpp", "cc", "cxx", "c++", "hpp", "hh", "hxx", "rs", "go", "java", "kt",
109 "kts", "swift", "py", "rb", "js", "ts", "jsx", "tsx", "m", "mm", "cs", "zig", "scala",
110 "dart", "php", "r", "jl", "lua", "pl", "pm", "tcl", "s", "asm", "S",
111 ];
112 let dot = self.filename.rsplit('.').next().unwrap_or("");
113 let ext = if dot.is_empty() || dot == self.filename {
114 ""
115 } else {
116 dot
117 };
118 exts.contains(&ext)
119 }
120
121 /// Draw one editor row: gutter prefix + content, or a tilde for rows
122 /// past end-of-file.
123 fn draw_line_at(&self, screen_row: usize, line_idx: usize, out: &mut Vec<u8>) {
124 out.extend_from_slice(format!("\x1b[{};1H", screen_row).as_bytes());
125 out.extend_from_slice(b"\x1b[K");
126 out.extend_from_slice(b"\x1b[0m");
127
128 if line_idx < self.lines.len() {
129 let gw = self.gutter_width();
130 let w = gw - 3;
131 let gutter = format!("{:>width$} │ ", line_idx + 1, width = w);
132 out.extend_from_slice(gutter.as_bytes());
133
134 // Truncate content to terminal width, then highlight
135 let max_cols = self.term_cols.saturating_sub(gw);
136 let line = &self.lines[line_idx];
137 let visible = if line.len() <= max_cols {
138 &line[..]
139 } else if let Some((end, _)) = line.char_indices().nth(max_cols) {
140 &line[..end]
141 } else {
142 &line[..line.len()]
143 };
144 if self.is_source_file() {
145 out.extend_from_slice(&self.highlight_line(visible));
146 out.extend_from_slice(b"\x1b[0m");
147 } else {
148 out.extend_from_slice(visible.as_bytes());
149 }
150 } else {
151 // Tilde row — past end of file
152 out.extend_from_slice(b"\x1b[0m~");
153 }
154 }
155
156 // ── Undo ────────────────────────────────────────────────────────────────
157
158 fn push_undo(&mut self) {
159 if self.undo_stack.len() >= Self::MAX_UNDO {
160 self.undo_stack.remove(0);
161 }
162 self.undo_stack.push(UndoEntry {
163 lines: self.lines.clone(),
164 cx: self.cx,
165 cy: self.cy,
166 scroll_y: self.scroll_y,
167 });
168 }
169
170 fn undo(&mut self) {
171 let entry = match self.undo_stack.pop() {
172 Some(e) => e,
173 None => {
174 self.status_msg = "Nothing to undo".to_string();
175 return;
176 }
177 };
178 self.lines = entry.lines;
179 self.cx = entry.cx;
180 self.cy = entry.cy;
181 self.scroll_y = entry.scroll_y;
182 self.modified = true;
183 self.status_msg = "Undone".to_string();
184 }
185
186 // ── Help screen ─────────────────────────────────────────────────────────
187
188 fn help_screen(&mut self) {
189 let mut out: Vec<u8> = Vec::new();
190 out.extend_from_slice(b"\x1b[?25l\x1b[2J\x1b[H");
191
192 let help_lines = [
193 ("", ""),
194 (" \u{2550}", "\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}"),
195 (" rsedit", " \u{2014} Keybindings"),
196 (" \u{2550}", "\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}"),
197 ("", ""),
198 (" Arrow keys", " Move cursor"),
199 (" ^\u{2190} / ^\u{2192}", " Word backward/forward"),
200 (" ^\u{2191} / ^\u{2193}", " Page up/down"),
201 (" Page Up / Page Down", " Scroll by page"),
202 (" Home (^A)", " Beginning of line"),
203 (" End (^E)", " End of line"),
204 (" Enter", " Split line at cursor"),
205 (" Backspace", " Delete char before cursor"),
206 (" Delete (^D / Del)", " Delete char at cursor"),
207 (" ^K", " Kill to end of line"),
208 (" Tab", " Insert spaces (smart tab)"),
209 (" ^S", " Save file"),
210 (" ^W", " Search forward"),
211 (" ^X", " Exit (twice to discard changes)"),
212 (" ^Z", " Suspend editor (job control)"),
213 (" ^_", " Undo"),
214 (" ^G", " This help"),
215 ("", ""),
216 (" Press any key to return to the editor...", ""),
217 ];
218
219 for (i, (left, right)) in help_lines.iter().enumerate() {
220 let row = i + 1;
221 out.extend_from_slice(format!("\x1b[{};1H", row).as_bytes());
222 out.extend_from_slice(b"\x1b[K");
223 out.extend_from_slice(left.as_bytes());
224 out.extend_from_slice(right.as_bytes());
225 }
226
227 // Cursor on the last help line ("Press any key...")
228 let prompt_row = help_lines.len();
229 out.extend_from_slice(format!("\x1b[{};1H", prompt_row).as_bytes());
230 out.extend_from_slice(b"\x1b[?25h");
231
232 vt::write_bytes(&out);
233
234 let _ = tty::read_byte();
235
236 self.draw_full();
237 }
238
239 // ── Search ──────────────────────────────────────────────────────────────
240
241 fn search(&mut self) {
242 let mut query = String::new();
243 let orig_cy = self.cy;
244 let orig_cx = self.cx;
245 let mut found = true;
246 let status_row = self.term_rows + 2;
247
248 loop {
249 // Draw search prompt on the status row
250 let mut out: Vec<u8> = Vec::new();
251 out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes());
252 out.extend_from_slice(b"\x1b[K");
253 if found {
254 out.extend_from_slice(b"Search: ");
255 } else {
256 out.extend_from_slice(b"Search: [not found] ");
257 }
258 out.extend_from_slice(query.as_bytes());
259 vt::write_bytes(&out);
260
261 let b = match self.read_byte() {
262 Some(b) => b,
263 None => return,
264 };
265
266 match b {
267 0x0a | 0x0d => break, // Enter — accept the position
268 0x1b | 0x03 => {
269 // Esc / ^C — cancel, restore original position
270 self.cy = orig_cy;
271 self.cx = orig_cx;
272 break;
273 }
274 0x7f => {
275 query.pop();
276 found = self.find_next(&query, orig_cy, orig_cx);
277 }
278 _ if (0x20..=0x7e).contains(&b) && query.len() < 256 => {
279 query.push(b as char);
280 found = self.find_next(&query, orig_cy, orig_cx);
281 }
282 _ => {}
283 }
284 }
285
286 if found && !query.is_empty() {
287 self.status_msg = format!("Found: {}", query);
288 }
289 self.scroll_to_cursor();
290 self.draw_full();
291 }
292
293 /// Search forward from (start_cy, start_cx) for `query`. If found,
294 /// move the cursor there and return true.
295 fn find_next(&mut self, query: &str, start_cy: usize, start_cx: usize) -> bool {
296 if query.is_empty() {
297 return false;
298 }
299
300 // Current line, after cursor
301 if let Some(pos) = self.lines[start_cy][start_cx..].find(query) {
302 self.cy = start_cy;
303 self.cx = start_cx + pos;
304 return true;
305 }
306
307 // Following lines
308 for i in (start_cy + 1)..self.lines.len() {
309 if let Some(pos) = self.lines[i].find(query) {
310 self.cy = i;
311 self.cx = pos;
312 return true;
313 }
314 }
315
316 false
317 }
318
319 // ── Main loop ───────────────────────────────────────────────────────────
320
321 fn run(&mut self) {
322 self.draw_full();
323
324 loop {
325 let b = match self.read_byte() {
326 Some(b) => b,
327 None => break,
328 };
329
330 self.key_count += 1;
331 self.status_msg.clear();
332
333 let prev_cy = self.cy;
334 let prev_scroll = self.scroll_y;
335 let prev_modified = self.modified;
336 let prev_line_count = self.lines.len();
337
338 // Any key other than ^X cancels a pending force-exit
339 let prev_x_count = self.last_x_count;
340 if b != 0x18 {
341 self.last_x_count = 0;
342 }
343
344 match b {
345 // ── File operations ──────────────────────────────────────
346 0x13 => self.save(),
347
348 // ^C: no-op (raw mode: 0x03 is just a byte)
349 0x03 => {}
350
351 // ── Undo ─────────────────────────────────────────────────
352
353 // ^_ (Ctrl-underscore) — undo
354 0x1f => self.undo(),
355
356 // ── Help ─────────────────────────────────────────────────
357
358 // ^G — help screen
359 0x07 => self.help_screen(),
360
361 // ── Search ───────────────────────────────────────────────
362
363 // ^W — search
364 0x17 => self.search(),
365
366 // ── Tab ──────────────────────────────────────────────────
367
368 // Tab → insert spaces up to the next 4-column boundary
369 0x09 => {
370 self.push_undo();
371 const TAB_STOP: usize = 4;
372 let n = TAB_STOP - (self.cx % TAB_STOP);
373 for _ in 0..n {
374 self.lines[self.cy].insert(self.cx, ' ');
375 self.cx += 1;
376 }
377 self.modified = true;
378 }
379
380 // ── Exit ─────────────────────────────────────────────────
381 0x18 => {
382 if !self.modified {
383 break;
384 }
385 let force = prev_x_count != 0
386 && (self.key_count - prev_x_count) <= Self::FORCE_EXIT_KEYS;
387 if force {
388 break;
389 }
390 self.last_x_count = self.key_count;
391 self.status_msg =
392 "Unsaved changes! ^S to save, or ^X again to discard.".to_string();
393 }
394
395 // ── Vertical movement ────────────────────────────────────
396
397 // ^N (next line) / ^P (prev line) — also mapped to
398 // arrow down/up via escape sequences
399 0x0e | 0x10 => {
400 let down = b == 0x0e;
401 if down {
402 if self.cy + 1 < self.lines.len() {
403 self.cy += 1;
404 }
405 } else if self.cy > 0 {
406 self.cy -= 1;
407 }
408 self.clamp_cursor();
409 }
410
411 // ── Horizontal movement ──────────────────────────────────
412
413 // ^F / ^B — right/left
414 0x06 | 0x02 => {
415 let right = b == 0x06;
416 if right {
417 if self.cx < self.lines[self.cy].len() {
418 self.cx += 1;
419 }
420 } else if self.cx > 0 {
421 self.cx -= 1;
422 }
423 }
424
425 // ^A / ^E — line start / line end
426 0x01 => self.cx = 0,
427 0x05 => self.cx = self.lines[self.cy].len(),
428
429 // ── Kill to end of line ──────────────────────────────────
430
431 // ^K
432 0x0b => {
433 self.push_undo();
434 self.lines[self.cy].truncate(self.cx);
435 self.modified = true;
436 }
437
438 // ── Escape sequences (arrows, pgup/pgdn, etc.) ───────────
439 0x1b => self.handle_escape(),
440
441 // ── Line split / join ────────────────────────────────────
442 0x0a | 0x0d => {
443 self.push_undo();
444 // Capture the leading whitespace before splitting so the
445 // new line inherits the same indent level. If the text
446 // before the cursor ends with an opening bracket, bump
447 // the indent by one extra level.
448 let (indent, extra) = {
449 let line = &self.lines[self.cy];
450 let ws = line.len() - line.trim_start().len();
451 let base = line[..ws].to_string();
452 let before = &line[..self.cx.min(line.len())];
453 let bump = before
454 .ends_with('{')
455 .then_some(" ")
456 .or_else(|| before.ends_with('(').then_some(" "))
457 .or_else(|| before.ends_with('[').then_some(" "))
458 .unwrap_or("")
459 .to_string();
460 (base, bump)
461 };
462 let full = indent.clone() + &extra;
463 let rest = self.lines[self.cy].split_off(self.cx);
464 self.lines.insert(self.cy + 1, rest);
465 self.cy += 1;
466 // Avoid doubling when split_off at 0 already moved the
467 // full line (with its indent) into the new line.
468 if !self.lines[self.cy].starts_with(&full) {
469 self.lines[self.cy].insert_str(0, &full);
470 }
471 self.cx = full.len();
472 self.modified = true;
473 }
474
475 // ── Backspace ────────────────────────────────────────────
476
477 // 0x7f (DEL / Backspace in raw mode)
478 0x7f => {
479 self.push_undo();
480 if self.cx > 0 {
481 self.lines[self.cy].remove(self.cx - 1);
482 self.cx -= 1;
483 self.modified = true;
484 } else if self.cy > 0 {
485 let rest = self.lines.remove(self.cy);
486 self.cy -= 1;
487 self.cx = self.lines[self.cy].len();
488 self.lines[self.cy].push_str(&rest);
489 self.modified = true;
490 }
491 }
492
493 // ── Delete ───────────────────────────────────────────────
494
495 // ^D
496 0x04 => {
497 self.push_undo();
498 if self.cx < self.lines[self.cy].len() {
499 self.lines[self.cy].remove(self.cx);
500 self.modified = true;
501 } else if self.cy + 1 < self.lines.len() {
502 let next = self.lines.remove(self.cy + 1);
503 self.lines[self.cy].push_str(&next);
504 self.modified = true;
505 }
506 }
507
508 // ── Printable characters ─────────────────────────────────
509 _ if (0x20..=0x7e).contains(&b) => {
510 let ch = b as char;
511 self.push_undo();
512 self.modified = true;
513
514 // Auto-dedent closing brackets when the cursor is at the
515 // end of a whitespace-only prefix (one indent level).
516 let dedent = matches!(ch, '}' | ')' | ']')
517 && self.cx >= 4
518 && self.lines[self.cy][..self.cx].chars().all(|c| c == ' ');
519
520 if dedent {
521 let indent = self.cx - 4;
522 let after = self.lines[self.cy][self.cx..].to_string();
523 self.lines[self.cy] =
524 format!("{:indent$}{}{}", "", ch, after, indent = indent);
525 self.cx = indent + 1;
526 } else {
527 self.lines[self.cy].insert(self.cx, ch);
528 self.cx += 1;
529 }
530 }
531
532 _ => {}
533 }
534
535 // Re-check syntax after any modification
536 if self.modified {
537 self.syntax_error = if self.is_source_file() {
538 self.check_syntax()
539 } else {
540 None
541 };
542 }
543
544 self.scroll_to_cursor();
545 self.update_screen(prev_cy, prev_scroll, prev_modified, prev_line_count);
546 }
547
548 // Restore terminal on exit: show cursor, clear screen, home.
549 vt::write_bytes(b"\x1b[?25h\x1b[2J\x1b[H");
550 }
551
552 // ── Cursor helpers ──────────────────────────────────────────────────────
553
554 fn clamp_cursor(&mut self) {
555 if self.cy >= self.lines.len() {
556 self.cy = self.lines.len().saturating_sub(1);
557 }
558 if self.cx > self.lines[self.cy].len() {
559 self.cx = self.lines[self.cy].len();
560 }
561 }
562
563 fn scroll_to_cursor(&mut self) {
564 let screen_rows = self.term_rows;
565 if self.cy < self.scroll_y {
566 self.scroll_y = self.cy;
567 }
568 if self.cy >= self.scroll_y + screen_rows {
569 self.scroll_y = self.cy.saturating_sub(screen_rows.saturating_sub(1));
570 }
571 }
572
573 // ── Screen updates ──────────────────────────────────────────────────────
574
575 fn update_screen(
576 &mut self,
577 prev_cy: usize,
578 prev_scroll: usize,
579 prev_modified: bool,
580 prev_line_count: usize,
581 ) {
582 // Full redraw on scroll or line-count change
583 if self.scroll_y != prev_scroll || self.lines.len() != prev_line_count {
584 self.draw_full();
585 return;
586 }
587
588 let status_changed = self.modified != prev_modified || !self.status_msg.is_empty();
589
590 let cursor_moved_only = self.cy == prev_cy
591 && !self.modified
592 && self.modified == prev_modified
593 && self.status_msg.is_empty();
594
595 if cursor_moved_only {
596 self.draw_cursor();
597 return;
598 }
599
600 let mut out: Vec<u8> = Vec::new();
601 let content_changed = self.modified || self.cy != prev_cy;
602
603 if content_changed {
604 // Redraw previous line
605 self.draw_line_at(prev_cy.saturating_sub(self.scroll_y) + 1, prev_cy, &mut out);
606
607 // If cursor moved, redraw the new line too
608 if self.cy != prev_cy {
609 self.draw_line_at(self.cy.saturating_sub(self.scroll_y) + 1, self.cy, &mut out);
610
611 // Redraw any lines between prev_cy and self.cy
612 let (lo, hi) = if self.cy > prev_cy {
613 (prev_cy + 1, self.cy)
614 } else {
615 (self.cy + 1, prev_cy)
616 };
617 for i in lo..hi {
618 self.draw_line_at(i.saturating_sub(self.scroll_y) + 1, i, &mut out);
619 }
620 }
621
622 // When modified flag changed but cursor didn't move, redraw
623 // the current line anyway (text changed in place)
624 if self.cy == prev_cy && self.modified {
625 self.draw_line_at(self.cy.saturating_sub(self.scroll_y) + 1, self.cy, &mut out);
626 }
627 }
628
629 // Status bar
630 if status_changed || content_changed {
631 out.extend_from_slice(b"\x1b7"); // save cursor
632 let status_row = self.term_rows + 2;
633 out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes());
634 out.extend_from_slice(b"\x1b[K");
635 out.extend_from_slice(self.status_line().as_bytes());
636 out.extend_from_slice(b"\x1b8"); // restore cursor
637 }
638
639 self.draw_cursor_to(&mut out);
640 vt::write_bytes(&out);
641 }
642
643 fn draw_cursor(&self) {
644 let mut out: Vec<u8> = Vec::new();
645 self.draw_cursor_to(&mut out);
646 vt::write_bytes(&out);
647 }
648
649 fn draw_cursor_to(&self, out: &mut Vec<u8>) {
650 let screen_row = self.cy.saturating_sub(self.scroll_y) + 1;
651 let screen_col = self.gutter_width() + self.cx + 1;
652 out.extend_from_slice(format!("\x1b[{};{}H", screen_row, screen_col).as_bytes());
653 }
654
655 fn draw_full(&mut self) {
656 let mut out: Vec<u8> = Vec::new();
657 out.extend_from_slice(b"\x1b[?25l"); // hide cursor
658 out.extend_from_slice(b"\x1b[2J"); // clear entire screen
659
660 let screen_rows = self.term_rows;
661 for i in 0..screen_rows {
662 let line_idx = self.scroll_y + i;
663 self.draw_line_at(i + 1, line_idx, &mut out);
664 }
665
666 // Separator line
667 let sep_row = self.term_rows + 1;
668 out.extend_from_slice(format!("\x1b[{};1H", sep_row).as_bytes());
669 out.extend_from_slice(b"\x1b[K");
670
671 // Status bar
672 let status_row = self.term_rows + 2;
673 out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes());
674 out.extend_from_slice(b"\x1b[K");
675 out.extend_from_slice(self.status_line().as_bytes());
676
677 self.draw_cursor_to(&mut out);
678 out.extend_from_slice(b"\x1b[?25h"); // show cursor
679
680 vt::write_bytes(&out);
681 }
682
683 /// Build the status bar text, optionally clippiing to fit the terminal
684 /// width (minus a 1-char margin).
685 fn status_line(&self) -> String {
686 let display_name = if self.filename.is_empty() {
687 "[new file]"
688 } else {
689 &self.filename
690 };
691
692 let pos = format!("Ln {}, Col {}", self.cy + 1, self.cx + 1);
693
694 // Prepend syntax error if present
695 let tag = self.syntax_error.as_ref().map(|e| format!("[{}]", e));
696
697 let raw = if !self.status_msg.is_empty() {
698 self.status_msg.clone()
699 } else if let Some(ref t) = tag {
700 format!(
701 "{} {} {} ^S save ^X exit ^W search ^_ undo",
702 display_name, pos, t
703 )
704 } else if self.modified {
705 format!(
706 "{} [modified] {} ^S save ^X exit ^W search ^_ undo",
707 display_name, pos
708 )
709 } else {
710 format!(
711 "{} {} ^S save ^X exit ^W search ^_ undo",
712 display_name, pos
713 )
714 };
715
716 // Truncate if wider than terminal (leave 1-char pad)
717 let max_w = self.term_cols.saturating_sub(1);
718 if raw.len() > max_w {
719 raw[..max_w].to_string()
720 } else {
721 raw
722 }
723 }
724
725 // ── Save ────────────────────────────────────────────────────────────────
726
727 fn save(&mut self) {
728 if self.filename.is_empty() {
729 self.prompt_filename();
730 return;
731 }
732 self.do_save();
733 }
734
735 fn prompt_filename(&mut self) {
736 let status_row = self.term_rows + 2;
737 let mut name = String::new();
738
739 loop {
740 let mut out: Vec<u8> = Vec::new();
741 out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes());
742 out.extend_from_slice(b"\x1b[K");
743 out.extend_from_slice(b"Save as: ");
744 out.extend_from_slice(name.as_bytes());
745 // Show cursor during prompt
746 vt::write_bytes(&out);
747
748 let b = match self.read_byte() {
749 Some(b) => b,
750 None => return,
751 };
752
753 match b {
754 0x0a | 0x0d => {
755 if !name.is_empty() {
756 self.filename = name;
757 self.do_save();
758 }
759 break;
760 }
761 0x1b => break,
762 0x7f => {
763 if !name.is_empty() {
764 name.pop();
765 }
766 }
767 0x03 => break,
768 _ if (0x20..=0x7e).contains(&b) && name.len() < 256 => {
769 name.push(b as char);
770 }
771 _ => {}
772 }
773 }
774
775 self.draw_full();
776 }
777
778 fn do_save(&mut self) {
779 // VFS (ramfs) write: truncate then write the whole buffer.
780 match save_lines(&self.filename, &self.lines) {
781 Ok(()) => {
782 self.modified = false;
783 // Clear undo stack — saving is a good checkpoint
784 self.undo_stack.clear();
785 self.status_msg = format!("Saved {}", self.filename);
786 }
787 Err(()) => {
788 self.status_msg = "Error: cannot write file".to_string();
789 }
790 }
791 }
792
793 // ── Word navigation helpers ──────────────────────────────────────────────
794
795 /// Move cursor to the start of the previous word on the current line.
796 fn prev_word(&mut self) {
797 let line = &self.lines[self.cy];
798 let bytes = line.as_bytes();
799 let mut pos = self.cx.min(bytes.len());
800 if pos == 0 {
801 return;
802 }
803 // Step left past any non-word chars before cursor
804 pos -= 1;
805 while pos > 0 && !bytes[pos].is_ascii_alphanumeric() {
806 pos -= 1;
807 }
808 // Step left through the word we landed in
809 while pos > 0 && bytes[pos.saturating_sub(1)].is_ascii_alphanumeric() {
810 pos -= 1;
811 }
812 self.cx = pos;
813 }
814
815 /// Move cursor to the start of the next word on the current line.
816 fn next_word(&mut self) {
817 let line = &self.lines[self.cy];
818 let bytes = line.as_bytes();
819 let mut pos = self.cx;
820 let len = bytes.len();
821 // Skip through the current word
822 while pos < len && bytes[pos].is_ascii_alphanumeric() {
823 pos += 1;
824 }
825 // Skip non-word chars to the start of the next word
826 while pos < len && !bytes[pos].is_ascii_alphanumeric() {
827 pos += 1;
828 }
829 self.cx = pos;
830 }
831
832 // ── Syntax highlighting ────────────────────────────────────────────────
833
834 /// Return `line` with ANSI color codes: strings in green, comments in
835 /// gray, brackets in yellow. Respects `\` escapes inside strings.
836 fn highlight_line(&self, line: &str) -> Vec<u8> {
837 let mut out = Vec::new();
838 let bytes = line.as_bytes();
839 let mut i = 0;
840
841 while i < bytes.len() {
842 // Line comment // …
843 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
844 out.extend_from_slice(b"\x1b[90m");
845 out.extend_from_slice(&bytes[i..]);
846 out.extend_from_slice(b"\x1b[0m");
847 return out;
848 }
849
850 // String "…" or '…'
851 if bytes[i] == b'"' || bytes[i] == b'\'' {
852 let quote = bytes[i];
853 out.extend_from_slice(b"\x1b[32m");
854 out.push(quote);
855 i += 1;
856 while i < bytes.len() {
857 if bytes[i] == b'\\' {
858 out.push(bytes[i]);
859 i += 1;
860 if i < bytes.len() {
861 out.push(bytes[i]);
862 i += 1;
863 }
864 continue;
865 }
866 out.push(bytes[i]);
867 if bytes[i] == quote {
868 i += 1;
869 break;
870 }
871 i += 1;
872 }
873 out.extend_from_slice(b"\x1b[0m");
874 continue;
875 }
876
877 // Brackets () [] {}
878 if matches!(bytes[i], b'(' | b')' | b'[' | b']' | b'{' | b'}') {
879 out.extend_from_slice(b"\x1b[93m");
880 out.push(bytes[i]);
881 out.extend_from_slice(b"\x1b[0m");
882 i += 1;
883 continue;
884 }
885
886 // Regular character
887 out.push(bytes[i]);
888 i += 1;
889 }
890
891 out
892 }
893
894 /// Scan the entire buffer for syntax errors (unclosed strings,
895 /// mismatched brackets). Returns the first error message, or `None`
896 /// if the buffer is clean.
897 fn check_syntax(&self) -> Option<String> {
898 // Stack of opening brackets: (char, line_0idx, col)
899 let mut stack: Vec<(u8, usize, usize)> = Vec::new();
900 let mut in_string = false;
901 let mut string_char: u8 = 0;
902
903 for (li, line) in self.lines.iter().enumerate() {
904 let bytes = line.as_bytes();
905 let mut i = 0;
906
907 while i < bytes.len() {
908 if in_string {
909 if bytes[i] == b'\\' {
910 i += 1;
911 if i < bytes.len() {
912 i += 1;
913 }
914 continue;
915 }
916 if bytes[i] == string_char {
917 in_string = false;
918 }
919 i += 1;
920 continue;
921 }
922
923 // Line comment
924 if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' {
925 break;
926 }
927
928 match bytes[i] {
929 b'"' | b'\'' => {
930 in_string = true;
931 string_char = bytes[i];
932 }
933 b'(' | b'[' | b'{' => {
934 stack.push((bytes[i], li, i));
935 }
936 b')' => match stack.last() {
937 Some(&(b'(', _, _)) => {
938 stack.pop();
939 }
940 _ => {
941 return Some(format!("Line {}: unmatched ')'", li + 1));
942 }
943 },
944 b']' => match stack.last() {
945 Some(&(b'[', _, _)) => {
946 stack.pop();
947 }
948 _ => {
949 return Some(format!("Line {}: unmatched ']'", li + 1));
950 }
951 },
952 b'}' => match stack.last() {
953 Some(&(b'{', _, _)) => {
954 stack.pop();
955 }
956 _ => {
957 return Some(format!("Line {}: unmatched '}}'", li + 1));
958 }
959 },
960 _ => {}
961 }
962 i += 1;
963 }
964 }
965
966 if in_string {
967 return Some(format!(
968 "Unclosed string (started with '{}')",
969 string_char as char
970 ));
971 }
972
973 if let Some((ch, li, _)) = stack.last() {
974 return Some(format!("Line {}: unclosed '{}'", li + 1, *ch as char));
975 }
976
977 None
978 }
979
980 // ── Escape sequence handling ────────────────────────────────────────────
981
982 fn handle_escape(&mut self) {
983 let b = match self.read_byte() {
984 Some(b) => b,
985 None => return,
986 };
987 if b != 0x5b && b != b'O' {
988 // Not a CSI / SS3 prefix. Push the byte back so the main loop
989 // processes it normally.
990 if self.read_pos > 0 {
991 self.read_pos -= 1;
992 }
993 return;
994 }
995 let cmd = match self.read_byte() {
996 Some(b) => b,
997 None => return,
998 };
999
1000 // For numeric parameters, read the next byte to determine sequence
1001 // type (terminator ~, modifier ;, or rxvt-style arrow letter).
1002 let next = if self.read_pos < self.read_buf.len() {
1003 Some(self.read_buf[self.read_pos])
1004 } else {
1005 None
1006 };
1007
1008 match cmd {
1009 // Arrow keys — only when NOT followed by modifier
1010 0x41 if next.is_none_or(|n| n != b';') => {
1011 if self.cy > 0 {
1012 self.cy -= 1;
1013 }
1014 self.clamp_cursor();
1015 }
1016 0x42 if next.is_none_or(|n| n != b';') => {
1017 if self.cy + 1 < self.lines.len() {
1018 self.cy += 1;
1019 }
1020 self.clamp_cursor();
1021 }
1022 0x43 if next.is_none_or(|n| n != b';') => {
1023 if self.cx < self.lines[self.cy].len() {
1024 self.cx += 1;
1025 }
1026 }
1027 0x44 if next.is_none_or(|n| n != b';') => {
1028 if self.cx > 0 {
1029 self.cx -= 1;
1030 }
1031 }
1032
1033 // Home (ESC [ H / ESC O H) and End (ESC [ F / ESC O F)
1034 0x48 => self.cx = 0,
1035 0x46 => self.cx = self.lines[self.cy].len(),
1036
1037 // ── Numeric CSI sequences ─────────────────────────────────---
1038
1039 // ESC [ 1~ / ESC [ 7~ → Home
1040 // ESC [ 4~ → End
1041 // ESC [ 1 ; 5 X → xterm-style Ctrl-modifier
1042 0x31 | 0x37 | 0x34 => {
1043 let term = match self.read_byte() {
1044 Some(b) => b,
1045 None => return,
1046 };
1047 match (cmd, term) {
1048 (0x31 | 0x37, 0x7e) => self.cx = 0,
1049 (0x34, 0x7e) => self.cx = self.lines[self.cy].len(),
1050 // ESC [ 1 ; 5 X — consume modifier + final command byte
1051 (_, 0x3b) => {
1052 let mod_b = match self.read_byte() {
1053 Some(b) => b,
1054 None => return,
1055 };
1056 let fin = match self.read_byte() {
1057 Some(b) => b,
1058 None => return,
1059 };
1060 if mod_b == b'5' {
1061 match fin {
1062 0x44 => self.prev_word(),
1063 0x43 => self.next_word(),
1064 0x41 => {
1065 let jump = self.term_rows.min(self.cy);
1066 self.cy = self.cy.saturating_sub(jump);
1067 self.clamp_cursor();
1068 }
1069 0x42 => {
1070 self.cy = (self.cy + self.term_rows)
1071 .min(self.lines.len().saturating_sub(1));
1072 self.clamp_cursor();
1073 }
1074 _ => {}
1075 }
1076 }
1077 }
1078 _ => {}
1079 }
1080 }
1081
1082 // ESC [ 5~ → Page Up
1083 // ESC [ 5A → rxvt Ctrl+Up
1084 // ESC [ 5B → rxvt Ctrl+Down
1085 // ESC [ 5C → rxvt Ctrl+Right
1086 // ESC [ 5D → rxvt Ctrl+Left
1087 0x35 => {
1088 let term = match self.read_byte() {
1089 Some(b) => b,
1090 None => return,
1091 };
1092 match term {
1093 0x7e => {
1094 // Page Up
1095 let jump = self.term_rows.min(self.cy);
1096 self.cy = self.cy.saturating_sub(jump);
1097 self.clamp_cursor();
1098 }
1099 // rxvt Ctrl+arrow: ESC [ 5 A-D
1100 0x41 => {
1101 let jump = self.term_rows.min(self.cy);
1102 self.cy = self.cy.saturating_sub(jump);
1103 self.clamp_cursor();
1104 }
1105 0x42 => {
1106 self.cy =
1107 (self.cy + self.term_rows).min(self.lines.len().saturating_sub(1));
1108 self.clamp_cursor();
1109 }
1110 0x43 => self.next_word(),
1111 0x44 => self.prev_word(),
1112 _ => {}
1113 }
1114 }
1115
1116 // ESC [ 6~ → Page Down
1117 // ESC [ 6A → rxvt Shift+Up (ignore)
1118 // etc.
1119 0x36 => {
1120 let term = match self.read_byte() {
1121 Some(b) => b,
1122 None => return,
1123 };
1124 if term == 0x7e {
1125 // Page Down
1126 self.cy = (self.cy + self.term_rows).min(self.lines.len().saturating_sub(1));
1127 self.clamp_cursor();
1128 }
1129 }
1130
1131 // Delete (ESC [ P / ESC [ 3~)
1132 0x50 => {
1133 self.push_undo();
1134 if self.cx < self.lines[self.cy].len() {
1135 self.lines[self.cy].remove(self.cx);
1136 self.modified = true;
1137 } else if self.cy + 1 < self.lines.len() {
1138 let next = self.lines.remove(self.cy + 1);
1139 self.lines[self.cy].push_str(&next);
1140 self.modified = true;
1141 }
1142 }
1143 0x33 => {
1144 let term = match self.read_byte() {
1145 Some(b) => b,
1146 None => return,
1147 };
1148 if term == 0x7e {
1149 self.push_undo();
1150 if self.cx < self.lines[self.cy].len() {
1151 self.lines[self.cy].remove(self.cx);
1152 self.modified = true;
1153 } else if self.cy + 1 < self.lines.len() {
1154 let next = self.lines.remove(self.cy + 1);
1155 self.lines[self.cy].push_str(&next);
1156 self.modified = true;
1157 }
1158 }
1159 }
1160
1161 _ => {}
1162 }
1163 }
1164
1165 // ── Input buffering ─────────────────────────────────────────────────────
1166
1167 fn fill_buf(&mut self) {
1168 if self.read_pos < self.read_buf.len() {
1169 return;
1170 }
1171 self.read_buf.clear();
1172 self.read_pos = 0;
1173 // First byte blocks until a keypress; then drain whatever else is
1174 // already queued (the tail of a multi-byte key sequence) so escape
1175 // sequences stay together in one buffer.
1176 self.read_buf.push(tty::read_byte());
1177 while self.read_buf.len() < 64 {
1178 match tty::try_read_byte() {
1179 Some(b) => self.read_buf.push(b),
1180 None => break,
1181 }
1182 }
1183 }
1184
1185 fn read_byte(&mut self) -> Option<u8> {
1186 self.fill_buf();
1187 if self.read_pos < self.read_buf.len() {
1188 let b = self.read_buf[self.read_pos];
1189 self.read_pos += 1;
1190 return Some(b);
1191 }
1192 None
1193 }
1194}
1195
1196// ── File I/O (ramfs via the VFS) ───────────────────────────────────────────
1197
1198/// Read a whole file from the VFS into lines. None if the file does not
1199/// exist or is not valid UTF-8.
1200fn load_lines(path: &str) -> Option<Vec<String>> {
1201 let h = rsnix::fs::resolve(path)?;
1202 let mut content: Vec<u8> = Vec::new();
1203 let mut buf = [0u8; 512];
1204 let mut pos = 0u64;
1205 loop {
1206 let n = rsnix::fs::read(h, &mut buf, pos).ok()?;
1207 if n == 0 {
1208 break;
1209 }
1210 content.extend_from_slice(&buf[..n]);
1211 pos += n as u64;
1212 if n < buf.len() {
1213 break;
1214 }
1215 }
1216 let s = core::str::from_utf8(&content).ok()?;
1217 let mut v: Vec<String> = s.lines().map(String::from).collect();
1218 if s.ends_with('\n') || v.is_empty() {
1219 v.push(String::new());
1220 }
1221 Some(v)
1222}
1223
1224/// Write lines (joined with '\n') back to a VFS file, truncating first.
1225fn save_lines(path: &str, lines: &[String]) -> Result<(), ()> {
1226 let h = rsnix::fs::resolve(path).ok_or(())?;
1227 rsnix::fs::set_len(h, 0)?;
1228 let mut buf = [0u8; 512];
1229 let mut pos = 0u64;
1230 for line in lines {
1231 let bytes = line.as_bytes();
1232 let mut off = 0;
1233 while off < bytes.len() {
1234 let n = core::cmp::min(512, bytes.len() - off);
1235 buf[..n].copy_from_slice(&bytes[off..off + n]);
1236 let w = rsnix::fs::write(h, &buf[..n], pos)?;
1237 if w == 0 {
1238 return Err(());
1239 }
1240 pos += w as u64;
1241 off += w as usize;
1242 }
1243 buf[0] = b'\n';
1244 let w = rsnix::fs::write(h, &buf[..1], pos)?;
1245 if w == 0 {
1246 return Err(());
1247 }
1248 pos += 1;
1249 }
1250 Ok(())
1251}
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}
diff --git a/shell/Cargo.toml b/shell/Cargo.toml
index 9fd10bc..89152e7 100644
--- a/shell/Cargo.toml
+++ b/shell/Cargo.toml
@@ -5,3 +5,4 @@ edition.workspace = true
5 5
6[dependencies] 6[dependencies]
7rsnix = { path = "../rsnix" } 7rsnix = { path = "../rsnix" }
8rsdosedit = { path = "../rsdosedit" }
diff --git a/shell/src/lib.rs b/shell/src/lib.rs
index 299956d..ec2474d 100644
--- a/shell/src/lib.rs
+++ b/shell/src/lib.rs
@@ -5,12 +5,9 @@ extern crate alloc;
5use alloc::string::ToString; 5use alloc::string::ToString;
6 6
7/// Minimal command shell — reads from PS/2 keyboard, writes to GOP + serial. 7/// Minimal command shell — reads from PS/2 keyboard, writes to GOP + serial.
8use rsnix::ps2::{self, KeyEvent}; 8use rsnix::tty::{self, LineInput};
9use rsnix::{gop_print, gop_println, serial_print, serial_println}; 9use rsnix::{gop_print, gop_println, serial_print, serial_println};
10 10
11/// Maximum input line length.
12const LINE_BUF_SIZE: usize = 256;
13
14/// Run the shell forever. 11/// Run the shell forever.
15pub fn run() -> ! { 12pub fn run() -> ! {
16 gop_println!( 13 gop_println!(
@@ -27,8 +24,8 @@ pub fn run() -> ! {
27 gop_print!("> "); 24 gop_print!("> ");
28 serial_print!("> "); 25 serial_print!("> ");
29 26
30 // Read one line from keyboard 27 // Read one line from keyboard (tty canonical mode)
31 match read_line() { 28 match tty::read_line() {
32 LineInput::Interrupted => continue, // ^C — fresh prompt 29 LineInput::Interrupted => continue, // ^C — fresh prompt
33 LineInput::Eof => { 30 LineInput::Eof => {
34 // ^D on an empty line: EOF, exit the shell. 31 // ^D on an empty line: EOF, exit the shell.
@@ -65,6 +62,7 @@ pub fn run() -> ! {
65 "mkdir" => cmd_mkdir(args), 62 "mkdir" => cmd_mkdir(args),
66 "touch" => cmd_touch(args), 63 "touch" => cmd_touch(args),
67 "rm" => cmd_rm(args), 64 "rm" => cmd_rm(args),
65 "edit" => cmd_edit(args),
68 _ => { 66 _ => {
69 gop_println!("unknown command: {}", cmd); 67 gop_println!("unknown command: {}", cmd);
70 serial_println!("unknown command: {}", cmd); 68 serial_println!("unknown command: {}", cmd);
@@ -75,74 +73,9 @@ pub fn run() -> ! {
75 } 73 }
76} 74}
77 75
78/// Result of one line-input session.
79enum LineInput {
80 Line(heapless_str::String<LINE_BUF_SIZE>),
81 Interrupted, // ^C
82 Eof, // ^D on an empty line
83}
84
85// --------------------------------------------------------------------------- 76// ---------------------------------------------------------------------------
86// Line input (PS/2 keyboard, no heap) 77// Line input is handled by the tty line discipline (rsnix::tty).
87// --------------------------------------------------------------------------- 78// ---------------------------------------------------------------------------
88fn read_line() -> LineInput {
89 let mut buf = heapless_str::String::<LINE_BUF_SIZE>::new();
90 loop {
91 match ps2::poll_key() {
92 KeyEvent::Enter => {
93 rsnix::gop::put_char('\n');
94 rsnix::serial::write_str("\r\n");
95 return LineInput::Line(buf);
96 }
97 KeyEvent::CtrlC => {
98 // Cancel the current line — like a real terminal, echo ^C.
99 buf.clear();
100 rsnix::gop::put_char('^');
101 rsnix::gop::put_char('C');
102 rsnix::gop::put_char('\n');
103 rsnix::serial::write_str("^C\r\n");
104 return LineInput::Interrupted;
105 }
106 KeyEvent::CtrlD => {
107 if buf.is_empty() {
108 // EOF on an empty line — exit the shell.
109 rsnix::gop::put_char('^');
110 rsnix::gop::put_char('D');
111 rsnix::gop::put_char('\n');
112 rsnix::serial::write_str("^D\r\n");
113 return LineInput::Eof;
114 }
115 // ^D with text on the line: ignored (bash semantics).
116 }
117 KeyEvent::Char(c) if c.is_ascii_graphic() || c == ' ' => {
118 if buf.push(c).is_ok() {
119 rsnix::gop::put_char(c);
120 // Echo to serial too
121 let mut s = [0u8; 4];
122 let s = c.encode_utf8(&mut s);
123 rsnix::serial::write_str(s);
124 }
125 }
126 KeyEvent::Backspace => {
127 if buf.pop().is_some() {
128 // GOP backspace: move cursor back and erase
129 rsnix::gop::put_char('\x08');
130 // Serial backspace: send BS SP BS (standard terminal protocol)
131 rsnix::serial::write_str("\x08 \x08");
132 }
133 }
134 KeyEvent::Tab => {
135 for _ in 0..4 {
136 if buf.push(' ').is_ok() {
137 rsnix::gop::put_char(' ');
138 rsnix::serial::write_str(" ");
139 }
140 }
141 }
142 _ => {}
143 }
144 }
145}
146 79
147/// Split a command line into the first word and the rest. 80/// Split a command line into the first word and the rest.
148fn split_first_word(s: &str) -> (&str, &str) { 81fn split_first_word(s: &str) -> (&str, &str) {
@@ -176,13 +109,14 @@ fn cmd_help() {
176 gop_println!(" mkdir — create a directory"); 109 gop_println!(" mkdir — create a directory");
177 gop_println!(" touch — create an empty file"); 110 gop_println!(" touch — create an empty file");
178 gop_println!(" rm — remove a file or empty dir"); 111 gop_println!(" rm — remove a file or empty dir");
112 gop_println!(" edit — full-screen editor (^S save, ^X exit, ^W search)");
179 gop_println!(" echo > /dev/null, > file, >> file"); 113 gop_println!(" echo > /dev/null, > file, >> file");
180 gop_println!(" halt — halt the system (hlt loop)"); 114 gop_println!(" halt — halt the system (hlt loop)");
181 gop_println!(" poweroff — shut down via ACPI (ResetSystem)"); 115 gop_println!(" poweroff — shut down via ACPI (ResetSystem)");
182 gop_println!(" exit — alias for poweroff"); 116 gop_println!(" exit — alias for poweroff");
183 serial_println!("available commands:"); 117 serial_println!("available commands:");
184 serial_println!( 118 serial_println!(
185 " help, echo, clear, hello, info, mem, mmap, stress, date, pci, ls, cat, hexdump, mkdir, touch, rm, halt, poweroff, exit" 119 " help, echo, clear, hello, info, mem, mmap, stress, date, pci, ls, cat, hexdump, mkdir, touch, rm, edit, halt, poweroff, exit"
186 ); 120 );
187} 121}
188 122
@@ -267,7 +201,7 @@ fn cmd_cat(args: &str) {
267 Some(h) => { 201 Some(h) => {
268 let mut pos = 0u64; 202 let mut pos = 0u64;
269 loop { 203 loop {
270 if interrupt_pressed() { 204 if rsnix::tty::interrupt_pressed() {
271 gop_println!("^C"); 205 gop_println!("^C");
272 serial_println!("^C"); 206 serial_println!("^C");
273 return; 207 return;
@@ -305,7 +239,7 @@ fn cmd_hexdump(args: &str) {
305 let mut buf = [0u8; 512]; 239 let mut buf = [0u8; 512];
306 let mut pos = 0u64; 240 let mut pos = 0u64;
307 loop { 241 loop {
308 if interrupt_pressed() { 242 if rsnix::tty::interrupt_pressed() {
309 gop_println!("^C"); 243 gop_println!("^C");
310 serial_println!("^C"); 244 serial_println!("^C");
311 return; 245 return;
@@ -345,16 +279,26 @@ fn cmd_hexdump(args: &str) {
345 } 279 }
346} 280}
347 281
348/// Returns true if the user pressed ^C since the last check; drains the 282
349/// keyboard buffer of any other buffered keys. 283// ---------------------------------------------------------------------------
350fn interrupt_pressed() -> bool { 284// edit — run the built-in editor (rsdosedit) on a file
351 let mut pressed = false; 285// ---------------------------------------------------------------------------
352 while let Some(ev) = ps2::try_poll_key() { 286
353 if matches!(ev, KeyEvent::CtrlC) { 287fn cmd_edit(args: &str) {
354 pressed = true; 288 let path = args.trim();
355 } 289 if path.is_empty() {
290 gop_println!("usage: edit <path>");
291 serial_println!("usage: edit <path>");
292 return;
293 }
294 if rsnix::fs::ensure_file(path).is_err() {
295 gop_println!("edit: cannot create {}", path);
296 serial_println!("edit: cannot create {}", path);
297 return;
356 } 298 }
357 pressed 299 rsnix::gop::clear_screen();
300 rsdosedit::run(path);
301 rsnix::gop::clear_screen();
358} 302}
359 303
360fn cmd_mkdir(args: &str) { 304fn cmd_mkdir(args: &str) {
@@ -682,71 +626,3 @@ fn cmd_poweroff() -> ! {
682 serial_println!("shutting down..."); 626 serial_println!("shutting down...");
683 rsnix::power::poweroff() 627 rsnix::power::poweroff()
684} 628}
685
686// ---------------------------------------------------------------------------
687// Minimal fixed-capacity string for no-stack use (avoids alloc dependency)
688// ---------------------------------------------------------------------------
689mod heapless_str {
690 use core::fmt;
691
692 /// A fixed-capacity string allocated entirely on the stack.
693 pub struct String<const N: usize> {
694 buf: [u8; N],
695 len: usize,
696 }
697
698 impl<const N: usize> String<N> {
699 pub const fn new() -> Self {
700 String {
701 buf: [0u8; N],
702 len: 0,
703 }
704 }
705
706 pub fn as_str(&self) -> &str {
707 // SAFETY: we only write valid ASCII bytes.
708 unsafe { core::str::from_utf8_unchecked(&self.buf[..self.len]) }
709 }
710
711 pub fn push(&mut self, c: char) -> Result<(), ()> {
712 if self.len >= N {
713 return Err(());
714 }
715 self.buf[self.len] = c as u8;
716 self.len += 1;
717 Ok(())
718 }
719
720 pub fn pop(&mut self) -> Option<char> {
721 if self.len == 0 {
722 return None;
723 }
724 self.len -= 1;
725 Some(self.buf[self.len] as char)
726 }
727
728 pub fn is_empty(&self) -> bool {
729 self.len == 0
730 }
731
732 pub fn clear(&mut self) {
733 self.len = 0;
734 }
735
736 pub fn trim(&self) -> &str {
737 self.as_str().trim()
738 }
739 }
740
741 impl<const N: usize> fmt::Display for String<N> {
742 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
743 write!(f, "{}", self.as_str())
744 }
745 }
746
747 impl<const N: usize> fmt::Debug for String<N> {
748 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
749 write!(f, "{:?}", self.as_str())
750 }
751 }
752}