fix(ui): Android soft keyboard support in terminal touch input

- summon the IME via Capacitor Keyboard.show() on tap-to-focus, since
  Android WebView only raises the keyboard for native tap-focus
- forward beforeinput text/backspace/enter to the terminal because
  Android IMEs deliver input as beforeinput with keyCode 229 keydowns,
  which ghostty-web ignores
- blur the focused terminal input and hide the keyboard before disposal
  so tearing down a focused editable mid-composition no longer wedges
  WebView touch dispatch (app froze on terminal tab close)
This commit is contained in:
Bohdan Triapitsyn
2026-07-24 22:48:59 +03:00
parent 16df9412fd
commit 9ea1a2a728
@@ -165,6 +165,20 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
return () => {
disposed = true;
// Removing a focused editable mid-IME-composition wedges Android
// WebView's input dispatch (the whole app stops responding to touch).
// Blur first so the IME detaches cleanly, and hide the soft keyboard
// explicitly on Android before the terminal DOM is torn down.
const active = document.activeElement;
if (active instanceof HTMLElement && container.contains(active)) {
active.blur();
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
if (capacitor?.getPlatform?.() === 'android') {
void import('@capacitor/keyboard')
.then(({ Keyboard }) => Keyboard.hide())
.catch(() => undefined);
}
}
observer?.disconnect();
if (resizeTimeout) clearTimeout(resizeTimeout);
if (fitFrame !== null) cancelAnimationFrame(fitFrame);
@@ -218,6 +232,36 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
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;
@@ -231,6 +275,16 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
let remainder = 0;
let selectionFocus: TerminalCellPosition | null = null;
const lineHeight = Math.max(12, fontSize + 2);
// 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 = () => {
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
if (capacitor?.getPlatform?.() !== 'android') return;
void import('@capacitor/keyboard')
.then(({ Keyboard }) => Keyboard.show())
.catch(() => undefined);
};
const clearLongPress = () => {
if (!longPressTimeout) return;
clearTimeout(longPressTimeout);
@@ -332,7 +386,10 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
pointerId = null;
gesture = 'idle';
if (shouldFinishSelection) finishSelection();
if (shouldFocus) terminal.focus();
if (shouldFocus) {
terminal.focus();
showAndroidSoftKeyboard();
}
};
const cancel = (event: PointerEvent) => {
if (pointerId !== event.pointerId) return;