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 /shell/src | |
| 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 'shell/src')
| -rw-r--r-- | shell/src/lib.rs | 180 |
1 files changed, 28 insertions, 152 deletions
diff --git a/shell/src/lib.rs b/shell/src/lib.rs index 299956d..ec2474d 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs | |||
| @@ -5,12 +5,9 @@ extern crate alloc; | |||
| 5 | use alloc::string::ToString; | 5 | use alloc::string::ToString; |
| 6 | 6 | ||
| 7 | /// Minimal command shell — reads from PS/2 keyboard, writes to GOP + serial. | 7 | /// Minimal command shell — reads from PS/2 keyboard, writes to GOP + serial. |
| 8 | use rsnix::ps2::{self, KeyEvent}; | 8 | use rsnix::tty::{self, LineInput}; |
| 9 | use rsnix::{gop_print, gop_println, serial_print, serial_println}; | 9 | use rsnix::{gop_print, gop_println, serial_print, serial_println}; |
| 10 | 10 | ||
| 11 | /// Maximum input line length. | ||
| 12 | const LINE_BUF_SIZE: usize = 256; | ||
| 13 | |||
| 14 | /// Run the shell forever. | 11 | /// Run the shell forever. |
| 15 | pub fn run() -> ! { | 12 | pub fn run() -> ! { |
| 16 | gop_println!( | 13 | gop_println!( |
| @@ -27,8 +24,8 @@ pub fn run() -> ! { | |||
| 27 | gop_print!("> "); | 24 | gop_print!("> "); |
| 28 | serial_print!("> "); | 25 | serial_print!("> "); |
| 29 | 26 | ||
| 30 | // Read one line from keyboard | 27 | // Read one line from keyboard (tty canonical mode) |
| 31 | match read_line() { | 28 | match tty::read_line() { |
| 32 | LineInput::Interrupted => continue, // ^C — fresh prompt | 29 | LineInput::Interrupted => continue, // ^C — fresh prompt |
| 33 | LineInput::Eof => { | 30 | LineInput::Eof => { |
| 34 | // ^D on an empty line: EOF, exit the shell. | 31 | // ^D on an empty line: EOF, exit the shell. |
| @@ -65,6 +62,7 @@ pub fn run() -> ! { | |||
| 65 | "mkdir" => cmd_mkdir(args), | 62 | "mkdir" => cmd_mkdir(args), |
| 66 | "touch" => cmd_touch(args), | 63 | "touch" => cmd_touch(args), |
| 67 | "rm" => cmd_rm(args), | 64 | "rm" => cmd_rm(args), |
| 65 | "edit" => cmd_edit(args), | ||
| 68 | _ => { | 66 | _ => { |
| 69 | gop_println!("unknown command: {}", cmd); | 67 | gop_println!("unknown command: {}", cmd); |
| 70 | serial_println!("unknown command: {}", cmd); | 68 | serial_println!("unknown command: {}", cmd); |
| @@ -75,74 +73,9 @@ pub fn run() -> ! { | |||
| 75 | } | 73 | } |
| 76 | } | 74 | } |
| 77 | 75 | ||
| 78 | /// Result of one line-input session. | ||
| 79 | enum LineInput { | ||
| 80 | Line(heapless_str::String<LINE_BUF_SIZE>), | ||
| 81 | Interrupted, // ^C | ||
| 82 | Eof, // ^D on an empty line | ||
| 83 | } | ||
| 84 | |||
| 85 | // --------------------------------------------------------------------------- | 76 | // --------------------------------------------------------------------------- |
| 86 | // Line input (PS/2 keyboard, no heap) | 77 | // Line input is handled by the tty line discipline (rsnix::tty). |
| 87 | // --------------------------------------------------------------------------- | 78 | // --------------------------------------------------------------------------- |
| 88 | fn read_line() -> LineInput { | ||
| 89 | let mut buf = heapless_str::String::<LINE_BUF_SIZE>::new(); | ||
| 90 | loop { | ||
| 91 | match ps2::poll_key() { | ||
| 92 | KeyEvent::Enter => { | ||
| 93 | rsnix::gop::put_char('\n'); | ||
| 94 | rsnix::serial::write_str("\r\n"); | ||
| 95 | return LineInput::Line(buf); | ||
| 96 | } | ||
| 97 | KeyEvent::CtrlC => { | ||
| 98 | // Cancel the current line — like a real terminal, echo ^C. | ||
| 99 | buf.clear(); | ||
| 100 | rsnix::gop::put_char('^'); | ||
| 101 | rsnix::gop::put_char('C'); | ||
| 102 | rsnix::gop::put_char('\n'); | ||
| 103 | rsnix::serial::write_str("^C\r\n"); | ||
| 104 | return LineInput::Interrupted; | ||
| 105 | } | ||
| 106 | KeyEvent::CtrlD => { | ||
| 107 | if buf.is_empty() { | ||
| 108 | // EOF on an empty line — exit the shell. | ||
| 109 | rsnix::gop::put_char('^'); | ||
| 110 | rsnix::gop::put_char('D'); | ||
| 111 | rsnix::gop::put_char('\n'); | ||
| 112 | rsnix::serial::write_str("^D\r\n"); | ||
| 113 | return LineInput::Eof; | ||
| 114 | } | ||
| 115 | // ^D with text on the line: ignored (bash semantics). | ||
| 116 | } | ||
| 117 | KeyEvent::Char(c) if c.is_ascii_graphic() || c == ' ' => { | ||
| 118 | if buf.push(c).is_ok() { | ||
| 119 | rsnix::gop::put_char(c); | ||
| 120 | // Echo to serial too | ||
| 121 | let mut s = [0u8; 4]; | ||
| 122 | let s = c.encode_utf8(&mut s); | ||
| 123 | rsnix::serial::write_str(s); | ||
| 124 | } | ||
| 125 | } | ||
| 126 | KeyEvent::Backspace => { | ||
| 127 | if buf.pop().is_some() { | ||
| 128 | // GOP backspace: move cursor back and erase | ||
| 129 | rsnix::gop::put_char('\x08'); | ||
| 130 | // Serial backspace: send BS SP BS (standard terminal protocol) | ||
| 131 | rsnix::serial::write_str("\x08 \x08"); | ||
| 132 | } | ||
| 133 | } | ||
| 134 | KeyEvent::Tab => { | ||
| 135 | for _ in 0..4 { | ||
| 136 | if buf.push(' ').is_ok() { | ||
| 137 | rsnix::gop::put_char(' '); | ||
| 138 | rsnix::serial::write_str(" "); | ||
| 139 | } | ||
| 140 | } | ||
| 141 | } | ||
| 142 | _ => {} | ||
| 143 | } | ||
| 144 | } | ||
| 145 | } | ||
| 146 | 79 | ||
| 147 | /// Split a command line into the first word and the rest. | 80 | /// Split a command line into the first word and the rest. |
| 148 | fn split_first_word(s: &str) -> (&str, &str) { | 81 | fn split_first_word(s: &str) -> (&str, &str) { |
| @@ -176,13 +109,14 @@ fn cmd_help() { | |||
| 176 | gop_println!(" mkdir — create a directory"); | 109 | gop_println!(" mkdir — create a directory"); |
| 177 | gop_println!(" touch — create an empty file"); | 110 | gop_println!(" touch — create an empty file"); |
| 178 | gop_println!(" rm — remove a file or empty dir"); | 111 | gop_println!(" rm — remove a file or empty dir"); |
| 112 | gop_println!(" edit — full-screen editor (^S save, ^X exit, ^W search)"); | ||
| 179 | gop_println!(" echo > /dev/null, > file, >> file"); | 113 | gop_println!(" echo > /dev/null, > file, >> file"); |
| 180 | gop_println!(" halt — halt the system (hlt loop)"); | 114 | gop_println!(" halt — halt the system (hlt loop)"); |
| 181 | gop_println!(" poweroff — shut down via ACPI (ResetSystem)"); | 115 | gop_println!(" poweroff — shut down via ACPI (ResetSystem)"); |
| 182 | gop_println!(" exit — alias for poweroff"); | 116 | gop_println!(" exit — alias for poweroff"); |
| 183 | serial_println!("available commands:"); | 117 | serial_println!("available commands:"); |
| 184 | serial_println!( | 118 | serial_println!( |
| 185 | " help, echo, clear, hello, info, mem, mmap, stress, date, pci, ls, cat, hexdump, mkdir, touch, rm, halt, poweroff, exit" | 119 | " help, echo, clear, hello, info, mem, mmap, stress, date, pci, ls, cat, hexdump, mkdir, touch, rm, edit, halt, poweroff, exit" |
| 186 | ); | 120 | ); |
| 187 | } | 121 | } |
| 188 | 122 | ||
| @@ -267,7 +201,7 @@ fn cmd_cat(args: &str) { | |||
| 267 | Some(h) => { | 201 | Some(h) => { |
| 268 | let mut pos = 0u64; | 202 | let mut pos = 0u64; |
| 269 | loop { | 203 | loop { |
| 270 | if interrupt_pressed() { | 204 | if rsnix::tty::interrupt_pressed() { |
| 271 | gop_println!("^C"); | 205 | gop_println!("^C"); |
| 272 | serial_println!("^C"); | 206 | serial_println!("^C"); |
| 273 | return; | 207 | return; |
| @@ -305,7 +239,7 @@ fn cmd_hexdump(args: &str) { | |||
| 305 | let mut buf = [0u8; 512]; | 239 | let mut buf = [0u8; 512]; |
| 306 | let mut pos = 0u64; | 240 | let mut pos = 0u64; |
| 307 | loop { | 241 | loop { |
| 308 | if interrupt_pressed() { | 242 | if rsnix::tty::interrupt_pressed() { |
| 309 | gop_println!("^C"); | 243 | gop_println!("^C"); |
| 310 | serial_println!("^C"); | 244 | serial_println!("^C"); |
| 311 | return; | 245 | return; |
| @@ -345,16 +279,26 @@ fn cmd_hexdump(args: &str) { | |||
| 345 | } | 279 | } |
| 346 | } | 280 | } |
| 347 | 281 | ||
| 348 | /// Returns true if the user pressed ^C since the last check; drains the | 282 | |
| 349 | /// keyboard buffer of any other buffered keys. | 283 | // --------------------------------------------------------------------------- |
| 350 | fn interrupt_pressed() -> bool { | 284 | // edit — run the built-in editor (rsdosedit) on a file |
| 351 | let mut pressed = false; | 285 | // --------------------------------------------------------------------------- |
| 352 | while let Some(ev) = ps2::try_poll_key() { | 286 | |
| 353 | if matches!(ev, KeyEvent::CtrlC) { | 287 | fn cmd_edit(args: &str) { |
| 354 | pressed = true; | 288 | let path = args.trim(); |
| 355 | } | 289 | if path.is_empty() { |
| 290 | gop_println!("usage: edit <path>"); | ||
| 291 | serial_println!("usage: edit <path>"); | ||
| 292 | return; | ||
| 293 | } | ||
| 294 | if rsnix::fs::ensure_file(path).is_err() { | ||
| 295 | gop_println!("edit: cannot create {}", path); | ||
| 296 | serial_println!("edit: cannot create {}", path); | ||
| 297 | return; | ||
| 356 | } | 298 | } |
| 357 | pressed | 299 | rsnix::gop::clear_screen(); |
| 300 | rsdosedit::run(path); | ||
| 301 | rsnix::gop::clear_screen(); | ||
| 358 | } | 302 | } |
| 359 | 303 | ||
| 360 | fn cmd_mkdir(args: &str) { | 304 | fn cmd_mkdir(args: &str) { |
| @@ -682,71 +626,3 @@ fn cmd_poweroff() -> ! { | |||
| 682 | serial_println!("shutting down..."); | 626 | serial_println!("shutting down..."); |
| 683 | rsnix::power::poweroff() | 627 | rsnix::power::poweroff() |
| 684 | } | 628 | } |
| 685 | |||
| 686 | // --------------------------------------------------------------------------- | ||
| 687 | // Minimal fixed-capacity string for no-stack use (avoids alloc dependency) | ||
| 688 | // --------------------------------------------------------------------------- | ||
| 689 | mod heapless_str { | ||
| 690 | use core::fmt; | ||
| 691 | |||
| 692 | /// A fixed-capacity string allocated entirely on the stack. | ||
| 693 | pub struct String<const N: usize> { | ||
| 694 | buf: [u8; N], | ||
| 695 | len: usize, | ||
| 696 | } | ||
| 697 | |||
| 698 | impl<const N: usize> String<N> { | ||
| 699 | pub const fn new() -> Self { | ||
| 700 | String { | ||
| 701 | buf: [0u8; N], | ||
| 702 | len: 0, | ||
| 703 | } | ||
| 704 | } | ||
| 705 | |||
| 706 | pub fn as_str(&self) -> &str { | ||
| 707 | // SAFETY: we only write valid ASCII bytes. | ||
| 708 | unsafe { core::str::from_utf8_unchecked(&self.buf[..self.len]) } | ||
| 709 | } | ||
| 710 | |||
| 711 | pub fn push(&mut self, c: char) -> Result<(), ()> { | ||
| 712 | if self.len >= N { | ||
| 713 | return Err(()); | ||
| 714 | } | ||
| 715 | self.buf[self.len] = c as u8; | ||
| 716 | self.len += 1; | ||
| 717 | Ok(()) | ||
| 718 | } | ||
| 719 | |||
| 720 | pub fn pop(&mut self) -> Option<char> { | ||
| 721 | if self.len == 0 { | ||
| 722 | return None; | ||
| 723 | } | ||
| 724 | self.len -= 1; | ||
| 725 | Some(self.buf[self.len] as char) | ||
| 726 | } | ||
| 727 | |||
| 728 | pub fn is_empty(&self) -> bool { | ||
| 729 | self.len == 0 | ||
| 730 | } | ||
| 731 | |||
| 732 | pub fn clear(&mut self) { | ||
| 733 | self.len = 0; | ||
| 734 | } | ||
| 735 | |||
| 736 | pub fn trim(&self) -> &str { | ||
| 737 | self.as_str().trim() | ||
| 738 | } | ||
| 739 | } | ||
| 740 | |||
| 741 | impl<const N: usize> fmt::Display for String<N> { | ||
| 742 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| 743 | write!(f, "{}", self.as_str()) | ||
| 744 | } | ||
| 745 | } | ||
| 746 | |||
| 747 | impl<const N: usize> fmt::Debug for String<N> { | ||
| 748 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| 749 | write!(f, "{:?}", self.as_str()) | ||
| 750 | } | ||
| 751 | } | ||
| 752 | } | ||
