| 1 | import { scaledTypographyLineHeight } from "./appearancePreferences"; |
| 2 | import { MOBILE_TYPOGRAPHY } from "./typography"; |
| 3 | import type { NativeLayoutMetrics } from "./reserved-regions"; |
| 4 | |
| 5 | function clamp(value: number, min: number, max: number): number { |
| 6 | return Math.min(Math.max(value, min), max); |
| 7 | } |
| 8 | |
| 9 | /** |
| 10 | * Use available space, not device or orientation labels, to choose the shell. |
| 11 | * |
| 12 | * The height floor deliberately keeps every current iPhone in the compact shell |
| 13 | * when it rotates to landscape, while still allowing iPad and foldable-sized |
| 14 | * windows to adopt the persistent sidebar as they resize. |
| 15 | */ |
| 16 | export const SPLIT_LAYOUT_MIN_WIDTH = 720; |
| 17 | export const SPLIT_LAYOUT_MIN_HEIGHT = 600; |
| 18 | |
| 19 | export const SPLIT_SIDEBAR_MIN_WIDTH = 280; |
| 20 | const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; |
| 21 | |
| 22 | export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; |
| 23 | export const CHAT_CONTENT_MAX_WIDTH = 960; |
| 24 | // min-h-8 uses the 14px rem configured in metro.config.js. |
| 25 | export const THREAD_WORK_ROW_MIN_HEIGHT = 28; |
| 26 | |
| 27 | export function deriveThreadWorkLogSizing(input: { |
| 28 | readonly baseFontSize: number; |
| 29 | readonly fontScale: number; |
| 30 | }) { |
| 31 | const lineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.footnote, input.baseFontSize); |
| 32 | return { |
| 33 | // Different text metrics can share the same minimum row height. |
| 34 | textSizeKey: `${input.baseFontSize}:${input.fontScale}`, |
| 35 | estimatedRowHeight: Math.max( |
| 36 | THREAD_WORK_ROW_MIN_HEIGHT, |
| 37 | Math.ceil(lineHeight * input.fontScale), |
| 38 | ), |
| 39 | // Native text can exceed its authored line height with accessibility scaling. |
| 40 | // Leave those rows measured instead of promising LegendList an exact size. |
| 41 | fixedRowHeight: |
| 42 | input.fontScale <= 1 && lineHeight <= THREAD_WORK_ROW_MIN_HEIGHT |
| 43 | ? THREAD_WORK_ROW_MIN_HEIGHT |
| 44 | : undefined, |
| 45 | }; |
| 46 | } |
| 47 | |
| 48 | export const AUXILIARY_PANE_MIN_WIDTH = 260; |
| 49 | export const AUXILIARY_PANE_MAX_WIDTH = 480; |
| 50 | const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320; |
| 51 | const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; |
| 52 | const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; |
| 53 | |
| 54 | export type LayoutVariant = "compact" | "split"; |
| 55 | |
| 56 | export interface Layout { |
| 57 | readonly variant: LayoutVariant; |
| 58 | readonly usesSplitView: boolean; |
| 59 | readonly listPaneWidth: number | null; |
| 60 | readonly listPaneGap?: number; |
| 61 | readonly shellPadding: number; |
| 62 | } |
| 63 | |
| 64 | export interface WorkspacePaneLayout { |
| 65 | readonly primarySidebarVisible: boolean; |
| 66 | readonly primarySidebarSuppressedByAuxiliary: boolean; |
| 67 | readonly contentPaneWidth: number; |
| 68 | readonly supportsAuxiliaryPane: boolean; |
| 69 | readonly auxiliaryPaneVisible: boolean; |
| 70 | readonly auxiliaryPaneWidth: number | null; |
| 71 | } |
| 72 | |
| 73 | export interface FileInspectorPaneLayout { |
| 74 | readonly supported: boolean; |
| 75 | readonly width: number | null; |
| 76 | } |
| 77 | |
| 78 | export function deriveThreadFeedInitialContentInset(input: { |
| 79 | readonly platform: string; |
| 80 | readonly usesNativeAutomaticInsets: boolean; |
| 81 | readonly bottomContentInset: number; |
| 82 | }): { readonly bottom: number } | undefined { |
| 83 | if (input.platform !== "android" || input.usesNativeAutomaticInsets) { |
| 84 | return undefined; |
| 85 | } |
| 86 | |
| 87 | return { bottom: Math.max(0, input.bottomContentInset) }; |
| 88 | } |
| 89 | |
| 90 | export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; |
| 91 | |
| 92 | export function deriveLayout(input: { |
| 93 | readonly width: number; |
| 94 | readonly height: number; |
| 95 | readonly nativeMetrics?: NativeLayoutMetrics | null; |
| 96 | }): Layout { |
| 97 | const { width, height } = input; |
| 98 | const metrics = input.nativeMetrics; |
| 99 | const usableWidth = width - (metrics?.safeArea.left ?? 0) - (metrics?.safeArea.right ?? 0); |
| 100 | // UIKit's size class supports the shorter Duo inner display. Geometry remains |
| 101 | // a floor so a narrow multitasking window cannot squeeze both columns. |
| 102 | const hasDuoGeometry = |
| 103 | metrics && |
| 104 | (metrics.verticalBarEdge !== "none" || |
| 105 | metrics.reservedRegions.some((region) => region.kind === "division")); |
| 106 | const wideEnoughForSplit = hasDuoGeometry |
| 107 | ? metrics.horizontalSizeClass === "regular" && |
| 108 | usableWidth >= SPLIT_SIDEBAR_MIN_WIDTH + 320 && |
| 109 | height >= 400 |
| 110 | : width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT; |
| 111 | |
| 112 | if (!wideEnoughForSplit) { |
| 113 | return { |
| 114 | variant: "compact", |
| 115 | usesSplitView: false, |
| 116 | listPaneWidth: null, |
| 117 | shellPadding: 0, |
| 118 | }; |
| 119 | } |
| 120 | |
| 121 | const division = metrics?.reservedRegions.find( |
| 122 | (region) => |
| 123 | region.kind === "division" && |
| 124 | region.height >= height / 2 && |
| 125 | region.x - metrics.safeArea.left >= SPLIT_SIDEBAR_MIN_WIDTH && |
| 126 | width - region.x - region.width - metrics.safeArea.right >= 320, |
| 127 | ); |
| 128 | return { |
| 129 | variant: "split", |
| 130 | usesSplitView: true, |
| 131 | listPaneWidth: |
| 132 | division?.x ?? |
| 133 | clamp(Math.round(width * 0.32), SPLIT_SIDEBAR_MIN_WIDTH, SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH), |
| 134 | ...(division ? { listPaneGap: division.width } : {}), |
| 135 | shellPadding: 0, |
| 136 | }; |
| 137 | } |
| 138 | |
| 139 | export function deriveWorkspacePaneLayout(input: { |
| 140 | readonly layout: Layout; |
| 141 | readonly viewportWidth: number; |
| 142 | readonly primarySidebarPreferredVisible: boolean; |
| 143 | readonly auxiliaryPanePreferredVisible: boolean; |
| 144 | readonly auxiliaryPaneRole?: WorkspaceAuxiliaryPaneRole; |
| 145 | readonly auxiliaryPanePreferredWidth?: number; |
| 146 | }): WorkspacePaneLayout { |
| 147 | const viewportWidth = Math.max(0, input.viewportWidth); |
| 148 | const auxiliaryPaneRole = input.auxiliaryPaneRole ?? "supplementary"; |
| 149 | const preferredPrimarySidebarVisible = |
| 150 | input.layout.usesSplitView && input.primarySidebarPreferredVisible; |
| 151 | const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible |
| 152 | ? (input.layout.listPaneWidth ?? 0) + (input.layout.listPaneGap ?? 0) |
| 153 | : 0; |
| 154 | |
| 155 | if (auxiliaryPaneRole === "inspector") { |
| 156 | const fileInspector = deriveFileInspectorPaneLayout({ |
| 157 | layout: input.layout, |
| 158 | viewportWidth, |
| 159 | preferredWidth: input.auxiliaryPanePreferredWidth, |
| 160 | reservedLeadingWidth: preferredPrimarySidebarWidth, |
| 161 | }); |
| 162 | const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible; |
| 163 | const primarySidebarSuppressedByAuxiliary = |
| 164 | preferredPrimarySidebarVisible && |
| 165 | auxiliaryPaneVisible && |
| 166 | fileInspector.width !== null && |
| 167 | input.layout.listPaneWidth !== null && |
| 168 | viewportWidth - |
| 169 | input.layout.listPaneWidth - |
| 170 | (input.layout.listPaneGap ?? 0) - |
| 171 | fileInspector.width < |
| 172 | FILE_INSPECTOR_MIN_MAIN_WIDTH; |
| 173 | const primarySidebarVisible = |
| 174 | preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; |
| 175 | const primarySidebarWidth = primarySidebarVisible ? preferredPrimarySidebarWidth : 0; |
| 176 | |
| 177 | return { |
| 178 | primarySidebarVisible, |
| 179 | primarySidebarSuppressedByAuxiliary, |
| 180 | contentPaneWidth: Math.max(0, viewportWidth - primarySidebarWidth), |
| 181 | supportsAuxiliaryPane: fileInspector.supported, |
| 182 | auxiliaryPaneVisible, |
| 183 | auxiliaryPaneWidth: fileInspector.width, |
| 184 | }; |
| 185 | } |
| 186 | |
| 187 | const contentPaneWidth = Math.max(0, viewportWidth - preferredPrimarySidebarWidth); |
| 188 | const supportsAuxiliaryPane = |
| 189 | input.layout.usesSplitView && contentPaneWidth >= AUXILIARY_PANE_MIN_CONTENT_WIDTH; |
| 190 | const auxiliaryPaneVisible = supportsAuxiliaryPane && input.auxiliaryPanePreferredVisible; |
| 191 | const defaultAuxiliaryPaneWidth = clamp( |
| 192 | Math.round(contentPaneWidth * 0.28), |
| 193 | AUXILIARY_PANE_MIN_WIDTH, |
| 194 | AUXILIARY_PANE_DEFAULT_MAX_WIDTH, |
| 195 | ); |
| 196 | |
| 197 | return { |
| 198 | primarySidebarVisible: preferredPrimarySidebarVisible, |
| 199 | primarySidebarSuppressedByAuxiliary: false, |
| 200 | contentPaneWidth, |
| 201 | supportsAuxiliaryPane, |
| 202 | auxiliaryPaneVisible, |
| 203 | auxiliaryPaneWidth: supportsAuxiliaryPane |
| 204 | ? constrainAuxiliaryPaneWidth({ |
| 205 | preferredWidth: input.auxiliaryPanePreferredWidth ?? defaultAuxiliaryPaneWidth, |
| 206 | availableWidth: contentPaneWidth, |
| 207 | }) |
| 208 | : null, |
| 209 | }; |
| 210 | } |
| 211 | |
| 212 | export function deriveFileInspectorPaneLayout(input: { |
| 213 | readonly layout: Layout; |
| 214 | readonly viewportWidth: number; |
| 215 | readonly preferredWidth?: number; |
| 216 | readonly reservedLeadingWidth?: number; |
| 217 | }): FileInspectorPaneLayout { |
| 218 | const viewportWidth = Math.max(0, input.viewportWidth); |
| 219 | const reservedLeadingWidth = Number.isFinite(input.reservedLeadingWidth) |
| 220 | ? Math.max(0, input.reservedLeadingWidth ?? 0) |
| 221 | : 0; |
| 222 | const availableContentWidth = Math.max(0, viewportWidth - reservedLeadingWidth); |
| 223 | const supported = |
| 224 | input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH; |
| 225 | |
| 226 | return { |
| 227 | supported, |
| 228 | width: supported |
| 229 | ? constrainAuxiliaryPaneWidth({ |
| 230 | preferredWidth: |
| 231 | input.preferredWidth ?? |
| 232 | clamp( |
| 233 | Math.round(availableContentWidth * 0.28), |
| 234 | AUXILIARY_PANE_MIN_WIDTH, |
| 235 | AUXILIARY_PANE_DEFAULT_MAX_WIDTH, |
| 236 | ), |
| 237 | availableWidth: availableContentWidth, |
| 238 | }) |
| 239 | : null, |
| 240 | }; |
| 241 | } |
| 242 | |
| 243 | /** |
| 244 | * Keep an auxiliary pane within native-feeling bounds without squeezing its |
| 245 | * neighboring content below a usable reading/editor width. |
| 246 | */ |
| 247 | export function constrainAuxiliaryPaneWidth(input: { |
| 248 | readonly preferredWidth: number; |
| 249 | readonly availableWidth: number; |
| 250 | }): number { |
| 251 | const safePreferredWidth = Number.isFinite(input.preferredWidth) |
| 252 | ? input.preferredWidth |
| 253 | : AUXILIARY_PANE_MIN_WIDTH; |
| 254 | const availableWidth = Number.isFinite(input.availableWidth) |
| 255 | ? Math.max(0, input.availableWidth) |
| 256 | : 0; |
| 257 | const maxWidth = Math.max( |
| 258 | AUXILIARY_PANE_MIN_WIDTH, |
| 259 | Math.min(AUXILIARY_PANE_MAX_WIDTH, availableWidth - FILE_INSPECTOR_MIN_MAIN_WIDTH), |
| 260 | ); |
| 261 | return clamp(Math.round(safePreferredWidth), AUXILIARY_PANE_MIN_WIDTH, maxWidth); |
| 262 | } |
| 263 | |
| 264 | export function deriveCenteredContentHorizontalPadding(input: { |
| 265 | readonly viewportWidth: number; |
| 266 | readonly maxContentWidth: number | null; |
| 267 | readonly minimumPadding: number; |
| 268 | }): number { |
| 269 | const viewportWidth = Number.isFinite(input.viewportWidth) ? Math.max(0, input.viewportWidth) : 0; |
| 270 | const minimumPadding = Number.isFinite(input.minimumPadding) |
| 271 | ? Math.max(0, input.minimumPadding) |
| 272 | : 0; |
| 273 | |
| 274 | if ( |
| 275 | input.maxContentWidth === null || |
| 276 | !Number.isFinite(input.maxContentWidth) || |
| 277 | input.maxContentWidth <= 0 |
| 278 | ) { |
| 279 | return minimumPadding; |
| 280 | } |
| 281 | |
| 282 | return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); |
| 283 | } |