Files
paseo/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
T

696 lines
21 KiB
TypeScript

import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
mergeTerminalModifiers,
normalizeDomTerminalKey,
normalizeTerminalTransportKey,
shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalEmulatorRuntimeTheme = {
backgroundColor: string;
foregroundColor: string;
cursorColor: string;
};
export type TerminalEmulatorRuntimeMountInput = {
root: HTMLDivElement;
host: HTMLDivElement;
initialOutputText: string;
theme: TerminalEmulatorRuntimeTheme;
};
export type TerminalEmulatorRuntimeCallbacks = {
onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
};
type TerminalEmulatorRuntimeDisposables = {
disposeInput: () => void;
disconnectResizeObserver: () => void;
removeWindowResize: () => void;
removeVisualViewportResize: () => void;
clearFitInterval: () => void;
clearFitTimeouts: () => void;
removeFontListeners: () => void;
removeTouchListeners: () => void;
restoreDocumentStyles: () => void;
restoreViewportStyles: () => void;
disposeFitAddon: () => void;
disposeTerminal: () => void;
};
type TerminalOutputOperation = {
type: "write" | "clear";
text: string;
onCommitted?: () => void;
};
declare global {
interface Window {
__paseoTerminal?: Terminal;
}
}
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
export class TerminalEmulatorRuntime {
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
private pendingModifiers: PendingTerminalModifiers = {
ctrl: false,
shift: false,
alt: false,
};
private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null;
private outputOperations: TerminalOutputOperation[] = [];
private inFlightOutputOperation: TerminalOutputOperation | null = null;
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
this.callbacks = input.callbacks;
}
setPendingModifiers(input: { pendingModifiers: PendingTerminalModifiers }): void {
this.pendingModifiers = input.pendingModifiers;
}
mount(input: TerminalEmulatorRuntimeMountInput): void {
terminalDebugLog({
scope: "emulator-runtime",
event: "mount:start",
details: {
initialOutputLength: input.initialOutputText.length,
},
});
this.unmount();
input.host.innerHTML = "";
this.lastSize = null;
const terminal = new Terminal({
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
cursorStyle: "bar",
fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
fontSize: 13,
lineHeight: 1.25,
scrollback: 10_000,
theme: {
background: input.theme.backgroundColor,
foreground: input.theme.foregroundColor,
cursor: input.theme.cursorColor,
},
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(input.host);
const restoreDocumentStyles = this.applyDocumentBoundsStyles({
root: input.root,
});
const restoreViewportStyles = this.applyViewportTouchStyles({
host: input.host,
});
this.terminal = terminal;
this.fitAddon = fitAddon;
window.__paseoTerminal = terminal;
const fitAndEmitResize = (force: boolean): void => {
const currentTerminal = this.terminal;
const currentFitAddon = this.fitAddon;
if (!currentTerminal || !currentFitAddon) {
return;
}
try {
currentFitAddon.fit();
} catch {
return;
}
const nextRows = currentTerminal.rows;
const nextCols = currentTerminal.cols;
const previous = this.lastSize;
if (!force && previous && previous.rows === nextRows && previous.cols === nextCols) {
return;
}
this.lastSize = { rows: nextRows, cols: nextCols };
terminalDebugLog({
scope: "emulator-runtime",
event: "resize:emit",
details: {
rows: nextRows,
cols: nextCols,
force,
},
});
this.callbacks.onResize?.({
rows: nextRows,
cols: nextCols,
});
};
fitAndEmitResize(true);
const inputDisposable = terminal.onData((data) => {
terminalDebugLog({
scope: "emulator-runtime",
event: "input:onData",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 64 }),
},
});
this.callbacks.onInput?.(data);
});
terminal.attachCustomKeyEventHandler((event) => {
if (event.type !== "keydown" || event.isComposing) {
return true;
}
const normalizedKey = normalizeDomTerminalKey(event.key);
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
return true;
}
if (
!shouldInterceptDomTerminalKey({
key: normalizedKey,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
pendingModifiers: this.pendingModifiers,
})
) {
return true;
}
const modifiers = mergeTerminalModifiers({
pendingModifiers: this.pendingModifiers,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
});
this.callbacks.onTerminalKey?.({
key: normalizeTerminalTransportKey(normalizedKey),
...modifiers,
});
terminalDebugLog({
scope: "emulator-runtime",
event: "key:intercepted",
details: {
key: normalizedKey,
ctrl: modifiers.ctrl,
shift: modifiers.shift,
alt: modifiers.alt,
meta: modifiers.meta,
},
});
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
this.callbacks.onPendingModifiersConsumed?.();
}
event.preventDefault();
event.stopPropagation();
return false;
});
const removeTouchListeners = this.setupTouchScrollHandlers({
root: input.root,
host: input.host,
terminal,
});
const resizeObserver = new ResizeObserver(() => {
fitAndEmitResize(false);
});
resizeObserver.observe(input.root);
resizeObserver.observe(input.host);
const windowResizeHandler = () => fitAndEmitResize(false);
window.addEventListener("resize", windowResizeHandler);
const visualViewport = window.visualViewport;
const visualViewportResizeHandler = () => fitAndEmitResize(false);
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
const fitInterval = window.setInterval(() => {
fitAndEmitResize(false);
}, 250);
const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) =>
window.setTimeout(() => {
fitAndEmitResize(true);
}, delayMs)
);
const fontSet = document.fonts;
const fontReadyHandler = () => {
fitAndEmitResize(true);
};
fontSet?.addEventListener?.("loadingdone", fontReadyHandler);
void fontSet?.ready
.then(() => {
fitAndEmitResize(true);
})
.catch(() => {
// no-op
});
window.setTimeout(() => {
fitAndEmitResize(true);
}, 0);
if (input.initialOutputText.length > 0) {
terminal.write(input.initialOutputText);
terminalDebugLog({
scope: "emulator-runtime",
event: "output:initial-write",
details: {
length: input.initialOutputText.length,
preview: summarizeTerminalText({
text: input.initialOutputText,
maxChars: 96,
}),
},
});
}
this.processOutputQueue();
const disposables: TerminalEmulatorRuntimeDisposables = {
disposeInput: () => {
inputDisposable.dispose();
},
disconnectResizeObserver: () => {
resizeObserver.disconnect();
},
removeWindowResize: () => {
window.removeEventListener("resize", windowResizeHandler);
},
removeVisualViewportResize: () => {
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
},
clearFitInterval: () => {
window.clearInterval(fitInterval);
},
clearFitTimeouts: () => {
for (const handle of fitTimeouts) {
window.clearTimeout(handle);
}
},
removeFontListeners: () => {
fontSet?.removeEventListener?.("loadingdone", fontReadyHandler);
},
removeTouchListeners,
restoreDocumentStyles,
restoreViewportStyles,
disposeFitAddon: () => {
fitAddon.dispose();
},
disposeTerminal: () => {
terminal.dispose();
},
};
this.cleanup = () => {
disposables.disposeInput();
disposables.disconnectResizeObserver();
disposables.removeWindowResize();
disposables.removeVisualViewportResize();
disposables.clearFitInterval();
disposables.clearFitTimeouts();
disposables.removeFontListeners();
disposables.removeTouchListeners();
disposables.disposeFitAddon();
disposables.disposeTerminal();
disposables.restoreDocumentStyles();
disposables.restoreViewportStyles();
};
}
write(input: { text: string; onCommitted?: () => void }): void {
if (input.text.length === 0) {
input.onCommitted?.();
return;
}
this.outputOperations.push({
type: "write",
text: input.text,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
scope: "emulator-runtime",
event: "output:enqueue",
details: {
chunkLength: input.text.length,
queueLength: this.outputOperations.length,
preview: summarizeTerminalText({ text: input.text, maxChars: 64 }),
},
});
this.processOutputQueue();
}
clear(input?: { onCommitted?: () => void }): void {
this.outputOperations.push({
type: "clear",
text: "",
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
scope: "emulator-runtime",
event: "output:clear",
});
this.processOutputQueue();
}
focus(): void {
this.terminal?.focus();
}
unmount(): void {
terminalDebugLog({
scope: "emulator-runtime",
event: "mount:unmount",
details: {
pendingWriteOperations: this.outputOperations.length,
},
});
this.clearInFlightOutputTimeout();
const inFlightOperation = this.inFlightOutputOperation;
this.inFlightOutputOperation = null;
if (inFlightOperation?.onCommitted) {
inFlightOperation.onCommitted();
}
const pendingOperations = this.outputOperations.splice(0, this.outputOperations.length);
for (const operation of pendingOperations) {
operation.onCommitted?.();
}
this.cleanup?.();
this.cleanup = null;
if (window.__paseoTerminal === this.terminal) {
window.__paseoTerminal = undefined;
}
this.terminal = null;
this.fitAddon = null;
this.lastSize = null;
}
private processOutputQueue(): void {
if (this.inFlightOutputOperation) {
return;
}
const terminal = this.terminal;
if (!terminal) {
return;
}
const operation = this.outputOperations.shift();
if (!operation) {
return;
}
this.inFlightOutputOperation = operation;
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
if (this.inFlightOutputOperation !== expectedOperation) {
return;
}
this.inFlightOutputOperation = null;
this.clearInFlightOutputTimeout();
expectedOperation.onCommitted?.();
this.processOutputQueue();
};
if (operation.type === "clear") {
terminal.reset();
finalizeOperation(operation);
return;
}
const text = operation.text;
terminalDebugLog({
scope: "emulator-runtime",
event: "output:flush",
details: {
length: text.length,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.inFlightOutputOperationTimeout = setTimeout(() => {
finalizeOperation(operation);
}, OUTPUT_OPERATION_TIMEOUT_MS);
try {
terminal.write(text, () => {
finalizeOperation(operation);
});
} catch {
finalizeOperation(operation);
}
}
private clearInFlightOutputTimeout(): void {
if (!this.inFlightOutputOperationTimeout) {
return;
}
clearTimeout(this.inFlightOutputOperationTimeout);
this.inFlightOutputOperationTimeout = null;
}
private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void {
const documentElement = document.documentElement;
const body = document.body;
const rootContainer = input.root.parentElement;
const previousDocumentElementOverflow = documentElement.style.overflow;
const previousDocumentElementWidth = documentElement.style.width;
const previousDocumentElementHeight = documentElement.style.height;
const previousBodyOverflow = body.style.overflow;
const previousBodyWidth = body.style.width;
const previousBodyHeight = body.style.height;
const previousBodyMargin = body.style.margin;
const previousBodyPadding = body.style.padding;
const previousRootOverflow = rootContainer?.style.overflow ?? "";
const previousRootWidth = rootContainer?.style.width ?? "";
const previousRootHeight = rootContainer?.style.height ?? "";
documentElement.style.overflow = "hidden";
documentElement.style.width = "100%";
documentElement.style.height = "100%";
body.style.overflow = "hidden";
body.style.width = "100%";
body.style.height = "100%";
body.style.margin = "0";
body.style.padding = "0";
if (rootContainer) {
rootContainer.style.overflow = "hidden";
rootContainer.style.width = "100%";
rootContainer.style.height = "100%";
}
return () => {
documentElement.style.overflow = previousDocumentElementOverflow;
documentElement.style.width = previousDocumentElementWidth;
documentElement.style.height = previousDocumentElementHeight;
body.style.overflow = previousBodyOverflow;
body.style.width = previousBodyWidth;
body.style.height = previousBodyHeight;
body.style.margin = previousBodyMargin;
body.style.padding = previousBodyPadding;
if (rootContainer) {
rootContainer.style.overflow = previousRootOverflow;
rootContainer.style.width = previousRootWidth;
rootContainer.style.height = previousRootHeight;
}
};
}
private applyViewportTouchStyles(input: { host: HTMLDivElement }): () => void {
const viewportElement = input.host.querySelector<HTMLElement>(".xterm-viewport");
const screenElement = input.host.querySelector<HTMLElement>(".xterm-screen");
const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? "";
const previousViewportTouchAction = viewportElement?.style.touchAction ?? "";
const previousViewportOverflowY = viewportElement?.style.overflowY ?? "";
const previousViewportOverflowX = viewportElement?.style.overflowX ?? "";
const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? "";
const previousViewportWebkitOverflowScrolling =
viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? "";
const previousScreenPointerEvents = screenElement?.style.pointerEvents ?? "";
if (viewportElement) {
viewportElement.style.overscrollBehavior = "none";
viewportElement.style.touchAction = "pan-y";
viewportElement.style.overflowY = "auto";
viewportElement.style.overflowX = "hidden";
viewportElement.style.pointerEvents = "auto";
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
}
if (screenElement) {
screenElement.style.pointerEvents = "none";
}
return () => {
if (viewportElement) {
viewportElement.style.overscrollBehavior = previousViewportOverscroll;
viewportElement.style.touchAction = previousViewportTouchAction;
viewportElement.style.overflowY = previousViewportOverflowY;
viewportElement.style.overflowX = previousViewportOverflowX;
viewportElement.style.pointerEvents = previousViewportPointerEvents;
viewportElement.style.setProperty(
"-webkit-overflow-scrolling",
previousViewportWebkitOverflowScrolling
);
}
if (screenElement) {
screenElement.style.pointerEvents = previousScreenPointerEvents;
}
};
}
private setupTouchScrollHandlers(input: {
root: HTMLDivElement;
host: HTMLDivElement;
terminal: Terminal;
}): () => void {
let touchScrollRemainderPx = 0;
const measuredLineHeight =
input.host.querySelector<HTMLElement>(".xterm-rows > div")?.getBoundingClientRect()
.height ?? 0;
const touchScrollLineHeightPx =
measuredLineHeight > 0
? measuredLineHeight
: DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX;
const activeTouch = {
identifier: -1,
startX: 0,
startY: 0,
lastX: 0,
lastY: 0,
mode: null as "vertical" | "horizontal" | null,
};
const touchStartHandler = (event: TouchEvent) => {
if (event.touches.length !== 1) {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
return;
}
const touch = event.touches[0];
if (!touch) {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
return;
}
activeTouch.identifier = touch.identifier;
activeTouch.startX = touch.clientX;
activeTouch.startY = touch.clientY;
activeTouch.lastX = touch.clientX;
activeTouch.lastY = touch.clientY;
activeTouch.mode = null;
touchScrollRemainderPx = 0;
};
const touchMoveHandler = (event: TouchEvent) => {
if (event.touches.length !== 1) {
return;
}
const touch = Array.from(event.touches).find(
(candidate) => candidate.identifier === activeTouch.identifier
);
if (!touch) {
return;
}
const totalDeltaX = touch.clientX - activeTouch.startX;
const totalDeltaY = touch.clientY - activeTouch.startY;
if (activeTouch.mode === null) {
const absX = Math.abs(totalDeltaX);
const absY = Math.abs(totalDeltaY);
if (absX > 8 || absY > 8) {
activeTouch.mode = absY >= absX ? "vertical" : "horizontal";
}
}
const deltaY = touch.clientY - activeTouch.lastY;
activeTouch.lastX = touch.clientX;
activeTouch.lastY = touch.clientY;
if (activeTouch.mode !== "vertical") {
return;
}
touchScrollRemainderPx += deltaY;
const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx);
if (lineDelta !== 0) {
input.terminal.scrollLines(-lineDelta);
touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx;
}
event.preventDefault();
};
const touchEndHandler = (event: TouchEvent) => {
const activeTouchEnded = Array.from(event.changedTouches).some(
(touch) => touch.identifier === activeTouch.identifier
);
if (activeTouchEnded || event.touches.length === 0) {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
}
};
const touchCancelHandler = () => {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
};
input.root.addEventListener("touchstart", touchStartHandler, { passive: true });
input.root.addEventListener("touchmove", touchMoveHandler, { passive: false });
input.root.addEventListener("touchend", touchEndHandler, { passive: true });
input.root.addEventListener("touchcancel", touchCancelHandler, { passive: true });
return () => {
input.root.removeEventListener("touchstart", touchStartHandler);
input.root.removeEventListener("touchmove", touchMoveHandler);
input.root.removeEventListener("touchend", touchEndHandler);
input.root.removeEventListener("touchcancel", touchCancelHandler);
};
}
}