mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
fix(app): dodge the Duo status region like window controls
The status region beside the iPhone Duo's top band is the window's top-right obstruction, the same one Windows and Linux window controls publish. Every row in the band (workspace header in both widths, left sidebar's first row, explorer tab rail) starts at the band's top, sits in the top bar's height, and the row owning the top-right corner pads past the status region. Nothing starts below it. The docked explorer takes the corner from the center column. Removes the per-row clearance (useTopBandRow) and its start-below fallback. Co-authored-by: Elsa Granger <zeyugao@outlook.com>
This commit is contained in:
co-authored by
Elsa Granger
parent
5bf7f8b7e7
commit
843ce6ebff
@@ -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 (
|
||||
<IconButtonChromeProvider value="header-island">
|
||||
<View ref={topBand.ref} onLayout={topBand.onLayout} style={rootStyle}>
|
||||
<View style={styles.row}>
|
||||
<HeaderSafeArea placement="inline" style={rootStyle}>
|
||||
<View style={inTopBand ? styles.topBandRow : styles.row}>
|
||||
<View style={styles.island}>
|
||||
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
|
||||
</View>
|
||||
@@ -91,7 +88,7 @@ export function CompactHeader({
|
||||
</View>
|
||||
{actions ? <View style={styles.island}>{actions}</View> : null}
|
||||
</View>
|
||||
</View>
|
||||
</HeaderSafeArea>
|
||||
</IconButtonChromeProvider>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<ComposerDockBackground style={styles.header} testID="composer-dock-header">
|
||||
<View ref={topBand.ref} onLayout={topBand.onLayout} style={innerStyle}>
|
||||
<View style={innerStyle}>
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
horizontalPadding={baseHorizontalPadding}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
resolveDesktopSidebarInsetLeft,
|
||||
resolveDesktopSidebarWidth,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
import { useWindowLayout, resolveDesktopDivisionLayout, useTopBandRow } from "@/window-layout";
|
||||
import { useTopBand, useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
@@ -46,7 +46,6 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
@@ -528,14 +527,6 @@ function MobileSidebar({
|
||||
closeSidebar();
|
||||
}, [closeSidebar]);
|
||||
|
||||
// The first nav row carries the close button; the panel slides in, so re-measure once settled.
|
||||
const navBand = useTopBandRow({
|
||||
offset: theme.spacing[2],
|
||||
height: TOUCH_TARGET,
|
||||
minimumWidth: TOUCH_TARGET * 2,
|
||||
settled: active,
|
||||
});
|
||||
const navBandStyle = useMemo(() => ({ paddingTop: navBand.top }), [navBand.top]);
|
||||
const closeButton = useMemo(
|
||||
() => (
|
||||
<SidebarCloseButton
|
||||
@@ -543,10 +534,10 @@ function MobileSidebar({
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
style={styles.mobileCloseButton(insets.right, navBand.trailing)}
|
||||
style={styles.mobileCloseButton(insets.right)}
|
||||
/>
|
||||
),
|
||||
[labels.closeSidebar, closeSidebar, insets.right, navBand.trailing],
|
||||
[labels.closeSidebar, closeSidebar, insets.right],
|
||||
);
|
||||
|
||||
const mobileSidebarInsetStyle = useMemo(
|
||||
@@ -559,13 +550,11 @@ function MobileSidebar({
|
||||
);
|
||||
|
||||
const navRows = (
|
||||
<View ref={navBand.ref} onLayout={navBand.onLayout} style={navBandStyle}>
|
||||
<SidebarNavRows
|
||||
style={styles.mobileNavRowsGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
</View>
|
||||
<SidebarNavRows
|
||||
style={styles.mobileNavRowsGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
);
|
||||
|
||||
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 (
|
||||
<MobilePanelOverlay
|
||||
@@ -886,7 +877,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
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,
|
||||
|
||||
@@ -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 = (
|
||||
<View
|
||||
style={styles.container(placement, topOffset)}
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
>
|
||||
<View
|
||||
ref={topBand.ref}
|
||||
onLayout={topBand.onLayout}
|
||||
style={widthBoundaryStyle}
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
>
|
||||
<View style={styles.widthBoundary} pointerEvents="box-none" collapsable={false}>
|
||||
{toast ? (
|
||||
<Animated.View
|
||||
entering={toastEntering}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type ControlSizing,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTopBandRow } from "@/window-layout";
|
||||
import { useComposerFullscreenExit } from "./fullscreen-exit";
|
||||
import { resolveGlyphRailOffset } from "./state";
|
||||
|
||||
@@ -84,12 +83,6 @@ export function ComposerInputPresentation({
|
||||
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
|
||||
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
|
||||
const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]);
|
||||
const exitSize = TOOLBAR_CONTROL_SIZES[controlSizing];
|
||||
const topBand = useTopBandRow({ offset: 0, height: exitSize, minimumWidth: exitSize });
|
||||
const exitRowStyle = useMemo(
|
||||
() => [styles.exitRow, { paddingTop: topBand.top, paddingRight: topBand.trailing }],
|
||||
[topBand.top, topBand.trailing],
|
||||
);
|
||||
return (
|
||||
<View
|
||||
ref={exitScopeRef}
|
||||
@@ -97,7 +90,7 @@ export function ComposerInputPresentation({
|
||||
testID={fullscreen ? "composer-fullscreen" : undefined}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<View ref={topBand.ref} onLayout={topBand.onLayout} style={exitRowStyle}>
|
||||
<View style={styles.exitRow}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -16,8 +16,6 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
||||
import type { SplitPane } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
|
||||
import { useTopBandRow } from "@/window-layout";
|
||||
|
||||
interface ExplorerSidebarDockProps extends Pick<
|
||||
WorkspaceDesktopTabsRowProps,
|
||||
@@ -119,61 +117,48 @@ export function ExplorerSidebarDock({
|
||||
[onCloseOtherTabs, tabs],
|
||||
);
|
||||
|
||||
const topBand = useTopBandRow({
|
||||
offset: 0,
|
||||
height: TOUCH_TARGET,
|
||||
// Trimming the rail beside the status region truncates its tab labels; start below instead.
|
||||
minimumWidth: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
const railBandStyle = useMemo(
|
||||
() => ({ paddingTop: topBand.top, paddingRight: topBand.trailing }),
|
||||
[topBand.top, topBand.trailing],
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanel active>
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<View style={styles.dock} testID="workspace-explorer-sidebar">
|
||||
<View ref={topBand.ref} onLayout={topBand.onLayout} style={railBandStyle}>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.tabRow}>
|
||||
<WorkspaceDesktopTabsRow
|
||||
host="explorer"
|
||||
launchPurpose="supporting"
|
||||
isFocused={isWorkspaceFocused}
|
||||
ownsKeyboardShortcuts={false}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
focusModeEnabled={false}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
externalDndContext={externalDndContext}
|
||||
paneId={pane.id}
|
||||
tabs={tabItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
onNavigateTab={handleSelectTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
/>
|
||||
</View>
|
||||
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
|
||||
<View pointerEvents="none" style={styles.tabRailDivider} />
|
||||
</WindowChromeSafeArea>
|
||||
</View>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.tabRow}>
|
||||
<WorkspaceDesktopTabsRow
|
||||
host="explorer"
|
||||
launchPurpose="supporting"
|
||||
isFocused={isWorkspaceFocused}
|
||||
ownsKeyboardShortcuts={false}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
focusModeEnabled={false}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
externalDndContext={externalDndContext}
|
||||
paneId={pane.id}
|
||||
tabs={tabItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
onNavigateTab={handleSelectTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
/>
|
||||
</View>
|
||||
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
|
||||
<View pointerEvents="none" style={styles.tabRailDivider} />
|
||||
</WindowChromeSafeArea>
|
||||
<View style={styles.content}>
|
||||
<WorkspacePanelHost
|
||||
paneId={pane.id}
|
||||
|
||||
@@ -163,7 +163,7 @@ import {
|
||||
WorkspaceExplorerSidebarToggle,
|
||||
WorkspaceHeaderExplorerToggle,
|
||||
} from "@/screens/workspace/workspace-explorer-toggle";
|
||||
import { useHasWindowChromeObstruction } from "@/utils/desktop-window";
|
||||
import { useHasWindowChromeObstruction, WindowChromeRegion } from "@/utils/desktop-window";
|
||||
import {
|
||||
resolveWorkspaceHeaderRenderState,
|
||||
type WorkspaceHeaderCheckoutState,
|
||||
@@ -1024,6 +1024,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 (
|
||||
<WindowChromeRegion corners={explorerOpen && !focusModeEnabled ? "top-left" : "both"}>
|
||||
<View style={styles.centerColumn}>{children}</View>
|
||||
</WindowChromeRegion>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceScreenContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -3635,7 +3652,10 @@ function WorkspaceScreenContent({
|
||||
<SheetTabs onCloseTab={handleCloseTabById} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.centerColumn}>
|
||||
<DesktopCenterColumnChrome
|
||||
explorerOpen={isExplorerSidebarShowing}
|
||||
focusModeEnabled={desktopFocusModeEnabled}
|
||||
>
|
||||
{rendersDesktopSplitContent ? null : (
|
||||
<AdaptiveWorkspaceChrome compactHeader={compactWorkspaceHeader}>
|
||||
{fullWorkspaceActions}
|
||||
@@ -3674,7 +3694,7 @@ function WorkspaceScreenContent({
|
||||
<View style={styles.centerContent}>
|
||||
<View style={styles.content}>{desktopContent}</View>
|
||||
</View>
|
||||
</View>
|
||||
</DesktopCenterColumnChrome>
|
||||
);
|
||||
|
||||
const [workspaceViewportWidth, setWorkspaceViewportWidth] = useState(0);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Insets extends { top: number; bottom: numb
|
||||
: { ...systemInsets, top, bottom };
|
||||
}
|
||||
|
||||
export interface TopBandClearance {
|
||||
/** Points the row's trailing controls keep from its trailing edge. */
|
||||
trailing: number;
|
||||
/** Points the row moves down when its controls cannot fit beside the occlusions. */
|
||||
top: number;
|
||||
}
|
||||
|
||||
export const NO_TOP_BAND_CLEARANCE: TopBandClearance = { trailing: 0, top: 0 };
|
||||
|
||||
/**
|
||||
* How a top-edge row keeps its controls out of the system occlusions it overlaps: trim its
|
||||
* trailing side to the leftmost occlusion, or, when `minimumWidth` no longer fits, start
|
||||
* below the occlusions. `row` is the row's frame without any clearance, in window points.
|
||||
*/
|
||||
export function resolveTopBandClearance(input: {
|
||||
row: WindowLayoutRect;
|
||||
occlusions: readonly WindowLayoutRect[];
|
||||
minimumWidth: number;
|
||||
}): TopBandClearance {
|
||||
const { row, occlusions, minimumWidth } = input;
|
||||
const overlapping = occlusions.filter((frame) => overlaps(frame, row));
|
||||
if (overlapping.length === 0) return NO_TOP_BAND_CLEARANCE;
|
||||
const trailing = Math.ceil(row.x + row.width - Math.min(...overlapping.map((frame) => frame.x)));
|
||||
if (row.width - trailing >= minimumWidth) return { trailing, top: 0 };
|
||||
const bottom = Math.max(...overlapping.map((frame) => frame.y + frame.height));
|
||||
return { trailing: 0, top: Math.ceil(bottom - row.y) };
|
||||
}
|
||||
|
||||
function overlaps(a: WindowLayoutRect, b: WindowLayoutRect): boolean {
|
||||
return (
|
||||
a.width > 0 &&
|
||||
a.height > 0 &&
|
||||
a.x < b.x + b.width &&
|
||||
a.x + a.width > b.x &&
|
||||
a.y < b.y + b.height &&
|
||||
a.y + a.height > b.y
|
||||
);
|
||||
}
|
||||
|
||||
export interface DesktopDivisionLayout {
|
||||
sidebarWidth: number;
|
||||
gap: number;
|
||||
|
||||
@@ -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<View | null>;
|
||||
onLayout: (() => void) | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clearance for a row that can land in the window's top band. The measured view's frame
|
||||
* must not depend on the clearance: apply `trailing` and `top` as padding inside it.
|
||||
*
|
||||
* - `offset`: points from the measured view's top to the row, without `top`.
|
||||
* - `height`: the row's height.
|
||||
* - `minimumWidth`: the narrowest row that still fits its controls.
|
||||
* - `settled`: re-measures when it changes, for rows whose panel slides into place.
|
||||
*/
|
||||
export function useTopBandRow(input: {
|
||||
offset: number;
|
||||
height: number;
|
||||
minimumWidth: number;
|
||||
settled?: boolean;
|
||||
}): TopBandRow {
|
||||
const { offset, height, minimumWidth, settled } = input;
|
||||
const band = useContext(TopBandContext);
|
||||
const ref = useRef<View | null>(null);
|
||||
const [frame, setFrame] = useState<WindowLayoutRect | null>(null);
|
||||
const measure = useCallback(() => {
|
||||
ref.current?.measureInWindow((x, y, width, measuredHeight) =>
|
||||
setFrame((previous) =>
|
||||
previous &&
|
||||
previous.x === x &&
|
||||
previous.y === y &&
|
||||
previous.width === width &&
|
||||
previous.height === measuredHeight
|
||||
? previous
|
||||
: { x, y, width, height: measuredHeight },
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const active = band !== null;
|
||||
useEffect(() => {
|
||||
if (active) measure();
|
||||
}, [active, measure, settled]);
|
||||
const clearance = useMemo(
|
||||
() =>
|
||||
band && frame
|
||||
? resolveTopBandClearance({
|
||||
row: { x: frame.x, y: frame.y + offset, width: frame.width, height },
|
||||
occlusions: band.occlusions,
|
||||
minimumWidth,
|
||||
})
|
||||
: NO_TOP_BAND_CLEARANCE,
|
||||
[band, frame, offset, height, minimumWidth],
|
||||
);
|
||||
return {
|
||||
ref,
|
||||
onLayout: active ? measure : undefined,
|
||||
trailing: clearance.trailing,
|
||||
top: clearance.top,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user