apple2js/js/ui/keyboard.ts
Ian Flanigan 04ae0327c2
Add the recommended eslint plugins for TypeScript (#121)
This adds both the recommended TypeScript checks, plus the recommended
TypeScript checks that require type checking.  This latter addition
means that eslint essentially has to compile all of the TypeScript in
the project, causing it to be slower. This isn't much of a problem in
VS Code because there's a lot of caching being done, but it's clearly
slower when run on the commandline.

All of the errors are either fixed or suppressed.  Some errors are
suppressed because fixing them would be too laborious for the little
value gained.

The eslint config is also slightly refactored to separate the strictly
TypeScript checks from the JavaScript checks.
2022-05-31 08:38:40 -07:00

562 lines
18 KiB
TypeScript

import { byte, DeepMemberOf, KnownKeys } from '../types';
import Apple2IO from '../apple2io';
import CPU6502 from '../cpu6502';
import { debug, toHex } from '../util';
// keycode: [plain, cntl, shift]
const keymap = {
// Most of these won't happen
0x00: [0x00, 0x00, 0x00], //
0x01: [0x01, 0x01, 0x01], //
0x02: [0x02, 0x02, 0x02], //
0x03: [0x03, 0x03, 0x03], //
0x04: [0x04, 0x04, 0x04], //
0x05: [0x05, 0x05, 0x05], //
0x06: [0x06, 0x06, 0x06], //
0x07: [0x07, 0x07, 0x07], //
0x08: [0x7F, 0x7F, 0x7F], // BS/DELETE
0x09: [0x09, 0x09, 0x09], // TAB
0x0A: [0x0A, 0x0A, 0x0A], //
0x0B: [0x0B, 0x0B, 0x0B], //
0x0C: [0x0C, 0x0C, 0x0C], //
0x0D: [0x0D, 0x0D, 0x0D], // CR
0x0E: [0x0E, 0x0E, 0x0E], //
0x0F: [0x0F, 0x0F, 0x0F], //
0x10: [0xff, 0xff, 0xff], // SHIFT
0x11: [0xff, 0xff, 0xff], // CTRL
0x12: [0xff, 0xff, 0xff], // ALT/OPTION
0x13: [0x13, 0x13, 0x13], //
0x14: [0x14, 0x14, 0x14], //
0x15: [0x15, 0x15, 0x15], //
0x16: [0x16, 0x16, 0x16], //
0x17: [0x17, 0x17, 0x18], //
0x18: [0x18, 0x18, 0x18], //
0x19: [0x19, 0x19, 0x19], //
0x1A: [0x1A, 0x1A, 0x1A], //
0x1B: [0x1B, 0x1B, 0x1B], // ESC
0x1C: [0x1C, 0x1C, 0x1C], //
0x1D: [0x1D, 0x1D, 0x1D], //
0x1E: [0x1E, 0x1E, 0x1E], //
0x1F: [0x1F, 0x1F, 0x1F], //
// Most of these besides space won't happen
0x20: [0x20, 0x20, 0x20], //
0x21: [0x21, 0x21, 0x21], //
0x22: [0x22, 0x22, 0x22], //
0x23: [0x23, 0x23, 0x23], //
0x24: [0x24, 0x24, 0x24], //
0x25: [0x08, 0x08, 0x08], // <- left
0x26: [0x0B, 0x0B, 0x0B], // ^ up
0x27: [0x15, 0x15, 0x15], // -> right
0x28: [0x0A, 0x0A, 0x0A], // v down
0x29: [0x29, 0x29, 0x29], // )
0x2A: [0x2A, 0x2A, 0x2A], // *
0x2B: [0x2B, 0x2B, 0x2B], // +
0x2C: [0x2C, 0x2C, 0x3C], // , - <
0x2D: [0x2D, 0x2D, 0x5F], // - - _
0x2E: [0x2E, 0x2E, 0x3E], // . - >
0x2F: [0x2F, 0x2F, 0x3F], // / - ?
0x30: [0x30, 0x30, 0x29], // 0 - )
0x31: [0x31, 0x31, 0x21], // 1 - !
0x32: [0x32, 0x00, 0x40], // 2 - @
0x33: [0x33, 0x33, 0x23], // 3 - #
0x34: [0x34, 0x34, 0x24], // 4 - $
0x35: [0x35, 0x35, 0x25], // 5 - %
0x36: [0x36, 0x36, 0x5E], // 6 - ^
0x37: [0x37, 0x37, 0x26], // 7 - &
0x38: [0x38, 0x38, 0x2A], // 8 - *
0x39: [0x39, 0x39, 0x28], // 9 - (
0x3A: [0x3A, 0x3A, 0x3A], // :
0x3B: [0x3B, 0x3B, 0x3A], // ; - :
0x3C: [0x3C, 0x3C, 0x3C], // <
0x3D: [0x3D, 0x3D, 0x2B], // = - +
0x3E: [0x3E, 0x3E, 0x3E], // >
0x3F: [0x3F, 0x3F, 0x3F], // ?
// Alpha and control
0x40: [0x40, 0x00, 0x40], // @
0x41: [0x61, 0x01, 0x41], // A
0x42: [0x62, 0x02, 0x42], // B
0x43: [0x63, 0x03, 0x43], // C - BRK
0x44: [0x64, 0x04, 0x44], // D
0x45: [0x65, 0x05, 0x45], // E
0x46: [0x66, 0x06, 0x46], // F
0x47: [0x67, 0x07, 0x47], // G - BELL
0x48: [0x68, 0x08, 0x48], // H
0x49: [0x69, 0x09, 0x49], // I - TAB
0x4A: [0x6A, 0x0A, 0x4A], // J - NL
0x4B: [0x6B, 0x0B, 0x4B], // K - VT
0x4C: [0x6C, 0x0C, 0x4C], // L
0x4D: [0x6D, 0x0D, 0x4D], // M - CR
0x4E: [0x6E, 0x0E, 0x4E], // N
0x4F: [0x6F, 0x0F, 0x4F], // O
0x50: [0x70, 0x10, 0x50], // P
0x51: [0x71, 0x11, 0x51], // Q
0x52: [0x72, 0x12, 0x52], // R
0x53: [0x73, 0x13, 0x53], // S
0x54: [0x74, 0x14, 0x54], // T
0x55: [0x75, 0x15, 0x55], // U
0x56: [0x76, 0x16, 0x56], // V
0x57: [0x77, 0x17, 0x57], // W
0x58: [0x78, 0x18, 0x58], // X
0x59: [0x79, 0x19, 0x59], // Y
0x5A: [0x7A, 0x1A, 0x5A], // Z
0x5B: [0xFF, 0xFF, 0xFF], // Left window
0x5C: [0xFF, 0xFF, 0xFF], // Right window
0x5D: [0xFF, 0xFF, 0xFF], // Select
0x5E: [0x5E, 0x1E, 0x5E], //
0x5F: [0x5F, 0x1F, 0x5F], // _
// Numeric pad
0x60: [0x30, 0x30, 0x30], // 0
0x61: [0x31, 0x31, 0x31], // 1
0x62: [0x32, 0x32, 0x32], // 2
0x63: [0x33, 0x33, 0x33], // 3
0x64: [0x34, 0x34, 0x34], // 4
0x65: [0x35, 0x35, 0x35], // 5
0x66: [0x36, 0x36, 0x36], // 6
0x67: [0x37, 0x37, 0x37], // 7
0x68: [0x38, 0x38, 0x38], // 8
0x69: [0x39, 0x39, 0x39], // 9
0x6A: [0x2A, 0x2A, 0x2A], // *
0x6B: [0x2B, 0x2B, 0x2B], // +
0x6D: [0x2D, 0x2D, 0x2D], // -
0x6E: [0x2E, 0x2E, 0x2E], // .
0x6F: [0x2F, 0x2F, 0x39], // /
// Stray keys
0xAD: [0x2D, 0x2D, 0x5F], // - - _
0xBA: [0x3B, 0x3B, 0x3A], // ; - :
0xBB: [0x3D, 0x3D, 0x2B], // = - +
0xBC: [0x2C, 0x2C, 0x3C], // , - <
0xBD: [0x2D, 0x2D, 0x5F], // - - _
0xBE: [0x2E, 0x2E, 0x3E], // . - >
0xBF: [0x2F, 0x2F, 0x3F], // / - ?
0xC0: [0x60, 0x60, 0x7E], // ` - ~
0xDB: [0x5B, 0x1B, 0x7B], // [ - {
0xDC: [0x5C, 0x1C, 0x7C], // \ - |
0xDD: [0x5D, 0x1D, 0x7D], // ] - }
0xDE: [0x27, 0x22, 0x22], // ' - '
0xFF: [0xFF, 0xFF, 0xFF] // No comma line
} as const;
function isKeyboardCode(code: number): code is KnownKeys<typeof keymap> {
return code in keymap;
}
const uiKitMap = {
'Dead': 0xFF,
'UIKeyInputLeftArrow': 0x08,
'UIKeyInputRightArrow': 0x15,
'UIKeyInputUpArrow': 0x0B,
'UIKeyInputDownArrow': 0x0A,
'UIKeyInputEscape': 0x1B
} as const;
function isUiKitKey(k: string): k is KnownKeys<typeof uiKitMap> {
return k in uiKitMap;
}
const keys2 = [
[
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', ':', '-', 'RESET'],
['ESC', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'REPT', 'RETURN'],
['CTRL', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ';', '&larr;', '&rarr;'],
['SHIFT', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ',', '.', '/', 'SHIFT'],
['POWER', '&nbsp;']
], [
['!', '"', '#', '$', '%', '&', '\'', '(', ')', '0', '*', '=', 'RESET'],
['ESC', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', '@', 'REPT', 'RETURN'],
['CTRL', 'A', 'S', 'D', 'F', 'BELL', 'H', 'J', 'K', 'L', '+', '&larr;', '&rarr;'],
['SHIFT', 'Z', 'X', 'C', 'V', 'B', '^', ']', '<', '>', '?', 'SHIFT'],
['POWER', '&nbsp;']
]
] as const;
type Key2 = DeepMemberOf<typeof keys2>;
const keys2e = [
[
['ESC', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 'DELETE'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '[', ']', '\\'],
['CTRL', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ';', '"', 'RETURN'],
['SHIFT', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ',', '.', '/', 'SHIFT'],
['LOCK', '`', 'POW', 'OPEN_APPLE', '&nbsp;', 'CLOSED_APPLE', '&larr;', '&rarr;', '&darr;', '&uarr;']
], [
['ESC', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', 'DELETE'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '|'],
['CTRL', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '\'', 'RETURN'],
['SHIFT', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?', 'SHIFT'],
['CAPS', '~', 'POW', 'OPEN_APPLE', '&nbsp;', 'CLOSED_APPLE', '&larr;', '&rarr;', '&darr;', '&uarr;']
]
] as const;
type Key2e = DeepMemberOf<typeof keys2e>;
type Key = Key2 | Key2e;
type KeyFunction = (key: KeyboardEvent) => void;
export default class KeyBoard {
private kb: HTMLElement;
private keys;
private shifted = false;
private controlled = false;
private capslocked = true;
// Initially caps lock on physical keyboard is assumed to be off,
// but on emulated keyboard it is on.
private capslockKeyUsed = false;
private optioned = false;
private commanded = false;
private functions: Record<string, KeyFunction> = {};
constructor(private cpu: CPU6502, private io: Apple2IO, private e: boolean) {
this.keys = e ? keys2e : keys2;
window.addEventListener('keydown', this.keydown);
window.addEventListener('keyup', this.keyup);
}
setFunction(key: string, fn: KeyFunction) {
this.functions[key] = fn;
}
mapKeyEvent(evt: KeyboardEvent) {
const code = evt.keyCode;
let key: byte = 0xff;
if (isUiKitKey(evt.key)) {
key = uiKitMap[evt.key];
} else if (isKeyboardCode(code)) {
key = keymap[code][evt.shiftKey ? 2 : (evt.ctrlKey ? 1 : 0)];
if (code !== 20 && this.capslockKeyUsed) {
this.capslockKey(evt.getModifierState('CapsLock'));
}
if (this.capslocked && key >= 0x61 && key <= 0x7A) {
key -= 0x20;
}
} else {
debug('Unhandled key = ' + toHex(code));
}
if (key === 0x7F && evt.shiftKey && evt.ctrlKey) {
this.cpu.reset();
key = 0xff;
}
return key;
}
shiftKey(down: boolean) {
const shiftKeys = this.kb.querySelectorAll('.key-SHIFT');
this.shifted = down;
if (down) {
this.io.buttonUp(2);
shiftKeys.forEach((key) => { key.classList.add('active'); });
} else {
this.io.buttonDown(2);
shiftKeys.forEach((key) => { key.classList.remove('active'); });
}
}
controlKey(down: boolean) {
const ctrlKey = this.kb.querySelector('.key-CTRL');
this.controlled = down;
if (down) {
ctrlKey!.classList.add('active');
} else {
ctrlKey!.classList.remove('active');
}
}
commandKey(down: boolean) {
const commandKey = this.kb.querySelector('.key-OPEN_APPLE');
if (!commandKey) {
return;
}
this.commanded = down;
if (down) {
this.io.buttonDown(0);
commandKey.classList.add('active');
} else {
this.io.buttonUp(0);
commandKey.classList.remove('active');
}
}
optionKey(down: boolean) {
const optionKey = this.kb.querySelector('.key-CLOSED_APPLE');
if (!optionKey) {
return;
}
this.optioned = down;
if (down) {
this.io.buttonDown(1);
optionKey.classList.add('active');
} else {
this.io.buttonUp(1);
optionKey.classList.remove('active');
}
}
/**
* Sets the state of the Caps Lock key. It is very complicated.
* @param down if `true`, Caps Lock is pressed; if `false` Caps Lock is not pressed;
* if `undefined`, Caps Lock is toggled and its "used" state is set to false;
* if called with no arguments, the state is toggled _if_ it has been used before,
* otherwise the used state is set to true.
*/
capslockKey(down?: boolean | undefined) {
const capsLock = this.kb.querySelector('.key-LOCK');
if (arguments.length === 0) {
if (this.capslockKeyUsed) {
this.capslocked = !this.capslocked;
} else {
this.capslockKeyUsed = true;
}
} else if (down === undefined) {
this.capslocked = !this.capslocked;
this.capslockKeyUsed = false;
} else {
this.capslocked = down;
}
if (this.capslocked) {
capsLock!.classList.add('active');
} else {
capsLock!.classList.remove('active');
}
}
reset(event: Event) {
event.preventDefault();
event.stopPropagation();
this.cpu.reset();
}
create(el: string) {
this.kb = document.querySelector(el)!;
let x, y, row, key, label, label1, label2;
const buildLabel = (k: string) => {
const span = document.createElement('span');
span.innerHTML = k;
if (k.length > 1 && k.substr(0, 1) !== '&')
span.classList.add('small');
return span;
};
for (y = 0; y < 5; y++) {
row = document.createElement('div');
row.classList.add('row');
row.classList.add(`row${y}`);
this.kb.append(row);
for (x = 0; x < this.keys[0][y].length; x++) {
const key1 = this.keys[0][y][x];
const key2 = this.keys[1][y][x];
label = document.createElement('div');
label1 = buildLabel(key1);
label2 = buildLabel(key2);
key = document.createElement('div');
key.classList.add('key');
key.classList.add('key-' + key1.replace(/[&#;]/g, ''));
if (key1.length > 1) {
if (key1 === 'LOCK')
key.classList.add('v-center2');
else
key.classList.add('v-center');
}
if (key1 !== key2) {
key.classList.add('key-' + key2.replace(/[&;]/g, ''));
label.append(label2);
label.append(document.createElement('br'));
}
if (key1 === 'LOCK') {
key.classList.add('active');
}
label.append(label1);
key.append(label);
key.dataset.key1 = key1;
key.dataset.key2 = key2;
const mouseDown = this.genMouseDown(key, key1, key2);
const mouseUp = this.genMouseUp(key);
if (window.ontouchstart === undefined) {
key.addEventListener('mousedown', mouseDown);
key.addEventListener('mouseup', mouseUp);
key.addEventListener('mouseleave', mouseUp);
} else {
key.addEventListener('touchstart', mouseDown);
key.addEventListener('touchend', mouseUp);
key.addEventListener('touchleave', mouseUp);
}
row.append(key);
}
}
}
private genMouseDown(target: HTMLElement, key1: Key, key2: Key) {
return (ev: MouseEvent) => {
ev.preventDefault();
target.classList.add('pressed');
let key: string = this.shifted ? key2 : key1;
switch (key) {
case 'BELL':
key = 'G';
break;
case 'RETURN':
key = '\r';
break;
case 'TAB':
key = '\t';
break;
case 'DELETE':
key = '\x7F';
break;
case '&larr;':
key = '\x08';
break;
case '&rarr;':
key = '\x15';
break;
case '&darr;':
key = '\x0A';
break;
case '&uarr;':
key = '\x0B';
break;
case '&nbsp;':
key = ' ';
break;
case 'ESC':
key = '\x1B';
break;
default:
break;
}
if (key.length > 1) {
switch (key) {
case 'SHIFT':
this.shiftKey(!this.shifted);
break;
case 'CTRL':
this.controlKey(!this.controlled);
break;
case 'CAPS':
case 'LOCK':
this.capslockKey(undefined);
break;
case 'POW':
case 'POWER':
if (window.confirm('Power Cycle?'))
window.location.reload();
break;
case 'RESET':
this.cpu.reset();
break;
case 'OPEN_APPLE':
this.commandKey(!this.commanded);
break;
case 'CLOSED_APPLE':
this.optionKey(!this.optioned);
break;
default:
break;
}
} else {
if (this.controlled && key >= '@' && key <= '_') {
this.io.keyDown(key.charCodeAt(0) - 0x40);
} else if (this.e && !this.shifted && !this.capslocked &&
key >= 'A' && key <= 'Z') {
this.io.keyDown(key.charCodeAt(0) + 0x20);
} else {
this.io.keyDown(key.charCodeAt(0));
}
}
};
}
private dialogOpen() {
return !!document.querySelector('.modal.is-open');
}
private genMouseUp(target: HTMLElement) {
return () => target.classList.remove('pressed');
}
private keydown = (evt: KeyboardEvent) => {
if (!this.dialogOpen() && (!evt.metaKey || evt.ctrlKey || this.e)) {
evt.preventDefault();
const key = this.mapKeyEvent(evt);
if (key !== 0xff) {
this.io.keyDown(key);
return;
}
}
if (evt.key === 'Shift') {
this.shiftKey(true);
} else if (evt.key === 'CapsLock') {
this.capslockKey();
} else if (evt.key === 'Control') {
this.controlKey(true);
} else if (evt.key === 'Meta') { // AKA Command
this.commandKey(true);
} else if (evt.key === 'Alt') {
if (evt.location === 1) {
this.commandKey(true);
} else {
this.optionKey(true);
}
} else {
if (evt.key in this.functions) {
this.functions[evt.key](evt);
evt.preventDefault();
}
}
};
private keyup = (evt: KeyboardEvent) => {
if (!this.dialogOpen()) {
this.io.keyUp();
}
if (evt.key === 'Shift') { // Shift
this.shiftKey(false);
} else if (evt.key === 'Control') { // Control
this.controlKey(false);
} else if (evt.key === 'Meta') { // AKA Command
this.commandKey(false);
} else if (evt.key === 'Alt') { // Alt
if (evt.location === 1) {
this.commandKey(false);
} else {
this.optionKey(false);
}
}
};
}