diff --git a/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift b/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift index 3ac90cf2b5..2168daf5b6 100644 --- a/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift +++ b/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift @@ -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 } diff --git a/packages/app/src/components/attachment-lightbox.test.tsx b/packages/app/src/components/attachment-lightbox.test.tsx index 82fe6b139f..4920195258 100644 --- a/packages/app/src/components/attachment-lightbox.test.tsx +++ b/packages/app/src/components/attachment-lightbox.test.tsx @@ -11,6 +11,7 @@ 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" }, diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx index cbc161ae66..1c3b45e0b3 100644 --- a/packages/app/src/components/headers/compact-header.tsx +++ b/packages/app/src/components/headers/compact-header.tsx @@ -12,8 +12,13 @@ import { iconButtonChromeGlyphSize, mutedIconColorMapping, } from "@/components/ui/icon-button-chrome"; -import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; +import { + HEADER_INNER_HEIGHT, + 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); @@ -39,7 +44,8 @@ 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. + * 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. The column hosting it reserves any window-chrome corner it owns. */ export function CompactHeader({ navigation, @@ -51,22 +57,23 @@ export function CompactHeader({ actions, }: CompactHeaderProps) { const insets = useSafeAreaInsets(); + const inTopBand = useTopBand() !== null; const rootStyle = useMemo( () => [ styles.root, { - paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE, + paddingTop: insets.top + (inTopBand ? 0 : HEADER_TOP_PADDING_MOBILE), marginLeft: insets.left, marginRight: insets.right, }, ], - [insets.top, insets.left, insets.right], + [inTopBand, insets.top, insets.left, insets.right], ); return ( - + {navigation === "menu" ? : } @@ -148,6 +155,12 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, + topBandRow: { + height: HEADER_INNER_HEIGHT, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, titleGroup: { flex: 1, minWidth: 0, diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 2adc95e7f6..ba40ab41fd 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -31,7 +31,7 @@ import { resolveDesktopSidebarInsetLeft, resolveDesktopSidebarWidth, } from "@/components/desktop-sidebar-layout"; -import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout"; +import { useTopBand, useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout"; import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET, @@ -807,9 +807,11 @@ 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 && styles.sidebarHeaderGroupBelowChrome], - [ownsTopLeft], + () => [styles.sidebarHeaderGroup, (ownsTopLeft || inTopBand) && styles.sidebarHeaderGroupFlush], + [ownsTopLeft, inTopBand], ); return ( ({ borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, - sidebarHeaderGroupBelowChrome: { + sidebarHeaderGroupFlush: { paddingTop: 0, }, // The nav block caps at half the sidebar; a long item list scrolls inside the diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index c1c375fbae..cb4d863448 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -164,7 +164,11 @@ import { WorkspaceExplorerSidebarToggle, WorkspaceHeaderExplorerToggle, } from "@/screens/workspace/workspace-explorer-toggle"; -import { useHasWindowChromeObstruction } from "@/utils/desktop-window"; +import { + useHasWindowChromeObstruction, + WindowChromeRegion, + WindowChromeSafeArea, +} from "@/utils/desktop-window"; import { resolveWorkspaceHeaderRenderState, type WorkspaceHeaderCheckoutState, @@ -1029,6 +1033,23 @@ 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 ( + + {children} + + ); +} + function WorkspaceScreenContent({ serverId, workspaceId, @@ -3516,6 +3537,11 @@ function WorkspaceScreenContent({ }, }; const compactWorkspaceHeader = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs); + // SplitContainer reserves the corner around its own compact headers; this is the column's. + const centerColumnCompactHeader = useMemo( + () => {compactWorkspaceHeader}, + [compactWorkspaceHeader], + ); const fullWorkspaceActions = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs, true); const desktopSplitContent = useMemo(() => { if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) { @@ -3639,9 +3665,12 @@ function WorkspaceScreenContent({ ) : ( - + {rendersDesktopSplitContent ? null : ( - + {fullWorkspaceActions} {renderWorkspaceScreenHeader()} @@ -3678,7 +3707,7 @@ function WorkspaceScreenContent({ {desktopContent} - + ); const [workspaceViewportWidth, setWorkspaceViewportWidth] = useState(0); diff --git a/packages/app/src/utils/desktop-window.test.ts b/packages/app/src/utils/desktop-window.test.ts index 21f35537dc..fcc5bcea91 100644 --- a/packages/app/src/utils/desktop-window.test.ts +++ b/packages/app/src/utils/desktop-window.test.ts @@ -9,30 +9,61 @@ import { describe("window chrome", () => { it("has no corner obstruction outside Electron or in fullscreen", () => { - expect(resolveWindowChromeObstruction({ mode: null, isFullscreen: false })).toEqual({ + expect( + resolveWindowChromeObstruction({ mode: null, isFullscreen: false, topBand: null }), + ).toEqual({ topLeft: null, topRight: null, }); - expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true })).toEqual({ + expect( + resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: true, topBand: null }), + ).toEqual({ topLeft: null, topRight: null, }); }); it("places native controls in their physical top corner", () => { - expect(resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false })).toEqual({ + expect( + resolveWindowChromeObstruction({ mode: "native-mac", isFullscreen: false, topBand: null }), + ).toEqual({ topLeft: { width: 78, height: 45 }, topRight: null, }); - expect(resolveWindowChromeObstruction({ mode: "custom-windows", isFullscreen: false })).toEqual( - { topLeft: null, topRight: { width: 138, height: 36 } }, - ); - expect(resolveWindowChromeObstruction({ mode: "custom-linux", isFullscreen: false })).toEqual({ + 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({ 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( diff --git a/packages/app/src/utils/desktop-window.tsx b/packages/app/src/utils/desktop-window.tsx index fa1f397e1c..5947764709 100644 --- a/packages/app/src/utils/desktop-window.tsx +++ b/packages/app/src/utils/desktop-window.tsx @@ -12,6 +12,7 @@ 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"; @@ -105,10 +106,15 @@ 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 { @@ -217,13 +223,15 @@ export function WindowChromeProvider({ children }: { children: ReactNode }) { }; }, []); + const topBand = useTopBand(); const obstruction = useMemo( () => resolveWindowChromeObstruction({ mode: isElectronReady ? getDesktopWindowChromeMode() : null, isFullscreen: windowState.isFullscreen, + topBand, }), - [isElectronReady, windowState.isFullscreen], + [isElectronReady, windowState.isFullscreen, topBand], ); const desktopWindowChrome = useMemo( () => ({ diff --git a/packages/app/src/window-layout/context.ts b/packages/app/src/window-layout/context.ts index e2d0246a92..b1da8c58f8 100644 --- a/packages/app/src/window-layout/context.ts +++ b/packages/app/src/window-layout/context.ts @@ -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(null); +/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */ +export const TopBandContext = createContext(null); + export function useWindowLayout(): WindowLayout | null { const layout = useContext(WindowLayoutContext); const { width, height } = useWindowDimensions(); @@ -13,3 +16,8 @@ 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); +} diff --git a/packages/app/src/window-layout/index.ios.tsx b/packages/app/src/window-layout/index.ios.tsx index 507133528e..47c985dace 100644 --- a/packages/app/src/window-layout/index.ios.tsx +++ b/packages/app/src/window-layout/index.ios.tsx @@ -2,18 +2,19 @@ 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 { useTopBand } from "./context"; export { resolveDesktopDivisionLayout, resolveWorkspaceActionPlacement, resolveWorkspaceBarActions, } from "./model"; -export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model"; +export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model"; const GeometryProbe = requireNativeViewManager< ViewProps & { @@ -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 that `WindowChromeProvider` reads through `useTopBand()`. + */ function WindowSafeAreaInsets({ children }: { children: ReactNode }) { const systemInsets = useSafeAreaInsets(); const layout = useWindowLayout(); const insets = useMemo(() => resolveSafeAreaInsets(systemInsets, layout), [systemInsets, layout]); - return {children}; + const topBand = useMemo( + () => resolveTopBand(systemInsets.top, layout), + [systemInsets.top, layout], + ); + return ( + + {children} + + ); } const styles = StyleSheet.create({ root: { flex: 1 } }); diff --git a/packages/app/src/window-layout/index.tsx b/packages/app/src/window-layout/index.tsx index c884d3e987..13a7c4c128 100644 --- a/packages/app/src/window-layout/index.tsx +++ b/packages/app/src/window-layout/index.tsx @@ -3,12 +3,13 @@ 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, WindowLayout, WindowLayoutRect } from "./model"; +export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model"; export function WindowLayoutProvider({ children }: { children: ReactNode }) { return children; diff --git a/packages/app/src/window-layout/model.test.ts b/packages/app/src/window-layout/model.test.ts index e363d4313a..ae54a26cc9 100644 --- a/packages/app/src/window-layout/model.test.ts +++ b/packages/app/src/window-layout/model.test.ts @@ -1,35 +1,155 @@ import { describe, expect, it } from "vitest"; -import { resolveSafeAreaInsets, type WindowLayout } from "./model"; +import { + resolveSafeAreaInsets, + resolveTopBand, + 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 { 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. +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(); + }); }); diff --git a/packages/app/src/window-layout/model.ts b/packages/app/src/window-layout/model.ts index d2ba0de709..0226d81bd5 100644 --- a/packages/app/src/window-layout/model.ts +++ b/packages/app/src/window-layout/model.ts @@ -14,18 +14,56 @@ 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( +/** + * 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( 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 DesktopDivisionLayout {