diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx
index c87c6df956..e150a4a85b 100644
--- a/packages/app/src/components/headers/compact-header.tsx
+++ b/packages/app/src/components/headers/compact-header.tsx
@@ -12,15 +12,21 @@ import {
iconButtonChromeGlyphSize,
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 {
+ HEADER_INNER_HEIGHT,
+ HEADER_INNER_HEIGHT_MOBILE,
+ HEADER_TOP_PADDING_MOBILE,
+} from "@/constants/layout";
+import { WindowChromeSafeArea } from "@/utils/desktop-window";
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[] = [];
-const ROW_PADDING = SPACING[3];
interface CompactHeaderProps {
/** The leading island: the sidebar menu, or a back button. */
@@ -42,7 +48,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, and keeps clear of the status region as it does of window controls.
*/
export function CompactHeader({
navigation,
@@ -54,30 +61,20 @@ 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 inTopBand = useTopBand() !== null;
const rootStyle = useMemo(
- () => [
- styles.root,
- {
- 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, topBand.top, topBand.trailing],
+ () => ({
+ paddingTop: insets.top + (inTopBand ? 0 : HEADER_TOP_PADDING_MOBILE),
+ marginLeft: insets.left,
+ marginRight: insets.right,
+ }),
+ [inTopBand, insets.top, insets.left, insets.right],
);
return (
-
-
+
+
{navigation === "menu" ? : }
@@ -91,7 +88,7 @@ export function CompactHeader({
{actions ? {actions} : null}
-
+
);
}
@@ -150,15 +147,18 @@ function BackButton({ onBack }: { onBack?: () => void }) {
}
const styles = StyleSheet.create((theme) => ({
- root: {
- paddingLeft: ROW_PADDING,
- },
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,
diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx
index feabfe1ecb..6492658389 100644
--- a/packages/app/src/components/headers/screen-header.tsx
+++ b/packages/app/src/components/headers/screen-header.tsx
@@ -12,7 +12,6 @@ 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;
@@ -42,33 +41,17 @@ 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 + topBand.top }],
- [insets.top, topPadding, topBand.top],
+ () => [styles.inner, { paddingTop: insets.top + topPadding }],
+ [insets.top, topPadding],
);
const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]);
const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]);
- // 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],
- );
+ const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]);
return (
-
+
({ paddingTop: navBand.top }), [navBand.top]);
const closeButton = useMemo(
() => (
),
- [labels.closeSidebar, closeSidebar, insets.right, navBand.trailing],
+ [labels.closeSidebar, closeSidebar, insets.right],
);
const mobileSidebarInsetStyle = useMemo(
@@ -559,13 +550,11 @@ function MobileSidebar({
);
const navRows = (
-
-
-
+
);
return (
@@ -759,9 +748,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
@@ -905,8 +896,8 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
- mobileCloseButton: (rightInset: number, topBandTrailing: number) => ({
- marginRight: Math.max(theme.spacing[2] + rightInset, topBandTrailing),
+ mobileCloseButton: (rightInset: number) => ({
+ marginRight: theme.spacing[2] + rightInset,
}),
desktopSidebarBorder: (hasDivision: boolean) => ({
borderRightWidth: hasDivision ? 0 : 1,
diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx
index d5d3eff1b8..62a15bad04 100644
--- a/packages/app/src/components/toast-host.tsx
+++ b/packages/app/src/components/toast-host.tsx
@@ -19,8 +19,6 @@ 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 {
@@ -202,30 +200,13 @@ 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 = (
-
+
{toast ? (
[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 (
{fullscreen ? (
-
+
) : (
-
+
{rendersDesktopSplitContent ? null : (
{fullWorkspaceActions}
@@ -3674,7 +3694,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 7f0f774bd7..b1da8c58f8 100644
--- a/packages/app/src/window-layout/context.ts
+++ b/packages/app/src/window-layout/context.ts
@@ -16,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 d907643b20..47c985dace 100644
--- a/packages/app/src/window-layout/index.ios.tsx
+++ b/packages/app/src/window-layout/index.ios.tsx
@@ -8,13 +8,13 @@ import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./mode
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
-export { useTopBandRow, type TopBandRow } from "./top-band";
+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 & {
@@ -44,7 +44,7 @@ export function WindowLayoutProvider({ children }: { children: ReactNode }) {
/**
* The one top and bottom inset every descendant reads through `useSafeAreaInsets()`, and
- * the top band whose occlusions top-edge rows clear with `useTopBandRow()`.
+ * the top band that `WindowChromeProvider` reads through `useTopBand()`.
*/
function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
const systemInsets = useSafeAreaInsets();
diff --git a/packages/app/src/window-layout/index.tsx b/packages/app/src/window-layout/index.tsx
index e93f32b9c4..13a7c4c128 100644
--- a/packages/app/src/window-layout/index.tsx
+++ b/packages/app/src/window-layout/index.tsx
@@ -3,13 +3,13 @@ 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 { 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 10065b8bf9..ae54a26cc9 100644
--- a/packages/app/src/window-layout/model.test.ts
+++ b/packages/app/src/window-layout/model.test.ts
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
resolveSafeAreaInsets,
resolveTopBand,
- resolveTopBandClearance,
type WindowLayout,
type WindowLayoutRect,
} from "./model";
@@ -59,12 +58,11 @@ describe("resolveSafeAreaInsets", () => {
});
// 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.
+// DeviceHub.
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 {
@@ -76,10 +74,40 @@ function duoLayout(occlusions: WindowLayoutRect[]): WindowLayout {
}
describe("resolveTopBand", () => {
- it("publishes the top bar's start and the occlusions when the band is in use", () => {
+ 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,
- occlusions: [duo.statusRegion],
+ 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,
});
});
@@ -125,78 +153,3 @@ describe("resolveTopBand", () => {
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);
- });
-});
diff --git a/packages/app/src/window-layout/model.ts b/packages/app/src/window-layout/model.ts
index 4a3367b32a..0226d81bd5 100644
--- a/packages/app/src/window-layout/model.ts
+++ b/packages/app/src/window-layout/model.ts
@@ -23,18 +23,31 @@ export interface WindowLayout {
/**
* 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`.
+ * 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;
- occlusions: readonly WindowLayoutRect[];
+ /**
+ * 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;
- return { top: Math.max(0, bar.y), occlusions: layout.occlusions ?? [] };
+ 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) } };
}
/**
@@ -53,45 +66,6 @@ export function resolveSafeAreaInsets 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 {
sidebarWidth: number;
gap: number;
diff --git a/packages/app/src/window-layout/top-band.tsx b/packages/app/src/window-layout/top-band.tsx
deleted file mode 100644
index 53e27fdcd5..0000000000
--- a/packages/app/src/window-layout/top-band.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-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;
- 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(null);
- const [frame, setFrame] = useState(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,
- };
-}