Break up UI

This commit is contained in:
Will Scullin 2021-07-11 14:57:08 -07:00
parent a9885dbfbd
commit 2c89289ff1
No known key found for this signature in database
GPG Key ID: 26DCD1042C6638CD
19 changed files with 1016 additions and 824 deletions

View File

@ -34,9 +34,9 @@
</head>
<body class="apple2"
ondragover="Apple2.handleDragOver(0, event)"
ondrop="Apple2.handleDrop(0, event)"
ondragend="Apple2.handleDragEnd(0, event)">
ondragover="Disk2UI.handleDragOver(0, event)"
ondrop="Disk2UI.handleDrop(0, event)"
ondragend="Disk2UI.handleDragEnd(0, event)">
<div class="outer">
<div id="header">
<a href="https://github.com/whscullin/apple2js#readme" target="_blank">
@ -51,31 +51,31 @@
</div>
<div class="inset">
<div class="disk"
ondragover="Apple2.handleDragOver(1, event)"
ondrop="Apple2.handleDrop(1, event)"
ondragend="Apple2.handleDragEnd(1, event)">
ondragover="Disk2UI.handleDragOver(event)"
ondrop="Disk2UI.handleDrop(1, event)"
ondragend="Disk2UI.handleDragEnd(event)">
<div class="disk-light" id="disk1"></div>
<button title="Load Disk"
onclick="Apple2.openLoad(1, event);">
onclick="Disk2UI.openLoad(1, event);">
<i class="fas fa-folder-open"></i>
</button>
<button title="Save Disk"
onclick="Apple2.openSave(1, event);">
onclick="Disk2UI.openSave(1, event);">
<i class="fas fa-save"></i>
</button>
<div id="disk-label1" class="disk-label">Disk 1</div>
</div>
<div class="disk"
ondragover="Apple2.handleDragOver(2, event)"
ondrop="Apple2.handleDrop(2, event)"
ondragend="Apple2.handleDragEnd(2, event)">
ondragover="Disk2UI.handleDragOver(event)"
ondrop="Disk2UI.handleDrop(2, event)"
ondragend="Disk2UI.handleDragEnd(event)">
<div class="disk-light" id="disk2"></div>
<button title="Load Disk"
onclick="Apple2.openLoad(2, event);">
onclick="Disk2UI.openLoad(2, event);">
<i class="fas fa-folder-open"></i>
</button>
<button title="Save Disk"
onclick="Apple2.openSave(2, event);">
onclick="Disk2UI.openSave(2, event);">
<i class="fas fa-save"></i>
</button>
<div id="disk-label2" class="disk-label">Disk 2</div>
@ -89,14 +89,14 @@
<button id="toggle-sound" onclick="Apple2.toggleSound()" title="Toggle Sound">
<i class="fas fa-volume-off"></i>
</button>
<button id="toggle-printer" onclick="Apple2.openPrinterModal()" title="Toggle Printer">
<button id="toggle-printer" onclick="Printer.openPrinterModal()" title="Toggle Printer">
<i class="fas fa-print"></i>
</button>
<div class="spacer"></div>
<button onclick="window.open('https://github.com/whscullin/apple2js#readme', 'blank')" title="About">
<i class="fas fa-info"></i>
</button>
<button onclick="Apple2.openOptions()" title="Options (F4)">
<button onclick="OptionsModal.openModal()" title="Options (F4)">
<i class="fas fa-cog"></i>
</button>
</div>
@ -165,7 +165,7 @@
</div>
</main>
<footer class="modal__footer">
<button class="modal__btn" onclick="Apple2.doSave()" aria-label="Save disk locally">Save</button>
<button class="modal__btn" onclick="Disk2UI.doSave()" aria-label="Save disk locally">Save</button>
</footer>
</div>
</div>
@ -227,13 +227,13 @@
<tr>
<td>
<select id="category_select" multiple="multiple"
onchange="Apple2.selectCategory(event)" >
onchange="Disk2UI.selectCategory(event)" >
</select>
</td>
<td>
<select id="disk_select" multiple="multiple"
onchange="Apple2.selectDisk(event)"
ondblclick="Apple2.clickDisk(event)">
onchange="Disk2UI.selectDisk(event)"
ondblclick="Disk2UI.clickDisk(event)">
</select>
</td>
</tr>
@ -248,7 +248,7 @@
</main>
<footer class="modal__footer">
<button class="modal__btn" data-micromodal-close aria-label="Close this dialog window">Cancel</button>
<button class="modal__btn" onclick="Apple2.doLoad(event)" aria-label="Open the selected disk">Open</button>
<button class="modal__btn" onclick="Disk2UI.doLoad(event)" aria-label="Open the selected disk">Open</button>
</footer>
</div>
</div>
@ -269,7 +269,7 @@
</main>
<footer class="modal__footer">
<a id="raw_printer_output" class="button">Download Raw Output</a>
<button class="modal__btn" onclick="Apple2.clearPrinterPaper()" aria-label="Clear the paper">Clear</button>
<button class="modal__btn" onclick="Printer.clear()" aria-label="Clear the paper">Clear</button>
<button class="modal__btn" data-micromodal-close aria-label="Close this dialog window">Close</button>
</footer>
</div>

View File

@ -34,9 +34,9 @@
</head>
<body class="apple2e"
ondragover="Apple2.handleDragOver(0, event)"
ondrop="Apple2.handleDrop(0, event)"
ondragend="Apple2.handleDragEnd(0, event)">
ondragover="Disk2UI.handleDragOver(event)"
ondrop="Disk2UI.handleDrop(0, event)"
ondragend="Disk2UI.handleDragEnd(event)">
<div class="outer">
<div id="header">
<a href="https://github.com/whscullin/apple2js#readme" target="_blank">
@ -49,36 +49,62 @@
<canvas id="screen" width="592" height="416"></canvas>
</div>
</div>
<div class="inset">
<div class="disk"
ondragover="Apple2.handleDragOver(1, event)"
ondrop="Apple2.handleDrop(1, event)"
ondragend="Apple2.handleDragEnd(1, event)">
<div class="disk-light" id="disk1"></div>
<button title="Load Disk"
onclick="Apple2.openLoad(1, event);">
<i class="fas fa-folder-open"></i>
</button>
<button title="Save Disk"
onclick="Apple2.openSave(1, event);">
<i class="fas fa-save"></i>
</button>
<div id="disk-label1" class="disk-label">Disk 1</div>
<div class="storage">
<div class="inset disk-ii">
<div class="disk"
ondragover="Disk2UI.handleDragOver( event)"
ondrop="Disk2UI.handleDrop(1, event)"
ondragend="Disk2UI.handleDragEnd(event)">
<div class="disk-light" id="disk1"></div>
<button title="Load Disk"
onclick="Disk2UI.openLoad(1, event);">
<i class="fas fa-folder-open"></i>
</button>
<button title="Save Disk"
onclick="Disk2UI.openSave(1, event);">
<i class="fas fa-save"></i>
</button>
<div id="disk-label1" class="disk-label">Disk 1</div>
</div>
<div class="disk"
ondragover="Disk2UI.handleDragOver(event)"
ondrop="Disk2UI.handleDrop(2, event)"
ondragend="Disk2UI.handleDragEnd(event)">
<div class="disk-light" id="disk2"></div>
<button title="Load Disk"
onclick="Disk2UI.openLoad(2, event);">
<i class="fas fa-folder-open"></i>
</button>
<button title="Save Disk"
onclick="Disk2UI.openSave(2, event);">
<i class="fas fa-save"></i>
</button>
<div id="disk-label2" class="disk-label">Disk 2</div>
</div>
</div>
<div class="disk"
ondragover="Apple2.handleDragOver(2, event)"
ondrop="Apple2.handleDrop(2, event)"
ondragend="Apple2.handleDragEnd(2, event)">
<div class="disk-light" id="disk2"></div>
<button title="Load Disk"
onclick="Apple2.openLoad(2, event);">
<i class="fas fa-folder-open"></i>
</button>
<button title="Save Disk"
onclick="Apple2.openSave(2, event);">
<i class="fas fa-save"></i>
</button>
<div id="disk-label2" class="disk-label">Disk 2</div>
<div class="inset block-storage">
<div class="disk"
ondragover="BlockStorageUI.handleDragOver(event)"
ondrop="BlockStorageUI.handleDrop(1, event)"
ondragend="BlockStorageUI.handleDragEnd(event)">
<div class="disk-light" id="mass-storage1"></div>
<button title="Load Disk"
onclick="BlockStorageUI.openLoad(1, event);">
<i class="fas fa-folder-open"></i>
</button>
<div id="mass-storage-label1" class="disk-label">HD 1</div>
</div>
<div class="disk"
ondragover="BlockStorageUI.handleDragOver(event)"
ondrop="BlockStorageUI.handleDrop(2, true, event)"
ondragend="BlockStorageUI.handleDragEnd(event)">
<div class="disk-light" id="mass-storage2"></div>
<button title="Load Disk"
onclick="BlockStorageUI.openLoad(2, event);">
<i class="fas fa-folder-open"></i>
</button>
<div id="mass-storage-label2" class="disk-label">HD 2</div>
</div>
</div>
</div>
<div id="reset-row">
@ -90,14 +116,14 @@
<button id="toggle-sound" onclick="Apple2.toggleSound()" title="Toggle Sound">
<i class="fas fa-volume-off"></i>
</button>
<button id="toggle-printer" onclick="Apple2.openPrinterModal()" title="Toggle Printer">
<button id="toggle-printer" onclick="Printer.openPrinterModal()" title="Toggle Printer">
<i class="fas fa-print"></i>
</button>
<div class="spacer"></div>
<button onclick="window.open('https://github.com/whscullin/apple2js#readme', 'blank')" title="About">
<i class="fas fa-info"></i>
</button>
<button onclick="Apple2.openOptions()" title="Options (F4)">
<button onclick="OptionsModal.openModal()" title="Options (F4)">
<i class="fas fa-cog"></i>
</button>
</div>
@ -170,7 +196,7 @@
</div>
</main>
<footer class="modal__footer">
<button class="modal__btn" onclick="Apple2.doSave()" aria-label="Save disk locally">Save</button>
<button class="modal__btn" onclick="Disk2UI.doSave()" aria-label="Save disk locally">Save</button>
</footer>
</div>
</div>
@ -232,13 +258,13 @@
<tr>
<td>
<select id="category_select" multiple="multiple"
onchange="Apple2.selectCategory(event)" >
onchange="Disk2UI.selectCategory(event)" >
</select>
</td>
<td>
<select id="disk_select" multiple="multiple"
onchange="Apple2.selectDisk(event)"
ondblclick="Apple2.clickDisk(event)">
onchange="Disk2UI.selectDisk(event)"
ondblclick="Disk2UI.clickDisk(event)">
</select>
</td>
</tr>
@ -253,7 +279,30 @@
</main>
<footer class="modal__footer">
<button class="modal__btn" data-micromodal-close aria-label="Close this dialog window">Cancel</button>
<button class="modal__btn" onclick="Apple2.doLoad(event)" aria-label="Open the selected disk">Open</button>
<button class="modal__btn" onclick="Disk2UI.doLoad(event)" aria-label="Open the selected disk">Open</button>
</footer>
</div>
</div>
</div>
<div class="modal" id="mass-storage-modal" aria-hidden="true">
<div class="modal__overlay" tabindex="-1" data-micromodal-close>
<div class="modal__container" role="dialog" aria-modal="true" aria-labelledby="Load Disk">
<header class="modal__header">
<span class="modal__title" id="mass-storage-modal-title">
Load Disk
</span>
<button class="modal__close" aria-label="Close modal" data-micromodal-close>
</button>
</header>
<main class="modal__content" id="mass-storage-modal-content">
<form action="#">
<input type="file" id="mass-storage-file" />
</form>
</main>
<footer class="modal__footer">
<button class="modal__btn" data-micromodal-close aria-label="Close this dialog window">Cancel</button>
<button class="modal__btn" onclick="BlockStorageUI.doLoad(event)" aria-label="Open the selected disk">Open</button>
</footer>
</div>
</div>
@ -274,7 +323,7 @@
</main>
<footer class="modal__footer">
<a id="raw_printer_output" class="button">Download Raw Output</a>
<button class="modal__btn" onclick="Apple2.clearPrinterPaper()" aria-label="Clear the paper">Clear</button>
<button class="modal__btn" onclick="Printer.clear()" aria-label="Clear the paper">Clear</button>
<button class="modal__btn" data-micromodal-close aria-label="Close this dialog window">Close</button>
</footer>
</div>

View File

@ -107,11 +107,37 @@ body {
border: 1px inset #888;
}
.storage {
display: flex;
flex-direction: row;
margin-right: -5px;
}
.apple2e .storage .inset {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin: 5px 5px 0 0;
}
.disk {
align-items: center;
display: flex;
flex-grow: 1;
max-width: 50%;
max-width: 100%;
width: 50%;
}
.mass .disk {
width: 100%;
}
.storage .inset.block-storage {
display: none;
}
.storage.mass .inset.block-storage {
display: flex;
}
.disk-light {
@ -152,9 +178,10 @@ th {
.inset {
border-radius: 6px;
background-color: #c4c1a0;
border: 3px inset #f0edd0;
padding: 6px;
margin: 10px 0;
margin: 5px 0;
display: flex;
}

View File

@ -4,12 +4,13 @@ import { rom as readOnlyRom } from '../roms/cards/cffa';
import { read2MGHeader } from '../formats/2mg';
import { ProDOSVolume } from '../formats/prodos';
import createBlockDisk from '../formats/block';
import type { DriveNumber } from 'js/formats/types';
import { dump } from '../formats/prodos/utils';
import {
BlockDisk,
BlockFormat,
ENCODING_BLOCK,
MassStorage,
BlockStorage,
} from 'js/formats/types';
const rom = new Uint8Array(readOnlyRom);
@ -85,7 +86,7 @@ export interface CFFAState {
disks: Array<BlockDisk | null>
}
export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
export default class CFFA implements Card, BlockStorage, Restorable<CFFAState> {
// CFFA internal Flags
@ -399,45 +400,47 @@ export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
setState(state: CFFAState) {
state.disks.forEach(
(disk, idx) => {
const drive = idx + 1 as DriveNumber;
if (disk) {
this.setBlockVolume(idx + 1, disk);
this.setBlockVolume(drive, disk);
} else {
this.resetBlockVolume(idx + 1);
this.resetBlockVolume(drive);
}
}
);
}
resetBlockVolume(drive: number) {
drive = drive - 1;
resetBlockVolume(drive: DriveNumber) {
const driveIdx = drive - 1;
this._sectors[drive] = [];
this._sectors[driveIdx] = [];
this._identity[drive][IDENTITY.SectorCountHigh] = 0;
this._identity[drive][IDENTITY.SectorCountLow] = 0;
this._identity[driveIdx][IDENTITY.SectorCountHigh] = 0;
this._identity[driveIdx][IDENTITY.SectorCountLow] = 0;
if (drive) {
if (driveIdx) {
rom[SETTINGS.Max32MBPartitionsDev1] = 0x0;
} else {
rom[SETTINGS.Max32MBPartitionsDev0] = 0x0;
}
}
setBlockVolume(drive: number, disk: BlockDisk) {
drive = drive - 1;
setBlockVolume(drive: DriveNumber, disk: BlockDisk) {
const driveIdx = drive - 1;
// Convert 512 byte blocks into 256 word sectors
this._sectors[drive] = disk.blocks.map(function(block) {
this._sectors[driveIdx] = disk.blocks.map(function(block) {
return new Uint16Array(block.buffer);
});
this._identity[drive][IDENTITY.SectorCountHigh] = this._sectors[0].length & 0xffff;
this._identity[drive][IDENTITY.SectorCountLow] = this._sectors[0].length >> 16;
this._identity[driveIdx][IDENTITY.SectorCountHigh] = this._sectors[0].length & 0xffff;
this._identity[driveIdx][IDENTITY.SectorCountLow] = this._sectors[0].length >> 16;
const prodos = new ProDOSVolume(disk);
dump(prodos);
this._partitions[drive] = prodos;
this._partitions[driveIdx] = prodos;
if (drive) {
rom[SETTINGS.Max32MBPartitionsDev1] = 0x1;
@ -449,7 +452,7 @@ export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
// Assign a raw disk image to a drive. Must be 2mg or raw PO image.
setBinary(drive: number, name: string, ext: BlockFormat, rawData: ArrayBuffer) {
setBinary(drive: DriveNumber, name: string, ext: BlockFormat, rawData: ArrayBuffer) {
const volume = 254;
const readOnly = false;
@ -467,4 +470,11 @@ export default class CFFA implements Card, MassStorage, Restorable<CFFAState> {
return this.setBlockVolume(drive, disk);
}
getMetadata(driveNo: number): Record<string, any> {
const drive = this._partitions[driveNo - 1];
return {
name: drive?.vdh.name,
};
}
}

View File

@ -13,6 +13,7 @@ import {
NibbleFormat,
DISK_PROCESSED,
DRIVE_NUMBERS,
DriveCallbacks,
DriveNumber,
JSONDisk,
ENCODING_NIBBLE,
@ -151,11 +152,6 @@ const PHASE_DELTA = [
[-2, -1, 0, 1],
[1, -2, -1, 0]
] as const;
export interface Callbacks {
driveLight: (drive: DriveNumber, on: boolean) => void;
dirty: (drive: DriveNumber, dirty: boolean) => void;
label: (drive: DriveNumber, name?: string, side?: string) => void;
}
/** Common information for Nibble and WOZ disks. */
@ -383,7 +379,7 @@ export default class DiskII implements Card {
private worker: Worker;
/** Builds a new Disk ][ card. */
constructor(private io: Apple2IO, private callbacks: Callbacks, private sectors = 16) {
constructor(private io: Apple2IO, private callbacks: DriveCallbacks, private sectors = 16) {
this.debug('Disk ][');
this.lastCycles = this.io.cycles();
@ -755,11 +751,12 @@ export default class DiskII implements Card {
this.cur = this.drives[this.drive - 1];
}
getMetadata(driveNo: DriveNumber) {
getMetadata(driveNo: DriveNumber): Record<string, any> {
const drive = this.drives[driveNo - 1];
return {
format: drive.format,
volume: drive.volume,
name: drive.name,
track: drive.track,
head: drive.head,
phase: drive.phase,

View File

@ -1,7 +1,7 @@
import { debug, toHex } from '../util';
import { rom as smartPortRom } from '../roms/cards/smartport';
import { Card, Restorable, byte, word, rom } from '../types';
import { MassStorage, BlockDisk, ENCODING_BLOCK } from '../formats/types';
import { BlockStorage, BlockDisk, ENCODING_BLOCK, DriveCallbacks, DriveNumber } from '../formats/types';
import CPU6502, { CpuState, flags } from '../cpu6502';
import { read2MGHeader } from '../formats/2mg';
import createBlockDisk from '../formats/block';
@ -15,7 +15,8 @@ export interface SmartPortState {
}
export interface SmartPortOptions {
block: boolean;
block: boolean
callbacks: DriveCallbacks
}
class Address {
@ -118,10 +119,12 @@ const DEVICE_TYPE_SCSI_HD = 0x07;
// $0D: Printer
// $0E: Clock
// $0F: Modem
export default class SmartPort implements Card, MassStorage, Restorable<SmartPortState> {
export default class SmartPort implements Card, BlockStorage, Restorable<SmartPortState> {
private rom: rom;
private disks: BlockDisk[] = [];
private lightTimers: number[] = [];
private callbacks;
constructor(private cpu: CPU6502, options: SmartPortOptions) {
if (options?.block) {
@ -133,6 +136,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
debug('SmartPort card');
this.rom = smartPortRom;
}
this.callbacks = options.callbacks;
}
private debug(..._args: any[]) {
@ -143,7 +147,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* dumpBlock
*/
dumpBlock(drive: number, block: number) {
dumpBlock(drive: DriveNumber, block: number) {
let result = '';
let b;
let jdx;
@ -178,7 +182,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* getDeviceInfo
*/
getDeviceInfo(state: CpuState, drive: number) {
getDeviceInfo(state: CpuState, drive: DriveNumber) {
if (this.disks[drive]) {
const blocks = this.disks[drive].blocks.length;
state.x = blocks & 0xff;
@ -196,7 +200,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* readBlock
*/
readBlock(state: CpuState, drive: number, block: number, buffer: Address) {
readBlock(state: CpuState, drive: DriveNumber, block: number, buffer: Address) {
this.debug('read drive=' + drive);
this.debug('read buffer=' + buffer);
this.debug('read block=$' + toHex(block));
@ -210,6 +214,13 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
// debug('read', '\n' + dumpBlock(drive, block));
this.callbacks.driveLight(drive, true);
if (this.lightTimers[drive]) {
window.clearTimeout(this.lightTimers[drive]);
}
this.lightTimers[drive] = window.setTimeout(() => {
this.callbacks.driveLight(drive, false);
}, 100);
for (let idx = 0; idx < 512; idx++) {
buffer.writeByte(this.disks[drive].blocks[block][idx]);
buffer = buffer.inc(1);
@ -223,7 +234,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* writeBlock
*/
writeBlock(state: CpuState, drive: number, block: number, buffer: Address) {
writeBlock(state: CpuState, drive: DriveNumber, block: number, buffer: Address) {
this.debug('write drive=' + drive);
this.debug('write buffer=' + buffer);
this.debug('write block=$' + toHex(block));
@ -256,7 +267,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
* formatDevice
*/
formatDevice(state: CpuState, drive: number) {
formatDevice(state: CpuState, drive: DriveNumber) {
if (!this.disks[drive]?.blocks.length) {
debug('Drive', drive, 'is empty');
state.a = DEVICE_OFFLINE;
@ -323,10 +334,10 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
if (off === blockOff && this.cpu.getSync()) { // Regular block device entry POINT
this.debug('block device entry');
cmd = this.cpu.read(0x00, COMMAND);
unit = this.cpu.read(0x00, UNIT);
unit = this.cpu.read(0x00, UNIT) as DriveNumber;
const bufferAddr = new Address(this.cpu, ADDRESS_LO);
const blockAddr = new Address(this.cpu, BLOCK_LO);
const drive = (unit & 0x80) ? 2 : 1;
const drive = ((unit & 0x80) ? 2 : 1) as DriveNumber;
const driveSlot = (unit & 0x70) >> 4;
buffer = bufferAddr.readAddress();
@ -374,7 +385,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
stackAddr.writeAddress(retVal.inc(3));
const parameterCount = cmdListAddr.readByte();
unit = cmdListAddr.inc(1).readByte();
unit = cmdListAddr.inc(1).readByte() as DriveNumber | 0;
buffer = cmdListAddr.inc(2).readAddress();
let status;
@ -444,16 +455,16 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
case 0x01: // READ BLOCK
block = cmdListAddr.inc(4).readWord();
this.readBlock(state, unit, block, buffer);
this.readBlock(state, unit as DriveNumber, block, buffer);
break;
case 0x02: // WRITE BLOCK
block = cmdListAddr.inc(4).readWord();
this.writeBlock(state, unit, block, buffer);
this.writeBlock(state, unit as DriveNumber, block, buffer);
break;
case 0x03: // FORMAT
this.formatDevice(state, unit);
this.formatDevice(state, unit as DriveNumber);
break;
case 0x04: // CONTROL
@ -518,7 +529,7 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
);
}
setBinary(drive: number, name: string, fmt: string, rawData: ArrayBuffer) {
setBinary(drive: DriveNumber, name: string, fmt: string, rawData: ArrayBuffer) {
const volume = 254;
const readOnly = false;
if (fmt == '2mg') {
@ -532,11 +543,21 @@ export default class SmartPort implements Card, MassStorage, Restorable<SmartPor
volume,
};
this.disks[drive] = createBlockDisk(options);
const disk = createBlockDisk(options);
this.callbacks.label(drive, disk.name);
this.disks[drive] = disk;
const prodos = new ProDOSVolume(this.disks[drive]);
dump(prodos);
return true;
}
getMetadata(driveNo: DriveNumber): Record<string, any> {
const drive = this.disks[driveNo - 1];
return {
name: drive.name,
readOnly: drive.readOnly,
};
}
}

View File

@ -192,6 +192,26 @@ export type FormatWorkerResponse =
/**
* Block device common interface
*/
export interface MassStorage {
setBinary(drive: number, name: string, ext: BlockFormat, data: ArrayBuffer): boolean
export interface BlockStorage {
setBinary(drive: DriveNumber, name: string, ext: BlockFormat, data: ArrayBuffer): boolean
getMetadata(drive: DriveNumber): Record<string, any>
}
export interface FloppyStorage {
setBinary(drive: DriveNumber, name: string, ext: NibbleFormat, data: ArrayBuffer): boolean
getMetadata(drive: DriveNumber): Record<string, any>
}
export interface DiskStorage<T> {
setBinary(drive: DriveNumber, name: string, ext: T, data: ArrayBuffer): boolean
getMetadata(drive: DriveNumber): Record<string, any>
}
/**
*
*/
export interface DriveCallbacks {
driveLight: (drive: DriveNumber, on: boolean) => void;
dirty: (drive: DriveNumber, dirty: boolean) => void;
label: (drive: DriveNumber, name: string, side?: string) => void;
}

View File

@ -1,6 +1,11 @@
import Prefs from './prefs';
import { driveLights, initUI, updateUI } from './ui/apple2';
import {
diskLights,
initUI,
BlockStorageLights,
updateUI
} from './ui/apple2';
import Printer from './ui/printer';
import DiskII from './cards/disk2';
@ -71,9 +76,12 @@ apple2.ready.then(() => {
const parallel = new Parallel(printer);
const videoTerm = new VideoTerm();
const slinky = new RAMFactor(1024 * 1024);
const disk2 = new DiskII(io, driveLights, sectors);
const disk2 = new DiskII(io, diskLights, sectors);
const clock = new Thunderclock();
const smartport = new SmartPort(cpu, { block: true });
const smartport = new SmartPort(cpu, {
block: true,
callbacks: BlockStorageLights,
});
io.setSlot(0, lc);
io.setSlot(1, parallel);

View File

@ -1,6 +1,6 @@
import Prefs from './prefs';
import { driveLights, initUI, updateUI } from './ui/apple2';
import { diskLights, BlockStorageLights, initUI, updateUI } from './ui/apple2';
import Printer from './ui/printer';
import { MouseUI } from './ui/mouse';
@ -60,10 +60,13 @@ apple2.ready.then(() => {
const parallel = new Parallel(printer);
const slinky = new RAMFactor(1024 * 1024);
const disk2 = new DiskII(io, driveLights);
const disk2 = new DiskII(io, diskLights);
const clock = new Thunderclock();
const smartport = new SmartPort(cpu, { block: !enhanced });
const mouse = new Mouse(cpu, mouseUI);
const smartport = new SmartPort(cpu, {
block: !enhanced,
callbacks: BlockStorageLights,
});
io.setSlot(1, parallel);
io.setSlot(2, slinky);

7
js/ui/alert.ts Normal file
View File

@ -0,0 +1,7 @@
import MicroModal from 'micromodal';
export function openAlert(msg: string) {
const el = document.querySelector<HTMLDivElement>('#alert-modal .message')!;
el.innerText = msg;
MicroModal.show('alert-modal');
}

View File

@ -3,18 +3,15 @@ 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 } from '../types';
import { BLOCK_FORMATS, NIBBLE_FORMATS, BlockStorage } from '../formats/types';
import {
DISK_FORMATS,
DriveNumber,
DRIVE_NUMBERS,
JSONDisk
} from '../formats/types';
import { initGamepad } from './gamepad';
import KeyBoard from './keyboard';
import Tape, { TAPE_TYPES } from './tape';
import type { GamepadConfiguration } from './types';
import Tape from './tape';
import { gup, hup } from './url';
import ApplesoftDump from '../applesoft/decompiler';
import ApplesoftCompiler from '../applesoft/compiler';
@ -32,6 +29,9 @@ import { Screen, SCREEN_FULL_PAGE } from './screen';
import { JoyStick } from './joystick';
import { System } from './system';
import { Disk2UI } from './disk2';
import { BlockStorageUI } from './block_storage';
let paused = false;
let startTime = Date.now();
@ -43,54 +43,22 @@ let hashtag = document.location.hash;
const optionsModal = new OptionsModal();
interface DiskDescriptor {
name: string;
disk?: number;
filename: string;
e?: boolean;
category: string;
}
type DiskCollection = {
[name: string]: DiskDescriptor[]
};
const CIDERPRESS_EXTENSION = /#([0-9a-f]{2})([0-9a-f]{4})$/i;
const BIN_TYPES = ['bin'];
const KNOWN_FILE_TYPES = [
...DISK_FORMATS,
...TAPE_TYPES,
...BIN_TYPES,
] as readonly string[];
const disk_categories: DiskCollection = { 'Local Saves': [] };
const disk_sets: DiskCollection = {};
// Disk names
const disk_cur_name: string[] = [];
// Disk categories
const disk_cur_cat: string[] = [];
let _apple2: Apple2;
let cpu: CPU6502;
let stats: Stats;
let vm: VideoModes;
let tape: Tape;
let _disk2: DiskII;
let _massStorage: MassStorage;
let _printer: Printer;
let audio: Audio;
let screen: Screen;
let joystick: JoyStick;
let system: System;
let keyboard: KeyBoard;
let io: Apple2IO;
let _currentDrive: DriveNumber = 1;
let _e: boolean;
let ready: Promise<[void, void]>;
export const driveLights = new DriveLights();
export const diskLights = new DriveLights('disk');
export const BlockStorageLights = new DriveLights('mass-storage');
export function dumpAppleSoftProgram() {
const dumper = new ApplesoftDump(cpu);
@ -103,401 +71,9 @@ export function compileAppleSoftProgram(program: string) {
dumpAppleSoftProgram();
}
export function openLoad(driveString: string, event: MouseEvent) {
const drive = parseInt(driveString, 10) as DriveNumber;
_currentDrive = drive;
if (event.metaKey && includes(DRIVE_NUMBERS, drive)) {
openLoadHTTP();
} else {
if (disk_cur_cat[drive]) {
const element = document.querySelector<HTMLSelectElement>('#category_select')!;
element.value = disk_cur_cat[drive];
selectCategory();
}
MicroModal.show('load-modal');
}
}
export function openSave(driveString: string, event: MouseEvent) {
const drive = parseInt(driveString, 10) as DriveNumber;
const mimeType = 'application/octet-stream';
const data = _disk2.getBinary(drive);
const a = document.querySelector<HTMLAnchorElement>('#local_save_link')!;
if (!data) {
alert('No data from drive ' + drive);
return;
}
const blob = new Blob([data], { 'type': mimeType });
a.href = window.URL.createObjectURL(blob);
a.download = driveLights.label(drive) + '.dsk';
if (event.metaKey) {
dumpDisk(drive);
} else {
const saveName = document.querySelector<HTMLInputElement>('#save_name')!;
saveName.value = driveLights.label(drive);
MicroModal.show('save-modal');
}
}
export function openAlert(msg: string) {
const el = document.querySelector<HTMLDivElement>('#alert-modal .message')!;
el.innerText = msg;
MicroModal.show('alert-modal');
}
/********************************************************************
*
* Drag and Drop
*/
export function handleDragOver(_drive: number, event: DragEvent) {
event.preventDefault();
event.dataTransfer!.dropEffect = 'copy';
}
export function handleDragEnd(_drive: number, event: DragEvent) {
const dt = event.dataTransfer!;
if (dt.items) {
for (let i = 0; i < dt.items.length; i++) {
dt.items.remove(i);
}
} else {
dt.clearData();
}
}
export function handleDrop(drive: number, event: DragEvent) {
event.preventDefault();
event.stopPropagation();
if (drive < 1) {
if (!_disk2.getMetadata(1)) {
drive = 1;
} else if (!_disk2.getMetadata(2)) {
drive = 2;
} else {
drive = 1;
}
}
const dt = event.dataTransfer!;
if (dt.files.length == 1) {
const runOnLoad = event.shiftKey;
doLoadLocal(drive as DriveNumber, dt.files[0], { runOnLoad });
} else if (dt.files.length == 2) {
doLoadLocal(1, dt.files[0]);
doLoadLocal(2, dt.files[1]);
} else {
for (let idx = 0; idx < dt.items.length; idx++) {
if (dt.items[idx].type === 'text/uri-list') {
dt.items[idx].getAsString(function (url) {
const parts = hup().split('|');
parts[drive - 1] = url;
document.location.hash = parts.join('|');
});
}
}
}
}
function loadingStart() {
const meter = document.querySelector<HTMLDivElement>('#loading-modal .meter')!;
meter.style.display = 'none';
MicroModal.show('loading-modal');
}
function loadingProgress(current: number, total: number) {
if (total) {
const meter = document.querySelector<HTMLDivElement>('#loading-modal .meter')!;
const progress = document.querySelector<HTMLDivElement>('#loading-modal .progress')!;
meter.style.display = 'block';
progress.style.width = current / total * meter.clientWidth + 'px';
}
}
function loadingStop() {
MicroModal.close('loading-modal');
if (!paused) {
ready.then(() => {
_apple2.run();
}).catch(console.error);
}
}
interface JSONBinaryImage {
type: 'binary',
start: word,
length: word,
data: byte[],
gamepad?: GamepadConfiguration,
}
export function loadAjax(drive: DriveNumber, url: string) {
loadingStart();
fetch(url).then(function (response: Response) {
if (response.ok) {
return response.json();
} else {
throw new Error('Error loading: ' + response.statusText);
}
}).then(function (data: JSONDisk | JSONBinaryImage) {
if (data.type === 'binary') {
loadBinary(data as JSONBinaryImage);
} else if (includes(DISK_FORMATS, data.type)) {
loadDisk(drive, data);
}
initGamepad(data.gamepad);
loadingStop();
}).catch(function (error) {
loadingStop();
openAlert(error.message);
console.error(error);
});
}
export function doLoad(event: MouseEvent|KeyboardEvent) {
MicroModal.close('load-modal');
const select = document.querySelector<HTMLSelectElement>('#disk_select')!;
const urls = select.value;
let url;
if (urls && urls.length) {
if (typeof (urls) == 'string') {
url = urls;
} else {
url = urls[0];
}
}
const localFile = document.querySelector<HTMLInputElement>('#local_file')!;
const files = localFile.files;
if (files && files.length == 1) {
const runOnLoad = event.shiftKey;
doLoadLocal(_currentDrive, files[0], { runOnLoad });
} else if (url) {
let filename;
MicroModal.close('load-modal');
if (url.substr(0, 6) == 'local:') {
filename = url.substr(6);
if (filename == '__manage') {
openManage();
} else {
loadLocalStorage(_currentDrive, filename);
}
} else {
const r1 = /json\/disks\/(.*).json$/.exec(url);
if (r1) {
filename = r1[1];
} else {
filename = url;
}
const parts = hup().split('|');
parts[_currentDrive - 1] = filename;
document.location.hash = parts.join('|');
}
}
}
export function doSave() {
const saveName = document.querySelector<HTMLInputElement>('#save_name')!;
const name = saveName.value;
saveLocalStorage(_currentDrive, name);
MicroModal.close('save-modal');
window.setTimeout(() => openAlert('Saved'), 0);
}
export function doDelete(name: string) {
if (window.confirm('Delete ' + name + '?')) {
deleteLocalStorage(name);
}
}
interface LoadOptions {
address?: word,
runOnLoad?: boolean,
}
function doLoadLocal(drive: DriveNumber, file: File, options: Partial<LoadOptions> = {}) {
const parts = file.name.split('.');
const ext = parts[parts.length - 1].toLowerCase();
const matches = file.name.match(CIDERPRESS_EXTENSION);
let type, aux;
if (matches && matches.length === 3) {
[, type, aux] = matches;
}
if (includes(DISK_FORMATS, ext)) {
doLoadLocalDisk(drive, file);
} else if (includes(TAPE_TYPES, ext)) {
tape.doLoadLocalTape(file);
} else if (BIN_TYPES.includes(ext) || type === '06' || options.address) {
const address = aux !== undefined ? parseInt(aux, 16) : undefined;
doLoadBinary(file, { address, ...options });
} else {
const addressInput = document.querySelector<HTMLInputElement>('#local_file_address');
const addressStr = addressInput?.value;
if (addressStr) {
const address = parseInt(addressStr, 16);
if (isNaN(address)) {
openAlert('Invalid address: ' + addressStr);
return;
}
doLoadBinary(file, { address, ...options });
} else {
openAlert('Unknown file type: ' + ext);
}
}
}
function doLoadBinary(file: File, options: LoadOptions) {
loadingStart();
const fileReader = new FileReader();
fileReader.onload = function () {
const result = this.result as ArrayBuffer;
let { address } = options;
address = address ?? 0x2000;
const bytes = new Uint8Array(result);
for (let idx = 0; idx < result.byteLength; idx++) {
cpu.write(address >> 8, address & 0xff, bytes[idx]);
address++;
}
if (options.runOnLoad) {
cpu.reset();
cpu.setPC(address);
}
loadingStop();
};
fileReader.readAsArrayBuffer(file);
}
function doLoadLocalDisk(drive: DriveNumber, file: File) {
loadingStart();
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('.');
// Remove any json file reference
const files = hup().split('|');
files[drive - 1] = '';
document.location.hash = files.join('|');
if (includes(DISK_FORMATS, ext)) {
if (result.byteLength >= 800 * 1024) {
if (
includes(BLOCK_FORMATS, ext) &&
_massStorage.setBinary(drive, name, ext, result)
) {
initGamepad();
} else {
openAlert(`Unable to load ${name}`);
}
} else {
if (
includes(NIBBLE_FORMATS, ext) &&
_disk2.setBinary(drive, name, ext, result)
) {
initGamepad();
} else {
openAlert(`Unable to load ${name}`);
}
}
}
loadingStop();
};
fileReader.readAsArrayBuffer(file);
}
export function doLoadHTTP(drive: DriveNumber, url?: string) {
if (!url) {
MicroModal.close('http-modal');
}
loadingStart();
const input = document.querySelector<HTMLInputElement>('#http_url')!;
url = url || input.value;
if (url) {
fetch(url).then(function (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 {
throw new Error('Error loading: ' + response.statusText);
}
}).then(function (data) {
const urlParts = url!.split('/');
const file = urlParts.pop()!;
const fileParts = file.split('.');
const ext = fileParts.pop()!.toLowerCase();
const name = decodeURIComponent(fileParts.join('.'));
if (includes(DISK_FORMATS, ext)) {
if (data.byteLength >= 800 * 1024) {
if (
includes(BLOCK_FORMATS, ext) &&
_massStorage.setBinary(drive, name, ext, data)
) {
initGamepad();
}
} else {
if (
includes(NIBBLE_FORMATS, ext) &&
_disk2.setBinary(drive, name, ext, data)
) {
initGamepad();
}
}
} else {
throw new Error(`Extension ${ext} not recognized.`);
}
loadingStop();
}).catch(function (error) {
loadingStop();
openAlert(error.message);
console.error(error);
});
}
}
function openLoadHTTP() {
MicroModal.show('http-modal');
}
function openManage() {
MicroModal.show('manage-modal');
}
let showStats = 0;
export function updateKHz() {
function updateKHz() {
const now = Date.now();
const ms = now - startTime;
const cycles = cpu.getCycles();
@ -557,195 +133,10 @@ function updateSoundButton(on: boolean) {
}
}
function dumpDisk(drive: DriveNumber) {
const wind = window.open('', '_blank')!;
wind.document.title = driveLights.label(drive);
wind.document.write('<pre>');
wind.document.write(_disk2.getJSON(drive, true));
wind.document.write('</pre>');
wind.document.close();
}
export function reset() {
_apple2.reset();
}
function loadBinary(bin: JSONBinaryImage) {
const maxLen = Math.min(bin.length, 0x10000 - bin.start);
for (let idx = 0; idx < maxLen; idx++) {
const pos = bin.start + idx;
cpu.write(pos, bin.data[idx]);
}
cpu.reset();
cpu.setPC(bin.start);
}
export function selectCategory() {
const diskSelect = document.querySelector<HTMLSelectElement>('#disk_select')!;
const categorySelect = document.querySelector<HTMLSelectElement>('#category_select')!;
diskSelect.innerHTML = '';
const cat = disk_categories[categorySelect.value];
if (cat) {
for (let idx = 0; idx < cat.length; idx++) {
const file = cat[idx];
let name = file.name;
if (file.disk) {
name += ' - ' + file.disk;
}
const option = document.createElement('option');
option.value = file.filename;
option.innerText = name;
diskSelect.append(option);
if (disk_cur_name[_currentDrive] === name) {
option.selected = true;
}
}
}
}
export function selectDisk() {
const localFile = document.querySelector<HTMLInputElement>('#local_file')!;
localFile.value = '';
}
export function clickDisk(event: MouseEvent|KeyboardEvent) {
doLoad(event);
}
/** Called to load disks from the local catalog. */
function loadDisk(drive: DriveNumber, disk: JSONDisk) {
let name = disk.name;
const category = disk.category!; // all disks in the local catalog have a category
if (disk.disk) {
name += ' - ' + disk.disk;
}
disk_cur_cat[drive] = category;
disk_cur_name[drive] = name;
_disk2.setDisk(drive, disk);
initGamepad(disk.gamepad);
}
/*
* LocalStorage Disk Storage
*/
function updateLocalStorage() {
const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}');
const names = Object.keys(diskIndex);
const cat: DiskDescriptor[] = disk_categories['Local Saves'] = [];
const contentDiv = document.querySelector<HTMLDivElement>('#manage-modal-content')!;
contentDiv.innerHTML = '';
names.forEach(function (name) {
cat.push({
'category': 'Local Saves',
'name': name,
'filename': 'local:' + name
});
contentDiv.innerHTML =
'<span class="local_save">' +
name +
' <a href="#" onclick="Apple2.doDelete(\'' +
name +
'\')">Delete</a><br /></span>';
});
cat.push({
'category': 'Local Saves',
'name': 'Manage Saves...',
'filename': 'local:__manage'
});
}
type LocalDiskIndex = {
[name: string]: string,
}
function saveLocalStorage(drive: DriveNumber, name: string) {
const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex;
const json = _disk2.getJSON(drive);
diskIndex[name] = json;
window.localStorage.diskIndex = JSON.stringify(diskIndex);
driveLights.label(drive, name);
driveLights.dirty(drive, false);
updateLocalStorage();
}
function deleteLocalStorage(name: string) {
const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex;
if (diskIndex[name]) {
delete diskIndex[name];
openAlert('Deleted');
}
window.localStorage.diskIndex = JSON.stringify(diskIndex);
updateLocalStorage();
}
function loadLocalStorage(drive: DriveNumber, name: string) {
const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex;
if (diskIndex[name]) {
_disk2.setJSON(drive, diskIndex[name]);
driveLights.label(drive, name);
driveLights.dirty(drive, false);
}
}
if (window.localStorage !== undefined) {
const nodes = document.querySelectorAll<HTMLElement>('.disksave');
nodes.forEach(function (el) {
el.style.display = 'inline-block';
});
}
const categorySelect = document.querySelector<HTMLSelectElement>('#category_select')!;
declare global {
interface Window {
disk_index: DiskDescriptor[];
}
}
function buildDiskIndex() {
let oldCat = '';
let option;
for (let idx = 0; idx < window.disk_index.length; idx++) {
const file = window.disk_index[idx];
const cat = file.category;
const name = file.name;
const disk = file.disk;
if (file.e && !_e) {
continue;
}
if (cat != oldCat) {
option = document.createElement('option');
option.value = cat;
option.innerText = cat;
categorySelect.append(option);
disk_categories[cat] = [];
oldCat = cat;
}
disk_categories[cat].push(file);
if (disk) {
if (!disk_sets[name]) {
disk_sets[name] = [];
}
disk_sets[name].push(file);
}
}
option = document.createElement('option');
option.innerText = 'Local Saves';
categorySelect.append(option);
updateLocalStorage();
}
/**
* Processes the URL fragment. It is expected to be of the form:
* `disk1|disk2` where each disk is the name of a local image OR
@ -763,12 +154,14 @@ function processHash(hash: string) {
const parts = file.split('.');
const ext = parts[parts.length - 1].toLowerCase();
if (ext == 'json') {
loadAjax(drive, file);
} else {
doLoadHTTP(drive, file);
window.Disk2UI.loadAjax(drive, file);
} else if (includes(NIBBLE_FORMATS, ext)) {
window.Disk2UI.doLoadHTTP(drive, file);
} else if (includes(BLOCK_FORMATS, ext)) {
window.BlockStorageUI.doLoadHTTP(drive, file);
}
} else if (file) {
loadAjax(drive, 'json/disks/' + file + '.json');
window.Disk2UI.loadAjax(drive, 'json/disks/' + file + '.json');
}
}
}
@ -799,25 +192,6 @@ export function pauseRun() {
paused = !paused;
}
export function openOptions() {
optionsModal.openModal();
}
export function openPrinterModal() {
const mimeType = 'application/octet-stream';
const data = _printer.getRawOutput();
const a = document.querySelector<HTMLAnchorElement>('#raw_printer_output')!;
const blob = new Blob([data], { 'type': mimeType });
a.href = window.URL.createObjectURL(blob);
a.download = 'raw_printer_output.bin';
MicroModal.show('printer-modal');
}
export function clearPrinterPaper() {
_printer.clear();
}
declare global {
interface Window {
clipboardData?: DataTransfer;
@ -830,40 +204,22 @@ declare global {
}
}
/**
* Returns the value of a query parameter or the empty string if it does not
* exist.
* @param name the parameter name. Note that `name` must not have any RegExp
* meta-characters except '[' and ']' or it will fail.
*/
function gup(name: string) {
const params = new URLSearchParams(window.location.search);
return params.get(name);
declare global {
interface Window {
Disk2UI: Disk2UI
OptionsModal: OptionsModal
BlockStorageUI: BlockStorageUI
Printer: Printer
}
}
/** Returns the URL fragment. */
function hup() {
const regex = new RegExp('#(.*)');
const hash = decodeURIComponent(window.location.hash);
const results = regex.exec(hash);
if (!results)
return '';
else
return results[1];
}
function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, printer: Printer, e: boolean) {
function onLoaded(apple2: Apple2, disk2: DiskII, BlockStorage: BlockStorage, printer: Printer, e: boolean) {
_apple2 = apple2;
cpu = _apple2.getCPU();
io = _apple2.getIO();
stats = apple2.getStats();
vm = apple2.getVideoModes();
tape = new Tape(io);
_disk2 = disk2;
_massStorage = massStorage;
_printer = printer;
_e = e;
system = new System(io, e);
optionsModal.addOptions(system);
@ -878,7 +234,15 @@ function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, print
optionsModal.addOptions(audio);
initSoundToggle();
ready = Promise.all([audio.ready, apple2.ready]);
window.Disk2UI = new Disk2UI(cpu, disk2, tape, e);
window.BlockStorageUI = new BlockStorageUI(BlockStorage);
window.OptionsModal = optionsModal;
window.Printer = printer;
ready = Promise.all([
audio.ready,
apple2.ready
]);
MicroModal.init();
@ -906,8 +270,6 @@ function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, print
}
});
buildDiskIndex();
/*
* Input Handling
*/
@ -935,33 +297,15 @@ function onLoaded(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, print
const hash = gup('disk') || hup();
if (hash) {
_apple2.stop();
processHash(hash);
} else {
ready.then(() => {
_apple2.run();
}).catch(console.error);
}
document.querySelector<HTMLInputElement>('#local_file')?.addEventListener(
'change',
(event: Event) => {
const target = event.target as HTMLInputElement;
const address = document.querySelector<HTMLInputElement>('#local_file_address_input')!;
const parts = target.value.split('.');
const ext = parts[parts.length - 1];
if (KNOWN_FILE_TYPES.includes(ext)) {
address.style.display = 'none';
} else {
address.style.display = 'inline-block';
}
}
);
ready.then(() => {
_apple2.run();
}).catch(console.error);
}
export function initUI(apple2: Apple2, disk2: DiskII, massStorage: MassStorage, printer: Printer, e: boolean) {
export function initUI(apple2: Apple2, disk2: DiskII, BlockStorage: BlockStorage, printer: Printer, e: boolean) {
window.addEventListener('load', () => {
onLoaded(apple2, disk2, massStorage, printer, e);
onLoaded(apple2, disk2, BlockStorage, printer, e);
});
}

47
js/ui/block_storage.ts Normal file
View File

@ -0,0 +1,47 @@
import MicroModal from 'micromodal';
import { includes } from '../types';
import {
BlockFormat,
BLOCK_FORMATS,
DriveNumber,
DiskStorage,
} from '../formats/types';
import { openAlert } from './alert';
import { StorageUI } from './storage';
export class BlockStorageUI extends StorageUI {
constructor(BlockStorage: DiskStorage<BlockFormat>) {
super(BlockStorage, BLOCK_FORMATS);
}
doLoadLocal(drive: DriveNumber, file: File) {
const parts = file.name.split('.');
const ext = parts[parts.length - 1].toLowerCase();
if (includes(BLOCK_FORMATS, ext)) {
this.doLoadLocalDisk(drive, file);
} else {
openAlert('Unknown file type: ' + ext);
}
}
//
// Mass Storage Load Dialog Methods
//
openLoad(driveString: string) {
const drive = parseInt(driveString, 10) as DriveNumber;
this.currentDrive = drive;
MicroModal.show('mass-storage-modal');
}
doLoad() {
MicroModal.close('mass-storage-modal');
const localFile = document.querySelector<HTMLInputElement>('#mass-storage-file')!;
const files = localFile.files;
if (files && files.length == 1) {
this.doLoadLocal(this.currentDrive, files[0]);
}
}
}

438
js/ui/disk2.ts Normal file
View File

@ -0,0 +1,438 @@
import MicroModal from 'micromodal';
import { includes, word } from '../types';
import {
DISK_FORMATS,
DriveNumber,
DRIVE_NUMBERS,
JSONDisk,
NIBBLE_FORMATS,
} from '../formats/types';
import Tape, { TAPE_TYPES } from './tape';
import DiskII from 'js/cards/disk2';
import CPU6502 from 'js/cpu6502';
import { openAlert } from './alert';
import { initGamepad } from './gamepad';
import {
loadingStart,
loadingStop
} from './loader';
import { StorageUI } from './storage';
import { hup } from './url';
const CIDERPRESS_EXTENSION = /#([0-9a-f]{2})([0-9a-f]{4})$/i;
const BIN_TYPES = ['bin'];
const KNOWN_FILE_TYPES = [
...DISK_FORMATS,
...TAPE_TYPES,
...BIN_TYPES,
] as readonly string[];
export interface LoadOptions {
address?: word,
runOnLoad?: boolean,
}
type LocalDiskIndex = {
[name: string]: string,
}
interface DiskDescriptor {
name: string;
disk?: number;
filename: string;
e?: boolean;
category: string;
}
type DiskCollection = {
[name: string]: DiskDescriptor[]
};
declare global {
interface Window {
disk_index: DiskDescriptor[];
}
}
export class Disk2UI extends StorageUI {
currentDrive: DriveNumber = 1;
disk_sets: DiskCollection = {};
disk_categories: DiskCollection = { 'Local Saves': [] };
// Disk names
disk_cur_name: string[] = [];
// Disk categories
disk_cur_cat: string[] = [];
constructor(
private cpu: CPU6502,
private disk2: DiskII,
private tape: Tape,
private e: boolean,
) {
super(disk2, NIBBLE_FORMATS);
this.buildDiskIndex();
document.querySelector<HTMLInputElement>('#local_file')?.addEventListener(
'change',
(event: Event) => {
const target = event.target as HTMLInputElement;
const address = document.querySelector<HTMLInputElement>('#local_file_address_input')!;
const parts = target.value.split('.');
const ext = parts[parts.length - 1];
if (KNOWN_FILE_TYPES.includes(ext)) {
address.style.display = 'none';
} else {
address.style.display = 'inline-block';
}
}
);
}
doLoadLocal(drive: DriveNumber, file: File, options: LoadOptions = {}) {
const parts = file.name.split('.');
const ext = parts[parts.length - 1].toLowerCase();
const matches = file.name.match(CIDERPRESS_EXTENSION);
let type, aux;
if (matches && matches.length === 3) {
[, type, aux] = matches;
}
if (includes(DISK_FORMATS, ext)) {
this.doLoadLocalDisk(drive, file);
} else if (includes(TAPE_TYPES, ext)) {
this.tape.doLoadLocalTape(file);
} else if (BIN_TYPES.includes(ext) || type === '06' || options.address) {
const address = aux !== undefined ? parseInt(aux, 16) : undefined;
this.doLoadBinary(file, { address, ...options });
} else {
const addressInput = document.querySelector<HTMLInputElement>('#local_file_address');
const addressStr = addressInput?.value;
if (addressStr) {
const address = parseInt(addressStr, 16);
if (isNaN(address)) {
openAlert('Invalid address: ' + addressStr);
return;
}
this.doLoadBinary(file, { address, ...options });
} else {
openAlert('Unknown file type: ' + ext);
}
}
}
doLoadBinary(file: File, options: LoadOptions) {
loadingStart();
const fileReader = new FileReader();
fileReader.onload = () => {
const result = fileReader.result as ArrayBuffer;
let { address } = options;
address = address ?? 0x2000;
const bytes = new Uint8Array(result);
for (let idx = 0; idx < result.byteLength; idx++) {
this.cpu.write(address >> 8, address & 0xff, bytes[idx]);
address++;
}
if (options.runOnLoad) {
this.cpu.reset();
this.cpu.setPC(address);
}
loadingStop();
};
fileReader.readAsArrayBuffer(file);
}
//
// Disk II Save Methods
//
openSave(drive: DriveNumber, event: MouseEvent) {
const mimeType = 'application/octet-stream';
const data = this.disk2.getBinary(drive);
const { name } = this.disk2.getMetadata(drive);
const a = document.querySelector<HTMLAnchorElement>('#local_save_link')!;
if (!data) {
alert('No data from drive ' + drive);
return;
}
const blob = new Blob([data], { 'type': mimeType });
a.href = window.URL.createObjectURL(blob);
a.download = name + '.dsk';
if (event.metaKey) {
this.dumpDisk(drive);
} else {
const saveName = document.querySelector<HTMLInputElement>('#save_name')!;
saveName.value = name;
MicroModal.show('save-modal');
}
}
private dumpDisk(drive: DriveNumber) {
const { name } = this.disk2.getMetadata(drive);
const wind = window.open('', '_blank')!;
wind.document.title = name;
wind.document.write('<pre>');
wind.document.write(this.disk2.getJSON(drive, true));
wind.document.write('</pre>');
wind.document.close();
}
//
// Disk II Load Dialog Methods
//
openLoad(drive: DriveNumber, event: MouseEvent) {
this.currentDrive = drive;
if (event.metaKey && includes(DRIVE_NUMBERS, drive)) {
this.openLoadHTTP();
} else {
if (this.disk_cur_cat[drive]) {
const element = document.querySelector<HTMLSelectElement>('#category_select')!;
element.value = this.disk_cur_cat[drive];
this.selectCategory();
}
MicroModal.show('load-modal');
}
}
selectCategory() {
const diskSelect = document.querySelector<HTMLSelectElement>('#disk_select')!;
const categorySelect = document.querySelector<HTMLSelectElement>('#category_select')!;
diskSelect.innerHTML = '';
const cat = this.disk_categories[categorySelect.value];
if (cat) {
for (let idx = 0; idx < cat.length; idx++) {
const file = cat[idx];
let name = file.name;
if (file.disk) {
name += ' - ' + file.disk;
}
const option = document.createElement('option');
option.value = file.filename;
option.innerText = name;
diskSelect.append(option);
if (this.disk_cur_name[this.currentDrive] === name) {
option.selected = true;
}
}
}
}
/** Called to load disks from the local catalog. */
loadDisk(drive: DriveNumber, disk: JSONDisk) {
let name = disk.name;
const category = disk.category!; // all disks in the local catalog have a category
if (disk.disk) {
name += ' - ' + disk.disk;
}
this.disk_cur_cat[drive] = category;
this.disk_cur_name[drive] = name;
this.disk2.setDisk(drive, disk);
initGamepad(disk.gamepad);
}
clickDisk(event: MouseEvent|KeyboardEvent) {
this.doLoad(event);
}
doLoad(event: MouseEvent|KeyboardEvent) {
MicroModal.close('load-modal');
const select = document.querySelector<HTMLSelectElement>('#disk_select')!;
const urls = select.value;
let url;
if (urls && urls.length) {
if (typeof (urls) == 'string') {
url = urls;
} else {
url = urls[0];
}
}
const localFile = document.querySelector<HTMLInputElement>('#local_file')!;
const files = localFile.files;
if (files && files.length == 1) {
const runOnLoad = event.shiftKey;
this.doLoadLocal(this.currentDrive, files[0], { runOnLoad });
} else if (url) {
let filename;
MicroModal.close('load-modal');
if (url.substr(0, 6) == 'local:') {
filename = url.substr(6);
if (filename == '__manage') {
this.openManage();
} else {
this.loadLocalStorage(this.currentDrive, filename);
}
} else {
const r1 = /json\/disks\/(.*).json$/.exec(url);
if (r1) {
filename = r1[1];
} else {
filename = url;
}
const parts = hup().split('|');
parts[this.currentDrive - 1] = filename;
document.location.hash = parts.join('|');
}
}
}
//
// Local Storage methods
//
doSave() {
const saveName = document.querySelector<HTMLInputElement>('#save_name')!;
const name = saveName.value;
this.saveLocalStorage(this.currentDrive, name);
MicroModal.close('save-modal');
window.setTimeout(() => openAlert('Saved'), 0);
}
doDelete(name: string) {
if (window.confirm('Delete ' + name + '?')) {
this.deleteLocalStorage(name);
}
}
openManage() {
MicroModal.show('manage-modal');
}
private saveLocalStorage(drive: DriveNumber, name: string) {
const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex;
const json = this.disk2.getJSON(drive);
diskIndex[name] = json;
window.localStorage.diskIndex = JSON.stringify(diskIndex);
this.updateLocalStorage();
}
private deleteLocalStorage(name: string) {
const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex;
if (diskIndex[name]) {
delete diskIndex[name];
openAlert('Deleted');
}
window.localStorage.diskIndex = JSON.stringify(diskIndex);
this.updateLocalStorage();
}
private loadLocalStorage(drive: DriveNumber, name: string) {
const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}') as LocalDiskIndex;
if (diskIndex[name]) {
this.disk2.setJSON(drive, diskIndex[name]);
}
}
private updateLocalStorage() {
const diskIndex = JSON.parse(window.localStorage.diskIndex || '{}');
const names = Object.keys(diskIndex);
const cat: DiskDescriptor[] = this.disk_categories['Local Saves'] = [];
const contentDiv = document.querySelector<HTMLDivElement>('#manage-modal-content')!;
contentDiv.innerHTML = '';
names.forEach(function (name) {
cat.push({
'category': 'Local Saves',
'name': name,
'filename': 'local:' + name
});
contentDiv.innerHTML =
'<span class="local_save">' +
name +
' <a href="#" onclick="Apple2.doDelete(\'' +
name +
'\')">Delete</a><br /></span>';
});
cat.push({
'category': 'Local Saves',
'name': 'Manage Saves...',
'filename': 'local:__manage'
});
}
//
// Disk II Modal
//
private buildDiskIndex() {
const categorySelect = document.querySelector<HTMLSelectElement>('#category_select')!;
let oldCat = '';
let option;
for (let idx = 0; idx < window.disk_index.length; idx++) {
const file = window.disk_index[idx];
const cat = file.category;
const name = file.name;
const disk = file.disk;
if (file.e && !this.e) {
continue;
}
if (cat != oldCat) {
option = document.createElement('option');
option.value = cat;
option.innerText = cat;
categorySelect.append(option);
this.disk_categories[cat] = [];
oldCat = cat;
}
this.disk_categories[cat].push(file);
if (disk) {
if (!this.disk_sets[name]) {
this.disk_sets[name] = [];
}
this.disk_sets[name].push(file);
}
}
option = document.createElement('option');
option.innerText = 'Local Saves';
categorySelect.append(option);
this.updateLocalStorage();
}
//
// JSON loading
//
loadAjax(drive: DriveNumber, url: string) {
loadingStart();
fetch(url).then(function (response: Response) {
loadingStop();
if (response.ok && response.status == 200) {
return response.json();
} else {
openAlert('Error loading: ' + response.statusText);
}
}).then((data: JSONDisk | undefined) => {
if (data) {
if (includes(DISK_FORMATS, data.type)) {
this.loadDisk(drive, data);
}
initGamepad(data.gamepad);
}
}).catch(function (error) {
loadingStop();
openAlert(error.message);
});
}
selectDisk() {
const localFile = document.querySelector<HTMLInputElement>('#local_file')!;
localFile.value = '';
}
}

View File

@ -1,13 +1,16 @@
import { Callbacks } from '../cards/disk2';
import type { DriveNumber } from '../formats/types';
import type { DriveCallbacks, DriveNumber } from '../formats/types';
export default class DriveLights implements DriveCallbacks {
constructor(private prefix: string) {}
export default class DriveLights implements Callbacks {
public driveLight(drive: DriveNumber, on: boolean) {
const disk =
document.querySelector('#disk' + drive)! as HTMLElement;
disk.style.backgroundImage =
on ? 'url(css/red-on-16.png)' :
'url(css/red-off-16.png)';
document.querySelector<HTMLElement>(`#${this.prefix}${drive}`);
if (disk) {
disk.style.backgroundImage =
on ? 'url(css/red-on-16.png)' :
'url(css/red-off-16.png)';
}
}
public dirty(_drive: DriveNumber, _dirty: boolean) {
@ -16,10 +19,14 @@ export default class DriveLights implements Callbacks {
public label(drive: DriveNumber, label?: string, side?: string) {
const labelElement =
document.querySelector('#disk-label' + drive)! as HTMLElement;
if (label) {
labelElement.innerText = label + (side ? ` - ${side}` : '');
document.querySelector<HTMLElement>(`#${this.prefix}-label${drive}`);
if (labelElement) {
if (label) {
labelElement.innerText = label + (side ? ` - ${side}` : '');
}
return labelElement.innerText;
} else {
return `Disk ${drive}`;
}
return labelElement.innerText;
}
}

20
js/ui/loader.ts Normal file
View File

@ -0,0 +1,20 @@
import MicroModal from 'micromodal';
export function loadingStart() {
const meter = document.querySelector<HTMLDivElement>('#loading-modal .meter')!;
meter.style.display = 'none';
MicroModal.show('loading-modal');
}
export function loadingProgress(current: number, total: number) {
if (total) {
const meter = document.querySelector<HTMLDivElement>('#loading-modal .meter')!;
const progress = document.querySelector<HTMLDivElement>('#loading-modal .progress')!;
meter.style.display = 'block';
progress.style.width = current / total * meter.clientWidth + 'px';
}
}
export function loadingStop() {
MicroModal.close('loading-modal');
}

View File

@ -1,4 +1,5 @@
import { byte } from '../types';
import MicroModal from 'micromodal';
/**
* Printer UI. The "paper" is bound to the element selected by the input.
@ -78,4 +79,15 @@ export default class Printer {
getRawOutput() {
return this._raw.slice(0, this._rawLen);
}
openPrinterModal() {
const mimeType = 'application/octet-stream';
const data = this.getRawOutput();
const a = document.querySelector<HTMLAnchorElement>('#raw_printer_output')!;
const blob = new Blob([data], { 'type': mimeType });
a.href = window.URL.createObjectURL(blob);
a.download = 'raw_printer_output.bin';
MicroModal.show('printer-modal');
}
}

165
js/ui/storage.ts Normal file
View File

@ -0,0 +1,165 @@
import MicroModal from 'micromodal';
import { includes } from '../types';
import {
BlockFormat,
BLOCK_FORMATS,
DriveNumber,
NibbleFormat,
NIBBLE_FORMATS,
DiskStorage
} from '../formats/types';
import { openAlert } from './alert';
import { initGamepad } from './gamepad';
import {
loadingProgress,
loadingStart,
loadingStop
} from './loader';
import { hup } from './url';
import type { LoadOptions } from './disk2';
export class StorageUI {
protected currentDrive: DriveNumber = 1;
constructor(
private storage: DiskStorage<BlockFormat|NibbleFormat>,
private formats: typeof NIBBLE_FORMATS | typeof BLOCK_FORMATS) {
}
handleDragOver(event: DragEvent) {
event.preventDefault();
event.dataTransfer!.dropEffect = 'copy';
}
handleDragEnd(event: DragEvent) {
const dt = event.dataTransfer!;
if (dt.items) {
for (let i = 0; i < dt.items.length; i++) {
dt.items.remove(i);
}
} else {
dt.clearData();
}
}
handleDrop(drive: DriveNumber, event: DragEvent) {
event.preventDefault();
event.stopPropagation();
if (drive < 1) {
if (!this.storage.getMetadata(1)) {
drive = 1;
} else if (!this.storage.getMetadata(2)) {
drive = 2;
} else {
drive = 1;
}
}
const dt = event.dataTransfer!;
if (dt.files.length == 1) {
const runOnLoad = event.shiftKey;
this.doLoadLocal(drive, dt.files[0], { runOnLoad });
} else if (dt.files.length == 2) {
this.doLoadLocal(1, dt.files[0]);
this.doLoadLocal(2, dt.files[1]);
}
}
doLoadLocalDisk(drive: DriveNumber, file: File) {
loadingStart();
const fileReader = new FileReader();
fileReader.onload = () => {
const result = fileReader.result as ArrayBuffer;
const parts = file.name.split('.');
const ext = parts.pop()!.toLowerCase();
const name = parts.join('.');
// Remove any json file reference
const files = hup().split('|');
files[drive - 1] = '';
document.location.hash = files.join('|');
if (includes(this.formats, ext)) {
if (
this.storage.setBinary(drive, name, ext, result)
) {
initGamepad();
} else {
openAlert(`Unable to load ${name}`);
}
}
loadingStop();
};
fileReader.readAsArrayBuffer(file);
}
doLoadHTTP(drive: DriveNumber, url?: string) {
if (!url) {
MicroModal.close('http-modal');
}
loadingStart();
const input = document.querySelector<HTMLInputElement>('#http_url')!;
url = url || input.value;
if (url) {
fetch(url).then((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 {
openAlert('Error loading: ' + response.statusText);
}
}).then((data) => {
const urlParts = url!.split('/');
const file = urlParts.pop()!;
const fileParts = file.split('.');
const ext = fileParts.pop()!.toLowerCase();
const name = decodeURIComponent(fileParts.join('.'));
if (includes(this.formats, ext)) {
if (data && this.storage.setBinary(drive, name, ext, data)) {
initGamepad();
} else {
openAlert(`Unable to load ${name}`);
}
} else {
openAlert('Unknown extension: ' + ext);
}
loadingStop();
}).catch(function (error) {
loadingStop();
openAlert(error.message);
});
}
}
protected doLoadLocal(_drive: DriveNumber, _file: File, _options?: LoadOptions) {
throw new Error('Unimplemented');
}
openLoadHTTP() {
MicroModal.show('http-modal');
}
}

25
js/ui/url.ts Normal file
View File

@ -0,0 +1,25 @@
/**
* Returns the value of a query parameter or the empty string if it does not
* exist.
* @param name the parameter name.
*/
export function gup(name: string) {
const params = new URLSearchParams(window.location.search);
return params.get(name) || '';
}
/**
* Returns the URL hash fragment minus the hash symbol or the empty
* string if it does not exist.
*/
export function hup() {
const regex = new RegExp('#(.*)');
const hash = decodeURIComponent(window.location.hash);
const results = regex.exec(hash);
if (!results)
return '';
else
return results[1];
}

View File

@ -71,14 +71,6 @@ describe('toBinary', () => {
});
});
describe('gup', () => {
// untestable due to direct reference to window.location
});
describe('hup', () => {
// untestable due to direct reference to window.location
});
describe('numToString', () => {
it('packs a zero byte into a string of all zeros', () => {
expect(numToString(0x00)).toEqual('\0\0\0\0');