diff --git a/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift b/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift index 3ac90cf2b5..2168daf5b6 100644 --- a/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift +++ b/packages/app/modules/paseo-window-layout/ios/PaseoWindowLayoutModule.swift @@ -18,6 +18,8 @@ final class WindowLayoutView: ExpoView { private var barGuide: UILayoutGuide? private var barEdge = "none" private var bottomBarGuide: UILayoutGuide? + private var topBarGuide: UILayoutGuide? + private var occlusions: [CGRect] = [] private var measuredBounds = CGRect.zero required init(appContext: AppContext? = nil) { @@ -38,6 +40,7 @@ final class WindowLayoutView: ExpoView { super.didMoveToWindow() previousPayload = nil bottomBarGuide = nil + topBarGuide = nil setNeedsUpdateConstraints() scheduleUpdate() } @@ -62,6 +65,8 @@ final class WindowLayoutView: ExpoView { if #available(iOS 27.1, *) { // UIKit tracks these reads and invalidates constraints when regions change. divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil } + // Frames already include the system's interactive margins. + occlusions = reservedRegions(kind: .occlusion).compactMap { $0.isActive ? $0.frame : nil } let edge: String switch traitCollection.verticalBarEdge { case .leading: edge = "leading" @@ -73,6 +78,11 @@ final class WindowLayoutView: ExpoView { // screen's corners rather than the rectangular safe area. bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44)) } + if topBarGuide == nil { + // Where a top bar rests. Beside a partial status region it can start above the + // rectangular safe area; content still keeps out of the occlusions. + topBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.top, extent: 44)) + } if edge != barEdge { barEdge = edge barGuide = edge == "none" ? nil : layoutGuide( @@ -106,11 +116,17 @@ final class WindowLayoutView: ExpoView { if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 { bottomClearance = max(0, bounds.maxY - bottomBar.maxY) } + var topBar: Any = NSNull() + if let frame = topBarGuide?.layoutFrame, frame.width > 0, frame.height > 0 { + topBar = rect(frame) + } let payload: [String: Any] = [ "bounds": rect(bounds), "verticalBarEdge": barEdge, "verticalBar": barPayload, "bottomClearance": bottomClearance, + "topBar": topBar, + "occlusions": occlusions.map(rect), "divisions": divisions.map(rect) ] guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return } diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx index cbc161ae66..69f73cc438 100644 --- a/packages/app/src/components/headers/compact-header.tsx +++ b/packages/app/src/components/headers/compact-header.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, type ReactNode } from "react"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Text, View } from "react-native"; +import { Text, View, type LayoutChangeEvent } from "react-native"; import { router } from "expo-router"; import { ArrowLeft } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -12,12 +12,19 @@ import { iconButtonChromeGlyphSize, mutedIconColorMapping, } from "@/components/ui/icon-button-chrome"; +import { TOUCH_TARGET } from "@/components/ui/control-sizing"; import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; +import { SPACING } from "@/styles/theme"; +import { useTopBandRow } from "@/window-layout"; import type { ShortcutKey } from "@/utils/format-shortcut"; const ThemedArrowLeft = withUnistyles(ArrowLeft); const NO_SHORTCUT: ShortcutKey[] = []; +const ROW_PADDING = SPACING[3]; +const ROW_GAP = SPACING[2]; +// Below this the title is unreadable; the row starts below the system's occlusions instead. +const MIN_TITLE_WIDTH = 96; interface CompactHeaderProps { /** The leading island: the sidebar menu, or a back button. */ @@ -51,21 +58,38 @@ export function CompactHeader({ actions, }: CompactHeaderProps) { const insets = useSafeAreaInsets(); + const [actionsWidth, setActionsWidth] = useState(0); + const measureActions = useCallback( + (event: LayoutChangeEvent) => setActionsWidth(event.nativeEvent.layout.width), + [], + ); + // 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, + minimumWidth: + ROW_PADDING + + TOUCH_TARGET + + ROW_GAP + + MIN_TITLE_WIDTH + + (actions ? ROW_GAP + actionsWidth : 0), + }); const rootStyle = useMemo( () => [ styles.root, { - paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE, + 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], + [insets.top, insets.left, insets.right, topBand.top, topBand.trailing], ); return ( - + {navigation === "menu" ? : } @@ -78,7 +102,11 @@ export function CompactHeader({ subtitle={subtitle} /> - {actions ? {actions} : null} + {actions ? ( + + {actions} + + ) : null} @@ -140,13 +168,13 @@ function BackButton({ onBack }: { onBack?: () => void }) { const styles = StyleSheet.create((theme) => ({ root: { - paddingHorizontal: theme.spacing[3], + paddingLeft: ROW_PADDING, }, row: { height: HEADER_INNER_HEIGHT_MOBILE, flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], + gap: ROW_GAP, }, titleGroup: { flex: 1, diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index 6492658389..2af603ade4 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -1,5 +1,5 @@ import { ComposerDockBackground } from "@/composer/dock"; -import { useMemo, type ReactNode } from "react"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; import type { LayoutChangeEvent } from "react-native"; import { View, type StyleProp, type ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -12,6 +12,11 @@ import { } from "@/constants/layout"; import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { TOUCH_TARGET } from "@/components/ui/control-sizing"; +import { useTopBandRow } from "@/window-layout"; + +// Leading content that must stay readable in the top band: a navigation button and a title. +const MIN_LEADING_WIDTH = TOUCH_TARGET + 8 + 96; interface ScreenHeaderProps { left?: ReactNode; @@ -41,17 +46,37 @@ export function ScreenHeader({ const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3]; + const [rightWidth, setRightWidth] = useState(0); + const measureRight = useCallback( + (event: LayoutChangeEvent) => setRightWidth(event.nativeEvent.layout.width), + [], + ); + const topBand = useTopBandRow({ + offset: insets.top + topPadding, + height: isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT, + minimumWidth: baseHorizontalPadding + MIN_LEADING_WIDTH + rightWidth, + }); const innerStyle = useMemo( - () => [styles.inner, { paddingTop: insets.top + topPadding }], - [insets.top, topPadding], + () => [styles.inner, { paddingTop: insets.top + topPadding + topBand.top }], + [insets.top, topPadding, topBand.top], ); const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]); const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]); - const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]); + // 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], + ); return ( - + {left} - {right} + + {right} + diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index b4b54db3db..5debd60cb9 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -29,7 +29,7 @@ import { resolveDesktopSidebarInsetLeft, resolveDesktopSidebarWidth, } from "@/components/desktop-sidebar-layout"; -import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout"; +import { useWindowLayout, resolveDesktopDivisionLayout, useTopBandRow } from "@/window-layout"; import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET, @@ -46,6 +46,7 @@ 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"; @@ -527,6 +528,14 @@ 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( () => ( ), - [labels.closeSidebar, closeSidebar, insets.right], + [labels.closeSidebar, closeSidebar, insets.right, navBand.trailing], ); const mobileSidebarInsetStyle = useMemo( @@ -550,11 +559,13 @@ function MobileSidebar({ ); const navRows = ( - + + + ); return ( @@ -894,8 +905,8 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, }, - mobileCloseButton: (rightInset: number) => ({ - marginRight: theme.spacing[2] + rightInset, + mobileCloseButton: (rightInset: number, topBandTrailing: number) => ({ + marginRight: Math.max(theme.spacing[2] + rightInset, topBandTrailing), }), 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 62a15bad04..d5d3eff1b8 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -19,6 +19,8 @@ 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 { @@ -200,13 +202,30 @@ 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 ? ( - +