From fa06681a8418b7e31af96e010bb424e178402f4d Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 31 Jul 2026 17:19:09 +0200 Subject: tty + vt terminal split, rsdosedit editor (1:1 UNIX console) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kernel now has the classic UNIX console split, so full-screen ANSI apps can run in ring 0: - vt.rs: terminal emulator owning the text screen — cell grid (char + fg/bg), logical cursor (inverse-video), scrolling, and a small ANSI CSI parser (cursor position H/f, erase J/K, moves A-D, SGR 16-colour with correct ANSI→VGA mapping, cursor show/hide ?25). Blits only changed cells; scroll shifts cell grids + pixels and re-rasterises only the exposed bottom row. gop_println!/clear_screen now render through the vt; gop.rs is the raw rasteriser (colored glyph blits, pixel shift). - tty.rs: line discipline — canonical read_line (echo, line buffer, ^C cancel, ^D EOF) moved out of the shell, and raw read_byte that translates KeyEvents to the terminal byte protocol (arrows → ESC[A-D, Backspace → 0x7f, ^X → 0x18, Ctrl+arrows → ESC[1;5A-D, Home/End/PgUp/ PgDn/Delete). interrupt_pressed() stays for streaming commands. - ps2: generic Ctrl(char) + CtrlArrow events (was: only CtrlC/CtrlD). - rsdosedit: no_std port of the RNG rsedit editor as the first real app — undo, syntax highlighting (verified green/gray/yellow on the framebuffer), search, word nav, smart indent, ^S save / ^X exit / ^X again to discard. termios → tty raw, stdout → vt, std::fs → VFS (ramfs), SIGWINCH → fixed geometry, force-exit window → key counter. - shell: 'edit ' command; read_line/strbuf moved to rsnix. - CONTEXT.md: layout + milestones + known-issue perf notes. Verified in QEMU: full edit → type → arrow-move → insert → save → exit round trip with byte-exact file content; ANSI colors via screendump. --- CONTEXT.md | 38 +- Cargo.lock | 8 + Cargo.toml | 2 +- rsdos/src/main.rs | 8 + rsdosedit/Cargo.toml | 7 + rsdosedit/src/lib.rs | 1251 ++++++++++++++++++++++++++++++++++++++++++++++++++ rsnix/src/gop.rs | 152 +++--- rsnix/src/lib.rs | 3 + rsnix/src/ps2.rs | 31 +- rsnix/src/strbuf.rs | 71 +++ rsnix/src/tty.rs | 239 ++++++++++ rsnix/src/vt.rs | 476 +++++++++++++++++++ shell/Cargo.toml | 1 + shell/src/lib.rs | 180 ++------ 14 files changed, 2209 insertions(+), 258 deletions(-) create mode 100644 rsdosedit/Cargo.toml create mode 100644 rsdosedit/src/lib.rs create mode 100644 rsnix/src/strbuf.rs create mode 100644 rsnix/src/tty.rs create mode 100644 rsnix/src/vt.rs diff --git a/CONTEXT.md b/CONTEXT.md index 66ab320..b557192 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -101,10 +101,20 @@ Single Rust crate (`x86_64-unknown-uefi` target). Module breakdown: - `fs.rs` — VFS: dirs, ramfs files, char devices (`/dev/mem`, `rtc`, `pci`, `version`, `meminfo`, `null`, `zero`, `console`, `serial`; `/tmp` ramfs; `/etc/motd`) -- `gop.rs` — framebuffer access and text rendering +- `gop.rs` — framebuffer rasteriser (glyph blits, pixel shifts); the text + surface itself lives in `vt` +- `vt.rs` — terminal emulator: cell grid, cursor, ANSI CSI parser (H/J/K, + cursor moves, SGR 16-colour, `?25l/h`), dirty-cell blitting +- `tty.rs` — line discipline: canonical `read_line` (echo, line buffer, + ^C/^D) and raw `read_byte` (terminal byte protocol: arrows → `ESC [ A–D`, + ^X → `0x18`, …) +- `strbuf.rs` — stack fixed-capacity ASCII string (moved from shell) - `font.rs` — 8×16 bitmap font data -- `ps2.rs` — PS/2 keyboard driver (polling) +- `ps2.rs` — PS/2 keyboard driver (polling); generic `Ctrl(char)` and + `CtrlArrow` events - `shell.rs` — command loop and built-in commands +- `rsdosedit/` — kernel-native port of the RNG `rsedit` terminal editor + (no_std; talks to `tty` raw mode + `vt` ANSI; saves via the VFS) ## Milestone status (v0.1) @@ -133,6 +143,30 @@ Single Rust crate (`x86_64-unknown-uefi` target). Module breakdown: ✅ **^C / ^D** — Ctrl+C cancels the line or aborts a running stream (`cat`/`hexdump` check between chunks), Ctrl+D on an empty line powers off (EOF) +✅ **tty + vt (terminal) — the 1:1 UNIX console split**: `tty` owns the + line discipline (canonical echo/buffer/^C/^D for the shell; raw mode + translates keys to the classic terminal byte protocol), `vt` owns the + screen (cell grid + ANSI CSI parser + 16-colour SGR + dirty-cell blits + into the GOP framebuffer). `gop_println!` etc. now render through `vt`. +✅ **rsdosedit** — the RNG `rsedit` editor ported to the kernel (no_std, + no libc): `edit ` opens a full-screen editor with undo, syntax + highlighting, search (^W), save (^S), exit (^X, twice to discard), word + nav (Ctrl+arrows), smart indent. Files live in the ramfs. Termios → + `tty` raw mode, stdout → `vt`, `std::fs` → VFS, SIGWINCH → fixed + geometry. Verified in QEMU: edit/type/navigate/insert/save/exit, exact + bytes via `cat`/`hexdump`, syntax colours via framebuffer dump. + +## Known issues / perf notes + +- **Scroll is slow under QEMU without KVM**: each scroll shifts the whole + framebuffer up (~3 MB copy at 1280×800) with TCG-emulated stores. + Deferred optimisation: ring-buffer rows / deferred pan so scrolling + never touches the whole framebuffer. The vt's cell grid already avoids + re-rasterising on scroll (only the exposed bottom row is blitted). +- **Editor redraw** re-blits changed cells per keystroke (fine at 1280×800); + the "optimize drawing someday" item. +- **ramfs only**: edited files live in memory, lost on reboot until disk + support (AHCI/FAT32) lands. ## Target audience diff --git a/Cargo.lock b/Cargo.lock index 1d697fc..7b1ced6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,6 +10,13 @@ dependencies = [ "shell", ] +[[package]] +name = "rsdosedit" +version = "0.1.0" +dependencies = [ + "rsnix", +] + [[package]] name = "rsnix" version = "0.2.0" @@ -18,5 +25,6 @@ version = "0.2.0" name = "shell" version = "0.2.0" dependencies = [ + "rsdosedit", "rsnix", ] diff --git a/Cargo.toml b/Cargo.toml index 14eaeff..cf18339 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["rsnix", "shell", "rsdos"] +members = ["rsnix", "shell", "rsdos", "rsdosedit"] [workspace.package] 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 } else { rsnix::serial_println!("gop: not found (0x{:x})", gop_result); } + rsnix::vt::init(); + rsnix::serial_println!("vt: terminal ready ({}x{})", { + let (rows, cols) = rsnix::vt::size(); + rows + }, { + let (rows, cols) = rsnix::vt::size(); + cols + }); // ExitBootServices — we now own the machine 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 @@ +[package] +name = "rsdosedit" +version = "0.1.0" +edition = "2021" + +[dependencies] +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 @@ +#![no_std] + +extern crate alloc; + +use alloc::format; +use alloc::string::String; +use alloc::string::ToString; +use alloc::vec; +use alloc::vec::Vec; + +use rsnix::{tty, vt}; + +// ── Undo ──────────────────────────────────────────────────────────────────── + +struct UndoEntry { + lines: Vec, + cx: usize, + cy: usize, + scroll_y: usize, +} + +// ── Entry point ───────────────────────────────────────────────────────────── + +/// Run the editor on `filename` (empty = new file). Blocks until the user +/// exits; the caller (shell) is expected to clear the screen afterwards. +pub fn run(filename: &str) { + let mut editor = Editor::new(String::from(filename)); + editor.run(); +} + +// ── Editor ────────────────────────────────────────────────────────────────── + +struct Editor { + filename: String, + lines: Vec, + cx: usize, + cy: usize, + scroll_y: usize, + modified: bool, + term_rows: usize, + term_cols: usize, + status_msg: String, + read_buf: Vec, + read_pos: usize, + /// Key count of the last ^X press that was blocked by unsaved changes. + /// A second ^X within FORCE_EXIT_KEYS exits even with unsaved work. + last_x_count: u64, + /// Total keys processed (a monotonic proxy for time — rsdos has no clock + /// visible to apps; FORCE_EXIT_KEYS ≈ 2 s of typing). + key_count: u64, + undo_stack: Vec, + /// First syntax error found after the last modification, if any. + syntax_error: Option, +} + +impl Editor { + /// How many processed keys after a blocked ^X a second ^X will force-exit + /// despite unsaved changes. + const FORCE_EXIT_KEYS: u64 = 40; + /// Maximum number of undo entries kept. + const MAX_UNDO: usize = 100; + + // ── Construction ──────────────────────────────────────────────────────── + + fn new(filename: String) -> Self { + let lines = if !filename.is_empty() { + match load_lines(&filename) { + Some(v) => v, + None => vec![String::new()], + } + } else { + vec![String::new()] + }; + + let (rows, cols) = vt::size(); + + Editor { + filename, + lines, + cx: 0, + cy: 0, + scroll_y: 0, + modified: false, + term_rows: rows.saturating_sub(2), + term_cols: cols, + status_msg: String::new(), + read_buf: Vec::new(), + read_pos: 0, + last_x_count: 0, + key_count: 0, + undo_stack: Vec::new(), + syntax_error: None, + } + } + + // ── Gutter helpers ────────────────────────────────────────────────────── + + /// Total width of the line-number gutter including its separator. + fn gutter_width(&self) -> usize { + let digits = self.lines.len().max(1).to_string().len(); + digits.max(2) + 3 // e.g. " 123 │ " = 6 for 3-digit numbers + } + + /// Whether the current filename extension is a recognised source-code + /// type that should get syntax highlighting and checking. + fn is_source_file(&self) -> bool { + let exts: &[&str] = &[ + "c", "h", "cpp", "cc", "cxx", "c++", "hpp", "hh", "hxx", "rs", "go", "java", "kt", + "kts", "swift", "py", "rb", "js", "ts", "jsx", "tsx", "m", "mm", "cs", "zig", "scala", + "dart", "php", "r", "jl", "lua", "pl", "pm", "tcl", "s", "asm", "S", + ]; + let dot = self.filename.rsplit('.').next().unwrap_or(""); + let ext = if dot.is_empty() || dot == self.filename { + "" + } else { + dot + }; + exts.contains(&ext) + } + + /// Draw one editor row: gutter prefix + content, or a tilde for rows + /// past end-of-file. + fn draw_line_at(&self, screen_row: usize, line_idx: usize, out: &mut Vec) { + out.extend_from_slice(format!("\x1b[{};1H", screen_row).as_bytes()); + out.extend_from_slice(b"\x1b[K"); + out.extend_from_slice(b"\x1b[0m"); + + if line_idx < self.lines.len() { + let gw = self.gutter_width(); + let w = gw - 3; + let gutter = format!("{:>width$} │ ", line_idx + 1, width = w); + out.extend_from_slice(gutter.as_bytes()); + + // Truncate content to terminal width, then highlight + let max_cols = self.term_cols.saturating_sub(gw); + let line = &self.lines[line_idx]; + let visible = if line.len() <= max_cols { + &line[..] + } else if let Some((end, _)) = line.char_indices().nth(max_cols) { + &line[..end] + } else { + &line[..line.len()] + }; + if self.is_source_file() { + out.extend_from_slice(&self.highlight_line(visible)); + out.extend_from_slice(b"\x1b[0m"); + } else { + out.extend_from_slice(visible.as_bytes()); + } + } else { + // Tilde row — past end of file + out.extend_from_slice(b"\x1b[0m~"); + } + } + + // ── Undo ──────────────────────────────────────────────────────────────── + + fn push_undo(&mut self) { + if self.undo_stack.len() >= Self::MAX_UNDO { + self.undo_stack.remove(0); + } + self.undo_stack.push(UndoEntry { + lines: self.lines.clone(), + cx: self.cx, + cy: self.cy, + scroll_y: self.scroll_y, + }); + } + + fn undo(&mut self) { + let entry = match self.undo_stack.pop() { + Some(e) => e, + None => { + self.status_msg = "Nothing to undo".to_string(); + return; + } + }; + self.lines = entry.lines; + self.cx = entry.cx; + self.cy = entry.cy; + self.scroll_y = entry.scroll_y; + self.modified = true; + self.status_msg = "Undone".to_string(); + } + + // ── Help screen ───────────────────────────────────────────────────────── + + fn help_screen(&mut self) { + let mut out: Vec = Vec::new(); + out.extend_from_slice(b"\x1b[?25l\x1b[2J\x1b[H"); + + let help_lines = [ + ("", ""), + (" \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}"), + (" rsedit", " \u{2014} Keybindings"), + (" \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}"), + ("", ""), + (" Arrow keys", " Move cursor"), + (" ^\u{2190} / ^\u{2192}", " Word backward/forward"), + (" ^\u{2191} / ^\u{2193}", " Page up/down"), + (" Page Up / Page Down", " Scroll by page"), + (" Home (^A)", " Beginning of line"), + (" End (^E)", " End of line"), + (" Enter", " Split line at cursor"), + (" Backspace", " Delete char before cursor"), + (" Delete (^D / Del)", " Delete char at cursor"), + (" ^K", " Kill to end of line"), + (" Tab", " Insert spaces (smart tab)"), + (" ^S", " Save file"), + (" ^W", " Search forward"), + (" ^X", " Exit (twice to discard changes)"), + (" ^Z", " Suspend editor (job control)"), + (" ^_", " Undo"), + (" ^G", " This help"), + ("", ""), + (" Press any key to return to the editor...", ""), + ]; + + for (i, (left, right)) in help_lines.iter().enumerate() { + let row = i + 1; + out.extend_from_slice(format!("\x1b[{};1H", row).as_bytes()); + out.extend_from_slice(b"\x1b[K"); + out.extend_from_slice(left.as_bytes()); + out.extend_from_slice(right.as_bytes()); + } + + // Cursor on the last help line ("Press any key...") + let prompt_row = help_lines.len(); + out.extend_from_slice(format!("\x1b[{};1H", prompt_row).as_bytes()); + out.extend_from_slice(b"\x1b[?25h"); + + vt::write_bytes(&out); + + let _ = tty::read_byte(); + + self.draw_full(); + } + + // ── Search ────────────────────────────────────────────────────────────── + + fn search(&mut self) { + let mut query = String::new(); + let orig_cy = self.cy; + let orig_cx = self.cx; + let mut found = true; + let status_row = self.term_rows + 2; + + loop { + // Draw search prompt on the status row + let mut out: Vec = Vec::new(); + out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes()); + out.extend_from_slice(b"\x1b[K"); + if found { + out.extend_from_slice(b"Search: "); + } else { + out.extend_from_slice(b"Search: [not found] "); + } + out.extend_from_slice(query.as_bytes()); + vt::write_bytes(&out); + + let b = match self.read_byte() { + Some(b) => b, + None => return, + }; + + match b { + 0x0a | 0x0d => break, // Enter — accept the position + 0x1b | 0x03 => { + // Esc / ^C — cancel, restore original position + self.cy = orig_cy; + self.cx = orig_cx; + break; + } + 0x7f => { + query.pop(); + found = self.find_next(&query, orig_cy, orig_cx); + } + _ if (0x20..=0x7e).contains(&b) && query.len() < 256 => { + query.push(b as char); + found = self.find_next(&query, orig_cy, orig_cx); + } + _ => {} + } + } + + if found && !query.is_empty() { + self.status_msg = format!("Found: {}", query); + } + self.scroll_to_cursor(); + self.draw_full(); + } + + /// Search forward from (start_cy, start_cx) for `query`. If found, + /// move the cursor there and return true. + fn find_next(&mut self, query: &str, start_cy: usize, start_cx: usize) -> bool { + if query.is_empty() { + return false; + } + + // Current line, after cursor + if let Some(pos) = self.lines[start_cy][start_cx..].find(query) { + self.cy = start_cy; + self.cx = start_cx + pos; + return true; + } + + // Following lines + for i in (start_cy + 1)..self.lines.len() { + if let Some(pos) = self.lines[i].find(query) { + self.cy = i; + self.cx = pos; + return true; + } + } + + false + } + + // ── Main loop ─────────────────────────────────────────────────────────── + + fn run(&mut self) { + self.draw_full(); + + loop { + let b = match self.read_byte() { + Some(b) => b, + None => break, + }; + + self.key_count += 1; + self.status_msg.clear(); + + let prev_cy = self.cy; + let prev_scroll = self.scroll_y; + let prev_modified = self.modified; + let prev_line_count = self.lines.len(); + + // Any key other than ^X cancels a pending force-exit + let prev_x_count = self.last_x_count; + if b != 0x18 { + self.last_x_count = 0; + } + + match b { + // ── File operations ────────────────────────────────────── + 0x13 => self.save(), + + // ^C: no-op (raw mode: 0x03 is just a byte) + 0x03 => {} + + // ── Undo ───────────────────────────────────────────────── + + // ^_ (Ctrl-underscore) — undo + 0x1f => self.undo(), + + // ── Help ───────────────────────────────────────────────── + + // ^G — help screen + 0x07 => self.help_screen(), + + // ── Search ─────────────────────────────────────────────── + + // ^W — search + 0x17 => self.search(), + + // ── Tab ────────────────────────────────────────────────── + + // Tab → insert spaces up to the next 4-column boundary + 0x09 => { + self.push_undo(); + const TAB_STOP: usize = 4; + let n = TAB_STOP - (self.cx % TAB_STOP); + for _ in 0..n { + self.lines[self.cy].insert(self.cx, ' '); + self.cx += 1; + } + self.modified = true; + } + + // ── Exit ───────────────────────────────────────────────── + 0x18 => { + if !self.modified { + break; + } + let force = prev_x_count != 0 + && (self.key_count - prev_x_count) <= Self::FORCE_EXIT_KEYS; + if force { + break; + } + self.last_x_count = self.key_count; + self.status_msg = + "Unsaved changes! ^S to save, or ^X again to discard.".to_string(); + } + + // ── Vertical movement ──────────────────────────────────── + + // ^N (next line) / ^P (prev line) — also mapped to + // arrow down/up via escape sequences + 0x0e | 0x10 => { + let down = b == 0x0e; + if down { + if self.cy + 1 < self.lines.len() { + self.cy += 1; + } + } else if self.cy > 0 { + self.cy -= 1; + } + self.clamp_cursor(); + } + + // ── Horizontal movement ────────────────────────────────── + + // ^F / ^B — right/left + 0x06 | 0x02 => { + let right = b == 0x06; + if right { + if self.cx < self.lines[self.cy].len() { + self.cx += 1; + } + } else if self.cx > 0 { + self.cx -= 1; + } + } + + // ^A / ^E — line start / line end + 0x01 => self.cx = 0, + 0x05 => self.cx = self.lines[self.cy].len(), + + // ── Kill to end of line ────────────────────────────────── + + // ^K + 0x0b => { + self.push_undo(); + self.lines[self.cy].truncate(self.cx); + self.modified = true; + } + + // ── Escape sequences (arrows, pgup/pgdn, etc.) ─────────── + 0x1b => self.handle_escape(), + + // ── Line split / join ──────────────────────────────────── + 0x0a | 0x0d => { + self.push_undo(); + // Capture the leading whitespace before splitting so the + // new line inherits the same indent level. If the text + // before the cursor ends with an opening bracket, bump + // the indent by one extra level. + let (indent, extra) = { + let line = &self.lines[self.cy]; + let ws = line.len() - line.trim_start().len(); + let base = line[..ws].to_string(); + let before = &line[..self.cx.min(line.len())]; + let bump = before + .ends_with('{') + .then_some(" ") + .or_else(|| before.ends_with('(').then_some(" ")) + .or_else(|| before.ends_with('[').then_some(" ")) + .unwrap_or("") + .to_string(); + (base, bump) + }; + let full = indent.clone() + &extra; + let rest = self.lines[self.cy].split_off(self.cx); + self.lines.insert(self.cy + 1, rest); + self.cy += 1; + // Avoid doubling when split_off at 0 already moved the + // full line (with its indent) into the new line. + if !self.lines[self.cy].starts_with(&full) { + self.lines[self.cy].insert_str(0, &full); + } + self.cx = full.len(); + self.modified = true; + } + + // ── Backspace ──────────────────────────────────────────── + + // 0x7f (DEL / Backspace in raw mode) + 0x7f => { + self.push_undo(); + if self.cx > 0 { + self.lines[self.cy].remove(self.cx - 1); + self.cx -= 1; + self.modified = true; + } else if self.cy > 0 { + let rest = self.lines.remove(self.cy); + self.cy -= 1; + self.cx = self.lines[self.cy].len(); + self.lines[self.cy].push_str(&rest); + self.modified = true; + } + } + + // ── Delete ─────────────────────────────────────────────── + + // ^D + 0x04 => { + self.push_undo(); + if self.cx < self.lines[self.cy].len() { + self.lines[self.cy].remove(self.cx); + self.modified = true; + } else if self.cy + 1 < self.lines.len() { + let next = self.lines.remove(self.cy + 1); + self.lines[self.cy].push_str(&next); + self.modified = true; + } + } + + // ── Printable characters ───────────────────────────────── + _ if (0x20..=0x7e).contains(&b) => { + let ch = b as char; + self.push_undo(); + self.modified = true; + + // Auto-dedent closing brackets when the cursor is at the + // end of a whitespace-only prefix (one indent level). + let dedent = matches!(ch, '}' | ')' | ']') + && self.cx >= 4 + && self.lines[self.cy][..self.cx].chars().all(|c| c == ' '); + + if dedent { + let indent = self.cx - 4; + let after = self.lines[self.cy][self.cx..].to_string(); + self.lines[self.cy] = + format!("{:indent$}{}{}", "", ch, after, indent = indent); + self.cx = indent + 1; + } else { + self.lines[self.cy].insert(self.cx, ch); + self.cx += 1; + } + } + + _ => {} + } + + // Re-check syntax after any modification + if self.modified { + self.syntax_error = if self.is_source_file() { + self.check_syntax() + } else { + None + }; + } + + self.scroll_to_cursor(); + self.update_screen(prev_cy, prev_scroll, prev_modified, prev_line_count); + } + + // Restore terminal on exit: show cursor, clear screen, home. + vt::write_bytes(b"\x1b[?25h\x1b[2J\x1b[H"); + } + + // ── Cursor helpers ────────────────────────────────────────────────────── + + fn clamp_cursor(&mut self) { + if self.cy >= self.lines.len() { + self.cy = self.lines.len().saturating_sub(1); + } + if self.cx > self.lines[self.cy].len() { + self.cx = self.lines[self.cy].len(); + } + } + + fn scroll_to_cursor(&mut self) { + let screen_rows = self.term_rows; + if self.cy < self.scroll_y { + self.scroll_y = self.cy; + } + if self.cy >= self.scroll_y + screen_rows { + self.scroll_y = self.cy.saturating_sub(screen_rows.saturating_sub(1)); + } + } + + // ── Screen updates ────────────────────────────────────────────────────── + + fn update_screen( + &mut self, + prev_cy: usize, + prev_scroll: usize, + prev_modified: bool, + prev_line_count: usize, + ) { + // Full redraw on scroll or line-count change + if self.scroll_y != prev_scroll || self.lines.len() != prev_line_count { + self.draw_full(); + return; + } + + let status_changed = self.modified != prev_modified || !self.status_msg.is_empty(); + + let cursor_moved_only = self.cy == prev_cy + && !self.modified + && self.modified == prev_modified + && self.status_msg.is_empty(); + + if cursor_moved_only { + self.draw_cursor(); + return; + } + + let mut out: Vec = Vec::new(); + let content_changed = self.modified || self.cy != prev_cy; + + if content_changed { + // Redraw previous line + self.draw_line_at(prev_cy.saturating_sub(self.scroll_y) + 1, prev_cy, &mut out); + + // If cursor moved, redraw the new line too + if self.cy != prev_cy { + self.draw_line_at(self.cy.saturating_sub(self.scroll_y) + 1, self.cy, &mut out); + + // Redraw any lines between prev_cy and self.cy + let (lo, hi) = if self.cy > prev_cy { + (prev_cy + 1, self.cy) + } else { + (self.cy + 1, prev_cy) + }; + for i in lo..hi { + self.draw_line_at(i.saturating_sub(self.scroll_y) + 1, i, &mut out); + } + } + + // When modified flag changed but cursor didn't move, redraw + // the current line anyway (text changed in place) + if self.cy == prev_cy && self.modified { + self.draw_line_at(self.cy.saturating_sub(self.scroll_y) + 1, self.cy, &mut out); + } + } + + // Status bar + if status_changed || content_changed { + out.extend_from_slice(b"\x1b7"); // save cursor + let status_row = self.term_rows + 2; + out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes()); + out.extend_from_slice(b"\x1b[K"); + out.extend_from_slice(self.status_line().as_bytes()); + out.extend_from_slice(b"\x1b8"); // restore cursor + } + + self.draw_cursor_to(&mut out); + vt::write_bytes(&out); + } + + fn draw_cursor(&self) { + let mut out: Vec = Vec::new(); + self.draw_cursor_to(&mut out); + vt::write_bytes(&out); + } + + fn draw_cursor_to(&self, out: &mut Vec) { + let screen_row = self.cy.saturating_sub(self.scroll_y) + 1; + let screen_col = self.gutter_width() + self.cx + 1; + out.extend_from_slice(format!("\x1b[{};{}H", screen_row, screen_col).as_bytes()); + } + + fn draw_full(&mut self) { + let mut out: Vec = Vec::new(); + out.extend_from_slice(b"\x1b[?25l"); // hide cursor + out.extend_from_slice(b"\x1b[2J"); // clear entire screen + + let screen_rows = self.term_rows; + for i in 0..screen_rows { + let line_idx = self.scroll_y + i; + self.draw_line_at(i + 1, line_idx, &mut out); + } + + // Separator line + let sep_row = self.term_rows + 1; + out.extend_from_slice(format!("\x1b[{};1H", sep_row).as_bytes()); + out.extend_from_slice(b"\x1b[K"); + + // Status bar + let status_row = self.term_rows + 2; + out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes()); + out.extend_from_slice(b"\x1b[K"); + out.extend_from_slice(self.status_line().as_bytes()); + + self.draw_cursor_to(&mut out); + out.extend_from_slice(b"\x1b[?25h"); // show cursor + + vt::write_bytes(&out); + } + + /// Build the status bar text, optionally clippiing to fit the terminal + /// width (minus a 1-char margin). + fn status_line(&self) -> String { + let display_name = if self.filename.is_empty() { + "[new file]" + } else { + &self.filename + }; + + let pos = format!("Ln {}, Col {}", self.cy + 1, self.cx + 1); + + // Prepend syntax error if present + let tag = self.syntax_error.as_ref().map(|e| format!("[{}]", e)); + + let raw = if !self.status_msg.is_empty() { + self.status_msg.clone() + } else if let Some(ref t) = tag { + format!( + "{} {} {} ^S save ^X exit ^W search ^_ undo", + display_name, pos, t + ) + } else if self.modified { + format!( + "{} [modified] {} ^S save ^X exit ^W search ^_ undo", + display_name, pos + ) + } else { + format!( + "{} {} ^S save ^X exit ^W search ^_ undo", + display_name, pos + ) + }; + + // Truncate if wider than terminal (leave 1-char pad) + let max_w = self.term_cols.saturating_sub(1); + if raw.len() > max_w { + raw[..max_w].to_string() + } else { + raw + } + } + + // ── Save ──────────────────────────────────────────────────────────────── + + fn save(&mut self) { + if self.filename.is_empty() { + self.prompt_filename(); + return; + } + self.do_save(); + } + + fn prompt_filename(&mut self) { + let status_row = self.term_rows + 2; + let mut name = String::new(); + + loop { + let mut out: Vec = Vec::new(); + out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes()); + out.extend_from_slice(b"\x1b[K"); + out.extend_from_slice(b"Save as: "); + out.extend_from_slice(name.as_bytes()); + // Show cursor during prompt + vt::write_bytes(&out); + + let b = match self.read_byte() { + Some(b) => b, + None => return, + }; + + match b { + 0x0a | 0x0d => { + if !name.is_empty() { + self.filename = name; + self.do_save(); + } + break; + } + 0x1b => break, + 0x7f => { + if !name.is_empty() { + name.pop(); + } + } + 0x03 => break, + _ if (0x20..=0x7e).contains(&b) && name.len() < 256 => { + name.push(b as char); + } + _ => {} + } + } + + self.draw_full(); + } + + fn do_save(&mut self) { + // VFS (ramfs) write: truncate then write the whole buffer. + match save_lines(&self.filename, &self.lines) { + Ok(()) => { + self.modified = false; + // Clear undo stack — saving is a good checkpoint + self.undo_stack.clear(); + self.status_msg = format!("Saved {}", self.filename); + } + Err(()) => { + self.status_msg = "Error: cannot write file".to_string(); + } + } + } + + // ── Word navigation helpers ────────────────────────────────────────────── + + /// Move cursor to the start of the previous word on the current line. + fn prev_word(&mut self) { + let line = &self.lines[self.cy]; + let bytes = line.as_bytes(); + let mut pos = self.cx.min(bytes.len()); + if pos == 0 { + return; + } + // Step left past any non-word chars before cursor + pos -= 1; + while pos > 0 && !bytes[pos].is_ascii_alphanumeric() { + pos -= 1; + } + // Step left through the word we landed in + while pos > 0 && bytes[pos.saturating_sub(1)].is_ascii_alphanumeric() { + pos -= 1; + } + self.cx = pos; + } + + /// Move cursor to the start of the next word on the current line. + fn next_word(&mut self) { + let line = &self.lines[self.cy]; + let bytes = line.as_bytes(); + let mut pos = self.cx; + let len = bytes.len(); + // Skip through the current word + while pos < len && bytes[pos].is_ascii_alphanumeric() { + pos += 1; + } + // Skip non-word chars to the start of the next word + while pos < len && !bytes[pos].is_ascii_alphanumeric() { + pos += 1; + } + self.cx = pos; + } + + // ── Syntax highlighting ──────────────────────────────────────────────── + + /// Return `line` with ANSI color codes: strings in green, comments in + /// gray, brackets in yellow. Respects `\` escapes inside strings. + fn highlight_line(&self, line: &str) -> Vec { + let mut out = Vec::new(); + let bytes = line.as_bytes(); + let mut i = 0; + + while i < bytes.len() { + // Line comment // … + if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' { + out.extend_from_slice(b"\x1b[90m"); + out.extend_from_slice(&bytes[i..]); + out.extend_from_slice(b"\x1b[0m"); + return out; + } + + // String "…" or '…' + if bytes[i] == b'"' || bytes[i] == b'\'' { + let quote = bytes[i]; + out.extend_from_slice(b"\x1b[32m"); + out.push(quote); + i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + out.push(bytes[i]); + i += 1; + if i < bytes.len() { + out.push(bytes[i]); + i += 1; + } + continue; + } + out.push(bytes[i]); + if bytes[i] == quote { + i += 1; + break; + } + i += 1; + } + out.extend_from_slice(b"\x1b[0m"); + continue; + } + + // Brackets () [] {} + if matches!(bytes[i], b'(' | b')' | b'[' | b']' | b'{' | b'}') { + out.extend_from_slice(b"\x1b[93m"); + out.push(bytes[i]); + out.extend_from_slice(b"\x1b[0m"); + i += 1; + continue; + } + + // Regular character + out.push(bytes[i]); + i += 1; + } + + out + } + + /// Scan the entire buffer for syntax errors (unclosed strings, + /// mismatched brackets). Returns the first error message, or `None` + /// if the buffer is clean. + fn check_syntax(&self) -> Option { + // Stack of opening brackets: (char, line_0idx, col) + let mut stack: Vec<(u8, usize, usize)> = Vec::new(); + let mut in_string = false; + let mut string_char: u8 = 0; + + for (li, line) in self.lines.iter().enumerate() { + let bytes = line.as_bytes(); + let mut i = 0; + + while i < bytes.len() { + if in_string { + if bytes[i] == b'\\' { + i += 1; + if i < bytes.len() { + i += 1; + } + continue; + } + if bytes[i] == string_char { + in_string = false; + } + i += 1; + continue; + } + + // Line comment + if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' { + break; + } + + match bytes[i] { + b'"' | b'\'' => { + in_string = true; + string_char = bytes[i]; + } + b'(' | b'[' | b'{' => { + stack.push((bytes[i], li, i)); + } + b')' => match stack.last() { + Some(&(b'(', _, _)) => { + stack.pop(); + } + _ => { + return Some(format!("Line {}: unmatched ')'", li + 1)); + } + }, + b']' => match stack.last() { + Some(&(b'[', _, _)) => { + stack.pop(); + } + _ => { + return Some(format!("Line {}: unmatched ']'", li + 1)); + } + }, + b'}' => match stack.last() { + Some(&(b'{', _, _)) => { + stack.pop(); + } + _ => { + return Some(format!("Line {}: unmatched '}}'", li + 1)); + } + }, + _ => {} + } + i += 1; + } + } + + if in_string { + return Some(format!( + "Unclosed string (started with '{}')", + string_char as char + )); + } + + if let Some((ch, li, _)) = stack.last() { + return Some(format!("Line {}: unclosed '{}'", li + 1, *ch as char)); + } + + None + } + + // ── Escape sequence handling ──────────────────────────────────────────── + + fn handle_escape(&mut self) { + let b = match self.read_byte() { + Some(b) => b, + None => return, + }; + if b != 0x5b && b != b'O' { + // Not a CSI / SS3 prefix. Push the byte back so the main loop + // processes it normally. + if self.read_pos > 0 { + self.read_pos -= 1; + } + return; + } + let cmd = match self.read_byte() { + Some(b) => b, + None => return, + }; + + // For numeric parameters, read the next byte to determine sequence + // type (terminator ~, modifier ;, or rxvt-style arrow letter). + let next = if self.read_pos < self.read_buf.len() { + Some(self.read_buf[self.read_pos]) + } else { + None + }; + + match cmd { + // Arrow keys — only when NOT followed by modifier + 0x41 if next.is_none_or(|n| n != b';') => { + if self.cy > 0 { + self.cy -= 1; + } + self.clamp_cursor(); + } + 0x42 if next.is_none_or(|n| n != b';') => { + if self.cy + 1 < self.lines.len() { + self.cy += 1; + } + self.clamp_cursor(); + } + 0x43 if next.is_none_or(|n| n != b';') => { + if self.cx < self.lines[self.cy].len() { + self.cx += 1; + } + } + 0x44 if next.is_none_or(|n| n != b';') => { + if self.cx > 0 { + self.cx -= 1; + } + } + + // Home (ESC [ H / ESC O H) and End (ESC [ F / ESC O F) + 0x48 => self.cx = 0, + 0x46 => self.cx = self.lines[self.cy].len(), + + // ── Numeric CSI sequences ─────────────────────────────────--- + + // ESC [ 1~ / ESC [ 7~ → Home + // ESC [ 4~ → End + // ESC [ 1 ; 5 X → xterm-style Ctrl-modifier + 0x31 | 0x37 | 0x34 => { + let term = match self.read_byte() { + Some(b) => b, + None => return, + }; + match (cmd, term) { + (0x31 | 0x37, 0x7e) => self.cx = 0, + (0x34, 0x7e) => self.cx = self.lines[self.cy].len(), + // ESC [ 1 ; 5 X — consume modifier + final command byte + (_, 0x3b) => { + let mod_b = match self.read_byte() { + Some(b) => b, + None => return, + }; + let fin = match self.read_byte() { + Some(b) => b, + None => return, + }; + if mod_b == b'5' { + match fin { + 0x44 => self.prev_word(), + 0x43 => self.next_word(), + 0x41 => { + let jump = self.term_rows.min(self.cy); + self.cy = self.cy.saturating_sub(jump); + self.clamp_cursor(); + } + 0x42 => { + self.cy = (self.cy + self.term_rows) + .min(self.lines.len().saturating_sub(1)); + self.clamp_cursor(); + } + _ => {} + } + } + } + _ => {} + } + } + + // ESC [ 5~ → Page Up + // ESC [ 5A → rxvt Ctrl+Up + // ESC [ 5B → rxvt Ctrl+Down + // ESC [ 5C → rxvt Ctrl+Right + // ESC [ 5D → rxvt Ctrl+Left + 0x35 => { + let term = match self.read_byte() { + Some(b) => b, + None => return, + }; + match term { + 0x7e => { + // Page Up + let jump = self.term_rows.min(self.cy); + self.cy = self.cy.saturating_sub(jump); + self.clamp_cursor(); + } + // rxvt Ctrl+arrow: ESC [ 5 A-D + 0x41 => { + let jump = self.term_rows.min(self.cy); + self.cy = self.cy.saturating_sub(jump); + self.clamp_cursor(); + } + 0x42 => { + self.cy = + (self.cy + self.term_rows).min(self.lines.len().saturating_sub(1)); + self.clamp_cursor(); + } + 0x43 => self.next_word(), + 0x44 => self.prev_word(), + _ => {} + } + } + + // ESC [ 6~ → Page Down + // ESC [ 6A → rxvt Shift+Up (ignore) + // etc. + 0x36 => { + let term = match self.read_byte() { + Some(b) => b, + None => return, + }; + if term == 0x7e { + // Page Down + self.cy = (self.cy + self.term_rows).min(self.lines.len().saturating_sub(1)); + self.clamp_cursor(); + } + } + + // Delete (ESC [ P / ESC [ 3~) + 0x50 => { + self.push_undo(); + if self.cx < self.lines[self.cy].len() { + self.lines[self.cy].remove(self.cx); + self.modified = true; + } else if self.cy + 1 < self.lines.len() { + let next = self.lines.remove(self.cy + 1); + self.lines[self.cy].push_str(&next); + self.modified = true; + } + } + 0x33 => { + let term = match self.read_byte() { + Some(b) => b, + None => return, + }; + if term == 0x7e { + self.push_undo(); + if self.cx < self.lines[self.cy].len() { + self.lines[self.cy].remove(self.cx); + self.modified = true; + } else if self.cy + 1 < self.lines.len() { + let next = self.lines.remove(self.cy + 1); + self.lines[self.cy].push_str(&next); + self.modified = true; + } + } + } + + _ => {} + } + } + + // ── Input buffering ───────────────────────────────────────────────────── + + fn fill_buf(&mut self) { + if self.read_pos < self.read_buf.len() { + return; + } + self.read_buf.clear(); + self.read_pos = 0; + // First byte blocks until a keypress; then drain whatever else is + // already queued (the tail of a multi-byte key sequence) so escape + // sequences stay together in one buffer. + self.read_buf.push(tty::read_byte()); + while self.read_buf.len() < 64 { + match tty::try_read_byte() { + Some(b) => self.read_buf.push(b), + None => break, + } + } + } + + fn read_byte(&mut self) -> Option { + self.fill_buf(); + if self.read_pos < self.read_buf.len() { + let b = self.read_buf[self.read_pos]; + self.read_pos += 1; + return Some(b); + } + None + } +} + +// ── File I/O (ramfs via the VFS) ─────────────────────────────────────────── + +/// Read a whole file from the VFS into lines. None if the file does not +/// exist or is not valid UTF-8. +fn load_lines(path: &str) -> Option> { + let h = rsnix::fs::resolve(path)?; + let mut content: Vec = Vec::new(); + let mut buf = [0u8; 512]; + let mut pos = 0u64; + loop { + let n = rsnix::fs::read(h, &mut buf, pos).ok()?; + if n == 0 { + break; + } + content.extend_from_slice(&buf[..n]); + pos += n as u64; + if n < buf.len() { + break; + } + } + let s = core::str::from_utf8(&content).ok()?; + let mut v: Vec = s.lines().map(String::from).collect(); + if s.ends_with('\n') || v.is_empty() { + v.push(String::new()); + } + Some(v) +} + +/// Write lines (joined with '\n') back to a VFS file, truncating first. +fn save_lines(path: &str, lines: &[String]) -> Result<(), ()> { + let h = rsnix::fs::resolve(path).ok_or(())?; + rsnix::fs::set_len(h, 0)?; + let mut buf = [0u8; 512]; + let mut pos = 0u64; + for line in lines { + let bytes = line.as_bytes(); + let mut off = 0; + while off < bytes.len() { + let n = core::cmp::min(512, bytes.len() - off); + buf[..n].copy_from_slice(&bytes[off..off + n]); + let w = rsnix::fs::write(h, &buf[..n], pos)?; + if w == 0 { + return Err(()); + } + pos += w as u64; + off += w as usize; + } + buf[0] = b'\n'; + let w = rsnix::fs::write(h, &buf[..1], pos)?; + if w == 0 { + return Err(()); + } + pos += 1; + } + Ok(()) +} diff --git a/rsnix/src/gop.rs b/rsnix/src/gop.rs index 9645bb0..81b8945 100644 --- a/rsnix/src/gop.rs +++ b/rsnix/src/gop.rs @@ -1,4 +1,8 @@ /// GOP framebuffer driver — renders text via a bitmap font. +/// +/// Text surface (cursor, scrolling, colours) lives in `crate::vt`; this +/// module is the low-level rasteriser: glyph blits and pixel shifts on the +/// linear framebuffer. use crate::font; use crate::uefi::{GraphicsOutputProtocol, PIXEL_BGR, PIXEL_RGB}; use core::fmt; @@ -13,11 +17,7 @@ pub struct Framebuffer { pitch: usize, // bytes per scan line bytes_per_pixel: usize, bg: u32, // packed BGRA or RGBA background colour - fg: u32, // packed foreground colour - cursor_x: usize, - cursor_y: usize, - rows: usize, - cols: usize, + bgr: bool, } static mut FB: Option = None; @@ -38,9 +38,8 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { PIXEL_RGB => (0x00_FF_FF_FFu32, 0x00_00_00_00u32), _ => (0x00_FF_FF_FFu32, 0x00_00_00_00u32), }; - - let rows = height / 16; - let cols = width / 8; + let bgr = info.pixel_format == PIXEL_BGR; + let _ = fg; let fb = Framebuffer { base, @@ -49,11 +48,7 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { pitch, bytes_per_pixel: bpp, bg, - fg, - cursor_x: 0, - cursor_y: 0, - rows, - cols, + bgr, }; fb.clear(); @@ -63,79 +58,45 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { } } -/// Draw a character at the current cursor position and advance. -pub fn put_char(c: char) { - let fb = unsafe { - match FB.as_mut() { - Some(f) => f, - None => return, - } - }; +// --------------------------------------------------------------------------- +// Public text API — the text surface now lives in `crate::vt`; these wrappers +// keep callers (shell, macros) unchanged. +// --------------------------------------------------------------------------- - if c == '\x08' { - // Backspace - move cursor back and erase - if fb.cursor_x > 0 { - fb.cursor_x -= 1; - } else if fb.cursor_y > 0 { - fb.cursor_y -= 1; - fb.cursor_x = fb.cols - 1; - } else { - return; - } - // Draw glyph 32 (space) to erase — glyph 0 is NUL, not blank - fb.draw_glyph(32, fb.cursor_x, fb.cursor_y); - return; - } - if c == '\n' { - fb.cursor_x = 0; - fb.cursor_y += 1; - if fb.cursor_y >= fb.rows { - fb.scroll(); - } - return; - } - if c == '\r' { - fb.cursor_x = 0; - return; - } +/// Draw a character at the current terminal cursor position and advance. +pub fn put_char(c: char) { + let b = if (c as u32) < 0x100 { c as u8 } else { b'?' }; + crate::vt::put_char(b); +} - let idx = c as usize; - if idx >= 256 { - // Unmapped character: draw a space - fb.draw_glyph(0, fb.cursor_x, fb.cursor_y); - } else { - fb.draw_glyph(idx, fb.cursor_x, fb.cursor_y); - } +/// Write a string to the terminal. +pub fn write_str(s: &str) { + crate::vt::write_str(s); +} - fb.cursor_x += 1; - if fb.cursor_x >= fb.cols { - fb.cursor_x = 0; - fb.cursor_y += 1; - if fb.cursor_y >= fb.rows { - fb.scroll(); - } - } +/// Clear the terminal and home the cursor. +pub fn clear_screen() { + crate::vt::clear_all(); } -/// Write a string to the framebuffer. -pub fn write_str(s: &str) { - for c in s.chars() { - put_char(c); +/// (cols, rows) of the text surface, from the current framebuffer mode. +pub fn dims() -> (usize, usize) { + unsafe { + match FB.as_ref() { + Some(f) => (f.width / 8, f.height / 16), + None => (0, 0), + } } } -/// Clear the entire screen to background colour and reset cursor to (0, 0). -pub fn clear_screen() { - let fb = unsafe { - match FB.as_mut() { - Some(f) => f, - None => return, +/// Pack an (r, g, b) colour for the framebuffer's pixel format. +pub fn rgb(r: u8, g: u8, b: u8) -> u32 { + unsafe { + match FB.as_ref() { + Some(f) if f.bgr => ((r as u32) << 16) | ((g as u32) << 8) | b as u32, + _ => ((b as u32) << 16) | ((g as u32) << 8) | r as u32, } - }; - fb.clear(); - // Reset cursor back to top-left so the next prompt appears at (0, 0) - fb.cursor_x = 0; - fb.cursor_y = 0; + } } // --------------------------------------------------------------------------- @@ -155,7 +116,10 @@ impl Framebuffer { } } - fn scroll(&mut self) { + /// Shift the whole framebuffer up one row (16 pixels) and clear the + /// exposed bottom row. Called by `vt::scroll_up`; the vt then re-blits + /// only the new bottom row. + pub(crate) fn shift_up(&self) { // Move everything up one row (16 pixels) let row_bytes = self.pitch * 16; let total = self.pitch * self.height; @@ -167,40 +131,44 @@ impl Framebuffer { ptr::write_volatile(last_row.add(i * 4) as *mut u32, self.bg); } } - self.cursor_y -= 1; } +} - fn draw_glyph(&self, glyph_idx: usize, col: usize, row: usize) { +/// Blit one glyph cell at (col, row) with explicit fg/bg colours. +pub(crate) fn blit_glyph(col: usize, row: usize, ch: u16, fg: (u8, u8, u8), bg: (u8, u8, u8)) { + if let Some(fb) = unsafe { FB.as_ref() } { + let glyph_idx = if ch < 256 { ch as usize } else { 32 }; let glyph = font::GLYPH_8X16; let data = match glyph.get(glyph_idx) { Some(d) => *d, None => return, }; + let fg = rgb(fg.0, fg.1, fg.2); + let bg = rgb(bg.0, bg.1, bg.2); let x = col * 8; let y = row * 16; for cy in 0..16 { let row_bits = data[cy]; for cx in 0..8 { - if row_bits & (0x80 >> cx) != 0 { - // Set foreground pixel - let offset = (y + cy) * self.pitch + (x + cx) * self.bytes_per_pixel; - unsafe { - ptr::write_volatile(self.base.add(offset) as *mut u32, self.fg); - } - } else { - // Set background pixel - let offset = (y + cy) * self.pitch + (x + cx) * self.bytes_per_pixel; - unsafe { - ptr::write_volatile(self.base.add(offset) as *mut u32, self.bg); - } + let offset = (y + cy) * fb.pitch + (x + cx) * fb.bytes_per_pixel; + unsafe { + let px = if row_bits & (0x80 >> cx) != 0 { fg } else { bg }; + ptr::write_volatile(fb.base.add(offset) as *mut u32, px); } } } } } +/// Shift the framebuffer up one row and clear the bottom row. +pub(crate) fn shift_up() { + if let Some(fb) = unsafe { FB.as_ref() } { + fb.shift_up(); + } +} + // --------------------------------------------------------------------------- // fmt::Write wrapper // --------------------------------------------------------------------------- diff --git a/rsnix/src/lib.rs b/rsnix/src/lib.rs index bb0a3eb..a8e279a 100644 --- a/rsnix/src/lib.rs +++ b/rsnix/src/lib.rs @@ -13,4 +13,7 @@ pub mod power; pub mod ps2; pub mod rtc; pub mod serial; +pub mod strbuf; +pub mod tty; pub mod uefi; +pub mod vt; diff --git a/rsnix/src/ps2.rs b/rsnix/src/ps2.rs index 1b10b3b..8dd58a0 100644 --- a/rsnix/src/ps2.rs +++ b/rsnix/src/ps2.rs @@ -22,8 +22,10 @@ pub enum KeyEvent { Backspace, Tab, Escape, - CtrlC, - CtrlD, + /// Ctrl + key (e.g. Ctrl('c') = ^C, Ctrl('s') = ^S). + Ctrl(char), + /// Ctrl + arrow (0=up, 1=down, 2=left, 3=right) — word/page nav. + CtrlArrow(u8), ArrowUp, ArrowDown, ArrowLeft, @@ -306,13 +308,14 @@ fn process_byte(byte: u8) -> Option { ch.to_ascii_uppercase() }; } - // Ctrl+letter — the shell uses ^C (interrupt) and ^D (EOF). + // Ctrl+key — the terminal control-byte protocol (^C, ^D, ^S, ^X, …). let ctrl = state.modifiers & (MOD_LCTRL | MOD_RCTRL) != 0; if ctrl { - return match ch.to_ascii_lowercase() { - b'c' => Some(KeyEvent::CtrlC), - b'd' => Some(KeyEvent::CtrlD), - _ => None, // swallow other Ctrl+key combinations + let lc = ch.to_ascii_lowercase(); + return if lc.is_ascii_graphic() { + Some(KeyEvent::Ctrl(lc as char)) + } else { + None // swallow non-graphic Ctrl combos }; } if ch != 0 { @@ -334,10 +337,10 @@ fn process_e0(byte: u8) -> Option { update_modifiers(byte, true, true); match byte { - 0x48 => Some(KeyEvent::ArrowUp), - 0x50 => Some(KeyEvent::ArrowDown), - 0x4B => Some(KeyEvent::ArrowLeft), - 0x4D => Some(KeyEvent::ArrowRight), + 0x48 => Some(if ctrl_held() { KeyEvent::CtrlArrow(0) } else { KeyEvent::ArrowUp }), + 0x50 => Some(if ctrl_held() { KeyEvent::CtrlArrow(1) } else { KeyEvent::ArrowDown }), + 0x4B => Some(if ctrl_held() { KeyEvent::CtrlArrow(2) } else { KeyEvent::ArrowLeft }), + 0x4D => Some(if ctrl_held() { KeyEvent::CtrlArrow(3) } else { KeyEvent::ArrowRight }), 0x47 => Some(KeyEvent::Home), 0x4F => Some(KeyEvent::End), 0x49 => Some(KeyEvent::PageUp), @@ -371,3 +374,9 @@ fn set_mod(state: &mut KbdState, bit: u8, pressed: bool) { state.modifiers &= !bit; } } + +/// Is either Ctrl key held? +fn ctrl_held() -> bool { + let state = unsafe { &mut KBD }; + state.modifiers & (MOD_LCTRL | MOD_RCTRL) != 0 +} diff --git a/rsnix/src/strbuf.rs b/rsnix/src/strbuf.rs new file mode 100644 index 0000000..262720d --- /dev/null +++ b/rsnix/src/strbuf.rs @@ -0,0 +1,71 @@ +//! Fixed-capacity ASCII string, allocated entirely on the stack. +//! Shared by the line discipline (`tty`) and the shell. + +use core::fmt; + +/// A fixed-capacity string allocated entirely on the stack. +pub struct String { + buf: [u8; N], + len: usize, +} + +impl Default for String { + fn default() -> Self { + Self::new() + } +} + +impl String { + pub const fn new() -> Self { + String { + buf: [0u8; N], + len: 0, + } + } + + pub fn as_str(&self) -> &str { + // SAFETY: we only write valid ASCII bytes. + unsafe { core::str::from_utf8_unchecked(&self.buf[..self.len]) } + } + + pub fn push(&mut self, c: char) -> Result<(), ()> { + if self.len >= N { + return Err(()); + } + self.buf[self.len] = c as u8; + self.len += 1; + Ok(()) + } + + pub fn pop(&mut self) -> Option { + if self.len == 0 { + return None; + } + self.len -= 1; + Some(self.buf[self.len] as char) + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + pub fn clear(&mut self) { + self.len = 0; + } + + pub fn trim(&self) -> &str { + self.as_str().trim() + } +} + +impl fmt::Display for String { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl fmt::Debug for String { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self.as_str()) + } +} diff --git a/rsnix/src/tty.rs b/rsnix/src/tty.rs new file mode 100644 index 0000000..8d745b3 --- /dev/null +++ b/rsnix/src/tty.rs @@ -0,0 +1,239 @@ +//! Line discipline over the PS/2 keyboard — the kernel's tty layer. +//! +//! Two modes, like a real UNIX terminal: +//! - **canonical** (`read_line`): input is echoed, buffered until Enter, +//! ^C cancels the line, ^D on an empty line is EOF. +//! - **raw** (`read_byte` / `try_read_byte`): keys are translated to the +//! classic terminal byte protocol (arrows → `ESC [ A–D`, Backspace → +//! `0x7f`, ^X → `0x18`, …) and delivered one byte at a time, with no echo. +//! Multi-byte sequences are queued internally so `read_byte` returns them +//! in order. +//! +//! `interrupt_pressed` is the cooperative-interrupt check used by streaming +//! commands (cat/hexdump): it drains the keyboard non-blockingly and reports +//! whether ^C was seen. + +use crate::ps2::{self, KeyEvent}; +use crate::strbuf; +use crate::{gop, serial}; + +pub const LINE_BUF_SIZE: usize = 256; + +/// Result of one canonical line-input session. +pub enum LineInput { + Line(strbuf::String), + Interrupted, // ^C + Eof, // ^D on an empty line +} + +// Raw-mode byte queue: holds the tail of a multi-byte key sequence. +static mut RAW_QUEUE: [u8; 8] = [0; 8]; +static mut RAW_LEN: usize = 0; + +// --------------------------------------------------------------------------- +// canonical mode +// --------------------------------------------------------------------------- + +/// Read one line in canonical mode: echo every key, ^C cancels the line, +/// ^D on an empty line is EOF. Echo goes to the terminal (GOP) and serial. +pub fn read_line() -> LineInput { + let mut buf = strbuf::String::::new(); + loop { + match ps2::poll_key() { + KeyEvent::Enter => { + gop::put_char('\n'); + serial::write_str("\r\n"); + return LineInput::Line(buf); + } + KeyEvent::Ctrl('c') => { + // Cancel the current line — like a real terminal, echo ^C. + buf.clear(); + gop::put_char('^'); + gop::put_char('C'); + gop::put_char('\n'); + serial::write_str("^C\r\n"); + return LineInput::Interrupted; + } + KeyEvent::Ctrl('d') => { + if buf.is_empty() { + // EOF on an empty line — the caller decides what to do. + gop::put_char('^'); + gop::put_char('D'); + gop::put_char('\n'); + serial::write_str("^D\r\n"); + return LineInput::Eof; + } + // ^D with text on the line: ignored (bash semantics). + } + KeyEvent::Char(c) if c.is_ascii_graphic() || c == ' ' => { + if buf.push(c).is_ok() { + gop::put_char(c); + let mut s = [0u8; 4]; + let s = c.encode_utf8(&mut s); + serial::write_str(s); + } + } + KeyEvent::Backspace => { + if buf.pop().is_some() { + gop::put_char('\x08'); + // Serial backspace: BS SP BS (standard terminal protocol) + serial::write_str("\x08 \x08"); + } + } + KeyEvent::Tab => { + for _ in 0..4 { + if buf.push(' ').is_ok() { + gop::put_char(' '); + serial::write_str(" "); + } + } + } + _ => {} + } + } +} + +/// Non-blocking drain of the keyboard: true if a ^C is pending. Used by +/// streaming commands (cat/hexdump) so a long dump can be interrupted. +pub fn interrupt_pressed() -> bool { + let mut pressed = false; + while let Some(ev) = ps2::try_poll_key() { + if matches!(ev, KeyEvent::Ctrl('c')) { + pressed = true; + } + } + pressed +} + +// --------------------------------------------------------------------------- +// raw mode +// --------------------------------------------------------------------------- + +/// Blocking read of the next raw-mode byte. Keys arrive translated to the +/// terminal byte protocol; multi-byte sequences are delivered in order. +pub fn read_byte() -> u8 { + loop { + if let Some(b) = pop_raw() { + return b; + } + let ev = ps2::poll_key(); + enqueue_key(ev); + } +} + +/// Non-blocking raw read: Some(byte) if one is available, else None. +pub fn try_read_byte() -> Option { + if let Some(b) = pop_raw() { + return Some(b); + } + match ps2::try_poll_key() { + Some(ev) => { + enqueue_key(ev); + pop_raw() + } + None => None, + } +} + +fn pop_raw() -> Option { + unsafe { + if RAW_LEN == 0 { + return None; + } + let b = RAW_QUEUE[0]; + for i in 1..RAW_LEN { + RAW_QUEUE[i - 1] = RAW_QUEUE[i]; + } + RAW_LEN -= 1; + Some(b) + } +} + +fn enqueue_key(ev: KeyEvent) { + let mut q = [0u8; 8]; + let n: usize = match ev { + KeyEvent::Char(c) if c.is_ascii() => { + q[0] = c as u8; + 1 + } + KeyEvent::Enter => { + q[0] = 0x0d; + 1 + } + KeyEvent::Backspace => { + q[0] = 0x7f; + 1 + } + KeyEvent::Tab => { + q[0] = 0x09; + 1 + } + KeyEvent::Escape => { + q[0] = 0x1b; + 1 + } + KeyEvent::Ctrl(c) => { + q[0] = (c as u8) & 0x1f; + 1 + } + KeyEvent::ArrowUp => { + q[..3].copy_from_slice(b"\x1b[A"); + 3 + } + KeyEvent::ArrowDown => { + q[..3].copy_from_slice(b"\x1b[B"); + 3 + } + KeyEvent::ArrowRight => { + q[..3].copy_from_slice(b"\x1b[C"); + 3 + } + KeyEvent::ArrowLeft => { + q[..3].copy_from_slice(b"\x1b[D"); + 3 + } + KeyEvent::CtrlArrow(0) => { + q[..6].copy_from_slice(b"\x1b[1;5A"); + 6 + } + KeyEvent::CtrlArrow(1) => { + q[..6].copy_from_slice(b"\x1b[1;5B"); + 6 + } + KeyEvent::CtrlArrow(2) => { + q[..6].copy_from_slice(b"\x1b[1;5D"); + 6 + } + KeyEvent::CtrlArrow(3) => { + q[..6].copy_from_slice(b"\x1b[1;5C"); + 6 + } + KeyEvent::Home => { + q[..3].copy_from_slice(b"\x1b[H"); + 3 + } + KeyEvent::End => { + q[..3].copy_from_slice(b"\x1b[F"); + 3 + } + KeyEvent::PageUp => { + q[..4].copy_from_slice(b"\x1b[5~"); + 4 + } + KeyEvent::PageDown => { + q[..4].copy_from_slice(b"\x1b[6~"); + 4 + } + KeyEvent::Delete => { + q[..4].copy_from_slice(b"\x1b[3~"); + 4 + } + _ => return, + }; + unsafe { + for (i, b) in q.iter().take(n).enumerate() { + RAW_QUEUE[i] = *b; + } + RAW_LEN = n; + } +} diff --git a/rsnix/src/vt.rs b/rsnix/src/vt.rs new file mode 100644 index 0000000..eb60e11 --- /dev/null +++ b/rsnix/src/vt.rs @@ -0,0 +1,476 @@ +//! Minimal ANSI terminal emulator over the GOP framebuffer. +//! +//! `vt` owns the text screen: a cell grid (char + fg/bg colour), a logical +//! cursor, scrolling, and a small CSI parser. It renders to the framebuffer +//! through `gop`, blitting only cells whose content changed since the last +//! flush. +//! +//! This is the kernel's "terminal". Plain text (shell output, typed echo) +//! flows through here in *ground* state; full-screen apps (rsdosedit) write +//! ANSI escape sequences that the CSI parser interprets — cursor addressing, +//! erase, 16-colour SGR, cursor show/hide. +//! +//! Known perf note: scrolling shifts the whole framebuffer one row up (a +//! ~3 MB copy at 1024×768), which dominates under QEMU without KVM. A ring +//! buffer / deferred-pan approach is the future optimisation; the cell grid +//! itself never re-rasterises on scroll — only the exposed bottom row is +//! blitted. + +use crate::gop; +use alloc::boxed::Box; +use alloc::vec; +use alloc::vec::Vec; + +/// Standard VGA 16-colour palette (r, g, b). +pub const PALETTE: [(u8, u8, u8); 16] = [ + (0, 0, 0), + (0, 0, 170), + (0, 170, 0), + (0, 170, 170), + (170, 0, 0), + (170, 0, 170), + (170, 85, 0), + (170, 170, 170), + (85, 85, 85), + (85, 85, 255), + (85, 255, 85), + (85, 255, 255), + (255, 85, 85), + (255, 85, 255), + (255, 255, 85), + (255, 255, 255), +]; + +#[derive(Clone, Copy, PartialEq, Eq)] +struct Cell { + ch: u16, + fg: u8, + bg: u8, +} + +const BLANK: Cell = Cell { ch: 32, fg: 7, bg: 0 }; + +// ---- screen state ---------------------------------------------------------- + +static mut CELLS: *mut Vec = core::ptr::null_mut(); +/// What is currently on the physical framebuffer (used to diff blits). +static mut RENDERED: *mut Vec = core::ptr::null_mut(); +static mut ROWS: usize = 0; +static mut COLS: usize = 0; + +// ---- cursor ---------------------------------------------------------------- + +static mut CURSOR_R: usize = 0; +static mut CURSOR_C: usize = 0; +static mut LAST_CURSOR: (usize, usize) = (0, 0); +static mut CURSOR_VISIBLE: bool = true; + +// ---- SGR state ------------------------------------------------------------- + +static mut SGR_FG: u8 = 7; +static mut SGR_BG: u8 = 0; + +// ---- ANSI parser state ----------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Parser { + Ground, + Escape, + Csi, +} + +static mut PARSER: Parser = Parser::Ground; +static mut PRIVATE: bool = false; +static mut PARAMS: [usize; 4] = [0; 4]; +static mut NPARAMS: usize = 0; +static mut CUR: usize = 0; + +// --------------------------------------------------------------------------- +// init / dimensions +// --------------------------------------------------------------------------- + +/// Allocate the cell grid. Call once after `gop::init()`. +pub fn init() { + let (cols, rows) = gop::dims(); + unsafe { + ROWS = rows; + COLS = cols; + CELLS = Box::into_raw(Box::new(vec![BLANK; rows * cols])); + RENDERED = Box::into_raw(Box::new(vec![BLANK; rows * cols])); + CURSOR_R = 0; + CURSOR_C = 0; + LAST_CURSOR = (0, 0); + CURSOR_VISIBLE = true; + SGR_FG = 7; + SGR_BG = 0; + PARSER = Parser::Ground; + NPARAMS = 0; + PRIVATE = false; + } +} + +/// (rows, cols) — matches the editor's expected (rows, cols) ordering. +pub fn size() -> (usize, usize) { + unsafe { (ROWS, COLS) } +} + +pub fn set_cursor_visible(v: bool) { + unsafe { CURSOR_VISIBLE = v; } + flush(); +} + +// --------------------------------------------------------------------------- +// cell access +// --------------------------------------------------------------------------- + +fn idx(r: usize, c: usize) -> usize { + r * unsafe { COLS } + c +} + +fn cells() -> &'static mut Vec { + unsafe { &mut *CELLS } +} + +fn rendered_cells() -> &'static mut Vec { + unsafe { &mut *RENDERED } +} + +fn cell(r: usize, c: usize) -> &'static mut Cell { + &mut cells()[idx(r, c)] +} + +fn rendered(r: usize, c: usize) -> &'static mut Cell { + &mut rendered_cells()[idx(r, c)] +} + +fn put_cell(r: usize, c: usize, ch: u16) { + let cell = cell(r, c); + cell.ch = ch; + cell.fg = unsafe { SGR_FG }; + cell.bg = unsafe { SGR_BG }; +} + +// --------------------------------------------------------------------------- +// writing — the ANSI state machine +// --------------------------------------------------------------------------- + +/// Feed one byte through the terminal state machine. +fn put_byte(b: u8) { + unsafe { + match PARSER { + Parser::Ground => match b { + 0x1b => PARSER = Parser::Escape, + 0x0d => CURSOR_C = 0, + 0x0a => newline(), + 0x08 => { + // Backspace: move cursor back and erase. + if CURSOR_C > 0 { + CURSOR_C -= 1; + } else if CURSOR_R > 0 { + CURSOR_R -= 1; + CURSOR_C = COLS - 1; + } else { + return; + } + let c = cell(CURSOR_R, CURSOR_C); + *c = BLANK; + } + 0x09 => { + let n = 8 - (CURSOR_C % 8); + CURSOR_C = (CURSOR_C + n).min(COLS - 1); + } + 0x00..=0x1f => {} // other control chars: ignored + _ => { + let ch = if b < 0x80 { b as u16 } else { b'?' as u16 }; + put_cell(CURSOR_R, CURSOR_C, ch); + CURSOR_C += 1; + if CURSOR_C >= COLS { + CURSOR_C = 0; + newline(); + } + } + }, + Parser::Escape => match b { + b'[' => { + PRIVATE = false; + NPARAMS = 0; + CUR = 0; + PARSER = Parser::Csi; + } + _ => PARSER = Parser::Ground, // unsupported escape — drop + }, + Parser::Csi => match b { + b'?' => PRIVATE = true, + b'0'..=b'9' => { + CUR = CUR.saturating_mul(10).saturating_add((b - b'0') as usize); + } + b';' => { + if NPARAMS < PARAMS.len() { + PARAMS[NPARAMS] = CUR; + NPARAMS += 1; + } + CUR = 0; + } + _ => { + if NPARAMS < PARAMS.len() { + PARAMS[NPARAMS] = CUR; + NPARAMS += 1; + } + PARSER = Parser::Ground; + csi_final(b); + } + }, + } + } +} + +fn param(i: usize, default: usize) -> usize { + unsafe { + if i < NPARAMS { + PARAMS[i] + } else { + default + } + } +} + +fn csi_final(b: u8) { + match b { + // Cursor position (1-based; 0 means 1) + b'H' | b'f' => { + let r = param(0, 1).max(1).saturating_sub(1); + let c = param(1, 1).max(1).saturating_sub(1); + unsafe { + CURSOR_R = r.min(ROWS - 1); + CURSOR_C = c.min(COLS - 1); + } + } + // Cursor up / down / forward / back + b'A' => unsafe { CURSOR_R = CURSOR_R.saturating_sub(param(0, 1).max(1)) }, + b'B' => unsafe { CURSOR_R = (CURSOR_R + param(0, 1).max(1)).min(ROWS - 1) }, + b'C' => unsafe { CURSOR_C = (CURSOR_C + param(0, 1).max(1)).min(COLS - 1) }, + b'D' => unsafe { CURSOR_C = CURSOR_C.saturating_sub(param(0, 1).max(1)) }, + // Erase in display: 0 below, 1 above, 2 all + b'J' => match param(0, 0) { + 0 => { + erase_eol(); + unsafe { + for r in (CURSOR_R + 1)..ROWS { + erase_row(r); + } + } + } + 1 => { + unsafe { + for r in 0..CURSOR_R { + erase_row(r); + } + } + erase_bol(); + } + _ => erase_all(), + }, + // Erase in line: 0 to EOL, 1 from BOL, 2 whole line + b'K' => match param(0, 0) { + 1 => erase_bol(), + 2 => { + let r = unsafe { CURSOR_R }; + erase_row(r); + } + _ => erase_eol(), + }, + // SGR — colours / attributes + b'm' => { + let n = unsafe { NPARAMS }; + if n == 0 { + apply_sgr(0); + } else { + for i in 0..n { + apply_sgr(unsafe { PARAMS[i] }); + } + } + } + // Private mode: 25 = cursor show/hide (h = show, l = hide) + b'l' | b'h' if unsafe { PRIVATE } => { + for i in 0..unsafe { NPARAMS } { + if unsafe { PARAMS[i] } == 25 { + unsafe { CURSOR_VISIBLE = b == b'h' }; + } + } + } + _ => {} // unknown / ignored + } +} + +/// ANSI SGR colour index (0–7) → VGA palette index. The two orderings +/// differ: ANSI 1=red, 3=yellow, 4=blue, 6=cyan; VGA 1=blue, 3=cyan, +/// 4=red, 6=brown. Terminals translate ANSI→VGA. +const ANSI_TO_VGA: [u8; 8] = [0, 4, 2, 6, 1, 5, 3, 7]; + +fn apply_sgr(p: usize) { + unsafe { + match p { + 0 => { + SGR_FG = 7; + SGR_BG = 0; + } + 1 => { + if SGR_FG < 8 { + SGR_FG += 8; // bold ≈ bright + } + } + 30..=37 => SGR_FG = ANSI_TO_VGA[p - 30], + 40..=47 => SGR_BG = ANSI_TO_VGA[p - 40], + 90..=97 => SGR_FG = ANSI_TO_VGA[p - 90] + 8, + 100..=107 => SGR_BG = ANSI_TO_VGA[p - 100] + 8, + 39 => SGR_FG = 7, + 49 => SGR_BG = 0, + _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// erase / scroll helpers +// --------------------------------------------------------------------------- + +fn erase_eol() { + let r = unsafe { CURSOR_R }; + let c = unsafe { CURSOR_C }; + for col in c..unsafe { COLS } { + *cell(r, col) = BLANK; + } +} + +fn erase_bol() { + let r = unsafe { CURSOR_R }; + let c = unsafe { CURSOR_C }; + for col in 0..=c { + *cell(r, col) = BLANK; + } +} + +fn erase_row(r: usize) { + for col in 0..unsafe { COLS } { + *cell(r, col) = BLANK; + } +} + +fn erase_all() { + for r in 0..unsafe { ROWS } { + erase_row(r); + } +} + +/// Advance cursor to the next line (col 0), scrolling if at the bottom. +fn newline() { + unsafe { + CURSOR_C = 0; + if CURSOR_R + 1 < ROWS { + CURSOR_R += 1; + } else { + scroll_up(); + } + } +} + +/// Shift the whole screen up one row: cell grids in memory, pixels on the +/// framebuffer. Only the exposed bottom row gets re-rasterised by `flush`. +fn scroll_up() { + let n = unsafe { ROWS * COLS }; + let cols = unsafe { COLS }; + cells().copy_within(cols..n, 0); + rendered_cells().copy_within(cols..n, 0); + for c in 0..cols { + cells()[n - cols + c] = BLANK; + rendered_cells()[n - cols + c] = BLANK; + } + gop::shift_up(); +} + +// --------------------------------------------------------------------------- +// public writing API +// --------------------------------------------------------------------------- + +/// Write raw bytes (ANSI-capable). Used by full-screen apps. +pub fn write_bytes(b: &[u8]) { + for &x in b { + put_byte(x); + } + flush(); +} + +/// Write a UTF-8 string (plain text; non-ASCII becomes '?'). +pub fn write_str(s: &str) { + for c in s.chars() { + let b = if (c as u32) < 0x80 { c as u8 } else { b'?' }; + put_byte(b); + } + flush(); +} + +/// Write a single ASCII byte / char. +pub fn put_char(b: u8) { + put_byte(b); + flush(); +} + +/// Clear the whole screen, reset colours, home the cursor. +pub fn clear_all() { + unsafe { + SGR_FG = 7; + SGR_BG = 0; + erase_all(); + CURSOR_R = 0; + CURSOR_C = 0; + } + flush(); +} + +// --------------------------------------------------------------------------- +// rendering +// --------------------------------------------------------------------------- + +/// Blit every cell whose content differs from what the framebuffer shows, +/// then draw the logical cursor (inverse video) on top. +fn flush() { + let rows = unsafe { ROWS }; + let cols = unsafe { COLS }; + let n = rows * cols; + { + let cells = cells(); + let rendered = rendered_cells(); + for i in 0..n { + let c = cells[i]; + let r = rendered[i]; + if c != r { + let row = i / cols; + let col = i % cols; + let fg = PALETTE[c.fg as usize]; + let bg = PALETTE[c.bg as usize]; + gop::blit_glyph(col, row, c.ch, fg, bg); + rendered[i] = c; + } + } + } + + unsafe { + let (lr, lc) = LAST_CURSOR; + let moved = (lr, lc) != (CURSOR_R, CURSOR_C); + // Erase the previous cursor position (restore its normal colours). + if (moved || !CURSOR_VISIBLE) && lr < rows && lc < cols { + let c = cells()[idx(lr, lc)]; + let fg = PALETTE[c.fg as usize]; + let bg = PALETTE[c.bg as usize]; + gop::blit_glyph(lc, lr, c.ch, fg, bg); + } + LAST_CURSOR = (CURSOR_R, CURSOR_C); + // Draw the cursor as an inverse-video cell. + if CURSOR_VISIBLE && CURSOR_R < rows && CURSOR_C < cols { + let c = cells()[idx(CURSOR_R, CURSOR_C)]; + let fg = PALETTE[c.fg as usize]; + let bg = PALETTE[c.bg as usize]; + gop::blit_glyph(CURSOR_C, CURSOR_R, c.ch, bg, fg); + } + } +} 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 [dependencies] rsnix = { path = "../rsnix" } +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; use alloc::string::ToString; /// Minimal command shell — reads from PS/2 keyboard, writes to GOP + serial. -use rsnix::ps2::{self, KeyEvent}; +use rsnix::tty::{self, LineInput}; use rsnix::{gop_print, gop_println, serial_print, serial_println}; -/// Maximum input line length. -const LINE_BUF_SIZE: usize = 256; - /// Run the shell forever. pub fn run() -> ! { gop_println!( @@ -27,8 +24,8 @@ pub fn run() -> ! { gop_print!("> "); serial_print!("> "); - // Read one line from keyboard - match read_line() { + // Read one line from keyboard (tty canonical mode) + match tty::read_line() { LineInput::Interrupted => continue, // ^C — fresh prompt LineInput::Eof => { // ^D on an empty line: EOF, exit the shell. @@ -65,6 +62,7 @@ pub fn run() -> ! { "mkdir" => cmd_mkdir(args), "touch" => cmd_touch(args), "rm" => cmd_rm(args), + "edit" => cmd_edit(args), _ => { gop_println!("unknown command: {}", cmd); serial_println!("unknown command: {}", cmd); @@ -75,74 +73,9 @@ pub fn run() -> ! { } } -/// Result of one line-input session. -enum LineInput { - Line(heapless_str::String), - Interrupted, // ^C - Eof, // ^D on an empty line -} - // --------------------------------------------------------------------------- -// Line input (PS/2 keyboard, no heap) +// Line input is handled by the tty line discipline (rsnix::tty). // --------------------------------------------------------------------------- -fn read_line() -> LineInput { - let mut buf = heapless_str::String::::new(); - loop { - match ps2::poll_key() { - KeyEvent::Enter => { - rsnix::gop::put_char('\n'); - rsnix::serial::write_str("\r\n"); - return LineInput::Line(buf); - } - KeyEvent::CtrlC => { - // Cancel the current line — like a real terminal, echo ^C. - buf.clear(); - rsnix::gop::put_char('^'); - rsnix::gop::put_char('C'); - rsnix::gop::put_char('\n'); - rsnix::serial::write_str("^C\r\n"); - return LineInput::Interrupted; - } - KeyEvent::CtrlD => { - if buf.is_empty() { - // EOF on an empty line — exit the shell. - rsnix::gop::put_char('^'); - rsnix::gop::put_char('D'); - rsnix::gop::put_char('\n'); - rsnix::serial::write_str("^D\r\n"); - return LineInput::Eof; - } - // ^D with text on the line: ignored (bash semantics). - } - KeyEvent::Char(c) if c.is_ascii_graphic() || c == ' ' => { - if buf.push(c).is_ok() { - rsnix::gop::put_char(c); - // Echo to serial too - let mut s = [0u8; 4]; - let s = c.encode_utf8(&mut s); - rsnix::serial::write_str(s); - } - } - KeyEvent::Backspace => { - if buf.pop().is_some() { - // GOP backspace: move cursor back and erase - rsnix::gop::put_char('\x08'); - // Serial backspace: send BS SP BS (standard terminal protocol) - rsnix::serial::write_str("\x08 \x08"); - } - } - KeyEvent::Tab => { - for _ in 0..4 { - if buf.push(' ').is_ok() { - rsnix::gop::put_char(' '); - rsnix::serial::write_str(" "); - } - } - } - _ => {} - } - } -} /// Split a command line into the first word and the rest. fn split_first_word(s: &str) -> (&str, &str) { @@ -176,13 +109,14 @@ fn cmd_help() { gop_println!(" mkdir — create a directory"); gop_println!(" touch — create an empty file"); gop_println!(" rm — remove a file or empty dir"); + gop_println!(" edit — full-screen editor (^S save, ^X exit, ^W search)"); gop_println!(" echo > /dev/null, > file, >> file"); gop_println!(" halt — halt the system (hlt loop)"); gop_println!(" poweroff — shut down via ACPI (ResetSystem)"); gop_println!(" exit — alias for poweroff"); serial_println!("available commands:"); serial_println!( - " help, echo, clear, hello, info, mem, mmap, stress, date, pci, ls, cat, hexdump, mkdir, touch, rm, halt, poweroff, exit" + " help, echo, clear, hello, info, mem, mmap, stress, date, pci, ls, cat, hexdump, mkdir, touch, rm, edit, halt, poweroff, exit" ); } @@ -267,7 +201,7 @@ fn cmd_cat(args: &str) { Some(h) => { let mut pos = 0u64; loop { - if interrupt_pressed() { + if rsnix::tty::interrupt_pressed() { gop_println!("^C"); serial_println!("^C"); return; @@ -305,7 +239,7 @@ fn cmd_hexdump(args: &str) { let mut buf = [0u8; 512]; let mut pos = 0u64; loop { - if interrupt_pressed() { + if rsnix::tty::interrupt_pressed() { gop_println!("^C"); serial_println!("^C"); return; @@ -345,16 +279,26 @@ fn cmd_hexdump(args: &str) { } } -/// Returns true if the user pressed ^C since the last check; drains the -/// keyboard buffer of any other buffered keys. -fn interrupt_pressed() -> bool { - let mut pressed = false; - while let Some(ev) = ps2::try_poll_key() { - if matches!(ev, KeyEvent::CtrlC) { - pressed = true; - } + +// --------------------------------------------------------------------------- +// edit — run the built-in editor (rsdosedit) on a file +// --------------------------------------------------------------------------- + +fn cmd_edit(args: &str) { + let path = args.trim(); + if path.is_empty() { + gop_println!("usage: edit "); + serial_println!("usage: edit "); + return; + } + if rsnix::fs::ensure_file(path).is_err() { + gop_println!("edit: cannot create {}", path); + serial_println!("edit: cannot create {}", path); + return; } - pressed + rsnix::gop::clear_screen(); + rsdosedit::run(path); + rsnix::gop::clear_screen(); } fn cmd_mkdir(args: &str) { @@ -682,71 +626,3 @@ fn cmd_poweroff() -> ! { serial_println!("shutting down..."); rsnix::power::poweroff() } - -// --------------------------------------------------------------------------- -// Minimal fixed-capacity string for no-stack use (avoids alloc dependency) -// --------------------------------------------------------------------------- -mod heapless_str { - use core::fmt; - - /// A fixed-capacity string allocated entirely on the stack. - pub struct String { - buf: [u8; N], - len: usize, - } - - impl String { - pub const fn new() -> Self { - String { - buf: [0u8; N], - len: 0, - } - } - - pub fn as_str(&self) -> &str { - // SAFETY: we only write valid ASCII bytes. - unsafe { core::str::from_utf8_unchecked(&self.buf[..self.len]) } - } - - pub fn push(&mut self, c: char) -> Result<(), ()> { - if self.len >= N { - return Err(()); - } - self.buf[self.len] = c as u8; - self.len += 1; - Ok(()) - } - - pub fn pop(&mut self) -> Option { - if self.len == 0 { - return None; - } - self.len -= 1; - Some(self.buf[self.len] as char) - } - - pub fn is_empty(&self) -> bool { - self.len == 0 - } - - pub fn clear(&mut self) { - self.len = 0; - } - - pub fn trim(&self) -> &str { - self.as_str().trim() - } - } - - impl fmt::Display for String { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) - } - } - - impl fmt::Debug for String { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", self.as_str()) - } - } -} -- cgit v1.2.3