Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 387be14a2e fix(usage): keep the sidebar usage summary current while it is shown
The summary fetched reports once on mount and never again: no interval,
window-focus and reconnect refetches are off app-wide, and the daemon does
not push report changes. It only updated when the Usage modal or Settings
mounted the same query and refetched it. It now re-reads every minute while
visible; the daemon answers from its five-minute cache, so source APIs are
still hit at most once per cache window.
2026-11-06 04:11:48 +01:00
15 changed files with 79 additions and 322 deletions
@@ -469,3 +469,27 @@ test("visible usage summary requests reports on load", async ({ page }) => {
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
});
test("visible usage summary picks up changed reports without opening Usage", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeSessionAt(31), () => claudeSessionAt(80)],
});
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await page.clock.install();
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
await page.clock.fastForward(61_000);
await expectPinnedUsage(page, ["80% 5h", "54% wk", "7% 5h", "12% wk"]);
await expect(usageModal(page)).toHaveCount(0);
});
function claudeSessionAt(usedPct: number) {
const reports = claudeAndCodexReports();
const claude = reports[0]!;
if (claude.report.status === "available") claude.report.windows[0]!.usedPct = usedPct;
return reports;
}
@@ -18,8 +18,6 @@ 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) {
@@ -40,7 +38,6 @@ final class WindowLayoutView: ExpoView {
super.didMoveToWindow()
previousPayload = nil
bottomBarGuide = nil
topBarGuide = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
@@ -65,8 +62,6 @@ 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"
@@ -78,11 +73,6 @@ 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(
@@ -116,17 +106,11 @@ 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 }
@@ -11,7 +11,6 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
opacity: { 50: 0.5 },
borderRadius: { full: 999, md: 6, lg: 8 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
@@ -12,19 +12,10 @@ import {
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { WindowChromeSafeArea } from "@/utils/desktop-window";
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useTopBand } from "@/window-layout";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
const HeaderSafeArea = withUnistyles(WindowChromeSafeArea, (theme) => ({
horizontalPadding: theme.spacing[3],
}));
const NO_SHORTCUT: ShortcutKey[] = [];
@@ -48,8 +39,7 @@ interface CompactHeaderProps {
* left-aligned between them. Neither the header nor its islands draw a background, so it reads the
* same in flow and floating over content (see `FloatingHeaderLayout`, whose fade gives the icons
* contrast). Its height matches the old flat header, so anything
* offset by the header height still lines up. In a window's top band it takes the top bar's
* place, like the full header, and keeps clear of the status region as it does of window controls.
* offset by the header height still lines up.
*/
export function CompactHeader({
navigation,
@@ -61,20 +51,22 @@ export function CompactHeader({
actions,
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
const inTopBand = useTopBand() !== null;
const rootStyle = useMemo(
() => ({
paddingTop: insets.top + (inTopBand ? 0 : HEADER_TOP_PADDING_MOBILE),
marginLeft: insets.left,
marginRight: insets.right,
}),
[inTopBand, insets.top, insets.left, insets.right],
() => [
styles.root,
{
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
marginLeft: insets.left,
marginRight: insets.right,
},
],
[insets.top, insets.left, insets.right],
);
return (
<IconButtonChromeProvider value="header-island">
<HeaderSafeArea placement="inline" style={rootStyle}>
<View style={inTopBand ? styles.topBandRow : styles.row}>
<View style={rootStyle}>
<View style={styles.row}>
<View style={styles.island}>
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
</View>
@@ -88,7 +80,7 @@ export function CompactHeader({
</View>
{actions ? <View style={styles.island}>{actions}</View> : null}
</View>
</HeaderSafeArea>
</View>
</IconButtonChromeProvider>
);
}
@@ -147,18 +139,15 @@ function BackButton({ onBack }: { onBack?: () => void }) {
}
const styles = StyleSheet.create((theme) => ({
root: {
paddingHorizontal: theme.spacing[3],
},
row: {
height: HEADER_INNER_HEIGHT_MOBILE,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
topBandRow: {
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
titleGroup: {
flex: 1,
minWidth: 0,
+4 -6
View File
@@ -29,7 +29,7 @@ import {
resolveDesktopSidebarInsetLeft,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
import { useTopBand, useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -748,11 +748,9 @@ function DesktopSidebar({
],
[insetsTop, insets.bottom, insets.left, overlay, divisionLayout],
);
// Below the window controls' row, or in the top band's place, the first row starts flush.
const inTopBand = useTopBand() !== null;
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, (ownsTopLeft || inTopBand) && styles.sidebarHeaderGroupFlush],
[ownsTopLeft, inTopBand],
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
[ownsTopLeft],
);
return (
<MobilePanelOverlay
@@ -877,7 +875,7 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
sidebarHeaderGroupFlush: {
sidebarHeaderGroupBelowChrome: {
paddingTop: 0,
},
// The nav block caps at half the sidebar; a long item list scrolls inside the
@@ -13,7 +13,6 @@ import {
type ControlSizing,
} from "@/components/ui/control-geometry";
import { StyleSheet } from "react-native-unistyles";
import { WindowChromeSafeArea } from "@/utils/desktop-window";
import { useComposerFullscreenExit } from "./fullscreen-exit";
import { resolveGlyphRailOffset } from "./state";
@@ -91,7 +90,7 @@ export function ComposerInputPresentation({
testID={fullscreen ? "composer-fullscreen" : undefined}
>
{fullscreen ? (
<WindowChromeSafeArea placement="inline" style={styles.exitRow}>
<View style={styles.exitRow}>
<Button
variant="ghost"
size="sm"
@@ -104,7 +103,7 @@ export function ComposerInputPresentation({
>
{null}
</Button>
</WindowChromeSafeArea>
</View>
) : null}
<ComposerFullscreenBody fullscreen={fullscreen}>
<View
@@ -163,7 +163,7 @@ import {
WorkspaceExplorerSidebarToggle,
WorkspaceHeaderExplorerToggle,
} from "@/screens/workspace/workspace-explorer-toggle";
import { useHasWindowChromeObstruction, WindowChromeRegion } from "@/utils/desktop-window";
import { useHasWindowChromeObstruction } from "@/utils/desktop-window";
import {
resolveWorkspaceHeaderRenderState,
type WorkspaceHeaderCheckoutState,
@@ -1024,23 +1024,6 @@ function useLastMainPane(input: {
return lastMainPaneRef;
}
/** The docked explorer beside the center column takes the top-right window-chrome corner. */
function DesktopCenterColumnChrome({
explorerOpen,
focusModeEnabled,
children,
}: {
explorerOpen: boolean;
focusModeEnabled: boolean;
children: ReactNode;
}) {
return (
<WindowChromeRegion corners={explorerOpen && !focusModeEnabled ? "top-left" : "both"}>
<View style={styles.centerColumn}>{children}</View>
</WindowChromeRegion>
);
}
function WorkspaceScreenContent({
serverId,
workspaceId,
@@ -3652,10 +3635,7 @@ function WorkspaceScreenContent({
<SheetTabs onCloseTab={handleCloseTabById} />
</View>
) : (
<DesktopCenterColumnChrome
explorerOpen={isExplorerSidebarShowing}
focusModeEnabled={desktopFocusModeEnabled}
>
<View style={styles.centerColumn}>
{rendersDesktopSplitContent ? null : (
<AdaptiveWorkspaceChrome compactHeader={compactWorkspaceHeader}>
{fullWorkspaceActions}
@@ -3694,7 +3674,7 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}>
<View style={styles.content}>{desktopContent}</View>
</View>
</DesktopCenterColumnChrome>
</View>
);
const [workspaceViewportWidth, setWorkspaceViewportWidth] = useState(0);
+3 -1
View File
@@ -166,7 +166,8 @@ const NO_REPORTS: UsageReportEntry[] = [];
/**
* The reports of the sidebar's usage host, which is connected and reports usage; none until they
* load, or without a host.
* load, or without a host. Re-read every minute while the summary is shown, since the daemon never
* pushes report changes.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
const setting = useUsageSetting(serverId);
@@ -177,6 +178,7 @@ export function useUsageHostReports(serverId: string | null): UsageReportEntry[]
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
refetchInterval: REPORTS_STALE_TIME_MS,
});
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
}
+7 -38
View File
@@ -9,61 +9,30 @@ import {
describe("window chrome", () => {
it("has no corner obstruction outside Electron or in fullscreen", () => {
expect(
resolveWindowChromeObstruction({ mode: null, isFullscreen: false, topBand: null }),
).toEqual({
expect(resolveWindowChromeObstruction({ mode: null, isFullscreen: false })).toEqual({
topLeft: null,
topRight: null,
});
expect(
resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true, topBand: null }),
).toEqual({
expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true })).toEqual({
topLeft: null,
topRight: null,
});
});
it("places native controls in their physical top corner", () => {
expect(
resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false, topBand: null }),
).toEqual({
expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false })).toEqual({
topLeft: { width: 78, height: 45 },
topRight: null,
});
expect(
resolveWindowChromeObstruction({
mode: "custom-windows",
isFullscreen: false,
topBand: null,
}),
).toEqual({ topLeft: null, topRight: { width: 138, height: 36 } });
expect(
resolveWindowChromeObstruction({ mode: "custom-linux", isFullscreen: false, topBand: null }),
).toEqual({
expect(resolveWindowChromeObstruction({ mode: "custom-windows", isFullscreen: false })).toEqual(
{ topLeft: null, topRight: { width: 138, height: 36 } },
);
expect(resolveWindowChromeObstruction({ mode: "custom-linux", isFullscreen: false })).toEqual({
topLeft: null,
topRight: { width: 108, height: 36 },
});
});
// Measured: iPhone Duo inner screen, portrait. The top bar ends at x 525, where the status
// region (0..82) starts; the band starts at y 26.
it("treats the status region beside a native top band as top-right window controls", () => {
expect(
resolveWindowChromeObstruction({
mode: null,
isFullscreen: false,
topBand: { top: 26, trailingObstruction: { width: 144, height: 56 } },
}),
).toEqual({ topLeft: null, topRight: { width: 144, height: 56 } });
expect(
resolveWindowChromeObstruction({
mode: null,
isFullscreen: false,
topBand: { top: 26, trailingObstruction: null },
}),
).toEqual({ topLeft: null, topRight: null });
});
it("insets and reserves only claimed corners", () => {
const obstruction = { topLeft: { width: 80, height: 28 }, topRight: { width: 48, height: 32 } };
expect(
+1 -9
View File
@@ -12,7 +12,6 @@ import {
getDesktopWindowControlsWidth,
} from "@/desktop/window-chrome-presentation";
import { isNative } from "@/constants/platform";
import { useTopBand, type TopBand } from "@/window-layout";
export type WindowChromeCorners = "none" | "top-left" | "top-right" | "both";
type WindowChromeSafeAreaPlacement = "inline" | "below";
@@ -106,15 +105,10 @@ export function removeWindowChromeCorner(
return corner === "top-left" ? "top-right" : "top-left";
}
/** Desktop window controls, or the system region a native top band gives way to. */
export function resolveWindowChromeObstruction(input: {
mode: DesktopWindowChromeMode | null;
isFullscreen: boolean;
topBand: TopBand | null;
}): WindowChromeObstruction {
if (input.topBand?.trailingObstruction) {
return { topLeft: null, topRight: input.topBand.trailingObstruction };
}
if (!input.mode || input.isFullscreen) return EMPTY_OBSTRUCTION;
if (input.mode === "native-mac") {
return {
@@ -223,15 +217,13 @@ export function WindowChromeProvider({ children }: { children: ReactNode }) {
};
}, []);
const topBand = useTopBand();
const obstruction = useMemo(
() =>
resolveWindowChromeObstruction({
mode: isElectronReady ? getDesktopWindowChromeMode() : null,
isFullscreen: windowState.isFullscreen,
topBand,
}),
[isElectronReady, windowState.isFullscreen, topBand],
[isElectronReady, windowState.isFullscreen],
);
const desktopWindowChrome = useMemo(
() => ({
+1 -9
View File
@@ -1,12 +1,9 @@
import { createContext, useContext } from "react";
import { useWindowDimensions } from "react-native";
import type { TopBand, WindowLayout } from "./model";
import type { 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,8 +13,3 @@ export function useWindowLayout(): WindowLayout | null {
? layout
: null;
}
/** The top band beside the system's status region, or null where the window has none. */
export function useTopBand(): TopBand | null {
return useContext(TopBandContext);
}
+5 -17
View File
@@ -2,19 +2,18 @@ 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 { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model";
import { WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
} from "./model";
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model";
const GeometryProbe = requireNativeViewManager<
ViewProps & {
@@ -42,23 +41,12 @@ export function WindowLayoutProvider({ children }: { children: ReactNode }) {
);
}
/**
* The one top and bottom inset every descendant reads through `useSafeAreaInsets()`, and
* the top band that `WindowChromeProvider` reads through `useTopBand()`.
*/
/** The one bottom inset every descendant reads through `useSafeAreaInsets()`. */
function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
const systemInsets = useSafeAreaInsets();
const layout = useWindowLayout();
const insets = useMemo(() => resolveSafeAreaInsets(systemInsets, layout), [systemInsets, layout]);
const topBand = useMemo(
() => resolveTopBand(systemInsets.top, layout),
[systemInsets.top, layout],
);
return (
<TopBandContext.Provider value={topBand}>
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
</TopBandContext.Provider>
);
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
}
const styles = StyleSheet.create({ root: { flex: 1 } });
+1 -2
View File
@@ -3,13 +3,12 @@ import type { ReactNode } from "react";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBand } from "./context";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
} from "./model";
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model";
export function WindowLayoutProvider({ children }: { children: ReactNode }) {
return children;
+6 -126
View File
@@ -1,155 +1,35 @@
import { describe, expect, it } from "vitest";
import {
resolveSafeAreaInsets,
resolveTopBand,
type WindowLayout,
type WindowLayoutRect,
} from "./model";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
const systemInsets = { top: 59, right: 0, bottom: 34, left: 0 };
function layout(input: Partial<WindowLayout> = {}): WindowLayout {
function layout(bottomClearance: number | null | undefined): 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({ bottomClearance: 24 }))).toEqual({
expect(resolveSafeAreaInsets(systemInsets, layout(24))).toEqual({
...systemInsets,
bottom: 24,
});
});
it("keeps a measured zero instead of falling back to the system inset", () => {
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 0 })).bottom).toBe(0);
expect(resolveSafeAreaInsets(systemInsets, layout(0)).bottom).toBe(0);
});
it.each([null, undefined])("leaves system insets alone without a clearance (%s)", (value) => {
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: value }))).toBe(
systemInsets,
);
expect(resolveSafeAreaInsets(systemInsets, layout(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.
const duo = {
systemTop: 82,
topBar: { x: 16, y: 26, width: 509, height: 44 },
statusRegion: { x: 525, y: 0, width: 144, 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 status region past its trailing end", () => {
expect(resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion]))).toEqual({
top: 26,
trailingObstruction: { width: 144, height: 56 },
});
});
it("reaches as low as the deepest occlusion beside the bar", () => {
const deeper = { x: 600, y: 0, width: 69, height: 96 };
expect(
resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion, deeper]))?.trailingObstruction,
).toEqual({ width: 144, height: 70 });
});
it("rounds a fractional obstruction up so controls never touch the status region", () => {
const fractional = layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: { x: 16, y: 26, width: 509.25, height: 44 },
occlusions: [{ x: 525.25, y: 0, width: 143.75, height: 82.5 }],
});
expect(resolveTopBand(duo.systemTop, fractional)?.trailingObstruction).toEqual({
width: 144,
height: 57,
});
});
it("has no trailing obstruction when the bar reaches the window's trailing edge", () => {
const fullWidth = layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: { x: 0, y: 26, width: 669, height: 44 },
});
expect(resolveTopBand(duo.systemTop, fullWidth)).toEqual({
top: 26,
trailingObstruction: null,
});
});
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();
});
});
+5 -43
View File
@@ -14,56 +14,18 @@ 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 band above the rectangular safe area that a top bar may use because the system
* occupies only its trailing end, like window controls on a desktop title bar.
*/
export interface TopBand {
/** Where the top bar starts, in window points. */
top: number;
/**
* The system region past the bar's trailing end, measured from the band's top, or null
* when the bar reaches the window's trailing edge.
*/
trailingObstruction: { width: number; height: number } | null;
}
/** 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;
const top = Math.max(0, bar.y);
const barEnd = bar.x + bar.width;
const width = Math.ceil(layout.bounds.width - barEnd);
if (width <= 0) return { top, trailingObstruction: null };
const beside = (layout.occlusions ?? []).filter(
(frame) => frame.x + frame.width > barEnd && frame.y < bar.y + bar.height,
);
const bottom = Math.max(bar.y + bar.height, ...beside.map((frame) => frame.y + frame.height));
return { top, trailingObstruction: { width, height: Math.ceil(bottom - top) } };
}
/**
* 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 }>(
/** The window's measured bottom clearance replaces the rectangular safe-area bottom. */
export function resolveSafeAreaInsets<Insets extends { bottom: number }>(
systemInsets: Insets,
layout: WindowLayout | null,
): Insets {
const clearance = layout?.bottomClearance;
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 };
return typeof clearance === "number" && clearance !== systemInsets.bottom
? { ...systemInsets, bottom: clearance }
: systemInsets;
}
export interface DesktopDivisionLayout {