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. --- rsdosedit/src/lib.rs | 1251 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1251 insertions(+) create mode 100644 rsdosedit/src/lib.rs (limited to 'rsdosedit/src') 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(()) +} -- cgit v1.2.3