Preact MVP

This commit is contained in:
Will Scullin 2022-05-06 16:35:49 -07:00
parent dc641c688a
commit ad80029d63
No known key found for this signature in database
GPG Key ID: 26DCD1042C6638CD
34 changed files with 1307 additions and 366 deletions

View File

@ -587,6 +587,7 @@ button:focus {
#reset-row .inset {
margin: 0;
width: 604px;
}
#reset {
@ -654,7 +655,6 @@ button:focus {
#options-modal {
width: 300px;
line-height: 1.75em;
}
#options-modal h3 {

View File

@ -2,17 +2,34 @@ 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 />
<Header e={system.e} />
<Apple2
e
enhanced
gl
rom="apple2enh"
characterRom="apple2enh_char"
gl={gl}
{...system}
/>
</>
);

View File

@ -1,4 +1,5 @@
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';
@ -6,17 +7,32 @@ import { ControlStrip } from './ControlStrip';
import { Inset } from './Inset';
import { Keyboard } from './Keyboard';
import { Screen } from './Screen';
import { DiskII } from './DiskII';
import { Drives } from './Drives';
/**
* Interface for the Apple2 component
*/
export interface Apple2Props {
characterRom: string;
enhanced: boolean,
e: boolean,
gl: boolean,
rom: string,
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>();
@ -40,14 +56,14 @@ export const Apple2 = (props: Apple2Props) => {
}, [screen.current]);
return (
<div className="apple2e outer">
<div className={cs('outer', { apple2e: e})}>
<Screen screen={screen} />
<Inset>
<DiskII io={io} />
<Drives io={io} sectors={sectors} />
</Inset>
<ControlStrip apple2={apple2} e={props.e} />
<ControlStrip apple2={apple2} e={e} />
<Inset>
<Keyboard apple2={apple2} />
<Keyboard apple2={apple2} e={e} />
</Inset>
</div>
);

View File

@ -1,11 +1,23 @@
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
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,
@ -14,16 +26,30 @@ export const CPUMeter = ({ apple2 }: CPUMeterProps) => {
});
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 - lastStats.current.cycles) /
(time - lastTime.current)
(stats.cycles - cycles) / delta
)
);
setFps(
Math.floor(
(stats.frames - frames) / delta * 1000
)
);
setRps(
Math.floor(
(stats.renderedFrames - renderedFrames) / delta * 1000
)
);
lastStats.current = { ...stats };
@ -33,9 +59,15 @@ export const CPUMeter = ({ apple2 }: CPUMeterProps) => {
return () => clearInterval(interval);
}, [apple2]);
const onClick = useCallback(() => {
setMode((mode) => (mode + 1) % 3);
}, []);
return (
<div id="khz">
{khz} Khz
<div id="khz" onClick={onClick}>
{mode === 0 && `${khz} Khz`}
{mode === 1 && `${fps} fps`}
{mode === 2 && `${rps} rps`}
</div>
);
};

View File

@ -0,0 +1,26 @@
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>
);

View File

@ -1,9 +1,16 @@
import { h } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
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 } from '../ui/audio';
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';
@ -12,15 +19,43 @@ interface ControlStripProps {
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(() =>
setAudio(new Audio(apple2.getIO()))
).catch(console.error);
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]);
@ -35,37 +70,64 @@ export const ControlStrip = ({ apple2, e }: ControlStripProps) => {
}, [apple2]);
const doToggleSound = useCallback(() => {
audio?.isEnabled();
const on = !audio?.isEnabled();
options.setOption(SOUND_ENABLED_OPTION, on);
setAudioEnabled(on);
}, [audio]);
const doReset = useCallback(() => {
apple2?.reset();
}, [apple2]);
const doReset = useCallback(() =>
apple2?.reset()
, [apple2]);
const doReadme = useCallback(() => {
window.open(README, '_blank');
}, []);
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 ? (
<button onClick={doPause} title="About">
<i class="fas fa-pause"></i>
</button>
<ControlButton
onClick={doPause}
title="Pause"
icon="pause"
/>
) : (
<button onClick={doRun} title="About">
<i class="fas fa-play"></i>
</button>
<ControlButton
onClick={doRun}
title="Run"
icon="play"
/>
)}
<button id="toggle-sound" onClick={doToggleSound} title="Toggle Sound">
<i class="fas fa-volume-off"></i>
</button>
<ControlButton
onClick={doToggleSound}
title="Toggle Sound"
icon={audioEnabled ? 'volume-up' : 'volume-off'}
/>
<div style={{flexGrow: 1}} />
<button onClick={doReadme} title="About">
<i class="fas fa-info"></i>
</button>
<ControlButton onClick={doReadme} title="About" icon="info" />
<ControlButton onClick={doShowOptions} title="Options (F4)" icon="cog" />
</Inset>
{e && (
<button id="reset" onClick={doReset}>

View File

@ -1,37 +1,67 @@
import { h, Fragment } from 'preact';
import { h } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import classNames from 'classnames';
import Disk2, { Callbacks } from '../cards/disk2';
import { NibbleFormat } from '../formats/types';
import Apple2IO from '../apple2io';
import cs from 'classnames';
import Disk2 from '../cards/disk2';
import { FileModal } from './FileModal';
import { loadJSON, loadHttpFile, getHashParts } from './util/files';
export interface DiskIIProps {
io?: Apple2IO
}
/**
* Storage structure for Disk II state returned via callbacks
*/
interface DriveData {
export interface DiskIIData {
number: 1 | 2
on: boolean
name?: string
side?: string
}
interface DriveProps extends DriveData {
/**
* Interface for Disk II component
*/
export interface DiskIIProps extends DiskIIData {
disk2?: Disk2
}
const Drive = ({ disk2, number, on, name, side }: DriveProps) => {
const label = side ? `${name} - ${side}` : name;
/**
* Disk II component
*
* Include 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);
const onOpen = useCallback((name: string, fmt: NibbleFormat, rawData: ArrayBuffer) => {
setModalOpen(false);
return disk2?.setBinary(number, name, fmt, rawData) || false;
}, [disk2, number]);
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 onCancel = useCallback(() => {
const doClose = useCallback(() => {
setModalOpen(false);
}, []);
@ -41,10 +71,10 @@ const Drive = ({ disk2, number, on, name, side }: DriveProps) => {
return (
<div className="disk">
<FileModal onOpen={onOpen} onCancel={onCancel} show={modalOpen} />
<FileModal disk2={disk2} number={number} onClose={doClose} isOpen={modalOpen} />
<div
id={`disk${number}`}
className={classNames('disk-light', { on })}
className={cs('disk-light', { on })}
/>
<button title="Load Disk">
<i class="fas fa-folder-open" onClick={onOpenModal} />
@ -58,36 +88,3 @@ const Drive = ({ disk2, number, on, name, side }: DriveProps) => {
</div>
);
};
export const DiskII = ({ io }: DiskIIProps) => {
const [disk2, setDisk2] = useState<Disk2>();
const [data1, setData1] = useState<DriveData>({ on: false, number: 1, name: 'Disk1' });
const [data2, setData2] = useState<DriveData>({ on: false, number: 2, name: 'Disk2' });
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);
io.setSlot(6, disk2);
setDisk2(disk2);
}
}, [io]);
return (
<>
<Drive disk2={disk2} {...data1} />
<Drive disk2={disk2} {...data2} />
</>
);
};

55
js/components/Drives.tsx Normal file
View File

@ -0,0 +1,55 @@
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
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} />
</>
);
};

View File

@ -1,51 +1,77 @@
import { h } from 'preact';
import { h, JSX } from 'preact';
import { useCallback, useRef, useState } from 'preact/hooks';
import { includes } from '../types';
import { NibbleFormat, DISK_FORMATS, NIBBLE_FORMATS } from '../formats/types';
import { DiskDescriptor, DriveNumber, NibbleFormat } from '../formats/types';
import { Modal, ModalContent, ModalFooter } from './Modal';
import { initGamepad } from '../ui/gamepad';
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 {
show: boolean
onOpen: (name: string, fmt: NibbleFormat, rawData: ArrayBuffer) => boolean
onCancel: () => void
isOpen: boolean
disk2?: DiskII,
number: DriveNumber,
onClose: (closeBox?: boolean) => void
}
export const FileModal = ({ show, onOpen, onCancel } : FileModalProps) => {
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 onClick = useCallback(() => {
if (inputRef.current && inputRef.current.files?.length === 1) {
const doCancel = useCallback(() => onClose(true), []);
const doOpen = useCallback(() => {
const hashParts = getHashParts();
if (disk2 && inputRef.current && inputRef.current.files?.length === 1) {
hashParts[number] = '';
setBusy(true);
const file = inputRef.current.files[0];
const fileReader = new FileReader();
fileReader.onload = function () {
const result = this.result as ArrayBuffer;
const parts = file.name.split('.');
const ext = parts.pop()!.toLowerCase();
const name = parts.join('.');
if (includes(DISK_FORMATS, ext)) {
if (result.byteLength >= 800 * 1024) {
console.error(`Unable to load ${name}`);
} else {
if (
includes(NIBBLE_FORMATS, ext) &&
onOpen(name, ext, result)
) {
initGamepad();
} else {
console.error(`Unable to load ${name}`);
}
}
}
setBusy(false);
};
fileReader.readAsArrayBuffer(file);
loadLocalFile(disk2, number, inputRef.current.files[0])
.catch(console.error)
.finally(() => {
setBusy(false);
onClose();
});
}
}, [ onOpen ]);
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) {
@ -53,14 +79,44 @@ export const FileModal = ({ show, onOpen, onCancel } : FileModalProps) => {
}
}, [ 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" show={show}>
<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={onCancel}>Cancel</button>
<button onClick={onClick} disabled={busy || empty}>Open</button>
<button onClick={doCancel}>Cancel</button>
<button onClick={doOpen} disabled={busy || empty}>Open</button>
</ModalFooter>
</Modal>
);

View File

@ -1,12 +1,25 @@
import { h } from 'preact';
export const Header = () => {
/**
* 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 ][ Emulator in JavaScript</div>
<div id="subtitle">An Apple {e ? '//e' : ']['} Emulator in JavaScript</div>
</div>
);
};

View File

@ -1,6 +1,12 @@
import { h, FunctionComponent } from 'preact';
import { h, FunctionalComponent } from 'preact';
export const Inset: FunctionComponent = ({ children }) => (
/**
* Convenience component for a nice beveled border
*
* @returns Inset component
*/
export const Inset: FunctionalComponent = ({ children }) => (
<div className="inset">
{children}
</div>

View File

@ -1,39 +1,37 @@
import { h, Fragment, JSX } from 'preact';
import classNames from 'classnames';
import { useCallback, useEffect, useState } from 'preact/hooks';
import cs from 'classnames';
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import { Apple2 as Apple2Impl } from '../apple2';
import { mapKeyEvent, keys2e, mapMouseEvent } from './util/keyboard';
import {
keys2,
keys2e,
mapKeyEvent,
mapMouseEvent,
keysAsTuples
} from './util/keyboard';
export interface KeyboardProps {
apple2: Apple2Impl | undefined
}
const keysAsTuples = (): string[][][] => {
const rows = [];
for (let idx = 0; idx < keys2e[0].length; idx++) {
const upper = keys2e[0][idx];
const lower = keys2e[1][idx];
const keys = [];
for (let jdx = 0; jdx < upper.length; jdx++) {
keys.push([upper[jdx], lower[jdx]]);
}
rows.push(keys);
}
return rows;
};
const keys = keysAsTuples();
/**
* 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={classNames({ small })}
dangerouslySetInnerHTML={{__html: key}}
className={cs({ small })}
dangerouslySetInnerHTML={{ __html: key }}
/>
);
};
/**
* Key properties
*/
interface KeyProps {
lower: string
upper: string
@ -43,18 +41,36 @@ interface KeyProps {
onMouseUp: (event: MouseEvent) => void
}
export const Key = ({ lower, upper, active, pressed, onMouseDown, onMouseUp }: KeyProps) => {
/**
* 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 > 0 ?
(upper === lower && upper.length > 1 ?
'v-center'
: ''
);
return (
<div
className={classNames(
className={cs(
'key',
`key-${keyName}`,
center,
@ -73,15 +89,34 @@ export const Key = ({ lower, upper, active, pressed, onMouseDown, onMouseUp }: K
);
};
export const Keyboard = ({ apple2 }: KeyboardProps) => {
/**
* 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 {
@ -151,19 +186,22 @@ export const Keyboard = ({ apple2 }: KeyboardProps) => {
[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 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]) =>
const bindKey = ([lower, upper]: [string, string]) =>
<Key
lower={lower}
upper={upper}
@ -180,7 +218,7 @@ export const Keyboard = ({ apple2 }: KeyboardProps) => {
);
return (
<div id="keyboard">
<div id="keyboard" style={{ marginLeft: e ? 0 : 15 }}>
{rows}
</div>
);

View File

@ -1,4 +1,10 @@
import { h, FunctionalComponent } from 'preact';
import { useCallback } from 'preact/hooks';
/**
* Temporary JS styling while I figure out how I really want
* to do it.
*/
const modalOverlayStyle = {
position: 'fixed',
@ -10,6 +16,7 @@ const modalOverlayStyle = {
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.6)',
zIndex: 1,
};
const modalStyle = {
@ -21,7 +28,7 @@ const modalStyle = {
boxSizing: 'border-box',
};
const modalTitleStyle = {
const modalHeaderStyle = {
display: 'flex',
fontSize: '14px',
justifyContent: 'space-between',
@ -33,10 +40,20 @@ const modalTitleStyle = {
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',
lineHeight: 1.5,
color: '#000'
};
@ -44,10 +61,12 @@ const modalFooterStyle = {
textAlign: 'right'
};
export interface ModalProps {
show: boolean
title: string
}
/**
* ModalOverlay creates a semi-transparent overlay in which the
* modal is centered.
*
* @returns ModalOverlay component
*/
export const ModalOverlay: FunctionalComponent = ({ children }) => {
return (
@ -57,6 +76,12 @@ export const ModalOverlay: FunctionalComponent = ({ children }) => {
);
};
/**
* ModalContent provides a styled container for modal content
*
* @returns ModalContent component
*/
export const ModalContent: FunctionalComponent = ({ children }) => {
return (
<div style={modalContentStyle}>
@ -65,6 +90,12 @@ export const ModalContent: FunctionalComponent = ({ children }) => {
);
};
/**
* ModalFooter provides a right-aligned container for modal buttons
*
* @returns ModalFooter component
*/
export const ModalFooter: FunctionalComponent = ({ children }) => {
return (
<div style={modalFooterStyle}>
@ -73,14 +104,69 @@ export const ModalFooter: FunctionalComponent = ({ children }) => {
);
};
export const Modal: FunctionalComponent<ModalProps> = ({ show, title, children }) => {
/**
* 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
*/
export const ModalHeader = ({ onClose, title }: ModalHeaderProps) => {
const doClose = useCallback(() => onClose?.(true), [onClose]);
return (
show ? (
<div style={modalHeaderStyle}>
<span style={modalTitleStyle}>{title}</span>
{onClose && (
<button onClick={doClose} title="Close">
{'\u2715'}
</button>
)}
</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}>
<div style={modalTitleStyle}>
{title}
</div>
{title && <ModalHeader onClose={onClose} title={title} />}
{children}
</div>
</ModalOverlay>

View File

@ -1,4 +1,8 @@
import { createContext } from 'preact';
import { OptionsModal } from '../ui/options_modal';
import { Options } from '../options';
export const OptionsContext = createContext(new OptionsModal());
/**
* Context for getting, setting and configuring options
*/
export const OptionsContext = createContext(new Options());

View File

@ -1,47 +1,170 @@
import { h } from 'preact';
import { useContext } from 'preact/hooks';
import { Modal } from './Modal';
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,
OptionsModal as Options,
OptionSection
} from '../ui/options_modal';
OptionSection,
SelectOption,
} from '../options';
export interface OptionsModalProps {
show: boolean
/**
* Boolean property interface
*/
interface BooleanProps {
option: BooleanOption
value: boolean
setValue: (name: string, value: boolean) => void
}
const Boolean = (options: Options, option: BooleanOption) => {
/**
*
* @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 (
<input
type="checkbox"
checked={options.getOption(option.name)}
/>
)
<li>
<input
type="checkbox"
checked={value}
onChange={onChange}
/>
<label>{label}</label>
</li>
);
};
export const OptionsModal = ({ show }: OptionsModalProps) => {
/**
* 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 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 (
<div>
<div>{section.name}</div>
{section.options.map(makeOption)}
</div>
<>
<h3>{section.name}</h3>
<ul>
{section.options.map(makeOption)}
</ul>
</>
);
};
const doClose = useCallback(() => onClose(), [onClose]);
return (
<Modal title="Options" show={show}>
{sections.map(makeSection)}
<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>
);
};

View File

@ -1,9 +1,20 @@
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">

View File

@ -0,0 +1,26 @@
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]);
};

View File

@ -0,0 +1,7 @@
import Prefs from 'js/prefs';
// Todo(whscullin): More robust preferences
const prefs = new Prefs();
export const usePrefs = () => prefs;

176
js/components/util/files.ts Normal file
View File

@ -0,0 +1,176 @@
import { includes } from 'js/types';
import { initGamepad } from 'js/ui/gamepad';
import {
DISK_FORMATS,
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 = async (
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();
const name = parts.join('.');
if (includes(DISK_FORMATS, ext)) {
if (result.byteLength >= 800 * 1024) {
reject(`Unable to load ${name}`);
} else {
if (
includes(NIBBLE_FORMATS, ext) &&
disk2?.setBinary(number, name, ext, result)
) {
initGamepad();
} else {
reject(`Unable to load ${name}`);
}
}
}
resolve(true);
};
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) => {
return new Promise((resolve, reject) => {
fetch(url).then(function (response: Response) {
if (response.ok) {
return response.json();
} else {
throw new Error('Error loading: ' + response.statusText);
}
}).then(function (data: JSONDisk) {
if (includes(DISK_FORMATS, data.type)) {
disk2.setDisk(number, data);
}
initGamepad(data.gamepad);
resolve(true);
}).catch(function (error) {
reject(error.message);
});
});
};
/**
* 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);
} else {
return new Promise((resolve, reject) => {
fetch(url).then(function (response: Response) {
if (response.ok) {
const reader = response!.body!.getReader();
let received = 0;
const chunks: Uint8Array[] = [];
// const contentLength = parseInt(response.headers.get('content-length')!, 10);
return reader.read().then(
function readChunk(result): Promise<ArrayBufferLike> {
if (result.done) {
const data = new Uint8Array(received);
let offset = 0;
for (let idx = 0; idx < chunks.length; idx++) {
data.set(chunks[idx], offset);
offset += chunks[idx].length;
}
return Promise.resolve(data.buffer);
}
received += result.value.length;
// if (contentLength) {
// loadingProgress(received, contentLength);
// }
chunks.push(result.value);
return reader.read().then(readChunk);
});
} else {
reject('Error loading: ' + response.statusText);
}
}).then(function (data: ArrayBufferLike) {
const urlParts = url!.split('/');
const file = urlParts.pop()!;
const fileParts = file.split('.');
const ext = fileParts.pop()!.toLowerCase();
const name = decodeURIComponent(fileParts.join('.'));
if (data.byteLength >= 800 * 1024) {
reject(`Unable to load ${url}`);
} else if (
includes(NIBBLE_FORMATS, ext) &&
disk2.setBinary(number, name, ext, data)
) {
initGamepad();
} else {
reject(`Extension ${ext} not recognized.`);
}
resolve(true);
}).catch(reject);
});
}
};

View File

@ -2,6 +2,11 @@ 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
@ -162,6 +167,10 @@ 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'],
@ -180,6 +189,10 @@ export const keys2 = [
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'],
@ -202,6 +215,15 @@ 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) => {
const code = evt.keyCode;
let key: byte = 0xff;
@ -221,6 +243,17 @@ export const mapKeyEvent = (evt: KeyboardEvent, caps: boolean) => {
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,
@ -281,3 +314,25 @@ export const mapMouseEvent = (
}
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;
};

View File

@ -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,
}
};

View File

@ -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';
export const DRIVE_NUMBERS = [1, 2] as const;
@ -18,6 +18,29 @@ export interface DiskOptions {
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
* data which the DiskII card can process.

94
js/options.ts Normal file
View File

@ -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;
}
}

View File

@ -1,10 +1,12 @@
const havePrefs = typeof window.localStorage !== 'undefined';
export default class Prefs {
params: URLSearchParams;
url: URL;
title: string;
constructor() {
this.params = new URLSearchParams(window.location.search);
this.url = new URL(window.location.href);
this.title = window.document.title;
}
havePrefs() {
@ -14,8 +16,8 @@ export default class Prefs {
readPref(name: string): string | null
readPref(name: string, defaultValue: string): string
readPref(name: string, defaultValue: string | null = null) {
if (this.params.has(name)) {
return this.params.get(name);
if (this.url.searchParams.has(name)) {
return this.url.searchParams.get(name);
}
if (havePrefs) {
@ -25,6 +27,15 @@ export default class Prefs {
}
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) {
window.localStorage.setItem(name, value);
}

View File

@ -3,18 +3,21 @@ import MicroModal from 'micromodal';
import { base64_json_parse, base64_json_stringify } from '../base64';
import { Audio, SOUND_ENABLED_OPTION } from './audio';
import DriveLights from './drive_lights';
import { byte, includes, word } from '../types';
import { BLOCK_FORMATS, MassStorage, NIBBLE_FORMATS } from '../formats/types';
import { includes, word } from '../types';
import {
BLOCK_FORMATS,
DISK_FORMATS,
DiskDescriptor,
DriveNumber,
DRIVE_NUMBERS,
MassStorage,
NIBBLE_FORMATS,
JSONBinaryImage,
JSONDisk
} from '../formats/types';
import { initGamepad } from './gamepad';
import KeyBoard from './keyboard';
import Tape, { TAPE_TYPES } from './tape';
import type { GamepadConfiguration } from './types';
import ApplesoftDump from '../applesoft/decompiler';
import ApplesoftCompiler from '../applesoft/compiler';
@ -31,6 +34,7 @@ import { OptionsModal } from './options_modal';
import { Screen, SCREEN_FULL_PAGE } from './screen';
import { JoyStick } from './joystick';
import { System } from './system';
import { Options } from '../options';
let paused = false;
@ -41,15 +45,8 @@ let lastRenderedFrames = 0;
let hashtag = document.location.hash;
const optionsModal = new OptionsModal();
interface DiskDescriptor {
name: string;
disk?: number;
filename: string;
e?: boolean;
category: string;
}
const options = new Options();
const optionsModal = new OptionsModal(options);
type DiskCollection = {
[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) {
loadingStart();
@ -538,7 +527,7 @@ export function toggleShowFPS() {
export function toggleSound() {
const on = !audio.isEnabled();
optionsModal.setOption(SOUND_ENABLED_OPTION, on);
options.setOption(SOUND_ENABLED_OPTION, on);
updateSoundButton(on);
}
@ -866,16 +855,16 @@ function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, print
_e = e;
system = new System(io, e);
optionsModal.addOptions(system);
options.addOptions(system);
joystick = new JoyStick(io);
optionsModal.addOptions(joystick);
options.addOptions(joystick);
screen = new Screen(vm);
optionsModal.addOptions(screen);
options.addOptions(screen);
audio = new Audio(io);
optionsModal.addOptions(audio);
options.addOptions(audio);
initSoundToggle();
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('F2', (event) => {
if (event.shiftKey) { // Full window, but not full screen
optionsModal.setOption(
options.setOption(
SCREEN_FULL_PAGE,
!optionsModal.getOption(SCREEN_FULL_PAGE)
!options.getOption(SCREEN_FULL_PAGE)
);
} else {
screen.enterFullScreen();

View File

@ -1,4 +1,4 @@
import { BOOLEAN_OPTION, OptionHandler } from './options_modal';
import { BOOLEAN_OPTION, OptionHandler } from '../options';
import Apple2IO from '../apple2io';
import { debug } from '../util';

View File

@ -1,5 +1,5 @@
import Apple2IO from '../apple2io';
import { BOOLEAN_OPTION, OptionHandler } from './options_modal';
import { BOOLEAN_OPTION, OptionHandler } from '../options';
const JOYSTICK_DISABLE = 'disable_mouse';
const JOYSTICK_FLIP_X_AXIS = 'flip_x';

View File

@ -1,103 +1,19 @@
import Prefs from '../prefs';
import MicroModal from 'micromodal';
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
}
import {
BOOLEAN_OPTION,
SELECT_OPTION,
Options,
SelectOption
} from '../options';
export class OptionsModal {
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;
}
constructor(private options: Options) {}
openModal = () => {
const content = document.querySelector('#options-modal-content');
if (content) {
content.innerHTML = '';
for (const section of this.sections) {
for (const section of this.options.getSections()) {
const { name, options } = section;
// Section header
@ -108,15 +24,15 @@ export class OptionsModal {
// Preferences
const list = document.createElement('ul');
for (const option of options) {
const { name, label, defaultVal, type } = option;
const { name, label, type } = option;
const onChange = (evt: InputEvent & { target: HTMLInputElement }) => {
const { target } = evt;
switch (type) {
case BOOLEAN_OPTION:
this.setOption(name, target.checked);
this.options.setOption(name, target.checked);
break;
default:
this.setOption(name, target.value);
this.options.setOption(name, target.value);
}
};
@ -127,7 +43,7 @@ export class OptionsModal {
case BOOLEAN_OPTION:
{
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.checked = checked;
element = inputElement;
@ -137,7 +53,7 @@ export class OptionsModal {
{
const selectOption = option as SelectOption;
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) {
const optionElement = document.createElement('option');
optionElement.value = value.value;
@ -151,7 +67,7 @@ export class OptionsModal {
default:
{
const inputElement = document.createElement('input');
const value = this.prefs.readPref(name, String(defaultVal));
const value = this.options.getOption(name) as string;
inputElement.value = value;
element = inputElement;
}

View File

@ -1,5 +1,5 @@
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_FULL_PAGE = 'full_page';

View File

@ -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';
const SYSTEM_TYPE_APPLE2E = 'computer_type2e';
const SYSTEM_TYPE_APPLE2 = 'computer_type2';
const SYSTEM_CPU_ACCELERATED = 'accelerator_toggle';
export const SYSTEM_TYPE_APPLE2E = 'computer_type2e';
export const SYSTEM_TYPE_APPLE2 = 'computer_type2';
export const SYSTEM_CPU_ACCELERATED = 'accelerator_toggle';
export class System implements OptionHandler {
constructor(private io: Apple2IO, private e: boolean) {}

17
json/disks/index.json Normal file
View File

@ -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"
}
]

View File

@ -1,11 +1,14 @@
/** @jest-environment jsdom */
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { Options } from 'js/options';
import {
BOOLEAN_OPTION,
SELECT_OPTION,
OptionHandler,
} from 'js/options';
import {
OptionsModal
} from 'js/ui/options_modal';
@ -59,10 +62,12 @@ const mockOptionHandler: OptionHandler = {
};
describe('OptionsModal', () => {
let options: Options;
let modal: OptionsModal;
beforeEach(() => {
modal = new OptionsModal();
modal.addOptions(mockOptionHandler);
options = new Options();
options.addOptions(mockOptionHandler);
modal = new OptionsModal(options);
});
afterEach(() => {
localStorage.clear();
@ -107,31 +112,32 @@ describe('OptionsModal', () => {
describe('getOption', () => {
beforeEach(() => {
modal = new OptionsModal();
modal.addOptions(mockOptionHandler);
options = new Options();
options.addOptions(mockOptionHandler);
modal = new OptionsModal(options);
});
it('gets boolean', () => {
expect(modal.getOption('option_1'))
expect(options.getOption('option_1'))
.toEqual(false);
expect(modal.getOption('option_3'))
expect(options.getOption('option_3'))
.toEqual(true);
});
it('gets selector', () => {
expect(modal.getOption('option_2'))
expect(options.getOption('option_2'))
.toEqual('select_1');
});
});
describe('setOption', () => {
it('sets boolean', () => {
modal.setOption('option_1', true);
options.setOption('option_1', true);
expect(mockOptionHandler.setOption)
.toHaveBeenCalledWith('option_1', true);
});
it('sets selector', () => {
modal.setOption('option_2', 'select_2');
options.setOption('option_2', 'select_2');
expect(mockOptionHandler.setOption)
.toHaveBeenCalledWith('option_2', 'select_2');
});

View File

@ -13,6 +13,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"sourceMap": true,
"strictNullChecks": true,
"outDir": "dist",
@ -26,7 +27,10 @@
"js/*": [
"js/*"
],
"test/*": [
"json/*": [
"json/*"
],
"test/*": [
"test/*"
]
}

View File

@ -25,6 +25,10 @@ const baseConfig = {
},
resolve: {
extensions: ['.ts', '.tsx', '.js'],
alias: {
js: path.resolve(__dirname, 'js/'),
json: path.resolve(__dirname, 'json/'),
}
},
};