1import { scaledTypographyLineHeight } from "./appearancePreferences";
2import { MOBILE_TYPOGRAPHY } from "./typography";
3import type { NativeLayoutMetrics } from "./reserved-regions";
4
5function 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 */
16export const SPLIT_LAYOUT_MIN_WIDTH = 720;
17export const SPLIT_LAYOUT_MIN_HEIGHT = 600;
18
19export const SPLIT_SIDEBAR_MIN_WIDTH = 280;
20const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380;
21
22export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960;
23export const CHAT_CONTENT_MAX_WIDTH = 960;
24// min-h-8 uses the 14px rem configured in metro.config.js.
25export const THREAD_WORK_ROW_MIN_HEIGHT = 28;
26
27export 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
48export const AUXILIARY_PANE_MIN_WIDTH = 260;
49export const AUXILIARY_PANE_MAX_WIDTH = 480;
50const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320;
51const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820;
52const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560;
53
54export type LayoutVariant = "compact" | "split";
55
56export 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
64export 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
73export interface FileInspectorPaneLayout {
74 readonly supported: boolean;
75 readonly width: number | null;
76}
77
78export 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
90export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector";
91
92export 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
139export 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
212export 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 */
247export 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
264export 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}