feat(terminal): replace ghostty-web with an in-repo libghostty-vt adapter
The terminal ran on the ghostty-web npm package plus a hand-written patch, and every rendering bug (recycled rows, duplicated reflow fragments, prompt artifacts) had to be worked around from outside. The emulator now is the official libghostty-vt C ABI compiled to WebAssembly, driven by a browser adapter ported from T3 Code (MIT, notice in LICENSE-T3CODE) and owned in packages/ui/src/lib/ghostty. The artifact is reproducible with scripts/build-libghostty-wasm.sh, including a workaround for Zig 0.15.2 on macOS 27 SDKs. On top of the port: one WASM instance per page with every tab kept mounted and hidden tabs paused; history replayed at the PTY size it was drawn for; shells spawned only after the first fitted grid so zsh never prints the PROMPT_SP marker; box drawing, block elements and Powerline arrows drawn procedurally to the exact cell so TUI borders and block logos have no gaps between rows; a software-rasterized canvas so Gecko renders every tab's text with the same smoothing; the symbols-only Nerd Font bundled instead of a CDN fetch; touch selection and scrolling driven through the surface API; a copy button in the tab strip for touch hosts; localized aria labels. Testing: bun tests run the real WASM (reflow, palette, replay isolation, recycled rows, box glyph geometry); viewport and view tests use a surface double; verified in Chromium and Zen (windowed and headless) for crisp text, new tabs, panel reopen, resize and box glyph rendering; package type-check, oxlint/eslint on new files, web build.
This commit is contained in:
@@ -1,89 +1,88 @@
|
||||
import React from 'react';
|
||||
import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { loadMonoFont } from '@/lib/fontLoader';
|
||||
import type { MonoFontOption } from '@/lib/fontOptions';
|
||||
import type { TerminalTheme } from '@/lib/terminalTheme';
|
||||
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
||||
import {
|
||||
getGhosttySafeResetSequence,
|
||||
rewriteGhosttyDefaultBackgroundResets,
|
||||
} from '@/lib/terminalOutput';
|
||||
import {
|
||||
getTerminalCellFromPoint,
|
||||
getTerminalWordRange,
|
||||
type TerminalCellPosition,
|
||||
} from '@/lib/terminalTouchSelection';
|
||||
import { toGhosttyTheme } from '@/lib/terminalTheme';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||
|
||||
import { selectTerminalChunkReplay } from './terminalChunkReplay';
|
||||
|
||||
// ghostty-web (638 KB raw of JS + the WASM VT) loads on demand: TerminalView
|
||||
// stays eagerly importable for the bottom dock without pulling the emulator
|
||||
// The libghostty-vt adapter (WASM VT + canvas renderer) loads on demand so the
|
||||
// bottom dock can import TerminalView eagerly without pulling the emulator
|
||||
// into the startup graph before a terminal is actually mounted.
|
||||
type GhosttyModule = typeof import('ghostty-web');
|
||||
type GhosttyRuntime = { module: GhosttyModule; ghostty: Ghostty };
|
||||
let ghosttyRuntimePromise: Promise<GhosttyRuntime> | null = null;
|
||||
const loadGhostty = (): Promise<GhosttyRuntime> =>
|
||||
ghosttyRuntimePromise ??= import('ghostty-web').then(async (module) => ({
|
||||
module,
|
||||
ghostty: await module.Ghostty.load(),
|
||||
}));
|
||||
type GhosttyTerminalSurface = import('@/lib/ghostty/surface').GhosttyTerminalSurface;
|
||||
type GhosttyTerminalSurfaceOptions = import('@/lib/ghostty/surface').GhosttyTerminalSurfaceOptions;
|
||||
|
||||
// Wait briefly for both the selected mono font and the web entry's deferred
|
||||
// Nerd Fonts before Ghostty measures glyphs. A cold CDN fetch must not block
|
||||
// opening the terminal, so the renderer starts after the bound and is rebuilt
|
||||
// once the fonts arrive. Runtimes without the Nerd Font hook resolve it at once.
|
||||
const TERMINAL_FONT_WAIT_MS = 2000;
|
||||
const loadNerdFonts = (): Promise<void> =>
|
||||
Promise.resolve(window.__openchamberEnsureNerdFonts?.()).catch(() => undefined);
|
||||
/** The subset of the surface the viewport drives; tests inject a double. */
|
||||
export type TerminalSurface = Pick<
|
||||
GhosttyTerminalSurface,
|
||||
| 'write'
|
||||
| 'resetAndWrite'
|
||||
| 'setTheme'
|
||||
| 'setFont'
|
||||
| 'setVisible'
|
||||
| 'fit'
|
||||
| 'refresh'
|
||||
| 'focus'
|
||||
| 'getSelection'
|
||||
| 'getSelectionPosition'
|
||||
| 'scrollLines'
|
||||
| 'selectWordAt'
|
||||
| 'extendSelectionTo'
|
||||
| 'dispose'
|
||||
>;
|
||||
|
||||
const waitForTerminalFonts = (font: MonoFontOption) => {
|
||||
const loaded = Promise.all([loadMonoFont(font), loadNerdFonts()]).then(() => undefined);
|
||||
const loadedBeforeTimeout = new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(false), TERMINAL_FONT_WAIT_MS);
|
||||
void loaded.then(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
return { loaded, loadedBeforeTimeout };
|
||||
export type TerminalSurfaceFactory = (
|
||||
mount: HTMLElement,
|
||||
options: GhosttyTerminalSurfaceOptions,
|
||||
) => Promise<TerminalSurface>;
|
||||
|
||||
const createGhosttySurface: TerminalSurfaceFactory = async (mount, options) => {
|
||||
const { GhosttyTerminalSurface } = await import('@/lib/ghostty/surface');
|
||||
return GhosttyTerminalSurface.create(mount, options);
|
||||
};
|
||||
|
||||
// The selected mono face loads from the app bundle, so this normally resolves
|
||||
// at once. A stalled fetch must not keep the terminal from opening: after the
|
||||
// bound the surface measures with whatever faces are available and refits
|
||||
// when the face arrives (document.fonts "loadingdone").
|
||||
const TERMINAL_FONT_WAIT_MS = 2000;
|
||||
const waitForMonoFont = (font: MonoFontOption): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, TERMINAL_FONT_WAIT_MS);
|
||||
void loadMonoFont(font).finally(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
type TerminalSize = { cols: number; rows: number };
|
||||
|
||||
const CONTENT_PADDING = 4;
|
||||
|
||||
const getProvisionalTerminalSize = (
|
||||
container: HTMLDivElement,
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
): TerminalSize | null => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return null;
|
||||
|
||||
const context = document.createElement('canvas').getContext('2d');
|
||||
const context = container.ownerDocument.createElement('canvas').getContext('2d');
|
||||
if (!context || container.clientWidth < 24 || container.clientHeight < 24) return null;
|
||||
|
||||
context.font = `${fontSize}px ${fontFamily}`;
|
||||
const metrics = context.measureText('M');
|
||||
const cellWidth = Math.ceil(metrics.width);
|
||||
const cellHeight = Math.ceil(
|
||||
(metrics.actualBoundingBoxAscent || fontSize * 0.8) +
|
||||
(metrics.actualBoundingBoxDescent || fontSize * 0.2),
|
||||
) + 2;
|
||||
const cellWidth = metrics.width;
|
||||
const glyphHeight = (metrics.actualBoundingBoxAscent || fontSize * 0.8) + (metrics.actualBoundingBoxDescent || fontSize * 0.2);
|
||||
// Mirrors measureGhosttyCell: the line height is the larger of 1.35em and the glyph box.
|
||||
const cellHeight = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight));
|
||||
if (cellWidth < 1 || cellHeight < 1) return null;
|
||||
|
||||
const style = window.getComputedStyle(container);
|
||||
const horizontalPadding =
|
||||
(Number.parseInt(style.paddingLeft, 10) || 0) +
|
||||
(Number.parseInt(style.paddingRight, 10) || 0);
|
||||
const verticalPadding =
|
||||
(Number.parseInt(style.paddingTop, 10) || 0) +
|
||||
(Number.parseInt(style.paddingBottom, 10) || 0);
|
||||
|
||||
// Match Ghostty FitAddon's 15px scrollbar reservation and minimum dimensions.
|
||||
return {
|
||||
cols: Math.max(2, Math.floor((container.clientWidth - horizontalPadding - 15) / cellWidth)),
|
||||
rows: Math.max(1, Math.floor((container.clientHeight - verticalPadding) / cellHeight)),
|
||||
cols: Math.max(2, Math.floor((container.clientWidth - CONTENT_PADDING * 2) / cellWidth)),
|
||||
rows: Math.max(1, Math.floor((container.clientHeight - CONTENT_PADDING * 2) / cellHeight)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -113,184 +112,84 @@ type Props = {
|
||||
enableTouchScroll?: boolean;
|
||||
autoFocus?: boolean;
|
||||
isVisible?: boolean;
|
||||
/** Surface construction, injectable for tests. */
|
||||
createSurface?: TerminalSurfaceFactory;
|
||||
};
|
||||
|
||||
const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
sessionKey, chunks, onInput, onResize, onProvisionalSize, theme, monoFont, fontFamily, fontSize, className,
|
||||
enableTouchScroll = false, autoFocus = true, isVisible = true,
|
||||
enableTouchScroll = false, autoFocus = true, isVisible = true, createSurface = createGhosttySurface,
|
||||
}, ref) => {
|
||||
const { t } = useI18n();
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const terminalRef = React.useRef<GhosttyTerminal | null>(null);
|
||||
const fitRef = React.useRef<FitAddon | null>(null);
|
||||
const surfaceRef = React.useRef<TerminalSurface | null>(null);
|
||||
const inputRef = React.useRef(onInput);
|
||||
const resizeRef = React.useRef(onResize);
|
||||
const provisionalSizeCallbackRef = React.useRef(onProvisionalSize);
|
||||
const lastSizeRef = React.useRef<TerminalSize | null>(null);
|
||||
const provisionalSizeRef = React.useRef<TerminalSize | null>(null);
|
||||
const lastChunkRef = React.useRef<number | null>(null);
|
||||
const writeQueueRef = React.useRef('');
|
||||
const outputRewriteCarryRef = React.useRef('');
|
||||
const safeResetRef = React.useRef(getGhosttySafeResetSequence(theme.background));
|
||||
const writingRef = React.useRef(false);
|
||||
// Incremented whenever the replay stream restarts, so a write completing from
|
||||
// before the restart cannot clear the in-flight flag of a newer write.
|
||||
const writeEpochRef = React.useRef(0);
|
||||
const visibleRef = React.useRef(isVisible);
|
||||
const rendererReadyRef = React.useRef(false);
|
||||
const labelsRef = React.useRef({ input: '', scrollbar: '' });
|
||||
const [ready, setReady] = React.useState(0);
|
||||
const [rendererGeneration, setRendererGeneration] = React.useState(0);
|
||||
inputRef.current = onInput;
|
||||
resizeRef.current = onResize;
|
||||
provisionalSizeCallbackRef.current = onProvisionalSize;
|
||||
visibleRef.current = isVisible;
|
||||
safeResetRef.current = getGhosttySafeResetSequence(theme.background);
|
||||
labelsRef.current = {
|
||||
input: t('terminalView.viewport.inputAria'),
|
||||
scrollbar: t('terminalView.viewport.scrollbarAria'),
|
||||
};
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const size = getProvisionalTerminalSize(container, fontFamily, fontSize);
|
||||
provisionalSizeRef.current = size;
|
||||
if (size) (provisionalSizeCallbackRef.current ?? resizeRef.current)(size.cols, size.rows);
|
||||
}, [fontFamily, fontSize]);
|
||||
|
||||
const fit = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const terminal = terminalRef.current;
|
||||
if (!container || !terminal || !fitRef.current || !visibleRef.current) return;
|
||||
const bounds = container.getBoundingClientRect();
|
||||
if (bounds.width < 24 || bounds.height < 24) return;
|
||||
try {
|
||||
fitRef.current.fit();
|
||||
const next = { cols: terminal.cols, rows: terminal.rows };
|
||||
if (!lastSizeRef.current || lastSizeRef.current.cols !== next.cols || lastSizeRef.current.rows !== next.rows) {
|
||||
lastSizeRef.current = next;
|
||||
resizeRef.current(next.cols, next.rows);
|
||||
}
|
||||
if (!rendererReadyRef.current) {
|
||||
rendererReadyRef.current = true;
|
||||
setReady((value) => value + 1);
|
||||
}
|
||||
} catch { /* hidden or detached */ }
|
||||
}, []);
|
||||
|
||||
const flush = React.useCallback(() => {
|
||||
if (writingRef.current || !writeQueueRef.current || !terminalRef.current) return;
|
||||
const terminal = terminalRef.current;
|
||||
const pending = writeQueueRef.current;
|
||||
writeQueueRef.current = '';
|
||||
const rewritten = rewriteGhosttyDefaultBackgroundResets(
|
||||
pending,
|
||||
outputRewriteCarryRef.current,
|
||||
safeResetRef.current,
|
||||
);
|
||||
outputRewriteCarryRef.current = rewritten.carry;
|
||||
if (!rewritten.data) {
|
||||
if (writeQueueRef.current) flush();
|
||||
return;
|
||||
}
|
||||
writingRef.current = true;
|
||||
const epoch = writeEpochRef.current;
|
||||
terminal.write(rewritten.data, () => {
|
||||
if (terminalRef.current !== terminal || writeEpochRef.current !== epoch) return;
|
||||
writingRef.current = false;
|
||||
if (writeQueueRef.current) flush();
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Replay discontinuities (restart, reconnect, buffer reset) only need the VT
|
||||
* state cleared. `Terminal.reset()` frees and rebuilds the WASM terminal while
|
||||
* keeping the canvas, renderer and font atlas, so prefer it over remounting the
|
||||
* whole terminal; the generation bump remains the fallback before the terminal
|
||||
* exists.
|
||||
*/
|
||||
const recreateRenderer = React.useCallback(() => {
|
||||
lastChunkRef.current = null;
|
||||
writeQueueRef.current = '';
|
||||
outputRewriteCarryRef.current = '';
|
||||
writingRef.current = false;
|
||||
writeEpochRef.current += 1;
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) {
|
||||
setRendererGeneration((value) => value + 1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
terminal.reset();
|
||||
const safeReset = safeResetRef.current;
|
||||
if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`);
|
||||
} catch {
|
||||
setRendererGeneration((value) => value + 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The surface lives for the whole mount. Theme and font changes are applied
|
||||
// in place below; only the container identity and the factory can recreate it.
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
let disposed = false;
|
||||
let terminal: GhosttyTerminal | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let fitFrame: number | null = null;
|
||||
let subscriptions: Array<{ dispose: () => void }> = [];
|
||||
const handleFocusIn = () => {
|
||||
if (terminal && visibleRef.current) terminal.options.cursorBlink = true;
|
||||
};
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
if (event.relatedTarget instanceof Node && container.contains(event.relatedTarget)) return;
|
||||
if (terminal) terminal.options.cursorBlink = false;
|
||||
};
|
||||
const handleWindowFocus = () => {
|
||||
if (terminal && visibleRef.current && container.contains(document.activeElement)) {
|
||||
terminal.options.cursorBlink = true;
|
||||
}
|
||||
};
|
||||
const handleWindowBlur = () => {
|
||||
if (terminal) terminal.options.cursorBlink = false;
|
||||
};
|
||||
let surface: TerminalSurface | null = null;
|
||||
const initialTheme = theme;
|
||||
const initialFont = { family: fontFamily, size: fontSize };
|
||||
const initialMonoFont = monoFont;
|
||||
const ownsTouch = !enableTouchScroll;
|
||||
|
||||
container.addEventListener('focusin', handleFocusIn);
|
||||
container.addEventListener('focusout', handleFocusOut);
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
|
||||
const fonts = waitForTerminalFonts(monoFont);
|
||||
Promise.all([loadGhostty(), fonts.loadedBeforeTimeout]).then(([{ module, ghostty }, fontsLoaded]) => {
|
||||
void (async () => {
|
||||
await waitForMonoFont(initialMonoFont);
|
||||
if (disposed) return;
|
||||
terminal = new module.Terminal({
|
||||
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
|
||||
...(provisionalSizeRef.current ?? {}),
|
||||
});
|
||||
const fitAddon = new module.FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(container);
|
||||
// ghostty-web marks the container contenteditable for touch IME input but
|
||||
// sets autocapitalize/autocorrect only on its hidden textarea. Mobile
|
||||
// keyboards (iOS and Android) therefore auto-capitalize the first letter
|
||||
// of every terminal command; disable IME text mangling on the container.
|
||||
container.setAttribute('autocapitalize', 'off');
|
||||
container.setAttribute('autocorrect', 'off');
|
||||
container.setAttribute('spellcheck', 'false');
|
||||
terminalRef.current = terminal;
|
||||
fitRef.current = fitAddon;
|
||||
subscriptions = [terminal.onData((data) => inputRef.current(data))];
|
||||
observer = new ResizeObserver(() => {
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(fit, 80);
|
||||
});
|
||||
observer.observe(container);
|
||||
fit();
|
||||
const safeReset = safeResetRef.current;
|
||||
if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`);
|
||||
fitFrame = requestAnimationFrame(fit);
|
||||
if (!fontsLoaded) {
|
||||
void fonts.loaded.then(() => {
|
||||
if (!disposed && terminalRef.current === terminal) {
|
||||
setRendererGeneration((value) => value + 1);
|
||||
}
|
||||
let created: TerminalSurface;
|
||||
try {
|
||||
created = await createSurface(container, {
|
||||
theme: toGhosttyTheme(initialTheme),
|
||||
font: initialFont,
|
||||
get visible() {
|
||||
return visibleRef.current;
|
||||
},
|
||||
labels: labelsRef.current,
|
||||
handleTouchPointer: ownsTouch,
|
||||
onData: (data) => inputRef.current(data),
|
||||
onResize: (cols, rows) => resizeRef.current(cols, rows),
|
||||
onLinkActivate: (text) => {
|
||||
void openExternalUrl(text);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[terminal] failed to initialize the terminal renderer', error);
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (disposed) {
|
||||
created.dispose();
|
||||
return;
|
||||
}
|
||||
surface = created;
|
||||
surfaceRef.current = created;
|
||||
created.setVisible(visibleRef.current);
|
||||
setReady((value) => value + 1);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
@@ -301,6 +200,7 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && container.contains(active)) {
|
||||
active.blur();
|
||||
// SAFETY: the Capacitor bridge installs window.Capacitor with getPlatform() on native shells only.
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
if (capacitor?.getPlatform?.() === 'android') {
|
||||
void import('@capacitor/keyboard')
|
||||
@@ -308,120 +208,56 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
observer?.disconnect();
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||
if (fitFrame !== null) cancelAnimationFrame(fitFrame);
|
||||
container.removeEventListener('focusin', handleFocusIn);
|
||||
container.removeEventListener('focusout', handleFocusOut);
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
subscriptions.forEach((subscription) => subscription.dispose());
|
||||
terminal?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitRef.current = null;
|
||||
lastSizeRef.current = null;
|
||||
surface?.dispose();
|
||||
surface = null;
|
||||
surfaceRef.current = null;
|
||||
lastChunkRef.current = null;
|
||||
writeQueueRef.current = '';
|
||||
outputRewriteCarryRef.current = '';
|
||||
writingRef.current = false;
|
||||
writeEpochRef.current += 1;
|
||||
rendererReadyRef.current = false;
|
||||
};
|
||||
}, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]);
|
||||
// Theme, font and touch mode are applied to the live surface by the effects below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [createSurface]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!terminal || !container) return;
|
||||
terminal.options.cursorBlink = isVisible && document.hasFocus() && container.contains(document.activeElement);
|
||||
surfaceRef.current?.setTheme(toGhosttyTheme(theme));
|
||||
}, [theme, ready]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void surfaceRef.current?.setFont({ family: fontFamily, size: fontSize });
|
||||
}, [fontFamily, fontSize, ready]);
|
||||
|
||||
React.useEffect(() => {
|
||||
surfaceRef.current?.setVisible(isVisible);
|
||||
}, [isVisible, ready]);
|
||||
|
||||
/**
|
||||
* Snapshot history was laid out by the shell for the PTY size recorded on the
|
||||
* chunk. Writing it into an emulator of another width wraps or joins lines the
|
||||
* shell never wrapped, and the shell's later SIGWINCH redraw only repaints
|
||||
* from its own cursor row down, so the stray fragments stay on screen. Replay
|
||||
* such a chunk at its own size and let the emulator reflow back to the fitted
|
||||
* size; a subsequent PTY resize (when the sizes differ) makes the shell redraw
|
||||
* on top of a consistent screen.
|
||||
*
|
||||
* Only valid while nothing is queued: the write must not overtake bytes that
|
||||
* are still waiting for the emulator.
|
||||
*/
|
||||
const writeReplayAtDrawnSize = React.useCallback((terminal: GhosttyTerminal, chunk: TerminalChunk): boolean => {
|
||||
if (!chunk.size || writingRef.current || writeQueueRef.current) return false;
|
||||
const rewritten = rewriteGhosttyDefaultBackgroundResets(
|
||||
chunk.replayData ?? chunk.data,
|
||||
outputRewriteCarryRef.current,
|
||||
safeResetRef.current,
|
||||
);
|
||||
outputRewriteCarryRef.current = rewritten.carry;
|
||||
if (!rewritten.data) return true;
|
||||
const fitted = { cols: terminal.cols, rows: terminal.rows };
|
||||
const resizeForReplay = chunk.size.cols !== fitted.cols || chunk.size.rows !== fitted.rows;
|
||||
if (resizeForReplay) terminal.resize(chunk.size.cols, chunk.size.rows);
|
||||
try {
|
||||
terminal.write(rewritten.data);
|
||||
} finally {
|
||||
if (resizeForReplay) terminal.resize(fitted.cols, fitted.rows);
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const { reset, replay, pending } = selectTerminalChunkReplay(chunks, lastChunkRef.current);
|
||||
if (reset) recreateRenderer();
|
||||
if (pending.length === 0) return;
|
||||
const queued = replay && writeReplayAtDrawnSize(terminal, pending[0]) ? pending.slice(1) : pending;
|
||||
writeQueueRef.current += queued
|
||||
.map((chunk) => replay ? (chunk.replayData ?? chunk.data) : chunk.data)
|
||||
.join('');
|
||||
if (replay) {
|
||||
// Snapshot history is laid out for the PTY size recorded on its chunk;
|
||||
// the surface replays it at that size and reflows to the fitted grid.
|
||||
const [snapshot, ...live] = pending;
|
||||
surface.resetAndWrite(snapshot ? (snapshot.replayData ?? snapshot.data) : '', snapshot?.size);
|
||||
const liveData = live.map((chunk) => chunk.replayData ?? chunk.data).join('');
|
||||
if (liveData) surface.write(liveData);
|
||||
} else if (reset) {
|
||||
surface.resetAndWrite('');
|
||||
} else if (pending.length > 0) {
|
||||
surface.write(pending.map((chunk) => chunk.data).join(''));
|
||||
}
|
||||
lastChunkRef.current = chunks.at(-1)?.id ?? null;
|
||||
flush();
|
||||
}, [chunks, flush, ready, recreateRenderer, writeReplayAtDrawnSize]);
|
||||
}, [chunks, ready]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoFocus || !isVisible) return;
|
||||
const frame = requestAnimationFrame(() => terminalRef.current?.focus());
|
||||
const frame = requestAnimationFrame(() => surfaceRef.current?.focus());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [autoFocus, isVisible, ready, sessionKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!enableTouchScroll || !container) return;
|
||||
// ghostty-web only reads keydown/composition events and preventDefaults
|
||||
// beforeinput without consuming it. Android IMEs deliver text via
|
||||
// beforeinput (their keydown arrives as keyCode 229, which ghostty
|
||||
// ignores), so forward those payloads to the terminal here. Composition
|
||||
// updates are skipped: ghostty commits them itself on compositionend.
|
||||
const handleBeforeInput = (event: Event) => {
|
||||
const input = event as InputEvent;
|
||||
if (input.isComposing) return;
|
||||
switch (input.inputType) {
|
||||
case 'insertText':
|
||||
if (input.data) inputRef.current(input.data);
|
||||
break;
|
||||
case 'insertLineBreak':
|
||||
case 'insertParagraph':
|
||||
inputRef.current('\r');
|
||||
break;
|
||||
case 'deleteContentBackward':
|
||||
inputRef.current('\x7f');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
container.addEventListener('beforeinput', handleBeforeInput);
|
||||
return () => container.removeEventListener('beforeinput', handleBeforeInput);
|
||||
}, [enableTouchScroll, ready]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const terminal = terminalRef.current;
|
||||
if (!enableTouchScroll || !container || !terminal) return;
|
||||
const surface = surfaceRef.current;
|
||||
if (!enableTouchScroll || !container || !surface) return;
|
||||
let pointerId: number | null = null;
|
||||
let longPressTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let gesture: 'idle' | 'pending' | 'scrolling' | 'selecting' = 'idle';
|
||||
@@ -429,12 +265,12 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
let startY = 0;
|
||||
let lastY = 0;
|
||||
let remainder = 0;
|
||||
let selectionFocus: TerminalCellPosition | null = null;
|
||||
const lineHeight = Math.max(12, fontSize + 2);
|
||||
const lineHeight = Math.max(12, Math.round(fontSize * 1.35));
|
||||
// Android WebView only raises the soft keyboard for a native tap-focus; the
|
||||
// pointer-captured, touch-action:none tap here focuses programmatically, so
|
||||
// the IME must be summoned explicitly via the Capacitor Keyboard plugin.
|
||||
const showAndroidSoftKeyboard = () => {
|
||||
// SAFETY: the Capacitor bridge installs window.Capacitor with getPlatform() on native shells only.
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
if (capacitor?.getPlatform?.() !== 'android') return;
|
||||
void import('@capacitor/keyboard')
|
||||
@@ -446,37 +282,6 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
clearTimeout(longPressTimeout);
|
||||
longPressTimeout = null;
|
||||
};
|
||||
const cellFromPoint = (clientX: number, clientY: number) => {
|
||||
const canvas = container.querySelector('canvas');
|
||||
if (!canvas) return null;
|
||||
return getTerminalCellFromPoint(clientX, clientY, canvas.getBoundingClientRect(), terminal.cols, terminal.rows);
|
||||
};
|
||||
const dispatchSelectionMouseEvent = (
|
||||
type: 'mousedown' | 'mousemove',
|
||||
cell: TerminalCellPosition,
|
||||
) => {
|
||||
const canvas = container.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const clientX = bounds.left + ((cell.column + 0.5) / terminal.cols) * bounds.width;
|
||||
const clientY = bounds.top + ((cell.row + 0.5) / terminal.rows) * bounds.height;
|
||||
canvas.dispatchEvent(new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
clientX,
|
||||
clientY,
|
||||
}));
|
||||
};
|
||||
const finishSelection = () => {
|
||||
document.dispatchEvent(new MouseEvent('mouseup', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
}));
|
||||
};
|
||||
const down = (event: PointerEvent) => {
|
||||
if (event.pointerType !== 'touch' || pointerId !== null) return;
|
||||
pointerId = event.pointerId;
|
||||
@@ -485,35 +290,18 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
startY = event.clientY;
|
||||
lastY = event.clientY;
|
||||
remainder = 0;
|
||||
selectionFocus = null;
|
||||
container.setPointerCapture(event.pointerId);
|
||||
longPressTimeout = setTimeout(() => {
|
||||
longPressTimeout = null;
|
||||
if (pointerId !== event.pointerId || gesture !== 'pending') return;
|
||||
const cell = cellFromPoint(startX, startY);
|
||||
if (!cell) return;
|
||||
|
||||
const buffer = terminal.buffer.active;
|
||||
const lineIndex = Math.max(0, buffer.length - terminal.rows - buffer.viewportY + cell.row);
|
||||
const line = buffer.getLine(lineIndex);
|
||||
const cells = Array.from({ length: terminal.cols }, (_, column) => line?.getCell(column)?.getChars() ?? '');
|
||||
const word = getTerminalWordRange(cells, cell.column);
|
||||
const selectionAnchor = { column: word.startColumn, row: cell.row };
|
||||
selectionFocus = { column: word.endColumn, row: cell.row };
|
||||
gesture = 'selecting';
|
||||
dispatchSelectionMouseEvent('mousedown', selectionAnchor);
|
||||
dispatchSelectionMouseEvent('mousemove', selectionFocus);
|
||||
if (surface.selectWordAt(startX, startY)) gesture = 'selecting';
|
||||
}, 350);
|
||||
};
|
||||
const move = (event: PointerEvent) => {
|
||||
if (pointerId !== event.pointerId) return;
|
||||
|
||||
if (gesture === 'selecting') {
|
||||
const focus = cellFromPoint(event.clientX, event.clientY);
|
||||
if (focus && (!selectionFocus || focus.column !== selectionFocus.column || focus.row !== selectionFocus.row)) {
|
||||
selectionFocus = focus;
|
||||
dispatchSelectionMouseEvent('mousemove', focus);
|
||||
}
|
||||
surface.extendSelectionTo(event.clientX, event.clientY);
|
||||
if (event.cancelable) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -530,32 +318,23 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
lastY = event.clientY;
|
||||
remainder += delta;
|
||||
const lines = Math.trunc(remainder / lineHeight);
|
||||
if (lines) { terminal.scrollLines(lines); remainder -= lines * lineHeight; }
|
||||
if (lines) { surface.scrollLines(lines); remainder -= lines * lineHeight; }
|
||||
if (event.cancelable) event.preventDefault();
|
||||
};
|
||||
const up = (event: PointerEvent) => {
|
||||
const finish = (event: PointerEvent, focusOnTap: boolean) => {
|
||||
if (pointerId !== event.pointerId) return;
|
||||
const shouldFocus = gesture === 'pending';
|
||||
const shouldFinishSelection = gesture === 'selecting';
|
||||
const shouldFocus = focusOnTap && gesture === 'pending';
|
||||
clearLongPress();
|
||||
if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId);
|
||||
pointerId = null;
|
||||
gesture = 'idle';
|
||||
if (shouldFinishSelection) finishSelection();
|
||||
if (shouldFocus) {
|
||||
terminal.focus();
|
||||
surface.focus();
|
||||
showAndroidSoftKeyboard();
|
||||
}
|
||||
};
|
||||
const cancel = (event: PointerEvent) => {
|
||||
if (pointerId !== event.pointerId) return;
|
||||
const shouldFinishSelection = gesture === 'selecting';
|
||||
clearLongPress();
|
||||
if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId);
|
||||
pointerId = null;
|
||||
gesture = 'idle';
|
||||
if (shouldFinishSelection) finishSelection();
|
||||
};
|
||||
const up = (event: PointerEvent) => finish(event, true);
|
||||
const cancel = (event: PointerEvent) => finish(event, false);
|
||||
container.addEventListener('pointerdown', down);
|
||||
container.addEventListener('pointermove', move, { passive: false });
|
||||
container.addEventListener('pointerup', up);
|
||||
@@ -570,22 +349,27 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
}, [enableTouchScroll, fontSize, ready]);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => terminalRef.current?.focus(),
|
||||
fit,
|
||||
focus: () => surfaceRef.current?.focus(),
|
||||
fit: () => {
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
surface.fit();
|
||||
surface.refresh();
|
||||
},
|
||||
getSelection: () => {
|
||||
const terminal = terminalRef.current;
|
||||
const range = terminal?.getSelectionPosition();
|
||||
const text = terminal?.getSelection() ?? '';
|
||||
const surface = surfaceRef.current;
|
||||
const range = surface?.getSelectionPosition();
|
||||
const text = surface?.getSelection() ?? '';
|
||||
if (!range || !text.trim()) return null;
|
||||
return { text, startLine: range.start.y + 1, endLine: range.end.y + 1 };
|
||||
},
|
||||
}), [fit]);
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-terminal-owner="main"
|
||||
className={cn('terminal-viewport-container h-full w-full overflow-hidden touch-none', className)}
|
||||
className={cn('terminal-viewport-container relative h-full w-full overflow-hidden touch-none', className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user