mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
387be14a2e |
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user