anvilsign in

collin/anvil

main / crates / anvil-web / assets / wterm / dom / wterm.js
1import { WasmBridge } from "@wterm/core";
2import { Renderer } from "./renderer.js";
3import { InputHandler } from "./input.js";
4import { DebugAdapter } from "./debug.js";
5import { isLinkActivationModifier } from "./hyperlink.js";
6const SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000;
7export class WTerm {
8 constructor(element, options = {}) {
9 this.bridge = null;
10 this.debug = null;
11 this.renderer = null;
12 this.input = null;
13 this.rafId = null;
14 this._synchronizedOutputTimer = null;
15 this._synchronizedOutputState = "idle";
16 this._synchronizedOutputGeneration = 0;
17 this._rendererNeedsSetup = false;
18 this.resizeObserver = null;
19 this._destroyed = false;
20 this._shouldScrollToBottom = false;
21 this._scrollbackDiscardedCount = 0;
22 this._programmaticScrollTop = null;
23 this._pendingResizeScrollTop = null;
24 this._rowHeight = 0;
25 this._charWidth = 0;
26 this.element = element;
27 this._coreOption = options.core;
28 this.wasmUrl = options.wasmUrl;
29 this.cols = options.cols || 80;
30 this.rows = options.rows || 24;
31 this.autoResize = options.autoResize !== false;
32 this._debugEnabled = options.debug ?? false;
33 this.onData = options.onData || null;
34 this.onTitle = options.onTitle || null;
35 this.onResize = options.onResize || null;
36 this._container = document.createElement("div");
37 this._container.className = "term-grid";
38 this.element.appendChild(this._container);
39 this.element.classList.add("wterm");
40 if (options.cursorBlink)
41 this.element.classList.add("cursor-blink");
42 this._onClickFocus = (event) => {
43 const target = event.target;
44 if (target instanceof Element && target.closest(".term-link")) {
45 if (isLinkActivationModifier(event, this.element.ownerDocument.defaultView?.navigator ?? navigator) ||
46 event.detail === 0) {
47 return;
48 }
49 event.preventDefault();
50 }
51 const sel = window.getSelection();
52 if (!sel || sel.isCollapsed)
53 this.input?.focus();
54 };
55 this.element.addEventListener("click", this._onClickFocus);
56 this._onModifierChange = (event) => {
57 this.element.classList.toggle("link-modifier-active", isLinkActivationModifier(event, this.element.ownerDocument.defaultView?.navigator ?? navigator));
58 };
59 this._onWindowBlur = () => {
60 this.element.classList.remove("link-modifier-active");
61 };
62 this.element.ownerDocument.addEventListener("keydown", this._onModifierChange);
63 this.element.ownerDocument.addEventListener("keyup", this._onModifierChange);
64 this.element.ownerDocument.defaultView?.addEventListener("blur", this._onWindowBlur);
65 this._onScroll = () => {
66 if (this._pendingResizeScrollTop !== null)
67 return;
68 if (this._programmaticScrollTop !== null &&
69 this.element.scrollTop === this._programmaticScrollTop) {
70 this._programmaticScrollTop = null;
71 return;
72 }
73 this._programmaticScrollTop = null;
74 this._shouldScrollToBottom = false;
75 this._scheduleRender();
76 };
77 this.element.addEventListener("scroll", this._onScroll, { passive: true });
78 }
79 async init() {
80 try {
81 if (this._coreOption) {
82 this.bridge = this._coreOption;
83 }
84 else {
85 this.bridge = await WasmBridge.load(this.wasmUrl);
86 }
87 if (this._destroyed)
88 return this;
89 this.bridge.init(this.cols, this.rows);
90 if (this._debugEnabled) {
91 this.debug = new DebugAdapter();
92 this.debug.setBridge(this.bridge);
93 globalThis.__wterm = this;
94 }
95 this._setRowHeight();
96 this._measureCharSize();
97 this.renderer = new Renderer(this._container);
98 this.renderer.setup(this.cols, this.rows);
99 this.input = new InputHandler(this.element, (data) => {
100 this._scrollToBottom();
101 if (this.onData) {
102 this.onData(data);
103 }
104 else {
105 this.write(data);
106 }
107 }, () => this.bridge, () => this._charWidth > 0 && this._rowHeight > 0
108 ? { charWidth: this._charWidth, rowHeight: this._rowHeight }
109 : null);
110 if (this.autoResize) {
111 this._setupResizeObserver();
112 }
113 else {
114 this._lockHeight();
115 }
116 this.input.focus();
117 this._initialRender();
118 }
119 catch (err) {
120 this.destroy();
121 throw new Error(`wterm: failed to initialize: ${err instanceof Error ? err.message : err}`);
122 }
123 return this;
124 }
125 _isScrolledToBottom() {
126 const el = this.element;
127 return el.scrollHeight - el.scrollTop - el.clientHeight < 5;
128 }
129 _scrollToBottom() {
130 const el = this.element;
131 const maxScroll = el.scrollHeight - el.clientHeight;
132 if (maxScroll <= 0) {
133 this._setScrollTop(0);
134 return;
135 }
136 this._setScrollTop(maxScroll);
137 }
138 _setScrollTop(value) {
139 if (this.element.scrollTop === value)
140 return;
141 this._programmaticScrollTop = value;
142 this.element.scrollTop = value;
143 }
144 write(data) {
145 if (!this.bridge)
146 return;
147 if (this.debug)
148 this.debug.traceWrite(data);
149 this._shouldScrollToBottom = this._isScrolledToBottom();
150 let deliveryError;
151 let hasDeliveryError = false;
152 const drain = () => {
153 const result = this._drainResponses();
154 if (!hasDeliveryError && result.hasError) {
155 hasDeliveryError = true;
156 deliveryError = result.error;
157 }
158 };
159 if (typeof data === "string") {
160 this.bridge.writeString(data, drain);
161 }
162 else {
163 this.bridge.writeRaw(data, drain);
164 }
165 const synchronized = this.bridge.synchronizedOutput?.() ?? false;
166 const generation = this.bridge.synchronizedOutputGeneration?.() ?? 0;
167 this._updateSynchronizedOutput(synchronized, generation);
168 if (this._synchronizedOutputState !== "held") {
169 this._setupRendererIfNeeded();
170 this._scheduleRender();
171 }
172 drain();
173 if (hasDeliveryError)
174 throw deliveryError;
175 }
176 resize(cols, rows) {
177 if (!this.bridge)
178 return;
179 this._shouldScrollToBottom =
180 this._pendingResizeScrollTop === null && this._isScrolledToBottom();
181 this.cols = cols;
182 this.rows = rows;
183 this.bridge.resize(cols, rows);
184 const synchronized = this.bridge.synchronizedOutput?.() ?? false;
185 const generation = this.bridge.synchronizedOutputGeneration?.() ?? 0;
186 if (this._updateSynchronizedOutput(synchronized, generation)) {
187 this._rendererNeedsSetup = true;
188 }
189 else {
190 this._setupRenderer(cols, rows);
191 this._scheduleRender();
192 }
193 if (this.onResize)
194 this.onResize(cols, rows);
195 }
196 focus() {
197 if (this.input) {
198 this.input.focus();
199 }
200 else {
201 this.element.focus();
202 }
203 }
204 _scheduleRender() {
205 if (this.rafId != null)
206 return;
207 this.rafId = requestAnimationFrame(() => {
208 this.rafId = null;
209 this._doRender();
210 });
211 }
212 _cancelScheduledRender() {
213 if (this.rafId != null) {
214 cancelAnimationFrame(this.rafId);
215 this.rafId = null;
216 }
217 }
218 _updateSynchronizedOutput(synchronized, generation) {
219 if (!synchronized) {
220 if (this._synchronizedOutputState === "held") {
221 this._cancelSynchronizedOutputFallback();
222 }
223 this._synchronizedOutputState = "idle";
224 return false;
225 }
226 if (this._synchronizedOutputState === "held" &&
227 generation !== this._synchronizedOutputGeneration) {
228 this._armSynchronizedOutputFallback(generation);
229 return true;
230 }
231 else if (this._synchronizedOutputState === "passthrough" &&
232 generation !== this._synchronizedOutputGeneration) {
233 this._synchronizedOutputState = "idle";
234 }
235 if (this._synchronizedOutputState !== "idle") {
236 return this._synchronizedOutputState === "held";
237 }
238 this._synchronizedOutputState = "held";
239 this._cancelScheduledRender();
240 this._armSynchronizedOutputFallback(generation);
241 return true;
242 }
243 _armSynchronizedOutputFallback(generation) {
244 this._cancelSynchronizedOutputFallback();
245 this._synchronizedOutputGeneration = generation;
246 this._synchronizedOutputTimer = setTimeout(() => {
247 if (this._synchronizedOutputState !== "held" ||
248 this._synchronizedOutputGeneration !== generation) {
249 return;
250 }
251 this._synchronizedOutputTimer = null;
252 this._synchronizedOutputState = "passthrough";
253 this._setupRendererIfNeeded();
254 this._cancelScheduledRender();
255 this._doRender();
256 }, SYNCHRONIZED_OUTPUT_TIMEOUT_MS);
257 }
258 _cancelSynchronizedOutputFallback() {
259 if (this._synchronizedOutputTimer == null)
260 return;
261 clearTimeout(this._synchronizedOutputTimer);
262 this._synchronizedOutputTimer = null;
263 }
264 _setupRendererIfNeeded() {
265 if (!this._rendererNeedsSetup)
266 return;
267 this._setupRenderer(this.cols, this.rows);
268 this._rendererNeedsSetup = false;
269 }
270 _setupRenderer(cols, rows) {
271 if (!this._shouldScrollToBottom && this._pendingResizeScrollTop === null) {
272 this._pendingResizeScrollTop = this.element.scrollTop;
273 }
274 this.renderer?.setup(cols, rows);
275 }
276 _initialRender() {
277 this._doRender();
278 }
279 _doRender() {
280 if (!this.bridge || !this.renderer)
281 return;
282 let dirtyCount = 0;
283 const t0 = this.debug ? performance.now() : 0;
284 if (this.debug) {
285 for (let r = 0; r < this.rows; r++) {
286 if (this.bridge.isDirtyRow(r))
287 dirtyCount++;
288 }
289 }
290 const rowHeight = this._rowHeight || 17;
291 const scrollbackCount = this.bridge.getScrollbackCount();
292 const discardedCount = this.bridge.getScrollbackDiscardedCount?.();
293 const discardedDelta = discardedCount !== undefined &&
294 discardedCount >= this._scrollbackDiscardedCount
295 ? discardedCount - this._scrollbackDiscardedCount
296 : 0;
297 if (discardedCount !== undefined) {
298 this._scrollbackDiscardedCount = discardedCount;
299 }
300 let scrollTop = this._pendingResizeScrollTop !== null
301 ? this._pendingResizeScrollTop
302 : this.element.scrollTop;
303 if (!this._shouldScrollToBottom && discardedDelta > 0) {
304 scrollTop = Math.max(0, scrollTop - discardedDelta * rowHeight);
305 if (this._pendingResizeScrollTop !== null) {
306 this._pendingResizeScrollTop = scrollTop;
307 }
308 else {
309 this._setScrollTop(scrollTop);
310 }
311 }
312 this.renderer.render(this.bridge, {
313 scrollTop: this._shouldScrollToBottom
314 ? Math.max(0, (scrollbackCount + this.rows) * rowHeight -
315 this.element.clientHeight)
316 : scrollTop,
317 clientHeight: this.element.clientHeight,
318 rowHeight,
319 scrollbackDiscardedCount: discardedCount,
320 });
321 if (this.debug) {
322 this.debug.recordRender(performance.now() - t0, dirtyCount);
323 }
324 const hasScrollback = scrollbackCount > 0;
325 this.element.classList.toggle("has-scrollback", hasScrollback);
326 if (this._shouldScrollToBottom) {
327 this._scrollToBottom();
328 }
329 else if (this._pendingResizeScrollTop !== null) {
330 const pendingScrollTop = this._pendingResizeScrollTop;
331 this._pendingResizeScrollTop = null;
332 this._setScrollTop(pendingScrollTop);
333 }
334 else if (!hasScrollback && this.element.scrollTop !== 0) {
335 this.element.scrollTop = 0;
336 }
337 const title = this.bridge.getTitle();
338 if (title !== null && this.onTitle) {
339 this.onTitle(title);
340 }
341 this._drainResponses();
342 }
343 _drainResponses() {
344 if (!this.bridge)
345 return { hasError: false };
346 let response;
347 let firstError;
348 let hasError = false;
349 while ((response = this.bridge.getResponse()) !== null) {
350 try {
351 if (this.onData)
352 this.onData(response);
353 }
354 catch (error) {
355 if (!hasError) {
356 hasError = true;
357 firstError = error;
358 }
359 }
360 }
361 return { hasError, error: firstError };
362 }
363 _lockHeight() {
364 const rh = this._rowHeight || 17;
365 const gridHeight = this.rows * rh;
366 const cs = getComputedStyle(this.element);
367 let extra = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
368 if (cs.boxSizing === "border-box") {
369 extra +=
370 (parseFloat(cs.borderTopWidth) || 0) +
371 (parseFloat(cs.borderBottomWidth) || 0);
372 }
373 this.element.style.height = `${gridHeight + extra}px`;
374 }
375 _setRowHeight() {
376 const probe = document.createElement("div");
377 probe.className = "term-row";
378 probe.style.visibility = "hidden";
379 probe.style.position = "absolute";
380 probe.textContent = "W";
381 this._container.appendChild(probe);
382 const h = probe.getBoundingClientRect().height;
383 probe.remove();
384 if (h > 0) {
385 const rh = Math.ceil(h);
386 this._rowHeight = rh;
387 this.element.style.setProperty("--term-row-height", `${rh}px`);
388 }
389 }
390 _measureCharSize() {
391 const row = document.createElement("div");
392 row.className = "term-row";
393 row.style.visibility = "hidden";
394 row.style.position = "absolute";
395 const probe = document.createElement("span");
396 probe.textContent = "W";
397 row.appendChild(probe);
398 this._container.appendChild(row);
399 const charWidth = probe.getBoundingClientRect().width;
400 const rowHeight = row.getBoundingClientRect().height;
401 row.remove();
402 if (charWidth === 0 || rowHeight === 0)
403 return null;
404 this._charWidth = charWidth;
405 this._rowHeight = rowHeight;
406 return { charWidth, rowHeight };
407 }
408 _setupResizeObserver() {
409 const initial = this._measureCharSize();
410 if (!initial)
411 return;
412 let { charWidth, rowHeight } = initial;
413 this.resizeObserver = new ResizeObserver((entries) => {
414 const measured = this._measureCharSize();
415 if (measured) {
416 charWidth = measured.charWidth;
417 rowHeight = measured.rowHeight;
418 }
419 for (const entry of entries) {
420 const { width, height } = entry.contentRect;
421 const newCols = Math.max(1, Math.floor(width / charWidth));
422 const newRows = Math.max(1, Math.floor(height / rowHeight));
423 if (newCols !== this.cols || newRows !== this.rows) {
424 this.resize(newCols, newRows);
425 }
426 }
427 });
428 this.resizeObserver.observe(this.element);
429 }
430 destroy() {
431 this._destroyed = true;
432 this._cancelScheduledRender();
433 this._cancelSynchronizedOutputFallback();
434 if (this.resizeObserver)
435 this.resizeObserver.disconnect();
436 if (this.input)
437 this.input.destroy();
438 this.element.removeEventListener("click", this._onClickFocus);
439 this.element.removeEventListener("scroll", this._onScroll);
440 this.element.ownerDocument.removeEventListener("keydown", this._onModifierChange);
441 this.element.ownerDocument.removeEventListener("keyup", this._onModifierChange);
442 this.element.ownerDocument.defaultView?.removeEventListener("blur", this._onWindowBlur);
443 this.element.classList.remove("link-modifier-active");
444 this.element.innerHTML = "";
445 if (this.debug &&
446 globalThis.__wterm === this) {
447 delete globalThis.__wterm;
448 }
449 this.debug = null;
450 }
451}