Preact UI (#106)
First pass at a Preact UI, still short some major features but full proof of concept.
This commit is contained in:
parent
702089224f
commit
4a188a9a5c
|
@ -20,7 +20,8 @@
|
||||||
"prefer-const": [
|
"prefer-const": [
|
||||||
"error"
|
"error"
|
||||||
],
|
],
|
||||||
"semi": [
|
"semi": "off",
|
||||||
|
"@typescript-eslint/semi": [
|
||||||
"error",
|
"error",
|
||||||
"always"
|
"always"
|
||||||
],
|
],
|
||||||
|
@ -52,7 +53,8 @@
|
||||||
"error"
|
"error"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"@typescript-eslint/require-await": ["error"]
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"builtin": true,
|
"builtin": true,
|
||||||
|
@ -120,5 +122,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ignorePatterns": ["coverage/**/*"]
|
"ignorePatterns": ["coverage/**/*"],
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"pragma": "h"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,5 +9,19 @@ module.exports = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'@babel/typescript',
|
||||||
|
{
|
||||||
|
jsxPragma: 'h'
|
||||||
|
}
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
'@babel/plugin-transform-react-jsx', {
|
||||||
|
pragma: 'h',
|
||||||
|
pragmaFrag: 'Fragment',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
|
@ -122,6 +122,10 @@ body {
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.disk-light.on {
|
||||||
|
background-image: url(red-on-16.png);
|
||||||
|
}
|
||||||
|
|
||||||
.disk-label {
|
.disk-label {
|
||||||
color: #000;
|
color: #000;
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
|
@ -583,6 +587,7 @@ button:focus {
|
||||||
|
|
||||||
#reset-row .inset {
|
#reset-row .inset {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
width: 604px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#reset {
|
#reset {
|
||||||
|
@ -650,7 +655,6 @@ button:focus {
|
||||||
|
|
||||||
#options-modal {
|
#options-modal {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
line-height: 1.75em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#options-modal h3 {
|
#options-modal h3 {
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>PreApple II</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="css/apple2.css" />
|
||||||
|
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.2/css/all.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="dist/preact.bundle.js"></script>
|
||||||
|
</body>
|
29
js/apple2.ts
29
js/apple2.ts
|
@ -33,25 +33,26 @@ import { processGamepad } from './ui/gamepad';
|
||||||
|
|
||||||
export interface Apple2Options {
|
export interface Apple2Options {
|
||||||
characterRom: string;
|
characterRom: string;
|
||||||
enhanced: boolean,
|
enhanced: boolean;
|
||||||
e: boolean,
|
e: boolean;
|
||||||
gl: boolean,
|
gl: boolean;
|
||||||
rom: string,
|
rom: string;
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement;
|
||||||
tick: () => void,
|
tick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stats {
|
export interface Stats {
|
||||||
frames: number,
|
cycles: number;
|
||||||
renderedFrames: number,
|
frames: number;
|
||||||
|
renderedFrames: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
cpu: CpuState,
|
cpu: CpuState;
|
||||||
vm: VideoModesState,
|
vm: VideoModesState;
|
||||||
io: Apple2IOState,
|
io: Apple2IOState;
|
||||||
mmu?: MMUState,
|
mmu?: MMUState;
|
||||||
ram?: RAMState[],
|
ram?: RAMState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Apple2 implements Restorable<State>, DebuggerContainer {
|
export class Apple2 implements Restorable<State>, DebuggerContainer {
|
||||||
|
@ -78,6 +79,7 @@ export class Apple2 implements Restorable<State>, DebuggerContainer {
|
||||||
private tick: () => void;
|
private tick: () => void;
|
||||||
|
|
||||||
private stats: Stats = {
|
private stats: Stats = {
|
||||||
|
cycles: 0,
|
||||||
frames: 0,
|
frames: 0,
|
||||||
renderedFrames: 0
|
renderedFrames: 0
|
||||||
};
|
};
|
||||||
|
@ -186,6 +188,7 @@ export class Apple2 implements Restorable<State>, DebuggerContainer {
|
||||||
this.stats.renderedFrames++;
|
this.stats.renderedFrames++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.stats.cycles = this.cpu.getCycles();
|
||||||
this.stats.frames++;
|
this.stats.frames++;
|
||||||
this.io.tick();
|
this.io.tick();
|
||||||
this.tick();
|
this.tick();
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import 'preact/debug';
|
||||||
|
import { h, Fragment } from 'preact';
|
||||||
|
import { Header } from './Header';
|
||||||
|
import { Apple2 } from './Apple2';
|
||||||
|
import { usePrefs } from './hooks/usePrefs';
|
||||||
|
import { SYSTEM_TYPE_APPLE2E } from '../ui/system';
|
||||||
|
import { SCREEN_GL } from '../ui/screen';
|
||||||
|
import { defaultSystem, systemTypes } from './util/systems';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top level application component, provides the parameters
|
||||||
|
* needed by the Apple2 component to bootstrap itself.
|
||||||
|
*
|
||||||
|
* @returns Application component
|
||||||
|
*/
|
||||||
|
export const App = () => {
|
||||||
|
const prefs = usePrefs();
|
||||||
|
const systemType = prefs.readPref(SYSTEM_TYPE_APPLE2E, 'apple2enh');
|
||||||
|
const gl = prefs.readPref(SCREEN_GL, 'true') === 'true';
|
||||||
|
|
||||||
|
const system = {
|
||||||
|
...defaultSystem,
|
||||||
|
...(systemTypes[systemType] || {})
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header e={system.e} />
|
||||||
|
<Apple2
|
||||||
|
gl={gl}
|
||||||
|
{...system}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { h } from 'preact';
|
||||||
|
import cs from 'classnames';
|
||||||
|
import {useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { Apple2 as Apple2Impl } from '../apple2';
|
||||||
|
import Apple2IO from '../apple2io';
|
||||||
|
import { ControlStrip } from './ControlStrip';
|
||||||
|
import { Inset } from './Inset';
|
||||||
|
import { Keyboard } from './Keyboard';
|
||||||
|
import { Screen } from './Screen';
|
||||||
|
import { Drives } from './Drives';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for the Apple2 component.
|
||||||
|
*/
|
||||||
|
export interface Apple2Props {
|
||||||
|
characterRom: string;
|
||||||
|
enhanced: boolean;
|
||||||
|
e: boolean;
|
||||||
|
gl: boolean;
|
||||||
|
rom: string;
|
||||||
|
sectors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to bind various UI components together to form
|
||||||
|
* the application layout. Includes the screen, drives,
|
||||||
|
* emulator controls and keyboard. Bootstraps the core
|
||||||
|
* Apple2 emulator.
|
||||||
|
*
|
||||||
|
* @param props Apple2 initialization props
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const Apple2 = (props: Apple2Props) => {
|
||||||
|
const { e, sectors } = props;
|
||||||
|
const screen = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [apple2, setApple2] = useState<Apple2Impl>();
|
||||||
|
const [io, setIO] = useState<Apple2IO>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (screen.current) {
|
||||||
|
const options = {
|
||||||
|
canvas: screen.current,
|
||||||
|
tick: () => {},
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
const apple2 = new Apple2Impl(options);
|
||||||
|
setApple2(apple2);
|
||||||
|
apple2.ready.then(() => {
|
||||||
|
const io = apple2.getIO();
|
||||||
|
setIO(io);
|
||||||
|
apple2.getCPU().reset();
|
||||||
|
apple2.run();
|
||||||
|
}).catch(error => console.error(error));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cs('outer', { apple2e: e})}>
|
||||||
|
<Screen screen={screen} />
|
||||||
|
<Inset>
|
||||||
|
<Drives io={io} sectors={sectors} />
|
||||||
|
</Inset>
|
||||||
|
<ControlStrip apple2={apple2} e={e} />
|
||||||
|
<Inset>
|
||||||
|
<Keyboard apple2={apple2} e={e} />
|
||||||
|
</Inset>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { h } from 'preact';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { Apple2 as Apple2Impl } from '../apple2';
|
||||||
|
import type { Stats } from '../apple2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for CPUMeter.
|
||||||
|
*/
|
||||||
|
export interface CPUMeterProps {
|
||||||
|
apple2: Apple2Impl | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple display that can cycle between emulator Khz
|
||||||
|
* performance, frames/second and rendered frames/second.
|
||||||
|
*
|
||||||
|
* @param apple2 Apple2 object
|
||||||
|
* @returns CPU Meter component
|
||||||
|
*/
|
||||||
|
export const CPUMeter = ({ apple2 }: CPUMeterProps) => {
|
||||||
|
const lastStats = useRef<Stats>({
|
||||||
|
frames: 0,
|
||||||
|
renderedFrames: 0,
|
||||||
|
cycles: 0,
|
||||||
|
});
|
||||||
|
const lastTime = useRef<number>(Date.now());
|
||||||
|
const [khz, setKhz] = useState<number>(0);
|
||||||
|
const [fps, setFps] = useState<number>(0);
|
||||||
|
const [rps, setRps] = useState<number>(0);
|
||||||
|
const [mode, setMode] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const { cycles, frames, renderedFrames } = lastStats.current;
|
||||||
|
const stats = apple2?.getStats();
|
||||||
|
const time = Date.now();
|
||||||
|
const delta = time - lastTime.current;
|
||||||
|
if (stats) {
|
||||||
|
setKhz(
|
||||||
|
Math.floor(
|
||||||
|
(stats.cycles - cycles) / delta
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setFps(
|
||||||
|
Math.floor(
|
||||||
|
(stats.frames - frames) / delta * 1000
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setRps(
|
||||||
|
Math.floor(
|
||||||
|
(stats.renderedFrames - renderedFrames) / delta * 1000
|
||||||
|
)
|
||||||
|
);
|
||||||
|
lastStats.current = { ...stats };
|
||||||
|
lastTime.current = time;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [apple2]);
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
setMode((mode) => (mode + 1) % 3);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="khz" onClick={onClick}>
|
||||||
|
{mode === 0 && `${khz} Khz`}
|
||||||
|
{mode === 1 && `${fps} fps`}
|
||||||
|
{mode === 2 && `${rps} rps`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { h, JSX } from 'preact';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for ControlButton.
|
||||||
|
*/
|
||||||
|
export interface ControlButtonProps {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
onClick: JSX.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple button with an icon, tooltip text and a callback.
|
||||||
|
*
|
||||||
|
* @param icon FontAwesome icon name
|
||||||
|
* @param title Tooltip text
|
||||||
|
* @param onClick Click callback
|
||||||
|
* @returns Control Button component
|
||||||
|
*/
|
||||||
|
export const ControlButton = ({ icon, title, onClick }: ControlButtonProps) => (
|
||||||
|
<button onClick={onClick} title={title}>
|
||||||
|
<i class={`fas fa-${icon}`}></i>
|
||||||
|
</button>
|
||||||
|
);
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { h } from 'preact';
|
||||||
|
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
|
||||||
|
import { CPUMeter } from './CPUMeter';
|
||||||
|
import { Inset } from './Inset';
|
||||||
|
import { useHotKey } from './hooks/useHotKey';
|
||||||
|
import { Apple2 as Apple2Impl } from '../apple2';
|
||||||
|
import { Audio, SOUND_ENABLED_OPTION } from '../ui/audio';
|
||||||
|
import { OptionsModal} from './OptionsModal';
|
||||||
|
import { OptionsContext } from './OptionsContext';
|
||||||
|
import { ControlButton } from './ControlButton';
|
||||||
|
import { JoyStick } from '../ui/joystick';
|
||||||
|
import { Screen, SCREEN_FULL_PAGE } from '../ui/screen';
|
||||||
|
import { System } from '../ui/system';
|
||||||
|
|
||||||
|
const README = 'https://github.com/whscullin/apple2js#readme';
|
||||||
|
|
||||||
|
interface ControlStripProps {
|
||||||
|
apple2: Apple2Impl | undefined;
|
||||||
|
e: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip containing containing controls for various system
|
||||||
|
* characteristics, like CPU speed, audio, and the system
|
||||||
|
* options panel.
|
||||||
|
*
|
||||||
|
* @param apple2 Apple2 object
|
||||||
|
* @param e Whether or not this is a //e
|
||||||
|
* @returns ControlStrip component
|
||||||
|
*/
|
||||||
|
export const ControlStrip = ({ apple2, e }: ControlStripProps) => {
|
||||||
|
const [running, setRunning] = useState(true);
|
||||||
|
const [audio, setAudio] = useState<Audio>();
|
||||||
|
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||||
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
|
const options = useContext(OptionsContext);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (apple2) {
|
||||||
|
apple2.ready.then(() => {
|
||||||
|
const io = apple2.getIO();
|
||||||
|
const vm = apple2.getVideoModes();
|
||||||
|
|
||||||
|
const system = new System(io, e);
|
||||||
|
options.addOptions(system);
|
||||||
|
|
||||||
|
const joystick = new JoyStick(io);
|
||||||
|
options.addOptions(joystick);
|
||||||
|
|
||||||
|
const screen = new Screen(vm);
|
||||||
|
options.addOptions(screen);
|
||||||
|
|
||||||
|
const audio = new Audio(io);
|
||||||
|
options.addOptions(audio);
|
||||||
|
setAudio(audio);
|
||||||
|
setAudioEnabled(audio.isEnabled());
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
}, [apple2]);
|
||||||
|
|
||||||
|
const doPause = useCallback(() => {
|
||||||
|
apple2?.stop();
|
||||||
|
setRunning(false);
|
||||||
|
}, [apple2]);
|
||||||
|
|
||||||
|
const doRun = useCallback(() => {
|
||||||
|
apple2?.run();
|
||||||
|
setRunning(true);
|
||||||
|
}, [apple2]);
|
||||||
|
|
||||||
|
const doToggleSound = useCallback(() => {
|
||||||
|
const on = !audio?.isEnabled();
|
||||||
|
options.setOption(SOUND_ENABLED_OPTION, on);
|
||||||
|
setAudioEnabled(on);
|
||||||
|
}, [audio]);
|
||||||
|
|
||||||
|
const doReset = useCallback(() =>
|
||||||
|
apple2?.reset()
|
||||||
|
, [apple2]);
|
||||||
|
|
||||||
|
const doReadme = useCallback(() =>
|
||||||
|
window.open(README, '_blank')
|
||||||
|
, []);
|
||||||
|
|
||||||
|
const doShowOptions = useCallback(() =>
|
||||||
|
setShowOptions(true)
|
||||||
|
, []);
|
||||||
|
|
||||||
|
const doCloseOptions = useCallback(() =>
|
||||||
|
setShowOptions(false)
|
||||||
|
, []);
|
||||||
|
|
||||||
|
const doToggleFullPage = useCallback(() =>
|
||||||
|
options.setOption(
|
||||||
|
SCREEN_FULL_PAGE,
|
||||||
|
!options.getOption(SCREEN_FULL_PAGE)
|
||||||
|
)
|
||||||
|
, []);
|
||||||
|
|
||||||
|
useHotKey('F2', doToggleFullPage);
|
||||||
|
useHotKey('F4', doShowOptions);
|
||||||
|
useHotKey('F12', doReset);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="reset-row">
|
||||||
|
<OptionsModal isOpen={showOptions} onClose={doCloseOptions} />
|
||||||
|
<Inset>
|
||||||
|
<CPUMeter apple2={apple2} />
|
||||||
|
{running ? (
|
||||||
|
<ControlButton
|
||||||
|
onClick={doPause}
|
||||||
|
title="Pause"
|
||||||
|
icon="pause"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ControlButton
|
||||||
|
onClick={doRun}
|
||||||
|
title="Run"
|
||||||
|
icon="play"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ControlButton
|
||||||
|
onClick={doToggleSound}
|
||||||
|
title="Toggle Sound"
|
||||||
|
icon={audioEnabled ? 'volume-up' : 'volume-off'}
|
||||||
|
/>
|
||||||
|
<div style={{flexGrow: 1}} />
|
||||||
|
<ControlButton onClick={doReadme} title="About" icon="info" />
|
||||||
|
<ControlButton onClick={doShowOptions} title="Options (F4)" icon="cog" />
|
||||||
|
</Inset>
|
||||||
|
{e && (
|
||||||
|
<button id="reset" onClick={doReset}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { h } from 'preact';
|
||||||
|
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||||
|
import cs from 'classnames';
|
||||||
|
import Disk2 from '../cards/disk2';
|
||||||
|
import { FileModal } from './FileModal';
|
||||||
|
import { loadJSON, loadHttpFile, getHashParts } from './util/files';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage structure for Disk II state returned via callbacks.
|
||||||
|
*/
|
||||||
|
export interface DiskIIData {
|
||||||
|
number: 1 | 2;
|
||||||
|
on: boolean;
|
||||||
|
name?: string;
|
||||||
|
side?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for Disk II component.
|
||||||
|
*/
|
||||||
|
export interface DiskIIProps extends DiskIIData {
|
||||||
|
disk2: Disk2 | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disk II component
|
||||||
|
*
|
||||||
|
* Includes drive light, disk name and side, and UI for loading disks.
|
||||||
|
* Handles initial loading of disks specified in the hash.
|
||||||
|
*
|
||||||
|
* @param disk2 Disk2 object
|
||||||
|
* @param number Drive 1 or 2
|
||||||
|
* @param on Active state
|
||||||
|
* @param name Disk name identifier
|
||||||
|
* @param side Disk side identifier
|
||||||
|
* @returns DiskII component
|
||||||
|
*/
|
||||||
|
export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => {
|
||||||
|
const label = side ? `${name} - ${side}` : name;
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hashParts = getHashParts();
|
||||||
|
if (disk2 && hashParts && hashParts[number]) {
|
||||||
|
const hashPart = decodeURIComponent(hashParts[number]);
|
||||||
|
if (hashPart.match(/^https?:/)) {
|
||||||
|
loadHttpFile(disk2, number, hashPart)
|
||||||
|
.catch((error) =>
|
||||||
|
console.error(error)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const filename = `/json/disks/${hashPart}.json`;
|
||||||
|
loadJSON(disk2, number, filename)
|
||||||
|
.catch((error) =>
|
||||||
|
console.error(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [disk2]);
|
||||||
|
|
||||||
|
const doClose = useCallback(() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onOpenModal = useCallback(() => {
|
||||||
|
setModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="disk">
|
||||||
|
<FileModal disk2={disk2} number={number} onClose={doClose} isOpen={modalOpen} />
|
||||||
|
<div
|
||||||
|
id={`disk${number}`}
|
||||||
|
className={cs('disk-light', { on })}
|
||||||
|
/>
|
||||||
|
<button title="Load Disk">
|
||||||
|
<i class="fas fa-folder-open" onClick={onOpenModal} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id={`disk-label${number}`}
|
||||||
|
className="disk-label"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { h, Fragment } from 'preact';
|
||||||
|
import {useEffect, useState } from 'preact/hooks';
|
||||||
|
import Disk2, { Callbacks } from '../cards/disk2';
|
||||||
|
import Apple2IO from '../apple2io';
|
||||||
|
import { DiskII, DiskIIData } from './DiskII';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for Drives component.
|
||||||
|
*/
|
||||||
|
export interface DrivesProps {
|
||||||
|
io: Apple2IO | undefined;
|
||||||
|
sectors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive interface component. Presents the interface to load disks.
|
||||||
|
* Provides the callback to the Disk2 object to update the DiskII
|
||||||
|
* components.
|
||||||
|
*
|
||||||
|
* @param io Apple I/O object
|
||||||
|
* @returns Drives component
|
||||||
|
*/
|
||||||
|
export const Drives = ({ io, sectors }: DrivesProps) => {
|
||||||
|
const [disk2, setDisk2] = useState<Disk2>();
|
||||||
|
const [data1, setData1] = useState<DiskIIData>({ on: false, number: 1, name: 'Disk 1' });
|
||||||
|
const [data2, setData2] = useState<DiskIIData>({ on: false, number: 2, name: 'Disk 2' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const setData = [setData1, setData2];
|
||||||
|
const callbacks: Callbacks = {
|
||||||
|
driveLight: (drive, on) => {
|
||||||
|
setData[drive - 1]?.(data => ({...data, on }));
|
||||||
|
},
|
||||||
|
label: (drive, name, side) => {
|
||||||
|
setData[drive - 1]?.(data => ({...data, name, side }));
|
||||||
|
},
|
||||||
|
dirty: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (io) {
|
||||||
|
const disk2 = new Disk2(io, callbacks, sectors);
|
||||||
|
io.setSlot(6, disk2);
|
||||||
|
setDisk2(disk2);
|
||||||
|
}
|
||||||
|
}, [io]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DiskII disk2={disk2} {...data1} />
|
||||||
|
<DiskII disk2={disk2} {...data2} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { h, JSX } from 'preact';
|
||||||
|
import { useCallback, useRef, useState } from 'preact/hooks';
|
||||||
|
import { DiskDescriptor, DriveNumber, NibbleFormat } from '../formats/types';
|
||||||
|
import { Modal, ModalContent, ModalFooter } from './Modal';
|
||||||
|
import { loadLocalFile, loadJSON, getHashParts, setHashParts } from './util/files';
|
||||||
|
import DiskII from '../cards/disk2';
|
||||||
|
|
||||||
|
import index from 'json/disks/index.json';
|
||||||
|
|
||||||
|
const categories = index.reduce<Record<string, DiskDescriptor[]>>(
|
||||||
|
(
|
||||||
|
acc: Record<string, DiskDescriptor[]>,
|
||||||
|
disk: DiskDescriptor
|
||||||
|
) => {
|
||||||
|
const category = disk.category || 'Misc';
|
||||||
|
acc[category] = [disk, ...(acc[category] || [])];
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryNames = Object.keys(categories).sort();
|
||||||
|
|
||||||
|
export type NibbleFileCallback = (
|
||||||
|
name: string,
|
||||||
|
fmt: NibbleFormat,
|
||||||
|
rawData: ArrayBuffer
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
interface FileModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
disk2: DiskII | undefined;
|
||||||
|
number: DriveNumber;
|
||||||
|
onClose: (closeBox?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileModal = ({ disk2, number, onClose, isOpen } : FileModalProps) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [busy, setBusy] = useState<boolean>(false);
|
||||||
|
const [empty, setEmpty] = useState<boolean>(true);
|
||||||
|
const [category, setCategory] = useState<string>();
|
||||||
|
const [filename, setFilename] = useState<string>();
|
||||||
|
|
||||||
|
const doCancel = useCallback(() => onClose(true), []);
|
||||||
|
|
||||||
|
const doOpen = useCallback(() => {
|
||||||
|
const hashParts = getHashParts();
|
||||||
|
|
||||||
|
if (disk2 && inputRef.current && inputRef.current.files?.length === 1) {
|
||||||
|
hashParts[number] = '';
|
||||||
|
setBusy(true);
|
||||||
|
loadLocalFile(disk2, number, inputRef.current.files[0])
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => {
|
||||||
|
setBusy(false);
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disk2 && filename) {
|
||||||
|
const name = filename.match(/\/([^/]+).json$/) || ['', ''];
|
||||||
|
hashParts[number] = name[1];
|
||||||
|
setBusy(true);
|
||||||
|
loadJSON(disk2, number, filename)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => {
|
||||||
|
setBusy(false);
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setHashParts(hashParts);
|
||||||
|
}, [ disk2, filename, number, onClose ]);
|
||||||
|
|
||||||
|
const onChange = useCallback(() => {
|
||||||
|
if (inputRef) {
|
||||||
|
setEmpty(!inputRef.current?.files?.length);
|
||||||
|
}
|
||||||
|
}, [ inputRef ]);
|
||||||
|
|
||||||
|
const doSelectCategory = useCallback(
|
||||||
|
(event: JSX.TargetedMouseEvent<HTMLSelectElement>) =>
|
||||||
|
setCategory(event.currentTarget.value)
|
||||||
|
, []
|
||||||
|
);
|
||||||
|
|
||||||
|
const doSelectFilename = useCallback(
|
||||||
|
(event: JSX.TargetedMouseEvent<HTMLSelectElement>) => {
|
||||||
|
setEmpty(!event.currentTarget.value);
|
||||||
|
setFilename(event.currentTarget.value);
|
||||||
|
}, []
|
||||||
|
);
|
||||||
|
|
||||||
|
const disks = category ? categories[category] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Open File" isOpen={isOpen}>
|
||||||
|
<ModalContent>
|
||||||
|
<div id="load-modal">
|
||||||
|
<select multiple onChange={doSelectCategory}>
|
||||||
|
{categoryNames.map((name) => (
|
||||||
|
<option>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select multiple onChange={doSelectFilename}>
|
||||||
|
{disks.map((disk) => (
|
||||||
|
<option value={disk.filename}>
|
||||||
|
{disk.name}
|
||||||
|
{disk.disk ? ` - ${disk.disk}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input type="file" ref={inputRef} onChange={onChange} />
|
||||||
|
</ModalContent>
|
||||||
|
<ModalFooter>
|
||||||
|
<button onClick={doCancel}>Cancel</button>
|
||||||
|
<button onClick={doOpen} disabled={busy || empty}>Open</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { h } from 'preact';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header component properties.
|
||||||
|
*/
|
||||||
|
export interface HeaderProps {
|
||||||
|
e: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header component, which consists of a badge and title.
|
||||||
|
*
|
||||||
|
* @returns Header component
|
||||||
|
*/
|
||||||
|
export const Header = ({ e }: HeaderProps) => {
|
||||||
|
return (
|
||||||
|
<div id="header">
|
||||||
|
<a href="https://github.com/whscullin/apple2js#readme" target="_blank">
|
||||||
|
<img src="img/badge.png" id="badge" />
|
||||||
|
</a>
|
||||||
|
<div id="subtitle">An Apple {e ? '//e' : ']['} Emulator in JavaScript</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { h, FunctionalComponent } from 'preact';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience component for a nice beveled border.
|
||||||
|
*
|
||||||
|
* @returns Inset component
|
||||||
|
*/
|
||||||
|
export const Inset: FunctionalComponent = ({ children }) => (
|
||||||
|
<div className="inset">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { h, Fragment, JSX } from 'preact';
|
||||||
|
import cs from 'classnames';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import { Apple2 as Apple2Impl } from '../apple2';
|
||||||
|
import {
|
||||||
|
keys2,
|
||||||
|
keys2e,
|
||||||
|
mapKeyEvent,
|
||||||
|
mapMouseEvent,
|
||||||
|
keysAsTuples
|
||||||
|
} from './util/keyboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function for massaging key labels for upper
|
||||||
|
* and lower case
|
||||||
|
*
|
||||||
|
* @param key Raw key label
|
||||||
|
* @returns Span representing that label
|
||||||
|
*/
|
||||||
|
const buildLabel = (key: string) => {
|
||||||
|
const small = key.length > 1 && !key.startsWith('&');
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cs({ small })}
|
||||||
|
dangerouslySetInnerHTML={{ __html: key }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key properties
|
||||||
|
*/
|
||||||
|
interface KeyProps {
|
||||||
|
lower: string;
|
||||||
|
upper: string;
|
||||||
|
active: boolean;
|
||||||
|
pressed: boolean;
|
||||||
|
onMouseDown: (event: MouseEvent) => void;
|
||||||
|
onMouseUp: (event: MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual Key components. Sets up DOM data attributes to be passed to mouse
|
||||||
|
* handlers
|
||||||
|
*
|
||||||
|
* @param lower Lower key symbol
|
||||||
|
* @param upper Upper key symbol
|
||||||
|
* @param active Active state for shift, control, lock
|
||||||
|
* @param pressed Pressed state
|
||||||
|
* @param onMouseDown mouse down callback
|
||||||
|
* @param onMouseUp mouse up callback
|
||||||
|
*/
|
||||||
|
export const Key = ({
|
||||||
|
lower,
|
||||||
|
upper,
|
||||||
|
active,
|
||||||
|
pressed,
|
||||||
|
onMouseDown,
|
||||||
|
onMouseUp
|
||||||
|
}: KeyProps) => {
|
||||||
|
const keyName = lower.replace(/[&#;]/g, '');
|
||||||
|
const center =
|
||||||
|
lower === 'LOCK' ?
|
||||||
|
'v-center2' :
|
||||||
|
(upper === lower && upper.length > 1 ?
|
||||||
|
'v-center'
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cs(
|
||||||
|
'key',
|
||||||
|
`key-${keyName}`,
|
||||||
|
center,
|
||||||
|
{ pressed, active },
|
||||||
|
)}
|
||||||
|
data-key1={lower}
|
||||||
|
data-key2={upper}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onMouseUp={onMouseUp}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{buildLabel(upper)}
|
||||||
|
{upper !== lower && <><br />{buildLabel(lower)}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard properties
|
||||||
|
*/
|
||||||
|
export interface KeyboardProps {
|
||||||
|
apple2: Apple2Impl | undefined;
|
||||||
|
e: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard component that can render an Apple ][ or //e keyboard
|
||||||
|
* and accept keyboard and mouse input. Relies heavily on the
|
||||||
|
* ancient keyboard css to achieve its appearance.
|
||||||
|
*
|
||||||
|
* @param apple2 Apple2 object
|
||||||
|
* @returns Keyboard component
|
||||||
|
*/
|
||||||
|
export const Keyboard = ({ apple2, e }: KeyboardProps) => {
|
||||||
|
const [pressed, setPressed] = useState<string[]>([]);
|
||||||
|
const [active, setActive] = useState<string[]>(['LOCK']);
|
||||||
|
const keys = useMemo(() => keysAsTuples(e ? keys2e : keys2 ), [e]);
|
||||||
|
|
||||||
|
// Set global keystroke handler
|
||||||
|
useEffect(() => {
|
||||||
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
|
const key = mapKeyEvent(event, active.includes('LOCK'));
|
||||||
|
if (key !== 0xff) {
|
||||||
|
// CTRL-SHIFT-DELETE for reset
|
||||||
|
if (key === 0x7F && event.shiftKey && event.ctrlKey) {
|
||||||
|
apple2?.reset();
|
||||||
|
} else {
|
||||||
|
apple2?.getIO().keyDown(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const keyUp = () => {
|
||||||
|
apple2?.getIO().keyUp();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', keyDown);
|
||||||
|
document.addEventListener('keyup', keyUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', keyDown);
|
||||||
|
document.removeEventListener('keyup', keyUp);
|
||||||
|
};
|
||||||
|
}, [apple2, active]);
|
||||||
|
|
||||||
|
const onMouseDown = useCallback(
|
||||||
|
(event: JSX.TargetedMouseEvent<HTMLElement>) => {
|
||||||
|
if (!apple2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toggleActive = (key: string) => {
|
||||||
|
if (!active.includes(key)) {
|
||||||
|
setActive([...active, key]);
|
||||||
|
} else {
|
||||||
|
setActive(active.filter(x => x !== key));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const io = apple2.getIO();
|
||||||
|
const { keyCode, key, keyLabel } = mapMouseEvent(
|
||||||
|
event,
|
||||||
|
active.includes('SHIFT'),
|
||||||
|
active.includes('CTRL'),
|
||||||
|
active.includes('LOCK'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
if (keyCode !== 0xff) {
|
||||||
|
io.keyDown(keyCode);
|
||||||
|
} else if (key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'SHIFT':
|
||||||
|
case 'CTRL':
|
||||||
|
case 'LOCK':
|
||||||
|
toggleActive(key);
|
||||||
|
break;
|
||||||
|
case 'RESET':
|
||||||
|
apple2.reset();
|
||||||
|
break;
|
||||||
|
case 'OPEN_APPLE':
|
||||||
|
io.ioSwitch(0, io.ioSwitch(0) ? 0 : 1);
|
||||||
|
toggleActive(key);
|
||||||
|
break;
|
||||||
|
case 'CLOSED_APPLE':
|
||||||
|
io.ioSwitch(1, io.ioSwitch(1) ? 0 : 1);
|
||||||
|
toggleActive(key);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPressed([...pressed, keyLabel]);
|
||||||
|
},
|
||||||
|
[apple2, active, pressed]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onMouseUp = useCallback(
|
||||||
|
(event: JSX.TargetedMouseEvent<HTMLElement>) => {
|
||||||
|
const { keyLabel } = mapMouseEvent(
|
||||||
|
event,
|
||||||
|
active.includes('SHIFT'),
|
||||||
|
active.includes('CTRL'),
|
||||||
|
active.includes('LOCK'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
apple2?.getIO().keyUp();
|
||||||
|
setPressed(pressed.filter(x => x !== keyLabel));
|
||||||
|
},
|
||||||
|
[apple2, active, pressed]
|
||||||
|
);
|
||||||
|
|
||||||
|
const bindKey = ([lower, upper]: [string, string]) =>
|
||||||
|
<Key
|
||||||
|
lower={lower}
|
||||||
|
upper={upper}
|
||||||
|
active={active.includes(lower)}
|
||||||
|
pressed={pressed.includes(upper)}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onMouseUp={onMouseUp}
|
||||||
|
/>;
|
||||||
|
|
||||||
|
const rows = keys.map((row, idx) =>
|
||||||
|
<div className={`row row${idx}`}>
|
||||||
|
{row.map(bindKey)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="keyboard" style={{ marginLeft: e ? 0 : 15 }}>
|
||||||
|
{rows}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { h, FunctionalComponent } from 'preact';
|
||||||
|
import { useCallback } from 'preact/hooks';
|
||||||
|
import { useHotKey } from './hooks/useHotKey';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary JS styling while I figure out how I really want
|
||||||
|
* to do it.
|
||||||
|
*/
|
||||||
|
const modalOverlayStyle = {
|
||||||
|
position: 'fixed',
|
||||||
|
left: '0',
|
||||||
|
right: '0',
|
||||||
|
top: '0',
|
||||||
|
bottom: '0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
zIndex: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalStyle = {
|
||||||
|
backgroundColor: '#c4c1a0',
|
||||||
|
padding: '10px',
|
||||||
|
maxHeight: '100vh',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalHeaderStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
fontSize: '14px',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: '#44372C',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '5px 11px',
|
||||||
|
border: '1px outset #66594E',
|
||||||
|
borderRadius: '3px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalTitleStyle = {
|
||||||
|
marginTop: 0,
|
||||||
|
marginBottom: 0,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
lineHeight: 1.25,
|
||||||
|
color: '#fff',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalContentStyle = {
|
||||||
|
marginTop: '10px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: '#000'
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalFooterStyle = {
|
||||||
|
textAlign: 'right'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModalOverlay creates a semi-transparent overlay in which the
|
||||||
|
* modal is centered.
|
||||||
|
*
|
||||||
|
* @returns ModalOverlay component
|
||||||
|
*/
|
||||||
|
export const ModalOverlay: FunctionalComponent = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div style={modalOverlayStyle}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModalContent provides a styled container for modal content
|
||||||
|
*
|
||||||
|
* @returns ModalContent component
|
||||||
|
*/
|
||||||
|
export const ModalContent: FunctionalComponent = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div style={modalContentStyle}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModalFooter provides a right-aligned container for modal buttons.
|
||||||
|
*
|
||||||
|
* @returns ModalFooter component
|
||||||
|
*/
|
||||||
|
export const ModalFooter: FunctionalComponent = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div style={modalFooterStyle}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModalCloseButton component properties
|
||||||
|
*/
|
||||||
|
interface ModalCloseButtonProp {
|
||||||
|
onClose: (closeBox?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a close button and registers a global Escape key
|
||||||
|
* hook to trigger it.
|
||||||
|
*
|
||||||
|
* @param onClose Close callback
|
||||||
|
* @returns ModalClose component
|
||||||
|
*/
|
||||||
|
export const ModalCloseButton = ({ onClose }: ModalCloseButtonProp) => {
|
||||||
|
const doClose = useCallback(() => onClose(true), [onClose]);
|
||||||
|
useHotKey('Escape', doClose);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={doClose} title="Close">
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModalHeader component properties
|
||||||
|
*/
|
||||||
|
export interface ModalHeaderProps {
|
||||||
|
onClose?: (closeBox?: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header used internally for Modal component
|
||||||
|
*
|
||||||
|
* @param onClose Close callback
|
||||||
|
* @param title Modal title
|
||||||
|
* @returns ModalHeader component
|
||||||
|
*/
|
||||||
|
export const ModalHeader = ({ onClose, title }: ModalHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<div style={modalHeaderStyle}>
|
||||||
|
<span style={modalTitleStyle}>{title}</span>
|
||||||
|
{onClose && <ModalCloseButton onClose={onClose} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal component properties
|
||||||
|
*/
|
||||||
|
export interface ModalProps {
|
||||||
|
onClose?: (closeBox?: boolean) => void
|
||||||
|
isOpen: boolean
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Very simple modal component, provides a transparent overlay, title bar
|
||||||
|
* with optional close box if onClose is provided. ModalContent and
|
||||||
|
* ModalFooter components are provided for convenience but not required.
|
||||||
|
*
|
||||||
|
* @param isOpen true to show modal
|
||||||
|
* @param title Modal title
|
||||||
|
* @onClose Close callback
|
||||||
|
* @returns Modal component
|
||||||
|
*/
|
||||||
|
export const Modal: FunctionalComponent<ModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
isOpen ? (
|
||||||
|
<ModalOverlay>
|
||||||
|
<div style={modalStyle}>
|
||||||
|
{title && <ModalHeader onClose={onClose} title={title} />}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</ModalOverlay>
|
||||||
|
) : null
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { createContext } from 'preact';
|
||||||
|
import { Options } from '../options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context for getting, setting and configuring options
|
||||||
|
*/
|
||||||
|
export const OptionsContext = createContext(new Options());
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { h, Fragment, JSX } from 'preact';
|
||||||
|
import { useCallback, useContext } from 'preact/hooks';
|
||||||
|
import { Modal, ModalContent, ModalFooter } from './Modal';
|
||||||
|
import { OptionsContext } from './OptionsContext';
|
||||||
|
import {
|
||||||
|
BOOLEAN_OPTION,
|
||||||
|
SELECT_OPTION,
|
||||||
|
BooleanOption,
|
||||||
|
Option,
|
||||||
|
OptionSection,
|
||||||
|
SelectOption,
|
||||||
|
} from '../options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boolean property interface
|
||||||
|
*/
|
||||||
|
interface BooleanProps {
|
||||||
|
option: BooleanOption;
|
||||||
|
value: boolean;
|
||||||
|
setValue: (name: string, value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param option Boolean option
|
||||||
|
* @param value Current value
|
||||||
|
* @param setValue Value setter
|
||||||
|
* @returns Boolean component
|
||||||
|
*/
|
||||||
|
const Boolean = ({ option, value, setValue } : BooleanProps) => {
|
||||||
|
const { label, name } = option;
|
||||||
|
const onChange = useCallback(
|
||||||
|
(event: JSX.TargetedMouseEvent<HTMLInputElement>) =>
|
||||||
|
setValue(name, event.currentTarget.checked)
|
||||||
|
, [name, setValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<label>{label}</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select property interface
|
||||||
|
*/
|
||||||
|
interface SelectProps {
|
||||||
|
option: SelectOption;
|
||||||
|
value: string;
|
||||||
|
setValue: (name: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select component that provides a dropdown to choose between
|
||||||
|
* options.
|
||||||
|
*
|
||||||
|
* @param option Select option
|
||||||
|
* @param value Current value
|
||||||
|
* @param setValue Value setter
|
||||||
|
* @returns Select component
|
||||||
|
*/
|
||||||
|
const Select = ({ option, value, setValue } : SelectProps) => {
|
||||||
|
const { label, name } = option;
|
||||||
|
const onChange = useCallback(
|
||||||
|
(event: JSX.TargetedMouseEvent<HTMLSelectElement>) => {
|
||||||
|
setValue(name, event.currentTarget.value);
|
||||||
|
},
|
||||||
|
[name, setValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeOption = (option: { name: string, value: string }) => (
|
||||||
|
<option selected={option.value === value} value={option.value}>
|
||||||
|
{option.name}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<select onChange={onChange}>
|
||||||
|
{option.values.map(makeOption)}
|
||||||
|
</select>
|
||||||
|
<label>{label}</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OptionsModal properties
|
||||||
|
*/
|
||||||
|
export interface OptionsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: (closeBox?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal to allow editing of various component provided
|
||||||
|
* options, like screen and cpu type
|
||||||
|
*
|
||||||
|
* @param Modal params
|
||||||
|
* @returns OptionsModal component
|
||||||
|
*/
|
||||||
|
export const OptionsModal = ({ isOpen, onClose }: OptionsModalProps) => {
|
||||||
|
const options = useContext(OptionsContext);
|
||||||
|
const sections = options.getSections();
|
||||||
|
const setValue = useCallback(( name: string, value: string | boolean ) => {
|
||||||
|
options.setOption(name, value);
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const makeOption = (option: Option) => {
|
||||||
|
const { name, type } = option;
|
||||||
|
const value = options.getOption(name);
|
||||||
|
switch (type) {
|
||||||
|
case BOOLEAN_OPTION:
|
||||||
|
return (
|
||||||
|
<Boolean
|
||||||
|
option={option as BooleanOption}
|
||||||
|
value={value as boolean}
|
||||||
|
setValue={setValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case SELECT_OPTION:
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
option={option as SelectOption}
|
||||||
|
value={value as string}
|
||||||
|
setValue={setValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeSection = (section: OptionSection) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>{section.name}</h3>
|
||||||
|
<ul>
|
||||||
|
{section.options.map(makeOption)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doClose = useCallback(() => onClose(), [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Options" isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalContent>
|
||||||
|
<div id="options-modal">
|
||||||
|
{sections.map(makeSection)}
|
||||||
|
</div>
|
||||||
|
<i>* Reload page to take effect</i>
|
||||||
|
</ModalContent>
|
||||||
|
<ModalFooter>
|
||||||
|
<button onClick={doClose}>Close</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { h, Ref } from 'preact';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen properties
|
||||||
|
*/
|
||||||
|
export interface ScreenProps {
|
||||||
|
screen: Ref<HTMLCanvasElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Styled canvas element that the Apple II display is
|
||||||
|
* rendered to by VideoModes.
|
||||||
|
*
|
||||||
|
* @param screen Canvas element reference
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const Screen = ({ screen }: ScreenProps) => {
|
||||||
|
return (
|
||||||
|
<div id="display">
|
||||||
|
<div class="overscan">
|
||||||
|
<canvas id="screen" width="592" height="416" ref={screen} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to allow registering hotkey that will automatically
|
||||||
|
* be cleaned up when they leave scope
|
||||||
|
*
|
||||||
|
* @param key KeyboardEvent key value to match
|
||||||
|
* @param callback Invoked when key is pressed
|
||||||
|
*/
|
||||||
|
export const useHotKey = (key: string, callback: (ev: KeyboardEvent) => void) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key === key) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
callback(ev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [key, callback]);
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
import Prefs from 'js/prefs';
|
||||||
|
|
||||||
|
// Todo(whscullin): More robust preferences
|
||||||
|
|
||||||
|
const prefs = new Prefs();
|
||||||
|
|
||||||
|
export const usePrefs = () => prefs;
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { includes } from 'js/types';
|
||||||
|
import { initGamepad } from 'js/ui/gamepad';
|
||||||
|
import {
|
||||||
|
DriveNumber,
|
||||||
|
JSONDisk,
|
||||||
|
NIBBLE_FORMATS
|
||||||
|
} from 'js/formats/types';
|
||||||
|
import DiskII from 'js/cards/disk2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routine to split a legacy hash into parts for disk loading
|
||||||
|
*
|
||||||
|
* @returns an padded array for 1 based indexing
|
||||||
|
*/
|
||||||
|
export const getHashParts = () => {
|
||||||
|
const parts = window.location.hash.match(/^#([^|]*)\|?(.*)$/) || ['', '', ''];
|
||||||
|
return ['', parts[1], parts[2]];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the location hash to reflect the current disk state, if possible
|
||||||
|
*
|
||||||
|
* @param parts a padded array with values starting at index 1
|
||||||
|
*/
|
||||||
|
export const setHashParts = (parts: string[]) => {
|
||||||
|
window.location.hash = `#${parts[1]}` + (parts[2] ? `|${parts[2]}` : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local file loading routine. Allows a File object from a file
|
||||||
|
* selection form element to be loaded.
|
||||||
|
*
|
||||||
|
* @param disk2 Disk2 object
|
||||||
|
* @param number Drive number
|
||||||
|
* @param file Browser File object to load
|
||||||
|
* @returns true if successful
|
||||||
|
*/
|
||||||
|
export const loadLocalFile = (
|
||||||
|
disk2: DiskII,
|
||||||
|
number: DriveNumber,
|
||||||
|
file: File,
|
||||||
|
) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
fileReader.onload = function () {
|
||||||
|
const result = this.result as ArrayBuffer;
|
||||||
|
const parts = file.name.split('.');
|
||||||
|
const ext = parts.pop()?.toLowerCase() || '[none]';
|
||||||
|
const name = parts.join('.');
|
||||||
|
|
||||||
|
if (includes(NIBBLE_FORMATS, ext)) {
|
||||||
|
if (result.byteLength >= 800 * 1024) {
|
||||||
|
reject(`Unable to load ${name}`);
|
||||||
|
} else {
|
||||||
|
initGamepad();
|
||||||
|
if (disk2.setBinary(number, name, ext, result)) {
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
reject(`Unable to load ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(`Extension ${ext} not recognized.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fileReader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON loading routine, loads a JSON file at the given URL. Requires
|
||||||
|
* proper cross domain loading headers if the URL is not on the same server
|
||||||
|
* as the emulator.
|
||||||
|
*
|
||||||
|
* @param disk2 Disk2 object
|
||||||
|
* @param number Drive number
|
||||||
|
* @param url URL, relative or absolute to JSON file
|
||||||
|
* @returns true if successful
|
||||||
|
*/
|
||||||
|
export const loadJSON = async (disk2: DiskII, number: DriveNumber, url: string) => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error loading: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data: JSONDisk = await response.json();
|
||||||
|
if (!includes(NIBBLE_FORMATS, data.type)) {
|
||||||
|
throw new Error(`Type ${data.type} not recognized.`);
|
||||||
|
}
|
||||||
|
disk2.setDisk(number, data);
|
||||||
|
initGamepad(data.gamepad);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP loading routine, loads a file at the given URL. Requires
|
||||||
|
* proper cross domain loading headers if the URL is not on the same server
|
||||||
|
* as the emulator. Only supports nibble based formats at the moment.
|
||||||
|
*
|
||||||
|
* @param disk2 Disk2 object
|
||||||
|
* @param number Drive number
|
||||||
|
* @param url URL, relative or absolute to JSON file
|
||||||
|
* @returns true if successful
|
||||||
|
*/
|
||||||
|
export const loadHttpFile = async (
|
||||||
|
disk2: DiskII,
|
||||||
|
number: DriveNumber,
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
if (url.endsWith('.json')) {
|
||||||
|
return loadJSON(disk2, number, url);
|
||||||
|
}
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error loading: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const reader = response.body!.getReader();
|
||||||
|
let received = 0;
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
let result = await reader.read();
|
||||||
|
while (!result.done) {
|
||||||
|
chunks.push(result.value);
|
||||||
|
received += result.value.length;
|
||||||
|
result = await reader.read();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(received);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
data.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlParts = url.split('/');
|
||||||
|
const file = urlParts.pop()!;
|
||||||
|
const fileParts = file.split('.');
|
||||||
|
const ext = fileParts.pop()?.toLowerCase() || '[none]';
|
||||||
|
const name = decodeURIComponent(fileParts.join('.'));
|
||||||
|
if (!includes(NIBBLE_FORMATS, ext)) {
|
||||||
|
throw new Error(`Extension ${ext} not recognized.`);
|
||||||
|
}
|
||||||
|
disk2.setBinary(number, name, ext, data);
|
||||||
|
initGamepad();
|
||||||
|
};
|
|
@ -0,0 +1,334 @@
|
||||||
|
import { JSX } from 'preact';
|
||||||
|
import { byte, DeepMemberOf, KnownKeys } from '../../types';
|
||||||
|
import { debug, toHex } from '../../util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of KeyboardEvent.keyCode to ASCII, for normal,
|
||||||
|
* shifted and control states.
|
||||||
|
*/
|
||||||
|
// keycode: [plain, ctrl, shift]
|
||||||
|
export 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;
|
||||||
|
|
||||||
|
export const 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;
|
||||||
|
|
||||||
|
|
||||||
|
export const isUiKitKey = (k: string): k is KnownKeys<typeof uiKitMap> => {
|
||||||
|
return k in uiKitMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard layout for the Apple ][ / ][+
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
|
||||||
|
export type Key2 = DeepMemberOf<typeof keys2>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard layout for the Apple //e
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
|
||||||
|
export type Key2e = DeepMemberOf<typeof keys2e>;
|
||||||
|
|
||||||
|
export type Key = Key2 | Key2e;
|
||||||
|
|
||||||
|
export type KeyFunction = (key: KeyboardEvent) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DOM keyboard event into an ASCII equivalent that
|
||||||
|
* an Apple // can recognize.
|
||||||
|
*
|
||||||
|
* @param evt Event to convert
|
||||||
|
* @param caps Caps Lock state
|
||||||
|
* @returns ASCII character
|
||||||
|
*/
|
||||||
|
export const mapKeyEvent = (evt: KeyboardEvent, caps: boolean) => {
|
||||||
|
// TODO(whscullin): Find replacement for deprecated keycode
|
||||||
|
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 (caps && key >= 0x61 && key <= 0x7A) {
|
||||||
|
key -= 0x20;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug(`Unhandled key = ${toHex(code)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a mouse event into an ASCII character based on the
|
||||||
|
* data attached to the target DOM element.
|
||||||
|
*
|
||||||
|
* @param event Event to convert
|
||||||
|
* @param shifted Shift key state
|
||||||
|
* @param controlled Control key state
|
||||||
|
* @param caps Caps Lock state
|
||||||
|
* @param e //e status
|
||||||
|
* @returns ASCII character
|
||||||
|
*/
|
||||||
|
export const mapMouseEvent = (
|
||||||
|
event: JSX.TargetedMouseEvent<HTMLElement>,
|
||||||
|
shifted: boolean,
|
||||||
|
controlled: boolean,
|
||||||
|
caps: boolean,
|
||||||
|
e: boolean,
|
||||||
|
) => {
|
||||||
|
const keyLabel = event.currentTarget?.dataset.key2 ?? '';
|
||||||
|
let key = event.currentTarget?.dataset[shifted ? 'key2' : 'key1'] ?? '';
|
||||||
|
let keyCode = 0xff;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (controlled && key >= '@' && key <= '_') {
|
||||||
|
keyCode = key.charCodeAt(0) - 0x40;
|
||||||
|
} else if (
|
||||||
|
e && !shifted && !caps &&
|
||||||
|
key >= 'A' && key <= 'Z'
|
||||||
|
) {
|
||||||
|
keyCode = key.charCodeAt(0) + 0x20;
|
||||||
|
} else {
|
||||||
|
keyCode = key.charCodeAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { keyCode, key, keyLabel };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remap keys so that upper and lower are a tuple per row instead of
|
||||||
|
* separate rows
|
||||||
|
*
|
||||||
|
* @param inKeys keys2 or keys2e
|
||||||
|
* @returns Keys remapped
|
||||||
|
*/
|
||||||
|
export const keysAsTuples = (inKeys: typeof keys2e | typeof keys2): string[][][] => {
|
||||||
|
const rows = [];
|
||||||
|
for (let idx = 0; idx < inKeys[0].length; idx++) {
|
||||||
|
const upper = inKeys[0][idx];
|
||||||
|
const lower = inKeys[1][idx];
|
||||||
|
const keys = [];
|
||||||
|
for (let jdx = 0; jdx < upper.length; jdx++) {
|
||||||
|
keys.push([upper[jdx], lower[jdx]]);
|
||||||
|
}
|
||||||
|
rows.push(keys);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
};
|
|
@ -0,0 +1,71 @@
|
||||||
|
export interface SystemType {
|
||||||
|
rom: string;
|
||||||
|
characterRom: string;
|
||||||
|
e: boolean;
|
||||||
|
enhanced: boolean;
|
||||||
|
sectors: 13 | 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced Apple //e
|
||||||
|
|
||||||
|
export const defaultSystem = {
|
||||||
|
rom: 'apple2enh',
|
||||||
|
characterRom: 'apple2enh_char',
|
||||||
|
e: true,
|
||||||
|
enhanced: true,
|
||||||
|
sectors: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const systemTypes: Record<string, Partial<SystemType>> = {
|
||||||
|
// Apple //e
|
||||||
|
apple2e: {
|
||||||
|
rom: 'apple2e',
|
||||||
|
characterRom: 'apple2e_char',
|
||||||
|
enhanced: false
|
||||||
|
},
|
||||||
|
apple2rm: {
|
||||||
|
characterRom: 'rmfont_char',
|
||||||
|
},
|
||||||
|
apple2ex: {
|
||||||
|
rom: 'apple2ex',
|
||||||
|
e: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Apple ][s
|
||||||
|
apple2: {
|
||||||
|
rom: 'intbasic',
|
||||||
|
characterRom: 'apple2_char',
|
||||||
|
e: false,
|
||||||
|
},
|
||||||
|
apple213: {
|
||||||
|
rom: 'intbasic',
|
||||||
|
characterRom: 'apple2_char',
|
||||||
|
e: false,
|
||||||
|
sectors: 13,
|
||||||
|
},
|
||||||
|
original: {
|
||||||
|
rom: 'original',
|
||||||
|
characterRom: 'apple2_char',
|
||||||
|
e: false,
|
||||||
|
},
|
||||||
|
apple2jplus: {
|
||||||
|
rom: 'apple2j',
|
||||||
|
characterRom: 'apple2j_char',
|
||||||
|
e: false,
|
||||||
|
},
|
||||||
|
apple2pig: {
|
||||||
|
rom: 'fpbasic',
|
||||||
|
characterRom: 'pigfont_char',
|
||||||
|
e: false,
|
||||||
|
},
|
||||||
|
apple2lc:{
|
||||||
|
rom: 'fpbasic',
|
||||||
|
characterRom: 'apple2lc_char',
|
||||||
|
e: false,
|
||||||
|
},
|
||||||
|
apple2plus: {
|
||||||
|
rom: 'fpbasic',
|
||||||
|
characterRom: 'apple2_char',
|
||||||
|
e: false,
|
||||||
|
}
|
||||||
|
};
|
|
@ -145,7 +145,7 @@ interface Opts {
|
||||||
type ReadFn = () => byte;
|
type ReadFn = () => byte;
|
||||||
type WriteFn = (val: byte) => void;
|
type WriteFn = (val: byte) => void;
|
||||||
type ReadAddrFn = (opts?: Opts) => word;
|
type ReadAddrFn = (opts?: Opts) => word;
|
||||||
type ImpliedFn = () => void
|
type ImpliedFn = () => void;
|
||||||
|
|
||||||
interface Instruction<T = any> {
|
interface Instruction<T = any> {
|
||||||
name: string
|
name: string
|
||||||
|
@ -161,9 +161,9 @@ type StrictInstruction =
|
||||||
Instruction<ImpliedFn> |
|
Instruction<ImpliedFn> |
|
||||||
Instruction<flag> |
|
Instruction<flag> |
|
||||||
Instruction<flag|0> |
|
Instruction<flag|0> |
|
||||||
Instruction<byte>
|
Instruction<byte>;
|
||||||
|
|
||||||
type Instructions = Record<byte, StrictInstruction>
|
type Instructions = Record<byte, StrictInstruction>;
|
||||||
|
|
||||||
type callback = (cpu: CPU6502) => boolean | void;
|
type callback = (cpu: CPU6502) => boolean | void;
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ export interface DebuggerContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
type symbols = { [key: number]: string };
|
type symbols = { [key: number]: string };
|
||||||
type breakpointFn = (info: DebugInfo) => boolean
|
type breakpointFn = (info: DebugInfo) => boolean;
|
||||||
|
|
||||||
const alwaysBreak = (_info: DebugInfo) => { return true; };
|
const alwaysBreak = (_info: DebugInfo) => { return true; };
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { h, render } from 'preact';
|
||||||
|
import { App } from './components/App';
|
||||||
|
|
||||||
|
render(<App />, document.getElementById('app')!);
|
|
@ -1,4 +1,4 @@
|
||||||
import type { byte, memory, MemberOf } from '../types';
|
import type { byte, memory, MemberOf, word } from '../types';
|
||||||
import type { GamepadConfiguration } from '../ui/types';
|
import type { GamepadConfiguration } from '../ui/types';
|
||||||
|
|
||||||
export const DRIVE_NUMBERS = [1, 2] as const;
|
export const DRIVE_NUMBERS = [1, 2] as const;
|
||||||
|
@ -18,6 +18,29 @@ export interface DiskOptions {
|
||||||
blockVolume?: boolean
|
blockVolume?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON file entry format
|
||||||
|
*/
|
||||||
|
export interface DiskDescriptor {
|
||||||
|
name: string;
|
||||||
|
disk?: number;
|
||||||
|
filename: string;
|
||||||
|
e?: boolean;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON binary image (not used?)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface JSONBinaryImage {
|
||||||
|
type: 'binary',
|
||||||
|
start: word,
|
||||||
|
length: word,
|
||||||
|
data: byte[],
|
||||||
|
gamepad?: GamepadConfiguration,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return value from disk format processors. Describes raw disk
|
* Return value from disk format processors. Describes raw disk
|
||||||
* data which the DiskII card can process.
|
* data which the DiskII card can process.
|
||||||
|
@ -187,7 +210,7 @@ export interface DiskProcessedResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormatWorkerResponse =
|
export type FormatWorkerResponse =
|
||||||
DiskProcessedResponse
|
DiskProcessedResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block device common interface
|
* Block device common interface
|
||||||
|
|
|
@ -0,0 +1,94 @@
|
||||||
|
import Prefs from './prefs';
|
||||||
|
|
||||||
|
export const BOOLEAN_OPTION = 'BOOLEAN_OPTION';
|
||||||
|
export const SELECT_OPTION = 'SELECT_OPTION';
|
||||||
|
|
||||||
|
export interface Option {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
type: string
|
||||||
|
defaultVal: string | boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BooleanOption extends Option {
|
||||||
|
type: typeof BOOLEAN_OPTION
|
||||||
|
defaultVal: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectOption extends Option {
|
||||||
|
type: typeof SELECT_OPTION
|
||||||
|
defaultVal: string
|
||||||
|
values: Array<{name: string, value: string}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OptionSection {
|
||||||
|
name: string
|
||||||
|
options: Option[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OptionHandler {
|
||||||
|
getOptions: () => OptionSection[]
|
||||||
|
setOption: (name: string, value: string | boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Options {
|
||||||
|
private prefs: Prefs = new Prefs();
|
||||||
|
private options: Record<string, Option> = {};
|
||||||
|
private handlers: Record<string, OptionHandler> = {};
|
||||||
|
private sections: OptionSection[] = [];
|
||||||
|
|
||||||
|
addOptions(handler: OptionHandler) {
|
||||||
|
const sections = handler.getOptions();
|
||||||
|
for (const section of sections) {
|
||||||
|
const { options } = section;
|
||||||
|
for (const option of options) {
|
||||||
|
const { name } = option;
|
||||||
|
this.handlers[name] = handler;
|
||||||
|
this.options[name] = option;
|
||||||
|
const value = this.getOption(name);
|
||||||
|
if (value != null) {
|
||||||
|
handler.setOption(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.sections.push(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getOption(name: string): string | boolean | undefined {
|
||||||
|
const option = this.options[name];
|
||||||
|
if (option) {
|
||||||
|
const { name, defaultVal, type } = option;
|
||||||
|
const stringVal = String(defaultVal);
|
||||||
|
const prefVal = this.prefs.readPref(name, stringVal);
|
||||||
|
switch (type) {
|
||||||
|
case BOOLEAN_OPTION:
|
||||||
|
return prefVal === 'true';
|
||||||
|
default:
|
||||||
|
return prefVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOption(name: string, value: string | boolean) {
|
||||||
|
if (name in this.options) {
|
||||||
|
const handler = this.handlers[name];
|
||||||
|
const option = this.options[name];
|
||||||
|
this.prefs.writePref(name, String(value));
|
||||||
|
switch (option.type) {
|
||||||
|
case BOOLEAN_OPTION:
|
||||||
|
handler.setOption(name, Boolean(value));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
handler.setOption(name, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions() {
|
||||||
|
return this.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSections() {
|
||||||
|
return this.sections;
|
||||||
|
}
|
||||||
|
}
|
23
js/prefs.ts
23
js/prefs.ts
|
@ -1,21 +1,23 @@
|
||||||
const havePrefs = typeof window.localStorage !== 'undefined';
|
const havePrefs = typeof window.localStorage !== 'undefined';
|
||||||
|
|
||||||
export default class Prefs {
|
export default class Prefs {
|
||||||
params: URLSearchParams;
|
url: URL;
|
||||||
|
title: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.params = new URLSearchParams(window.location.search);
|
this.url = new URL(window.location.href);
|
||||||
|
this.title = window.document.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
havePrefs() {
|
havePrefs() {
|
||||||
return havePrefs;
|
return havePrefs;
|
||||||
}
|
}
|
||||||
|
|
||||||
readPref(name: string): string | null
|
readPref(name: string): string | null;
|
||||||
readPref(name: string, defaultValue: string): string
|
readPref(name: string, defaultValue: string): string;
|
||||||
readPref(name: string, defaultValue: string | null = null) {
|
readPref(name: string, defaultValue: string | null = null) {
|
||||||
if (this.params.has(name)) {
|
if (this.url.searchParams.has(name)) {
|
||||||
return this.params.get(name);
|
return this.url.searchParams.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (havePrefs) {
|
if (havePrefs) {
|
||||||
|
@ -25,6 +27,15 @@ export default class Prefs {
|
||||||
}
|
}
|
||||||
|
|
||||||
writePref(name: string, value: string) {
|
writePref(name: string, value: string) {
|
||||||
|
if (this.url.searchParams.has(name)) {
|
||||||
|
this.url.searchParams.set(name, value);
|
||||||
|
history.replaceState(
|
||||||
|
null,
|
||||||
|
this.title,
|
||||||
|
this.url.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (havePrefs) {
|
if (havePrefs) {
|
||||||
window.localStorage.setItem(name, value);
|
window.localStorage.setItem(name, value);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,18 +3,21 @@ import MicroModal from 'micromodal';
|
||||||
import { base64_json_parse, base64_json_stringify } from '../base64';
|
import { base64_json_parse, base64_json_stringify } from '../base64';
|
||||||
import { Audio, SOUND_ENABLED_OPTION } from './audio';
|
import { Audio, SOUND_ENABLED_OPTION } from './audio';
|
||||||
import DriveLights from './drive_lights';
|
import DriveLights from './drive_lights';
|
||||||
import { byte, includes, word } from '../types';
|
import { includes, word } from '../types';
|
||||||
import { BLOCK_FORMATS, MassStorage, NIBBLE_FORMATS } from '../formats/types';
|
|
||||||
import {
|
import {
|
||||||
|
BLOCK_FORMATS,
|
||||||
DISK_FORMATS,
|
DISK_FORMATS,
|
||||||
|
DiskDescriptor,
|
||||||
DriveNumber,
|
DriveNumber,
|
||||||
DRIVE_NUMBERS,
|
DRIVE_NUMBERS,
|
||||||
|
MassStorage,
|
||||||
|
NIBBLE_FORMATS,
|
||||||
|
JSONBinaryImage,
|
||||||
JSONDisk
|
JSONDisk
|
||||||
} from '../formats/types';
|
} from '../formats/types';
|
||||||
import { initGamepad } from './gamepad';
|
import { initGamepad } from './gamepad';
|
||||||
import KeyBoard from './keyboard';
|
import KeyBoard from './keyboard';
|
||||||
import Tape, { TAPE_TYPES } from './tape';
|
import Tape, { TAPE_TYPES } from './tape';
|
||||||
import type { GamepadConfiguration } from './types';
|
|
||||||
|
|
||||||
import ApplesoftDump from '../applesoft/decompiler';
|
import ApplesoftDump from '../applesoft/decompiler';
|
||||||
import ApplesoftCompiler from '../applesoft/compiler';
|
import ApplesoftCompiler from '../applesoft/compiler';
|
||||||
|
@ -31,6 +34,7 @@ import { OptionsModal } from './options_modal';
|
||||||
import { Screen, SCREEN_FULL_PAGE } from './screen';
|
import { Screen, SCREEN_FULL_PAGE } from './screen';
|
||||||
import { JoyStick } from './joystick';
|
import { JoyStick } from './joystick';
|
||||||
import { System } from './system';
|
import { System } from './system';
|
||||||
|
import { Options } from '../options';
|
||||||
|
|
||||||
let paused = false;
|
let paused = false;
|
||||||
|
|
||||||
|
@ -41,15 +45,8 @@ let lastRenderedFrames = 0;
|
||||||
|
|
||||||
let hashtag = document.location.hash;
|
let hashtag = document.location.hash;
|
||||||
|
|
||||||
const optionsModal = new OptionsModal();
|
const options = new Options();
|
||||||
|
const optionsModal = new OptionsModal(options);
|
||||||
interface DiskDescriptor {
|
|
||||||
name: string;
|
|
||||||
disk?: number;
|
|
||||||
filename: string;
|
|
||||||
e?: boolean;
|
|
||||||
category: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiskCollection = {
|
type DiskCollection = {
|
||||||
[name: string]: DiskDescriptor[]
|
[name: string]: DiskDescriptor[]
|
||||||
|
@ -228,14 +225,6 @@ function loadingStop() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JSONBinaryImage {
|
|
||||||
type: 'binary',
|
|
||||||
start: word,
|
|
||||||
length: word,
|
|
||||||
data: byte[],
|
|
||||||
gamepad?: GamepadConfiguration,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadAjax(drive: DriveNumber, url: string) {
|
export function loadAjax(drive: DriveNumber, url: string) {
|
||||||
loadingStart();
|
loadingStart();
|
||||||
|
|
||||||
|
@ -538,7 +527,7 @@ export function toggleShowFPS() {
|
||||||
|
|
||||||
export function toggleSound() {
|
export function toggleSound() {
|
||||||
const on = !audio.isEnabled();
|
const on = !audio.isEnabled();
|
||||||
optionsModal.setOption(SOUND_ENABLED_OPTION, on);
|
options.setOption(SOUND_ENABLED_OPTION, on);
|
||||||
updateSoundButton(on);
|
updateSoundButton(on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -662,7 +651,7 @@ function updateLocalStorage() {
|
||||||
|
|
||||||
type LocalDiskIndex = {
|
type LocalDiskIndex = {
|
||||||
[name: string]: string,
|
[name: string]: string,
|
||||||
}
|
};
|
||||||
|
|
||||||
function saveLocalStorage(drive: DriveNumber, name: string) {
|
function saveLocalStorage(drive: DriveNumber, name: string) {
|
||||||
const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex;
|
const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex;
|
||||||
|
@ -866,16 +855,16 @@ function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, print
|
||||||
_e = e;
|
_e = e;
|
||||||
|
|
||||||
system = new System(io, e);
|
system = new System(io, e);
|
||||||
optionsModal.addOptions(system);
|
options.addOptions(system);
|
||||||
|
|
||||||
joystick = new JoyStick(io);
|
joystick = new JoyStick(io);
|
||||||
optionsModal.addOptions(joystick);
|
options.addOptions(joystick);
|
||||||
|
|
||||||
screen = new Screen(vm);
|
screen = new Screen(vm);
|
||||||
optionsModal.addOptions(screen);
|
options.addOptions(screen);
|
||||||
|
|
||||||
audio = new Audio(io);
|
audio = new Audio(io);
|
||||||
optionsModal.addOptions(audio);
|
options.addOptions(audio);
|
||||||
initSoundToggle();
|
initSoundToggle();
|
||||||
|
|
||||||
ready = Promise.all([audio.ready, apple2.ready]);
|
ready = Promise.all([audio.ready, apple2.ready]);
|
||||||
|
@ -887,9 +876,9 @@ function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, print
|
||||||
keyboard.setFunction('F1', () => cpu.reset());
|
keyboard.setFunction('F1', () => cpu.reset());
|
||||||
keyboard.setFunction('F2', (event) => {
|
keyboard.setFunction('F2', (event) => {
|
||||||
if (event.shiftKey) { // Full window, but not full screen
|
if (event.shiftKey) { // Full window, but not full screen
|
||||||
optionsModal.setOption(
|
options.setOption(
|
||||||
SCREEN_FULL_PAGE,
|
SCREEN_FULL_PAGE,
|
||||||
!optionsModal.getOption(SCREEN_FULL_PAGE)
|
!options.getOption(SCREEN_FULL_PAGE)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
screen.enterFullScreen();
|
screen.enterFullScreen();
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { BOOLEAN_OPTION, OptionHandler } from './options_modal';
|
import { BOOLEAN_OPTION, OptionHandler } from '../options';
|
||||||
import Apple2IO from '../apple2io';
|
import Apple2IO from '../apple2io';
|
||||||
import { debug } from '../util';
|
import { debug } from '../util';
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ declare global {
|
||||||
new(options?: AudioWorkletNodeOptions): AudioWorkletProcessor;
|
new(options?: AudioWorkletNodeOptions): AudioWorkletProcessor;
|
||||||
};
|
};
|
||||||
|
|
||||||
function registerProcessor(name: string, ctor :{ new(): AudioWorkletProcessor; }): void
|
function registerProcessor(name: string, ctor :{ new(): AudioWorkletProcessor; }): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AppleAudioProcessor extends AudioWorkletProcessor {
|
export class AppleAudioProcessor extends AudioWorkletProcessor {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import Apple2IO from '../apple2io';
|
import Apple2IO from '../apple2io';
|
||||||
import { BOOLEAN_OPTION, OptionHandler } from './options_modal';
|
import { BOOLEAN_OPTION, OptionHandler } from '../options';
|
||||||
|
|
||||||
const JOYSTICK_DISABLE = 'disable_mouse';
|
const JOYSTICK_DISABLE = 'disable_mouse';
|
||||||
const JOYSTICK_FLIP_X_AXIS = 'flip_x';
|
const JOYSTICK_FLIP_X_AXIS = 'flip_x';
|
||||||
|
|
|
@ -202,7 +202,7 @@ type Key2e = DeepMemberOf<typeof keys2e>;
|
||||||
|
|
||||||
type Key = Key2 | Key2e;
|
type Key = Key2 | Key2e;
|
||||||
|
|
||||||
type KeyFunction = (key: KeyboardEvent) => void
|
type KeyFunction = (key: KeyboardEvent) => void;
|
||||||
|
|
||||||
export default class KeyBoard {
|
export default class KeyBoard {
|
||||||
private kb: HTMLElement;
|
private kb: HTMLElement;
|
||||||
|
|
|
@ -1,95 +1,19 @@
|
||||||
import Prefs from '../prefs';
|
|
||||||
import MicroModal from 'micromodal';
|
import MicroModal from 'micromodal';
|
||||||
|
import {
|
||||||
export const BOOLEAN_OPTION = 'BOOLEAN_OPTION';
|
BOOLEAN_OPTION,
|
||||||
export const SELECT_OPTION = 'SELECT_OPTION';
|
SELECT_OPTION,
|
||||||
|
Options,
|
||||||
export interface Option {
|
SelectOption
|
||||||
name: string
|
} from '../options';
|
||||||
label: string
|
|
||||||
type: string
|
|
||||||
defaultVal: string | boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BooleanOption extends Option {
|
|
||||||
type: typeof BOOLEAN_OPTION
|
|
||||||
defaultVal: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectOption extends Option {
|
|
||||||
type: typeof SELECT_OPTION
|
|
||||||
defaultVal: string
|
|
||||||
values: Array<{name: string, value: string}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OptionSection {
|
|
||||||
name: string
|
|
||||||
options: Option[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OptionHandler {
|
|
||||||
getOptions: () => OptionSection[]
|
|
||||||
setOption: (name: string, value: string | boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OptionsModal {
|
export class OptionsModal {
|
||||||
private prefs: Prefs = new Prefs();
|
constructor(private options: Options) {}
|
||||||
private options: Record<string, Option> = {};
|
|
||||||
private handlers: Record<string, OptionHandler> = {};
|
|
||||||
private sections: OptionSection[] = [];
|
|
||||||
|
|
||||||
addOptions(handler: OptionHandler) {
|
|
||||||
const sections = handler.getOptions();
|
|
||||||
for (const section of sections) {
|
|
||||||
const { options } = section;
|
|
||||||
for (const option of options) {
|
|
||||||
const { name } = option;
|
|
||||||
this.handlers[name] = handler;
|
|
||||||
this.options[name] = option;
|
|
||||||
const value = this.getOption(name);
|
|
||||||
if (value != null) {
|
|
||||||
handler.setOption(name, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.sections.push(section);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getOption(name: string): string | boolean | undefined {
|
|
||||||
const option = this.options[name];
|
|
||||||
if (option) {
|
|
||||||
const { name, defaultVal, type } = option;
|
|
||||||
const stringVal = String(defaultVal);
|
|
||||||
const prefVal = this.prefs.readPref(name, stringVal);
|
|
||||||
switch (type) {
|
|
||||||
case BOOLEAN_OPTION:
|
|
||||||
return prefVal === 'true';
|
|
||||||
default:
|
|
||||||
return prefVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setOption(name: string, value: string | boolean) {
|
|
||||||
if (name in this.options) {
|
|
||||||
const handler = this.handlers[name];
|
|
||||||
const option = this.options[name];
|
|
||||||
this.prefs.writePref(name, String(value));
|
|
||||||
switch (option.type) {
|
|
||||||
case BOOLEAN_OPTION:
|
|
||||||
handler.setOption(name, Boolean(value));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
handler.setOption(name, String(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
openModal = () => {
|
openModal = () => {
|
||||||
const content = document.querySelector('#options-modal-content');
|
const content = document.querySelector('#options-modal-content');
|
||||||
if (content) {
|
if (content) {
|
||||||
content.innerHTML = '';
|
content.innerHTML = '';
|
||||||
for (const section of this.sections) {
|
for (const section of this.options.getSections()) {
|
||||||
const { name, options } = section;
|
const { name, options } = section;
|
||||||
|
|
||||||
// Section header
|
// Section header
|
||||||
|
@ -100,15 +24,15 @@ export class OptionsModal {
|
||||||
// Preferences
|
// Preferences
|
||||||
const list = document.createElement('ul');
|
const list = document.createElement('ul');
|
||||||
for (const option of options) {
|
for (const option of options) {
|
||||||
const { name, label, defaultVal, type } = option;
|
const { name, label, type } = option;
|
||||||
const onChange = (evt: InputEvent & { target: HTMLInputElement }) => {
|
const onChange = (evt: InputEvent & { target: HTMLInputElement }) => {
|
||||||
const { target } = evt;
|
const { target } = evt;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case BOOLEAN_OPTION:
|
case BOOLEAN_OPTION:
|
||||||
this.setOption(name, target.checked);
|
this.options.setOption(name, target.checked);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
this.setOption(name, target.value);
|
this.options.setOption(name, target.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -119,7 +43,7 @@ export class OptionsModal {
|
||||||
case BOOLEAN_OPTION:
|
case BOOLEAN_OPTION:
|
||||||
{
|
{
|
||||||
const inputElement = document.createElement('input');
|
const inputElement = document.createElement('input');
|
||||||
const checked = this.prefs.readPref(name, String(defaultVal)) === 'true';
|
const checked = this.options.getOption(name) as boolean;
|
||||||
inputElement.setAttribute('type', 'checkbox');
|
inputElement.setAttribute('type', 'checkbox');
|
||||||
inputElement.checked = checked;
|
inputElement.checked = checked;
|
||||||
element = inputElement;
|
element = inputElement;
|
||||||
|
@ -129,7 +53,7 @@ export class OptionsModal {
|
||||||
{
|
{
|
||||||
const selectOption = option as SelectOption;
|
const selectOption = option as SelectOption;
|
||||||
const selectElement = document.createElement('select');
|
const selectElement = document.createElement('select');
|
||||||
const selected = this.prefs.readPref(name, String(defaultVal));
|
const selected = this.options.getOption(name) as string;
|
||||||
for (const value of selectOption.values) {
|
for (const value of selectOption.values) {
|
||||||
const optionElement = document.createElement('option');
|
const optionElement = document.createElement('option');
|
||||||
optionElement.value = value.value;
|
optionElement.value = value.value;
|
||||||
|
@ -143,7 +67,7 @@ export class OptionsModal {
|
||||||
default:
|
default:
|
||||||
{
|
{
|
||||||
const inputElement = document.createElement('input');
|
const inputElement = document.createElement('input');
|
||||||
const value = this.prefs.readPref(name, String(defaultVal));
|
const value = this.options.getOption(name) as string;
|
||||||
inputElement.value = value;
|
inputElement.value = value;
|
||||||
element = inputElement;
|
element = inputElement;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { VideoModes } from '../videomodes';
|
import { VideoModes } from '../videomodes';
|
||||||
import { BOOLEAN_OPTION, OptionHandler } from './options_modal';
|
import { BOOLEAN_OPTION, OptionHandler } from '../options';
|
||||||
|
|
||||||
export const SCREEN_MONO = 'mono_screen';
|
export const SCREEN_MONO = 'mono_screen';
|
||||||
export const SCREEN_FULL_PAGE = 'full_page';
|
export const SCREEN_FULL_PAGE = 'full_page';
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { BOOLEAN_OPTION, SELECT_OPTION, OptionHandler } from './options_modal';
|
import { BOOLEAN_OPTION, SELECT_OPTION, OptionHandler } from '../options';
|
||||||
import Apple2IO from '../apple2io';
|
import Apple2IO from '../apple2io';
|
||||||
|
|
||||||
const SYSTEM_TYPE_APPLE2E = 'computer_type2e';
|
export const SYSTEM_TYPE_APPLE2E = 'computer_type2e';
|
||||||
const SYSTEM_TYPE_APPLE2 = 'computer_type2';
|
export const SYSTEM_TYPE_APPLE2 = 'computer_type2';
|
||||||
const SYSTEM_CPU_ACCELERATED = 'accelerator_toggle';
|
export const SYSTEM_CPU_ACCELERATED = 'accelerator_toggle';
|
||||||
|
|
||||||
export class System implements OptionHandler {
|
export class System implements OptionHandler {
|
||||||
constructor(private io: Apple2IO, private e: boolean) {}
|
constructor(private io: Apple2IO, private e: boolean) {}
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"filename": "json/disks/audit.json",
|
||||||
|
"name": "Apple II Audit",
|
||||||
|
"category": "Utility"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "json/disks/dos33master.json",
|
||||||
|
"name": "DOS 3.3 Master",
|
||||||
|
"category": "System"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "json/disks/prodos.json",
|
||||||
|
"name": "ProDOS",
|
||||||
|
"category": "System"
|
||||||
|
}
|
||||||
|
]
|
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
|
@ -6,7 +6,7 @@
|
||||||
"build": "rimraf dist/* && webpack --mode=production",
|
"build": "rimraf dist/* && webpack --mode=production",
|
||||||
"dev": "webpack serve --mode=development",
|
"dev": "webpack serve --mode=development",
|
||||||
"index": "bin/index > json/disks/index.js",
|
"index": "bin/index > json/disks/index.js",
|
||||||
"lint": "eslint '**/*.js' '**/*.ts'",
|
"lint": "eslint '**/*.js' '**/*.ts' '**/*.tsx'",
|
||||||
"start": "webpack serve --mode=development --progress",
|
"start": "webpack serve --mode=development --progress",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
|
@ -25,18 +25,20 @@
|
||||||
"homepage": "https://github.com/whscullin/apple2js#readme",
|
"homepage": "https://github.com/whscullin/apple2js#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.9.0",
|
"@babel/core": "^7.9.0",
|
||||||
|
"@babel/plugin-transform-react-jsx": "^7.17.3",
|
||||||
"@babel/preset-env": "^7.9.0",
|
"@babel/preset-env": "^7.9.0",
|
||||||
|
"@babel/preset-typescript": "^7.16.7",
|
||||||
"@testing-library/dom": "^7.30.3",
|
"@testing-library/dom": "^7.30.3",
|
||||||
"@testing-library/user-event": "^13.1.3",
|
"@testing-library/user-event": "^13.1.3",
|
||||||
"@types/jest": "^27.0.2",
|
"@types/jest": "^27.0.2",
|
||||||
"@types/jest-image-snapshot": "^4.3.1",
|
"@types/jest-image-snapshot": "^4.3.1",
|
||||||
"@types/micromodal": "^0.3.2",
|
"@types/micromodal": "^0.3.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
"@typescript-eslint/eslint-plugin": "^5.22.0",
|
||||||
"@typescript-eslint/parser": "^5.4.0",
|
"@typescript-eslint/parser": "^5.22.0",
|
||||||
"ajv": "^6.12.0",
|
"ajv": "^6.12.0",
|
||||||
"babel-jest": "^27.2.4",
|
"babel-jest": "^27.2.4",
|
||||||
"canvas": "^2.8.0",
|
"canvas": "^2.8.0",
|
||||||
"eslint": "^8.3.0",
|
"eslint": "^8.15.0",
|
||||||
"file-loader": "^6.0.0",
|
"file-loader": "^6.0.0",
|
||||||
"jest": "^27.2.4",
|
"jest": "^27.2.4",
|
||||||
"jest-image-snapshot": "^4.5.1",
|
"jest-image-snapshot": "^4.5.1",
|
||||||
|
@ -54,6 +56,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"apple2shader": "0.0.3",
|
"apple2shader": "0.0.3",
|
||||||
"micromodal": "^0.4.2"
|
"classnames": "^2.3.1",
|
||||||
|
"micromodal": "^0.4.2",
|
||||||
|
"preact": "^10.7.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ const detail = !!process.env.JEST_DETAIL;
|
||||||
/**
|
/**
|
||||||
* Memory address and value
|
* Memory address and value
|
||||||
*/
|
*/
|
||||||
type MemoryValue = [address: word, value: byte]
|
type MemoryValue = [address: word, value: byte];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents initial and final CPU and memory states
|
* Represents initial and final CPU and memory states
|
||||||
|
@ -50,7 +50,7 @@ type MemoryValue = [address: word, value: byte]
|
||||||
/**
|
/**
|
||||||
* CPU cycle memory operation
|
* CPU cycle memory operation
|
||||||
*/
|
*/
|
||||||
type Cycle = [address: word, value: byte, type: 'read'|'write']
|
type Cycle = [address: word, value: byte, type: 'read'|'write'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One test record
|
* One test record
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
/** @jest-environment jsdom */
|
/** @jest-environment jsdom */
|
||||||
import { screen } from '@testing-library/dom';
|
import { screen } from '@testing-library/dom';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { Options } from 'js/options';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BOOLEAN_OPTION,
|
BOOLEAN_OPTION,
|
||||||
SELECT_OPTION,
|
SELECT_OPTION,
|
||||||
OptionHandler,
|
OptionHandler,
|
||||||
|
} from 'js/options';
|
||||||
|
import {
|
||||||
OptionsModal
|
OptionsModal
|
||||||
} from 'js/ui/options_modal';
|
} from 'js/ui/options_modal';
|
||||||
|
|
||||||
|
@ -59,10 +62,12 @@ const mockOptionHandler: OptionHandler = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('OptionsModal', () => {
|
describe('OptionsModal', () => {
|
||||||
|
let options: Options;
|
||||||
let modal: OptionsModal;
|
let modal: OptionsModal;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
modal = new OptionsModal();
|
options = new Options();
|
||||||
modal.addOptions(mockOptionHandler);
|
options.addOptions(mockOptionHandler);
|
||||||
|
modal = new OptionsModal(options);
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
@ -107,31 +112,32 @@ describe('OptionsModal', () => {
|
||||||
|
|
||||||
describe('getOption', () => {
|
describe('getOption', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
modal = new OptionsModal();
|
options = new Options();
|
||||||
modal.addOptions(mockOptionHandler);
|
options.addOptions(mockOptionHandler);
|
||||||
|
modal = new OptionsModal(options);
|
||||||
});
|
});
|
||||||
it('gets boolean', () => {
|
it('gets boolean', () => {
|
||||||
expect(modal.getOption('option_1'))
|
expect(options.getOption('option_1'))
|
||||||
.toEqual(false);
|
.toEqual(false);
|
||||||
expect(modal.getOption('option_3'))
|
expect(options.getOption('option_3'))
|
||||||
.toEqual(true);
|
.toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gets selector', () => {
|
it('gets selector', () => {
|
||||||
expect(modal.getOption('option_2'))
|
expect(options.getOption('option_2'))
|
||||||
.toEqual('select_1');
|
.toEqual('select_1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setOption', () => {
|
describe('setOption', () => {
|
||||||
it('sets boolean', () => {
|
it('sets boolean', () => {
|
||||||
modal.setOption('option_1', true);
|
options.setOption('option_1', true);
|
||||||
expect(mockOptionHandler.setOption)
|
expect(mockOptionHandler.setOption)
|
||||||
.toHaveBeenCalledWith('option_1', true);
|
.toHaveBeenCalledWith('option_1', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets selector', () => {
|
it('sets selector', () => {
|
||||||
modal.setOption('option_2', 'select_2');
|
options.setOption('option_2', 'select_2');
|
||||||
expect(mockOptionHandler.setOption)
|
expect(mockOptionHandler.setOption)
|
||||||
.toHaveBeenCalledWith('option_2', 'select_2');
|
.toHaveBeenCalledWith('option_2', 'select_2');
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { MemoryPages, byte, word } from 'js/types';
|
import { MemoryPages, byte, word } from 'js/types';
|
||||||
import { assertByte } from './asserts';
|
import { assertByte } from './asserts';
|
||||||
|
|
||||||
export type Log = [address: word, value: byte, types: 'read'|'write']
|
export type Log = [address: word, value: byte, types: 'read'|'write'];
|
||||||
export class TestMemory implements MemoryPages {
|
export class TestMemory implements MemoryPages {
|
||||||
private data: Buffer;
|
private data: Buffer;
|
||||||
private logging: boolean = false;
|
private logging: boolean = false;
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"jsx": "react",
|
||||||
|
"jsxFactory": "h",
|
||||||
|
"jsxFragmentFactory": "Fragment",
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
@ -10,6 +13,7 @@
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
@ -23,7 +27,10 @@
|
||||||
"js/*": [
|
"js/*": [
|
||||||
"js/*"
|
"js/*"
|
||||||
],
|
],
|
||||||
"test/*": [
|
"json/*": [
|
||||||
|
"json/*"
|
||||||
|
],
|
||||||
|
"test/*": [
|
||||||
"test/*"
|
"test/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ const baseConfig = {
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.ts$/i,
|
test: /\.tsx?$/i,
|
||||||
use: [
|
use: [
|
||||||
{
|
{
|
||||||
loader: 'ts-loader'
|
loader: 'ts-loader'
|
||||||
|
@ -24,7 +24,11 @@ const baseConfig = {
|
||||||
chunkFilename: '[name].bundle.js',
|
chunkFilename: '[name].bundle.js',
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: ['.ts', '.js'],
|
extensions: ['.ts', '.tsx', '.js'],
|
||||||
|
alias: {
|
||||||
|
js: path.resolve(__dirname, 'js/'),
|
||||||
|
json: path.resolve(__dirname, 'json/'),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -32,7 +36,8 @@ const appConfig = merge(baseConfig,
|
||||||
{
|
{
|
||||||
entry: {
|
entry: {
|
||||||
main2: path.resolve('js/entry2.ts'),
|
main2: path.resolve('js/entry2.ts'),
|
||||||
main2e: path.resolve('js/entry2e.ts')
|
main2e: path.resolve('js/entry2e.ts'),
|
||||||
|
preact: path.resolve('js/entry.tsx'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
library: {
|
library: {
|
||||||
|
|
Loading…
Reference in New Issue