Fix mobile terminal focus and background

This commit is contained in:
Mohamed Boudra
2026-05-25 19:26:25 +07:00
parent 423956c6a0
commit 2252046f56
4 changed files with 108 additions and 18 deletions
@@ -80,7 +80,7 @@ type BridgeInboundMessage =
| { type: "restoreOutput"; streamKey: string; text: string } | { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string } | { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string } | { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string } | { type: "resize"; streamKey: string }
| { type: "setTheme"; streamKey: string; theme: ITheme } | { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number } | { type: "setScrollback"; streamKey: string; lines: number }
@@ -543,19 +543,33 @@ export default function TerminalEmulator({
resetWebViewDocument(); resetWebViewDocument();
}, [resetWebViewDocument]); }, [resetWebViewDocument]);
const handleWebViewTouchStart = useCallback(() => {
webViewRef.current?.requestFocus();
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
}, [sendToWebView, streamKey]);
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
const rootStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.root, { backgroundColor }],
[backgroundColor],
);
const webViewStyle = useMemo<StyleProp<ViewStyle>>( const webViewStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webView, { backgroundColor: xtermTheme.background ?? "#0b0b0b" }], () => [styles.webView, { backgroundColor }],
[xtermTheme.background], [backgroundColor],
);
const webViewContainerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webViewContainer, { backgroundColor }],
[backgroundColor],
); );
return ( return (
<View style={styles.root} testID={testId}> <View style={rootStyle} testID={testId}>
<WebView <WebView
key={webViewEpoch} key={webViewEpoch}
ref={webViewRef} ref={webViewRef}
source={TERMINAL_WEBVIEW_SOURCE} source={TERMINAL_WEBVIEW_SOURCE}
style={webViewStyle} style={webViewStyle}
containerStyle={styles.webViewContainer} containerStyle={webViewContainerStyle}
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST} originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
scrollEnabled scrollEnabled
nestedScrollEnabled nestedScrollEnabled
@@ -564,12 +578,13 @@ export default function TerminalEmulator({
keyboardDisplayRequiresUserAction={false} keyboardDisplayRequiresUserAction={false}
automaticallyAdjustContentInsets={false} automaticallyAdjustContentInsets={false}
contentInsetAdjustmentBehavior="never" contentInsetAdjustmentBehavior="never"
textInteractionEnabled={false} textInteractionEnabled
allowsLinkPreview={false} allowsLinkPreview={false}
setSupportMultipleWindows={false} setSupportMultipleWindows={false}
setBuiltInZoomControls={false} setBuiltInZoomControls={false}
setDisplayZoomControls={false} setDisplayZoomControls={false}
textZoom={100} textZoom={100}
onTouchStart={handleWebViewTouchStart}
onMessage={handleMessage} onMessage={handleMessage}
onLoadStart={handleLoadStart} onLoadStart={handleLoadStart}
onContentProcessDidTerminate={handleContentProcessDidTerminate} onContentProcessDidTerminate={handleContentProcessDidTerminate}
@@ -585,14 +600,11 @@ const styles = StyleSheet.create({
minHeight: 0, minHeight: 0,
minWidth: 0, minWidth: 0,
overflow: "hidden", overflow: "hidden",
backgroundColor: "#0b0b0b",
}, },
webView: { webView: {
flex: 1, flex: 1,
backgroundColor: "#0b0b0b",
}, },
webViewContainer: { webViewContainer: {
flex: 1, flex: 1,
backgroundColor: "#0b0b0b",
}, },
}); });
@@ -174,6 +174,7 @@ export class TerminalEmulatorRuntime {
private suppressInput = false; private suppressInput = false;
private readonly inputModeTracker = new TerminalInputModeTracker(); private readonly inputModeTracker = new TerminalInputModeTracker();
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState(); private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
private themeBackgroundElements: HTMLElement[] = [];
private handleVisibilityRestore = (): void => { private handleVisibilityRestore = (): void => {
if (typeof document !== "undefined" && document.visibilityState !== "visible") { if (typeof document !== "undefined" && document.visibilityState !== "visible") {
@@ -252,6 +253,8 @@ export class TerminalEmulatorRuntime {
// Ligatures require Font Access API or compatible environment // Ligatures require Font Access API or compatible environment
} }
terminal.open(input.host); terminal.open(input.host);
this.themeBackgroundElements = this.collectThemeBackgroundElements(input);
this.applyThemeBackground(input.theme);
try { try {
terminal.unicode.activeVersion = "11"; terminal.unicode.activeVersion = "11";
} catch { } catch {
@@ -646,6 +649,7 @@ export class TerminalEmulatorRuntime {
return; return;
} }
this.applyThemeBackground(input.theme);
this.refreshVisibleRows(); this.refreshVisibleRows();
} }
@@ -665,8 +669,15 @@ export class TerminalEmulatorRuntime {
this.refreshVisibleRows(); this.refreshVisibleRows();
} }
focus(): void { focus(input?: { forceRefocus?: boolean }): void {
this.terminal?.focus(); const terminal = this.terminal;
if (!terminal) {
return;
}
if (input?.forceRefocus) {
terminal.blur();
}
terminal.focus();
} }
private refreshVisibleRows(): void { private refreshVisibleRows(): void {
@@ -682,6 +693,26 @@ export class TerminalEmulatorRuntime {
} }
} }
private collectThemeBackgroundElements(input: {
root: HTMLDivElement;
host: HTMLDivElement;
}): HTMLElement[] {
return [
input.root,
input.host,
...Array.from(
input.host.querySelectorAll<HTMLElement>(".xterm, .xterm-screen, .xterm-viewport"),
),
];
}
private applyThemeBackground(theme: ITheme): void {
const background = theme.background ?? "#0b0b0b";
for (const element of this.themeBackgroundElements) {
element.style.backgroundColor = background;
}
}
unmount(): void { unmount(): void {
this.clearInFlightOutputTimeout(); this.clearInFlightOutputTimeout();
const inFlightOperation = this.inFlightOutputOperation; const inFlightOperation = this.inFlightOutputOperation;
@@ -703,6 +734,7 @@ export class TerminalEmulatorRuntime {
this.fitAddon = null; this.fitAddon = null;
this.fitAndEmitResize = null; this.fitAndEmitResize = null;
this.lastSize = null; this.lastSize = null;
this.themeBackgroundElements = [];
this.suppressInput = false; this.suppressInput = false;
this.inputModeDecoder.decode(); this.inputModeDecoder.decode();
this.inputModeTracker.reset(); this.inputModeTracker.reset();
@@ -29,7 +29,7 @@ type InboundMessage =
| { type: "restoreOutput"; streamKey: string; text: string } | { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string } | { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string } | { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string } | { type: "resize"; streamKey: string }
| { type: "setTheme"; streamKey: string; theme: ITheme } | { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number } | { type: "setScrollback"; streamKey: string; lines: number }
@@ -88,10 +88,20 @@ const sendToNative = (message: OutboundMessage): void => {
window.ReactNativeWebView?.postMessage?.(JSON.stringify(message)); window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
}; };
const TERMINAL_BACKGROUND_CSS_VAR = "--paseo-terminal-background";
const DEFAULT_TERMINAL_BACKGROUND = "#0b0b0b";
function getTerminalBackground(theme: ITheme): string {
return theme.background ?? DEFAULT_TERMINAL_BACKGROUND;
}
const installStyles = (): void => { const installStyles = (): void => {
const style = document.createElement("style"); const style = document.createElement("style");
style.textContent = ` style.textContent = `
${xtermCss} ${xtermCss}
:root {
${TERMINAL_BACKGROUND_CSS_VAR}: transparent;
}
html, html,
body, body,
#terminal-root { #terminal-root {
@@ -101,7 +111,7 @@ body,
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
overscroll-behavior: none; overscroll-behavior: none;
background: #0b0b0b; background: var(${TERMINAL_BACKGROUND_CSS_VAR});
} }
#terminal-root { #terminal-root {
display: flex; display: flex;
@@ -115,6 +125,12 @@ body,
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: var(${TERMINAL_BACKGROUND_CSS_VAR});
}
#terminal-root .xterm,
#terminal-root .xterm-screen,
#terminal-root .xterm-viewport {
background-color: var(${TERMINAL_BACKGROUND_CSS_VAR}) !important;
} }
[data-terminal-scrollbar-root="true"] .xterm-viewport { [data-terminal-scrollbar-root="true"] .xterm-viewport {
scrollbar-width: none; scrollbar-width: none;
@@ -124,6 +140,17 @@ body,
width: 0; width: 0;
height: 0; height: 0;
} }
#terminal-root .xterm .xterm-helper-textarea {
opacity: 0.01;
width: 1px;
height: 1px;
min-width: 1px;
min-height: 1px;
color: transparent;
background: transparent;
caret-color: transparent;
z-index: 5 !important;
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);
}; };
@@ -148,6 +175,7 @@ class TerminalWebViewBridge {
private readonly host: HTMLDivElement, private readonly host: HTMLDivElement,
) { ) {
this.root.addEventListener("pointerdown", this.handlePointerDown, { passive: true }); this.root.addEventListener("pointerdown", this.handlePointerDown, { passive: true });
this.root.addEventListener("touchstart", this.handleTouchStart, { passive: true });
this.root.addEventListener("pointermove", this.handlePointerMove, { passive: false }); this.root.addEventListener("pointermove", this.handlePointerMove, { passive: false });
this.root.addEventListener("pointerup", this.handlePointerUp, { passive: true }); this.root.addEventListener("pointerup", this.handlePointerUp, { passive: true });
this.root.addEventListener("pointercancel", this.handlePointerUp, { passive: true }); this.root.addEventListener("pointercancel", this.handlePointerUp, { passive: true });
@@ -204,12 +232,13 @@ class TerminalWebViewBridge {
this.runtime?.clear(); this.runtime?.clear();
break; break;
case "focus": case "focus":
this.runtime?.focus(); this.runtime?.focus({ forceRefocus: message.forceRefocus });
break; break;
case "resize": case "resize":
this.runtime?.resize({ force: true }); this.runtime?.resize({ force: true });
break; break;
case "setTheme": case "setTheme":
this.applyThemeBackground(message.theme);
this.runtime?.setTheme({ theme: message.theme }); this.runtime?.setTheme({ theme: message.theme });
break; break;
case "setScrollback": case "setScrollback":
@@ -228,7 +257,7 @@ class TerminalWebViewBridge {
this.unmount(this.streamKey); this.unmount(this.streamKey);
this.streamKey = message.streamKey; this.streamKey = message.streamKey;
this.swipeGesturesEnabled = message.swipeGesturesEnabled; this.swipeGesturesEnabled = message.swipeGesturesEnabled;
document.body.style.backgroundColor = message.theme.background ?? "#0b0b0b"; this.applyThemeBackground(message.theme);
const runtime = new TerminalEmulatorRuntime(); const runtime = new TerminalEmulatorRuntime();
this.runtime = runtime; this.runtime = runtime;
@@ -266,6 +295,14 @@ class TerminalWebViewBridge {
sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true }); sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true });
} }
private applyThemeBackground(theme: ITheme): void {
const background = getTerminalBackground(theme);
document.documentElement.style.setProperty(TERMINAL_BACKGROUND_CSS_VAR, background);
document.body.style.backgroundColor = background;
this.root.style.backgroundColor = background;
this.host.style.backgroundColor = background;
}
private unmount(streamKey: string | null): void { private unmount(streamKey: string | null): void {
if (!this.runtime) { if (!this.runtime) {
return; return;
@@ -325,7 +362,11 @@ class TerminalWebViewBridge {
} }
private handlePointerDown = (event: PointerEvent): void => { private handlePointerDown = (event: PointerEvent): void => {
if (!this.swipeGesturesEnabled || !event.isPrimary) { if (!event.isPrimary) {
return;
}
this.runtime?.focus({ forceRefocus: true });
if (!this.swipeGesturesEnabled) {
return; return;
} }
this.trackingSwipe = true; this.trackingSwipe = true;
@@ -335,6 +376,12 @@ class TerminalWebViewBridge {
this.startY = event.clientY; this.startY = event.clientY;
}; };
private handleTouchStart = (event: TouchEvent): void => {
if (event.touches.length === 1) {
this.runtime?.focus({ forceRefocus: true });
}
};
private handlePointerMove = (event: PointerEvent): void => { private handlePointerMove = (event: PointerEvent): void => {
if (!this.trackingSwipe || this.firedSwipe || !this.streamKey) { if (!this.trackingSwipe || this.firedSwipe || !this.streamKey) {
return; return;
File diff suppressed because one or more lines are too long