diff options
Diffstat (limited to '')
| -rw-r--r-- | CONTEXT.md | 38 | ||||
| -rw-r--r-- | Cargo.lock | 8 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | rsdos/src/main.rs | 8 | ||||
| -rw-r--r-- | rsdosedit/Cargo.toml | 7 | ||||
| -rw-r--r-- | rsdosedit/src/lib.rs | 1251 | ||||
| -rw-r--r-- | rsnix/src/gop.rs | 152 | ||||
| -rw-r--r-- | rsnix/src/lib.rs | 3 | ||||
| -rw-r--r-- | rsnix/src/ps2.rs | 31 | ||||
| -rw-r--r-- | rsnix/src/strbuf.rs | 71 | ||||
| -rw-r--r-- | rsnix/src/tty.rs | 239 | ||||
| -rw-r--r-- | rsnix/src/vt.rs | 476 | ||||
| -rw-r--r-- | shell/Cargo.toml | 1 | ||||
| -rw-r--r-- | shell/src/lib.rs | 180 |
14 files changed, 2209 insertions, 258 deletions
| @@ -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 | ||
| @@ -11,6 +11,13 @@ dependencies = [ | |||
| 11 | ] | 11 | ] |
| 12 | 12 | ||
| 13 | [[package]] | 13 | [[package]] |
| 14 | name = "rsdosedit" | ||
| 15 | version = "0.1.0" | ||
| 16 | dependencies = [ | ||
| 17 | "rsnix", | ||
| 18 | ] | ||
| 19 | |||
| 20 | [[package]] | ||
| 14 | name = "rsnix" | 21 | name = "rsnix" |
| 15 | version = "0.2.0" | 22 | version = "0.2.0" |
| 16 | 23 | ||
| @@ -18,5 +25,6 @@ version = "0.2.0" | |||
| 18 | name = "shell" | 25 | name = "shell" |
| 19 | version = "0.2.0" | 26 | version = "0.2.0" |
| 20 | dependencies = [ | 27 | dependencies = [ |
| 28 | "rsdosedit", | ||
| 21 | "rsnix", | 29 | "rsnix", |
| 22 | ] | 30 | ] |
| @@ -1,6 +1,6 @@ | |||
| 1 | [workspace] | 1 | [workspace] |
| 2 | resolver = "2" | 2 | resolver = "2" |
| 3 | members = ["rsnix", "shell", "rsdos"] | 3 | members = ["rsnix", "shell", "rsdos", "rsdosedit"] |
| 4 | 4 | ||
| 5 | [workspace.package] | 5 | [workspace.package] |
| 6 | version = "0.2.0" | 6 | version = "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] | ||
| 2 | name = "rsdosedit" | ||
| 3 | version = "0.1.0" | ||
| 4 | edition = "2021" | ||
| 5 | |||
| 6 | [dependencies] | ||
| 7 | rsnix = { 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 | |||
| 3 | extern crate alloc; | ||
| 4 | |||
| 5 | use alloc::format; | ||
| 6 | use alloc::string::String; | ||
| 7 | use alloc::string::ToString; | ||
| 8 | use alloc::vec; | ||
| 9 | use alloc::vec::Vec; | ||
| 10 | |||
| 11 | use rsnix::{tty, vt}; | ||
| 12 | |||
| 13 | // ── Undo ──────────────────────────────────────────────────────────────────── | ||
| 14 | |||
| 15 | struct 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. | ||
| 26 | pub fn run(filename: &str) { | ||
| 27 | let mut editor = Editor::new(String::from(filename)); | ||
| 28 | editor.run(); | ||
| 29 | } | ||
| 30 | |||
| 31 | // ── Editor ────────────────────────────────────────────────────────────────── | ||
| 32 | |||
| 33 | struct 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 | |||
| 56 | impl 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. | ||
| 1200 | fn 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. | ||
| 1225 | fn 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. | ||
| 2 | use crate::font; | 6 | use crate::font; |
| 3 | use crate::uefi::{GraphicsOutputProtocol, PIXEL_BGR, PIXEL_RGB}; | 7 | use crate::uefi::{GraphicsOutputProtocol, PIXEL_BGR, PIXEL_RGB}; |
| 4 | use core::fmt; | 8 | use 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 | ||
| 23 | static mut FB: Option<Framebuffer> = None; | 23 | static 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 | // --------------------------------------------------------------------------- |
| 67 | pub 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 | 67 | pub 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 { | 73 | pub 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 { | 78 | pub 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. |
| 121 | pub fn write_str(s: &str) { | 83 | pub 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. |
| 128 | pub fn clear_screen() { | 93 | pub 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. |
| 138 | pub(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. | ||
| 166 | pub(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; | |||
| 13 | pub mod ps2; | 13 | pub mod ps2; |
| 14 | pub mod rtc; | 14 | pub mod rtc; |
| 15 | pub mod serial; | 15 | pub mod serial; |
| 16 | pub mod strbuf; | ||
| 17 | pub mod tty; | ||
| 16 | pub mod uefi; | 18 | pub mod uefi; |
| 19 | pub mod vt; | ||
diff --git a/rsnix/src/ps2.rs b/rsnix/src/ps2.rs index 1b10b3b..8dd58a0 100644 --- a/rsnix/src/ps2.rs +++ b/rsnix/src/ps2.rs | |||
| @@ -22,8 +22,10 @@ pub enum KeyEvent { | |||
| 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? | ||
| 379 | fn 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 | |||
| 4 | use core::fmt; | ||
| 5 | |||
| 6 | /// A fixed-capacity string allocated entirely on the stack. | ||
| 7 | pub struct String<const N: usize> { | ||
| 8 | buf: [u8; N], | ||
| 9 | len: usize, | ||
| 10 | } | ||
| 11 | |||
| 12 | impl<const N: usize> Default for String<N> { | ||
| 13 | fn default() -> Self { | ||
| 14 | Self::new() | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | impl<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 | |||
| 61 | impl<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 | |||
| 67 | impl<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 | |||
| 16 | use crate::ps2::{self, KeyEvent}; | ||
| 17 | use crate::strbuf; | ||
| 18 | use crate::{gop, serial}; | ||
| 19 | |||
| 20 | pub const LINE_BUF_SIZE: usize = 256; | ||
| 21 | |||
| 22 | /// Result of one canonical line-input session. | ||
| 23 | pub 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. | ||
| 30 | static mut RAW_QUEUE: [u8; 8] = [0; 8]; | ||
| 31 | static 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. | ||
| 39 | pub 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. | ||
| 98 | pub 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. | ||
| 114 | pub 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. | ||
| 125 | pub 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 | |||
| 138 | fn 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 | |||
| 152 | fn 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 | |||
| 19 | use crate::gop; | ||
| 20 | use alloc::boxed::Box; | ||
| 21 | use alloc::vec; | ||
| 22 | use alloc::vec::Vec; | ||
| 23 | |||
| 24 | /// Standard VGA 16-colour palette (r, g, b). | ||
| 25 | pub 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)] | ||
| 45 | struct Cell { | ||
| 46 | ch: u16, | ||
| 47 | fg: u8, | ||
| 48 | bg: u8, | ||
| 49 | } | ||
| 50 | |||
| 51 | const BLANK: Cell = Cell { ch: 32, fg: 7, bg: 0 }; | ||
| 52 | |||
| 53 | // ---- screen state ---------------------------------------------------------- | ||
| 54 | |||
| 55 | static mut CELLS: *mut Vec<Cell> = core::ptr::null_mut(); | ||
| 56 | /// What is currently on the physical framebuffer (used to diff blits). | ||
| 57 | static mut RENDERED: *mut Vec<Cell> = core::ptr::null_mut(); | ||
| 58 | static mut ROWS: usize = 0; | ||
| 59 | static mut COLS: usize = 0; | ||
| 60 | |||
| 61 | // ---- cursor ---------------------------------------------------------------- | ||
| 62 | |||
| 63 | static mut CURSOR_R: usize = 0; | ||
| 64 | static mut CURSOR_C: usize = 0; | ||
| 65 | static mut LAST_CURSOR: (usize, usize) = (0, 0); | ||
| 66 | static mut CURSOR_VISIBLE: bool = true; | ||
| 67 | |||
| 68 | // ---- SGR state ------------------------------------------------------------- | ||
| 69 | |||
| 70 | static mut SGR_FG: u8 = 7; | ||
| 71 | static mut SGR_BG: u8 = 0; | ||
| 72 | |||
| 73 | // ---- ANSI parser state ----------------------------------------------------- | ||
| 74 | |||
| 75 | #[derive(Clone, Copy, PartialEq, Eq)] | ||
| 76 | enum Parser { | ||
| 77 | Ground, | ||
| 78 | Escape, | ||
| 79 | Csi, | ||
| 80 | } | ||
| 81 | |||
| 82 | static mut PARSER: Parser = Parser::Ground; | ||
| 83 | static mut PRIVATE: bool = false; | ||
| 84 | static mut PARAMS: [usize; 4] = [0; 4]; | ||
| 85 | static mut NPARAMS: usize = 0; | ||
| 86 | static mut CUR: usize = 0; | ||
| 87 | |||
| 88 | // --------------------------------------------------------------------------- | ||
| 89 | // init / dimensions | ||
| 90 | // --------------------------------------------------------------------------- | ||
| 91 | |||
| 92 | /// Allocate the cell grid. Call once after `gop::init()`. | ||
| 93 | pub 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. | ||
| 113 | pub fn size() -> (usize, usize) { | ||
| 114 | unsafe { (ROWS, COLS) } | ||
| 115 | } | ||
| 116 | |||
| 117 | pub fn set_cursor_visible(v: bool) { | ||
| 118 | unsafe { CURSOR_VISIBLE = v; } | ||
| 119 | flush(); | ||
| 120 | } | ||
| 121 | |||
| 122 | // --------------------------------------------------------------------------- | ||
| 123 | // cell access | ||
| 124 | // --------------------------------------------------------------------------- | ||
| 125 | |||
| 126 | fn idx(r: usize, c: usize) -> usize { | ||
| 127 | r * unsafe { COLS } + c | ||
| 128 | } | ||
| 129 | |||
| 130 | fn cells() -> &'static mut Vec<Cell> { | ||
| 131 | unsafe { &mut *CELLS } | ||
| 132 | } | ||
| 133 | |||
| 134 | fn rendered_cells() -> &'static mut Vec<Cell> { | ||
| 135 | unsafe { &mut *RENDERED } | ||
| 136 | } | ||
| 137 | |||
| 138 | fn cell(r: usize, c: usize) -> &'static mut Cell { | ||
| 139 | &mut cells()[idx(r, c)] | ||
| 140 | } | ||
| 141 | |||
| 142 | fn rendered(r: usize, c: usize) -> &'static mut Cell { | ||
| 143 | &mut rendered_cells()[idx(r, c)] | ||
| 144 | } | ||
| 145 | |||
| 146 | fn 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. | ||
| 158 | fn 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 | |||
| 227 | fn param(i: usize, default: usize) -> usize { | ||
| 228 | unsafe { | ||
| 229 | if i < NPARAMS { | ||
| 230 | PARAMS[i] | ||
| 231 | } else { | ||
| 232 | default | ||
| 233 | } | ||
| 234 | } | ||
| 235 | } | ||
| 236 | |||
| 237 | fn 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. | ||
| 308 | const ANSI_TO_VGA: [u8; 8] = [0, 4, 2, 6, 1, 5, 3, 7]; | ||
| 309 | |||
| 310 | fn 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 | |||
| 337 | fn 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 | |||
| 345 | fn 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 | |||
| 353 | fn erase_row(r: usize) { | ||
| 354 | for col in 0..unsafe { COLS } { | ||
| 355 | *cell(r, col) = BLANK; | ||
| 356 | } | ||
| 357 | } | ||
| 358 | |||
| 359 | fn 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. | ||
| 366 | fn 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`. | ||
| 379 | fn 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. | ||
| 396 | pub 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 '?'). | ||
| 404 | pub 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. | ||
| 413 | pub fn put_char(b: u8) { | ||
| 414 | put_byte(b); | ||
| 415 | flush(); | ||
| 416 | } | ||
| 417 | |||
| 418 | /// Clear the whole screen, reset colours, home the cursor. | ||
| 419 | pub 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. | ||
| 436 | fn 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] |
| 7 | rsnix = { path = "../rsnix" } | 7 | rsnix = { path = "../rsnix" } |
| 8 | rsdosedit = { 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; | |||
| 5 | use alloc::string::ToString; | 5 | use 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. |
| 8 | use rsnix::ps2::{self, KeyEvent}; | 8 | use rsnix::tty::{self, LineInput}; |
| 9 | use rsnix::{gop_print, gop_println, serial_print, serial_println}; | 9 | use rsnix::{gop_print, gop_println, serial_print, serial_println}; |
| 10 | 10 | ||
| 11 | /// Maximum input line length. | ||
| 12 | const LINE_BUF_SIZE: usize = 256; | ||
| 13 | |||
| 14 | /// Run the shell forever. | 11 | /// Run the shell forever. |
| 15 | pub fn run() -> ! { | 12 | pub 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. | ||
| 79 | enum 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 | // --------------------------------------------------------------------------- |
| 88 | fn 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. |
| 148 | fn split_first_word(s: &str) -> (&str, &str) { | 81 | fn 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 | // --------------------------------------------------------------------------- |
| 350 | fn 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) { | 287 | fn 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 | ||
| 360 | fn cmd_mkdir(args: &str) { | 304 | fn 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 | // --------------------------------------------------------------------------- | ||
| 689 | mod 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 | } | ||
