Merge branch 'test-keyboard-layout-stuff'

This commit is contained in:
EthanShoeDev
2025-09-22 17:41:04 -04:00
6 changed files with 136 additions and 164 deletions

View File

@@ -19,6 +19,7 @@ export default function TabsLayout() {
// rippleColor={theme.colors.transparent} // rippleColor={theme.colors.transparent}
// ios // ios
// blurEffect="systemChromeMaterial" // blurEffect="systemChromeMaterial"
// disableTransparentOnScrollEdge={true}
> >
<NativeTabs.Trigger name="index"> <NativeTabs.Trigger name="index">
<Label selectedStyle={{ color: theme.colors.textPrimary }}>Hosts</Label> <Label selectedStyle={{ color: theme.colors.textPrimary }}>Hosts</Label>

View File

@@ -21,7 +21,7 @@ import {
type InputConnectionDetails, type InputConnectionDetails,
} from '@/lib/secrets-manager'; } from '@/lib/secrets-manager';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
import { useBottomTabPadding } from '@/lib/useBottomTabPadding'; import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing';
export default function TabsIndex() { export default function TabsIndex() {
return <Host />; return <Host />;
@@ -45,7 +45,7 @@ function Host() {
const sshConnMutation = useSshConnMutation({ const sshConnMutation = useSshConnMutation({
onConnectionProgress: (s) => setLastConnectionProgressEvent(s), onConnectionProgress: (s) => setLastConnectionProgressEvent(s),
}); });
const { paddingBottom, onLayout } = useBottomTabPadding(12); const marginBottom = useBottomTabSpacing();
const connectionForm = useAppForm({ const connectionForm = useAppForm({
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values // https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
defaultValues, defaultValues,
@@ -86,10 +86,9 @@ function Host() {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}> <SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
<ScrollView <ScrollView
contentContainerStyle={[{ paddingBottom }]} contentContainerStyle={[{ marginBottom }]}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
style={{ backgroundColor: theme.colors.background }} style={{ backgroundColor: theme.colors.background }}
onLayout={onLayout}
> >
<View <View
style={[ style={[

View File

@@ -12,14 +12,15 @@ import {
useFocusEffect, useFocusEffect,
} from 'expo-router'; } from 'expo-router';
import React, { startTransition, useEffect, useRef, useState } from 'react'; import React, { startTransition, useEffect, useRef, useState } from 'react';
import { Dimensions, Platform, Pressable, Text, View } from 'react-native'; import { Pressable, Text, View } from 'react-native';
import { import {
SafeAreaView, KeyboardAvoidingView,
useSafeAreaInsets, KeyboardToolbar,
} from 'react-native-safe-area-context'; } from 'react-native-keyboard-controller';
import { useSshStore } from '@/lib/ssh-store'; import { useSshStore } from '@/lib/ssh-store';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing';
export default function TabsShellDetail() { export default function TabsShellDetail() {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
@@ -104,130 +105,125 @@ function ShellDetail() {
}; };
}, [shell]); }, [shell]);
const insets = useSafeAreaInsets(); const marginBottom = useBottomTabSpacing();
const estimatedTabBarHeight = Platform.select({
ios: 49,
android: 80,
default: 56,
});
const windowH = Dimensions.get('window').height;
const computeBottomExtra = (y: number, height: number) => {
const extra = windowH - (y + height);
return extra > 0 ? extra : 0;
};
// Measure any bottom overlap (e.g., native tab bar) and add padding to avoid it
const [bottomExtra, setBottomExtra] = useState(0);
return ( return (
<SafeAreaView <>
edges={['left', 'right']} <View
onLayout={(e) => { style={{
const { y, height } = e.nativeEvent.layout; justifyContent: 'flex-start',
const extra = computeBottomExtra(y, height); backgroundColor: theme.colors.background,
if (extra !== bottomExtra) setBottomExtra(extra); paddingTop: 2,
}} paddingLeft: 8,
style={{ paddingRight: 8,
flex: 1, paddingBottom: 0,
justifyContent: 'flex-start', marginBottom,
backgroundColor: theme.colors.background, flex: 1,
paddingTop: 2,
paddingLeft: 8,
paddingRight: 8,
paddingBottom: insets.bottom + (bottomExtra || estimatedTabBarHeight),
}}
>
<Stack.Screen
options={{
headerBackVisible: true,
headerRight: () => (
<Pressable
accessibilityLabel="Disconnect"
hitSlop={10}
onPress={async () => {
if (!connection) return;
try {
await connection.disconnect();
} catch (e) {
console.warn('Failed to disconnect', e);
}
}}
>
<Ionicons name="power" size={20} color={theme.colors.primary} />
</Pressable>
),
}} }}
/> >
<XtermJsWebView <Stack.Screen
ref={xtermRef} options={{
style={{ flex: 1 }} headerBackVisible: true,
webViewOptions={{ headerRight: () => (
// Prevent iOS from adding automatic top inset inside WebView <Pressable
contentInsetAdjustmentBehavior: 'never', accessibilityLabel="Disconnect"
}} hitSlop={10}
logger={{ onPress={async () => {
log: console.log, if (!connection) return;
// debug: console.log, try {
warn: console.warn, await connection.disconnect();
error: console.error, } catch (e) {
}} console.warn('Failed to disconnect', e);
// xterm options }
xtermOptions={{ }}
theme: { >
background: theme.colors.background, <Ionicons name="power" size={20} color={theme.colors.primary} />
foreground: theme.colors.textPrimary, </Pressable>
}, ),
}} }}
onInitialized={() => { />
if (terminalReadyRef.current) return; <KeyboardAvoidingView
terminalReadyRef.current = true; behavior="height"
keyboardVerticalOffset={210}
if (!shell) throw new Error('Shell not found'); style={{ flex: 1 }}
>
// Replay from head, then attach live listener <XtermJsWebView
void (async () => { ref={xtermRef}
const res = shell.readBuffer({ mode: 'head' }); style={{ flex: 1 }}
console.log('readBuffer(head)', { webViewOptions={{
chunks: res.chunks.length, // Prevent iOS from adding automatic top inset inside WebView
nextSeq: res.nextSeq, contentInsetAdjustmentBehavior: 'never',
dropped: res.dropped, }}
}); logger={{
if (res.chunks.length) { log: console.log,
const chunks = res.chunks.map((c) => c.bytes); // debug: console.log,
const xr = xtermRef.current; warn: console.warn,
if (xr) { error: console.error,
xr.writeMany(chunks.map((c) => new Uint8Array(c))); }}
xr.flush(); // xterm options
} xtermOptions={{
} theme: {
const id = shell.addListener( background: theme.colors.background,
(ev: ListenerEvent) => { foreground: theme.colors.textPrimary,
if ('kind' in ev) {
console.log('listener.dropped', ev);
return;
}
const chunk = ev;
const xr3 = xtermRef.current;
if (xr3) xr3.write(new Uint8Array(chunk.bytes));
}, },
{ cursor: { mode: 'seq', seq: res.nextSeq } }, }}
); onInitialized={() => {
console.log('shell listener attached', id.toString()); if (terminalReadyRef.current) return;
listenerIdRef.current = id; terminalReadyRef.current = true;
})();
// Focus to pop the keyboard (iOS needs the prop we set) if (!shell) throw new Error('Shell not found');
const xr2 = xtermRef.current;
if (xr2) xr2.focus(); // Replay from head, then attach live listener
}} void (async () => {
onData={(terminalMessage) => { const res = shell.readBuffer({ mode: 'head' });
if (!shell) return; console.log('readBuffer(head)', {
const bytes = encoder.encode(terminalMessage); chunks: res.chunks.length,
shell.sendData(bytes.buffer).catch((e: unknown) => { nextSeq: res.nextSeq,
console.warn('sendData failed', e); dropped: res.dropped,
router.back(); });
}); if (res.chunks.length) {
const chunks = res.chunks.map((c) => c.bytes);
const xr = xtermRef.current;
if (xr) {
xr.writeMany(chunks.map((c) => new Uint8Array(c)));
xr.flush();
}
}
const id = shell.addListener(
(ev: ListenerEvent) => {
if ('kind' in ev) {
console.log('listener.dropped', ev);
return;
}
const chunk = ev;
const xr3 = xtermRef.current;
if (xr3) xr3.write(new Uint8Array(chunk.bytes));
},
{ cursor: { mode: 'seq', seq: res.nextSeq } },
);
console.log('shell listener attached', id.toString());
listenerIdRef.current = id;
})();
// Focus to pop the keyboard (iOS needs the prop we set)
const xr2 = xtermRef.current;
if (xr2) xr2.focus();
}}
onData={(terminalMessage) => {
if (!shell) return;
const bytes = encoder.encode(terminalMessage);
shell.sendData(bytes.buffer).catch((e: unknown) => {
console.warn('sendData failed', e);
router.back();
});
}}
/>
</KeyboardAvoidingView>
</View>
<KeyboardToolbar
offset={{
opened: -80,
}} }}
/> />
</SafeAreaView> </>
); );
} }

View File

@@ -1,36 +0,0 @@
import React from 'react';
import { Dimensions, Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type LayoutEvent = {
nativeEvent: {
layout: {
y: number;
height: number;
};
};
};
export function useBottomTabPadding(basePadding = 12) {
const insets = useSafeAreaInsets();
const windowH = Dimensions.get('window').height;
const estimatedTabBarHeight = Platform.select({
ios: 49,
android: 80,
default: 56,
});
const [bottomExtra, setBottomExtra] = React.useState(0);
const onLayout = React.useCallback(
(e: LayoutEvent) => {
const { y, height } = e.nativeEvent.layout;
const extra = windowH - (y + height);
setBottomExtra(extra > 0 ? extra : 0);
},
[windowH],
);
const paddingBottom =
basePadding + insets.bottom + (bottomExtra || estimatedTabBarHeight!);
return { paddingBottom, onLayout } as const;
}

View File

@@ -0,0 +1,12 @@
import { Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export function useBottomTabSpacing() {
const insets = useSafeAreaInsets();
const estimatedTabBarHeight = Platform.select({
ios: 49,
android: 80,
default: 56,
});
return insets.bottom + estimatedTabBarHeight;
}

View File

@@ -153,7 +153,7 @@ window.onload = () => {
window.addEventListener('message', handler); window.addEventListener('message', handler);
// Initial handshake (send once) // Initial handshake (send once)
setTimeout(() => sendToRn({ type: 'initialized' }), 50); setTimeout(() => sendToRn({ type: 'initialized' }), 100);
} catch (e) { } catch (e) {
sendToRn({ sendToRn({
type: 'debug', type: 'debug',