fix(app): adapt Android orientation and tabs to fold geometry

Use physical display size for orientation and rendered window width for pane layout. Keep the native tab row usable when text measurements do not arrive during a fold.

Co-authored-by: yz <43207690+yzim@users.noreply.github.com>
Co-authored-by: Sam Ng <hongsheng@gmail.com>
(cherry picked from commit 83f0a8219e0afee1f1e6b5084d6a37346b5c98ca)
This commit is contained in:
Mohamed Boudra
2026-09-30 15:42:07 +02:00
parent 7b7685678d
commit bf4979fd62
28 changed files with 436 additions and 102 deletions
+11
View File
@@ -21273,6 +21273,16 @@
"node": ">=10"
}
},
"node_modules/expo-screen-orientation": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-9.0.9.tgz",
"integrity": "sha512-UTU745XoqBvQJkZ820GTfMuOxlkppYqaeQ+x0wdu44cyrZiZugqe+egFF2+zC+SaxgAltU5EuO8GIj9xSF+YMw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-server": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
@@ -36473,6 +36483,7 @@
"expo-localization": "~17.0.9",
"expo-notifications": "^0.32.16",
"expo-router": "~6.0.13",
"expo-screen-orientation": "~9.0.9",
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-sqlite": "~16.0.10",
+8 -1
View File
@@ -100,7 +100,8 @@ export default {
name: variant.name,
slug: "voice-mobile",
version: nativeReleaseVersion.appVersion,
orientation: "portrait",
// Android chooses its full window before the runtime phone/fold/tablet policy applies.
orientation: "default",
icon: "./assets/images/icon.png",
scheme: "paseo",
userInterfaceStyle: "automatic",
@@ -108,6 +109,12 @@ export default {
ios: {
supportsTablet: true,
infoPlist: {
// Keep the existing iPhone restriction while the iPad orientation key
// generated by Expo continues to permit rotation.
UISupportedInterfaceOrientations: [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
],
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
ITSAppUsesNonExemptEncryption: false,
},
@@ -0,0 +1,5 @@
# Android fold-open flow
Use a task-owned foldable emulator with real `cmd device_state` CLOSED (0) and OPENED (2) states, a connected development app, and an unsent draft. The recorded run used `paseo-fold-p3a` (Pixel 9 Pro Fold, API 35). Set `DRAFT_TEXT` to the exact composer value. Run the flow after a CLOSED → OPENED transition and confirm `adb -s <serial> shell cmd device_state print-state` returns `2`. `workspace-tabs-scroll` and the Settings footer prove the wide shell is usable. A regular tablet can satisfy those UI assertions, so the device-state precondition is part of the test, not inferred from the flow.
The `.ad` flow opens the app to establish an agent-device session. For the Gboard transition check, keep the app foreground, show Gboard with a real unsent draft, change device state with `cmd device_state state 0` then `state 2`, and capture the app before reopening it. An app-open step can recover a missed native layout callback and cannot prove that the immediate transition was correct. Retain before/after screenshots, a post-transition accessibility snapshot, `dumpsys activity` window bounds, and `dumpsys input_method` IME state. Repeat with the keyboard hidden, and verify CLOSED still presents the compact tab switcher.
@@ -0,0 +1,8 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tabs-scroll\"" 12000
wait "label=\"${DRAFT_TEXT}\"" 10000
wait "label=\"Settings\"" 10000
close
@@ -0,0 +1,15 @@
apply plugin: 'com.android.library'
group = 'sh.paseo.screengeometry'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.screengeometry"
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,28 @@
package sh.paseo.screengeometry
import android.os.Build
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PaseoScreenGeometryModule : Module() {
override fun definition() = ModuleDefinition {
Name("PaseoScreenGeometry")
Function("getPhysicalScreenSize") {
val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activity.display
} else {
@Suppress("DEPRECATION")
activity.windowManager.defaultDisplay
}
val mode = display.mode
val density = activity.resources.displayMetrics.density
mapOf(
"width" to mode.physicalWidth / density,
"height" to mode.physicalHeight / density,
)
}
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["sh.paseo.screengeometry.PaseoScreenGeometryModule"]
}
}
@@ -0,0 +1,5 @@
{
"name": "paseo-screen-geometry",
"version": "0.1.0",
"private": true
}
+1
View File
@@ -95,6 +95,7 @@
"expo-localization": "~17.0.9",
"expo-notifications": "^0.32.16",
"expo-router": "~6.0.13",
"expo-screen-orientation": "~9.0.9",
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-sqlite": "~16.0.10",
@@ -0,0 +1,19 @@
import { getConfig } from "@expo/config";
import path from "node:path";
import { describe, expect, it } from "vitest";
describe("native orientation configuration", () => {
const { exp } = getConfig(path.resolve(import.meta.dirname, ".."));
it("lets Android choose the full display window before runtime orientation policy applies", () => {
expect(exp.orientation).toBe("default");
});
it("keeps the iPhone portrait orientations explicit while iPad supports rotation", () => {
expect(exp.ios?.infoPlist?.UISupportedInterfaceOrientations).toEqual([
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
]);
expect(exp.ios?.supportsTablet).toBe(true);
});
});
+23 -11
View File
@@ -15,7 +15,7 @@ import {
useState,
useSyncExternalStore,
} from "react";
import { AppState, useWindowDimensions, View } from "react-native";
import { AppState, View, type LayoutChangeEvent } from "react-native";
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -50,6 +50,7 @@ import {
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
import { MeasuredWindowWidthProvider, useLayoutWindowWidth } from "@/constants/window-width";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
@@ -87,6 +88,7 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useAdaptiveOrientation } from "@/orientation";
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
@@ -457,6 +459,7 @@ interface AppContainerProps {
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
const isOrientationReady = useAdaptiveOrientation();
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const daemons = useHosts();
const { settings, updateSettings } = useAppSettings();
@@ -466,7 +469,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const viewportWidth = useLayoutWindowWidth();
const cycleTheme = useCallback(() => {
void updateSettings({ theme: getNextThemePreference(settings.theme) });
@@ -622,6 +625,8 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
surface
);
if (!isOrientationReady) return null;
return <CommandCenterProvider>{content}</CommandCenterProvider>;
}
@@ -967,16 +972,23 @@ function RootProviders({ children }: { children: ReactNode }) {
}
function RootAppTree() {
const [measuredWindowWidth, setMeasuredWindowWidth] = useState<number | null>(null);
const handleRootLayout = useCallback((event: LayoutChangeEvent) => {
const width = event.nativeEvent.layout.width;
if (width > 0) setMeasuredWindowWidth((current) => (current === width ? current : width));
}, []);
return (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</View>
</GestureHandlerRootView>
<MeasuredWindowWidthProvider width={measuredWindowWidth}>
<GestureHandlerRootView style={flexStyle} onLayout={handleRootLayout}>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</View>
</GestureHandlerRootView>
</MeasuredWindowWidthProvider>
);
}
+2 -2
View File
@@ -6,7 +6,6 @@ import {
Pressable,
StyleSheet as RNStyleSheet,
Text,
useWindowDimensions,
View,
type PressableStateCallbackType,
} from "react-native";
@@ -29,6 +28,7 @@ import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { useLayoutWindowWidth } from "@/constants/window-width";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -678,7 +678,7 @@ function DesktopSidebar({
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const viewportWidth = useLayoutWindowWidth();
const visibleSidebarWidth = resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
+5 -5
View File
@@ -1,5 +1,6 @@
import { useUnistyles } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
import { useLayoutWindowWidth } from "@/constants/window-width";
import { isCompactWindowWidth } from "@/styles/breakpoints";
export const FOOTER_HEIGHT = 75;
@@ -34,12 +35,11 @@ export {
} from "./platform";
/**
* Reactive hook — re-renders the component when the breakpoint changes.
* Always use this instead of reading UnistylesRuntime.breakpoint directly.
* The rendered root width owns pane layout. Android fold transitions can
* leave both RN Dimensions and Unistyles' runtime breakpoint at the old size.
*/
export function useIsCompactFormFactor(): boolean {
const { rt } = useUnistyles();
return rt.breakpoint === "xs" || rt.breakpoint === "sm";
return isCompactWindowWidth(useLayoutWindowWidth());
}
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { resolveLayoutWindowWidth } from "./window-width";
describe("resolveLayoutWindowWidth", () => {
it("uses the rendered root after a fold when RN Dimensions still reports the closed width", () => {
expect(resolveLayoutWindowWidth(851.7, 443.1)).toBe(851.7);
expect(resolveLayoutWindowWidth(443.1, 851.7)).toBe(443.1);
});
it("uses RN's initial width until the root has laid out", () => {
expect(resolveLayoutWindowWidth(null, 411)).toBe(411);
expect(resolveLayoutWindowWidth(0, 411)).toBe(411);
});
});
@@ -0,0 +1,29 @@
import { createContext, type ReactNode, useContext } from "react";
import { useWindowDimensions } from "react-native";
const MeasuredWindowWidthContext = createContext<number | null>(null);
export function resolveLayoutWindowWidth(measuredWidth: number | null, dimensionsWidth: number) {
return measuredWidth !== null && measuredWidth > 0 ? measuredWidth : dimensionsWidth;
}
export function MeasuredWindowWidthProvider({
width,
children,
}: {
width: number | null;
children: ReactNode;
}) {
return (
<MeasuredWindowWidthContext.Provider value={width}>
{children}
</MeasuredWindowWidthContext.Provider>
);
}
/** The root view's actual width survives fold changes that skip RN Dimensions updates. */
export function useLayoutWindowWidth(): number {
const measuredWidth = useContext(MeasuredWindowWidthContext);
const { width: dimensionsWidth } = useWindowDimensions();
return resolveLayoutWindowWidth(measuredWidth, dimensionsWidth);
}
@@ -0,0 +1,58 @@
import * as ScreenOrientation from "expo-screen-orientation";
import { useEffect, useState } from "react";
import { Dimensions, Platform } from "react-native";
import { useLayoutWindowWidth } from "@/constants/window-width";
import { getAndroidPhysicalScreenSize } from "./screen-geometry";
import { resolveAndroidOrientationPolicy } from "./policy";
const isAndroid = Platform.OS === "android";
export function useAdaptiveOrientation(): boolean {
const layoutWindowWidth = useLayoutWindowWidth();
const [screen, setScreen] = useState(() =>
isAndroid ? getAndroidPhysicalScreenSize() : Dimensions.get("screen"),
);
const policy = resolveAndroidOrientationPolicy(screen);
const [ready, setReady] = useState(() => !isAndroid || policy === "system");
useEffect(() => {
if (!isAndroid) return;
const updateScreen = () => {
const nextScreen = getAndroidPhysicalScreenSize();
setScreen((current) =>
current.width === nextScreen.width && current.height === nextScreen.height
? current
: nextScreen,
);
};
updateScreen();
const subscription = Dimensions.addEventListener("change", updateScreen);
return () => subscription.remove();
}, [layoutWindowWidth]);
useEffect(() => {
if (!isAndroid) return;
let active = true;
const request =
policy === "portrait"
? ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP)
: ScreenOrientation.unlockAsync();
void request
.catch((error) => {
console.warn("[Orientation] Could not apply Android screen orientation:", error);
})
.finally(() => {
if (active) setReady(true);
});
return () => {
active = false;
};
}, [policy]);
// An unrestricted native cold start can briefly report a landscape phone
// window; wait for the portrait request before mounting layout content.
return ready;
}
+3
View File
@@ -0,0 +1,3 @@
export function useAdaptiveOrientation(): boolean {
return true;
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { resolveAndroidOrientationPolicy } from "./policy";
describe("Android orientation policy", () => {
it("allows a landscape tablet display at the API 35 letterbox baseline", () => {
expect(resolveAndroidOrientationPolicy({ width: 1280, height: 800 })).toBe("system");
});
it("changes with a real fold display transition in either direction", () => {
const unfolded = { width: 852, height: 883 };
const folded = { width: 443, height: 994 };
expect(resolveAndroidOrientationPolicy(unfolded)).toBe("system");
expect(resolveAndroidOrientationPolicy(folded)).toBe("portrait");
expect(resolveAndroidOrientationPolicy(unfolded)).toBe("system");
});
it("keeps a phone portrait when physically turned landscape", () => {
expect(resolveAndroidOrientationPolicy({ width: 914, height: 411 })).toBe("portrait");
});
it("uses the 600dp shortest-side threshold regardless of rotation", () => {
expect(resolveAndroidOrientationPolicy({ width: 600, height: 1000 })).toBe("system");
expect(resolveAndroidOrientationPolicy({ width: 1000, height: 599 })).toBe("portrait");
});
});
+10
View File
@@ -0,0 +1,10 @@
export type AndroidOrientationPolicy = "portrait" | "system";
// Screen size owns the Android orientation allowance. Pane layout still uses
// the available window width, including in split and floating windows.
export function resolveAndroidOrientationPolicy(screen: {
width: number;
height: number;
}): AndroidOrientationPolicy {
return Math.min(screen.width, screen.height) >= 600 ? "system" : "portrait";
}
@@ -0,0 +1,16 @@
import { requireNativeModule } from "expo-modules-core";
interface AndroidPhysicalScreenSize {
width: number;
height: number;
}
interface PaseoScreenGeometryModule {
getPhysicalScreenSize(): AndroidPhysicalScreenSize;
}
export function getAndroidPhysicalScreenSize(): AndroidPhysicalScreenSize {
return requireNativeModule<PaseoScreenGeometryModule>(
"PaseoScreenGeometry",
).getPhysicalScreenSize();
}
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { useWindowDimensions } from "react-native";
import { useLayoutWindowWidth } from "@/constants/window-width";
import {
computeWorkspaceTabLayout,
type WorkspaceTabLayoutMetrics,
@@ -7,7 +7,7 @@ import {
} from "@/screens/workspace/workspace-tab-layout";
interface UseWorkspaceTabLayoutInput {
tabLabelWidths: number[];
tabLabelWidths: (number | null)[];
viewportWidthOverride?: number | null;
metrics: WorkspaceTabLayoutMetrics;
}
@@ -19,7 +19,7 @@ interface UseWorkspaceTabLayoutResult {
export function useWorkspaceTabLayout(
input: UseWorkspaceTabLayoutInput,
): UseWorkspaceTabLayoutResult {
const { width: viewportWidth } = useWindowDimensions();
const viewportWidth = useLayoutWindowWidth();
const resolvedViewportWidth =
typeof input.viewportWidthOverride === "number" && input.viewportWidthOverride > 0
? input.viewportWidthOverride
@@ -9,7 +9,7 @@ import React, {
type Dispatch,
type SetStateAction,
} from "react";
import { Pressable, Text, View, type LayoutChangeEvent } from "react-native";
import { Pressable, Text, View, type LayoutChangeEvent, type ScrollView } from "react-native";
import {
CopyX,
ArrowLeftToLine,
@@ -49,7 +49,10 @@ import { buttonControlHeight } from "@/components/ui/control-geometry";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { Shortcut } from "@/components/ui/shortcut";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import { retainWorkspaceTabMeasuredWidth } from "@/screens/workspace/workspace-tab-layout";
import {
retainWorkspaceTabMeasuredWidth,
scrollOffsetToRevealWorkspaceTab,
} from "@/screens/workspace/workspace-tab-layout";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
@@ -459,39 +462,25 @@ interface WorkspaceTabLabelMeasurement {
width: number;
}
interface WorkspaceTabTrackSnapshot {
signature: string;
tabs: ResolvedWorkspaceDesktopTabRowItem[];
labels: WorkspaceTabLabel[];
labelWidths: number[];
}
function workspaceTabLabelSignature(labels: WorkspaceTabLabel[]): string {
return JSON.stringify(labels);
}
function completeWorkspaceTabLabelWidths(
function resolveWorkspaceTabLabelWidths(
labels: WorkspaceTabLabel[],
measurements: Map<string, WorkspaceTabLabelMeasurement>,
): number[] | null {
const widths: number[] = [];
): (number | null)[] {
const widths: (number | null)[] = [];
for (const { key, label, modified } of labels) {
const measurement = measurements.get(key);
if (!measurement || measurement.label !== label || measurement.width <= 0) {
return null;
}
const labelWidth =
measurement?.label === label && measurement.width > 0 ? measurement.width : null;
// The modified dot sits in the content row, so a modified tab needs that much more width
// before its label starts truncating.
const modifiedAllowance = modified ? TAB_CONTENT_GAP + TAB_MODIFIED_DOT_SIZE : 0;
widths.push(measurement.width + TAB_LABEL_LAYOUT_ALLOWANCE + modifiedAllowance);
widths.push(
labelWidth === null ? null : labelWidth + TAB_LABEL_LAYOUT_ALLOWANCE + modifiedAllowance,
);
}
return widths;
}
function sameWidths(left: number[], right: number[]): boolean {
return left.length === right.length && left.every((width, index) => width === right[index]);
}
interface WorkspaceDesktopTabsRowProps {
paneId?: string;
isFocused?: boolean;
@@ -536,8 +525,6 @@ interface WorkspaceDesktopTabPresentationSlotProps {
onResolve: (tabKey: string, presentation: WorkspaceTabPresentation) => void;
}
const EMPTY_RESOLVED_TAB_ROWS: ResolvedWorkspaceDesktopTabRowItem[] = [];
function WorkspaceDesktopTabPresentationSlot({
tab,
serverId,
@@ -1022,12 +1009,14 @@ function ResolvedWorkspaceDesktopTabsRow({
const { t } = useTranslation();
const newTabKeys = useShortcutKeys("workspace-tab-new");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsScrollViewportWidth, setTabsScrollViewportWidth] = useState<number>(0);
const tabsScrollRef = useRef<ScrollView>(null);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
const tabScrollBoundary = useHorizontalScrollBoundary();
const { onLayout: onTabBoundaryLayout } = tabScrollBoundary;
const [labelMeasurements, setLabelMeasurements] = useState(
() => new Map<string, WorkspaceTabLabelMeasurement>(),
);
const [trackSnapshot, setTrackSnapshot] = useState<WorkspaceTabTrackSnapshot | null>(null);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setTabsContainerWidth, event);
@@ -1101,7 +1090,6 @@ function ResolvedWorkspaceDesktopTabsRow({
}),
[fallbackTabLabels, tabs],
);
const tabLabelSignature = useMemo(() => workspaceTabLabelSignature(tabLabels), [tabLabels]);
const currentTabLabelKeys = useMemo(() => new Set(tabLabels.map(({ key }) => key)), [tabLabels]);
useEffect(() => {
setLabelMeasurements((current) => {
@@ -1116,35 +1104,6 @@ function ResolvedWorkspaceDesktopTabsRow({
return next;
});
}, [currentTabLabelKeys]);
const publishMeasuredTrack = useCallback(() => {
if (tabsContainerWidth <= 0) {
return;
}
const labelWidths = completeWorkspaceTabLabelWidths(tabLabels, labelMeasurements);
if (!labelWidths) {
return;
}
setTrackSnapshot((current) => {
if (
current?.signature === tabLabelSignature &&
sameWidths(current.labelWidths, labelWidths)
) {
return current;
}
return {
signature: tabLabelSignature,
tabs,
labels: tabLabels,
labelWidths,
};
});
}, [labelMeasurements, tabLabelSignature, tabLabels, tabs, tabsContainerWidth]);
useLayoutEffect(() => {
publishMeasuredTrack();
}, [publishMeasuredTrack]);
const handleTabLabelLayout = useCallback(
(key: string, label: string, event: LayoutChangeEvent) => {
const width = Math.ceil(event.nativeEvent.layout.width);
@@ -1164,25 +1123,46 @@ function ResolvedWorkspaceDesktopTabsRow({
[],
);
const displayedTabs = useMemo(() => {
if (!trackSnapshot) {
return EMPTY_RESOLVED_TAB_ROWS;
}
const currentTabs = new Map(
tabs.map((tab, index) => [tab.tab.key, { tab, label: tabLabels[index]?.label }]),
);
return trackSnapshot.tabs.map((snapshotTab, index) => {
const current = currentTabs.get(snapshotTab.tab.key);
return current?.label === trackSnapshot.labels[index]?.label ? current.tab : snapshotTab;
});
}, [tabLabels, tabs, trackSnapshot]);
const tabLabelWidths = useMemo(
() => resolveWorkspaceTabLabelWidths(tabLabels, labelMeasurements),
[tabLabels, labelMeasurements],
);
const displayedTabs = tabs;
const { layout } = useWorkspaceTabLayout({
tabLabelWidths: trackSnapshot?.labelWidths ?? [],
tabLabelWidths,
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
metrics: layoutMetrics,
});
const selectedTabIndex = displayedTabs.findIndex((item) => item.isActive);
const selectedTabKey = selectedTabIndex >= 0 ? displayedTabs[selectedTabIndex]?.tab.key : null;
useEffect(() => {
if (!layout.requiresHorizontalScrollFallback || selectedTabKey === null) return;
const offset = scrollOffsetToRevealWorkspaceTab(
layout.items.map((item) => item.width),
selectedTabIndex,
tabsScrollViewportWidth,
TAB_CHIP_GAP,
TAB_ROW_PADDING_HORIZONTAL,
);
tabsScrollRef.current?.scrollTo({ x: offset, animated: true });
}, [
layout.items,
layout.requiresHorizontalScrollFallback,
selectedTabIndex,
selectedTabKey,
tabsScrollViewportWidth,
]);
const handleTabsScrollLayout = useCallback(
(event: LayoutChangeEvent) => {
onTabBoundaryLayout(event);
updateMeasuredWidth(setTabsScrollViewportWidth, event);
},
[onTabBoundaryLayout],
);
const handleDragEnd = useCallback(
(nextTabs: ResolvedWorkspaceDesktopTabRowItem[]) => {
onReorderTabs(nextTabs.map((tab) => tab.tab));
@@ -1336,13 +1316,14 @@ function ResolvedWorkspaceDesktopTabsRow({
/>
<View style={styles.tabsScrollContainer}>
<Animated.ScrollView
ref={tabsScrollRef}
horizontal
scrollEnabled={layout.requiresHorizontalScrollFallback}
testID="workspace-tabs-scroll"
style={tabsScrollStyle}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
onLayout={tabScrollBoundary.onLayout}
onLayout={handleTabsScrollLayout}
onContentSizeChange={tabScrollBoundary.onContentSizeChange}
onScroll={tabScrollBoundary.onScroll}
scrollEventThrottle={16}
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
computeWorkspaceTabLayout,
retainWorkspaceTabMeasuredWidth,
scrollOffsetToRevealWorkspaceTab,
} from "@/screens/workspace/workspace-tab-layout";
const metrics = {
@@ -120,6 +121,33 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.items.map((item) => item.width)).toEqual([102, 138]);
});
it("keeps unmeasured tabs and controls accessible until text layout arrives", () => {
const initial = computeWorkspaceTabLayout({
viewportWidth: 440,
tabLabelWidths: [null, null, null],
metrics,
});
expect(initial.items.map((item) => item.width)).toEqual([96, 96, 96]);
expect(initial.closeButtonPolicy).toBe("all");
expect(initial.requiresHorizontalScrollFallback).toBe(false);
const measured = computeWorkspaceTabLayout({
viewportWidth: 440,
tabLabelWidths: [40, 130, 90],
metrics,
});
expect(measured.items).toHaveLength(3);
expect(measured.items[1]?.width).toBeGreaterThan(initial.items[1]?.width ?? 0);
const overflowing = computeWorkspaceTabLayout({
viewportWidth: 440,
tabLabelWidths: [null, null, null, null],
metrics,
});
expect(overflowing.requiresHorizontalScrollFallback).toBe(true);
expect(overflowing.items).toHaveLength(4);
});
});
describe("retainWorkspaceTabMeasuredWidth", () => {
@@ -131,3 +159,15 @@ describe("retainWorkspaceTabMeasuredWidth", () => {
expect(retainWorkspaceTabMeasuredWidth(720, 640)).toBe(640);
});
});
describe("scrollOffsetToRevealWorkspaceTab", () => {
it("reveals the selected tab after a wide row becomes an overflowing row", () => {
const widths = Array.from({ length: 6 }, () => 96);
expect(scrollOffsetToRevealWorkspaceTab(widths, 5, 482, 4, 4)).toBe(122);
expect(scrollOffsetToRevealWorkspaceTab(widths, 0, 482, 4, 4)).toBe(0);
});
it("keeps a selected tab in place when it already fits", () => {
expect(scrollOffsetToRevealWorkspaceTab([96, 160, 96], 1, 480, 4, 4)).toBe(0);
});
});
@@ -15,7 +15,7 @@ export interface WorkspaceTabLayoutMetrics {
export interface WorkspaceTabLayoutInput {
viewportWidth: number;
tabLabelWidths: number[];
tabLabelWidths: (number | null)[];
metrics: WorkspaceTabLayoutMetrics;
}
@@ -40,6 +40,21 @@ export function retainWorkspaceTabMeasuredWidth(
return measuredWidth;
}
export function scrollOffsetToRevealWorkspaceTab(
widths: readonly number[],
selectedIndex: number,
viewportWidth: number,
gap: number,
padding: number,
): number {
if (selectedIndex < 0 || selectedIndex >= widths.length || viewportWidth <= 0) return 0;
const end =
padding +
widths.slice(0, selectedIndex + 1).reduce((total, width) => total + width, 0) +
gap * selectedIndex;
return Math.max(0, end + padding - viewportWidth);
}
function clamp(value: number, min: number, max: number): number {
if (value < min) {
return min;
@@ -74,8 +89,13 @@ export function computeWorkspaceTabLayout(
input.metrics.tabContentGap +
input.metrics.tabHorizontalPadding * 2 +
input.metrics.closeButtonWidth;
// An unmeasured label starts at the chip's known minimum. The row stays
// interactive if native skips the hidden Text onLayout during a fold, and
// exact measurements take over whenever they arrive.
const naturalWidths = input.tabLabelWidths.map((labelWidth) =>
clamp(tabChromeWidth + labelWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth),
labelWidth === null
? input.metrics.minTabWidth
: clamp(tabChromeWidth + labelWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth),
);
const naturalTotalWidth = naturalWidths.reduce((total, width) => total + width, 0);
const minimumTotalWidth = input.metrics.minTabWidth * tabCount;
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { BREAKPOINTS, isCompactWindowWidth } from "./breakpoints";
describe("pane layout width", () => {
it("keeps a narrow window compact on a large physical display", () => {
expect(isCompactWindowWidth(443)).toBe(true);
});
it("switches at the same medium breakpoint used by styles", () => {
expect(isCompactWindowWidth(BREAKPOINTS.md - 1)).toBe(true);
expect(isCompactWindowWidth(BREAKPOINTS.md)).toBe(false);
expect(isCompactWindowWidth(852)).toBe(false);
});
});
+11
View File
@@ -0,0 +1,11 @@
export const BREAKPOINTS = {
xs: 0,
sm: 576,
md: 720,
lg: 992,
xl: 1200,
} as const;
export function isCompactWindowWidth(width: number): boolean {
return width < BREAKPOINTS.md;
}
+2 -7
View File
@@ -1,15 +1,10 @@
import { StyleSheet } from "react-native-unistyles";
import { BREAKPOINTS } from "./breakpoints";
import { REGISTERED_THEMES } from "./theme";
StyleSheet.configure({
themes: REGISTERED_THEMES,
breakpoints: {
xs: 0,
sm: 576,
md: 720,
lg: 992,
xl: 1200,
},
breakpoints: BREAKPOINTS,
settings: {
adaptiveThemes: true,
},