diff --git a/js/types.ts b/js/types.ts index 46fa1f4..213e83a 100644 --- a/js/types.ts +++ b/js/types.ts @@ -9,6 +9,29 @@ export type MemberOf> = T extends ReadonlyArray ? E : never; +/** + * Recursively extracts all members of a constant array as a type. Used as: + * + * @example + * const SOME_ARRAYS = [['a'],['b', 2], 3] as const; + * type SomeArrayValues = DeepMemberOf; // 'a' | 'b' | 2 | 3 + */ +export type DeepMemberOf> = + T extends ReadonlyArray + ? (E extends ReadonlyArray ? DeepMemberOf : E) + : never; + +/** + * Extracts the declared keys of a type by removing `string` and `number`. + * + * Cribbed from the interwebs: + * https://github.com/microsoft/TypeScript/issues/25987#issuecomment-408339599 + */ +export type KnownKeys = { + [K in keyof T]: string extends K ? never : number extends K ? never : K +} extends { [_ in keyof T]: infer U } ? U : never; + + /** A bit. */ export type bit = 0 | 1; diff --git a/js/ui/audio.js b/js/ui/audio.js deleted file mode 100644 index 8f4afd5..0000000 --- a/js/ui/audio.js +++ /dev/null @@ -1,93 +0,0 @@ -/* Copyright 2010-2019 Will Scullin - * - * Permission to use, copy, modify, distribute, and sell this software and its - * documentation for any purpose is hereby granted without fee, provided that - * the above copyright notice appear in all copies and that both that - * copyright notice and this permission notice appear in supporting - * documentation. No representations are made about the suitability of this - * software for any purpose. It is provided "as is" without express or - * implied warranty. - */ - -import { debug } from '../util'; - -/* - * Audio Handling - */ - -var SAMPLE_SIZE = 1024; -var SAMPLE_RATE = 44000; - -export default function Audio(io) { - var sound = true; - var _samples = []; - - var audioContext; - var AudioContext = window.AudioContext || window.webkitAudioContext; - var audioNode; - var started = false; - - if (AudioContext) { - audioContext = new AudioContext({ - sampleRate: SAMPLE_RATE - }); - audioNode = audioContext.createScriptProcessor(SAMPLE_SIZE, 1, 1); - - audioNode.onaudioprocess = function(event) { - var data = event.outputBuffer.getChannelData(0); - var sample = _samples.shift(); - var idx = 0; - - var len = data.length; - if (sample) { - len = Math.min(sample.length, len); - for (; idx < len; idx++) { - data[idx] = sample[idx]; - } - } - - for (; idx < data.length; idx++) { - data[idx] = 0.0; - } - }; - - audioNode.connect(audioContext.destination); - } - - function _initAudio(io) { - if (audioContext) { - debug('Using Webkit Audio'); - io.sampleRate(audioContext.sampleRate, SAMPLE_SIZE); - io.addSampleListener(function(sample) { - if (sound) { - if (_samples.length < 5) { - _samples.push(sample); - } - } - }); - } - } - - _initAudio(io); - - return { - autoStart: function () { - if (audioContext && !started) { - _samples = []; - audioContext.resume(); - started = true; - } - }, - - start: function () { - if (audioContext) { - _samples = []; - audioContext.resume(); - } - }, - - enable: function(enable) { - sound = enable; - } - }; -} diff --git a/js/ui/audio.ts b/js/ui/audio.ts new file mode 100644 index 0000000..143f931 --- /dev/null +++ b/js/ui/audio.ts @@ -0,0 +1,89 @@ +/* Copyright 2010-2019 Will Scullin + * + * Permission to use, copy, modify, distribute, and sell this software and its + * documentation for any purpose is hereby granted without fee, provided that + * the above copyright notice appear in all copies and that both that + * copyright notice and this permission notice appear in supporting + * documentation. No representations are made about the suitability of this + * software for any purpose. It is provided "as is" without express or + * implied warranty. + */ + +import Apple2IO from '../apple2io'; +import { debug } from '../util'; + +/* + * Audio Handling + */ + +const SAMPLE_SIZE = 1024; +const SAMPLE_RATE = 44000; + +export default class Audio { + private sound = true; + private samples: number[][] = []; + + private audioContext; + private audioNode; + private started = false; + + constructor(io: Apple2IO) { + this.audioContext = new AudioContext({ + sampleRate: SAMPLE_RATE + }); + + // TODO(flan): MDN says that createScriptProcessor is deprecated and + // replaced by AudioWorklet. FF and Chrome support AudioWorklet, but + // Safari does not (yet). + this.audioNode = this.audioContext.createScriptProcessor(SAMPLE_SIZE, 1, 1); + + this.audioNode.onaudioprocess = (event) => { + const data = event.outputBuffer.getChannelData(0); + const sample = this.samples.shift(); + let idx = 0; + let len = data.length; + + if (sample) { + len = Math.min(sample.length, len); + for (; idx < len; idx++) { + data[idx] = sample[idx]; + } + } + + for (; idx < data.length; idx++) { + data[idx] = 0.0; + } + }; + + this.audioNode.connect(this.audioContext.destination); + io.sampleRate(this.audioContext.sampleRate, SAMPLE_SIZE); + io.addSampleListener((sample: number[]) => { + if (this.sound) { + if (this.samples.length < 5) { + this.samples.push(sample); + } + } + }); + debug('Sound initialized'); + } + + + autoStart() { + if (this.audioContext && !this.started) { + this.samples = []; + this.audioContext.resume(); + this.started = true; + } + } + + start() { + if (this.audioContext) { + this.samples = []; + this.audioContext.resume(); + } + } + + enable(enable: boolean) { + this.sound = enable; + } +} diff --git a/js/ui/gamepad.js b/js/ui/gamepad.js deleted file mode 100644 index 132b14d..0000000 --- a/js/ui/gamepad.js +++ /dev/null @@ -1,122 +0,0 @@ -/* Copyright 2010-2019 Will Scullin - * - * Permission to use, copy, modify, distribute, and sell this software and its - * documentation for any purpose is hereby granted without fee, provided that - * the above copyright notice appear in all copies and that both that - * copyright notice and this permission notice appear in supporting - * documentation. No representations are made about the suitability of this - * software for any purpose. It is provided "as is" without express or - * implied warranty. - */ - -var getGamepads = navigator.getGamepads || navigator.webkitGetGamepads; -export var gamepad; -var gamepadMap = []; -var gamepadState = []; -var flipX = false; -var flipY = false; - -var BUTTON = { - // Buttons - 'A': 0, - 'B': 1, - 'X': 2, - 'Y': 3, - - // Triggers - 'L1': 4, - 'R1': 5, - - // Analog stick buttons - 'L3': 6, - 'R3': 7, - - // Special - 'START': 8, - 'SELECT': 9, - 'LOGO': 10, - - // D pad - 'UP': 11, - 'DOWN': 12, - 'LEFT': 13, - 'RIGHT': 14 -}; - -var DEFAULT_GAMEPAD = { - 'A': 0, - 'B': 1, - 'L1': 0, - 'R1': 1, - 'START': 0x1B -}; - -window.addEventListener('gamepadconnected', function(e) { - gamepad = e.gamepad; -}); - -export function processGamepad(io) { - if (getGamepads) { - gamepad = getGamepads.call(navigator)[0]; - } - if (gamepad) { - var x = (gamepad.axes[0] * 1.414 + 1) / 2.0; - var y = (gamepad.axes[1] * 1.414 + 1) / 2.0; - io.paddle(0, flipX ? 1.0 - x : x); - io.paddle(1, flipY ? 1.0 - y : y); - var val; - for (var idx = 0; idx < gamepad.buttons.length; idx++) { - val = gamepadMap[idx]; - if (val !== undefined) { - var old = gamepadState[idx]; - var button = gamepad.buttons[idx]; - var pressed; - if (typeof(button) == 'object') { - pressed = button.pressed; - } else { - pressed = (button == 1.0); - } - - if (pressed && !old) { - if (val <= 0) { - io.buttonDown(-val); - } else { - io.keyDown(gamepadMap[idx]); - } - } else if (!pressed && old) { - if (val <= 0) { - io.buttonUp(-val); - } else { - io.keyUp(); - } - } - gamepadState[idx] = pressed; - } - } - } -} - -export function configGamepad(configFlipX, configFlipY) { - flipX = configFlipX; - flipY = configFlipY; -} - -export function initGamepad(data) { - for (var idx = 0; idx < 16; idx++) { - gamepadMap[idx] = undefined; - } - var map = data || DEFAULT_GAMEPAD; - Object.keys(map).forEach(function(key) { - var val = map[key]; - if (typeof val == 'string') { - val = val.charCodeAt(0); - } else { - val = -val; - } - if (key in BUTTON) { - gamepadMap[BUTTON[key]] = val; - } else { - gamepadMap[parseInt(key, 10)] = val; - } - }); -} diff --git a/js/ui/gamepad.ts b/js/ui/gamepad.ts new file mode 100644 index 0000000..e40dd06 --- /dev/null +++ b/js/ui/gamepad.ts @@ -0,0 +1,152 @@ +/* Copyright 2010-2019 Will Scullin + * + * Permission to use, copy, modify, distribute, and sell this software and its + * documentation for any purpose is hereby granted without fee, provided that + * the above copyright notice appear in all copies and that both that + * copyright notice and this permission notice appear in supporting + * documentation. No representations are made about the suitability of this + * software for any purpose. It is provided "as is" without express or + * implied warranty. + */ + +import Apple2IO from '../apple2io'; +import { KnownKeys } from '../types'; + +export let gamepad: Gamepad | null = null; + +const BUTTON = { + // Buttons + 'A': 0, + 'B': 1, + 'X': 2, + 'Y': 3, + + // Triggers + 'L1': 4, + 'R1': 5, + + // Analog stick buttons + 'L3': 6, + 'R3': 7, + + // Special + 'START': 8, + 'SELECT': 9, + 'LOGO': 10, + + // D pad + 'UP': 11, + 'DOWN': 12, + 'LEFT': 13, + 'RIGHT': 14 +} as const; + +type ButtonType = KnownKeys; + +/** + * A `GamepadConfiguration` maps buttons on the controller to Apple Paddle + * buttons or keys on the keyboard. If the value is a number, it must be + * 0 | 1 | 2 and will map to the corresponding paddle button. If the value + * is a string, the _first_ character of the string is used as a key to + * press on the keyboard. + */ +export type GamepadConfiguration = { + [K in ButtonType]?: 0 | 1 | 2 | string; +}; + +const DEFAULT_GAMEPAD: GamepadConfiguration = { + 'A': 0, + 'B': 1, + 'L1': 0, + 'R1': 1, + 'START': '\x1B' +} as const; + +/** + * An array with 16 entries. For each entry _e_: + * + * * if _e_ <= 0, then _-e_ is 0 | 1 | 2 and represents a joystick button; + * * if _e_ > 0, then _e_ is a key on the keyboard that is pressed; + * * if _e_ is undefined, nothing happens. + */ +const gamepadMap: Array = []; +/** + * An array with 16 entries saying whether or not the given button is + * currently pressed. + */ +const gamepadState: boolean[] = []; +let flipX = false; +let flipY = false; + + +window.addEventListener('gamepadconnected', function (e: GamepadEvent) { + gamepad = e.gamepad; +}); + +export function processGamepad(io: Apple2IO) { + // Always use the first gamepad + gamepad = navigator.getGamepads()[0]; + if (!gamepad) { + return; + } + const x = (gamepad.axes[0] * 1.414 + 1) / 2.0; + const y = (gamepad.axes[1] * 1.414 + 1) / 2.0; + io.paddle(0, flipX ? 1.0 - x : x); + io.paddle(1, flipY ? 1.0 - y : y); + for (let idx = 0; idx < gamepad.buttons.length; idx++) { + const val = gamepadMap[idx]; + if (val !== undefined) { + const old = gamepadState[idx]; + const button = gamepad.buttons[idx]; + let pressed: boolean; + if (typeof button === 'object') { + pressed = button.pressed; + } else { + pressed = (button === 1.0); + } + + if (pressed && !old) { + if (val <= 0) { + io.buttonDown(-val as 0 | 1 | 2); + } else { + io.keyDown(gamepadMap[idx]!); + } + } else if (!pressed && old) { + if (val <= 0) { + io.buttonUp(-val as 0 | 1 | 2); + } else { + io.keyUp(); + } + } + gamepadState[idx] = pressed; + } + } +} + +export function configGamepad(configFlipX: boolean, configFlipY: boolean) { + flipX = configFlipX; + flipY = configFlipY; +} + +export function initGamepad(data?: GamepadConfiguration) { + // Clear map + for (let idx = 0; idx < 16; idx++) { + gamepadMap[idx] = undefined; + } + const map = data || DEFAULT_GAMEPAD; + for (const entry of Object.entries(map)) { + const key = entry[0] as ButtonType; + const val = entry[1] as number | string; + let mapVal; + if (typeof val === 'string') { + mapVal = val.charCodeAt(0); + } else { + mapVal = -val; + } + if (key in BUTTON) { + gamepadMap[BUTTON[key]] = mapVal; + } else { + gamepadMap[parseInt(key, 10)] = mapVal; + } + } +} diff --git a/js/ui/keyboard.js b/js/ui/keyboard.js deleted file mode 100644 index c342b14..0000000 --- a/js/ui/keyboard.js +++ /dev/null @@ -1,468 +0,0 @@ -/* Copyright 2010-2019 Will Scullin - * - * Permission to use, copy, modify, distribute, and sell this software and its - * documentation for any purpose is hereby granted without fee, provided that - * the above copyright notice appear in all copies and that both that - * copyright notice and this permission notice appear in supporting - * documentation. No representations are made about the suitability of this - * software for any purpose. It is provided "as is" without express or - * implied warranty. - */ - -import { debug, toHex } from '../util'; - -export default function KeyBoard(cpu, io, e) { - // keycode: [plain, cntl, shift] - var 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 - }; - - var uiKitMap = { - 'Dead': 0xFF, - 'UIKeyInputLeftArrow': 0x08, - 'UIKeyInputRightArrow': 0x15, - 'UIKeyInputUpArrow': 0x0B, - 'UIKeyInputDownArrow': 0x0A, - 'UIKeyInputEscape': 0x1B - }; - - var 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',';','←','→'], - ['SHIFT','Z','X','C','V','B','N','M',',','.','/','SHIFT'], - ['POWER', ' '] - ], [ - ['!','"','#','$','%','&','\'','(',')','0','*','=','RESET'], - ['ESC','Q','W','E','R','T','Y','U','I','O','@','REPT','RETURN'], - ['CTRL','A','S','D','F','BELL','H','J','K','L','+','←','→'], - ['SHIFT','Z','X','C','V','B','^',']','<','>','?','SHIFT'], - ['POWER', ' '] - ] - ]; - - var 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',' ','CLOSED_APPLE','←','→','↓','↑'] - ], [ - ['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',' ','CLOSED_APPLE','←','→','↓','↑'] - ] - ]; - - var keys = e ? keys2e : keys2; - - var shifted = false; - var controlled = false; - var capslocked = true; - // Initially caps lock on physical keyboard is assumed to be off, - // but on emulated keyboard it is on. - var capslockKeyUsed = false; - var optioned = false; - var commanded = false; - - var kb; - - return { - mapKeyEvent: function keyboard_mapKeyEvent(evt) { - var code = evt.keyCode, key = 0xff; - - if (evt.key in uiKitMap) { - key = uiKitMap[evt.key]; - } else if (code in keymap) { - key = keymap[code][evt.shiftKey ? 2 : (evt.ctrlKey ? 1 : 0)]; - - if (code != 20 && capslockKeyUsed) { - this.capslockKey(evt.getModifierState('CapsLock')); - } - - if (capslocked && key >= 0x61 && key <= 0x7A) { - key -= 0x20; - } - } else { - debug('Unhandled key = ' + toHex(code)); - } - - if (key == 0x7F && evt.shiftKey && evt.ctrlKey) { - cpu.reset(); - key = 0xff; - } - - return key; - }, - - shiftKey: function keyboard_shiftKey(down) { - var shiftKeys = kb.querySelectorAll('.key-SHIFT'); - shifted = down; - if (down) { - io.buttonUp(2); - shiftKeys.forEach(function(key) { key.classList.add('active'); }); - } else { - io.buttonDown(2); - shiftKeys.forEach(function(key) { key.classList.remove('active'); }); - } - }, - - controlKey: function keyboard_controlKey(down) { - var ctrlKey = kb.querySelector('.key-CTRL'); - controlled = down; - if (down) { - ctrlKey.classList.add('active'); - } else { - ctrlKey.classList.remove('active'); - } - }, - - commandKey: function keyboard_commandKey(down) { - var commandKey = kb.querySelector('.key-OPEN_APPLE'); - if (!commandKey) { - return; - } - commanded = down; - if (down) { - io.buttonDown(0); - commandKey.classList.add('active'); - } else { - io.buttonUp(0); - commandKey.classList.remove('active'); - } - }, - - optionKey: function keyboard_optionKey(down) { - var optionKey = kb.querySelector('.key-CLOSED_APPLE'); - if (!optionKey) { - return; - } - optioned = down; - if (down) { - io.buttonDown(1); - optionKey.classList.add('active'); - } else { - io.buttonUp(1); - optionKey.classList.remove('active'); - } - }, - - capslockKey: function keyboard_caplockKey(down) { - var capsLock = kb.querySelector('.key-LOCK'); - - if (arguments.length == 0) { - if (capslockKeyUsed) { - capslocked = !capslocked; - } else { - capslockKeyUsed = true; - } - } else if (down === undefined) { - capslocked = !capslocked; - capslockKeyUsed = false; - } else { - capslocked = down; - } - - if (capslocked) { - capsLock.classList.add('active'); - } else { - capsLock.classList.remove('active'); - } - }, - - reset: function keyboard_reset(event) { - event.preventDefault(); - event.stopPropagation(); - cpu.reset(); - }, - - create: function keyboard_create(el) { - kb = document.querySelector(el); - var x, y, row, key, key1, key2, label, label1, label2, self = this; - - function buildLabel(k) { - var span = document.createElement('span'); - span.innerHTML = k; - if (k.length > 1 && k.substr(0,1) != '&') - span.classList.add('small'); - return span; - } - - function _mouseup() { - this.classList.remove('pressed'); - } - - function _mousedown(ev) { - ev.preventDefault(); - this.classList.add('pressed'); - var key = this.dataset[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 '←': - key = '\x08'; - break; - case '→': - key = '\x15'; - break; - case '↓': - key = '\x0A'; - break; - case '↑': - key = '\x0B'; - break; - case ' ': - key = ' '; - break; - case 'ESC': - key = '\x1B'; - break; - default: - break; - } - - if (key.length > 1) { - switch (key) { - case 'SHIFT': - self.shiftKey(!shifted); - break; - case 'CTRL': - self.controlKey(!controlled); - break; - case 'CAPS': - case 'LOCK': - self.capslockKey(undefined); - break; - case 'POW': - case 'POWER': - if (window.confirm('Power Cycle?')) - window.location.reload(); - break; - case 'RESET': - cpu.reset(); - break; - case 'OPEN_APPLE': - self.commandKey(!commanded); - break; - case 'CLOSED_APPLE': - self.optionKey(!optioned); - break; - default: - break; - } - } else { - if (controlled && key >= '@' && key <= '_') { - io.keyDown(key.charCodeAt(0) - 0x40); - } else if (!e && !shifted && !capslocked && - key >= 'A' && key <= 'Z') { - io.keyDown(key.charCodeAt(0) + 0x20); - } else { - io.keyDown(key.charCodeAt(0)); - } - } - } - - for (y = 0; y < 5; y++) { - row = document.createElement('div'); - row.classList.add('row'); - row.classList.add('row' + y); - kb.append(row); - for (x = 0; x < keys[0][y].length; x++) { - key1 = keys[0][y][x]; - key2 = 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; - - 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); - } - } - } - }; -} diff --git a/js/ui/keyboard.ts b/js/ui/keyboard.ts new file mode 100644 index 0000000..913dc7c --- /dev/null +++ b/js/ui/keyboard.ts @@ -0,0 +1,503 @@ +/* Copyright 2010-2019 Will Scullin + * + * Permission to use, copy, modify, distribute, and sell this software and its + * documentation for any purpose is hereby granted without fee, provided that + * the above copyright notice appear in all copies and that both that + * copyright notice and this permission notice appear in supporting + * documentation. No representations are made about the suitability of this + * software for any purpose. It is provided "as is" without express or + * implied warranty. + */ + +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 { + 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 { + 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', ';', '←', '→'], + ['SHIFT', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ',', '.', '/', 'SHIFT'], + ['POWER', ' '] + ], [ + ['!', '"', '#', '$', '%', '&', '\'', '(', ')', '0', '*', '=', 'RESET'], + ['ESC', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', '@', 'REPT', 'RETURN'], + ['CTRL', 'A', 'S', 'D', 'F', 'BELL', 'H', 'J', 'K', 'L', '+', '←', '→'], + ['SHIFT', 'Z', 'X', 'C', 'V', 'B', '^', ']', '<', '>', '?', 'SHIFT'], + ['POWER', ' '] + ] +] as const; + + +type Key2 = DeepMemberOf; + +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', ' ', 'CLOSED_APPLE', '←', '→', '↓', '↑'] + ], [ + ['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', ' ', 'CLOSED_APPLE', '←', '→', '↓', '↑'] + ] +] as const; + +type Key2e = DeepMemberOf; + +type Key = Key2 | Key2e; + +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; + + constructor(private cpu: CPU6502, private io: Apple2IO, private e: boolean) { + this.keys = e ? keys2e : keys2; + } + + 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 '←': + key = '\x08'; + break; + case '→': + key = '\x15'; + break; + case '↓': + key = '\x0A'; + break; + case '↑': + key = '\x0B'; + break; + case ' ': + 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 genMouseUp(target: HTMLElement) { + return () => target.classList.remove('pressed'); + } +} diff --git a/js/ui/printer.js b/js/ui/printer.js deleted file mode 100644 index e0bb845..0000000 --- a/js/ui/printer.js +++ /dev/null @@ -1,86 +0,0 @@ -/* Copyright 2010-2019 Will Scullin - * - * Permission to use, copy, modify, distribute, and sell this software and its - * documentation for any purpose is hereby granted without fee, provided that - * the above copyright notice appear in all copies and that both that - * copyright notice and this permission notice appear in supporting - * documentation. No representations are made about the suitability of this - * software for any purpose. It is provided "as is" without express or - * implied warranty. - */ - -/** - * Printer UI. The "paper" is bound to the element selected by the input. - * - * Every line that is output to the printer is added as a
to the paper. - * The high bit of all characters is stripped and only visible characters are - * added to the output. The following characters receive special treatment: - * - * * `EOT` (ASCII 4): deletes last character - * * `HT` (ASCII 9): replaced with 8 spaces - * * `LF` (ASCII 10): silently removed - * * `CR` (ASCII 13): a newline and carriage return - * - * @param {string} el The selector of the element on which to bind the "paper". - */ -export default function Printer(el) { - var paper = document.querySelector(el); - var _lineBuffer = ''; - var _line; - var _rawLen = 0; - var _raw = new Uint8Array(1024); - - function newLine() { - _line = document.createElement('div'); - _line.classList.add('line'); - _line.innerText = _lineBuffer; - paper.append(_line); - _lineBuffer = ''; - } - - newLine(); - - return { - putChar: function(val) { - var ascii = val & 0x7f; - var visible = val >= 0x20; - var c = String.fromCharCode(ascii); - - if (c === '\r') { - newLine(); - } else if (c === '\n') { - // eat for now - } else if (c === '\t') { - _lineBuffer += ' '; - } else if (ascii === 0x04) { - _lineBuffer = _lineBuffer.slice(0, -1); - } else if (visible) { - _lineBuffer += c; - } - _line.innerText = _lineBuffer; - _raw[_rawLen] = val; - _rawLen++; - if (_rawLen > _raw.length) { - const newRaw = new Uint8Array(_raw.length * 2); - newRaw.set(_raw); - _raw = newRaw; - } - }, - - clear: function() { - _lineBuffer = ''; - paper.innerHTML = ''; - newLine(); - _raw = new Uint8Array(1024); - _rawLen = 0; - }, - - hasPrintout: function() { - return paper.text.length; - }, - - getRawOutput: function() { - return _raw.slice(0, _rawLen); - } - }; -} diff --git a/js/ui/printer.ts b/js/ui/printer.ts new file mode 100644 index 0000000..f329c39 --- /dev/null +++ b/js/ui/printer.ts @@ -0,0 +1,92 @@ +/* Copyright 2010-2019 Will Scullin + * + * Permission to use, copy, modify, distribute, and sell this software and its + * documentation for any purpose is hereby granted without fee, provided that + * the above copyright notice appear in all copies and that both that + * copyright notice and this permission notice appear in supporting + * documentation. No representations are made about the suitability of this + * software for any purpose. It is provided "as is" without express or + * implied warranty. + */ + +import { byte } from '../types'; + +/** + * Printer UI. The "paper" is bound to the element selected by the input. + * + * Every line that is output to the printer is added as a
to the paper. + * The high bit of all characters is stripped and only visible characters are + * added to the output. The following characters receive special treatment: + * + * * `EOT` (ASCII 4): deletes last character + * * `HT` (ASCII 9): replaced with 8 spaces + * * `LF` (ASCII 10): silently removed + * * `CR` (ASCII 13): a newline and carriage return + */ +export default class Printer { + private paper: HTMLElement; + private _lineBuffer = ''; + private _line: HTMLElement; + private _rawLen = 0; + private _raw = new Uint8Array(1024); + + /** + * Creates a new printer bound to the given element. + * @param {string} el The selector of the element on which to bind the "paper". + */ + constructor(el: string) { + this.paper = document.querySelector(el)!; + this.newLine(); + } + + private newLine() { + this._line = document.createElement('div'); + this._line.classList.add('line'); + this._line.innerText = this._lineBuffer; + this.paper.append(this._line); + this._lineBuffer = ''; + } + + putChar(val: byte) { + const ascii = val & 0x7f; + const visible = val >= 0x20; + const c = String.fromCharCode(ascii); + + if (c === '\r') { + this.newLine(); + } else if (c === '\n') { + // eat for now + } else if (c === '\t') { + // possibly not right due to tab stops + this._lineBuffer += ' '; + } else if (ascii === 0x04) { + this._lineBuffer = this._lineBuffer.slice(0, -1); + } else if (visible) { + this._lineBuffer += c; + } + this._line.innerText = this._lineBuffer; + this._raw[this._rawLen] = val; + this._rawLen++; + if (this._rawLen > this._raw.length) { + const newRaw = new Uint8Array(this._raw.length * 2); + newRaw.set(this._raw); + this._raw = newRaw; + } + } + + clear() { + this._lineBuffer = ''; + this.paper.innerHTML = ''; + this.newLine(); + this._raw = new Uint8Array(1024); + this._rawLen = 0; + } + + hasPrintout() { + return this.paper.innerText.length > 0; + } + + getRawOutput() { + return this._raw.slice(0, this._rawLen); + } +} diff --git a/js/ui/tape.js b/js/ui/tape.js deleted file mode 100644 index 5855b54..0000000 --- a/js/ui/tape.js +++ /dev/null @@ -1,81 +0,0 @@ - -/* Copyright 2010-2019 Will Scullin - * - * Permission to use, copy, modify, distribute, and sell this software and its - * documentation for any purpose is hereby granted without fee, provided that - * the above copyright notice appear in all copies and that both that - * copyright notice and this permission notice appear in supporting - * documentation. No representations are made about the suitability of this - * software for any purpose. It is provided "as is" without express or - * implied warranty. - */ - -import { debug } from '../util'; - -export var TAPE_TYPES = ['wav','aiff','aif','mp3','m4a']; - -export default function Tape(io) { - var AudioContext = window.AudioContext || window.webkitAudioContext; - - return { - doLoadLocalTape: function(file, done) { - var kHz = io.getKHz(); - - // Audio Buffer Source - var context; - if (AudioContext) { - context = new AudioContext(); - } else { - window.alert('Not supported by your browser'); - done(); - return; - } - - var fileReader = new FileReader(); - fileReader.onload = function(ev) { - context.decodeAudioData(ev.target.result, function(buffer) { - var buf = []; - var data = buffer.getChannelData(0), datum = data[0]; - var old = (datum > 0.0), current; - var last = 0, delta, ival; - debug('Sample Count: ' + data.length); - debug('Sample rate: ' + buffer.sampleRate); - for (var idx = 1; idx < data.length; idx++) { - datum = data[idx]; - if ((datum > 0.1) || (datum < -0.1)) { - current = (datum > 0.0); - if (current != old) { - delta = idx - last; - if (delta > 2000000) { - delta = 2000000; - } - ival = delta / buffer.sampleRate * 1000; - if (ival >= 0.550 && ival < 0.750) { - ival = 0.650; // Header - } else if (ival >= 0.175 && ival < 0.225) { - ival = 0.200; // sync 1 - } else if (ival >= 0.225 && ival < 0.275) { - ival = 0.250; // 0 / sync 2 - } else if (ival >= 0.450 && ival < 0.550) { - ival = 0.500; // 1 - } else { - // debug(idx + ' ' + buf.length + ' ' + ival); - } - buf.push([parseInt(ival * kHz), current]); - old = current; - last = idx; - } - } - } - io.setTape(buf); - if (done) { - done(); - } - }, function(error) { - window.alert(error.message); - }); - }; - fileReader.readAsArrayBuffer(file); - } - }; -} diff --git a/js/ui/tape.ts b/js/ui/tape.ts new file mode 100644 index 0000000..497f27e --- /dev/null +++ b/js/ui/tape.ts @@ -0,0 +1,85 @@ + +/* Copyright 2010-2019 Will Scullin + * + * Permission to use, copy, modify, distribute, and sell this software and its + * documentation for any purpose is hereby granted without fee, provided that + * the above copyright notice appear in all copies and that both that + * copyright notice and this permission notice appear in supporting + * documentation. No representations are made about the suitability of this + * software for any purpose. It is provided "as is" without express or + * implied warranty. + */ + +import { TapeData } from '../types'; +import Apple2IO from '../apple2io'; +import { debug } from '../util'; + +export const TAPE_TYPES = ['wav', 'aiff', 'aif', 'mp3', 'm4a'] as const; + +export default class Tape { + constructor(private readonly io: Apple2IO) {} + + public doLoadLocalTape(file: File, done: () => void) { + const kHz = this.io.getKHz(); + + // Audio Buffer Source + let context: AudioContext; + if (AudioContext) { + context = new AudioContext(); + } else { + window.alert('Not supported by your browser'); + done(); + return; + } + + const fileReader = new FileReader(); + fileReader.onload = (ev: ProgressEvent) => { + const target: FileReader = ev.target as FileReader; + const result: ArrayBuffer = target.result as ArrayBuffer; + context.decodeAudioData(result, (buffer) => { + const buf: TapeData = []; + const data = buffer.getChannelData(0); + let datum = data[0]; + let old = (datum > 0.0), current; + let last = 0; + let delta: number; + debug('Sample Count: ' + data.length); + debug('Sample rate: ' + buffer.sampleRate); + for (let idx = 1; idx < data.length; idx++) { + datum = data[idx]; + if ((datum > 0.1) || (datum < -0.1)) { + current = (datum > 0.0); + if (current != old) { + delta = idx - last; + if (delta > 2000000) { + delta = 2000000; + } + let ival = delta / buffer.sampleRate * 1000; + if (ival >= 0.550 && ival < 0.750) { + ival = 0.650; // Header + } else if (ival >= 0.175 && ival < 0.225) { + ival = 0.200; // sync 1 + } else if (ival >= 0.225 && ival < 0.275) { + ival = 0.250; // 0 / sync 2 + } else if (ival >= 0.450 && ival < 0.550) { + ival = 0.500; // 1 + } else { + // debug(idx + ' ' + buf.length + ' ' + ival); + } + buf.push([ival * kHz, current]); + old = current; + last = idx; + } + } + } + this.io.setTape(buf); + if (done) { + done(); + } + }, function (error) { + window.alert(error.message); + }); + }; + fileReader.readAsArrayBuffer(file); + } +}