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