From 4a188a9a5c2a8fa8373f0cd73206194c56700a08 Mon Sep 17 00:00:00 2001 From: Will Scullin Date: Tue, 10 May 2022 06:52:06 -0700 Subject: [PATCH] Preact UI (#106) First pass at a Preact UI, still short some major features but full proof of concept. --- .eslintrc.json | 13 +- babel.config.js | 14 + css/apple2.css | 6 +- index.html | 10 + js/apple2.ts | 29 +- js/components/App.tsx | 35 + js/components/Apple2.tsx | 69 ++ js/components/CPUMeter.tsx | 72 ++ js/components/ControlButton.tsx | 24 + js/components/ControlStrip.tsx | 138 ++++ js/components/DiskII.tsx | 87 +++ js/components/Drives.tsx | 53 ++ js/components/FileModal.tsx | 123 +++ js/components/Header.tsx | 24 + js/components/Inset.tsx | 12 + js/components/Keyboard.tsx | 222 ++++++ js/components/Modal.tsx | 188 +++++ js/components/OptionsContext.tsx | 7 + js/components/OptionsModal.tsx | 166 ++++ js/components/Screen.tsx | 25 + js/components/hooks/useHotKey.ts | 25 + js/components/hooks/usePrefs.ts | 7 + js/components/util/files.ts | 143 ++++ js/components/util/keyboard.ts | 334 ++++++++ js/components/util/systems.ts | 71 ++ js/cpu6502.ts | 6 +- js/debugger.ts | 2 +- js/entry.tsx | 4 + js/formats/types.ts | 27 +- js/options.ts | 94 +++ js/prefs.ts | 23 +- js/ui/apple2.ts | 45 +- js/ui/audio.ts | 2 +- js/ui/audio_worker.ts | 2 +- js/ui/joystick.ts | 2 +- js/ui/keyboard.ts | 2 +- js/ui/options_modal.ts | 104 +-- js/ui/screen.ts | 2 +- js/ui/system.ts | 8 +- json/disks/index.json | 17 + package-lock.json | 1216 +++++++++++++++++------------- package.json | 14 +- test/cpu-tom-harte.spec.ts | 4 +- test/js/ui/options_modal.spec.ts | 24 +- test/util/memory.ts | 2 +- tsconfig.json | 9 +- webpack.config.js | 11 +- 47 files changed, 2819 insertions(+), 698 deletions(-) create mode 100644 index.html create mode 100644 js/components/App.tsx create mode 100644 js/components/Apple2.tsx create mode 100644 js/components/CPUMeter.tsx create mode 100644 js/components/ControlButton.tsx create mode 100644 js/components/ControlStrip.tsx create mode 100644 js/components/DiskII.tsx create mode 100644 js/components/Drives.tsx create mode 100644 js/components/FileModal.tsx create mode 100644 js/components/Header.tsx create mode 100644 js/components/Inset.tsx create mode 100644 js/components/Keyboard.tsx create mode 100644 js/components/Modal.tsx create mode 100644 js/components/OptionsContext.tsx create mode 100644 js/components/OptionsModal.tsx create mode 100644 js/components/Screen.tsx create mode 100644 js/components/hooks/useHotKey.ts create mode 100644 js/components/hooks/usePrefs.ts create mode 100644 js/components/util/files.ts create mode 100644 js/components/util/keyboard.ts create mode 100644 js/components/util/systems.ts create mode 100644 js/entry.tsx create mode 100644 js/options.ts create mode 100644 json/disks/index.json diff --git a/.eslintrc.json b/.eslintrc.json index cbbbcc8..442f3ea 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -20,7 +20,8 @@ "prefer-const": [ "error" ], - "semi": [ + "semi": "off", + "@typescript-eslint/semi": [ "error", "always" ], @@ -52,7 +53,8 @@ "error" ] } - ] + ], + "@typescript-eslint/require-await": ["error"] }, "env": { "builtin": true, @@ -120,5 +122,10 @@ } } ], - "ignorePatterns": ["coverage/**/*"] + "ignorePatterns": ["coverage/**/*"], + "settings": { + "react": { + "pragma": "h" + } + } } diff --git a/babel.config.js b/babel.config.js index 442aa3c..69cbd06 100644 --- a/babel.config.js +++ b/babel.config.js @@ -9,5 +9,19 @@ module.exports = { }, }, ], + [ + '@babel/typescript', + { + jsxPragma: 'h' + } + ], ], + plugins: [ + [ + '@babel/plugin-transform-react-jsx', { + pragma: 'h', + pragmaFrag: 'Fragment', + } + ] + ] }; diff --git a/css/apple2.css b/css/apple2.css index 3952ebc..916ef7f 100644 --- a/css/apple2.css +++ b/css/apple2.css @@ -122,6 +122,10 @@ body { height: 16px; } +.disk-light.on { + background-image: url(red-on-16.png); +} + .disk-label { color: #000; font-family: sans-serif; @@ -583,6 +587,7 @@ button:focus { #reset-row .inset { margin: 0; + width: 604px; } #reset { @@ -650,7 +655,6 @@ button:focus { #options-modal { width: 300px; - line-height: 1.75em; } #options-modal h3 { diff --git a/index.html b/index.html new file mode 100644 index 0000000..37fa90a --- /dev/null +++ b/index.html @@ -0,0 +1,10 @@ + + + PreApple II + + + + +
+ + diff --git a/js/apple2.ts b/js/apple2.ts index d72aced..1527895 100644 --- a/js/apple2.ts +++ b/js/apple2.ts @@ -33,25 +33,26 @@ import { processGamepad } from './ui/gamepad'; export interface Apple2Options { characterRom: string; - enhanced: boolean, - e: boolean, - gl: boolean, - rom: string, - canvas: HTMLCanvasElement, - tick: () => void, + enhanced: boolean; + e: boolean; + gl: boolean; + rom: string; + canvas: HTMLCanvasElement; + tick: () => void; } export interface Stats { - frames: number, - renderedFrames: number, + cycles: number; + frames: number; + renderedFrames: number; } interface State { - cpu: CpuState, - vm: VideoModesState, - io: Apple2IOState, - mmu?: MMUState, - ram?: RAMState[], + cpu: CpuState; + vm: VideoModesState; + io: Apple2IOState; + mmu?: MMUState; + ram?: RAMState[]; } export class Apple2 implements Restorable, DebuggerContainer { @@ -78,6 +79,7 @@ export class Apple2 implements Restorable, DebuggerContainer { private tick: () => void; private stats: Stats = { + cycles: 0, frames: 0, renderedFrames: 0 }; @@ -186,6 +188,7 @@ export class Apple2 implements Restorable, DebuggerContainer { this.stats.renderedFrames++; } } + this.stats.cycles = this.cpu.getCycles(); this.stats.frames++; this.io.tick(); this.tick(); diff --git a/js/components/App.tsx b/js/components/App.tsx new file mode 100644 index 0000000..0bac016 --- /dev/null +++ b/js/components/App.tsx @@ -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 ( + <> +
+ + + ); +}; diff --git a/js/components/Apple2.tsx b/js/components/Apple2.tsx new file mode 100644 index 0000000..317355d --- /dev/null +++ b/js/components/Apple2.tsx @@ -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(null); + const [apple2, setApple2] = useState(); + const [io, setIO] = useState(); + + 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 ( +
+ + + + + + + + +
+ ); +}; diff --git a/js/components/CPUMeter.tsx b/js/components/CPUMeter.tsx new file mode 100644 index 0000000..43c206b --- /dev/null +++ b/js/components/CPUMeter.tsx @@ -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({ + frames: 0, + renderedFrames: 0, + cycles: 0, + }); + const lastTime = useRef(Date.now()); + const [khz, setKhz] = useState(0); + const [fps, setFps] = useState(0); + const [rps, setRps] = useState(0); + const [mode, setMode] = useState(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 ( +
+ {mode === 0 && `${khz} Khz`} + {mode === 1 && `${fps} fps`} + {mode === 2 && `${rps} rps`} +
+ ); +}; diff --git a/js/components/ControlButton.tsx b/js/components/ControlButton.tsx new file mode 100644 index 0000000..c39f9cd --- /dev/null +++ b/js/components/ControlButton.tsx @@ -0,0 +1,24 @@ +import { h, JSX } from 'preact'; + +/** + * Interface for ControlButton. + */ +export interface ControlButtonProps { + icon: string; + title: string; + onClick: JSX.MouseEventHandler; +} + +/** + * 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) => ( + +); diff --git a/js/components/ControlStrip.tsx b/js/components/ControlStrip.tsx new file mode 100644 index 0000000..ddf4de9 --- /dev/null +++ b/js/components/ControlStrip.tsx @@ -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