janela 0.5.0 → 0.7.0

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/runtime/janela.ts CHANGED
@@ -91,7 +91,6 @@ export type {
91
91
  DialogFilter,
92
92
  Events,
93
93
  FsCallback,
94
- JanelaApp,
95
94
  OpenDialogOptions,
96
95
  SaveDialogOptions,
97
96
  WindowConfig,
@@ -100,14 +99,16 @@ export type {
100
99
  // The typed-contract helpers are values, so they are re-exported as values.
101
100
  // A project's `import { defineCommands } from "janela/host"` is rewritten to
102
101
  // this module by the CLI before scriptc sees it.
103
- export { defineCommands, defineEvents, emit, on, onAsync } from "./types";
102
+ export { defineCommands, defineEvents } from "./types";
104
103
 
105
104
  import type {
106
105
  AsyncCommandHandler,
107
106
  CommandHandler,
107
+ CommandShapes,
108
+ Commands,
108
109
  DialogFilter,
110
+ Events,
109
111
  FsCallback,
110
- JanelaApp,
111
112
  OpenDialogOptions,
112
113
  SaveDialogOptions,
113
114
  WindowConfig,
@@ -120,118 +121,149 @@ function encode(value: unknown): string {
120
121
  return JSON.stringify(value);
121
122
  }
122
123
 
123
- export function createApp(cfg: WindowConfig): JanelaApp {
124
- const h = wvCreate(0) + 0;
125
- wvSetTitle(h, cfg.title);
126
- wvSetSize(h, cfg.width, cfg.height, 0);
127
- wvInit(h, BOOTSTRAP);
124
+
125
+ // Filters cross as "Name|ext,ext|Name|ext" - the shim needs no JSON parser
126
+ // for what is always a short, flat list.
127
+ function encodeFilters(filters: DialogFilter[] | undefined): string {
128
+ if (filters === undefined || filters.length === 0) return "";
129
+ const parts: string[] = [];
130
+ for (let i = 0; i < filters.length; i++) {
131
+ parts.push(filters[i].name);
132
+ parts.push(filters[i].extensions.join(","));
133
+ }
134
+ return parts.join("|");
135
+ }
136
+
137
+ // Loop tuning. The drain budget is wall-clock rather than a byte count on
138
+ // purpose: a fixed chunk size fixes the WORST turn but also caps throughput
139
+ // (128 KB per 8 ms tick would cap reads at ~16 MB/s), whereas a time budget
140
+ // spends whatever the machine can do in the time available.
141
+ const DRAIN_BUDGET_MS = 4; // = a quarter of a 60fps frame
142
+ const DRAIN_SLICE = 131072; // 128 KB - granularity within the budget
143
+
144
+ // 8 ms is plenty for timers and task chains, but while a payload is draining
145
+ // the loop does real work every turn, and waiting 8 ms between 4 ms slices
146
+ // would halve throughput for no benefit.
147
+ const TICK_IDLE_MS = 8;
148
+ const TICK_DRAIN_MS = 4;
149
+
150
+ /**
151
+ * A running janela app.
152
+ *
153
+ * This is a class rather than an interface because the contract-typed methods
154
+ * below are generic, and scriptc dispatches generic methods statically: it can
155
+ * only compile a call when the receiver's runtime class is provable, which an
156
+ * interface (being signature-only) never is. A class receiver works even as a
157
+ * plain function parameter, which is what `setup(app)` is.
158
+ */
159
+ export class JanelaApp<
160
+ C extends CommandShapes = CommandShapes,
161
+ E = Record<string, unknown>,
162
+ > {
163
+ handle: number;
164
+ names: string[] = [];
165
+ handlers: CommandHandler[] = [];
128
166
 
129
167
  // ---- the host loop -------------------------------------------------------
130
168
  // scriptc's event loop is parked for as long as the program sits inside the
131
169
  // wvRun() FFI call, so setTimeout/await never fire while the window is open.
132
170
  // These queues are drained instead by the retained tick handler that the
133
171
  // shim's ticker posts to the UI thread, and the ticker only runs while there
134
- // is work — an idle app costs nothing.
135
- const asyncNames: string[] = [];
136
- const asyncHandlers: AsyncCommandHandler[] = [];
137
- let taskFns: (() => void)[] = [];
138
- let timerFns: (() => void)[] = [];
139
- let timerDue: number[] = [];
140
- let jobIds: number[] = [];
141
- let jobCbs: FsCallback[] = [];
142
- let ticking = false;
172
+ // is work - an idle app costs nothing.
173
+ asyncNames: string[] = [];
174
+ asyncHandlers: AsyncCommandHandler[] = [];
175
+ taskFns: (() => void)[] = [];
176
+ timerFns: (() => void)[] = [];
177
+ timerDue: number[] = [];
178
+ jobIds: number[] = [];
179
+ jobCbs: FsCallback[] = [];
180
+ ticking = false;
181
+ tickMs = TICK_IDLE_MS;
143
182
 
144
183
  // ---- the drain -----------------------------------------------------------
145
184
  // A finished job's bytes still have to be decoded into a TypeScript string,
146
185
  // and that cost is proportional to the payload: taking a 100 MB file in one
147
186
  // call froze the window for ~240 ms. So a finished job moves here and is
148
187
  // decoded a slice at a time, giving the run loop the thread back between
149
- // slices — total work is unchanged, but no single turn carries much of it.
150
- //
151
- // The budget is wall-clock rather than a byte count on purpose: a fixed
152
- // chunk size fixes the WORST turn but also caps throughput (128 KB per 8 ms
153
- // tick would cap reads at ~16 MB/s), whereas a time budget spends whatever
154
- // the machine can do in the time available.
155
- const DRAIN_BUDGET_MS = 4; // ≈ a quarter of a 60fps frame
156
- const DRAIN_SLICE = 131072; // 128 KB — granularity within the budget
157
- let drainIds: number[] = [];
158
- let drainCbs: FsCallback[] = [];
159
- let drainOk: boolean[] = [];
160
- let drainParts: string[][] = [];
161
- let drainOff: number[] = [];
162
- let drainSize: number[] = [];
163
-
164
- // Tick interval: 8 ms is plenty for timers and task chains, but while a
165
- // payload is draining the loop is doing real work every turn, and waiting
166
- // 8 ms between 4 ms slices would halve throughput for no benefit. So the
167
- // ticker runs tighter for as long as there is a payload in flight.
168
- const TICK_IDLE_MS = 8;
169
- const TICK_DRAIN_MS = 4;
170
- let tickMs = TICK_IDLE_MS;
171
-
172
- const retick = (): void => {
173
- const want = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
174
- if (!ticking || want === tickMs) return;
175
- tickMs = want;
176
- wvTickStart(h, want);
177
- };
178
-
179
- const wake = (): void => {
180
- if (ticking) return;
181
- ticking = true;
182
- tickMs = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
183
- wvTickStart(h, tickMs);
184
- };
185
-
186
- const idle = (): void => {
187
- if (!ticking) return;
188
+ // slices - total work is unchanged, but no single turn carries much of it.
189
+ drainIds: number[] = [];
190
+ drainCbs: FsCallback[] = [];
191
+ drainOk: boolean[] = [];
192
+ drainParts: string[][] = [];
193
+ drainOff: number[] = [];
194
+ drainSize: number[] = [];
195
+
196
+ constructor(cfg: WindowConfig) {
197
+ const h = wvCreate(0) + 0;
198
+ this.handle = h;
199
+ wvSetTitle(h, cfg.title);
200
+ wvSetSize(h, cfg.width, cfg.height, 0);
201
+ wvInit(h, BOOTSTRAP);
202
+ }
203
+
204
+ retick(): void {
205
+ const want = this.drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
206
+ if (!this.ticking || want === this.tickMs) return;
207
+ this.tickMs = want;
208
+ wvTickStart(this.handle, want);
209
+ }
210
+
211
+ wake(): void {
212
+ if (this.ticking) return;
213
+ this.ticking = true;
214
+ this.tickMs = this.drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
215
+ wvTickStart(this.handle, this.tickMs);
216
+ }
217
+
218
+ idle(): void {
219
+ if (!this.ticking) return;
188
220
  if (
189
- taskFns.length > 0 ||
190
- timerFns.length > 0 ||
191
- jobIds.length > 0 ||
192
- drainIds.length > 0
221
+ this.taskFns.length > 0 ||
222
+ this.timerFns.length > 0 ||
223
+ this.jobIds.length > 0 ||
224
+ this.drainIds.length > 0
193
225
  ) {
194
226
  return;
195
227
  }
196
- ticking = false;
197
- wvTickStop(h);
198
- };
228
+ this.ticking = false;
229
+ wvTickStop(this.handle);
230
+ }
199
231
 
200
232
  // Decode as much of the pending payloads as the budget allows, then yield.
201
233
  // Slices are taken from one job at a time so a big read finishes promptly
202
234
  // rather than every concurrent read finishing slowly.
203
- const drainSome = (): void => {
204
- if (drainIds.length === 0) return;
235
+ drainSome(): void {
236
+ if (this.drainIds.length === 0) return;
205
237
  const started = Date.now() + 0;
206
238
 
207
- while (drainIds.length > 0) {
239
+ while (this.drainIds.length > 0) {
208
240
  let chunk = "";
209
241
  const taken =
210
- wvJobTakeAt(h, drainIds[0], drainOff[0], DRAIN_SLICE, (text) => {
242
+ wvJobTakeAt(this.handle, this.drainIds[0], this.drainOff[0], DRAIN_SLICE, (text) => {
211
243
  chunk = text;
212
244
  }) + 0;
213
245
 
214
246
  // A negative count means the job vanished; treat the payload as final
215
247
  // rather than spinning on it forever.
216
248
  if (taken > 0) {
217
- drainParts[0].push(chunk);
218
- drainOff[0] = drainOff[0] + taken;
249
+ this.drainParts[0].push(chunk);
250
+ this.drainOff[0] = this.drainOff[0] + taken;
219
251
  }
220
252
 
221
- if (taken <= 0 || drainOff[0] >= drainSize[0]) {
253
+ if (taken <= 0 || this.drainOff[0] >= this.drainSize[0]) {
222
254
  // Joining is one unavoidable O(n) copy: the callback is handed a
223
255
  // single string, so the whole payload must be materialised once.
224
- const payload = drainParts[0].join("");
225
- const cb = drainCbs[0];
226
- const ok = drainOk[0];
227
- wvJobFree(h, drainIds[0]);
228
-
229
- drainIds = drainIds.slice(1);
230
- drainCbs = drainCbs.slice(1);
231
- drainOk = drainOk.slice(1);
232
- drainParts = drainParts.slice(1);
233
- drainOff = drainOff.slice(1);
234
- drainSize = drainSize.slice(1);
256
+ const payload = this.drainParts[0].join("");
257
+ const cb = this.drainCbs[0];
258
+ const ok = this.drainOk[0];
259
+ wvJobFree(this.handle, this.drainIds[0]);
260
+
261
+ this.drainIds = this.drainIds.slice(1);
262
+ this.drainCbs = this.drainCbs.slice(1);
263
+ this.drainOk = this.drainOk.slice(1);
264
+ this.drainParts = this.drainParts.slice(1);
265
+ this.drainOff = this.drainOff.slice(1);
266
+ this.drainSize = this.drainSize.slice(1);
235
267
 
236
268
  if (ok) {
237
269
  cb(null, payload);
@@ -244,88 +276,76 @@ export function createApp(cfg: WindowConfig): JanelaApp {
244
276
 
245
277
  if (Date.now() - started >= DRAIN_BUDGET_MS) return;
246
278
  }
247
- };
279
+ }
248
280
 
249
281
  // One turn of the loop: every task queued so far, plus every due timer.
250
282
  // Tasks queued *by* this turn wait for the next one, so a defer() chain
251
283
  // yields to the UI between slices instead of starving it.
252
- const turn = (): void => {
253
- const tasks = taskFns;
254
- taskFns = [];
284
+ turn(): void {
285
+ const tasks = this.taskFns;
286
+ this.taskFns = [];
255
287
  for (let i = 0; i < tasks.length; i++) tasks[i]();
256
288
 
257
- if (timerFns.length > 0) {
289
+ if (this.timerFns.length > 0) {
258
290
  const now = Date.now() + 0;
259
291
  const keptFns: (() => void)[] = [];
260
292
  const keptDue: number[] = [];
261
293
  const fire: (() => void)[] = [];
262
- for (let i = 0; i < timerFns.length; i++) {
263
- if (timerDue[i] <= now) {
264
- fire.push(timerFns[i]);
294
+ for (let i = 0; i < this.timerFns.length; i++) {
295
+ if (this.timerDue[i] <= now) {
296
+ fire.push(this.timerFns[i]);
265
297
  } else {
266
- keptFns.push(timerFns[i]);
267
- keptDue.push(timerDue[i]);
298
+ keptFns.push(this.timerFns[i]);
299
+ keptDue.push(this.timerDue[i]);
268
300
  }
269
301
  }
270
- timerFns = keptFns;
271
- timerDue = keptDue;
302
+ this.timerFns = keptFns;
303
+ this.timerDue = keptDue;
272
304
  for (let i = 0; i < fire.length; i++) fire[i]();
273
305
  }
274
306
 
275
307
  // Finished file jobs: the worker thread has already done the blocking
276
308
  // syscall, so all that happens on this (UI) thread is the drain.
277
- if (jobIds.length > 0) {
309
+ if (this.jobIds.length > 0) {
278
310
  const keptIds: number[] = [];
279
311
  const keptCbs: FsCallback[] = [];
280
312
  const doneIds: number[] = [];
281
313
  const doneCbs: FsCallback[] = [];
282
314
  const doneOk: boolean[] = [];
283
- for (let i = 0; i < jobIds.length; i++) {
284
- const st = wvJobStatus(h, jobIds[i]) + 0;
315
+ for (let i = 0; i < this.jobIds.length; i++) {
316
+ const st = wvJobStatus(this.handle, this.jobIds[i]) + 0;
285
317
  if (st === JOB_PENDING) {
286
- keptIds.push(jobIds[i]);
287
- keptCbs.push(jobCbs[i]);
318
+ keptIds.push(this.jobIds[i]);
319
+ keptCbs.push(this.jobCbs[i]);
288
320
  } else {
289
- doneIds.push(jobIds[i]);
290
- doneCbs.push(jobCbs[i]);
321
+ doneIds.push(this.jobIds[i]);
322
+ doneCbs.push(this.jobCbs[i]);
291
323
  doneOk.push(st === JOB_OK);
292
324
  }
293
325
  }
294
- jobIds = keptIds;
295
- jobCbs = keptCbs;
326
+ this.jobIds = keptIds;
327
+ this.jobCbs = keptCbs;
296
328
  for (let i = 0; i < doneIds.length; i++) {
297
329
  // On failure the payload IS the error message, so one path serves both
298
330
  // outcomes. Nothing is decoded here: the job joins the drain queue and
299
331
  // its bytes are taken a slice at a time, under a time budget.
300
- drainIds.push(doneIds[i]);
301
- drainCbs.push(doneCbs[i]);
302
- drainOk.push(doneOk[i]);
303
- drainParts.push([]);
304
- drainOff.push(0);
305
- drainSize.push(wvJobSize(h, doneIds[i]) + 0);
332
+ this.drainIds.push(doneIds[i]);
333
+ this.drainCbs.push(doneCbs[i]);
334
+ this.drainOk.push(doneOk[i]);
335
+ this.drainParts.push([]);
336
+ this.drainOff.push(0);
337
+ this.drainSize.push(wvJobSize(this.handle, doneIds[i]) + 0);
306
338
  }
307
339
  }
308
340
 
309
- drainSome();
310
- retick();
311
- idle();
312
- };
313
-
314
- // Filters cross as "Name|ext,ext|Name|ext" — the shim needs no JSON parser
315
- // for what is always a short, flat list.
316
- const encodeFilters = (filters: DialogFilter[] | undefined): string => {
317
- if (filters === undefined || filters.length === 0) return "";
318
- const parts: string[] = [];
319
- for (let i = 0; i < filters.length; i++) {
320
- parts.push(filters[i].name);
321
- parts.push(filters[i].extensions.join(","));
322
- }
323
- return parts.join("|");
324
- };
341
+ this.drainSome();
342
+ this.retick();
343
+ this.idle();
344
+ }
325
345
 
326
346
  // Both dialog kinds share one path: start the job, then let the same drain
327
347
  // that serves file I/O deliver the answer on a later turn.
328
- const startDialog = (
348
+ startDialog(
329
349
  kind: number,
330
350
  flags: number,
331
351
  title: string | undefined,
@@ -333,9 +353,9 @@ export function createApp(cfg: WindowConfig): JanelaApp {
333
353
  defaultName: string | undefined,
334
354
  filters: DialogFilter[] | undefined,
335
355
  cb: (paths: string[] | null, err?: string) => void,
336
- ): void => {
356
+ ): void {
337
357
  const id = wvDialog(
338
- h,
358
+ this.handle,
339
359
  kind,
340
360
  flags,
341
361
  title === undefined ? "" : title,
@@ -344,12 +364,12 @@ export function createApp(cfg: WindowConfig): JanelaApp {
344
364
  encodeFilters(filters),
345
365
  ) + 0;
346
366
  if (id < 0) {
347
- taskFns.push(() => cb(null, "EAGAIN: could not open a dialog"));
348
- wake();
367
+ this.taskFns.push(() => cb(null, "EAGAIN: could not open a dialog"));
368
+ this.wake();
349
369
  return;
350
370
  }
351
- jobIds.push(id);
352
- jobCbs.push((err, text) => {
371
+ this.jobIds.push(id);
372
+ this.jobCbs.push((err, text) => {
353
373
  if (err !== null) {
354
374
  cb(null, err);
355
375
  return;
@@ -357,147 +377,248 @@ export function createApp(cfg: WindowConfig): JanelaApp {
357
377
  // "null" is a cancel; anything else is a JSON array of paths.
358
378
  cb(JSON.parse(text) as string[] | null);
359
379
  });
360
- wake();
361
- };
362
-
363
- const app: JanelaApp = {
364
- handle: h,
365
- names: [],
366
- handlers: [],
367
-
368
- command: (name, handler) => {
369
- app.names.push(name);
370
- app.handlers.push(handler);
371
- },
372
-
373
- commandAsync: (name, handler) => {
374
- asyncNames.push(name);
375
- asyncHandlers.push(handler);
376
- },
377
-
378
- defer: (fn) => {
379
- taskFns.push(fn);
380
- wake();
381
- },
382
-
383
- sleep: (ms, fn) => {
384
- timerFns.push(fn);
385
- timerDue.push(Date.now() + (ms > 0 ? ms : 0));
386
- wake();
387
- },
388
-
389
- readFileAsync: (path, cb) => {
390
- const id = wvFsRead(h, path) + 0;
391
- if (id < 0) {
392
- app.defer(() => cb("EAGAIN: could not start a read of '" + path + "'", ""));
393
- return;
394
- }
395
- jobIds.push(id);
396
- jobCbs.push(cb);
397
- wake();
398
- },
399
-
400
- writeFileAsync: (path, data, cb) => {
401
- const id = wvFsWrite(h, path, data) + 0;
402
- if (id < 0) {
403
- app.defer(() => cb("EAGAIN: could not start a write of '" + path + "'"));
404
- return;
380
+ this.wake();
381
+ }
382
+
383
+ /**
384
+ * Register a named command, callable from the page as janela.invoke(name, args).
385
+ *
386
+ * With a contract (`JanelaApp<App>`) the name must be one the contract
387
+ * declares, `args` is inferred from it, and the return value is checked
388
+ * against it. Without one, `args` is `unknown` and any name is accepted.
389
+ */
390
+ command<K extends keyof C & string>(
391
+ name: K,
392
+ handler: (args: C[K]["args"]) => C[K]["result"],
393
+ ): void {
394
+ this.names.push(name);
395
+ // The cast is on the VALUE, inside a contextually-typed closure: casting
396
+ // the function itself to another signature and calling through it fails
397
+ // at runtime.
398
+ this.handlers.push((args: unknown) => handler(args as C[K]["args"]));
399
+ }
400
+
401
+ /**
402
+ * Register a command that answers later; see AsyncCommandHandler. Under a
403
+ * contract, `resolve` takes exactly the declared result type.
404
+ */
405
+ commandAsync<K extends keyof C & string>(
406
+ name: K,
407
+ handler: (
408
+ args: C[K]["args"],
409
+ resolve: (value: C[K]["result"]) => void,
410
+ reject: (reason: unknown) => void,
411
+ ) => void,
412
+ ): void {
413
+ this.asyncNames.push(name);
414
+ this.asyncHandlers.push(
415
+ (args: unknown, resolve: (v: unknown) => void, reject: (r: unknown) => void) => {
416
+ handler(args as C[K]["args"], (value: C[K]["result"]) => resolve(value), reject);
417
+ },
418
+ );
419
+ }
420
+
421
+ /** Run fn on the next turn of the host loop - the way to slice long work. */
422
+ defer(fn: () => void): void {
423
+ this.taskFns.push(fn);
424
+ this.wake();
425
+ }
426
+
427
+ /** Run fn after at least ms. The host loop's timer; scriptc's setTimeout
428
+ * cannot fire while the window is open (its loop is parked inside run()). */
429
+ sleep(ms: number, fn: () => void): void {
430
+ this.timerFns.push(fn);
431
+ this.timerDue.push(Date.now() + (ms > 0 ? ms : 0));
432
+ this.wake();
433
+ }
434
+
435
+ /**
436
+ * Read a file without blocking the window. The syscall runs on a shim
437
+ * worker thread; the callback lands on the UI thread on a later turn.
438
+ */
439
+ readFileAsync(path: string, cb: FsCallback): void {
440
+ const id = wvFsRead(this.handle, path) + 0;
441
+ if (id < 0) {
442
+ this.defer(() => cb("EAGAIN: could not start a read of '" + path + "'", ""));
443
+ return;
444
+ }
445
+ this.jobIds.push(id);
446
+ this.jobCbs.push(cb);
447
+ this.wake();
448
+ }
449
+
450
+ /** Write a file without blocking the window; cb(null) on success. */
451
+ writeFileAsync(path: string, data: string, cb: (err: string | null) => void): void {
452
+ const id = wvFsWrite(this.handle, path, data) + 0;
453
+ if (id < 0) {
454
+ this.defer(() => cb("EAGAIN: could not start a write of '" + path + "'"));
455
+ return;
456
+ }
457
+ this.jobIds.push(id);
458
+ // The write payload is empty on success; the shared callback shape just
459
+ // ignores the text argument.
460
+ this.jobCbs.push((err, _text) => cb(err));
461
+ this.wake();
462
+ }
463
+
464
+ /** Show the native "open" dialog; cb gets the paths, or null on cancel. */
465
+ openFileDialog(
466
+ options: OpenDialogOptions,
467
+ cb: (paths: string[] | null, err?: string) => void,
468
+ ): void {
469
+ let flags = 0;
470
+ if (options.multiple === true) flags = flags + DLG_MULTIPLE;
471
+ if (options.directory === true) flags = flags + DLG_DIRECTORY;
472
+ this.startDialog(DLG_OPEN, flags, options.title, options.defaultPath, "",
473
+ options.filters, (paths, err) => cb(paths, err));
474
+ }
475
+
476
+ /** Show the native "save" dialog; cb gets the path, or null on cancel. */
477
+ saveFileDialog(
478
+ options: SaveDialogOptions,
479
+ cb: (path: string | null, err?: string) => void,
480
+ ): void {
481
+ this.startDialog(DLG_SAVE, 0, options.title, options.defaultPath,
482
+ options.defaultName, options.filters, (paths, err) => {
483
+ if (paths === null) {
484
+ cb(null, err);
485
+ return;
486
+ }
487
+ cb(paths.length > 0 ? paths[0] : null, err);
488
+ });
489
+ }
490
+
491
+ /** Change the window title at any time, not just at startup. */
492
+ setTitle(title: string): void {
493
+ wvSetTitle(this.handle, title);
494
+ }
495
+
496
+ /** Resize the window. `hint`: 0 none, 1 minimum, 2 maximum, 3 fixed. */
497
+ setSize(width: number, height: number, hint?: number): void {
498
+ wvSetSize(this.handle, width, height, hint === undefined ? 0 : hint);
499
+ }
500
+
501
+ /** Enter or leave fullscreen. */
502
+ setFullscreen(on: boolean): void {
503
+ wvSetFullscreen(this.handle, on ? 1 : 0);
504
+ }
505
+
506
+ /**
507
+ * Fire an event into the page; the payload is delivered as a value. Under a
508
+ * contract, the name must be declared and the payload must match its type.
509
+ */
510
+ emit<K extends keyof E & string>(event: K, payload: E[K]): void {
511
+ wvEval(
512
+ this.handle,
513
+ "window.__wvEmit(" + JSON.stringify(event) + "," + encode(payload) + ");",
514
+ );
515
+ }
516
+
517
+ /** Close the window and make run() return. */
518
+ quit(): void {
519
+ wvTerminate(this.handle);
520
+ }
521
+
522
+ /** Show the page and block until the window closes. Returns the run status. */
523
+ run(html: string): number {
524
+ const h = this.handle;
525
+ // Both handlers are retained: registered once here, called by the shim
526
+ // for as long as the window is open.
527
+ wvOnTick(h, () => {
528
+ this.turn();
529
+ });
530
+ wvOnInvoke(h, (req) => {
531
+ const env = JSON.parse(req) as string[];
532
+ const cmd = env[0];
533
+ const args = JSON.parse(env[1]) as unknown;
534
+ for (let i = 0; i < this.names.length; i++) {
535
+ if (this.names[i] === cmd) {
536
+ wvReply(h, encode(this.handlers[i](args)));
537
+ return 0;
538
+ }
405
539
  }
406
- jobIds.push(id);
407
- // The write payload is empty on success; the shared callback shape just
408
- // ignores the text argument.
409
- jobCbs.push((err, _text) => cb(err));
410
- wake();
411
- },
412
-
413
- openFileDialog: (options, cb) => {
414
- let flags = 0;
415
- if (options.multiple === true) flags = flags + DLG_MULTIPLE;
416
- if (options.directory === true) flags = flags + DLG_DIRECTORY;
417
- startDialog(DLG_OPEN, flags, options.title, options.defaultPath, "",
418
- options.filters, (paths, err) => cb(paths, err));
419
- },
420
-
421
- saveFileDialog: (options, cb) => {
422
- startDialog(DLG_SAVE, 0, options.title, options.defaultPath,
423
- options.defaultName, options.filters, (paths, err) => {
424
- if (paths === null) {
425
- cb(null, err);
426
- return;
540
+ for (let i = 0; i < this.asyncNames.length; i++) {
541
+ if (this.asyncNames[i] === cmd) {
542
+ // Park the page's promise: the shim holds this call's id and
543
+ // answers it when resolve/reject reaches wvResolve, whenever
544
+ // that is. Meanwhile the loop is free to serve other calls.
545
+ const id = wvDefer(h) + 0;
546
+ if (id < 0) {
547
+ wvReply(h, encode("cannot defer command: " + cmd));
548
+ return 1;
427
549
  }
428
- cb(paths.length > 0 ? paths[0] : null, err);
429
- });
430
- },
431
-
432
- setTitle: (title) => {
433
- wvSetTitle(h, title);
434
- },
550
+ const settle = (status: number): ((value: unknown) => void) => {
551
+ let done = false;
552
+ return (value: unknown) => {
553
+ if (done) return; // a promise settles once
554
+ done = true;
555
+ wvReply(h, encode(value));
556
+ wvResolve(h, id, status);
557
+ };
558
+ };
559
+ this.asyncHandlers[i](args, settle(0), settle(1));
560
+ return 0;
561
+ }
562
+ }
563
+ wvReply(h, encode("unknown command: " + cmd));
564
+ return 1; // rejects the frontend promise
565
+ });
435
566
 
436
- setSize: (width, height, hint) => {
437
- wvSetSize(h, width, height, hint === undefined ? 0 : hint);
438
- },
567
+ wvBind(h, "__invoke");
568
+ wvSetHtml(h, html);
569
+ const rc = wvRun(h) + 0;
570
+ return rc;
571
+ }
572
+ }
439
573
 
440
- setFullscreen: (on) => {
441
- wvSetFullscreen(h, on ? 1 : 0);
442
- },
574
+ export function createApp<
575
+ C extends CommandShapes = CommandShapes,
576
+ E = Record<string, unknown>,
577
+ >(cfg: WindowConfig): JanelaApp<C, E> {
578
+ return new JanelaApp<C, E>(cfg);
579
+ }
443
580
 
444
- emit: (event, payload) => {
445
- wvEval(
446
- h,
447
- "window.__wvEmit(" + JSON.stringify(event) + "," + encode(payload) + ");",
448
- );
449
- },
581
+ // ---------------------------------------------------------------------------
582
+ // Deprecated standalone registrars (0.5.x)
583
+ // ---------------------------------------------------------------------------
584
+ // These were the shape before the app itself carried the contract. They still
585
+ // work; prefer app.command / app.commandAsync / app.emit.
586
+
587
+ /** @deprecated Use `app.command(name, handler)` on a contract-typed app. */
588
+ export function on<M extends CommandShapes, K extends keyof M & string>(
589
+ app: JanelaApp,
590
+ _commands: Commands<M>,
591
+ name: K,
592
+ handler: (args: M[K]["args"]) => M[K]["result"],
593
+ ): void {
594
+ app.command(name, (args: unknown) => handler(args as M[K]["args"]));
595
+ }
450
596
 
451
- quit: () => {
452
- wvTerminate(h);
597
+ /** @deprecated Use `app.commandAsync(name, handler)` on a contract-typed app. */
598
+ export function onAsync<M extends CommandShapes, K extends keyof M & string>(
599
+ app: JanelaApp,
600
+ _commands: Commands<M>,
601
+ name: K,
602
+ handler: (
603
+ args: M[K]["args"],
604
+ resolve: (value: M[K]["result"]) => void,
605
+ reject: (reason: unknown) => void,
606
+ ) => void,
607
+ ): void {
608
+ app.commandAsync(
609
+ name,
610
+ (args: unknown, resolve: (v: unknown) => void, reject: (r: unknown) => void) => {
611
+ handler(args as M[K]["args"], (value: M[K]["result"]) => resolve(value), reject);
453
612
  },
613
+ );
614
+ }
454
615
 
455
- run: (html) => {
456
- // Both handlers are retained: registered once here, called by the shim
457
- // for as long as the window is open.
458
- wvOnTick(h, turn);
459
- wvOnInvoke(h, (req) => {
460
- const env = JSON.parse(req) as string[];
461
- const cmd = env[0];
462
- const args = JSON.parse(env[1]) as unknown;
463
- for (let i = 0; i < app.names.length; i++) {
464
- if (app.names[i] === cmd) {
465
- wvReply(h, encode(app.handlers[i](args)));
466
- return 0;
467
- }
468
- }
469
- for (let i = 0; i < asyncNames.length; i++) {
470
- if (asyncNames[i] === cmd) {
471
- // Park the page's promise: the shim holds this call's id and
472
- // answers it when resolve/reject reaches wvResolve, whenever
473
- // that is. Meanwhile the loop is free to serve other calls.
474
- const id = wvDefer(h) + 0;
475
- if (id < 0) {
476
- wvReply(h, encode("cannot defer command: " + cmd));
477
- return 1;
478
- }
479
- const settle = (status: number): ((value: unknown) => void) => {
480
- let done = false;
481
- return (value: unknown) => {
482
- if (done) return; // a promise settles once
483
- done = true;
484
- wvReply(h, encode(value));
485
- wvResolve(h, id, status);
486
- };
487
- };
488
- asyncHandlers[i](args, settle(0), settle(1));
489
- return 0;
490
- }
491
- }
492
- wvReply(h, encode("unknown command: " + cmd));
493
- return 1; // rejects the frontend promise
494
- });
495
-
496
- wvBind(h, "__invoke");
497
- wvSetHtml(h, html);
498
- const rc = wvRun(h) + 0;
499
- return rc;
500
- },
501
- };
502
- return app;
616
+ /** @deprecated Use `app.emit(event, payload)` on a contract-typed app. */
617
+ export function emit<E, K extends keyof E & string>(
618
+ app: JanelaApp,
619
+ _events: Events<E>,
620
+ name: K,
621
+ payload: E[K],
622
+ ): void {
623
+ app.emit(name, payload as unknown);
503
624
  }