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/README.md +149 -0
- package/api/index.d.ts +69 -4
- package/api/index.js +31 -2
- package/bin/janela.mjs +9 -1
- package/package.json +2 -2
- package/runtime/janela.ts +400 -265
- package/runtime/types.ts +58 -57
- package/templates/react/files/src/App.tsx +12 -6
- package/templates/react/files/src-host/main.ts +43 -18
- package/templates/solid/files/src/App.tsx +9 -4
- package/templates/solid/files/src-host/main.ts +43 -18
- package/templates/svelte/files/src/App.svelte +9 -4
- package/templates/svelte/files/src-host/main.ts +43 -18
- package/templates/vue/files/src/App.vue +13 -7
- package/templates/vue/files/src-host/main.ts +43 -18
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
tickMs =
|
|
162
|
-
wvTickStart(
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
567
|
+
wvBind(h, "__invoke");
|
|
568
|
+
wvSetHtml(h, html);
|
|
569
|
+
const rc = wvRun(h) + 0;
|
|
570
|
+
return rc;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
425
573
|
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
}
|