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/README.md +87 -16
- package/api/index.d.ts +26 -6
- package/bin/janela.mjs +9 -1
- package/package.json +2 -2
- package/runtime/janela.ts +387 -266
- package/runtime/types.ts +15 -127
- package/templates/react/files/src-host/main.ts +23 -26
- package/templates/solid/files/src-host/main.ts +23 -26
- package/templates/svelte/files/src-host/main.ts +23 -26
- package/templates/vue/files/src-host/main.ts +23 -26
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
tickMs =
|
|
176
|
-
wvTickStart(
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
567
|
+
wvBind(h, "__invoke");
|
|
568
|
+
wvSetHtml(h, html);
|
|
569
|
+
const rc = wvRun(h) + 0;
|
|
570
|
+
return rc;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
439
573
|
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
}
|