jsbeeb 1.22.2 → 1.22.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  [![jsbeeb tests](https://github.com/mattgodbolt/jsbeeb/actions/workflows/test-and-deploy.yml/badge.svg)](https://github.com/mattgodbolt/jsbeeb/actions/workflows/test-and-deploy.yml)
2
- [![coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmattgodbolt%2Fjsbeeb%2Fbadges%2Fcoverage.json)](https://github.com/mattgodbolt/jsbeeb/actions/workflows/test-and-deploy.yml)
2
+ [![coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fbbc.xania.org%2Fcoverage%2Fbadge.json)](https://bbc.xania.org/coverage/index.html)
3
3
 
4
4
  # jsbeeb - JavaScript BBC Micro Emulator
5
5
 
@@ -16,6 +16,7 @@ different peripherals.
16
16
  - [Emulator Shortcuts](#emulator-shortcuts)
17
17
  - [Printer Output](#printer-output)
18
18
  - [Save State and Rewind](#save-state-and-rewind)
19
+ - [Joystick Support](#joystick-support)
19
20
  - [Getting Set Up to Run Locally](#getting-set-up-to-run-locally)
20
21
  - [Running as a Desktop Application](#running-as-a-desktop-application)
21
22
  - [URL Parameters](#url-parameters)
@@ -120,10 +121,10 @@ Save and load full emulator state snapshots from the **State** menu (or `Ctrl+S`
120
121
 
121
122
  The emulator continuously captures snapshots into a 30-slot rewind buffer (~1 per second). Open the rewind scrubber from **State > Rewind** or press **Alt+PageDown** to browse recent states as a visual filmstrip:
122
123
 
123
- - **Left/Right arrows** navigate between snapshots (the main screen updates live)
124
+ - **Left/Right arrows**: navigate between snapshots (the main screen updates live)
124
125
  - **Click** a thumbnail to jump to that point
125
- - **Enter** commit selection and close the panel
126
- - **Escape** cancel and restore the original state
126
+ - **Enter**: commit selection and close the panel
127
+ - **Escape**: cancel and restore the original state
127
128
 
128
129
  ### Joystick Support
129
130
 
@@ -146,13 +147,13 @@ presses `SPACE`. To play Superior's Space Invaders on a pad, where `COPY` fires:
146
147
 
147
148
  The gamepad control names are:
148
149
 
149
- - `FIRE` every button at once, which is usually what you want for a one-button game
150
- - `UP` `DOWN` `LEFT` `RIGHT` both analogue sticks at once, plus one face button each (`UP` is also `A`, `DOWN` is
150
+ - `FIRE`: every button at once, which is usually what you want for a one-button game
151
+ - `UP` `DOWN` `LEFT` `RIGHT`: both analogue sticks at once, plus one face button each (`UP` is also `A`, `DOWN` is
151
152
  `X`, `LEFT` is `Y`, `RIGHT` is `B`)
152
- - `UP1` `DOWN1` `LEFT1` `RIGHT1` the left stick only; `UP2` `DOWN2` the right stick only; `UP3` `DOWN3` … — the
153
- face buttons only
154
- - `A` `B` `X` `Y` `START` `BACK` `LB` `RB` `LT` `RT` individual buttons, by their Xbox 360 names
155
- - `FIRE1` `FIRE2` clicking the left and right sticks
153
+ - `UP1` `DOWN1` `LEFT1` `RIGHT1`: the left stick only; `UP2` `DOWN2` and so on: the right stick only; `UP3` `DOWN3`
154
+ and so on: the face buttons only
155
+ - `A` `B` `X` `Y` `START` `BACK` `LB` `RB` `LT` `RT`: individual buttons, by their Xbox 360 names
156
+ - `FIRE1` `FIRE2`: clicking the left and right sticks
156
157
 
157
158
  The BBC key names are the same as for the keyboard, and digits may be written either way round: `GP.A=1` and `GP.A=K1`
158
159
  both press `1`. Unlike `KEY.`, gamepad mappings are BBC-only: there's no Atom equivalent. The D-pad's default mapping
@@ -191,7 +192,9 @@ site to make it smaller and faster to load when it's deployed to [https://bbc.xa
191
192
 
192
193
  ## Running as a Desktop Application
193
194
 
194
- jsbeeb can also run as a standalone desktop application using Electron:
195
+ jsbeeb can also run as a standalone desktop application using Electron. Prebuilt packages (Debian/Ubuntu `.deb`,
196
+ Fedora/RHEL `.rpm` and a Windows installer) are attached to each
197
+ [GitHub release](https://github.com/mattgodbolt/jsbeeb/releases), or you can build your own:
195
198
 
196
199
  ### Running in Development
197
200
 
@@ -235,17 +238,15 @@ USE_SYSTEM_FPM=true npm run electron:build
235
238
  **Debian/Ubuntu:**
236
239
 
237
240
  ```sh
238
- sudo apt install ./out/dist/jsbeeb_1.0.1_amd64.deb
241
+ sudo apt install ./out/dist/jsbeeb_<version>_amd64.deb
239
242
  ```
240
243
 
241
244
  **Fedora/RHEL/CentOS:**
242
245
 
243
246
  ```sh
244
- sudo rpm -i out/dist/jsbeeb-1.0.1.x86_64.rpm
247
+ sudo rpm -i out/dist/jsbeeb-<version>.x86_64.rpm
245
248
  ```
246
249
 
247
- **Note:** Electron support was re-enabled in November 2024 after being disabled during the ESM migration in 2021. It now works with Electron 28+ which added full ES Modules support.
248
-
249
250
  ## URL Parameters
250
251
 
251
252
  - `autoboot` - fakes a shift break
@@ -335,19 +336,11 @@ Note that every update you make means you need to make a new raw link.
335
336
 
336
337
  If you're looking to help:
337
338
 
338
- - Testing
339
- - Play lots of games and report issues either on [GitHub](https://github.com/mattgodbolt/jsbeeb/issues) or by email (
340
- matt@godbolt.org).
341
- - Core
342
- - Get the "boo" of the boot "boo-beep" working (disabled currently as the JavaScript startup makes the sound
343
- dreadfully choppy on Chrome at least).
344
- - Save disc support
345
- - Local discs need to be made more workable and need an "export" feature
346
- - Multiple discs need a UI
347
- - `git grep -i todo`
348
- - Optimisation
349
- - While every attempt to make things fast has been made, I'm sure there's some more clever things that can be done
350
- without compromising emulation accuracy
339
+ - Play lots of games and report anything that doesn't behave like the real machine, either on
340
+ [GitHub](https://github.com/mattgodbolt/jsbeeb/issues) or by email (matt@godbolt.org).
341
+ - Pick something from the [issue tracker](https://github.com/mattgodbolt/jsbeeb/issues): there's a mix of emulation
342
+ accuracy work, missing hardware and peripherals, user interface improvements and performance ideas in there, at all
343
+ levels of difficulty.
351
344
 
352
345
  ## Tests
353
346
 
@@ -370,12 +363,14 @@ For timing correctness, we have:
370
363
  correctness-sensitive when it comes to the timers and interrupts of the BBC.
371
364
  - Some 65C12-specific read-modify-write tests written by Ed Spittles.
372
365
 
373
- Tests can be run automatically if you have `node` installed - just run `make` and it'll ensure the relevant libraries
374
- are installed, then it'll run the tests. Please note it can take a while to run the whole test suite.
366
+ Tests are run via npm scripts: `npm test` runs the whole suite. The vitest suites are `npm run test:unit`,
367
+ `npm run test:integration` and `npm run test:shader`; CPU tests are `npm run test:cpu`, and the smoke test is
368
+ `npm run test:smoke` (the shader and smoke tests need Chrome installed). Please note it can take a while to run the
369
+ whole test suite.
375
370
 
376
371
  ## Thanks
377
372
 
378
- jsbeeb was heavily based on Sarah Walker's C [B-Em emulator](https://github.com/stardot/b-em) -- thanks to her for her
373
+ jsbeeb was heavily based on Sarah Walker's C [B-Em emulator](https://github.com/stardot/b-em); thanks to her for her
379
374
  hard work and for open sourcing her code. B-em is now being maintained by a group of enthusiasts - thanks to them too!
380
375
 
381
376
  Huge thanks to Richard Talbot-Watkins for his advice and help along the way in fathoming out the instruction timings,
@@ -410,7 +405,8 @@ and posted the [video up on YouTube](https://www.youtube.com/watch?v=37jyHQT7fXQ
410
405
 
411
406
  ## License
412
407
 
413
- This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
408
+ This project is licensed under the GNU General Public License v3.0 (or later); see the [LICENSE](LICENSE) file for
409
+ details.
414
410
 
415
411
  ## Contact
416
412
 
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "name": "jsbeeb",
8
8
  "description": "Emulate a BBC Micro",
9
9
  "repository": "git@github.com:mattgodbolt/jsbeeb.git",
10
- "version": "1.22.2",
10
+ "version": "1.22.3",
11
11
  "//engines": "If you change the version of Node, it must also be updated at the top of the Dockerfile.",
12
12
  "engines": {
13
13
  "node": ">=24.15.0"
@@ -4,7 +4,7 @@
4
4
  // Handles IPC communication for loading disc/tape images and showing modals from Electron's main process.
5
5
 
6
6
  function init(args) {
7
- const { loadDiscImage, loadTapeImage, loadStateFile, processor, modals, actions, config } = args;
7
+ const { loadDiscImage, loadTapeImage, loadStateFile, processor, modals, actions, config, media } = args;
8
8
  const api = window.electronAPI;
9
9
 
10
10
  api.onLoadDisc(async (message) => {
@@ -58,7 +58,9 @@ function init(args) {
58
58
  config.addEventListener("settings-changed", (e) => {
59
59
  api.saveSettings(e.detail);
60
60
  });
61
- config.addEventListener("media-changed", (e) => {
61
+ }
62
+ if (media) {
63
+ media.addEventListener("media-changed", (e) => {
62
64
  api.saveSettings(e.detail);
63
65
  });
64
66
  }
package/src/main.js CHANGED
@@ -22,6 +22,7 @@ import { Display } from "./web/display.js";
22
22
  import { Layout } from "./web/layout.js";
23
23
  import { EmulationLoop, RewindCaptureInterval } from "./web/emulation-loop.js";
24
24
  import { KeyboardSetup } from "./web/keyboard-setup.js";
25
+ import { AccessibilitySwitches } from "./web/accessibility-switches.js";
25
26
  import { AnalogueInputs } from "./web/analogue-inputs.js";
26
27
  import { FrontPanel } from "./web/front-panel.js";
27
28
  import { Machine } from "./web/machine.js";
@@ -92,19 +93,7 @@ const printer = new Printer({
92
93
  }),
93
94
  });
94
95
 
95
- const keys = new KeyboardSetup({
96
- enterDebugger: () => loop.stop(true),
97
- reload: () => window.location.reload(),
98
- toggleFast: () => loop.toggleFastAsPossible(),
99
- openRewind: () => rewindUI.open(),
100
- openPrinter: () => frontPanel.checkPrinterWindow(),
101
- pause: () => loop.stop(false),
102
- resume: () => loop.go(),
103
- onAnyKeyDown: () => {
104
- audioHandler.tryResume();
105
- inputs.ensureMicrophoneRunning();
106
- },
107
- });
96
+ const accessibilitySwitches = new AccessibilitySwitches();
108
97
 
109
98
  const settings = new Settings({ urlState });
110
99
  const { config, speechOutput, keyLayout, displayMode, audioOutput, speakerAmount } = settings;
@@ -188,7 +177,7 @@ const machine = new Machine({
188
177
  cpuMultiplier,
189
178
  extraRoms,
190
179
  stationId,
191
- userPort: keys.userPort,
180
+ userPort: accessibilitySwitches.userPort,
192
181
  printer,
193
182
  speechOutput,
194
183
  video,
@@ -197,6 +186,26 @@ const machine = new Machine({
197
186
  });
198
187
  const processor = machine.processor;
199
188
 
189
+ const keys = new KeyboardSetup({
190
+ actions: {
191
+ enterDebugger: () => loop.stop(true),
192
+ reload: () => window.location.reload(),
193
+ toggleFast: () => loop.toggleFastAsPossible(),
194
+ openRewind: () => rewindUI.open(),
195
+ openPrinter: () => frontPanel.checkPrinterWindow(),
196
+ pause: () => loop.stop(false),
197
+ resume: () => loop.go(),
198
+ onAnyKeyDown: () => {
199
+ audioHandler.tryResume();
200
+ inputs.ensureMicrophoneRunning();
201
+ },
202
+ },
203
+ accessibilitySwitches,
204
+ processor,
205
+ dbgr,
206
+ keyLayout,
207
+ });
208
+
200
209
  const rewindBuffer = new RewindBuffer(30);
201
210
  const loop = new EmulationLoop({
202
211
  processor,
@@ -225,23 +234,15 @@ loop.addEventListener("running", () => {
225
234
 
226
235
  const modals = new Modals({ loop });
227
236
 
228
- const drives = new Drives({ fdc: processor.fdc, driveTracks, areYouSure: modals.areYouSure.bind(modals) });
237
+ const drives = new Drives({ fdc: processor.fdc, driveTracks, confirm: modals.confirm.bind(modals) });
229
238
  const media = new MediaLoader({
230
239
  processor,
231
240
  model,
232
241
  drives,
233
242
  urlState,
234
- config,
235
243
  modals,
236
244
  isSnapshotFile,
237
245
  loadSnapshot: (file, buffer) => snapshots.loadStateFromFile(file, buffer),
238
- // The archives are created further down; each source resolves when used.
239
- sources: {
240
- sth: (name) => sthPicker.discs.fetch(name),
241
- tapeSth: (name) => sthPicker.tapes.fetch(name),
242
- hfe: (path) => hfePicker.archive.fetch(path),
243
- drive: (cat, layout) => drivePicker.load(cat, layout),
244
- },
245
246
  });
246
247
  const autoBoot = new Autoboot({
247
248
  model,
@@ -257,7 +258,7 @@ const sthPicker = new SthPicker({
257
258
  processor,
258
259
  autoboot: (image) => autoBoot.boot(image),
259
260
  });
260
- const hfePicker = new HfePicker({
261
+ new HfePicker({
261
262
  media,
262
263
  drives,
263
264
  modals,
@@ -265,7 +266,7 @@ const hfePicker = new HfePicker({
265
266
  processor,
266
267
  autoboot: (image) => autoBoot.boot(image),
267
268
  });
268
- const drivePicker = new GoogleDrivePicker({ media, drives, modals, processor });
269
+ new GoogleDrivePicker({ media, drives, modals, processor });
269
270
  const snapshots = new SnapshotUI({
270
271
  processor,
271
272
  model,
@@ -295,8 +296,6 @@ if (parsedQuery.microphoneChannel !== undefined) {
295
296
  // Apply ADC source settings from URL parameters
296
297
  inputs.updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
297
298
 
298
- keys.attach({ processor, dbgr, keyLayout });
299
-
300
299
  const frontPanel = new FrontPanel({ processor, model, printer });
301
300
 
302
301
  // ------------------------------------------------------------------------
@@ -315,7 +314,7 @@ rewindUI.updateButtonState();
315
314
  if (processor.fdc) new DiscVisualiser({ fdc: processor.fdc });
316
315
  else document.getElementById("disc-visualiser-open").classList.add("disabled");
317
316
 
318
- new Layout({
317
+ const layout = new Layout({
319
318
  screenCanvas,
320
319
  display,
321
320
  embed: parsedQuery.embed !== undefined,
@@ -323,7 +322,7 @@ new Layout({
323
322
  });
324
323
 
325
324
  // Everything a setting can reach now exists.
326
- settings.wire({ audioHandler, display, quickSettings, machine, keys, inputs, modals });
325
+ settings.wire({ audioHandler, display, layout, quickSettings, machine, keys, inputs, modals });
327
326
 
328
327
  // ------------------------------------------------------------------------
329
328
  // The page's own handlers.
@@ -500,6 +499,7 @@ electron({
500
499
  loadTapeImage: media.loadTapeImage.bind(media),
501
500
  processor,
502
501
  config,
502
+ media,
503
503
  modals: {
504
504
  show: (modalId, sthType) => {
505
505
  if (modalId === "sth" && sthType) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The accessibility switches on the user port. Bits 0-7 correspond to switches
3
+ * 1-8, active low: 0xff = no switches pressed; a clear bit = that switch held.
4
+ *
5
+ * On real hardware, the Brilliant Computing switch interface box and special-ed
6
+ * joystick connect to the User Port only; they do not touch the analogue port
7
+ * or the System VIA fire buttons (PB4/PB5), which belong to the standard
8
+ * analogue joystick connector.
9
+ */
10
+ export class AccessibilitySwitches {
11
+ constructor() {
12
+ this.switchState = 0xff;
13
+ const switches = this;
14
+ this.userPort = {
15
+ write() {},
16
+ read() {
17
+ return switches.switchState;
18
+ },
19
+ };
20
+ }
21
+
22
+ setSwitch(index, held) {
23
+ if (held) this.switchState &= ~(1 << index);
24
+ else this.switchState |= 1 << index;
25
+ }
26
+ }
@@ -90,8 +90,7 @@ export class AnalogueInputs {
90
90
 
91
91
  clearMicrophoneChannel() {
92
92
  this.config.setMicrophoneChannel(undefined);
93
- delete this.urlState.params.microphoneChannel;
94
- this.urlState.updateUrl();
93
+ this.urlState.set({ microphoneChannel: undefined });
95
94
  }
96
95
 
97
96
  async ensureMicrophoneRunning() {
@@ -28,12 +28,7 @@ export class AutobootTicks {
28
28
  for (const check of this.checks) {
29
29
  check.addEventListener("click", () => {
30
30
  this.show(check.checked);
31
- if (check.checked) {
32
- urlState.params.autoboot = "";
33
- } else {
34
- delete urlState.params.autoboot;
35
- }
36
- urlState.updateUrl();
31
+ urlState.set({ autoboot: check.checked ? true : undefined });
37
32
  });
38
33
  }
39
34
  }
package/src/web/debug.js CHANGED
@@ -544,7 +544,7 @@ export class Debugger {
544
544
  case "N":
545
545
  this.updatePrevMem();
546
546
  this.cpu.execute(1);
547
- self.debug(this.cpu.pc);
547
+ this.debug(this.cpu.pc);
548
548
  break;
549
549
  case "m":
550
550
  this.stepOver();
@@ -111,9 +111,6 @@ export class Display {
111
111
  this.sizeCanvasFor(this.filterClass);
112
112
  this.video.paint();
113
113
  this.setCrtPic();
114
- window.setTimeout(() => window.dispatchEvent(new Event("resize")), 1);
115
- // Relayout now as well: the monitor picture may have changed shape.
116
- window.dispatchEvent(new Event("resize"));
117
114
  }
118
115
 
119
116
  sizeCanvasFor(filterClass) {
package/src/web/drives.js CHANGED
@@ -11,7 +11,7 @@ const tracksPerStepFor = (tracks) => (tracks === "40" ? 2 : 1);
11
11
  * switches on the Discs menu, and downloading what is in drive 0.
12
12
  */
13
13
  export class Drives {
14
- constructor({ fdc, driveTracks, areYouSure }) {
14
+ constructor({ fdc, driveTracks, confirm }) {
15
15
  this.fdc = fdc;
16
16
  this.driveTracks = driveTracks;
17
17
  this.saidWritesAreNotKept = false;
@@ -33,7 +33,7 @@ export class Drives {
33
33
  if (drive) this.showDriveTracks(driveIndex);
34
34
  }
35
35
 
36
- document.getElementById("download-drive-link").addEventListener("click", () => {
36
+ document.getElementById("download-drive-link").addEventListener("click", async () => {
37
37
  const disc = this.discToDownload();
38
38
  if (!disc) return;
39
39
  const save = (options) =>
@@ -41,9 +41,8 @@ export class Drives {
41
41
  try {
42
42
  save();
43
43
  } catch (e) {
44
- areYouSure(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel", () =>
45
- save({ force: true }),
46
- );
44
+ if (await confirm(`${e.message} Save anyway, losing what will not fit?`, "Save anyway", "Cancel"))
45
+ save({ force: true });
47
46
  }
48
47
  });
49
48
 
@@ -16,6 +16,7 @@ export class GoogleDrivePicker {
16
16
  this.modals = modals;
17
17
  this.processor = processor;
18
18
  this.googleDrive = loader;
19
+ media.addSource("drive", (cat, layout) => this.load(cat, layout));
19
20
 
20
21
  this.authEl = document.getElementById("google-drive-auth");
21
22
  this.el = document.getElementById("google-drive");
@@ -41,6 +41,7 @@ export class HfePicker {
41
41
  showArchiveMessage("hfe", "hfe-list", "There was an error accessing the HFE archive");
42
42
  },
43
43
  );
44
+ media.addSource("hfe", (path) => this.archive.fetch(path));
44
45
 
45
46
  this.modal = new bootstrap.Modal(document.getElementById("hfe"));
46
47
  document.getElementById("hfe").addEventListener("shown.bs.modal", () => {
@@ -2,36 +2,16 @@ import * as utils from "../utils.js";
2
2
  import { Keyboard } from "../keyboard.js";
3
3
  import { showNotice } from "./reporting.js";
4
4
 
5
- /**
6
- * The page's keyboard: the emulated one, the browser shortcuts around it, and
7
- * the accessibility switches on the user port. Built in two steps because the
8
- * user port has to exist before the processor does, and the keyboard after.
9
- */
5
+ /** The page's keyboard: the emulated one and the browser shortcuts around it. */
10
6
  export class KeyboardSetup {
11
7
  /**
12
- * @param {object} actions what each shortcut does, supplied late-bound:
8
+ * @param {object} opts
9
+ * @param {object} opts.actions what each shortcut does, supplied late-bound:
13
10
  * enterDebugger, reload, toggleFast, openRewind, openPrinter,
14
11
  * pause, resume, onAnyKeyDown
12
+ * @param {import("./accessibility-switches.js").AccessibilitySwitches} opts.accessibilitySwitches
15
13
  */
16
- constructor(actions) {
17
- this.actions = actions;
18
- this.keyboard = null;
19
-
20
- // Accessibility switch state: bits 0-7 correspond to switches 1-8.
21
- // Active low: 0xff = no switches pressed; clearing a bit = that switch is pressed.
22
- this.switchState = 0xff;
23
- const setup = this;
24
- this.userPort = {
25
- write() {},
26
- read() {
27
- return setup.switchState;
28
- },
29
- };
30
- }
31
-
32
- /** Initialise keyboard now that processor exists */
33
- attach({ processor, dbgr, keyLayout }) {
34
- const { actions } = this;
14
+ constructor({ actions, accessibilitySwitches, processor, dbgr, keyLayout }) {
35
15
  const keyboard = (this.keyboard = new Keyboard({
36
16
  processor,
37
17
  inputEnabledFunction: () => document.activeElement && document.activeElement.id === "paste-text",
@@ -66,22 +46,11 @@ export class KeyboardSetup {
66
46
  keyboard.registerKeyHandler(utils.keyCodes.PAGEDOWN, onDown("pagedown", actions.openRewind), alt);
67
47
  keyboard.registerKeyHandler(utils.keyCodes.B, onDown(null, actions.openPrinter), ctrl);
68
48
 
69
- // Register accessibility switch key handlers.
70
- // Keys 1-8 (K1-K8) and function keys F1-F8 both map to user port bits 0-7
71
- // (active low: pressing the key clears the corresponding bit in &FE60).
72
- //
73
- // On real hardware, the Brilliant Computing switch interface box and special-ed
74
- // joystick connect to the User Port only; they do not touch the analogue port
75
- // or the System VIA fire buttons (PB4/PB5), which belong to the standard
76
- // analogue joystick connector. So we only update switchState here.
77
- const handleSwitch = (bit) => (down) => {
78
- if (down) this.switchState &= ~(1 << bit);
79
- else this.switchState |= 1 << bit;
80
- };
81
-
82
- // Alt+1-8 and Alt+F1-F8 trigger the switches. Using Alt means the underlying
83
- // key is never forwarded to the BBC Micro (keyboard.js bails out early when a
84
- // handler fires), so typing numbers or using function keys works normally.
49
+ // Alt+1-8 and Alt+F1-F8 trigger the accessibility switches. Using Alt means
50
+ // the underlying key is never forwarded to the BBC Micro (keyboard.js bails
51
+ // out early when a handler fires), so typing numbers or using function keys
52
+ // works normally.
53
+ const handleSwitch = (index) => (down) => accessibilitySwitches.setSwitch(index, down);
85
54
  for (let i = 0; i < 8; i++) {
86
55
  keyboard.registerKeyHandler(utils.keyCodes.K1 + i, handleSwitch(i), alt);
87
56
  keyboard.registerKeyHandler(utils.keyCodes.F1 + i, handleSwitch(i), alt);
@@ -96,11 +65,7 @@ export class KeyboardSetup {
96
65
  }
97
66
 
98
67
  sendRawKeyboard(keysToSend, checkCapsAndShiftLocks) {
99
- if (this.keyboard) {
100
- this.keyboard.sendRawKeyboard(keysToSend, checkCapsAndShiftLocks);
101
- } else {
102
- console.warn("Tried to send keys before keyboard was initialised");
103
- }
68
+ this.keyboard.sendRawKeyboard(keysToSend, checkCapsAndShiftLocks);
104
69
  }
105
70
 
106
71
  clearKeys() {
@@ -50,22 +50,20 @@ function readFileAsBinaryString(file) {
50
50
  * URL schema can name, the local file inputs, the drop zone and the built-in
51
51
  * list. Choosing what goes in a drive funnels through drives.putDiscIn.
52
52
  */
53
- export class MediaLoader {
53
+ export class MediaLoader extends EventTarget {
54
54
  /**
55
55
  * @param {object} deps
56
- * @param {object} deps.sources fetchers keyed by schema: sth, tapeSth and hfe
57
- * resolve an archive name to image data; drive loads a Google Drive file
58
56
  * @param {Function} deps.isSnapshotFile says whether a dropped file is a save state
59
57
  * @param {Function} deps.loadSnapshot restores a dropped save state
60
58
  */
61
- constructor({ processor, model, drives, urlState, config, modals, sources, isSnapshotFile, loadSnapshot }) {
59
+ constructor({ processor, model, drives, urlState, modals, isSnapshotFile, loadSnapshot }) {
60
+ super();
62
61
  this.processor = processor;
63
62
  this.model = model;
64
63
  this.drives = drives;
65
64
  this.urlState = urlState;
66
- this.config = config;
67
65
  this.modals = modals;
68
- this.sources = sources;
66
+ this.sources = {};
69
67
 
70
68
  document.getElementById("disc_load").addEventListener("change", async (evt) => {
71
69
  if (evt.target.files.length === 0) return;
@@ -105,8 +103,7 @@ export class MediaLoader {
105
103
  tapeName = unzipped.name;
106
104
  }
107
105
  this.setProcessorTape(await loadTapeFromData(tapeName, tapeData, model));
108
- delete this.params.tape;
109
- urlState.updateUrl();
106
+ urlState.set({ tape: undefined });
110
107
  modals.hide("tapes");
111
108
  } catch (error) {
112
109
  reportLoadFailure(file.name, error);
@@ -167,6 +164,11 @@ export class MediaLoader {
167
164
  return this.urlState.params;
168
165
  }
169
166
 
167
+ /** Register the fetcher behind an image schema; each picker calls this as it is constructed. */
168
+ addSource(schema, fetcher) {
169
+ this.sources[schema] = fetcher;
170
+ }
171
+
170
172
  /** Route tape to the correct interface (ACIA for BBC, PPIA for Atom) */
171
173
  setProcessorTape(tape) {
172
174
  if (this.model.isAtom) {
@@ -177,22 +179,18 @@ export class MediaLoader {
177
179
  }
178
180
 
179
181
  setDisc1Image(name) {
180
- delete this.params.disc;
181
- this.params.disc1 = name;
182
- this.urlState.updateUrl();
183
- this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
182
+ this.urlState.set({ disc: undefined, disc1: name });
183
+ this.dispatchEvent(new CustomEvent("media-changed", { detail: { disc1: name } }));
184
184
  }
185
185
 
186
186
  setDisc2Image(name) {
187
- this.params.disc2 = name;
188
- this.urlState.updateUrl();
189
- this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
187
+ this.urlState.set({ disc2: name });
188
+ this.dispatchEvent(new CustomEvent("media-changed", { detail: { disc2: name } }));
190
189
  }
191
190
 
192
191
  setTapeImage(name) {
193
- this.params.tape = name;
194
- this.urlState.updateUrl();
195
- this.config.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
192
+ this.urlState.set({ tape: name });
193
+ this.dispatchEvent(new CustomEvent("media-changed", { detail: { tape: name } }));
196
194
  }
197
195
 
198
196
  async loadHTMLFile(file) {
@@ -207,9 +205,7 @@ export class MediaLoader {
207
205
  // Local file: retain the image bytes for embedding in save-to-file snapshots.
208
206
  loadedDisc.setOriginalImage(imageData);
209
207
  this.drives.putDiscIn(0, loadedDisc);
210
- delete this.params.disc;
211
- delete this.params.disc1;
212
- this.urlState.updateUrl();
208
+ this.urlState.set({ disc: undefined, disc1: undefined });
213
209
  this.modals.hide("discs");
214
210
  }
215
211
 
package/src/web/modals.js CHANGED
@@ -58,26 +58,29 @@ export class Modals {
58
58
  if (message) toast(message);
59
59
  }
60
60
 
61
- areYouSure(message, yesText, noText, yesFunc) {
61
+ /** @returns {Promise<boolean>} true for the yes button; false for any other way out of the dialog */
62
+ confirm(message, yesText, noText) {
62
63
  const yesButton = this.aysEl.querySelector(".ays-yes");
63
64
  this.aysEl.querySelector(".context").textContent = message;
64
65
  this.aysEl.querySelector(".ays-no").textContent = noText;
65
66
  yesButton.textContent = yesText;
66
- let confirmed = false;
67
- const onYes = () => {
68
- confirmed = true;
69
- this.aysModal.hide();
70
- };
71
- yesButton.addEventListener("click", onYes, { once: true });
72
- // The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
73
- this.aysEl.addEventListener(
74
- "hidden.bs.modal",
75
- () => {
76
- yesButton.removeEventListener("click", onYes);
77
- if (confirmed) yesFunc();
78
- },
79
- { once: true },
80
- );
81
- this.aysModal.show();
67
+ return new Promise((resolve) => {
68
+ let confirmed = false;
69
+ const onYes = () => {
70
+ confirmed = true;
71
+ this.aysModal.hide();
72
+ };
73
+ yesButton.addEventListener("click", onYes, { once: true });
74
+ // The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
75
+ this.aysEl.addEventListener(
76
+ "hidden.bs.modal",
77
+ () => {
78
+ yesButton.removeEventListener("click", onYes);
79
+ resolve(confirmed);
80
+ },
81
+ { once: true },
82
+ );
83
+ this.aysModal.show();
84
+ });
82
85
  }
83
86
  }
@@ -39,13 +39,13 @@ export class Settings {
39
39
  if (changed.displayMode) this.applyDisplayMode(changed.displayMode);
40
40
  },
41
41
  (changed) => this.onDialogClosed(changed),
42
- () => {
43
- this.targets.modals.areYouSure(
42
+ async () => {
43
+ const restart = await this.targets.modals.confirm(
44
44
  "Your change is saved, but only takes effect when the emulator restarts. Restart now?",
45
45
  "Restart now",
46
46
  "Later",
47
- () => window.location.reload(),
48
47
  );
48
+ if (restart) window.location.reload();
49
49
  },
50
50
  );
51
51
 
@@ -106,8 +106,7 @@ export class Settings {
106
106
  this.config.setAudioOutput(output);
107
107
  this.targets.quickSettings?.showAudioOutput(output);
108
108
  window.localStorage.audioOutput = output;
109
- this.urlState.params.audioOutput = output;
110
- this.urlState.updateUrl();
109
+ this.urlState.set({ audioOutput: output });
111
110
  }
112
111
 
113
112
  applySpeakerAmount(amount) {
@@ -123,18 +122,19 @@ export class Settings {
123
122
  applyDisplayMode(mode) {
124
123
  this.displayMode = mode;
125
124
  this.targets.display.setMode(mode);
125
+ // The monitor picture may have changed shape.
126
+ this.targets.layout.resize();
126
127
  this.config.setDisplayMode(mode);
127
128
  this.targets.quickSettings?.showDisplayMode(mode);
128
129
  window.localStorage.displayMode = mode;
129
- this.urlState.params.displayMode = mode;
130
- this.urlState.updateUrl();
130
+ this.urlState.set({ displayMode: mode });
131
131
  }
132
132
 
133
133
  onDialogClosed(changed) {
134
134
  const { urlState } = this;
135
135
  const { machine, keys, inputs } = this.targets;
136
136
  const parsedQuery = urlState.params;
137
- Object.assign(parsedQuery, changed);
137
+ urlState.set(changed);
138
138
  if (changed.keyLayout) {
139
139
  window.localStorage.keyLayout = changed.keyLayout;
140
140
  machine.emulationConfig.keyLayout = changed.keyLayout;
@@ -155,6 +155,5 @@ export class Settings {
155
155
  machine.processor.tube.cpuMultiplier = changed.tubeCpuMultiplier;
156
156
  }
157
157
  }
158
- urlState.updateUrl();
159
158
  }
160
159
  }
@@ -46,6 +46,8 @@ export class SthPicker {
46
46
  onError,
47
47
  true,
48
48
  );
49
+ media.addSource("sth", (name) => this.discs.fetch(name));
50
+ media.addSource("tapeSth", (name) => this.tapes.fetch(name));
49
51
 
50
52
  document.addEventListener("click", (e) => {
51
53
  const target = e.target.closest("a.sth");
@@ -56,7 +56,8 @@ export const UrlParamTypes = {
56
56
 
57
57
  /**
58
58
  * The page's settings as carried in its URL. `params` is the one parsed
59
- * object: whoever changes a setting edits it in place and calls updateUrl.
59
+ * object: whoever changes a setting hands the change to set(), which edits
60
+ * it in place and writes the URL in one step.
60
61
  */
61
62
  export class UrlState {
62
63
  constructor(location, history, paramTypes = UrlParamTypes) {
@@ -78,6 +79,15 @@ export class UrlState {
78
79
  return buildUrlFromParams(this.baseUrl, { ...this.params, ...overrides }, this.paramTypes);
79
80
  }
80
81
 
82
+ /** Apply `changes` to the parameters (undefined deletes a key) and push the new URL. */
83
+ set(changes) {
84
+ for (const [key, value] of Object.entries(changes)) {
85
+ if (value === undefined) delete this.params[key];
86
+ else this.params[key] = value;
87
+ }
88
+ this.updateUrl();
89
+ }
90
+
81
91
  updateUrl() {
82
92
  this.history.pushState(null, null, this.url());
83
93
  }
File without changes