From bf4979fd62a4118c45ab3577a687577b434c21be Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 29 Sep 2026 19:11:05 +0200 Subject: [PATCH] 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 (cherry picked from commit 83f0a8219e0afee1f1e6b5084d6a37346b5c98ca) --- package-lock.json | 11 ++ packages/app/app.config.js | 9 +- .../native-fold-open-preconditions.md | 5 + .../agent-device/native-fold-open.android.ad | 8 ++ .../android/build.gradle | 15 +++ .../android/src/main/AndroidManifest.xml | 1 + .../PaseoScreenGeometryModule.kt | 28 ++++ .../expo-module.config.json | 6 + .../paseo-screen-geometry/package.json | 5 + packages/app/package.json | 1 + .../src/android-orientation-config.test.ts | 19 +++ packages/app/src/app/_layout.tsx | 34 +++-- packages/app/src/components/left-sidebar.tsx | 4 +- packages/app/src/constants/layout.ts | 10 +- .../app/src/constants/window-width.test.ts | 14 ++ packages/app/src/constants/window-width.tsx | 29 +++++ packages/app/src/orientation/index.native.ts | 58 +++++++++ packages/app/src/orientation/index.ts | 3 + packages/app/src/orientation/policy.test.ts | 25 ++++ packages/app/src/orientation/policy.ts | 10 ++ .../app/src/orientation/screen-geometry.ts | 16 +++ .../workspace/use-workspace-tab-layout.ts | 6 +- .../workspace/workspace-desktop-tabs-row.tsx | 123 ++++++++---------- .../workspace/workspace-tab-layout.test.ts | 40 ++++++ .../screens/workspace/workspace-tab-layout.ts | 24 +++- packages/app/src/styles/breakpoints.test.ts | 14 ++ packages/app/src/styles/breakpoints.ts | 11 ++ packages/app/src/styles/unistyles.ts | 9 +- 28 files changed, 436 insertions(+), 102 deletions(-) create mode 100644 packages/app/e2e/mobile/agent-device/native-fold-open-preconditions.md create mode 100644 packages/app/e2e/mobile/agent-device/native-fold-open.android.ad create mode 100644 packages/app/modules/paseo-screen-geometry/android/build.gradle create mode 100644 packages/app/modules/paseo-screen-geometry/android/src/main/AndroidManifest.xml create mode 100644 packages/app/modules/paseo-screen-geometry/android/src/main/java/sh/paseo/screengeometry/PaseoScreenGeometryModule.kt create mode 100644 packages/app/modules/paseo-screen-geometry/expo-module.config.json create mode 100644 packages/app/modules/paseo-screen-geometry/package.json create mode 100644 packages/app/src/android-orientation-config.test.ts create mode 100644 packages/app/src/constants/window-width.test.ts create mode 100644 packages/app/src/constants/window-width.tsx create mode 100644 packages/app/src/orientation/index.native.ts create mode 100644 packages/app/src/orientation/index.ts create mode 100644 packages/app/src/orientation/policy.test.ts create mode 100644 packages/app/src/orientation/policy.ts create mode 100644 packages/app/src/orientation/screen-geometry.ts create mode 100644 packages/app/src/styles/breakpoints.test.ts create mode 100644 packages/app/src/styles/breakpoints.ts diff --git a/package-lock.json b/package-lock.json index c396338f9b..7c8c78850a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 79d68249f2..c3fe69fd0b 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -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, }, diff --git a/packages/app/e2e/mobile/agent-device/native-fold-open-preconditions.md b/packages/app/e2e/mobile/agent-device/native-fold-open-preconditions.md new file mode 100644 index 0000000000..23e094cf08 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-fold-open-preconditions.md @@ -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 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. diff --git a/packages/app/e2e/mobile/agent-device/native-fold-open.android.ad b/packages/app/e2e/mobile/agent-device/native-fold-open.android.ad new file mode 100644 index 0000000000..4058e9ac08 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-fold-open.android.ad @@ -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 diff --git a/packages/app/modules/paseo-screen-geometry/android/build.gradle b/packages/app/modules/paseo-screen-geometry/android/build.gradle new file mode 100644 index 0000000000..69ff84a6e9 --- /dev/null +++ b/packages/app/modules/paseo-screen-geometry/android/build.gradle @@ -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" +} diff --git a/packages/app/modules/paseo-screen-geometry/android/src/main/AndroidManifest.xml b/packages/app/modules/paseo-screen-geometry/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..94cbbcfc39 --- /dev/null +++ b/packages/app/modules/paseo-screen-geometry/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/app/modules/paseo-screen-geometry/android/src/main/java/sh/paseo/screengeometry/PaseoScreenGeometryModule.kt b/packages/app/modules/paseo-screen-geometry/android/src/main/java/sh/paseo/screengeometry/PaseoScreenGeometryModule.kt new file mode 100644 index 0000000000..b085ba2366 --- /dev/null +++ b/packages/app/modules/paseo-screen-geometry/android/src/main/java/sh/paseo/screengeometry/PaseoScreenGeometryModule.kt @@ -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, + ) + } + } +} diff --git a/packages/app/modules/paseo-screen-geometry/expo-module.config.json b/packages/app/modules/paseo-screen-geometry/expo-module.config.json new file mode 100644 index 0000000000..e5de86dbbb --- /dev/null +++ b/packages/app/modules/paseo-screen-geometry/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["sh.paseo.screengeometry.PaseoScreenGeometryModule"] + } +} diff --git a/packages/app/modules/paseo-screen-geometry/package.json b/packages/app/modules/paseo-screen-geometry/package.json new file mode 100644 index 0000000000..8191be4fd6 --- /dev/null +++ b/packages/app/modules/paseo-screen-geometry/package.json @@ -0,0 +1,5 @@ +{ + "name": "paseo-screen-geometry", + "version": "0.1.0", + "private": true +} diff --git a/packages/app/package.json b/packages/app/package.json index 1a293d00a0..26b84c1ede 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/android-orientation-config.test.ts b/packages/app/src/android-orientation-config.test.ts new file mode 100644 index 0000000000..1b0e05bea4 --- /dev/null +++ b/packages/app/src/android-orientation-config.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index cf682e7611..e1d0cef9b3 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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 {content}; } @@ -967,16 +972,23 @@ function RootProviders({ children }: { children: ReactNode }) { } function RootAppTree() { + const [measuredWindowWidth, setMeasuredWindowWidth] = useState(null); + const handleRootLayout = useCallback((event: LayoutChangeEvent) => { + const width = event.nativeEvent.layout.width; + if (width > 0) setMeasuredWindowWidth((current) => (current === width ? current : width)); + }, []); return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index b1de36c8ea..cc969cd408 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -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, diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 721708715c..b47572e148 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -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. diff --git a/packages/app/src/constants/window-width.test.ts b/packages/app/src/constants/window-width.test.ts new file mode 100644 index 0000000000..3ef4cf297e --- /dev/null +++ b/packages/app/src/constants/window-width.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/constants/window-width.tsx b/packages/app/src/constants/window-width.tsx new file mode 100644 index 0000000000..9aabbb407e --- /dev/null +++ b/packages/app/src/constants/window-width.tsx @@ -0,0 +1,29 @@ +import { createContext, type ReactNode, useContext } from "react"; +import { useWindowDimensions } from "react-native"; + +const MeasuredWindowWidthContext = createContext(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 ( + + {children} + + ); +} + +/** 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); +} diff --git a/packages/app/src/orientation/index.native.ts b/packages/app/src/orientation/index.native.ts new file mode 100644 index 0000000000..6ed0e89aa6 --- /dev/null +++ b/packages/app/src/orientation/index.native.ts @@ -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; +} diff --git a/packages/app/src/orientation/index.ts b/packages/app/src/orientation/index.ts new file mode 100644 index 0000000000..60d3b47ab8 --- /dev/null +++ b/packages/app/src/orientation/index.ts @@ -0,0 +1,3 @@ +export function useAdaptiveOrientation(): boolean { + return true; +} diff --git a/packages/app/src/orientation/policy.test.ts b/packages/app/src/orientation/policy.test.ts new file mode 100644 index 0000000000..2baa63034c --- /dev/null +++ b/packages/app/src/orientation/policy.test.ts @@ -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"); + }); +}); diff --git a/packages/app/src/orientation/policy.ts b/packages/app/src/orientation/policy.ts new file mode 100644 index 0000000000..78d38acb6c --- /dev/null +++ b/packages/app/src/orientation/policy.ts @@ -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"; +} diff --git a/packages/app/src/orientation/screen-geometry.ts b/packages/app/src/orientation/screen-geometry.ts new file mode 100644 index 0000000000..0fafd5f315 --- /dev/null +++ b/packages/app/src/orientation/screen-geometry.ts @@ -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( + "PaseoScreenGeometry", + ).getPhysicalScreenSize(); +} diff --git a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts index 409d957abd..86db03be1b 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts @@ -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 diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index bbb0561130..2835152526 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -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, -): 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(0); + const [tabsScrollViewportWidth, setTabsScrollViewportWidth] = useState(0); + const tabsScrollRef = useRef(null); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); const tabScrollBoundary = useHorizontalScrollBoundary(); + const { onLayout: onTabBoundaryLayout } = tabScrollBoundary; const [labelMeasurements, setLabelMeasurements] = useState( () => new Map(), ); - const [trackSnapshot, setTrackSnapshot] = useState(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({ /> { 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); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index 2693e24cd7..94f179af85 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -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; diff --git a/packages/app/src/styles/breakpoints.test.ts b/packages/app/src/styles/breakpoints.test.ts new file mode 100644 index 0000000000..e7b849ccbc --- /dev/null +++ b/packages/app/src/styles/breakpoints.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/styles/breakpoints.ts b/packages/app/src/styles/breakpoints.ts new file mode 100644 index 0000000000..93f7cc22c6 --- /dev/null +++ b/packages/app/src/styles/breakpoints.ts @@ -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; +} diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index b679d5e694..fecb3624dc 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -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, },