@hex1b/web-terminal 0.1.0 → 0.166.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.
Files changed (50) hide show
  1. package/README.md +314 -11
  2. package/dist/backend-selection.d.ts +7 -0
  3. package/dist/backend-selection.d.ts.map +1 -0
  4. package/dist/backend-selection.js +23 -0
  5. package/dist/backend-selection.js.map +1 -0
  6. package/dist/hyperlinks.d.ts +10 -0
  7. package/dist/hyperlinks.d.ts.map +1 -0
  8. package/dist/hyperlinks.js +28 -0
  9. package/dist/hyperlinks.js.map +1 -0
  10. package/dist/mouse-input.d.ts +3 -0
  11. package/dist/mouse-input.d.ts.map +1 -1
  12. package/dist/mouse-input.js +69 -2
  13. package/dist/mouse-input.js.map +1 -1
  14. package/dist/protocol.d.ts +1 -0
  15. package/dist/protocol.d.ts.map +1 -1
  16. package/dist/protocol.js +40 -0
  17. package/dist/protocol.js.map +1 -1
  18. package/dist/render-backend.d.ts +31 -0
  19. package/dist/render-backend.d.ts.map +1 -0
  20. package/dist/render-backend.js +5 -0
  21. package/dist/render-backend.js.map +1 -0
  22. package/dist/renderer-options.d.ts +3 -0
  23. package/dist/renderer-options.d.ts.map +1 -0
  24. package/dist/renderer-options.js +6 -0
  25. package/dist/renderer-options.js.map +1 -0
  26. package/dist/renderer.d.ts +12 -24
  27. package/dist/renderer.d.ts.map +1 -1
  28. package/dist/renderer.js +35 -173
  29. package/dist/renderer.js.map +1 -1
  30. package/dist/terminal-worker.js +18 -10
  31. package/dist/terminal-worker.js.map +1 -1
  32. package/dist/types.d.ts +77 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js.map +1 -1
  35. package/dist/web-terminal.d.ts +8 -1
  36. package/dist/web-terminal.d.ts.map +1 -1
  37. package/dist/web-terminal.js +130 -17
  38. package/dist/web-terminal.js.map +1 -1
  39. package/dist/webgl2-backend.d.ts +38 -0
  40. package/dist/webgl2-backend.d.ts.map +1 -0
  41. package/dist/webgl2-backend.js +456 -0
  42. package/dist/webgl2-backend.js.map +1 -0
  43. package/dist/webgpu-backend.d.ts +24 -0
  44. package/dist/webgpu-backend.d.ts.map +1 -0
  45. package/dist/webgpu-backend.js +234 -0
  46. package/dist/webgpu-backend.js.map +1 -0
  47. package/dist/wire-types.d.ts +20 -2
  48. package/dist/wire-types.d.ts.map +1 -1
  49. package/dist/wire-types.js.map +1 -1
  50. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @hex1b/web-terminal
2
2
 
3
- The first-party WebGPU browser terminal for Hex1b. It renders server-authoritative
3
+ The first-party GPU-rendered browser terminal for Hex1b. It renders server-authoritative
4
4
  cells and graphics in a module worker, with local input routing, producer-backed
5
5
  history and selection, clipboard actions, and primary/secondary view sizing.
6
6
  There are no runtime package dependencies.
@@ -51,12 +51,89 @@ terminal.focus();
51
51
  or disposes a mounted view. Disposal removes only the appended element and its
52
52
  connection, not the container or server-side shared terminal.
53
53
 
54
+ ### Connection closure and workload completion
55
+
56
+ Use `onClose(details)` to observe the browser's actual WebSocket close event.
57
+ `TerminalCloseDetails` contains readonly `code`, `reason`, and `wasClean` fields.
58
+ The callback runs once with the view already disconnected, **even if the socket
59
+ closes before the first HWT frame or authoritative HMP peer state**. A pending
60
+ mount rejects after the callback, so capture any host state before calling mount.
61
+ The details object is frozen. Reasons are untrusted text; do not render them as HTML.
62
+
63
+ ```ts
64
+ import { WebTerminal, type TerminalCloseDetails } from "@hex1b/web-terminal";
65
+
66
+ const container = document.getElementById("terminal");
67
+ if (!container) throw new Error("Missing terminal container");
68
+ let closed: TerminalCloseDetails | undefined;
69
+ try {
70
+ const terminal = await WebTerminal.mount(container, {
71
+ url: "/ws/terminal",
72
+ onClose(details) {
73
+ closed = details;
74
+ console.log("View closed", details.code, details.reason, details.wasClean);
75
+ }
76
+ });
77
+ terminal.focus();
78
+ } catch (error) {
79
+ // closed is set for a transport close, but not for local initialization failure.
80
+ console.error("Mount failed", closed, error);
81
+ }
82
+ ```
83
+
84
+ Transport loss is **not workload completion**. Code 1006 means the browser did not
85
+ receive a close frame; even code 1000 and `wasClean: true` only describe transport
86
+ closure, not successful process exit. Hosts can define an application close-code
87
+ contract and send it when their authoritative producer reports completion,
88
+ including before any HWT frame exists. Hex1b does not assign workload meaning to
89
+ close codes or reason strings. HTTP upgrade failures generally surface as 1006;
90
+ browsers do not expose the rejected HTTP response body or status through this API.
91
+
92
+ The client never retries automatically. The host decides whether to mount a new
93
+ view after transport loss or leave an ended tab/dialog visible. Abort, explicit
94
+ disposal, mount timeout, and local initialization/renderer failures do not
95
+ synthesize `onClose`; no callback runs after disposal. Callback exceptions are
96
+ reported to the host, not swallowed or retried, and do not leave mounting pending.
97
+ The API does not retain the producer after exit or promise a final rendered frame.
98
+
54
99
  ### Browser and deployment requirements
55
100
 
56
- Use HTTPS or localhost and a browser with WebGPU, module workers, transferable
57
- OffscreenCanvas, worker animation frames, ResizeObserver, and CSS Font Loading.
58
- Clipboard access also requires browser permission and, for relevant actions, a
59
- user gesture. There is no Canvas2D terminal-rendering fallback.
101
+ Use a browser with WebGPU or WebGL2, module workers, transferable OffscreenCanvas,
102
+ worker animation frames, ResizeObserver, and CSS Font Loading. WebGPU requires
103
+ HTTPS or localhost; WebGL2 rendering also works on ordinary HTTP origins.
104
+ Clipboard API access still requires a secure context, browser permission and,
105
+ for relevant actions, a user gesture. There is no Canvas2D terminal-rendering fallback.
106
+ Renderer selection does not change transport security: use HTTPS/WSS to protect
107
+ terminal input and output.
108
+
109
+ ### Renderer selection
110
+
111
+ Set the mount-time `renderer` option to `"auto"` (the default), `"webgpu"`, or
112
+ `"webgl2"`. Auto prefers WebGPU and uses WebGL2 if the secure context, API,
113
+ adapter, device acquisition, or presentation context is unavailable. Explicit
114
+ modes require that backend and report an error rather than falling back.
115
+ Shader, font, validation, and unexpected initialization errors are not
116
+ compatibility fallbacks. Runtime GPU/context loss terminates the view with an
117
+ error; it does not switch backends behind the caller's back.
118
+
119
+ ```ts
120
+ const terminal = await WebTerminal.mount(container, {
121
+ url: "/ws/terminal",
122
+ renderer: "webgl2", // Use WebGL2 even if WebGPU is available.
123
+ onStats(stats) {
124
+ console.log(stats.renderer, stats.rendererFallbackReason);
125
+ }
126
+ });
127
+ ```
128
+
129
+ `stats.renderer` identifies the active backend after initialization.
130
+ `stats.rendererFallbackReason` explains an automatic fallback and is absent
131
+ for explicit selections and successful WebGPU initialization. Both backends
132
+ share glyph rasterization, frame preparation, clipping, and image ordering.
133
+ WebGPU preference is not a performance guarantee; compare representative
134
+ workloads on your target browsers and devices.
135
+
136
+ ### Module and worker deployment
60
137
 
61
138
  The package contains browser ES modules, not a single bundle. For bare static
62
139
  hosting, copy **all of `dist/`**, preserving its directory structure, and import
@@ -100,10 +177,14 @@ workers, fonts, and the intended WebSocket endpoint.
100
177
  | --- | --- |
101
178
  | `workerUrl` | Optional module-worker entry; useful when worker assets are deployed separately. |
102
179
  | `scale` | GPU backing scale `0.5`–`3`, or `"auto"` (default, bounded device pixel ratio). |
180
+ | `renderer` | `"auto"` (prefer WebGPU), `"webgpu"`, or `"webgl2"`; selected once per mount. |
103
181
  | `font` | One family and optional downloadable font faces; see below. |
104
182
  | `sizing` | `{ mode: "auto", fontSize?: number }` or `{ mode: "fixed", columns, rows, fontSize?: number }`. |
105
- | `readOnly` | Disable application input while retaining history inspection and selection. |
183
+ | `readOnly` | Initial per-view input policy; change it later with `setReadOnly(boolean)`. |
106
184
  | `label` | Accessible label for the terminal's hidden keyboard input. |
185
+ | `onTitleChange` | Initial authoritative workload title, then distinct presented changes; see below. |
186
+ | `onClose` | Native WebSocket close details, including pre-mount transport failure; not workload completion. |
187
+ | `onProgressChange`, `onShellIntegrationChange` | Initial authoritative activity, then distinct presented changes for host-owned chrome. |
107
188
  | `inputBindings`, `onInput`, `actions` | Per-view input policy and custom actions. |
108
189
  | `onSelectionUI` | Synchronous, cancelable UI notification hook. |
109
190
 
@@ -114,7 +195,7 @@ Font size is an integer from 8–32, defaulting to 16. Import `MIN_FONT_SIZE` an
114
195
  ownership. `requestPrimary()` explicitly requests ownership; inspect `peer` or
115
196
  `onRoleChange` to observe the result.
116
197
 
117
- The handle exposes `geometry`, `peer`, `connected`, `stats`, `screenText`,
198
+ The handle exposes `geometry`, `peer`, `connected`, `readOnly`, `title`, `progress`, `shellIntegration`, `stats`, `screenText`,
118
199
  `sizing`, `viewport`, `selection`, `inputBindings`, and `inputContext`.
119
200
  Metrics start empty; check optional fields before using them. History may be
120
201
  unavailable, and selection can be unavailable, none, pending, valid, or
@@ -122,8 +203,215 @@ invalidated. Narrow `viewport.available` and `selection.status` before using
122
203
  their state-specific values. `screenText` reflects the presented viewport, not
123
204
  an independently reconstructed ANSI buffer.
124
205
 
125
- Callbacks include `onGeometry`, `onRoleChange`, `onSizingChange`, `onStats`,
126
- `onViewportChange`, `onSelectionChange`, `onStatus`, and `onInputError`.
206
+ Callbacks include `onGeometry`, `onRoleChange`, `onTitleChange`, `onSizingChange`, `onStats`,
207
+ `onProgressChange`, `onShellIntegrationChange`, `onViewportChange`, `onSelectionChange`, `onStatus`, and `onInputError`.
208
+
209
+ ### Live read-only views
210
+
211
+ Call `terminal.setReadOnly(true)` to disable application input on an already
212
+ mounted view. `terminal.readOnly`, `inputContext.readOnly`, and selection UI
213
+ notifications reflect the new policy. Use `setReadOnly(false)` to re-enable input;
214
+ neither call remounts, reconnects, releases the peer's primary role, or changes
215
+ the server's current grid. A writable primary resumes automatic sizing requests.
216
+ Mutating the original `options.readOnly` after mount has no effect.
217
+
218
+ Read-only blocks keyboard/text/IME input, application mouse reports, direct
219
+ `paste()`/`pasteClipboard()`, the paste paths of `runAction()`, `resize()`,
220
+ `setSizing()`, `requestPrimary()`, and automatic resize requests. Explicit input
221
+ methods throw when disabled; DOM application input is not forwarded. Routing
222
+ overrides cannot bypass this policy. Custom actions can still run local operations,
223
+ but any terminal input method they call remains gated.
224
+
225
+ Active pointer capture and queued mouse movement, pending composition, queued
226
+ resize, and pending clipboard pastes are cancelled on policy change. Quickly
227
+ re-enabling input does not revive a previously pending paste. Commands already
228
+ dispatched cannot be recalled. Output, local selection gestures, history
229
+ navigation, resync, and copying remain available, including a copy already in
230
+ progress. UI notifications follow their usual coalescing rules.
231
+
232
+ ```ts
233
+ import { WebTerminal } from "@hex1b/web-terminal";
234
+
235
+ const container = document.getElementById("terminal");
236
+ const inputEnabled = document.querySelector<HTMLInputElement>("#input-enabled");
237
+ if (!container || !inputEnabled) throw new Error("Missing terminal controls");
238
+
239
+ const terminal = await WebTerminal.mount(container, {
240
+ url: "/ws/terminal",
241
+ readOnly: !inputEnabled.checked
242
+ });
243
+ inputEnabled.addEventListener("change", () => terminal.setReadOnly(!inputEnabled.checked));
244
+ ```
245
+
246
+ **Client policy is not authorization.** Enforce it independently on each server
247
+ view with `Hwt1PresentationAdapter.IsReadOnly`, initially or at runtime. For
248
+ example, in the host's existing connection setup (C# snippet):
249
+
250
+ ```csharp
251
+ var presentation = new Hwt1PresentationAdapter { IsReadOnly = true };
252
+ // Attach this presentation to the view's terminal and drive its existing transport loops.
253
+ // Only trusted host policy should grant writes:
254
+ presentation.IsReadOnly = false;
255
+ ```
256
+
257
+ The adapter ignores producer-mutating browser input, resize, and primary requests
258
+ while read-only, but still processes acknowledgements, resync, history, selection,
259
+ and copy. This is **per presentation**, not a producer-wide input lock: direct
260
+ terminal automation and other authorized viewers continue. A policy change does
261
+ not retract a command the adapter already accepted. The host must update both its
262
+ server policy and browser UX; client changes do not authorize themselves, and the
263
+ server property does not automatically change client UI.
264
+
265
+ ### Workload titles
266
+
267
+ The read-only `terminal.title` is the current presented workload title. An empty
268
+ string means unset or explicitly cleared; choose your own fallback. The optional
269
+ `onTitleChange(title)` callback runs once with the first authoritative presented
270
+ value, **including `""`, before mount resolves**. The getter is updated before
271
+ the callback. Later notifications report only distinct presented values. Identical
272
+ updates, same-title resyncs, cursor blinking, and statistics do not notify again.
273
+ Intermediate workload changes can coalesce; this is not an event for every OSC
274
+ sequence.
275
+
276
+ ```ts
277
+ import { WebTerminal } from "@hex1b/web-terminal";
278
+
279
+ const container = document.getElementById("terminal");
280
+ const header = document.getElementById("terminal-header");
281
+ if (!container || !header) throw new Error("Missing terminal elements");
282
+ const resourceName = "Build service";
283
+
284
+ const terminal = await WebTerminal.mount(container, {
285
+ url: "/ws/terminal",
286
+ onTitleChange(title) {
287
+ header.textContent = title || resourceName;
288
+ }
289
+ });
290
+ console.log(terminal.title);
291
+ ```
292
+
293
+ The callback uses elements and fallback text captured **before** mounting, not
294
+ the still-pending `terminal` result. The component does not change `document.title`,
295
+ your header, or the input's accessible `label` automatically. There is no title
296
+ subscription method or DOM title event.
297
+
298
+ Titles are normalized by the core to at most 4,096 UTF-16 code units, with C0,
299
+ DEL, and C1 controls removed, malformed surrogates replaced with U+FFFD, and
300
+ truncation at a Unicode scalar boundary. They remain **untrusted text**: markup
301
+ and bidi characters are preserved. Use `textContent`, not `innerHTML`; apply your
302
+ own presentation and bidi policies.
303
+
304
+ OSC 0 and OSC 2 set or explicitly clear the window title; OSC 1 is icon-only.
305
+ Use `ESC ] 0 ; text BEL` or `ESC ] 2 ; text BEL`; `ESC \` (ST) may replace BEL.
306
+ The UTF-8 input path also accepts Unicode C1 OSC (`U+009D`) and ST (`U+009C`).
307
+ Semicolons within `text` are literal. Existing OSC 22/23 saved-title extensions
308
+ update the same state, including after a late HMP1 attachment.
309
+ RIS, soft reset, screen clearing, and buffer switching preserve the title and
310
+ existing saved-title behavior. History inspection retains the current workload
311
+ title rather than a title associated with an old row.
312
+
313
+ Disconnect and disposal retain the last known title without a synthetic clear.
314
+ Disposal (including abort) stops title callbacks. Attach a new view to reconnect;
315
+ it receives its own initial current title. Preliminary disconnected relay frames
316
+ do not trigger the initial notification. Callbacks run directly like the other
317
+ state callbacks; thrown host errors are not swallowed or retried.
318
+
319
+ The required title field needs the matching server build. Missing or malformed
320
+ title metadata fails the connection; an older server is not silently treated as
321
+ an empty title.
322
+ The per-title bound is not a limit on all parser buffering or saved-stack depth.
323
+ An HMP1 snapshot with too much saved title state fails its 16 MiB replay limit
324
+ rather than silently discarding saved titles.
325
+
326
+ ### Application progress and shell activity
327
+
328
+ `terminal.progress` exposes OSC 9;4 state as `{ state, percentage }`.
329
+ The states are `"none"`, `"normal"`, `"error"`, `"indeterminate"`, and `"warning"`.
330
+ Normal/error/warning percentages are integers from 0 through 100. None and
331
+ indeterminate have `percentage: null`; none means the host should hide its indicator.
332
+ This is application-reported progress, not inferred from output or CPU activity.
333
+ It is independent of `ProgressWidget`, which draws inside terminal cells.
334
+
335
+ `terminal.shellIntegration` exposes OSC 133 as `{ phase, lastExitCode }`.
336
+ The phases are `"unknown"`, `"prompt"` (A), `"commandLine"` (B),
337
+ `"executing"` (C), and `"finished"` (D). B means input after the prompt, **not**
338
+ command execution. Unknown does not mean idle. `lastExitCode` is a signed
339
+ 32-bit integer, or null when no status was reported; null is not success.
340
+ A/B/C preserve the last reported result, and D replaces it, including clearing
341
+ it to null when the shell omits its status. No command text, history, or output
342
+ locations are retained by these APIs.
343
+
344
+ Both getters return defensive copies. Their callbacks receive the first
345
+ authoritative presented state before mount resolves, then distinct presented
346
+ changes. Both getters are updated before either activity callback. Callbacks
347
+ use the same direct, synchronous host-callback convention as title changes;
348
+ host exceptions are not swallowed or retried.
349
+
350
+ Frames coalesce: the browser might see only Finished for a fast command, or
351
+ miss an entire command whose final state is unchanged. These callbacks are
352
+ **current-state notifications, not a lossless start/finish event stream**.
353
+ Resync/replay never invent commands, unchanged state does not notify again,
354
+ and a new mount receives its own baseline.
355
+
356
+ This example creates optional chrome outside the terminal:
357
+
358
+ ```ts
359
+ import { WebTerminal } from "@hex1b/web-terminal";
360
+
361
+ const status = document.createElement("span");
362
+ const progress = document.createElement("progress");
363
+ progress.max = 100;
364
+ progress.hidden = true;
365
+ const container = document.createElement("div");
366
+ container.style.cssText = "width:800px;height:480px";
367
+ document.body.append(status, progress, container);
368
+
369
+ const terminal = await WebTerminal.mount(container, {
370
+ url: "/ws/terminal",
371
+ onProgressChange(value) {
372
+ progress.hidden = value.state === "none";
373
+ progress.dataset.state = value.state; // Host CSS can distinguish error/warning.
374
+ if (value.percentage === null) progress.removeAttribute("value");
375
+ else progress.value = value.percentage;
376
+ },
377
+ onShellIntegrationChange(value) {
378
+ status.textContent = value.phase +
379
+ (value.lastExitCode === null ? "" : ` (last exit ${value.lastExitCode})`);
380
+ },
381
+ onStats(stats) {
382
+ if (!stats.connected) {
383
+ progress.hidden = true;
384
+ status.textContent = "Disconnected";
385
+ }
386
+ }
387
+ });
388
+ console.log(terminal.progress, terminal.shellIntegration);
389
+ ```
390
+
391
+ No title, document chrome, or progress UI is changed automatically by the
392
+ component. The sample endpoint must be supplied by your application.
393
+ Callbacks can run before the `terminal` variable is assigned; use their
394
+ arguments during initial mounting.
395
+
396
+ RIS resets progress to None and shell integration to Unknown. Soft reset,
397
+ screen clearing, resize, and buffer switches preserve them. OSC 9;4 state 0
398
+ clears only progress; a shell completion does not implicitly clear it.
399
+ Disconnect, process exit, and disposal retain the last reported values
400
+ without inventing a completion or progress clear. Check `connected` before
401
+ showing active chrome, and remount to reconnect. Disposal stops callbacks.
402
+ Snapshots and historical viewports carry current activity, not activity at
403
+ the time a particular row was printed.
404
+
405
+ The core accepts BEL, ESC-backslash ST, and decoded Unicode C1 terminators
406
+ through its UTF-8 input path. Determinate progress requires unsigned decimal
407
+ 0-100; clear/indeterminate allow an omitted percentage and ignore its optional
408
+ value. OSC 133 supports the basic A/B/C forms and D with an optional signed
409
+ decimal exit status (an empty field also means no status). Missing required,
410
+ malformed, overflowed, excess, or unsupported arguments do not change state.
411
+ The raw-output presentation path still forwards the original sequences to
412
+ supporting outer terminals. Required activity metadata needs the matching
413
+ server build; invalid/missing wire fields fail the connection, not silently
414
+ fall back to default state.
127
415
 
128
416
  ## Input and clipboard
129
417
 
@@ -172,6 +460,20 @@ actions reject if selection/input/focus changes before their asynchronous work
172
460
  can be applied safely. Errors are surfaced rather than silently reported as
173
461
  successful copies or pastes.
174
462
 
463
+ ### Hyperlinks
464
+
465
+ Hold Ctrl or Cmd and click an OSC 8 hyperlink to open its destination in a new
466
+ tab. Hovering shows the destination and activation hint; holding the modifier
467
+ also shows a pointer cursor. Links work in live output, scrollback, and read-only
468
+ views. Plain clicks and drags retain their existing selection/application
469
+ behavior, and explicit input-policy routes or actions take precedence.
470
+ Shift and Alt/Option continue to reserve selection gestures.
471
+
472
+ Only absolute `http:`, `https:`, and `mailto:` destinations are activated
473
+ (`mailto:` handling depends on the browser). New tabs use `noopener,noreferrer`.
474
+ Script, data, file, relative, and custom-scheme URLs are not activated.
475
+ Plain URL text is not automatically detected; the workload must emit OSC 8.
476
+
175
477
  ## Selection UI hooks
176
478
 
177
479
  `onSelectionUI` receives a typed `SelectionUIEvent`, also dispatched as the
@@ -239,5 +541,6 @@ Only `dist/`, this README, the MIT license, and package metadata are shipped.
239
541
  A prepared tarball is self-contained and can be published with
240
542
  `npm publish ./hex1b-web-terminal-<version>.tgz --ignore-scripts`; it does not
241
543
  need development sources or build scripts. The package name is always
242
- `@hex1b/web-terminal`, including GitHub Packages. Registry selection is left to
243
- the caller; no registry is pinned in `package.json`.
544
+ `@hex1b/web-terminal`. CI publishes main/release builds to npmjs; PR builds
545
+ provide the tarball as the `npm-web-terminal` workflow artifact and do not
546
+ publish it to a registry. No registry is pinned in `package.json`.
@@ -0,0 +1,7 @@
1
+ import type { RenderBackend } from "./render-backend.js";
2
+ import type { TerminalRendererPreference } from "./types.js";
3
+ export declare function createRenderBackend(canvas: OffscreenCanvas, onFatal: (error: Error) => void, preference?: TerminalRendererPreference): Promise<{
4
+ backend: RenderBackend;
5
+ fallbackReason?: string;
6
+ }>;
7
+ //# sourceMappingURL=backend-selection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backend-selection.d.ts","sourceRoot":"","sources":["../src/backend-selection.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIzD,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAE7D,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,EAChG,UAAU,GAAE,0BAAmC,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,aAAa,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAe/G"}
@@ -0,0 +1,23 @@
1
+ import { RendererUnavailableError } from "./render-backend.js";
2
+ import { normalizeRenderer } from "./renderer-options.js";
3
+ import { WebGpuBackend } from "./webgpu-backend.js";
4
+ import { WebGl2Backend } from "./webgl2-backend.js";
5
+ export async function createRenderBackend(canvas, onFatal, preference = "auto") {
6
+ const requested = normalizeRenderer(preference);
7
+ if (requested === "webgl2")
8
+ return { backend: await WebGl2Backend.create(canvas, onFatal) };
9
+ try {
10
+ return { backend: await WebGpuBackend.create(canvas, onFatal) };
11
+ }
12
+ catch (error) {
13
+ if (requested !== "auto" || !(error instanceof RendererUnavailableError))
14
+ throw error;
15
+ try {
16
+ return { backend: await WebGl2Backend.create(canvas, onFatal), fallbackReason: error.message };
17
+ }
18
+ catch (fallbackError) {
19
+ throw new AggregateError([error, fallbackError], `WebGPU unavailable (${error.message}); WebGL2 initialization failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
20
+ }
21
+ }
22
+ }
23
+ //# sourceMappingURL=backend-selection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backend-selection.js","sourceRoot":"","sources":["../src/backend-selection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AAE/D,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGpD,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,MAAuB,EAAE,OAA+B,EAChG,aAAyC,MAAM;IAC/C,MAAM,SAAS,GAAG,iBAAiB,CAAC,UAAU,CAAC,CAAC;IAChD,IAAI,SAAS,KAAK,QAAQ;QAAE,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAC5F,IAAI,CAAC;QACH,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAClE,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,SAAS,KAAK,MAAM,IAAI,CAAC,CAAC,KAAK,YAAY,wBAAwB,CAAC;YAAE,MAAM,KAAK,CAAC;QACtF,IAAI,CAAC;YACH,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC;QACjG,CAAC;QAAC,OAAO,aAAa,EAAE,CAAC;YACvB,MAAM,IAAI,cAAc,CAAC,CAAC,KAAK,EAAE,aAAa,CAAC,EAC7C,uBAAuB,KAAK,CAAC,OAAO,oCAClC,aAAa,YAAY,KAAK,CAAC,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC;QACxF,CAAC;IACH,CAAC;AACH,CAAC","sourcesContent":["import { RendererUnavailableError } from \"./render-backend.js\";\nimport type { RenderBackend } from \"./render-backend.js\";\nimport { normalizeRenderer } from \"./renderer-options.js\";\nimport { WebGpuBackend } from \"./webgpu-backend.js\";\nimport { WebGl2Backend } from \"./webgl2-backend.js\";\nimport type { TerminalRendererPreference } from \"./types.js\";\n\nexport async function createRenderBackend(canvas: OffscreenCanvas, onFatal: (error: Error) => void,\n preference: TerminalRendererPreference = \"auto\"): Promise<{ backend: RenderBackend; fallbackReason?: string }> {\n const requested = normalizeRenderer(preference);\n if (requested === \"webgl2\") return { backend: await WebGl2Backend.create(canvas, onFatal) };\n try {\n return { backend: await WebGpuBackend.create(canvas, onFatal) };\n } catch (error) {\n if (requested !== \"auto\" || !(error instanceof RendererUnavailableError)) throw error;\n try {\n return { backend: await WebGl2Backend.create(canvas, onFatal), fallbackReason: error.message };\n } catch (fallbackError) {\n throw new AggregateError([error, fallbackError],\n `WebGPU unavailable (${error.message}); WebGL2 initialization failed: ${\n fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);\n }\n }\n}\n"]}
@@ -0,0 +1,10 @@
1
+ import type { TerminalPoint } from "./types.js";
2
+ import type { HyperlinkRange } from "./wire-types.js";
3
+ /** OSC 8 destinations are untrusted output, not page-relative navigation. */
4
+ export declare function hyperlinkUri(uri: string): string | null;
5
+ export declare class Hyperlinks {
6
+ #private;
7
+ update(ranges: readonly HyperlinkRange[]): void;
8
+ at(point: TerminalPoint): string | null;
9
+ }
10
+ //# sourceMappingURL=hyperlinks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hyperlinks.d.ts","sourceRoot":"","sources":["../src/hyperlinks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIvD;AAED,qBAAa,UAAU;;IAGrB,MAAM,CAAC,MAAM,EAAE,SAAS,cAAc,EAAE,GAAG,IAAI;IAa/C,EAAE,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,GAAG,IAAI;CAIxC"}
@@ -0,0 +1,28 @@
1
+ /** OSC 8 destinations are untrusted output, not page-relative navigation. */
2
+ export function hyperlinkUri(uri) {
3
+ if (/[\u0000-\u0020\u007f]/u.test(uri) || !URL.canParse(uri))
4
+ return null;
5
+ const url = new URL(uri);
6
+ return ["https:", "http:", "mailto:"].includes(url.protocol) ? url.href : null;
7
+ }
8
+ export class Hyperlinks {
9
+ #rows = new Map();
10
+ update(ranges) {
11
+ this.#rows.clear();
12
+ const destinations = new Map();
13
+ for (const range of ranges) {
14
+ if (!destinations.has(range.uri))
15
+ destinations.set(range.uri, hyperlinkUri(range.uri));
16
+ const uri = destinations.get(range.uri);
17
+ if (!uri)
18
+ continue;
19
+ const row = this.#rows.get(range.row) ?? [];
20
+ row.push({ ...range, uri });
21
+ this.#rows.set(range.row, row);
22
+ }
23
+ }
24
+ at(point) {
25
+ return this.#rows.get(point.y)?.find(range => point.x >= range.startColumn && point.x < range.endColumn)?.uri ?? null;
26
+ }
27
+ }
28
+ //# sourceMappingURL=hyperlinks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hyperlinks.js","sourceRoot":"","sources":["../src/hyperlinks.ts"],"names":[],"mappings":"AAGA,6EAA6E;AAC7E,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,IAAI,wBAAwB,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1E,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IACzB,OAAO,CAAC,QAAQ,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AACjF,CAAC;AAED,MAAM,OAAO,UAAU;IACrB,KAAK,GAAG,IAAI,GAAG,EAA4B,CAAC;IAE5C,MAAM,CAAC,MAAiC;QACtC,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;QACnB,MAAM,YAAY,GAAG,IAAI,GAAG,EAAyB,CAAC;QACtD,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC;gBAAE,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;YACvF,MAAM,GAAG,GAAG,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YACxC,IAAI,CAAC,GAAG;gBAAE,SAAS;YACnB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;YAC5C,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC;YAC5B,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACjC,CAAC;IACH,CAAC;IAED,EAAE,CAAC,KAAoB;QACrB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAC3C,KAAK,CAAC,CAAC,IAAI,KAAK,CAAC,WAAW,IAAI,KAAK,CAAC,CAAC,GAAG,KAAK,CAAC,SAAS,CAAC,EAAE,GAAG,IAAI,IAAI,CAAC;IAC5E,CAAC;CACF","sourcesContent":["import type { TerminalPoint } from \"./types.js\";\nimport type { HyperlinkRange } from \"./wire-types.js\";\n\n/** OSC 8 destinations are untrusted output, not page-relative navigation. */\nexport function hyperlinkUri(uri: string): string | null {\n if (/[\\u0000-\\u0020\\u007f]/u.test(uri) || !URL.canParse(uri)) return null;\n const url = new URL(uri);\n return [\"https:\", \"http:\", \"mailto:\"].includes(url.protocol) ? url.href : null;\n}\n\nexport class Hyperlinks {\n #rows = new Map<number, HyperlinkRange[]>();\n\n update(ranges: readonly HyperlinkRange[]): void {\n this.#rows.clear();\n const destinations = new Map<string, string | null>();\n for (const range of ranges) {\n if (!destinations.has(range.uri)) destinations.set(range.uri, hyperlinkUri(range.uri));\n const uri = destinations.get(range.uri);\n if (!uri) continue;\n const row = this.#rows.get(range.row) ?? [];\n row.push({ ...range, uri });\n this.#rows.set(range.row, row);\n }\n }\n\n at(point: TerminalPoint): string | null {\n return this.#rows.get(point.y)?.find(range =>\n point.x >= range.startColumn && point.x < range.endColumn)?.uri ?? null;\n }\n}\n"]}
@@ -14,9 +14,12 @@ interface MouseInspection {
14
14
  execute?: (decision: Extract<InputDecision, {
15
15
  action: unknown;
16
16
  }>, input: TerminalInput) => void;
17
+ hyperlink?: (point: TerminalPoint) => string | null;
18
+ openHyperlink?: (uri: string) => void;
17
19
  }
18
20
  export interface MouseCapture {
19
21
  update(columns: number, rows: number, tracking: MouseTrackingMode): void;
22
+ refresh(): void;
20
23
  cancel(): void;
21
24
  dispose(): void;
22
25
  }
@@ -1 +1 @@
1
- {"version":3,"file":"mouse-input.d.ts","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAmB,iBAAiB,EAAiB,aAAa,EAC3F,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,KAAK,EAA6B,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAK/E,UAAU,eAAe;IACvB,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;IAC7C,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IAC5F,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IACxC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,aAAa,KAAK,IAAI,CAAC;IAC3D,GAAG,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,aAAa,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,aAAa,EAAE;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC,EAAE,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;CACjG;AACD,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACzE,MAAM,IAAI,IAAI,CAAC;IACf,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,MAAM,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,EAC3F,KAAK,EAAE,MAAM,IAAI,EAAE,UAAU,GAAE,eAAoB,GAAG,YAAY,CA+SnE"}
1
+ {"version":3,"file":"mouse-input.d.ts","sourceRoot":"","sources":["../src/mouse-input.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAmB,iBAAiB,EAAiB,aAAa,EAC3F,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,KAAK,EAA6B,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAM/E,UAAU,eAAe;IACvB,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;IAC7C,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IAC5F,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IACxC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,aAAa,KAAK,IAAI,CAAC;IAC3D,GAAG,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,aAAa,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,aAAa,EAAE;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC,EAAE,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAChG,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,MAAM,GAAG,IAAI,CAAC;IACpD,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC;AACD,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACzE,OAAO,IAAI,IAAI,CAAC;IAChB,MAAM,IAAI,IAAI,CAAC;IACf,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,MAAM,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,EAC3F,KAAK,EAAE,MAAM,IAAI,EAAE,UAAU,GAAE,eAAoB,GAAG,YAAY,CA2WnE"}
@@ -24,10 +24,27 @@ export function captureMouse(canvas, send, focus, inspection = {}) {
24
24
  let completedClick;
25
25
  let routedGesture;
26
26
  let contextMenuRoute;
27
+ let hyperlinkClick;
28
+ let hoverEvent;
29
+ let hoverModifiers;
30
+ const originalTitle = canvas.title;
31
+ const originalCursor = canvas.style.cursor;
27
32
  const state = () => ({ tracking, ...inspection.state?.() });
28
33
  function point(event, clamp = false) {
29
34
  return cellPoint(event, canvas.getBoundingClientRect(), columns, rows, clamp);
30
35
  }
36
+ function refreshHover(modifiers = hoverModifiers) {
37
+ if (!inspection.hyperlink)
38
+ return;
39
+ hoverModifiers = modifiers;
40
+ const position = hoverEvent && point(hoverEvent);
41
+ const uri = position ? inspection.hyperlink(position) : null;
42
+ canvas.title = uri ? `${uri}\nCtrl/Cmd+click to open link` : originalTitle;
43
+ canvas.style.cursor = uri && modifiers && (modifiers.ctrlKey || modifiers.metaKey) &&
44
+ !modifiers.altKey && !modifiers.shiftKey ? "pointer" : originalCursor;
45
+ if (hyperlinkClick && hyperlinkClick.uri !== uri)
46
+ hyperlinkClick.dragged = true;
47
+ }
31
48
  function updateAutoScroll() {
32
49
  clearTimeout(autoScroll);
33
50
  autoScroll = undefined;
@@ -83,6 +100,7 @@ export function captureMouse(canvas, send, focus, inspection = {}) {
83
100
  pointerEvent = undefined;
84
101
  selectionClick = undefined;
85
102
  completedClick = undefined;
103
+ hyperlinkClick = undefined;
86
104
  gesture.end();
87
105
  routedGesture = undefined;
88
106
  if (report)
@@ -115,6 +133,8 @@ export function captureMouse(canvas, send, focus, inspection = {}) {
115
133
  if (!position)
116
134
  return;
117
135
  if (pointerId !== null) {
136
+ if (hyperlinkClick)
137
+ hyperlinkClick.dragged = true;
118
138
  if (routedGesture !== InputRoute.Browser)
119
139
  event.preventDefault();
120
140
  if ((gesture.owner === "app" || routedGesture === InputRoute.Application) && pointerId === event.pointerId)
@@ -125,8 +145,20 @@ export function captureMouse(canvas, send, focus, inspection = {}) {
125
145
  const input = { type: "pointer", button: pointerButtons[event.button],
126
146
  point: Object.freeze({ ...position }), ...inputModifiers(event) };
127
147
  const decision = inspection.resolve?.(input) ?? { route: InputRoute.Continue };
128
- const route = decision.action !== undefined ? InputRoute.Consume : decision.route;
148
+ let route = decision.action !== undefined ? InputRoute.Consume : decision.route;
149
+ if (route === InputRoute.Continue && event.button === 0 && (event.ctrlKey || event.metaKey) &&
150
+ !event.altKey && !event.shiftKey) {
151
+ const uri = inspection.hyperlink?.(position);
152
+ if (uri) {
153
+ hyperlinkClick = { uri, x: event.clientX, y: event.clientY, dragged: false };
154
+ route = InputRoute.Consume;
155
+ }
156
+ }
129
157
  contextMenuRoute = event.button === 2 ? route : undefined;
158
+ if (hyperlinkClick)
159
+ contextMenuRoute = InputRoute.Consume;
160
+ hoverEvent = event;
161
+ refreshHover(event);
130
162
  if (route !== InputRoute.Continue) {
131
163
  completedClick = selectionClick = undefined;
132
164
  click.count = 0;
@@ -186,6 +218,10 @@ export function captureMouse(canvas, send, focus, inspection = {}) {
186
218
  return;
187
219
  if (pointerId !== null && pointerId !== event.pointerId)
188
220
  return;
221
+ hoverEvent = event;
222
+ refreshHover(event);
223
+ if (hyperlinkClick && Math.hypot(event.clientX - hyperlinkClick.x, event.clientY - hyperlinkClick.y) >= 4)
224
+ hyperlinkClick.dragged = true;
189
225
  const position = point(event, pointerId !== null);
190
226
  if (!position)
191
227
  return;
@@ -224,6 +260,21 @@ export function captureMouse(canvas, send, focus, inspection = {}) {
224
260
  canvas.addEventListener("pointerup", event => {
225
261
  if (pointerId !== event.pointerId)
226
262
  return;
263
+ if (hyperlinkClick) {
264
+ event.preventDefault();
265
+ const link = hyperlinkClick;
266
+ const position = point(event);
267
+ const activate = event.button === 0 && event.buttons === 0 && !link.dragged &&
268
+ Math.hypot(event.clientX - link.x, event.clientY - link.y) < 4 &&
269
+ position && inspection.hyperlink?.(position) === link.uri;
270
+ if (event.buttons === 0)
271
+ cancel(false, false);
272
+ else
273
+ link.dragged = true;
274
+ if (activate)
275
+ inspection.openHyperlink?.(link.uri);
276
+ return;
277
+ }
227
278
  const position = point(event, true) ?? lastPoint;
228
279
  if (routedGesture) {
229
280
  if (routedGesture === InputRoute.Application && position)
@@ -262,11 +313,21 @@ export function captureMouse(canvas, send, focus, inspection = {}) {
262
313
  inspection.begin?.(completed.point, { mode, extend: false });
263
314
  }, options);
264
315
  canvas.addEventListener("pointercancel", () => cancel(), options);
316
+ canvas.addEventListener("pointerleave", () => {
317
+ hoverEvent = undefined;
318
+ refreshHover();
319
+ }, options);
265
320
  canvas.addEventListener("lostpointercapture", () => {
266
321
  if (pointerId !== null)
267
322
  cancel();
268
323
  }, options);
269
- window.addEventListener("blur", () => cancel(), options);
324
+ window.addEventListener("blur", () => {
325
+ cancel();
326
+ hoverEvent = undefined;
327
+ refreshHover();
328
+ }, options);
329
+ window.addEventListener("keydown", event => refreshHover(event), { ...options, capture: true });
330
+ window.addEventListener("keyup", event => refreshHover(event), { ...options, capture: true });
270
331
  canvas.addEventListener("contextmenu", event => {
271
332
  if (contextMenuRoute !== undefined && contextMenuRoute !== InputRoute.Continue) {
272
333
  const route = contextMenuRoute;
@@ -279,6 +340,8 @@ export function captureMouse(canvas, send, focus, inspection = {}) {
279
340
  event.preventDefault();
280
341
  }, options);
281
342
  canvas.addEventListener("wheel", event => {
343
+ if (hyperlinkClick)
344
+ hyperlinkClick.dragged = true;
282
345
  const input = { type: "wheel", deltaX: event.deltaX, deltaY: event.deltaY,
283
346
  deltaMode: event.deltaMode, point: point(event), ...inputModifiers(event) };
284
347
  const decision = pointerId === null
@@ -345,11 +408,15 @@ export function captureMouse(canvas, send, focus, inspection = {}) {
345
408
  rows = nextRows;
346
409
  tracking = nextTracking;
347
410
  }
411
+ refreshHover();
348
412
  },
413
+ refresh() { refreshHover(); },
349
414
  cancel() { cancel(); },
350
415
  dispose() {
351
416
  cancel(false);
352
417
  listeners.abort();
418
+ canvas.title = originalTitle;
419
+ canvas.style.cursor = originalCursor;
353
420
  }
354
421
  };
355
422
  }