janela 0.4.0 → 0.6.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
@@ -67,6 +67,11 @@ const BOOTSTRAP =
67
67
  " listen: function (event, cb) {" +
68
68
  " if (!window.__wvListeners[event]) window.__wvListeners[event] = [];" +
69
69
  " window.__wvListeners[event].push(cb);" +
70
+ " return function () {" +
71
+ " var a = window.__wvListeners[event] || [];" +
72
+ " var i = a.indexOf(cb);" +
73
+ " if (i >= 0) a.splice(i, 1);" +
74
+ " };" +
70
75
  " }," +
71
76
  "};" +
72
77
  "window.__wvEmit = function (event, payload) {" +
@@ -80,20 +85,30 @@ const BOOTSTRAP =
80
85
  export type {
81
86
  AsyncCommandHandler,
82
87
  CommandHandler,
88
+ CommandShape,
89
+ CommandShapes,
90
+ Commands,
83
91
  DialogFilter,
92
+ Events,
84
93
  FsCallback,
85
- JanelaApp,
86
94
  OpenDialogOptions,
87
95
  SaveDialogOptions,
88
96
  WindowConfig,
89
97
  } from "./types";
90
98
 
99
+ // The typed-contract helpers are values, so they are re-exported as values.
100
+ // A project's `import { defineCommands } from "janela/host"` is rewritten to
101
+ // this module by the CLI before scriptc sees it.
102
+ export { defineCommands, defineEvents } from "./types";
103
+
91
104
  import type {
92
105
  AsyncCommandHandler,
93
106
  CommandHandler,
107
+ CommandShapes,
108
+ Commands,
94
109
  DialogFilter,
110
+ Events,
95
111
  FsCallback,
96
- JanelaApp,
97
112
  OpenDialogOptions,
98
113
  SaveDialogOptions,
99
114
  WindowConfig,
@@ -106,118 +121,149 @@ function encode(value: unknown): string {
106
121
  return JSON.stringify(value);
107
122
  }
108
123
 
109
- export function createApp(cfg: WindowConfig): JanelaApp {
110
- const h = wvCreate(0) + 0;
111
- wvSetTitle(h, cfg.title);
112
- wvSetSize(h, cfg.width, cfg.height, 0);
113
- 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[] = [];
114
166
 
115
167
  // ---- the host loop -------------------------------------------------------
116
168
  // scriptc's event loop is parked for as long as the program sits inside the
117
169
  // wvRun() FFI call, so setTimeout/await never fire while the window is open.
118
170
  // These queues are drained instead by the retained tick handler that the
119
171
  // shim's ticker posts to the UI thread, and the ticker only runs while there
120
- // is work — an idle app costs nothing.
121
- const asyncNames: string[] = [];
122
- const asyncHandlers: AsyncCommandHandler[] = [];
123
- let taskFns: (() => void)[] = [];
124
- let timerFns: (() => void)[] = [];
125
- let timerDue: number[] = [];
126
- let jobIds: number[] = [];
127
- let jobCbs: FsCallback[] = [];
128
- 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;
129
182
 
130
183
  // ---- the drain -----------------------------------------------------------
131
184
  // A finished job's bytes still have to be decoded into a TypeScript string,
132
185
  // and that cost is proportional to the payload: taking a 100 MB file in one
133
186
  // call froze the window for ~240 ms. So a finished job moves here and is
134
187
  // decoded a slice at a time, giving the run loop the thread back between
135
- // slices — total work is unchanged, but no single turn carries much of it.
136
- //
137
- // The budget is wall-clock rather than a byte count on purpose: a fixed
138
- // chunk size fixes the WORST turn but also caps throughput (128 KB per 8 ms
139
- // tick would cap reads at ~16 MB/s), whereas a time budget spends whatever
140
- // 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
- let drainIds: number[] = [];
144
- let drainCbs: FsCallback[] = [];
145
- let drainOk: boolean[] = [];
146
- let drainParts: string[][] = [];
147
- let drainOff: number[] = [];
148
- let drainSize: number[] = [];
149
-
150
- // Tick interval: 8 ms is plenty for timers and task chains, but while a
151
- // payload is draining the loop is doing real work every turn, and waiting
152
- // 8 ms between 4 ms slices would halve throughput for no benefit. So the
153
- // ticker runs tighter for as long as there is a payload in flight.
154
- const TICK_IDLE_MS = 8;
155
- const TICK_DRAIN_MS = 4;
156
- let tickMs = TICK_IDLE_MS;
157
-
158
- const retick = (): void => {
159
- const want = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
160
- if (!ticking || want === tickMs) return;
161
- tickMs = want;
162
- wvTickStart(h, want);
163
- };
164
-
165
- const wake = (): void => {
166
- if (ticking) return;
167
- ticking = true;
168
- tickMs = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
169
- wvTickStart(h, tickMs);
170
- };
171
-
172
- const idle = (): void => {
173
- 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;
174
220
  if (
175
- taskFns.length > 0 ||
176
- timerFns.length > 0 ||
177
- jobIds.length > 0 ||
178
- drainIds.length > 0
221
+ this.taskFns.length > 0 ||
222
+ this.timerFns.length > 0 ||
223
+ this.jobIds.length > 0 ||
224
+ this.drainIds.length > 0
179
225
  ) {
180
226
  return;
181
227
  }
182
- ticking = false;
183
- wvTickStop(h);
184
- };
228
+ this.ticking = false;
229
+ wvTickStop(this.handle);
230
+ }
185
231
 
186
232
  // Decode as much of the pending payloads as the budget allows, then yield.
187
233
  // Slices are taken from one job at a time so a big read finishes promptly
188
234
  // rather than every concurrent read finishing slowly.
189
- const drainSome = (): void => {
190
- if (drainIds.length === 0) return;
235
+ drainSome(): void {
236
+ if (this.drainIds.length === 0) return;
191
237
  const started = Date.now() + 0;
192
238
 
193
- while (drainIds.length > 0) {
239
+ while (this.drainIds.length > 0) {
194
240
  let chunk = "";
195
241
  const taken =
196
- wvJobTakeAt(h, drainIds[0], drainOff[0], DRAIN_SLICE, (text) => {
242
+ wvJobTakeAt(this.handle, this.drainIds[0], this.drainOff[0], DRAIN_SLICE, (text) => {
197
243
  chunk = text;
198
244
  }) + 0;
199
245
 
200
246
  // A negative count means the job vanished; treat the payload as final
201
247
  // rather than spinning on it forever.
202
248
  if (taken > 0) {
203
- drainParts[0].push(chunk);
204
- drainOff[0] = drainOff[0] + taken;
249
+ this.drainParts[0].push(chunk);
250
+ this.drainOff[0] = this.drainOff[0] + taken;
205
251
  }
206
252
 
207
- if (taken <= 0 || drainOff[0] >= drainSize[0]) {
253
+ if (taken <= 0 || this.drainOff[0] >= this.drainSize[0]) {
208
254
  // Joining is one unavoidable O(n) copy: the callback is handed a
209
255
  // single string, so the whole payload must be materialised once.
210
- const payload = drainParts[0].join("");
211
- const cb = drainCbs[0];
212
- const ok = drainOk[0];
213
- wvJobFree(h, drainIds[0]);
214
-
215
- drainIds = drainIds.slice(1);
216
- drainCbs = drainCbs.slice(1);
217
- drainOk = drainOk.slice(1);
218
- drainParts = drainParts.slice(1);
219
- drainOff = drainOff.slice(1);
220
- 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);
221
267
 
222
268
  if (ok) {
223
269
  cb(null, payload);
@@ -230,88 +276,76 @@ export function createApp(cfg: WindowConfig): JanelaApp {
230
276
 
231
277
  if (Date.now() - started >= DRAIN_BUDGET_MS) return;
232
278
  }
233
- };
279
+ }
234
280
 
235
281
  // One turn of the loop: every task queued so far, plus every due timer.
236
282
  // Tasks queued *by* this turn wait for the next one, so a defer() chain
237
283
  // yields to the UI between slices instead of starving it.
238
- const turn = (): void => {
239
- const tasks = taskFns;
240
- taskFns = [];
284
+ turn(): void {
285
+ const tasks = this.taskFns;
286
+ this.taskFns = [];
241
287
  for (let i = 0; i < tasks.length; i++) tasks[i]();
242
288
 
243
- if (timerFns.length > 0) {
289
+ if (this.timerFns.length > 0) {
244
290
  const now = Date.now() + 0;
245
291
  const keptFns: (() => void)[] = [];
246
292
  const keptDue: number[] = [];
247
293
  const fire: (() => void)[] = [];
248
- for (let i = 0; i < timerFns.length; i++) {
249
- if (timerDue[i] <= now) {
250
- 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]);
251
297
  } else {
252
- keptFns.push(timerFns[i]);
253
- keptDue.push(timerDue[i]);
298
+ keptFns.push(this.timerFns[i]);
299
+ keptDue.push(this.timerDue[i]);
254
300
  }
255
301
  }
256
- timerFns = keptFns;
257
- timerDue = keptDue;
302
+ this.timerFns = keptFns;
303
+ this.timerDue = keptDue;
258
304
  for (let i = 0; i < fire.length; i++) fire[i]();
259
305
  }
260
306
 
261
307
  // Finished file jobs: the worker thread has already done the blocking
262
308
  // syscall, so all that happens on this (UI) thread is the drain.
263
- if (jobIds.length > 0) {
309
+ if (this.jobIds.length > 0) {
264
310
  const keptIds: number[] = [];
265
311
  const keptCbs: FsCallback[] = [];
266
312
  const doneIds: number[] = [];
267
313
  const doneCbs: FsCallback[] = [];
268
314
  const doneOk: boolean[] = [];
269
- for (let i = 0; i < jobIds.length; i++) {
270
- 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;
271
317
  if (st === JOB_PENDING) {
272
- keptIds.push(jobIds[i]);
273
- keptCbs.push(jobCbs[i]);
318
+ keptIds.push(this.jobIds[i]);
319
+ keptCbs.push(this.jobCbs[i]);
274
320
  } else {
275
- doneIds.push(jobIds[i]);
276
- doneCbs.push(jobCbs[i]);
321
+ doneIds.push(this.jobIds[i]);
322
+ doneCbs.push(this.jobCbs[i]);
277
323
  doneOk.push(st === JOB_OK);
278
324
  }
279
325
  }
280
- jobIds = keptIds;
281
- jobCbs = keptCbs;
326
+ this.jobIds = keptIds;
327
+ this.jobCbs = keptCbs;
282
328
  for (let i = 0; i < doneIds.length; i++) {
283
329
  // On failure the payload IS the error message, so one path serves both
284
330
  // outcomes. Nothing is decoded here: the job joins the drain queue and
285
331
  // its bytes are taken a slice at a time, under a time budget.
286
- drainIds.push(doneIds[i]);
287
- drainCbs.push(doneCbs[i]);
288
- drainOk.push(doneOk[i]);
289
- drainParts.push([]);
290
- drainOff.push(0);
291
- 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);
292
338
  }
293
339
  }
294
340
 
295
- drainSome();
296
- retick();
297
- idle();
298
- };
299
-
300
- // Filters cross as "Name|ext,ext|Name|ext" — the shim needs no JSON parser
301
- // for what is always a short, flat list.
302
- const encodeFilters = (filters: DialogFilter[] | undefined): string => {
303
- if (filters === undefined || filters.length === 0) return "";
304
- const parts: string[] = [];
305
- for (let i = 0; i < filters.length; i++) {
306
- parts.push(filters[i].name);
307
- parts.push(filters[i].extensions.join(","));
308
- }
309
- return parts.join("|");
310
- };
341
+ this.drainSome();
342
+ this.retick();
343
+ this.idle();
344
+ }
311
345
 
312
346
  // Both dialog kinds share one path: start the job, then let the same drain
313
347
  // that serves file I/O deliver the answer on a later turn.
314
- const startDialog = (
348
+ startDialog(
315
349
  kind: number,
316
350
  flags: number,
317
351
  title: string | undefined,
@@ -319,9 +353,9 @@ export function createApp(cfg: WindowConfig): JanelaApp {
319
353
  defaultName: string | undefined,
320
354
  filters: DialogFilter[] | undefined,
321
355
  cb: (paths: string[] | null, err?: string) => void,
322
- ): void => {
356
+ ): void {
323
357
  const id = wvDialog(
324
- h,
358
+ this.handle,
325
359
  kind,
326
360
  flags,
327
361
  title === undefined ? "" : title,
@@ -330,12 +364,12 @@ export function createApp(cfg: WindowConfig): JanelaApp {
330
364
  encodeFilters(filters),
331
365
  ) + 0;
332
366
  if (id < 0) {
333
- taskFns.push(() => cb(null, "EAGAIN: could not open a dialog"));
334
- wake();
367
+ this.taskFns.push(() => cb(null, "EAGAIN: could not open a dialog"));
368
+ this.wake();
335
369
  return;
336
370
  }
337
- jobIds.push(id);
338
- jobCbs.push((err, text) => {
371
+ this.jobIds.push(id);
372
+ this.jobCbs.push((err, text) => {
339
373
  if (err !== null) {
340
374
  cb(null, err);
341
375
  return;
@@ -343,147 +377,248 @@ export function createApp(cfg: WindowConfig): JanelaApp {
343
377
  // "null" is a cancel; anything else is a JSON array of paths.
344
378
  cb(JSON.parse(text) as string[] | null);
345
379
  });
346
- wake();
347
- };
348
-
349
- const app: JanelaApp = {
350
- handle: h,
351
- names: [],
352
- handlers: [],
353
-
354
- command: (name, handler) => {
355
- app.names.push(name);
356
- app.handlers.push(handler);
357
- },
358
-
359
- commandAsync: (name, handler) => {
360
- asyncNames.push(name);
361
- asyncHandlers.push(handler);
362
- },
363
-
364
- defer: (fn) => {
365
- taskFns.push(fn);
366
- wake();
367
- },
368
-
369
- sleep: (ms, fn) => {
370
- timerFns.push(fn);
371
- timerDue.push(Date.now() + (ms > 0 ? ms : 0));
372
- wake();
373
- },
374
-
375
- readFileAsync: (path, cb) => {
376
- const id = wvFsRead(h, path) + 0;
377
- if (id < 0) {
378
- app.defer(() => cb("EAGAIN: could not start a read of '" + path + "'", ""));
379
- return;
380
- }
381
- jobIds.push(id);
382
- jobCbs.push(cb);
383
- wake();
384
- },
385
-
386
- writeFileAsync: (path, data, cb) => {
387
- const id = wvFsWrite(h, path, data) + 0;
388
- if (id < 0) {
389
- app.defer(() => cb("EAGAIN: could not start a write of '" + path + "'"));
390
- 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
+ }
391
539
  }
392
- jobIds.push(id);
393
- // The write payload is empty on success; the shared callback shape just
394
- // ignores the text argument.
395
- jobCbs.push((err, _text) => cb(err));
396
- wake();
397
- },
398
-
399
- openFileDialog: (options, cb) => {
400
- let flags = 0;
401
- if (options.multiple === true) flags = flags + DLG_MULTIPLE;
402
- if (options.directory === true) flags = flags + DLG_DIRECTORY;
403
- startDialog(DLG_OPEN, flags, options.title, options.defaultPath, "",
404
- options.filters, (paths, err) => cb(paths, err));
405
- },
406
-
407
- saveFileDialog: (options, cb) => {
408
- startDialog(DLG_SAVE, 0, options.title, options.defaultPath,
409
- options.defaultName, options.filters, (paths, err) => {
410
- if (paths === null) {
411
- cb(null, err);
412
- 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;
413
549
  }
414
- cb(paths.length > 0 ? paths[0] : null, err);
415
- });
416
- },
417
-
418
- setTitle: (title) => {
419
- wvSetTitle(h, title);
420
- },
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
+ });
421
566
 
422
- setSize: (width, height, hint) => {
423
- wvSetSize(h, width, height, hint === undefined ? 0 : hint);
424
- },
567
+ wvBind(h, "__invoke");
568
+ wvSetHtml(h, html);
569
+ const rc = wvRun(h) + 0;
570
+ return rc;
571
+ }
572
+ }
425
573
 
426
- setFullscreen: (on) => {
427
- wvSetFullscreen(h, on ? 1 : 0);
428
- },
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
+ }
429
580
 
430
- emit: (event, payload) => {
431
- wvEval(
432
- h,
433
- "window.__wvEmit(" + JSON.stringify(event) + "," + encode(payload) + ");",
434
- );
435
- },
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
+ }
436
596
 
437
- quit: () => {
438
- 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);
439
612
  },
613
+ );
614
+ }
440
615
 
441
- run: (html) => {
442
- // Both handlers are retained: registered once here, called by the shim
443
- // for as long as the window is open.
444
- wvOnTick(h, turn);
445
- wvOnInvoke(h, (req) => {
446
- const env = JSON.parse(req) as string[];
447
- const cmd = env[0];
448
- const args = JSON.parse(env[1]) as unknown;
449
- for (let i = 0; i < app.names.length; i++) {
450
- if (app.names[i] === cmd) {
451
- wvReply(h, encode(app.handlers[i](args)));
452
- return 0;
453
- }
454
- }
455
- for (let i = 0; i < asyncNames.length; i++) {
456
- if (asyncNames[i] === cmd) {
457
- // Park the page's promise: the shim holds this call's id and
458
- // answers it when resolve/reject reaches wvResolve, whenever
459
- // that is. Meanwhile the loop is free to serve other calls.
460
- const id = wvDefer(h) + 0;
461
- if (id < 0) {
462
- wvReply(h, encode("cannot defer command: " + cmd));
463
- return 1;
464
- }
465
- const settle = (status: number): ((value: unknown) => void) => {
466
- let done = false;
467
- return (value: unknown) => {
468
- if (done) return; // a promise settles once
469
- done = true;
470
- wvReply(h, encode(value));
471
- wvResolve(h, id, status);
472
- };
473
- };
474
- asyncHandlers[i](args, settle(0), settle(1));
475
- return 0;
476
- }
477
- }
478
- wvReply(h, encode("unknown command: " + cmd));
479
- return 1; // rejects the frontend promise
480
- });
481
-
482
- wvBind(h, "__invoke");
483
- wvSetHtml(h, html);
484
- const rc = wvRun(h) + 0;
485
- return rc;
486
- },
487
- };
488
- 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);
489
624
  }