Implement a nice terminal emulator on the JS side
This commit is contained in:
240
emu.js
240
emu.js
@@ -11,6 +11,12 @@ const PERIPHS_SIZE = 68;
|
|||||||
|
|
||||||
const POLL_INTERVAL_MS = 20;
|
const POLL_INTERVAL_MS = 20;
|
||||||
|
|
||||||
|
const COLS = 80;
|
||||||
|
const ROWS = 36;
|
||||||
|
const TAB_WIDTH = 8;
|
||||||
|
|
||||||
|
const CURSOR_IDLE_TIME_MS = 1000;
|
||||||
|
|
||||||
class Emulator {
|
class Emulator {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.mem = new WebAssembly.Memory({
|
this.mem = new WebAssembly.Memory({
|
||||||
@@ -23,12 +29,26 @@ class Emulator {
|
|||||||
this.mem_u8[i] = 0;
|
this.mem_u8[i] = 0;
|
||||||
|
|
||||||
this.decoder = new TextDecoder('utf-8');
|
this.decoder = new TextDecoder('utf-8');
|
||||||
|
this.encoder = new TextEncoder('utf-8');
|
||||||
|
|
||||||
this.output = document.getElementById('output');
|
this.output = document.getElementById('output');
|
||||||
this.input = document.getElementById('input');
|
this.rx_queue = [];
|
||||||
|
|
||||||
this.timer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
|
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 = new Worker('boot.js');
|
||||||
this.worker.postMessage(this.mem);
|
this.worker.postMessage(this.mem);
|
||||||
}
|
}
|
||||||
@@ -36,21 +56,213 @@ class Emulator {
|
|||||||
poll() {
|
poll() {
|
||||||
const txhead = Atomics.load(this.mem_u8, TXHEAD);
|
const txhead = Atomics.load(this.mem_u8, TXHEAD);
|
||||||
const txtail = Atomics.load(this.mem_u8, TXTAIL);
|
const txtail = Atomics.load(this.mem_u8, TXTAIL);
|
||||||
if (txhead !== txtail)
|
if (txhead != txtail)
|
||||||
this.handle_txdata(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) {
|
fifo_next(idx) {
|
||||||
const data = [];
|
return (idx + 1) & 0x1f;
|
||||||
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);
|
|
||||||
|
|
||||||
const str = this.decoder.decode(new Uint8Array(data));
|
handle_tx_data(head, tail) {
|
||||||
this.output.innerText += str;
|
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 '<span id="cursor">' + ec + '</span>';
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,5 @@
|
|||||||
<body>
|
<body>
|
||||||
<script type="text/javascript" src="emu.js"></script>
|
<script type="text/javascript" src="emu.js"></script>
|
||||||
<div id="output"></div>
|
<div id="output"></div>
|
||||||
<input id="input" type="text" autofocus></input>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
34
styles.css
34
styles.css
@@ -1,17 +1,35 @@
|
|||||||
|
:root {
|
||||||
|
--bg: black;
|
||||||
|
--fg: white;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
line-height: 1.4em;
|
line-height: 1.5em;
|
||||||
font-family: 'Courier 10 Pitch', monospace;
|
font-family: 'Courier 10 Pitch', monospace;
|
||||||
font-size: 12pt;
|
font-size: 12pt;
|
||||||
background-color: black;
|
background-color: var(--bg);
|
||||||
color: white;
|
color: var(--fg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#output {
|
#output {
|
||||||
margin: 1.4em auto;
|
margin: 1.5em auto;
|
||||||
width: 80em;
|
white-space: pre;
|
||||||
height: 48em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#input {
|
#cursor {
|
||||||
display: none;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user