janela 0.2.0 → 0.3.1

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.
Files changed (38) hide show
  1. package/README.md +71 -13
  2. package/bin/janela.mjs +355 -40
  3. package/package.json +1 -1
  4. package/runtime/janela.ts +280 -37
  5. package/shim/wvshim.cc +593 -70
  6. package/templates/index.html +17 -0
  7. package/templates/main.ts +32 -0
  8. package/templates/react/deps.json +4 -0
  9. package/templates/react/files/index.html +11 -0
  10. package/templates/react/files/janela.conf.json +10 -0
  11. package/templates/react/files/src/App.css +3 -0
  12. package/templates/react/files/src/App.jsx +38 -0
  13. package/templates/react/files/src/main.jsx +9 -0
  14. package/templates/react/files/src-host/main.ts +42 -0
  15. package/templates/react/files/vite.config.js +6 -0
  16. package/templates/solid/deps.json +4 -0
  17. package/templates/solid/files/index.html +11 -0
  18. package/templates/solid/files/janela.conf.json +10 -0
  19. package/templates/solid/files/src/App.css +3 -0
  20. package/templates/solid/files/src/App.jsx +35 -0
  21. package/templates/solid/files/src/main.jsx +4 -0
  22. package/templates/solid/files/src-host/main.ts +42 -0
  23. package/templates/solid/files/vite.config.js +6 -0
  24. package/templates/svelte/deps.json +7 -0
  25. package/templates/svelte/files/index.html +11 -0
  26. package/templates/svelte/files/janela.conf.json +10 -0
  27. package/templates/svelte/files/src/App.svelte +33 -0
  28. package/templates/svelte/files/src/main.js +4 -0
  29. package/templates/svelte/files/src-host/main.ts +42 -0
  30. package/templates/svelte/files/svelte.config.js +3 -0
  31. package/templates/svelte/files/vite.config.js +6 -0
  32. package/templates/vue/deps.json +4 -0
  33. package/templates/vue/files/index.html +11 -0
  34. package/templates/vue/files/janela.conf.json +10 -0
  35. package/templates/vue/files/src/App.vue +39 -0
  36. package/templates/vue/files/src/main.js +4 -0
  37. package/templates/vue/files/src-host/main.ts +42 -0
  38. package/templates/vue/files/vite.config.js +6 -0
package/runtime/janela.ts CHANGED
@@ -28,12 +28,34 @@ declare function wvTickStart(h: number, intervalMs: number): number;
28
28
  declare function wvTickStop(h: number): number;
29
29
  declare function wvFsRead(h: number, path: string): number;
30
30
  declare function wvFsWrite(h: number, path: string, data: string): number;
31
- declare function wvFsStatus(h: number, id: number): number;
32
- declare function wvFsTake(h: number, id: number, sink: (text: string) => void): number;
33
- declare function wvFsFree(h: number, id: number): number;
31
+ declare function wvJobStatus(h: number, id: number): number;
32
+ declare function wvJobSize(h: number, id: number): number;
33
+ declare function wvJobTakeAt(
34
+ h: number,
35
+ id: number,
36
+ offset: number,
37
+ maxBytes: number,
38
+ sink: (text: string) => void,
39
+ ): number;
40
+ declare function wvJobFree(h: number, id: number): number;
41
+ declare function wvDialog(
42
+ h: number,
43
+ kind: number,
44
+ flags: number,
45
+ title: string,
46
+ defaultPath: string,
47
+ defaultName: string,
48
+ filters: string,
49
+ ): number;
50
+ declare function wvSetFullscreen(h: number, on: number): number;
34
51
 
35
- const FS_PENDING = 0;
36
- const FS_OK = 1;
52
+ const JOB_PENDING = 0;
53
+ const JOB_OK = 1;
54
+
55
+ const DLG_OPEN = 0;
56
+ const DLG_SAVE = 1;
57
+ const DLG_MULTIPLE = 1;
58
+ const DLG_DIRECTORY = 2;
37
59
 
38
60
  // Injected into every page before it loads (webview_init).
39
61
  const BOOTSTRAP =
@@ -81,6 +103,32 @@ export type AsyncCommandHandler = (
81
103
  */
82
104
  export type FsCallback = (err: string | null, text: string) => void;
83
105
 
106
+ /** A named group of extensions offered in a dialog's file-type popup. */
107
+ export interface DialogFilter {
108
+ name: string;
109
+ /** Bare extensions, no dot and no glob: ["png", "jpg"]. */
110
+ extensions: string[];
111
+ }
112
+
113
+ export interface OpenDialogOptions {
114
+ title?: string;
115
+ /** Directory the dialog opens in. */
116
+ defaultPath?: string;
117
+ /** Allow picking more than one entry. */
118
+ multiple?: boolean;
119
+ /** Pick directories instead of files. Not supported on Windows. */
120
+ directory?: boolean;
121
+ filters?: DialogFilter[];
122
+ }
123
+
124
+ export interface SaveDialogOptions {
125
+ title?: string;
126
+ defaultPath?: string;
127
+ /** Filename pre-filled in the name field. */
128
+ defaultName?: string;
129
+ filters?: DialogFilter[];
130
+ }
131
+
84
132
  export interface WindowConfig {
85
133
  title: string;
86
134
  width: number;
@@ -113,6 +161,30 @@ export interface JanelaApp {
113
161
  data: string,
114
162
  cb: (err: string | null) => void,
115
163
  ) => void;
164
+ /**
165
+ * Show the native "open" dialog. `cb` gets the chosen paths, or null if the
166
+ * user cancelled. The modal runs on a later turn of the UI thread, so
167
+ * calling this from inside a command does not block that command's reply —
168
+ * pair it with commandAsync when the page is waiting for the result.
169
+ */
170
+ openFileDialog: (
171
+ options: OpenDialogOptions,
172
+ cb: (paths: string[] | null, err?: string) => void,
173
+ ) => void;
174
+ /** Show the native "save" dialog; cb gets the path, or null on cancel. */
175
+ saveFileDialog: (
176
+ options: SaveDialogOptions,
177
+ cb: (path: string | null, err?: string) => void,
178
+ ) => void;
179
+ /** Change the window title at any time, not just at startup. */
180
+ setTitle: (title: string) => void;
181
+ /**
182
+ * Resize the window. `hint` is webview's sizing hint: 0 none, 1 minimum,
183
+ * 2 maximum, 3 fixed.
184
+ */
185
+ setSize: (width: number, height: number, hint?: number) => void;
186
+ /** Enter or leave fullscreen. */
187
+ setFullscreen: (on: boolean) => void;
116
188
  /** Fire an event into the page; the payload is delivered as a value. */
117
189
  emit: (event: string, payload: unknown) => void;
118
190
  /** Close the window and make run() return. */
@@ -145,23 +217,115 @@ export function createApp(cfg: WindowConfig): JanelaApp {
145
217
  let taskFns: (() => void)[] = [];
146
218
  let timerFns: (() => void)[] = [];
147
219
  let timerDue: number[] = [];
148
- let fsIds: number[] = [];
149
- let fsCbs: FsCallback[] = [];
220
+ let jobIds: number[] = [];
221
+ let jobCbs: FsCallback[] = [];
150
222
  let ticking = false;
151
223
 
224
+ // ---- the drain -----------------------------------------------------------
225
+ // A finished job's bytes still have to be decoded into a TypeScript string,
226
+ // and that cost is proportional to the payload: taking a 100 MB file in one
227
+ // call froze the window for ~240 ms. So a finished job moves here and is
228
+ // decoded a slice at a time, giving the run loop the thread back between
229
+ // slices — total work is unchanged, but no single turn carries much of it.
230
+ //
231
+ // The budget is wall-clock rather than a byte count on purpose: a fixed
232
+ // chunk size fixes the WORST turn but also caps throughput (128 KB per 8 ms
233
+ // tick would cap reads at ~16 MB/s), whereas a time budget spends whatever
234
+ // the machine can do in the time available.
235
+ const DRAIN_BUDGET_MS = 4; // ≈ a quarter of a 60fps frame
236
+ const DRAIN_SLICE = 131072; // 128 KB — granularity within the budget
237
+ let drainIds: number[] = [];
238
+ let drainCbs: FsCallback[] = [];
239
+ let drainOk: boolean[] = [];
240
+ let drainParts: string[][] = [];
241
+ let drainOff: number[] = [];
242
+ let drainSize: number[] = [];
243
+
244
+ // Tick interval: 8 ms is plenty for timers and task chains, but while a
245
+ // payload is draining the loop is doing real work every turn, and waiting
246
+ // 8 ms between 4 ms slices would halve throughput for no benefit. So the
247
+ // ticker runs tighter for as long as there is a payload in flight.
248
+ const TICK_IDLE_MS = 8;
249
+ const TICK_DRAIN_MS = 4;
250
+ let tickMs = TICK_IDLE_MS;
251
+
252
+ const retick = (): void => {
253
+ const want = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
254
+ if (!ticking || want === tickMs) return;
255
+ tickMs = want;
256
+ wvTickStart(h, want);
257
+ };
258
+
152
259
  const wake = (): void => {
153
260
  if (ticking) return;
154
261
  ticking = true;
155
- wvTickStart(h, 8);
262
+ tickMs = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
263
+ wvTickStart(h, tickMs);
156
264
  };
157
265
 
158
266
  const idle = (): void => {
159
267
  if (!ticking) return;
160
- if (taskFns.length > 0 || timerFns.length > 0 || fsIds.length > 0) return;
268
+ if (
269
+ taskFns.length > 0 ||
270
+ timerFns.length > 0 ||
271
+ jobIds.length > 0 ||
272
+ drainIds.length > 0
273
+ ) {
274
+ return;
275
+ }
161
276
  ticking = false;
162
277
  wvTickStop(h);
163
278
  };
164
279
 
280
+ // Decode as much of the pending payloads as the budget allows, then yield.
281
+ // Slices are taken from one job at a time so a big read finishes promptly
282
+ // rather than every concurrent read finishing slowly.
283
+ const drainSome = (): void => {
284
+ if (drainIds.length === 0) return;
285
+ const started = Date.now() + 0;
286
+
287
+ while (drainIds.length > 0) {
288
+ let chunk = "";
289
+ const taken =
290
+ wvJobTakeAt(h, drainIds[0], drainOff[0], DRAIN_SLICE, (text) => {
291
+ chunk = text;
292
+ }) + 0;
293
+
294
+ // A negative count means the job vanished; treat the payload as final
295
+ // rather than spinning on it forever.
296
+ if (taken > 0) {
297
+ drainParts[0].push(chunk);
298
+ drainOff[0] = drainOff[0] + taken;
299
+ }
300
+
301
+ if (taken <= 0 || drainOff[0] >= drainSize[0]) {
302
+ // Joining is one unavoidable O(n) copy: the callback is handed a
303
+ // single string, so the whole payload must be materialised once.
304
+ const payload = drainParts[0].join("");
305
+ const cb = drainCbs[0];
306
+ const ok = drainOk[0];
307
+ wvJobFree(h, drainIds[0]);
308
+
309
+ drainIds = drainIds.slice(1);
310
+ drainCbs = drainCbs.slice(1);
311
+ drainOk = drainOk.slice(1);
312
+ drainParts = drainParts.slice(1);
313
+ drainOff = drainOff.slice(1);
314
+ drainSize = drainSize.slice(1);
315
+
316
+ if (ok) {
317
+ cb(null, payload);
318
+ } else {
319
+ cb(payload, "");
320
+ }
321
+ // User code just ran and may have taken a while; re-check the budget
322
+ // before starting another payload.
323
+ }
324
+
325
+ if (Date.now() - started >= DRAIN_BUDGET_MS) return;
326
+ }
327
+ };
328
+
165
329
  // One turn of the loop: every task queued so far, plus every due timer.
166
330
  // Tasks queued *by* this turn wait for the next one, so a defer() chain
167
331
  // yields to the UI between slices instead of starving it.
@@ -190,44 +354,92 @@ export function createApp(cfg: WindowConfig): JanelaApp {
190
354
 
191
355
  // Finished file jobs: the worker thread has already done the blocking
192
356
  // syscall, so all that happens on this (UI) thread is the drain.
193
- if (fsIds.length > 0) {
357
+ if (jobIds.length > 0) {
194
358
  const keptIds: number[] = [];
195
359
  const keptCbs: FsCallback[] = [];
196
360
  const doneIds: number[] = [];
197
361
  const doneCbs: FsCallback[] = [];
198
362
  const doneOk: boolean[] = [];
199
- for (let i = 0; i < fsIds.length; i++) {
200
- const st = wvFsStatus(h, fsIds[i]) + 0;
201
- if (st === FS_PENDING) {
202
- keptIds.push(fsIds[i]);
203
- keptCbs.push(fsCbs[i]);
363
+ for (let i = 0; i < jobIds.length; i++) {
364
+ const st = wvJobStatus(h, jobIds[i]) + 0;
365
+ if (st === JOB_PENDING) {
366
+ keptIds.push(jobIds[i]);
367
+ keptCbs.push(jobCbs[i]);
204
368
  } else {
205
- doneIds.push(fsIds[i]);
206
- doneCbs.push(fsCbs[i]);
207
- doneOk.push(st === FS_OK);
369
+ doneIds.push(jobIds[i]);
370
+ doneCbs.push(jobCbs[i]);
371
+ doneOk.push(st === JOB_OK);
208
372
  }
209
373
  }
210
- fsIds = keptIds;
211
- fsCbs = keptCbs;
374
+ jobIds = keptIds;
375
+ jobCbs = keptCbs;
212
376
  for (let i = 0; i < doneIds.length; i++) {
213
- // On failure the payload IS the error message, so one take serves both
214
- // outcomes. The sink runs synchronously inside wvFsTake (the callback
215
- // is lifetime:"call"), so `payload` is set by the time it returns.
216
- let payload = "";
217
- wvFsTake(h, doneIds[i], (text) => {
218
- payload = text;
219
- });
220
- wvFsFree(h, doneIds[i]);
221
- if (doneOk[i]) {
222
- doneCbs[i](null, payload);
223
- } else {
224
- doneCbs[i](payload, "");
225
- }
377
+ // On failure the payload IS the error message, so one path serves both
378
+ // outcomes. Nothing is decoded here: the job joins the drain queue and
379
+ // its bytes are taken a slice at a time, under a time budget.
380
+ drainIds.push(doneIds[i]);
381
+ drainCbs.push(doneCbs[i]);
382
+ drainOk.push(doneOk[i]);
383
+ drainParts.push([]);
384
+ drainOff.push(0);
385
+ drainSize.push(wvJobSize(h, doneIds[i]) + 0);
226
386
  }
227
387
  }
388
+
389
+ drainSome();
390
+ retick();
228
391
  idle();
229
392
  };
230
393
 
394
+ // Filters cross as "Name|ext,ext|Name|ext" — the shim needs no JSON parser
395
+ // for what is always a short, flat list.
396
+ const encodeFilters = (filters: DialogFilter[] | undefined): string => {
397
+ if (filters === undefined || filters.length === 0) return "";
398
+ const parts: string[] = [];
399
+ for (let i = 0; i < filters.length; i++) {
400
+ parts.push(filters[i].name);
401
+ parts.push(filters[i].extensions.join(","));
402
+ }
403
+ return parts.join("|");
404
+ };
405
+
406
+ // Both dialog kinds share one path: start the job, then let the same drain
407
+ // that serves file I/O deliver the answer on a later turn.
408
+ const startDialog = (
409
+ kind: number,
410
+ flags: number,
411
+ title: string | undefined,
412
+ defaultPath: string | undefined,
413
+ defaultName: string | undefined,
414
+ filters: DialogFilter[] | undefined,
415
+ cb: (paths: string[] | null, err?: string) => void,
416
+ ): void => {
417
+ const id = wvDialog(
418
+ h,
419
+ kind,
420
+ flags,
421
+ title === undefined ? "" : title,
422
+ defaultPath === undefined ? "" : defaultPath,
423
+ defaultName === undefined ? "" : defaultName,
424
+ encodeFilters(filters),
425
+ ) + 0;
426
+ if (id < 0) {
427
+ taskFns.push(() => cb(null, "EAGAIN: could not open a dialog"));
428
+ wake();
429
+ return;
430
+ }
431
+ jobIds.push(id);
432
+ jobCbs.push((err, text) => {
433
+ if (err !== null) {
434
+ cb(null, err);
435
+ return;
436
+ }
437
+ // "null" is a cancel; anything else is a JSON array of paths.
438
+ cb(JSON.parse(text) as string[] | null);
439
+ });
440
+ wake();
441
+ };
442
+
231
443
  const app: JanelaApp = {
232
444
  handle: h,
233
445
  names: [],
@@ -260,8 +472,8 @@ export function createApp(cfg: WindowConfig): JanelaApp {
260
472
  app.defer(() => cb("EAGAIN: could not start a read of '" + path + "'", ""));
261
473
  return;
262
474
  }
263
- fsIds.push(id);
264
- fsCbs.push(cb);
475
+ jobIds.push(id);
476
+ jobCbs.push(cb);
265
477
  wake();
266
478
  },
267
479
 
@@ -271,13 +483,44 @@ export function createApp(cfg: WindowConfig): JanelaApp {
271
483
  app.defer(() => cb("EAGAIN: could not start a write of '" + path + "'"));
272
484
  return;
273
485
  }
274
- fsIds.push(id);
486
+ jobIds.push(id);
275
487
  // The write payload is empty on success; the shared callback shape just
276
488
  // ignores the text argument.
277
- fsCbs.push((err, _text) => cb(err));
489
+ jobCbs.push((err, _text) => cb(err));
278
490
  wake();
279
491
  },
280
492
 
493
+ openFileDialog: (options, cb) => {
494
+ let flags = 0;
495
+ if (options.multiple === true) flags = flags + DLG_MULTIPLE;
496
+ if (options.directory === true) flags = flags + DLG_DIRECTORY;
497
+ startDialog(DLG_OPEN, flags, options.title, options.defaultPath, "",
498
+ options.filters, (paths, err) => cb(paths, err));
499
+ },
500
+
501
+ saveFileDialog: (options, cb) => {
502
+ startDialog(DLG_SAVE, 0, options.title, options.defaultPath,
503
+ options.defaultName, options.filters, (paths, err) => {
504
+ if (paths === null) {
505
+ cb(null, err);
506
+ return;
507
+ }
508
+ cb(paths.length > 0 ? paths[0] : null, err);
509
+ });
510
+ },
511
+
512
+ setTitle: (title) => {
513
+ wvSetTitle(h, title);
514
+ },
515
+
516
+ setSize: (width, height, hint) => {
517
+ wvSetSize(h, width, height, hint === undefined ? 0 : hint);
518
+ },
519
+
520
+ setFullscreen: (on) => {
521
+ wvSetFullscreen(h, on ? 1 : 0);
522
+ },
523
+
281
524
  emit: (event, payload) => {
282
525
  wvEval(
283
526
  h,