diff --git a/emu.js b/emu.js index eabf3bf..13eb03a 100644 --- a/emu.js +++ b/emu.js @@ -11,6 +11,12 @@ const PERIPHS_SIZE = 68; const POLL_INTERVAL_MS = 20; +const COLS = 80; +const ROWS = 36; +const TAB_WIDTH = 8; + +const CURSOR_IDLE_TIME_MS = 1000; + class Emulator { constructor() { this.mem = new WebAssembly.Memory({ @@ -23,12 +29,26 @@ class Emulator { this.mem_u8[i] = 0; this.decoder = new TextDecoder('utf-8'); + this.encoder = new TextEncoder('utf-8'); this.output = document.getElementById('output'); - this.input = document.getElementById('input'); - + this.rx_queue = []; this.timer = setInterval(() => this.poll(), POLL_INTERVAL_MS); + this.grid = Array.from( + { length: ROWS }, + () => new Array(COLS).fill(' ')); + this.cursor = { x: 0, y: 0 }; + this.range = { + start: { x: 0, y: 0 }, + end: { x: 0, y: 0 } + }; + this.idle_timer = null; + document.addEventListener('keydown', (e) => this.handle_keydown(e)); + + this.flush_output(); + document.getElementById('cursor').classList.add('blinking'); + this.worker = new Worker('boot.js'); this.worker.postMessage(this.mem); } @@ -36,21 +56,213 @@ class Emulator { poll() { const txhead = Atomics.load(this.mem_u8, TXHEAD); const txtail = Atomics.load(this.mem_u8, TXTAIL); - if (txhead !== txtail) - this.handle_txdata(txhead, txtail); + if (txhead != txtail) + this.handle_tx_data(txhead, txtail); + + if (this.rx_queue.length != 0) { + const rxhead = Atomics.load(this.mem_u8, RXHEAD); + const rxtail = Atomics.load(this.mem_u8, RXTAIL); + if (this.fifo_next(rxtail) != rxhead) + this.handle_rx_data(rxhead, rxtail); + } } - handle_txdata(head, tail) { - const data = []; - let i = head; - do { - data.push(this.mem_u8[TXBUF + i]); - i = (i + 1) % TXBUF_SIZE; - } while (i !== tail); - Atomics.store(this.mem_u8, TXHEAD, tail); + fifo_next(idx) { + return (idx + 1) & 0x1f; + } - const str = this.decoder.decode(new Uint8Array(data)); - this.output.innerText += str; + handle_tx_data(head, tail) { + const data = []; + do { + data.push(this.mem_u8[TXBUF + head]); + head = this.fifo_next(head); + } while (head != tail); + Atomics.store(this.mem_u8, TXHEAD, head); + this.print(this.decoder.decode(new Uint8Array(data))); + } + + handle_rx_data(head, tail) { + do { + this.mem_u8[RXBUF + tail] = this.rx_queue.shift(); + tail = this.fifo_next(tail); + } while (this.fifo_next(tail) != head && this.rx_queue.length != 0); + Atomics.store(this.mem_u8, RXTAIL, tail); + } + + print(str) { + for (const cp of str) { + switch (cp) { + case '\n': + do + this.print_character(' '); + while (this.range.start.x != 0); + break; + case '\t': + do + this.print_character(' '); + while (this.range.start.x % TAB_WIDTH != 0); + break; + default: + this.print_character(cp); + break; + } + } + this.flush_output(); + } + + print_character(c) { + this.shift_up(this.range.start); + this.grid[this.range.start.y][this.range.start.x] = c; + this.cursor = this.next_cell(this.cursor); + this.range.start = this.next_cell(this.range.start); + } + + handle_keydown(e) { + if (e.key.length == 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { + this.shift_up(this.cursor); + this.grid[this.cursor.y][this.cursor.x] = e.key; + this.cursor_move(1, 0); + } else if (e.key == 'Enter') { + this.cursor.y = this.range.end.y + 1; + this.cursor.x = 0; + this.submit_line(); + Object.assign(this.range.start, this.cursor); + Object.assign(this.range.end, this.cursor); + } else if (e.key == 'ArrowLeft' || e.key == 'b' && e.ctrlKey) { + this.cursor_move(-1, 0); + } else if (e.key == 'ArrowRight' || e.key == 'f' && e.ctrlKey) { + this.cursor_move(1, 0); + } else if (e.key == 'ArrowUp' || e.key == 'p' && e.ctrlKey) { + this.cursor_move(0, -1); + } else if (e.key == 'ArrowDown' || e.key == 'n' && e.ctrlKey) { + this.cursor_move(0, 1); + } else if (e.key == 'Backspace') { + if (this.cursor_move(-1, 0)) + this.shift_down(this.cursor); + } else if (e.key == 'Delete' || e.key == 'd' && e.ctrlKey) { + this.shift_down(this.cursor); + } else if (e.key == 'Home' || e.key == 'a' && e.ctrlKey) { + Object.assign(this.cursor, this.range.start); + } else if (e.key == 'End' || e.key == 'e' && e.ctrlKey) { + Object.assign(this.cursor, this.range.end); + } else { + return; + } + + e.preventDefault(); + this.flush_output(); + + document.getElementById('cursor').classList.remove('blinking'); + if (this.idle_timer) + clearTimeout(this.idle_timer); + this.idle_timer = setTimeout(() => { + document.getElementById('cursor').classList.add('blinking'); + }, CURSOR_IDLE_TIME_MS); + } + + submit_line() { + let line = ''; + let cell = this.range.start; + while (cell.x != this.range.end.x && cell.y != this.range.y) { + line += this.grid[cell.y][cell.x]; + cell = this.next_cell(cell); + } + line += '\n'; + for (let cu of this.encoder.encode(line)) { + this.rx_queue.push(cu); + } + } + + cursor_move(dx, dy) { + let x = this.cursor.x + dx; + let y = this.cursor.y + dy; + if (x < 0) { + --y; + x += COLS; + } else if (x >= COLS) { + ++y; + x -= COLS; + } + + if (this.range.start.y > y || this.range.end.y < y) + return false; + if (y == this.range.start.y && this.range.start.x > x) + return false; + if (y == this.range.end.y && this.range.end.x < x) + return false; + + this.cursor.x = x; + this.cursor.y = y; + return true; + } + + prev_cell(cell) { + if (cell.x > 0) + return { x: cell.x - 1, y: cell.y }; + else if (cell.y > 0) + return { x: COLS - 1, y: cell.y - 1 }; + else + return null; + } + + next_cell(cell) { + if (cell.x < COLS - 1) + return { x: cell.x + 1, y: cell.y }; + else if (cell.y < ROWS - 1) + return { x: 0, y: cell.y + 1 }; + else + return null; + } + + shift_up(start) { + let cell = { x: this.range.end.x, y: this.range.end.y }; + while (cell.x != start.x || cell.y != start.y) { + let prev = this.prev_cell(cell); + this.grid[cell.y][cell.x] = this.grid[prev.y][prev.x]; + cell = prev; + } + this.range.end = this.next_cell(this.range.end); + } + + shift_down(start) { + let cell = { x: start.x, y: start.y }; + while (cell.x != this.range.end.x || cell.y != this.range.end.y) { + let next = this.next_cell(cell); + this.grid[cell.y][cell.x] = this.grid[next.y][next.x]; + cell = next; + } + this.grid[this.range.end.y][this.range.end.x] = ' '; + this.range.end = this.prev_cell(this.range.end); + } + + flush_output() { + const html = this.grid.map((row, y) => { + return row.map((c, x) => { + const ec = this.html_escape(c); + if (x == this.cursor.x && y == this.cursor.y) + return '' + ec + ''; + else + return ec; + }).join(''); + }).join('\n'); + this.output.innerHTML = html; + } + + html_escape(c) { + switch (c) { + case '"': + return '"'; + case "'": + return '''; + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + default: + return c; + } } } diff --git a/index.html b/index.html index 4f52248..8a7e713 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,5 @@
- diff --git a/styles.css b/styles.css index 6c637c9..8946c4c 100644 --- a/styles.css +++ b/styles.css @@ -1,17 +1,35 @@ +:root { + --bg: black; + --fg: white; +} + body { - line-height: 1.4em; + line-height: 1.5em; font-family: 'Courier 10 Pitch', monospace; font-size: 12pt; - background-color: black; - color: white; + background-color: var(--bg); + color: var(--fg); + display: flex; + align-items: center; } #output { - margin: 1.4em auto; - width: 80em; - height: 48em; + margin: 1.5em auto; + white-space: pre; } -#input { - display: none; +#cursor { + background-color: var(--fg); + color: var(--bg); +} + +.blinking { + animation: blink 1.5s step-start infinite; +} + +@keyframes blink { + 50% { + background-color: var(--bg); + color: var(--fg); + } }