diff --git a/Cargo.lock b/Cargo.lock index 01b17a4..9e669da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,7 @@ name = "display-server" version = "0.1.0" dependencies = [ "driver-framebuffer-mock", + "libkos", "port-framebuffer", "server-framework", "types", @@ -295,6 +296,14 @@ dependencies = [ "types", ] +[[package]] +name = "shell" +version = "0.1.0" +dependencies = [ + "libkos", + "types", +] + [[package]] name = "smptest" version = "0.1.0" @@ -348,6 +357,15 @@ version = "0.1.0" name = "telephony-server" version = "0.1.0" +[[package]] +name = "terminal-server" +version = "0.1.0" +dependencies = [ + "libkos", + "server-framework", + "types", +] + [[package]] name = "types" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 76ed603..46e1f18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/adapter-riscv", "crates/apps/hello", + "crates/apps/shell", "crates/apps/echo", "crates/apps/smptest", "crates/apps/spawntest", @@ -35,6 +36,7 @@ members = [ "crates/servers/power-server", "crates/servers/print-server", "crates/servers/session-server", + "crates/servers/terminal-server", "crates/servers/storage-server", "crates/servers/sync-server", "crates/servers/telephony-server", diff --git a/Makefile b/Makefile index 91a507f..fec52f3 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,8 @@ build: cargo build --target $(RISCV_TARGET) --release \ -p adapter-riscv -p init -p echo -p snake \ -p display-qemu -p input-qemu -p storage-qemu \ - -p log-server -p permission-manager -p session-server -p smptest -p spawntest -p hello + -p log-server -p permission-manager -p session-server -p smptest -p spawntest -p hello \ + -p terminal-server -p shell image: build scripts/make-disk-image.sh diff --git a/crates/apps/shell/Cargo.toml b/crates/apps/shell/Cargo.toml new file mode 100644 index 0000000..bb83856 --- /dev/null +++ b/crates/apps/shell/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "shell" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "shell" +test = false +bench = false + +[dependencies] +types = { workspace = true } +libkos = { workspace = true } diff --git a/crates/apps/shell/src/main.rs b/crates/apps/shell/src/main.rs new file mode 100644 index 0000000..2e0396a --- /dev/null +++ b/crates/apps/shell/src/main.rs @@ -0,0 +1,48 @@ +//! The shell, for now built-ins only; running programs comes next. + +#![no_std] +#![no_main] + +extern crate libkos; + +use libkos::{print, println}; +use types::bounded::CapSlot; + +/// Init's manifest gives it the terminal first. +const TERMINAL: CapSlot = CapSlot::const_new(0); + +fn run(line: &str) { + let mut words = line.split_ascii_whitespace(); + match words.next() { + None => {} + Some("help") => { + println!("built-ins: help, echo , clear"); + } + Some("echo") => { + let mut first = true; + for w in words { + print!("{}{}", if first { "" } else { " " }, w); + first = false; + } + println!(); + } + Some("clear") => libkos::terminal::clear(TERMINAL), + Some(other) => println!("shell: {}: not found", other), + } +} + +#[unsafe(no_mangle)] +extern "C" fn main() { + // Everything this shell prints goes to the terminal. + libkos::start::set_log(Some(TERMINAL)); + println!("k-os shell. Type 'help'."); + let mut buf = [0u8; 256]; + loop { + print!("$ "); + let n = libkos::terminal::read_line(TERMINAL, &mut buf); + match core::str::from_utf8(&buf[..n]) { + Ok(line) => run(line.trim_end_matches('\n')), + Err(_) => println!("shell: not text"), + } + } +} diff --git a/crates/apps/snake/src/main.rs b/crates/apps/snake/src/main.rs index d439a98..112c0c9 100644 --- a/crates/apps/snake/src/main.rs +++ b/crates/apps/snake/src/main.rs @@ -67,16 +67,17 @@ extern "C" fn main() { fn handle_input(snake: &mut Snake) { // Drain all pending events, apply the latest direction loop { - let Ok([tag, kind, _, _]) = syscall::request(DISPLAY_EP, [types::display::input::POLL, 0, 0, 0]) else { + let Ok([tag, kind, ch, _]) = syscall::request(DISPLAY_EP, [types::display::input::POLL, 0, 0, 0]) else { break; }; if tag == 0 { break; } // NoEvent — queue drained if tag == 1 { - match kind { - 0 => snake.set_direction(Direction::Up), - 1 => snake.set_direction(Direction::Down), - 2 => snake.set_direction(Direction::Left), - 3 => snake.set_direction(Direction::Right), + // Arrows, or WASD (kind 9 is a character). + match (kind, ch as u8) { + (0, _) | (9, b'w') => snake.set_direction(Direction::Up), + (1, _) | (9, b's') => snake.set_direction(Direction::Down), + (2, _) | (9, b'a') => snake.set_direction(Direction::Left), + (3, _) | (9, b'd') => snake.set_direction(Direction::Right), _ => {} } } diff --git a/crates/drivers/input/virtio/src/keymap.rs b/crates/drivers/input/virtio/src/keymap.rs new file mode 100644 index 0000000..5f6583c --- /dev/null +++ b/crates/drivers/input/virtio/src/keymap.rs @@ -0,0 +1,56 @@ +//! Linux key codes (what virtio-input reports) to keys, US layout. + +use port_input_device::Key; + +pub const LEFT_SHIFT: u16 = 42; +pub const RIGHT_SHIFT: u16 = 54; + +/// Rows of the main block: the first key's code, then each key unshifted +/// and shifted. +const ROWS: [(u16, &[u8], &[u8]); 4] = [ + (2, b"1234567890-=", b"!@#$%^&*()_+"), + (16, b"qwertyuiop[]", b"QWERTYUIOP{}"), + (30, b"asdfghjkl;'`", b"ASDFGHJKL:\"~"), + (44, b"zxcvbnm,./", b"ZXCVBNM<>?"), +]; + +pub fn key(code: u16, shift: bool) -> Option { + let named = match code { + 1 => Some(Key::Escape), + 14 => Some(Key::Backspace), + 15 => Some(Key::Tab), + 28 => Some(Key::Enter), + 57 => Some(Key::Space), + 103 => Some(Key::Up), + 108 => Some(Key::Down), + 105 => Some(Key::Left), + 106 => Some(Key::Right), + 43 => Some(Key::Char(if shift { b'|' } else { b'\\' })), + _ => None, + }; + named.or_else(|| { + ROWS.iter().find_map(|(first, plain, shifted)| { + let i = usize::from(code.checked_sub(*first)?); + let row = if shift { shifted } else { plain }; + row.get(i).map(|c| Key::Char(*c)) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn letters_digits_and_shift() { + assert_eq!(key(35, false), Some(Key::Char(b'h'))); + assert_eq!(key(35, true), Some(Key::Char(b'H'))); + assert_eq!(key(11, false), Some(Key::Char(b'0'))); + assert_eq!(key(12, true), Some(Key::Char(b'_'))); + assert_eq!(key(53, false), Some(Key::Char(b'/'))); + assert_eq!(key(41, true), Some(Key::Char(b'~'))); + assert_eq!(key(28, false), Some(Key::Enter)); + assert_eq!(key(103, false), Some(Key::Up)); + assert_eq!(key(29, false), None, "left ctrl is not a character"); + } +} diff --git a/crates/drivers/input/virtio/src/lib.rs b/crates/drivers/input/virtio/src/lib.rs index 9b3d02c..03ccc3c 100644 --- a/crates/drivers/input/virtio/src/lib.rs +++ b/crates/drivers/input/virtio/src/lib.rs @@ -1,5 +1,7 @@ #![no_std] +mod keymap; + use hw_virtio::mmio::MmioAccess; use port_input_device::{InputDevice, InputEvent, Key}; @@ -28,6 +30,7 @@ pub struct VirtioInputDevice { last_used: u16, next_avail: u16, evbuf_pa: usize, + shift: bool, } impl VirtioInputDevice { @@ -45,7 +48,7 @@ impl VirtioInputDevice { let evbuf_pa = libkos::device::dma(1, EVBUF_VA).ok()?; hw_virtio::mmio::init_legacy_device(®s, vq_pa, QUEUE_SIZE as u32); - let mut dev = Self { last_used: 0, next_avail: 0, evbuf_pa }; + let mut dev = Self { last_used: 0, next_avail: 0, evbuf_pa, shift: false }; dev.fill_queue(); Some(dev) } @@ -118,25 +121,6 @@ impl VirtioInputDevice { } } -fn linux_keycode_to_key(code: u16) -> Option { - match code { - 17 => Some(Key::Up), // KEY_W - 31 => Some(Key::Down), // KEY_S - 30 => Some(Key::Left), // KEY_A - 32 => Some(Key::Right), // KEY_D - 103 => Some(Key::Up), // KEY_UP - 108 => Some(Key::Down), // KEY_DOWN - 105 => Some(Key::Left), // KEY_LEFT - 106 => Some(Key::Right),// KEY_RIGHT - 28 => Some(Key::Enter), // KEY_ENTER - 1 => Some(Key::Escape), // KEY_ESC - 57 => Some(Key::Space), // KEY_SPACE - 14 => Some(Key::Backspace), // KEY_BACKSPACE - 15 => Some(Key::Tab), // KEY_TAB - _ => None, - } -} - impl InputDevice for VirtioInputDevice { fn poll(&mut self) -> Option { loop { @@ -151,8 +135,12 @@ impl InputDevice for VirtioInputDevice { let (ev_type, ev_code, ev_value) = self.read_event(desc_idx); self.resubmit(desc_idx); + if ev_type == EV_KEY && matches!(ev_code, keymap::LEFT_SHIFT | keymap::RIGHT_SHIFT) { + self.shift = ev_value != 0; + continue; + } if ev_type == EV_KEY { - if let Some(key) = linux_keycode_to_key(ev_code) { + if let Some(key) = keymap::key(ev_code, self.shift) { if ev_value == KEY_PRESS { return Some(InputEvent::KeyDown(key)); } else if ev_value == 0 { diff --git a/crates/init/src/manifest.rs b/crates/init/src/manifest.rs index 5de7d1a..23a8099 100644 --- a/crates/init/src/manifest.rs +++ b/crates/init/src/manifest.rs @@ -60,6 +60,8 @@ pub const SERVERS: &[Program] = &[ // The framebuffer alone is ~1000 pages at 1280x800. ..server("display", b"display.elf", Limits { pages: 1280, regions: 4, ..SMALL }) }, + // Text on the display, lines from the keyboard (it takes input focus). + Program { uses: &["display"], ..server("terminal", b"terminal.elf", SMALL) }, server("permis", b"permis.elf", SMALL), server("session", b"session.elf", SMALL), ]; @@ -76,6 +78,6 @@ pub const TESTS: &[Program] = &[Program { /// The desktop; the test image runs the SMP self-test instead. pub const APPS: &[Program] = &[Program { serves: false, - uses: &["display"], - ..server("snake", b"snake.elf", Limits { pages: 128, ..SMALL }) + uses: &["terminal"], + ..server("shell", b"shell.elf", SMALL) }]; diff --git a/crates/libkos/src/alloc_impl.rs b/crates/libkos/src/alloc_impl.rs index 8c16124..1e43a5b 100644 --- a/crates/libkos/src/alloc_impl.rs +++ b/crates/libkos/src/alloc_impl.rs @@ -39,22 +39,21 @@ impl KosAllocator { } impl AllocatorInner { - fn request_page(&mut self) -> *mut u8 { - if self.page_count >= MAX_PAGES { + /// `n` pages in one piece, so an allocation larger than a page fits. + fn request_pages(&mut self, n: usize) -> *mut u8 { + if self.page_count + n > MAX_PAGES { return ptr::null_mut(); } - - let count = match types::bounded::PageCount::new(1) { - Some(c) => c, - None => return ptr::null_mut(), + let Some(count) = u16::try_from(n).ok().and_then(types::bounded::PageCount::new) else { + return ptr::null_mut(); }; - let va = match crate::syscall::request_pages(count) { - Some(va) => va, - None => return ptr::null_mut(), + let Some(va) = crate::syscall::request_pages(count) else { + return ptr::null_mut(); }; - - self.pages[self.page_count] = va.raw(); - self.page_count += 1; + if let Some(slot) = self.pages.get_mut(self.page_count) { + *slot = va.raw(); + } + self.page_count += n; va.raw() as *mut u8 } @@ -125,14 +124,13 @@ unsafe impl GlobalAlloc for KosAllocator { return ptr; } - let pages_needed = (size + PAGE_SIZE - 1) / PAGE_SIZE; - for _ in 0..pages_needed { - let page = inner.request_page(); - if page.is_null() { - return ptr::null_mut(); - } - inner.add_to_free_list(page, PAGE_SIZE); + // Alignment can cost up to `align` bytes of the block. + let pages_needed = (size + align + PAGE_SIZE - 1) / PAGE_SIZE; + let block = inner.request_pages(pages_needed); + if block.is_null() { + return ptr::null_mut(); } + inner.add_to_free_list(block, pages_needed * PAGE_SIZE); inner.alloc_from_free_list(size, align) } diff --git a/crates/libkos/src/lib.rs b/crates/libkos/src/lib.rs index 9608038..18ea126 100644 --- a/crates/libkos/src/lib.rs +++ b/crates/libkos/src/lib.rs @@ -12,6 +12,7 @@ pub mod io; mod rt; pub mod spawn; pub mod start; +pub mod terminal; pub mod syscall; #[cfg(target_os = "none")] diff --git a/crates/libkos/src/rt.rs b/crates/libkos/src/rt.rs index 956500d..b318824 100644 --- a/crates/libkos/src/rt.rs +++ b/crates/libkos/src/rt.rs @@ -18,6 +18,7 @@ _start: #[cfg(target_os = "none")] #[panic_handler] -fn panic(_: &core::panic::PanicInfo) -> ! { +fn panic(info: &core::panic::PanicInfo) -> ! { + crate::println!("panic: {}", info); crate::syscall::exit(); } diff --git a/crates/libkos/src/terminal.rs b/crates/libkos/src/terminal.rs new file mode 100644 index 0000000..dd6852a --- /dev/null +++ b/crates/libkos/src/terminal.rs @@ -0,0 +1,29 @@ +//! The terminal (`types::terminal`). Output is the log protocol, so a +//! process prints to a terminal by making it its log (`start::set_log`). + +use crate::syscall; +use types::bounded::CapSlot; + +/// Reads a typed line into `buf`, newline included if it fits; how much. +pub fn read_line(terminal: CapSlot, buf: &mut [u8]) -> usize { + let mut n = 0; + while n < buf.len() { + let Ok(words) = syscall::request(terminal, [types::terminal::READ, 0, 0, 0]) else { + break; + }; + let Some((bytes, len)) = types::log::text_of(words) else { + break; + }; + let take = len.min(buf.len() - n); + buf[n..n + take].copy_from_slice(&bytes[..take]); + n += take; + if bytes[..len].last() == Some(&b'\n') || len == 0 { + break; + } + } + n +} + +pub fn clear(terminal: CapSlot) { + let _ = syscall::request(terminal, [types::terminal::CLEAR, 0, 0, 0]); +} diff --git a/crates/servers/terminal-server/Cargo.toml b/crates/servers/terminal-server/Cargo.toml new file mode 100644 index 0000000..2ba5cee --- /dev/null +++ b/crates/servers/terminal-server/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "terminal-server" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "terminal-server" +test = false +bench = false + +[dependencies] +types = { workspace = true } +libkos = { workspace = true } +server-framework = { workspace = true } diff --git a/crates/servers/terminal-server/src/grid.rs b/crates/servers/terminal-server/src/grid.rs new file mode 100644 index 0000000..7f49544 --- /dev/null +++ b/crates/servers/terminal-server/src/grid.rs @@ -0,0 +1,89 @@ +//! The terminal's text: a grid of cells, a cursor, and which rows changed. + +use alloc::vec; +use alloc::vec::Vec; + +pub const MAX_COLS: usize = 160; +pub const MAX_ROWS: usize = 64; + +/// On the heap: a full screen is more than a program stack holds. +pub struct Grid { + cells: Vec, + cols: usize, + rows: usize, + pub col: usize, + pub row: usize, + /// Rows to redraw. + dirty: Vec, +} + +impl Grid { + pub fn new(cols: usize, rows: usize) -> Self { + let (cols, rows) = (cols.clamp(1, MAX_COLS), rows.clamp(1, MAX_ROWS)); + Self { cells: vec![b' '; cols * rows], cols, rows, col: 0, row: 0, dirty: vec![true; rows] } + } + + pub fn row(&self, r: usize) -> &[u8] { + &self.cells[r * self.cols..(r + 1) * self.cols] + } + + pub fn rows(&self) -> usize { + self.rows + } + + /// The rows changed since the last call, each once. + pub fn take_dirty(&mut self) -> impl Iterator + '_ { + let rows = self.rows; + self.dirty[..rows].iter_mut().enumerate().filter_map(|(r, d)| core::mem::take(d).then_some(r)) + } + + fn mark(&mut self, r: usize) { + self.dirty[r] = true; + } + + fn newline(&mut self) { + // The row the cursor leaves, so its cursor cell is redrawn. + self.mark(self.row); + self.col = 0; + if self.row + 1 < self.rows { + self.row += 1; + return; + } + // At the bottom: everything moves up a row. + self.cells.copy_within(self.cols.., 0); + let last = (self.rows - 1) * self.cols; + self.cells[last..].fill(b' '); + self.dirty.fill(true); + } + + /// Writes one byte at the cursor: `\n` moves to the next line, other + /// bytes wrap at the right edge. + pub fn put(&mut self, b: u8) { + if b == b'\n' { + self.newline(); + return; + } + if self.col == self.cols { + self.newline(); + } + self.cells[self.row * self.cols + self.col] = b; + self.mark(self.row); + self.col += 1; + } + + /// Erases the cell before the cursor, on this row only. + pub fn back(&mut self) { + if self.col > 0 { + self.col -= 1; + self.cells[self.row * self.cols + self.col] = b' '; + self.mark(self.row); + } + } + + pub fn clear(&mut self) { + self.cells.fill(b' '); + self.col = 0; + self.row = 0; + self.dirty.fill(true); + } +} diff --git a/crates/servers/terminal-server/src/lib.rs b/crates/servers/terminal-server/src/lib.rs new file mode 100644 index 0000000..581a76f --- /dev/null +++ b/crates/servers/terminal-server/src/lib.rs @@ -0,0 +1,90 @@ +#![no_std] + +extern crate alloc; + +pub mod grid; +pub mod server; + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; + +use libkos::display::{self, CELL_HEIGHT, CELL_WIDTH}; +use libkos::input::{InputEvent, Key}; +use libkos::syscall; +use types::bounded::CapSlot; +use types::display::{Color, ScreenCoord}; + +/// Where init put them (its manifest: own endpoint, then `uses`). +const EP: CapSlot = CapSlot::const_new(0); +const DISPLAY: CapSlot = CapSlot::const_new(1); + +const FG: Color = Color::rgb(220, 220, 220); +const BG: Color = Color::rgb(16, 16, 24); +/// Output is shown at most this long after it is written. The timebase is +/// QEMU virt's 10 MHz. +const FLUSH_DELAY: u64 = 20 * 10_000; + +struct Screen { + keys: CapSlot, + /// Bound to the serving thread; the flush timer signals it. + timer: CapSlot, +} + +fn at(col: usize, row: usize) -> (ScreenCoord, ScreenCoord) { + (ScreenCoord::new((col * CELL_WIDTH) as u16), ScreenCoord::new((row * CELL_HEIGHT) as u16)) +} + +impl server::Console for Screen { + fn draw_row(&mut self, row: usize, text: &[u8]) { + let (x, y) = at(0, row); + display::draw_text(DISPLAY, x, y, FG, BG, text); + } + + fn draw_cursor(&mut self, col: usize, row: usize, under: u8) { + let (x, y) = at(col, row); + display::draw_text(DISPLAY, x, y, BG, FG, &[under]); + } + + fn flush(&mut self) { + display::flush(DISPLAY); + } + + fn flush_later(&mut self) { + let at = syscall::get_time().raw() + FLUSH_DELAY; + let _ = syscall::set_timer(self.timer, types::time::Ticks::from_raw(at)); + } + + fn next_key(&mut self) -> u8 { + loop { + while let Some(event) = display::poll_input(DISPLAY) { + let InputEvent::KeyDown(key) = event else { continue }; + match key { + Key::Char(c) => return c, + Key::Space | Key::Tab => return b' ', + Key::Enter => return b'\n', + Key::Backspace => return server::BACKSPACE, + _ => {} + } + } + let _ = syscall::wait(self.keys); + } + } +} + +pub fn run() -> ! { + let Some(keys) = display::subscribe_input(DISPLAY) else { + libkos::println!("terminal: no input focus, exiting"); + syscall::exit(); + }; + let Some(timer) = syscall::create_notification().ok().filter(|t| syscall::bind_notification(*t).is_ok()) else { + libkos::println!("terminal: no timer notification, exiting"); + syscall::exit(); + }; + let (w, h) = display::get_dimensions(DISPLAY); + display::clear(DISPLAY, BG); + let cols = w.raw() as usize / CELL_WIDTH; + let rows = h.raw() as usize / CELL_HEIGHT; + let mut srv = server::TerminalServer::new(Screen { keys, timer }, cols, rows); + server_framework::serve(&mut srv, EP) +} diff --git a/crates/servers/terminal-server/src/main.rs b/crates/servers/terminal-server/src/main.rs new file mode 100644 index 0000000..f61d97d --- /dev/null +++ b/crates/servers/terminal-server/src/main.rs @@ -0,0 +1,9 @@ +#![no_std] +#![no_main] + +extern crate libkos; + +#[unsafe(no_mangle)] +extern "C" fn main() { + terminal_server::run(); +} diff --git a/crates/servers/terminal-server/src/server.rs b/crates/servers/terminal-server/src/server.rs new file mode 100644 index 0000000..f4e8a61 --- /dev/null +++ b/crates/servers/terminal-server/src/server.rs @@ -0,0 +1,184 @@ +//! Text out (the log protocol) and lines in, on a screen and keyboard +//! behind `Console`. + +use server_framework::{Encode, MALFORMED, Server}; +use types::bounded::CapSlot; +use types::log::{TEXT_BYTES, text_of, text_words}; + +use crate::grid::Grid; + +/// What the terminal draws on and reads from. +pub trait Console { + fn draw_row(&mut self, row: usize, text: &[u8]); + /// Shows the cursor at a cell, over what `draw_row` drew there. + fn draw_cursor(&mut self, col: usize, row: usize, under: u8); + fn flush(&mut self); + /// Soon calls `TerminalServer::signal`, to flush a burst of output once. + fn flush_later(&mut self); + /// Blocks until a key is typed: a byte, `\n` for Enter, 8 for Backspace. + fn next_key(&mut self) -> u8; +} + +pub const BACKSPACE: u8 = 8; +const MAX_LINE: usize = 256; + +pub struct TerminalServer { + console: C, + grid: Grid, + /// A typed line not yet handed out in full, and how much of it was. + line: [u8; MAX_LINE], + line_len: usize, + line_read: usize, + flush_pending: bool, +} + +/// An empty reply, whose tag is not text. +pub struct Done; + +impl Encode for Done { + fn encode(&self) -> [usize; 4] { + [0; 4] + } +} + +impl TerminalServer { + pub fn new(console: C, cols: usize, rows: usize) -> Self { + let mut s = Self { + console, + grid: Grid::new(cols, rows), + line: [0; MAX_LINE], + line_len: 0, + line_read: 0, + flush_pending: false, + }; + s.redraw(); + s.console.flush(); + s + } + + pub fn console(&self) -> &C { + &self.console + } + + pub fn grid(&self) -> &Grid { + &self.grid + } + + fn redraw(&mut self) { + let mut dirty = [false; crate::grid::MAX_ROWS]; + for r in self.grid.take_dirty() { + dirty[r] = true; + } + for (r, _) in dirty.iter().enumerate().filter(|(_, d)| **d) { + self.console.draw_row(r, self.grid.row(r)); + } + let (col, row) = (self.grid.col, self.grid.row); + let under = self.grid.row(row).get(col).copied().unwrap_or(b' '); + self.console.draw_cursor(col, row, under); + } + + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.grid.put(b); + } + self.redraw(); + if !self.flush_pending { + self.flush_pending = true; + self.console.flush_later(); + } + } + + /// Edits a line from the keyboard, echoing it, until Enter. + fn read_line(&mut self) { + self.console.flush(); + self.line_len = 0; + self.line_read = 0; + loop { + match self.console.next_key() { + b'\n' => { + self.line[self.line_len] = b'\n'; + self.line_len += 1; + self.grid.put(b'\n'); + break; + } + BACKSPACE if self.line_len > 0 => { + self.line_len -= 1; + self.grid.back(); + } + BACKSPACE => continue, + // Room is kept for the closing newline. + b if self.line_len + 1 < MAX_LINE && (b' '..=b'~').contains(&b) => { + self.line[self.line_len] = b; + self.line_len += 1; + self.grid.put(b); + } + _ => continue, + } + self.redraw(); + self.console.flush(); + } + self.redraw(); + self.console.flush(); + } + + /// The next chunk of the typed line, reading one first if none is left. + fn read(&mut self) -> [usize; 4] { + if self.line_read == self.line_len { + self.read_line(); + } + let end = (self.line_read + TEXT_BYTES).min(self.line_len); + let chunk = &self.line[self.line_read..end]; + self.line_read = end; + text_words(chunk) + } +} + +impl Server for TerminalServer { + type Request = Request; + type Response = Done; + + fn handle(&mut self, request: Request) -> Done { + match request { + Request::Clear => { + self.grid.clear(); + self.redraw(); + self.console.flush(); + } + } + Done + } + + fn handle_call(&mut self, _badge: usize, words: [usize; 4], _cap: Option) -> ([usize; 4], Option) { + let reply = match words[0] { + types::terminal::READ => self.read(), + types::terminal::CLEAR => { + self.handle(Request::Clear); + [0; 4] + } + _ => match text_of(words) { + Some((bytes, len)) => { + self.write(&bytes[..len]); + [0; 4] + } + None => [MALFORMED, 0, 0, 0], + }, + }; + (reply, None) + } + + /// The flush timer: show a burst of output in one frame. + fn signal(&mut self, _bits: usize) { + self.flush_pending = false; + self.console.flush(); + } +} + +pub enum Request { + Clear, +} + +impl server_framework::Decode for Request { + fn decode(words: [usize; 4]) -> Option { + (words[0] == types::terminal::CLEAR).then_some(Request::Clear) + } +} diff --git a/crates/servers/terminal-server/src/tests/grid.rs b/crates/servers/terminal-server/src/tests/grid.rs new file mode 100644 index 0000000..d549556 --- /dev/null +++ b/crates/servers/terminal-server/src/tests/grid.rs @@ -0,0 +1,38 @@ +use crate::grid::Grid; + +fn text(g: &Grid, r: usize) -> &str { + core::str::from_utf8(g.row(r)).unwrap().trim_end() +} + +#[test] +fn text_wraps_at_the_edge_and_newlines_move_down() { + let mut g = Grid::new(4, 3); + for b in b"abcdef\nx" { + g.put(*b); + } + assert_eq!((text(&g, 0), text(&g, 1), text(&g, 2)), ("abcd", "ef", "x")); + assert_eq!((g.col, g.row), (1, 2)); +} + +#[test] +fn the_bottom_line_scrolls_everything_up() { + let mut g = Grid::new(4, 2); + for b in b"one\ntwo\nsix" { + g.put(*b); + } + assert_eq!((text(&g, 0), text(&g, 1)), ("two", "six")); + let _ = g.take_dirty().count(); + g.put(b'\n'); + assert_eq!(g.take_dirty().count(), 2, "a scroll redraws every row"); +} + +#[test] +fn backspace_stops_at_the_row_start_and_dirty_rows_come_once() { + let mut g = Grid::new(4, 2); + g.put(b'a'); + g.back(); + g.back(); + assert_eq!((text(&g, 0), g.col), ("", 0)); + let _ = g.take_dirty().count(); + assert_eq!(g.take_dirty().count(), 0); +} diff --git a/crates/servers/terminal-server/src/tests/mod.rs b/crates/servers/terminal-server/src/tests/mod.rs new file mode 100644 index 0000000..945d763 --- /dev/null +++ b/crates/servers/terminal-server/src/tests/mod.rs @@ -0,0 +1,2 @@ +mod grid; +mod server; diff --git a/crates/servers/terminal-server/src/tests/server.rs b/crates/servers/terminal-server/src/tests/server.rs new file mode 100644 index 0000000..aae87cf --- /dev/null +++ b/crates/servers/terminal-server/src/tests/server.rs @@ -0,0 +1,70 @@ +extern crate std; + +use crate::server::{BACKSPACE, Console, TerminalServer}; +use server_framework::Server; +use std::vec::Vec; +use types::log::{text_of, text_words}; + +#[derive(Default)] +struct Fake { + keys: Vec, + flushes: usize, + timers: usize, +} + +impl Console for Fake { + fn draw_row(&mut self, _row: usize, _text: &[u8]) {} + fn draw_cursor(&mut self, _col: usize, _row: usize, _under: u8) {} + fn flush(&mut self) { + self.flushes += 1; + } + fn flush_later(&mut self) { + self.timers += 1; + } + fn next_key(&mut self) -> u8 { + self.keys.remove(0) + } +} + +fn term(keys: &[u8]) -> TerminalServer { + TerminalServer::new(Fake { keys: keys.to_vec(), ..Fake::default() }, 40, 10) +} + +fn read(t: &mut TerminalServer) -> Vec { + let (words, _) = t.handle_call(1, [types::terminal::READ, 0, 0, 0], None); + let (bytes, len) = text_of(words).unwrap(); + bytes[..len].to_vec() +} + +#[test] +fn writes_land_on_the_grid_and_flush_once_per_burst() { + let mut t = term(&[]); + let before = t.console().flushes; + for chunk in [&b"hello "[..], b"world"] { + t.handle_call(1, text_words(chunk), None); + } + assert_eq!(&t.grid().row(0)[..11], b"hello world"); + assert_eq!((t.console().timers, t.console().flushes), (1, before)); + t.signal(1); + assert_eq!(t.console().flushes, before + 1); +} + +#[test] +fn a_read_returns_the_edited_line_in_chunks() { + let mut keys = b"ls -lx".to_vec(); + keys.push(BACKSPACE); + keys.extend_from_slice(b"a /home/someone/very/long\n"); + let mut t = term(&keys); + let mut line = read(&mut t); + while line.last() != Some(&b'\n') { + line.extend(read(&mut t)); + } + assert_eq!(line, b"ls -la /home/someone/very/long\n"); + assert_eq!(&t.grid().row(0)[..6], b"ls -la"); +} + +#[test] +fn backspace_on_an_empty_line_does_nothing() { + let mut t = term(&[BACKSPACE, b'x', b'\n']); + assert_eq!(read(&mut t), b"x\n"); +} diff --git a/crates/types/src/display.rs b/crates/types/src/display.rs index dfc7f59..74d0690 100644 --- a/crates/types/src/display.rs +++ b/crates/types/src/display.rs @@ -74,7 +74,14 @@ pub mod text { let len = s.len().min(TEXT_BYTES); let mut bytes = [0u8; TEXT_BYTES]; bytes[..len].copy_from_slice(&s[..len]); - Self { x, y, fg, bg, bytes, len } + Self { + x, + y, + fg, + bg, + bytes, + len, + } } pub fn chars(&self) -> &[u8] { @@ -85,7 +92,9 @@ pub mod text { let (lo, hi) = self.bytes.as_chunks::<8>().0.split_at(1); [ TEXT | self.len << 8 | (self.fg.raw() as usize) << 16, - self.x.raw() as usize | (self.y.raw() as usize) << 16 | (self.bg.raw() as usize) << 32, + self.x.raw() as usize + | (self.y.raw() as usize) << 16 + | (self.bg.raw() as usize) << 32, u64::from_le_bytes(lo[0]) as usize, u64::from_le_bytes(hi[0]) as usize, ] @@ -117,7 +126,13 @@ pub mod text { #[test] fn text_round_trips() { - let t = Text::new(ScreenCoord::new(1272), ScreenCoord::new(784), Color::rgb(1, 2, 3), Color::rgb(4, 5, 6), b"$ ls -la /home/x"); + let t = Text::new( + ScreenCoord::new(1272), + ScreenCoord::new(784), + Color::rgb(1, 2, 3), + Color::rgb(4, 5, 6), + b"$ ls -la /home/x", + ); assert_eq!(Text::from_words(t.words()), Some(t)); assert_eq!(Text::new(t.x, t.y, t.fg, t.bg, &[b'a'; 40]).len, TEXT_BYTES); } diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 3116171..21447ca 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -19,4 +19,5 @@ pub mod spawn; pub mod start_arg; pub mod storage; pub mod syscall_abi; +pub mod terminal; pub mod time; diff --git a/crates/types/src/terminal.rs b/crates/types/src/terminal.rs new file mode 100644 index 0000000..cfd1bb7 --- /dev/null +++ b/crates/types/src/terminal.rs @@ -0,0 +1,7 @@ +//! The terminal's requests beyond text output, which is the log protocol +//! (`types::log`): a program given the terminal as its log prints there. + +/// Reply: the next chunk of a typed line as a text message (`types::log`); +/// the line's last chunk ends with `\n`. Blocks until a line is entered. +pub const READ: usize = 10; +pub const CLEAR: usize = 11; diff --git a/docs/ipc.md b/docs/ipc.md index 72a563c..a3a6627 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -94,6 +94,13 @@ client. It subscribes to the input server with a notification, drains it when signalled, and passes events on to the app in focus: the last one to subscribe (`types::display::input`). Apps out of focus get nothing. +The terminal server takes focus and draws text with the display server's +font. Its output side is the log protocol (`types::log`), so a program whose +log endpoint is the terminal prints there with `println!`: standard output +is whatever text endpoint a program was given. `READ` (`types::terminal`) +blocks until a line is typed and returns it in chunks, the last ending in +`\n`. The desktop is the shell, given the terminal. + Only init holds `ProcessCreate`. Everyone else runs programs through init's spawn service (`types::spawn`, client `libkos::spawn`), which init serves once boot is done. A manifest entry with `spawns` gets a badged copy of diff --git a/scripts/make-disk-image.sh b/scripts/make-disk-image.sh index 5288829..d2f0140 100755 --- a/scripts/make-disk-image.sh +++ b/scripts/make-disk-image.sh @@ -30,6 +30,8 @@ copy log-server "log.elf" copy permission-manager "permis.elf" copy storage-qemu "storage.elf" copy session-server "session.elf" +copy terminal-server "terminal.elf" +copy shell "shell.elf" # Only the test image: its presence makes init run the SMP self-test. if [ -n "${SMPTEST:-}" ]; then copy smptest "smptest.elf"