Compare commits

...
Author SHA1 Message Date
Mohamed BoudraandElsa Granger 5bf7f8b7e7 fix(app): keep the header in the top band whichever sidebar is open
With the left sidebar pinned open on the iPhone Duo inner screen, the
workspace header dropped below the status region, so it jumped 56pt
between sidebar states. Headers now always trim their trailing side to
the status region and let the title give up width instead.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-06 15:19:10 +01:00
Mohamed BoudraandElsa Granger 142b2d2bc4 fix(app): keep explorer tab labels whole beside the Duo status region
Measured the iPhone Duo inner screen in portrait (669×951, iOS 27.1
simulator unfolded in DeviceHub): system top 82, top bar guide
{16, 26, 509, 44}, status region occlusion {525, 0, 144, 82}. The tests
now use those values instead of estimates.

On that screen the explorer tab rail, trimmed to the status region,
cut the Changes tab down to "C". The rail now starts below an occlusion
it meets, like toasts; the workspace pane keeps the band.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-06 10:14:37 +01:00
Mohamed BoudraandElsa Granger 7bafd0fdad fix(app): use the top band beside the iPhone Duo status region
On iOS 27.1+, the window layout probe publishes the top bar guide frame
and active occlusion rects. When the top bar starts above the system top
inset, the safe-area top moves up to it, and each header row that lands
in the band trims its trailing padding to the status region. A row
whose controls cannot fit beside it starts below the region on its own.

When the guide is missing or starts at or below the system top, the
override is a no-op, which is the case on every iPhone and on the Duo
outer screen.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-06 05:10:28 +01:00
13 changed files with 495 additions and 74 deletions
@@ -18,6 +18,8 @@ final class WindowLayoutView: ExpoView {
private var barGuide: UILayoutGuide?
private var barEdge = "none"
private var bottomBarGuide: UILayoutGuide?
private var topBarGuide: UILayoutGuide?
private var occlusions: [CGRect] = []
private var measuredBounds = CGRect.zero
required init(appContext: AppContext? = nil) {
@@ -38,6 +40,7 @@ final class WindowLayoutView: ExpoView {
super.didMoveToWindow()
previousPayload = nil
bottomBarGuide = nil
topBarGuide = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
@@ -62,6 +65,8 @@ final class WindowLayoutView: ExpoView {
if #available(iOS 27.1, *) {
// UIKit tracks these reads and invalidates constraints when regions change.
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
// Frames already include the system's interactive margins.
occlusions = reservedRegions(kind: .occlusion).compactMap { $0.isActive ? $0.frame : nil }
let edge: String
switch traitCollection.verticalBarEdge {
case .leading: edge = "leading"
@@ -73,6 +78,11 @@ final class WindowLayoutView: ExpoView {
// screen's corners rather than the rectangular safe area.
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
}
if topBarGuide == nil {
// Where a top bar rests. Beside a partial status region it can start above the
// rectangular safe area; content still keeps out of the occlusions.
topBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.top, extent: 44))
}
if edge != barEdge {
barEdge = edge
barGuide = edge == "none" ? nil : layoutGuide(
@@ -106,11 +116,17 @@ final class WindowLayoutView: ExpoView {
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
}
var topBar: Any = NSNull()
if let frame = topBarGuide?.layoutFrame, frame.width > 0, frame.height > 0 {
topBar = rect(frame)
}
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"bottomClearance": bottomClearance,
"topBar": topBar,
"occlusions": occlusions.map(rect),
"divisions": divisions.map(rect)
]
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
@@ -13,11 +13,14 @@ import {
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import { SPACING } from "@/styles/theme";
import { useTopBandRow } from "@/window-layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
const NO_SHORTCUT: ShortcutKey[] = [];
const ROW_PADDING = SPACING[3];
interface CompactHeaderProps {
/** The leading island: the sidebar menu, or a back button. */
@@ -51,21 +54,29 @@ export function CompactHeader({
actions,
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
// The trailing side gives way to the occlusion, so it needs no padding of its own.
const topBand = useTopBandRow({
offset: insets.top + HEADER_TOP_PADDING_MOBILE,
height: HEADER_INNER_HEIGHT_MOBILE,
// The header stays in the band in every sidebar state; the title gives up width instead.
minimumWidth: 0,
});
const rootStyle = useMemo(
() => [
styles.root,
{
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE + topBand.top,
paddingRight: Math.max(ROW_PADDING, topBand.trailing),
marginLeft: insets.left,
marginRight: insets.right,
},
],
[insets.top, insets.left, insets.right],
[insets.top, insets.left, insets.right, topBand.top, topBand.trailing],
);
return (
<IconButtonChromeProvider value="header-island">
<View style={rootStyle}>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={rootStyle}>
<View style={styles.row}>
<View style={styles.island}>
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
@@ -140,7 +151,7 @@ function BackButton({ onBack }: { onBack?: () => void }) {
const styles = StyleSheet.create((theme) => ({
root: {
paddingHorizontal: theme.spacing[3],
paddingLeft: ROW_PADDING,
},
row: {
height: HEADER_INNER_HEIGHT_MOBILE,
@@ -12,6 +12,7 @@ import {
} from "@/constants/layout";
import { WindowChromeSafeArea } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useTopBandRow } from "@/window-layout";
interface ScreenHeaderProps {
left?: ReactNode;
@@ -41,17 +42,33 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3];
const topBand = useTopBandRow({
offset: insets.top + topPadding,
height: isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT,
// The header stays in the band in every sidebar state; the title gives up width instead.
minimumWidth: 0,
});
const innerStyle = useMemo(
() => [styles.inner, { paddingTop: insets.top + topPadding }],
[insets.top, topPadding],
() => [styles.inner, { paddingTop: insets.top + topPadding + topBand.top }],
[insets.top, topPadding, topBand.top],
);
const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]);
const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]);
const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]);
// The occlusion replaces the row's trailing padding rather than adding to it.
const rightCombinedStyle = useMemo(
() => [
styles.right,
rightStyle,
topBand.trailing > baseHorizontalPadding && {
marginRight: topBand.trailing - baseHorizontalPadding,
},
],
[rightStyle, topBand.trailing, baseHorizontalPadding],
);
return (
<ComposerDockBackground style={styles.header} testID="composer-dock-header">
<View style={innerStyle}>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={innerStyle}>
<WindowChromeSafeArea
placement="inline"
horizontalPadding={baseHorizontalPadding}
+21 -10
View File
@@ -29,7 +29,7 @@ import {
resolveDesktopSidebarInsetLeft,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import { useWindowLayout, resolveDesktopDivisionLayout, useTopBandRow } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -46,6 +46,7 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { isNative as platformIsNative } from "@/constants/platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
@@ -527,6 +528,14 @@ function MobileSidebar({
closeSidebar();
}, [closeSidebar]);
// The first nav row carries the close button; the panel slides in, so re-measure once settled.
const navBand = useTopBandRow({
offset: theme.spacing[2],
height: TOUCH_TARGET,
minimumWidth: TOUCH_TARGET * 2,
settled: active,
});
const navBandStyle = useMemo(() => ({ paddingTop: navBand.top }), [navBand.top]);
const closeButton = useMemo(
() => (
<SidebarCloseButton
@@ -534,10 +543,10 @@ function MobileSidebar({
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
style={styles.mobileCloseButton(insets.right)}
style={styles.mobileCloseButton(insets.right, navBand.trailing)}
/>
),
[labels.closeSidebar, closeSidebar, insets.right],
[labels.closeSidebar, closeSidebar, insets.right, navBand.trailing],
);
const mobileSidebarInsetStyle = useMemo(
@@ -550,11 +559,13 @@ function MobileSidebar({
);
const navRows = (
<SidebarNavRows
style={styles.mobileNavRowsGroup}
onBeforeNavigate={closeSidebar}
firstRowTrailing={closeButton}
/>
<View ref={navBand.ref} onLayout={navBand.onLayout} style={navBandStyle}>
<SidebarNavRows
style={styles.mobileNavRowsGroup}
onBeforeNavigate={closeSidebar}
firstRowTrailing={closeButton}
/>
</View>
);
return (
@@ -894,8 +905,8 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
mobileCloseButton: (rightInset: number) => ({
marginRight: theme.spacing[2] + rightInset,
mobileCloseButton: (rightInset: number, topBandTrailing: number) => ({
marginRight: Math.max(theme.spacing[2] + rightInset, topBandTrailing),
}),
desktopSidebarBorder: (hasDivision: boolean) => ({
borderRightWidth: hasDivision ? 0 : 1,
+20 -1
View File
@@ -19,6 +19,8 @@ import type { Theme } from "@/styles/theme";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { useTopBandRow } from "@/window-layout";
import { AlertTriangle, CheckCircle2, Info } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import {
@@ -200,13 +202,30 @@ export function ToastViewport({
const topOffset =
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
// A centred toast cannot trim one side, so it always moves below an occlusion it meets.
const topBand = useTopBandRow({
offset: 0,
height: TOUCH_TARGET,
minimumWidth: Number.POSITIVE_INFINITY,
});
const widthBoundaryStyle = useMemo(
() => [styles.widthBoundary, { paddingTop: topBand.top }],
[topBand.top],
);
const content = (
<View
style={styles.container(placement, topOffset)}
pointerEvents="box-none"
collapsable={false}
>
<View style={styles.widthBoundary} pointerEvents="box-none" collapsable={false}>
<View
ref={topBand.ref}
onLayout={topBand.onLayout}
style={widthBoundaryStyle}
pointerEvents="box-none"
collapsable={false}
>
{toast ? (
<Animated.View
entering={toastEntering}
@@ -13,6 +13,7 @@ import {
type ControlSizing,
} from "@/components/ui/control-geometry";
import { StyleSheet } from "react-native-unistyles";
import { useTopBandRow } from "@/window-layout";
import { useComposerFullscreenExit } from "./fullscreen-exit";
import { resolveGlyphRailOffset } from "./state";
@@ -83,6 +84,12 @@ export function ComposerInputPresentation({
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]);
const exitSize = TOOLBAR_CONTROL_SIZES[controlSizing];
const topBand = useTopBandRow({ offset: 0, height: exitSize, minimumWidth: exitSize });
const exitRowStyle = useMemo(
() => [styles.exitRow, { paddingTop: topBand.top, paddingRight: topBand.trailing }],
[topBand.top, topBand.trailing],
);
return (
<View
ref={exitScopeRef}
@@ -90,7 +97,7 @@ export function ComposerInputPresentation({
testID={fullscreen ? "composer-fullscreen" : undefined}
>
{fullscreen ? (
<View style={styles.exitRow}>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={exitRowStyle}>
<Button
variant="ghost"
size="sm"
@@ -16,6 +16,8 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import type { SplitPane } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { useTopBandRow } from "@/window-layout";
interface ExplorerSidebarDockProps extends Pick<
WorkspaceDesktopTabsRowProps,
@@ -117,48 +119,61 @@ export function ExplorerSidebarDock({
[onCloseOtherTabs, tabs],
);
const topBand = useTopBandRow({
offset: 0,
height: TOUCH_TARGET,
// Trimming the rail beside the status region truncates its tab labels; start below instead.
minimumWidth: Number.POSITIVE_INFINITY,
});
const railBandStyle = useMemo(
() => ({ paddingTop: topBand.top, paddingRight: topBand.trailing }),
[topBand.top, topBand.trailing],
);
return (
<RetainedPanel active>
<WindowChromeRegion corners="top-right">
<View style={styles.dock} testID="workspace-explorer-sidebar">
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
<TitlebarDragRegion />
<View style={styles.tabRow}>
<WorkspaceDesktopTabsRow
host="explorer"
launchPurpose="supporting"
isFocused={isWorkspaceFocused}
ownsKeyboardShortcuts={false}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
focusModeEnabled={false}
onExitFocusMode={onExitFocusMode}
externalDndContext={externalDndContext}
paneId={pane.id}
tabs={tabItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
onNavigateTab={handleSelectTab}
onCloseTab={onCloseTab}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
/>
</View>
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
<View pointerEvents="none" style={styles.tabRailDivider} />
</WindowChromeSafeArea>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={railBandStyle}>
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
<TitlebarDragRegion />
<View style={styles.tabRow}>
<WorkspaceDesktopTabsRow
host="explorer"
launchPurpose="supporting"
isFocused={isWorkspaceFocused}
ownsKeyboardShortcuts={false}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
focusModeEnabled={false}
onExitFocusMode={onExitFocusMode}
externalDndContext={externalDndContext}
paneId={pane.id}
tabs={tabItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
onNavigateTab={handleSelectTab}
onCloseTab={onCloseTab}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
/>
</View>
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
<View pointerEvents="none" style={styles.tabRailDivider} />
</WindowChromeSafeArea>
</View>
<View style={styles.content}>
<WorkspacePanelHost
paneId={pane.id}
+4 -1
View File
@@ -1,9 +1,12 @@
import { createContext, useContext } from "react";
import { useWindowDimensions } from "react-native";
import type { WindowLayout } from "./model";
import type { TopBand, WindowLayout } from "./model";
export const WindowLayoutContext = createContext<WindowLayout | null>(null);
/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */
export const TopBandContext = createContext<TopBand | null>(null);
export function useWindowLayout(): WindowLayout | null {
const layout = useContext(WindowLayoutContext);
const { width, height } = useWindowDimensions();
+16 -4
View File
@@ -2,12 +2,13 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
import { requireNativeViewManager } from "expo-modules-core";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
import { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBandRow, type TopBandRow } from "./top-band";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
@@ -41,12 +42,23 @@ export function WindowLayoutProvider({ children }: { children: ReactNode }) {
);
}
/** The one bottom inset every descendant reads through `useSafeAreaInsets()`. */
/**
* The one top and bottom inset every descendant reads through `useSafeAreaInsets()`, and
* the top band whose occlusions top-edge rows clear with `useTopBandRow()`.
*/
function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
const systemInsets = useSafeAreaInsets();
const layout = useWindowLayout();
const insets = useMemo(() => resolveSafeAreaInsets(systemInsets, layout), [systemInsets, layout]);
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
const topBand = useMemo(
() => resolveTopBand(systemInsets.top, layout),
[systemInsets.top, layout],
);
return (
<TopBandContext.Provider value={topBand}>
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
</TopBandContext.Provider>
);
}
const styles = StyleSheet.create({ root: { flex: 1 } });
+1
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBandRow, type TopBandRow } from "./top-band";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
+173 -6
View File
@@ -1,35 +1,202 @@
import { describe, expect, it } from "vitest";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
import {
resolveSafeAreaInsets,
resolveTopBand,
resolveTopBandClearance,
type WindowLayout,
type WindowLayoutRect,
} from "./model";
const systemInsets = { top: 59, right: 0, bottom: 34, left: 0 };
function layout(bottomClearance: number | null | undefined): WindowLayout {
function layout(input: Partial<WindowLayout> = {}): WindowLayout {
return {
bounds: { x: 0, y: 0, width: 402, height: 874 },
verticalBarEdge: "none",
verticalBar: null,
bottomClearance,
divisions: [],
...input,
};
}
describe("resolveSafeAreaInsets", () => {
it("replaces only the bottom with the measured bar clearance", () => {
expect(resolveSafeAreaInsets(systemInsets, layout(24))).toEqual({
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 24 }))).toEqual({
...systemInsets,
bottom: 24,
});
});
it("keeps a measured zero instead of falling back to the system inset", () => {
expect(resolveSafeAreaInsets(systemInsets, layout(0)).bottom).toBe(0);
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 0 })).bottom).toBe(0);
});
it.each([null, undefined])("leaves system insets alone without a clearance (%s)", (value) => {
expect(resolveSafeAreaInsets(systemInsets, layout(value))).toBe(systemInsets);
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: value }))).toBe(
systemInsets,
);
});
it("leaves system insets alone before the native layout arrives", () => {
expect(resolveSafeAreaInsets(systemInsets, null)).toBe(systemInsets);
});
it("starts the top where the top bar rests when it sits above the system top", () => {
const insets = resolveSafeAreaInsets(
systemInsets,
layout({ topBar: { x: 0, y: 12, width: 402, height: 44 } }),
);
expect(insets).toEqual({ ...systemInsets, top: 12 });
});
it("keeps the system top when the top bar rests at or below it", () => {
const insets = resolveSafeAreaInsets(
systemInsets,
layout({ topBar: { x: 0, y: 59, width: 402, height: 44 } }),
);
expect(insets).toBe(systemInsets);
});
});
// Measured: iPhone Duo inner screen, portrait (669×951), iOS 27.1 simulator unfolded in
// DeviceHub. The Live Activity width (202pt) is still an estimate from #7924's ActivityKit QA.
const duo = {
systemTop: 82,
topBar: { x: 16, y: 26, width: 509, height: 44 },
statusRegion: { x: 525, y: 0, width: 144, height: 82 },
liveActivityRegion: { x: 467, y: 0, width: 202, height: 82 },
};
function duoLayout(occlusions: WindowLayoutRect[]): WindowLayout {
return layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: duo.topBar,
occlusions,
});
}
describe("resolveTopBand", () => {
it("publishes the top bar's start and the occlusions when the band is in use", () => {
expect(resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion]))).toEqual({
top: 26,
occlusions: [duo.statusRegion],
});
});
it("ignores occlusions without a top bar guide (before iOS 27.1 or unresolved)", () => {
const withoutGuide = layout({ topBar: null, occlusions: [duo.statusRegion] });
expect(resolveTopBand(duo.systemTop, withoutGuide)).toBeNull();
expect(resolveTopBand(duo.systemTop, layout({ occlusions: [duo.statusRegion] }))).toBeNull();
});
it("ignores occlusions when the top bar guide sits below the status region", () => {
const below = layout({
topBar: { x: 16, y: 82, width: 637, height: 44 },
occlusions: [duo.statusRegion],
});
expect(resolveTopBand(duo.systemTop, below)).toBeNull();
});
// Measured: iPhone 17 Pro, iOS 27.2 simulator, portrait.
it("is null on a regular iPhone, whose top bar starts at the status bar's bottom", () => {
const iphone = layout({
topBar: { x: 18, y: 62, width: 366, height: 44 },
occlusions: [{ x: 0, y: 0, width: 402, height: 54 }],
});
expect(resolveTopBand(62, iphone)).toBeNull();
});
// Measured: iPhone Duo outer screen, iOS 27.1 simulator. The status region is a trailing
// column, which the vertical bar layout already owns.
it("is null on the Duo outer screen, whose top bar starts below a zero system top", () => {
const outer = layout({
bounds: { x: 0, y: 0, width: 466, height: 678 },
verticalBarEdge: "trailing",
topBar: { x: 7 / 3, y: 26, width: 1139 / 3, height: 44 },
occlusions: [
{ x: 1199 / 3, y: 88 / 3, width: 37, height: 37 },
{ x: 382, y: 0, width: 84, height: 174 },
],
});
expect(resolveTopBand(0, outer)).toBeNull();
});
it("is null before the native layout arrives", () => {
expect(resolveTopBand(duo.systemTop, null)).toBeNull();
});
});
describe("resolveTopBandClearance", () => {
// The full workspace header with the sidebar closed spans the window.
const fullHeader = { x: 0, y: duo.topBar.y, width: 669, height: 44 };
// A row beside a 320pt sidebar whose controls need more width than the status region leaves.
const narrowRow = { x: 320, y: duo.topBar.y + 8, width: 349, height: 56 };
const narrowMinimum = 300;
it("trims the trailing side to the status region", () => {
expect(
resolveTopBandClearance({
row: fullHeader,
occlusions: [duo.statusRegion],
minimumWidth: 300,
}),
).toEqual({ trailing: 144, top: 0 });
});
it("trims further when a Live Activity widens the status region", () => {
expect(
resolveTopBandClearance({
row: fullHeader,
occlusions: [duo.liveActivityRegion],
minimumWidth: 300,
}),
).toEqual({ trailing: 202, top: 0 });
});
it("starts below the status region when the controls no longer fit beside it", () => {
expect(
resolveTopBandClearance({
row: narrowRow,
occlusions: [duo.statusRegion],
minimumWidth: narrowMinimum,
}),
).toEqual({ trailing: 0, top: 48 });
});
it("starts below the lowest of several overlapping occlusions", () => {
expect(
resolveTopBandClearance({
row: narrowRow,
occlusions: [duo.statusRegion, { x: 600, y: 0, width: 69, height: 96 }],
minimumWidth: narrowMinimum,
}),
).toEqual({ trailing: 0, top: 62 });
});
it("leaves rows alone that do not overlap an occlusion", () => {
const sidebarRow = { x: 0, y: duo.topBar.y + 8, width: 320, height: 44 };
const rowBelow = { x: 0, y: 82, width: 669, height: 44 };
for (const row of [sidebarRow, rowBelow]) {
expect(
resolveTopBandClearance({ row, occlusions: [duo.statusRegion], minimumWidth: 0 }),
).toEqual({ trailing: 0, top: 0 });
}
});
it("rounds a fractional clearance up so controls never touch the occlusion", () => {
expect(
resolveTopBandClearance({
row: { x: 0, y: 10, width: 669.5, height: 44 },
occlusions: [{ x: 525.25, y: 0, width: 144, height: 60.5 }],
minimumWidth: Number.POSITIVE_INFINITY,
}),
).toEqual({ trailing: 0, top: 51 });
expect(
resolveTopBandClearance({
row: { x: 0, y: 10, width: 669.5, height: 44 },
occlusions: [{ x: 525.25, y: 0, width: 144, height: 60.5 }],
minimumWidth: 0,
}).trailing,
).toBe(145);
});
});
+69 -5
View File
@@ -14,18 +14,82 @@ export interface WindowLayout {
* the system has no bottom bar guide. Zero is a measured value.
*/
bottomClearance?: number | null;
/** Where a top bar rests, or null until the system resolves its top bar guide. */
topBar?: WindowLayoutRect | null;
/** Active system occlusions (status region, camera, Live Activities), margins included. */
occlusions?: WindowLayoutRect[];
divisions: WindowLayoutRect[];
}
/** The window's measured bottom clearance replaces the rectangular safe-area bottom. */
export function resolveSafeAreaInsets<Insets extends { bottom: number }>(
/**
* The band above the rectangular safe area that a top bar may use because the system
* occupies only part of it. Content outside a top-edge row still keeps out of `occlusions`.
*/
export interface TopBand {
top: number;
occlusions: readonly WindowLayoutRect[];
}
/** Null unless the top bar guide starts above the system's rectangular safe area. */
export function resolveTopBand(systemTop: number, layout: WindowLayout | null): TopBand | null {
const bar = layout?.topBar;
if (!layout || !bar || bar.y >= systemTop) return null;
return { top: Math.max(0, bar.y), occlusions: layout.occlusions ?? [] };
}
/**
* The window's measured bars replace the rectangular safe area: the bottom bar's clearance,
* and the top bar's start when it sits above the system top.
*/
export function resolveSafeAreaInsets<Insets extends { top: number; bottom: number }>(
systemInsets: Insets,
layout: WindowLayout | null,
): Insets {
const clearance = layout?.bottomClearance;
return typeof clearance === "number" && clearance !== systemInsets.bottom
? { ...systemInsets, bottom: clearance }
: systemInsets;
const bottom = typeof clearance === "number" ? clearance : systemInsets.bottom;
const top = resolveTopBand(systemInsets.top, layout)?.top ?? systemInsets.top;
return top === systemInsets.top && bottom === systemInsets.bottom
? systemInsets
: { ...systemInsets, top, bottom };
}
export interface TopBandClearance {
/** Points the row's trailing controls keep from its trailing edge. */
trailing: number;
/** Points the row moves down when its controls cannot fit beside the occlusions. */
top: number;
}
export const NO_TOP_BAND_CLEARANCE: TopBandClearance = { trailing: 0, top: 0 };
/**
* How a top-edge row keeps its controls out of the system occlusions it overlaps: trim its
* trailing side to the leftmost occlusion, or, when `minimumWidth` no longer fits, start
* below the occlusions. `row` is the row's frame without any clearance, in window points.
*/
export function resolveTopBandClearance(input: {
row: WindowLayoutRect;
occlusions: readonly WindowLayoutRect[];
minimumWidth: number;
}): TopBandClearance {
const { row, occlusions, minimumWidth } = input;
const overlapping = occlusions.filter((frame) => overlaps(frame, row));
if (overlapping.length === 0) return NO_TOP_BAND_CLEARANCE;
const trailing = Math.ceil(row.x + row.width - Math.min(...overlapping.map((frame) => frame.x)));
if (row.width - trailing >= minimumWidth) return { trailing, top: 0 };
const bottom = Math.max(...overlapping.map((frame) => frame.y + frame.height));
return { trailing: 0, top: Math.ceil(bottom - row.y) };
}
function overlaps(a: WindowLayoutRect, b: WindowLayoutRect): boolean {
return (
a.width > 0 &&
a.height > 0 &&
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
export interface DesktopDivisionLayout {
@@ -0,0 +1,78 @@
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import type { View } from "react-native";
import { TopBandContext } from "./context";
import {
NO_TOP_BAND_CLEARANCE,
resolveTopBandClearance,
type TopBandClearance,
type WindowLayoutRect,
} from "./model";
export interface TopBandRow extends TopBandClearance {
/** Attach to the view whose top edge `offset` is measured from. */
ref: RefObject<View | null>;
onLayout: (() => void) | undefined;
}
/**
* Clearance for a row that can land in the window's top band. The measured view's frame
* must not depend on the clearance: apply `trailing` and `top` as padding inside it.
*
* - `offset`: points from the measured view's top to the row, without `top`.
* - `height`: the row's height.
* - `minimumWidth`: the narrowest row that still fits its controls.
* - `settled`: re-measures when it changes, for rows whose panel slides into place.
*/
export function useTopBandRow(input: {
offset: number;
height: number;
minimumWidth: number;
settled?: boolean;
}): TopBandRow {
const { offset, height, minimumWidth, settled } = input;
const band = useContext(TopBandContext);
const ref = useRef<View | null>(null);
const [frame, setFrame] = useState<WindowLayoutRect | null>(null);
const measure = useCallback(() => {
ref.current?.measureInWindow((x, y, width, measuredHeight) =>
setFrame((previous) =>
previous &&
previous.x === x &&
previous.y === y &&
previous.width === width &&
previous.height === measuredHeight
? previous
: { x, y, width, height: measuredHeight },
),
);
}, []);
const active = band !== null;
useEffect(() => {
if (active) measure();
}, [active, measure, settled]);
const clearance = useMemo(
() =>
band && frame
? resolveTopBandClearance({
row: { x: frame.x, y: frame.y + offset, width: frame.width, height },
occlusions: band.occlusions,
minimumWidth,
})
: NO_TOP_BAND_CLEARANCE,
[band, frame, offset, height, minimumWidth],
);
return {
ref,
onLayout: active ? measure : undefined,
trailing: clearance.trailing,
top: clearance.top,
};
}