272 lines · 8.2 KB · TypeScript
RawDownload
import { memo, useCallback, useEffect, useRef } from "react";import {  Pressable,  ScrollView,  TextInput,  View,  type LayoutChangeEvent,  type NativeSyntheticEvent,  type ViewProps,  useColorScheme,} from "react-native"; import { AppText as Text } from "../../components/AppText";import { MOBILE_TYPOGRAPHY } from "../../lib/typography";import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule";import {  buildGhosttyThemeConfig,  getPierreTerminalTheme,  type TerminalTheme,} from "./terminalTheme";import { terminalDebugLog } from "./terminalDebugLog"; interface TerminalInputEvent {  readonly data: string;} interface TerminalResizeEvent {  readonly cols: number;  readonly rows: number;} interface TerminalSurfaceProps extends ViewProps {  readonly terminalKey: string;  readonly buffer: string;  readonly fontSize?: number;  readonly isRunning: boolean;  readonly keyboardFocusRequest?: number;  readonly theme?: TerminalTheme;  readonly onInput: (data: string) => void;  readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void;} function estimateGridSize(input: {  readonly width: number;  readonly height: number;  readonly fontSize: number;}): { readonly cols: number; readonly rows: number } {  const cellWidth = input.fontSize * 0.62;  const cellHeight = input.fontSize * 1.35;  return {    cols: Math.max(20, Math.min(400, Math.floor(input.width / cellWidth))),    rows: Math.max(5, Math.min(200, Math.floor(input.height / cellHeight))),  };} const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {  const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;  const inputRef = useRef<TextInput>(null);  const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";  const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);  const statusLabel = props.isRunning    ? "Native terminal unavailable. Using text fallback."    : "Open terminal to start a shell.";   const handleLayout = (event: LayoutChangeEvent) => {    const { width, height } = event.nativeEvent.layout;    props.onResize(estimateGridSize({ width, height, fontSize }));  };   useEffect(() => {    if ((props.keyboardFocusRequest ?? 0) > 0) {      inputRef.current?.blur();      const focusFrame = requestAnimationFrame(() => inputRef.current?.focus());      return () => cancelAnimationFrame(focusFrame);    }     return undefined;  }, [props.keyboardFocusRequest]);   return (    <View      style={[        {          flex: 1,          backgroundColor: theme.background,          borderRadius: 8,          overflow: "hidden",        },        props.style,      ]}      onLayout={handleLayout}    >      <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}>        <Text          style={{            color: theme.mutedForeground,            fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,            paddingBottom: 8,          }}        >          {statusLabel}        </Text>        <ScrollView          style={{ flex: 1 }}          contentContainerStyle={{ paddingBottom: 12 }}          showsVerticalScrollIndicator={false}        >          <Text            selectable            style={{              color: theme.foreground,              fontFamily: "Menlo",              fontSize,              lineHeight: Math.round(fontSize * 1.35),            }}          >            {props.buffer || "$ "}          </Text>        </ScrollView>      </View>      <View        style={{          borderTopWidth: 1,          borderTopColor: theme.border,          flexDirection: "row",          alignItems: "center",          gap: 8,          padding: 8,        }}      >        <TextInput          ref={inputRef}          autoCapitalize="none"          autoCorrect={false}          blurOnSubmit={false}          editable={props.isRunning}          placeholder="type and press return"          placeholderTextColor={theme.mutedForeground}          returnKeyType="send"          style={{            color: theme.foreground,            flex: 1,            fontFamily: "Menlo",            fontSize: MOBILE_TYPOGRAPHY.footnote.fontSize,            padding: 0,          }}          onSubmitEditing={(event) => {            const text = event.nativeEvent.text;            if (text.length > 0) {              props.onInput(`${text}\n`);            }          }}        />        <Pressable          disabled={!props.isRunning}          style={({ pressed }) => ({            opacity: !props.isRunning ? 0.35 : pressed ? 0.65 : 1,            paddingHorizontal: 10,            paddingVertical: 6,            borderRadius: 8,            backgroundColor: theme.border,          })}          onPress={() => props.onInput("\u0003")}        >          <Text            style={{              color: theme.foreground,              fontFamily: "DMSans_700Bold",              fontSize: MOBILE_TYPOGRAPHY.caption.fontSize,            }}          >            Ctrl-C          </Text>        </Pressable>      </View>    </View>  );}); export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {  const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;  const keyboardInputRef = useRef<TextInput>(null);  const appearanceScheme = useColorScheme() === "light" ? "light" : "dark";  const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme);  const { onInput, onResize } = props;  const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView();  const hasNativeSurface = Boolean(NativeTerminalSurfaceView);   useEffect(() => {    terminalDebugLog("native:surface", {      terminalKey: props.terminalKey,      native: hasNativeSurface,      bufferLen: props.buffer.length,      isRunning: props.isRunning,    });  }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);  const handleNativeInput = useCallback(    (event: NativeSyntheticEvent<TerminalInputEvent>) => {      onInput(event.nativeEvent.data);    },    [onInput],  );  const handleNativeResize = useCallback(    (event: NativeSyntheticEvent<TerminalResizeEvent>) => {      onResize({        cols: event.nativeEvent.cols,        rows: event.nativeEvent.rows,      });    },    [onResize],  );   // Reopen focus through React and forward input normally; this avoids a native focus-command bridge.  useEffect(() => {    if (!NativeTerminalSurfaceView || (props.keyboardFocusRequest ?? 0) <= 0) {      return undefined;    }     keyboardInputRef.current?.blur();    const focusFrame = requestAnimationFrame(() => keyboardInputRef.current?.focus());    return () => cancelAnimationFrame(focusFrame);  }, [NativeTerminalSurfaceView, props.keyboardFocusRequest]);   const handleKeyboardInput = useCallback(    (data: string) => {      if (data.length > 0) {        onInput(data);        keyboardInputRef.current?.clear();      }    },    [onInput],  );   if (NativeTerminalSurfaceView) {    return (      <View style={props.style}>        <NativeTerminalSurfaceView          appearanceScheme={appearanceScheme}          backgroundColor={theme.background}          foregroundColor={theme.foreground}          mutedForegroundColor={theme.mutedForeground}          terminalKey={props.terminalKey}          initialBuffer={props.buffer}          fontSize={fontSize}          style={{ flex: 1 }}          themeConfig={buildGhosttyThemeConfig(theme)}          onInput={handleNativeInput}          onResize={handleNativeResize}        />        <TextInput          ref={keyboardInputRef}          autoCapitalize="none"          autoCorrect={false}          blurOnSubmit={false}          caretHidden          editable={props.isRunning}          keyboardType="ascii-capable"          style={{ bottom: 0, height: 1, left: 0, opacity: 0.01, position: "absolute", width: 1 }}          onChangeText={handleKeyboardInput}          onKeyPress={(event) => {            if (event.nativeEvent.key === "Backspace") {              onInput("\u007f");            }          }}          onSubmitEditing={() => onInput("\n")}        />      </View>    );  }   return <FallbackTerminalSurface {...props} fontSize={fontSize} theme={theme} />;});