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