gesso-framework 0.6.20 → 0.6.21

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/dist/index.js CHANGED
@@ -1370,6 +1370,23 @@ function isInputMessage(message) {
1370
1370
  return INPUT_MESSAGE_TYPES.has(message.type);
1371
1371
  }
1372
1372
  /**
1373
+ * The inputs a person makes one at a time, whose frame is drawn as soon
1374
+ * as they are routed (`GessoRuntime.answerInput`). Pointer moves and the
1375
+ * wheel come in streams, and wait for the refresh that coalesces them.
1376
+ */
1377
+ const DISCRETE_INPUT_TYPES = /* @__PURE__ */ new Set([
1378
+ "pointerDown",
1379
+ "pointerUp",
1380
+ "keyDown",
1381
+ "beforeInput",
1382
+ "compositionUpdate",
1383
+ "compositionEnd",
1384
+ "paste"
1385
+ ]);
1386
+ function isDiscreteInputMessage(message) {
1387
+ return DISCRETE_INPUT_TYPES.has(message.type);
1388
+ }
1389
+ /**
1373
1390
  * Milliseconds since the Unix epoch, at `performance.now()`'s
1374
1391
  * resolution.
1375
1392
  *
@@ -1809,6 +1826,9 @@ var ShellService = class {
1809
1826
  /** Clipboard writes asked for and not yet answered, by the id sent with each. */
1810
1827
  copies = /* @__PURE__ */ new Map();
1811
1828
  nextCopyId = 1;
1829
+ /** Clipboard reads asked for and not yet answered, by the id sent with each. */
1830
+ reads = /* @__PURE__ */ new Map();
1831
+ nextReadId = 1;
1812
1832
  /** File requests asked for and not yet answered, by the id sent with each. */
1813
1833
  files = /* @__PURE__ */ new Map();
1814
1834
  nextFileId = 1;
@@ -1968,6 +1988,37 @@ var ShellService = class {
1968
1988
  this.copies.delete(id);
1969
1989
  resolve(copied);
1970
1990
  }
1991
+ /**
1992
+ * The text on the system clipboard, for a Paste chosen from a menu
1993
+ * rather than typed: a key's paste comes with its text, a menu's has
1994
+ * to ask. Null when there is none to give: the browser refused (it
1995
+ * wants a secure context, a focused document, and in some browsers
1996
+ * the person's say-so, which it asks for itself), or no shell is
1997
+ * installed. It never rejects.
1998
+ */
1999
+ readText() {
2000
+ const handler = this.handler;
2001
+ if (handler === void 0 || handler === null) return Promise.resolve(null);
2002
+ const id = this.nextReadId++;
2003
+ const settled = new Promise((resolve) => {
2004
+ this.reads.set(id, resolve);
2005
+ });
2006
+ handler({
2007
+ type: "clipboardRead",
2008
+ id
2009
+ });
2010
+ return settled;
2011
+ }
2012
+ /**
2013
+ * Called by the runtime with what a clipboard read found. Not for
2014
+ * applications. An unknown id is ignored, on `settlePopup`'s terms.
2015
+ */
2016
+ settleClipboardRead(id, text) {
2017
+ const resolve = this.reads.get(id);
2018
+ if (resolve === void 0) return;
2019
+ this.reads.delete(id);
2020
+ resolve(text);
2021
+ }
1971
2022
  /** Opens a URL in the user's browser, in a new tab or window. */
1972
2023
  openUrl(url) {
1973
2024
  this.handler?.({
@@ -2884,11 +2935,27 @@ const KEY_INPUT_TYPES = /* @__PURE__ */ new Set([
2884
2935
  *
2885
2936
  * Nothing here reads the textarea's content as truth except the
2886
2937
  * composition string, which is the one thing only the browser knows.
2938
+ *
2939
+ * A secret field (`EditingState.secret`) is edited through an
2940
+ * `<input type="password">` in the textarea's place, made the first
2941
+ * time one takes focus. Only a password input makes the browser and the
2942
+ * OS treat what is typed as a password: macOS turns on secure input, so
2943
+ * no other application can read the keys; nothing autocorrects, learns
2944
+ * or spell-checks it; a screen reader calls it a secure text field and
2945
+ * does not echo the characters. Every listener the textarea has, the
2946
+ * input has too. Its value is the state's text, which for a secret
2947
+ * field is already the mask, so the value reaches no element.
2887
2948
  */
2888
2949
  var EditingProxy = class {
2889
2950
  canvas;
2890
2951
  sink;
2891
2952
  textarea;
2953
+ /** The password input, once a secret field has had focus. */
2954
+ password = null;
2955
+ /** Whichever of the two the focused editable is edited through. */
2956
+ field;
2957
+ /** What `describe` was last given, so a swap of element carries it across. */
2958
+ described = [null];
2892
2959
  doc;
2893
2960
  view;
2894
2961
  state = null;
@@ -2904,14 +2971,24 @@ var EditingProxy = class {
2904
2971
  this.view = this.doc.defaultView;
2905
2972
  const textarea = this.doc.createElement("textarea");
2906
2973
  this.textarea = textarea;
2907
- textarea.setAttribute("aria-hidden", "true");
2908
- textarea.setAttribute("autocomplete", "off");
2909
- textarea.setAttribute("autocorrect", "off");
2910
- textarea.setAttribute("autocapitalize", "off");
2911
- textarea.setAttribute("spellcheck", "false");
2974
+ this.field = textarea;
2912
2975
  textarea.setAttribute("wrap", "off");
2913
- textarea.tabIndex = -1;
2914
- Object.assign(textarea.style, {
2976
+ const detachTextarea = this.prepare(textarea);
2977
+ const detachWindow = this.listenWindow();
2978
+ this.detach = () => {
2979
+ detachTextarea();
2980
+ detachWindow();
2981
+ };
2982
+ }
2983
+ /** Hides an element, puts it over the canvas and listens to it. */
2984
+ prepare(field) {
2985
+ field.setAttribute("aria-hidden", "true");
2986
+ field.setAttribute("autocomplete", "off");
2987
+ field.setAttribute("autocorrect", "off");
2988
+ field.setAttribute("autocapitalize", "off");
2989
+ field.setAttribute("spellcheck", "false");
2990
+ field.tabIndex = -1;
2991
+ Object.assign(field.style, {
2915
2992
  position: "fixed",
2916
2993
  left: "0px",
2917
2994
  top: "0px",
@@ -2928,16 +3005,62 @@ var EditingProxy = class {
2928
3005
  pointerEvents: "none",
2929
3006
  zIndex: "2147483647"
2930
3007
  });
2931
- this.doc.body.appendChild(textarea);
2932
- this.detach = this.listen();
3008
+ this.doc.body.appendChild(field);
3009
+ return this.listen(field);
3010
+ }
3011
+ /** The password input, made the first time a secret field needs it. */
3012
+ passwordField() {
3013
+ if (this.password === null) {
3014
+ const input = this.doc.createElement("input");
3015
+ input.setAttribute("type", "password");
3016
+ const detach = this.prepare(input);
3017
+ input.setAttribute("data-1p-ignore", "");
3018
+ input.setAttribute("data-lpignore", "true");
3019
+ this.password = input;
3020
+ const previous = this.detach;
3021
+ this.detach = () => {
3022
+ previous();
3023
+ detach();
3024
+ };
3025
+ }
3026
+ return this.password;
3027
+ }
3028
+ /** Whether an element is one this proxy edits through. */
3029
+ owns(element) {
3030
+ return element !== null && (element === this.textarea || element === this.password);
3031
+ }
3032
+ /**
3033
+ * Moves editing to the other element: the focused editable became, or
3034
+ * stopped being, a secret one. Focus follows if the old element had
3035
+ * it; the blur that causes is ours, not the person leaving the field.
3036
+ */
3037
+ swapTo(next) {
3038
+ const previous = this.field;
3039
+ const hadFocus = this.doc.activeElement === previous;
3040
+ this.field = next;
3041
+ this.composing = false;
3042
+ this.write(previous, null);
3043
+ this.write(next, ...this.described);
3044
+ if (previous === this.password) previous.value = "";
3045
+ if (hadFocus) {
3046
+ this.refocusing = true;
3047
+ try {
3048
+ next.focus({ preventScroll: true });
3049
+ } finally {
3050
+ this.refocusing = false;
3051
+ }
3052
+ }
2933
3053
  }
2934
3054
  /** True while an editable in the runtime has focus. */
2935
3055
  get active() {
2936
3056
  return this.state !== null;
2937
3057
  }
2938
- /** The hidden element, for tests. */
3058
+ /**
3059
+ * The hidden element editing goes through: the textarea, or the
3060
+ * password input while a secret field has focus. For tests.
3061
+ */
2939
3062
  get element() {
2940
- return this.textarea;
3063
+ return this.field;
2941
3064
  }
2942
3065
  /**
2943
3066
  * The runtime's editing state changed. Null means no editable has
@@ -2950,19 +3073,22 @@ var EditingProxy = class {
2950
3073
  this.state = state;
2951
3074
  if (state === null) {
2952
3075
  if (wasActive) {
2953
- if (this.doc.activeElement === this.textarea) {
2954
- this.textarea.blur();
3076
+ if (this.doc.activeElement === this.field) {
3077
+ this.field.blur();
2955
3078
  this.canvas.focus({ preventScroll: true });
2956
3079
  }
3080
+ if (this.password !== null) this.password.value = "";
2957
3081
  }
2958
3082
  return;
2959
3083
  }
3084
+ const wanted = state.secret === true ? this.passwordField() : this.textarea;
3085
+ if (wanted !== this.field) this.swapTo(wanted);
2960
3086
  this.position(state);
2961
3087
  this.mirror(state);
2962
- this.textarea.setAttribute("enterkeyhint", state.multiline ? "enter" : "done");
2963
- if (!wasActive || this.doc.activeElement !== this.textarea) {
3088
+ this.field.setAttribute("enterkeyhint", state.multiline ? "enter" : "done");
3089
+ if (!wasActive || this.doc.activeElement !== this.field) {
2964
3090
  const active = this.doc.activeElement;
2965
- if (active === null || active === this.doc.body || active === this.canvas || active === this.textarea) this.textarea.focus({ preventScroll: true });
3091
+ if (active === null || active === this.doc.body || active === this.canvas || this.owns(active)) this.field.focus({ preventScroll: true });
2966
3092
  }
2967
3093
  }
2968
3094
  /**
@@ -2977,8 +3103,8 @@ var EditingProxy = class {
2977
3103
  * IME with nothing to compose into.
2978
3104
  */
2979
3105
  focus() {
2980
- if (this.disposed || this.state === null || this.doc.activeElement === this.textarea) return;
2981
- this.textarea.focus({ preventScroll: true });
3106
+ if (this.disposed || this.state === null || this.doc.activeElement === this.field) return;
3107
+ this.field.focus({ preventScroll: true });
2982
3108
  }
2983
3109
  /**
2984
3110
  * Re-takes DOM focus from inside a user gesture, so a phone opens its
@@ -3010,15 +3136,15 @@ var EditingProxy = class {
3010
3136
  */
3011
3137
  raiseKeyboard() {
3012
3138
  if (this.disposed || this.state === null) return;
3013
- const textarea = this.textarea;
3014
- if (this.doc.activeElement !== textarea) {
3015
- textarea.focus({ preventScroll: true });
3139
+ const field = this.field;
3140
+ if (this.doc.activeElement !== field) {
3141
+ field.focus({ preventScroll: true });
3016
3142
  return;
3017
3143
  }
3018
3144
  this.refocusing = true;
3019
3145
  try {
3020
- textarea.blur();
3021
- textarea.focus({ preventScroll: true });
3146
+ field.blur();
3147
+ field.focus({ preventScroll: true });
3022
3148
  } finally {
3023
3149
  this.refocusing = false;
3024
3150
  }
@@ -3034,10 +3160,21 @@ var EditingProxy = class {
3034
3160
  * the person is really typing into. That the two cannot disagree is
3035
3161
  * the reason the field's semantics live here rather than on a second
3036
3162
  * element beside it.
3163
+ *
3164
+ * Nothing of the record's value is written: the element's own value
3165
+ * is the field's text (a secret field's mask), and that is what a
3166
+ * screen reader reads.
3037
3167
  */
3038
3168
  describe(record, activeDescendant, controls) {
3039
3169
  if (this.disposed) return;
3040
- const textarea = this.textarea;
3170
+ this.described = [
3171
+ record,
3172
+ activeDescendant,
3173
+ controls
3174
+ ];
3175
+ this.write(this.field, record, activeDescendant, controls);
3176
+ }
3177
+ write(textarea, record, activeDescendant, controls) {
3041
3178
  if (record === null) {
3042
3179
  textarea.setAttribute("aria-hidden", "true");
3043
3180
  for (const attribute of [
@@ -3075,6 +3212,7 @@ var EditingProxy = class {
3075
3212
  this.detach();
3076
3213
  this.state = null;
3077
3214
  this.textarea.remove();
3215
+ this.password?.remove();
3078
3216
  }
3079
3217
  /** Puts the element where the caret is, so the IME window opens there. */
3080
3218
  position(state) {
@@ -3082,7 +3220,7 @@ var EditingProxy = class {
3082
3220
  const height = Math.max(1, state.caret.height);
3083
3221
  const left = rect.left + Math.min(Math.max(0, state.caret.x), Math.max(0, rect.width - 1));
3084
3222
  const top = rect.top + Math.min(Math.max(0, state.caret.y), Math.max(0, rect.height - height));
3085
- const style = this.textarea.style;
3223
+ const style = this.field.style;
3086
3224
  style.left = `${left}px`;
3087
3225
  style.top = `${top}px`;
3088
3226
  style.height = `${height}px`;
@@ -3092,15 +3230,15 @@ var EditingProxy = class {
3092
3230
  /** Mirrors the runtime's text and selection, except while the IME owns the element. */
3093
3231
  mirror(state) {
3094
3232
  if (this.composing) return;
3095
- const textarea = this.textarea;
3233
+ const textarea = this.field;
3096
3234
  if (textarea.value !== state.text) textarea.value = state.text;
3097
3235
  const direction = state.selectionDirection ?? "forward";
3098
3236
  if (textarea.selectionStart !== state.selectionStart || textarea.selectionEnd !== state.selectionEnd || state.selectionEnd > state.selectionStart && textarea.selectionDirection !== direction) try {
3099
3237
  textarea.setSelectionRange(state.selectionStart, state.selectionEnd, direction);
3100
3238
  } catch {}
3101
3239
  }
3102
- listen() {
3103
- const textarea = this.textarea;
3240
+ /** Listens to one of the two elements; each has the same listeners. */
3241
+ listen(textarea) {
3104
3242
  const onBeforeInput = (event) => {
3105
3243
  const input = event;
3106
3244
  const type = input.inputType;
@@ -3127,42 +3265,19 @@ var EditingProxy = class {
3127
3265
  this.sink.compositionEnd(text);
3128
3266
  if (this.state !== null) this.mirror(this.state);
3129
3267
  };
3130
- const onPaste = (event) => {
3131
- const clipboard = event.clipboardData;
3132
- event.preventDefault();
3133
- const text = clipboard?.getData("text/plain") ?? "";
3134
- const html = clipboard?.getData("text/html") ?? "";
3135
- if (text.length > 0 || html.length > 0) this.sink.paste(text, html.length > 0 ? html : null);
3136
- };
3137
- /**
3138
- * A paste aimed at the canvas rather than at the field.
3139
- *
3140
- * When no editable has focus this proxy blurs and hands focus back
3141
- * to the canvas, so the browser fires `paste` there — and the
3142
- * listener above, which is on the textarea, never hears it. The
3143
- * text was dropped before it reached the application at all, which
3144
- * is half of why a grid could not be pasted into; the other half
3145
- * was `UiEditingController.paste` having nowhere to send it.
3146
- *
3147
- * The two listeners cannot both fire for one paste: the event goes
3148
- * to whichever element has focus, and only one of them ever does.
3149
- */
3150
- const onCanvasPaste = (event) => {
3151
- if (this.doc.activeElement === textarea) return;
3152
- onPaste(event);
3153
- };
3154
3268
  const onCopy = (event) => {
3155
3269
  const clipboard = event.clipboardData;
3156
3270
  const state = this.state;
3157
3271
  if (clipboard === null || state === null) return;
3158
3272
  event.preventDefault();
3273
+ if (state.secret === true) return;
3159
3274
  clipboard.setData("text/plain", state.clipboard ?? state.text.slice(state.selectionStart, state.selectionEnd));
3160
3275
  if (state.html !== void 0) clipboard.setData("text/html", state.html);
3161
3276
  };
3162
3277
  const onCut = (event) => {
3163
3278
  const state = this.state;
3164
3279
  onCopy(event);
3165
- if (state !== null && (state.clipboard !== void 0 ? state.clipboard.length > 0 : state.selectionEnd > state.selectionStart)) this.sink.beforeInput("deleteByCut", null);
3280
+ if (state !== null && state.secret !== true && (state.clipboard !== void 0 ? state.clipboard.length > 0 : state.selectionEnd > state.selectionStart)) this.sink.beforeInput("deleteByCut", null);
3166
3281
  };
3167
3282
  const onKeyDown = (event) => {
3168
3283
  this.sink.keyDown?.(event);
@@ -3173,45 +3288,83 @@ var EditingProxy = class {
3173
3288
  const onBlur = (event) => {
3174
3289
  if (this.refocusing) return;
3175
3290
  const related = event.relatedTarget;
3176
- if (this.state !== null && this.doc.hasFocus() && related !== this.canvas) this.sink.blur();
3177
- };
3178
- const onReposition = () => {
3179
- if (this.state !== null) this.position(this.state);
3291
+ if (this.state !== null && this.doc.hasFocus() && related !== this.canvas && !this.owns(related)) this.sink.blur();
3180
3292
  };
3181
3293
  textarea.addEventListener("beforeinput", onBeforeInput);
3182
3294
  textarea.addEventListener("compositionstart", onCompositionStart);
3183
3295
  textarea.addEventListener("input", onInput);
3184
3296
  textarea.addEventListener("compositionend", onCompositionEnd);
3185
- textarea.addEventListener("paste", onPaste);
3297
+ textarea.addEventListener("paste", this.onPaste);
3186
3298
  textarea.addEventListener("copy", onCopy);
3187
3299
  textarea.addEventListener("cut", onCut);
3188
3300
  textarea.addEventListener("keydown", onKeyDown);
3189
- this.canvas.addEventListener("paste", onCanvasPaste);
3190
3301
  textarea.addEventListener("keyup", onKeyUp);
3191
3302
  textarea.addEventListener("blur", onBlur);
3192
- this.view?.addEventListener("scroll", onReposition, {
3193
- capture: true,
3194
- passive: true
3195
- });
3196
- this.view?.addEventListener("resize", onReposition);
3197
3303
  return () => {
3198
3304
  textarea.removeEventListener("beforeinput", onBeforeInput);
3199
3305
  textarea.removeEventListener("compositionstart", onCompositionStart);
3200
3306
  textarea.removeEventListener("input", onInput);
3201
3307
  textarea.removeEventListener("compositionend", onCompositionEnd);
3202
- textarea.removeEventListener("paste", onPaste);
3308
+ textarea.removeEventListener("paste", this.onPaste);
3203
3309
  textarea.removeEventListener("copy", onCopy);
3204
3310
  textarea.removeEventListener("cut", onCut);
3205
3311
  textarea.removeEventListener("keydown", onKeyDown);
3206
- this.canvas.removeEventListener("paste", onCanvasPaste);
3207
3312
  textarea.removeEventListener("keyup", onKeyUp);
3208
3313
  textarea.removeEventListener("blur", onBlur);
3314
+ };
3315
+ }
3316
+ onPaste = (event) => {
3317
+ const clipboard = event.clipboardData;
3318
+ event.preventDefault();
3319
+ const text = clipboard?.getData("text/plain") ?? "";
3320
+ const html = clipboard?.getData("text/html") ?? "";
3321
+ if (text.length > 0 || html.length > 0) this.sink.paste(text, html.length > 0 ? html : null);
3322
+ };
3323
+ listenWindow() {
3324
+ /**
3325
+ * A paste aimed at the canvas rather than at the field.
3326
+ *
3327
+ * When no editable has focus this proxy blurs and hands focus back
3328
+ * to the canvas, so the browser fires `paste` there — and the
3329
+ * listener on the field's element never hears it. The
3330
+ * text was dropped before it reached the application at all, which
3331
+ * is half of why a grid could not be pasted into; the other half
3332
+ * was `UiEditingController.paste` having nowhere to send it.
3333
+ *
3334
+ * The two listeners cannot both fire for one paste: the event goes
3335
+ * to whichever element has focus, and only one of them ever does.
3336
+ */
3337
+ const onCanvasPaste = (event) => {
3338
+ if (this.owns(this.doc.activeElement)) return;
3339
+ this.onPaste(event);
3340
+ };
3341
+ const onReposition = () => {
3342
+ if (this.state !== null) this.position(this.state);
3343
+ };
3344
+ this.view?.addEventListener("scroll", onReposition, {
3345
+ capture: true,
3346
+ passive: true
3347
+ });
3348
+ this.view?.addEventListener("resize", onReposition);
3349
+ this.canvas.addEventListener("paste", onCanvasPaste);
3350
+ return () => {
3351
+ this.canvas.removeEventListener("paste", onCanvasPaste);
3209
3352
  this.view?.removeEventListener("scroll", onReposition, { capture: true });
3210
3353
  this.view?.removeEventListener("resize", onReposition);
3211
3354
  };
3212
3355
  }
3213
3356
  };
3214
3357
  /**
3358
+ * Reads the system clipboard's text from the main thread; null where
3359
+ * the browser has no async clipboard or refuses (no secure context, no
3360
+ * focus, or the person said no to its prompt). It never rejects.
3361
+ */
3362
+ function readClipboard() {
3363
+ const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
3364
+ if (clipboard === void 0 || typeof clipboard.readText !== "function") return Promise.resolve(null);
3365
+ return clipboard.readText().then((text) => text, () => null);
3366
+ }
3367
+ /**
3215
3368
  * Writes text to the system clipboard from the main thread, and answers
3216
3369
  * whether it got there. The async API needs a secure context and, in
3217
3370
  * some browsers, a recent user gesture; the `execCommand` fallback
@@ -4984,6 +5137,14 @@ var WorkerApp = class {
4984
5137
  }));
4985
5138
  return;
4986
5139
  }
5140
+ if (message.type === "clipboardRead") {
5141
+ readClipboard().then((text) => this.post({
5142
+ type: "clipboardReadResult",
5143
+ id: message.id,
5144
+ text
5145
+ }));
5146
+ return;
5147
+ }
4987
5148
  if (message.type === "openUrl") {
4988
5149
  openUrlWith(this.options.onOpenUrl, message.url);
4989
5150
  return;
@@ -6587,10 +6748,13 @@ var FocusService = class {
6587
6748
  }
6588
6749
  /**
6589
6750
  * Ends the innermost trap and returns focus to whatever held it when
6590
- * the trap was taken — the button that opened the dialog.
6751
+ * the trap was taken — the button that opened the dialog. Given the
6752
+ * node trapped in, only that trap ends, and nothing when it ended
6753
+ * already: a trap's subtree removed releases it, and a release after
6754
+ * that would otherwise end the trap under it.
6591
6755
  */
6592
- releaseTrap() {
6593
- this.run((manager) => manager.popScope());
6756
+ releaseTrap(scope) {
6757
+ this.run((manager) => manager.popScope(scope));
6594
6758
  }
6595
6759
  run(action) {
6596
6760
  const manager = this.manager;
@@ -7393,6 +7557,12 @@ const MAX_ROOT_COMPONENT_DEPTH = 32;
7393
7557
  */
7394
7558
  /** How many shell requests are held for a listener that has not attached, before the rest are dropped. */
7395
7559
  const HELD_SHELL_REQUESTS = 256;
7560
+ /** The longest last frame after which an input's frame is still drawn early: half a 60 Hz refresh. See `GessoRuntime.answerInput`. */
7561
+ const EARLY_FRAME_MAX_MS = 8;
7562
+ /** The least time between two early frames: about half a refresh at 60 Hz. See `GessoRuntime.answerInput`. */
7563
+ const EARLY_FRAME_GAP_MS = 8;
7564
+ /** How long after a discrete input a channel's patch counts as its answer, and is drawn early. See `GessoRuntime.answerInput`. */
7565
+ const ANSWER_WINDOW_MS = 500;
7396
7566
  var GessoRuntime = class {
7397
7567
  services;
7398
7568
  channels;
@@ -7438,6 +7608,12 @@ var GessoRuntime = class {
7438
7608
  constraints;
7439
7609
  pixelRatio;
7440
7610
  lastFrameMs = 0;
7611
+ /** The timer that draws an input's frame early; see `answerInput`. */
7612
+ earlyFrame = null;
7613
+ /** When the last early frame was drawn, on this thread's clock. */
7614
+ lastEarlyFrame = -Infinity;
7615
+ /** When the last discrete input was routed; see `answerInput`. */
7616
+ lastDiscreteInput = -Infinity;
7441
7617
  /** The display time the frame being drawn is for; see `FrameMetrics.time`. */
7442
7618
  frameTime = null;
7443
7619
  /** When the frame's first phase began, before its changes were collected; see `FrameMetrics.durationMs`. */
@@ -7789,7 +7965,10 @@ var GessoRuntime = class {
7789
7965
  */
7790
7966
  deferPatchesFrom(sources) {
7791
7967
  this.replicas = sources;
7792
- for (const source of sources) source.deferPatches(() => this.scheduler.notifyDirty());
7968
+ for (const source of sources) source.deferPatches(() => {
7969
+ this.scheduler.notifyDirty();
7970
+ if (now() - this.lastDiscreteInput < ANSWER_WINDOW_MS) this.drawEarly();
7971
+ });
7793
7972
  }
7794
7973
  /** Starts the frame scheduler. */
7795
7974
  start() {
@@ -7964,6 +8143,51 @@ var GessoRuntime = class {
7964
8143
  this.inputLatency.mark(at, this.scheduler.framePending);
7965
8144
  }
7966
8145
  /**
8146
+ * Draws the frame a discrete input armed (a key, a character typed, a
8147
+ * paste, a press) as soon as the input has been routed, rather than at
8148
+ * the next refresh.
8149
+ *
8150
+ * Waiting for the refresh is what typing cost. With keys at random
8151
+ * points in the frame, a key waited half a refresh on average and
8152
+ * nearly a whole one at worst before its frame began, so keystroke to
8153
+ * frame was p50 12.5 ms and p95 20.7 ms for about 5 ms of work:
8154
+ * a quarter of all keys over a frame at 60 Hz. A frame drawn in its
8155
+ * own task is committed when the task ends, and shown at the next
8156
+ * refresh the compositor has not yet taken, which is never later than
8157
+ * the frame the refresh would have drawn.
8158
+ *
8159
+ * It runs in a task of its own, a zero timer, not in the input's: a
8160
+ * key arrives as `keyDown` and then `beforeInput`, two messages posted
8161
+ * together, and one frame for both is the point. Continuous input
8162
+ * (pointer moves, the wheel) is left to the refresh, which already
8163
+ * coalesces it. Two guards keep it from costing more than it saves:
8164
+ * nothing early while the last frame took more than half a refresh,
8165
+ * when a second frame in the same refresh would take time the next
8166
+ * one needs; and no early frame within `EARLY_FRAME_GAP_MS` of the
8167
+ * last, so a burst of keys is still one frame per refresh or so.
8168
+ *
8169
+ * The answer to an input often comes later, from another thread: quick
8170
+ * open's results, a completion list. A channel's patch that arrives
8171
+ * within `ANSWER_WINDOW_MS` of a discrete input is drawn early the same
8172
+ * way (see `deferPatchesFrom`), so the answer does not wait for a
8173
+ * refresh either.
8174
+ */
8175
+ answerInput() {
8176
+ this.lastDiscreteInput = now();
8177
+ this.drawEarly();
8178
+ }
8179
+ /** Draws the pending frame in a task of its own, if the guards in `answerInput` allow. */
8180
+ drawEarly() {
8181
+ if (!this.started || !this.visible || this.earlyFrame !== null || !this.scheduler.framePending) return;
8182
+ if (this.lastFrameMs > EARLY_FRAME_MAX_MS || now() - this.lastEarlyFrame < EARLY_FRAME_GAP_MS) return;
8183
+ this.earlyFrame = setTimeout(() => {
8184
+ this.earlyFrame = null;
8185
+ if (!this.started || !this.scheduler.framePending) return;
8186
+ this.lastEarlyFrame = now();
8187
+ this.scheduler.flush(now());
8188
+ }, 0);
8189
+ }
8190
+ /**
7967
8191
  * Turns the layout inspector on or off. While on, every frame paints
7968
8192
  * the hovered node's boxes and the measure heatmap over the scene, and
7969
8193
  * the inspect listener receives the hovered node's explanation.
@@ -8376,6 +8600,10 @@ var GessoRuntime = class {
8376
8600
  settleClipboard(id, copied) {
8377
8601
  this.services.get(ShellService).settleClipboard(id, copied);
8378
8602
  }
8603
+ /** Reports what a clipboard read found, settling the promise `ShellService.readText` returned. */
8604
+ settleClipboardRead(id, text) {
8605
+ this.services.get(ShellService).settleClipboardRead(id, text);
8606
+ }
8379
8607
  /**
8380
8608
  * Reports what the shell found in `localStorage`, settling the
8381
8609
  * promise `ShellService.requestStorage` returned.
@@ -8685,6 +8913,10 @@ var GessoRuntime = class {
8685
8913
  if (node === void 0 || !this.focusManager.focus(node)) this.focusManager.blur();
8686
8914
  }
8687
8915
  dispose() {
8916
+ if (this.earlyFrame !== null) {
8917
+ clearTimeout(this.earlyFrame);
8918
+ this.earlyFrame = null;
8919
+ }
8688
8920
  if (this.scrollbarTimer !== null) {
8689
8921
  clearTimeout(this.scrollbarTimer);
8690
8922
  this.scrollbarTimer = null;
@@ -10571,6 +10803,11 @@ var GessoApp = class {
10571
10803
  else if (request.type === "storage") this.runtime.settleStorage(request.id, shellStorageDenied());
10572
10804
  else if (request.type === "file") this.runtime.settleFile(request.id, shellFilesUnsupported("There is no document to reach files through."));
10573
10805
  else if (request.type === "clipboard") this.runtime.settleClipboard(request.id, false);
10806
+ else if (request.type === "clipboardRead") this.runtime.settleClipboardRead(request.id, null);
10807
+ return;
10808
+ }
10809
+ if (request.type === "clipboardRead") {
10810
+ readClipboard().then((text) => this.runtime.settleClipboardRead(request.id, text));
10574
10811
  return;
10575
10812
  }
10576
10813
  if (request.type === "clipboard") {
@@ -11785,6 +12022,9 @@ var RenderWorkerApp = class {
11785
12022
  case "clipboardResult":
11786
12023
  runtime.settleClipboard(message.id, message.copied);
11787
12024
  break;
12025
+ case "clipboardReadResult":
12026
+ runtime.settleClipboardRead(message.id, message.text);
12027
+ break;
11788
12028
  case "storageResult":
11789
12029
  runtime.settleStorage(message.id, message.result);
11790
12030
  break;
@@ -11801,6 +12041,7 @@ var RenderWorkerApp = class {
11801
12041
  runtime.setFullscreen(message.active);
11802
12042
  return;
11803
12043
  case "visibility":
12044
+ this.clock?.setHidden(!message.visible);
11804
12045
  runtime.setVisible(message.visible);
11805
12046
  break;
11806
12047
  case "inspector":
@@ -11829,7 +12070,10 @@ var RenderWorkerApp = class {
11829
12070
  this.channels = void 0;
11830
12071
  this.runtime = void 0;
11831
12072
  }
11832
- if (this.runtime !== void 0 && isInputMessage(message)) this.runtime.noteInput(message.at);
12073
+ if (this.runtime !== void 0 && isInputMessage(message)) {
12074
+ this.runtime.noteInput(message.at);
12075
+ if (isDiscreteInputMessage(message)) this.runtime.answerInput();
12076
+ }
11833
12077
  }
11834
12078
  /**
11835
12079
  * Swaps the `APPLICATION_WORKER` placeholder for the shell's port.
@@ -11934,6 +12178,10 @@ var RenderWorkerApp = class {
11934
12178
  id: request.id,
11935
12179
  text: request.text
11936
12180
  });
12181
+ else if (request.type === "clipboardRead") this.host.postMessage({
12182
+ type: "clipboardRead",
12183
+ id: request.id
12184
+ });
11937
12185
  else if (request.type === "openUrl") this.host.postMessage({
11938
12186
  type: "openUrl",
11939
12187
  url: request.url