Open History to see the last commit
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
9const MEDIUM_LAYOUT_MIN_WIDTH = 600;
10const EXPANDED_LAYOUT_MIN_WIDTH = 720;
11const SPLIT_LAYOUT_MIN_HEIGHT = 600;
12
13const SPLIT_SIDEBAR_MIN_WIDTH = 280;
14const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380;
15
16export const CHAT_CONTENT_MAX_WIDTH = 960;
17// Limit Android reading width for wide foldables and keyboard-open split views.
18export const ANDROID_READING_MAX_WIDTH = 640;
19// min-h-8 uses the 14px rem configured in metro.config.js.
20export const THREAD_WORK_ROW_MIN_HEIGHT = 28;
21
22export function deriveThreadArrangementSizing(input: {
23 readonly baseFontSize: number;
24 readonly fontScale: number;
25}) {
26 const bodyLineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.body, input.baseFontSize);
27 const headerLineHeight = scaledTypographyLineHeight(
28 MOBILE_TYPOGRAPHY.footnote,
29 input.baseFontSize,
30 );
31 return {
32 rowHeight: Math.max(56, Math.ceil(2 * bodyLineHeight * input.fontScale) + 10),
33 headerHeight: Math.max(48, Math.ceil(headerLineHeight * input.fontScale) + 24),
34 };
35}
36
37export function deriveThreadWorkLogSizing(input: {
38 readonly baseFontSize: number;
39 readonly fontScale: number;
40}) {
41 const lineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.footnote, input.baseFontSize);
42 return {
43 textSizeKey: `${input.baseFontSize}:${input.fontScale}`,
44 estimatedRowHeight: Math.max(
45 THREAD_WORK_ROW_MIN_HEIGHT,
46 Math.ceil(lineHeight * input.fontScale),
47 ),
48 // Accessibility scaling can exceed authored line heights; leave those rows measured.
49 fixedRowHeight:
50 input.fontScale <= 1 && lineHeight <= THREAD_WORK_ROW_MIN_HEIGHT
51 ? THREAD_WORK_ROW_MIN_HEIGHT
52 : undefined,
53 };
54}
55
56const AUXILIARY_PANE_MIN_WIDTH = 260;
57const AUXILIARY_PANE_MAX_WIDTH = 480;
58const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320;
59const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820;
60const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560;
61// UIKit's inspector column keeps its own width, so the sidebar steps aside only when the page
62// would get narrower than this: landscape 11" and 13" iPads keep three columns, portrait ones don't.
63export const NATIVE_INSPECTOR_MIN_MAIN_WIDTH = 320;
64
65/**
66 * The window's width class. Compact: the sidebar is the root and pages push over it. Medium: the
67 * sidebar floats over the content. Expanded: the sidebar sits beside the content.
68 */
69export type LayoutVariant = "compact" | "medium" | "expanded";
70
71export interface Layout {
72 readonly variant: LayoutVariant;
73 /** Medium or expanded: pages replace the content beside the sidebar instead of pushing. */
74 readonly usesSplitView: boolean;
75 readonly listPaneWidth: number | null;
76 readonly listPaneGap?: number;
77 readonly shellPadding: number;
78}
79
80export interface WorkspacePaneLayout {
81 readonly primarySidebarVisible: boolean;
82 /** Whether the sidebar shows once a collapsed split expands; independent of the current width. */
83 readonly primarySidebarWhenExpanded: boolean;
84 readonly primarySidebarSuppressedByAuxiliary: boolean;
85 readonly contentPaneWidth: number;
86 readonly supportsAuxiliaryPane: boolean;
87 readonly auxiliaryPaneVisible: boolean;
88 readonly auxiliaryPaneWidth: number | null;
89}
90
91export interface FileInspectorPaneLayout {
92 readonly supported: boolean;
93 readonly width: number | null;
94}
95
96export function deriveLayout(input: {
97 readonly width: number;
98 readonly height: number;
99 readonly nativeMetrics?: NativeLayoutMetrics | null;
100 readonly nativeWorkspace?: boolean;
101}): Layout {
102 const { width, height } = input;
103 const metrics = input.nativeMetrics;
104 const usableWidth = width - (metrics?.safeArea.left ?? 0) - (metrics?.safeArea.right ?? 0);
105 // UIKit retains split columns below the generic tablet breakpoint. Native
106 // columns follow its size class while reserving room for both column minimums.
107 // Unfolded in portrait, the inner display reports neither a side bar nor a
108 // fold, only its hinge.
109 const hasDuoGeometry =
110 metrics &&
111 (metrics.hasHinge === true ||
112 metrics.verticalBarEdge !== "none" ||
113 metrics.reservedRegions.some((region) => region.kind === "division"));
114 const variant: LayoutVariant =
115 metrics && (input.nativeWorkspace || hasDuoGeometry)
116 ? metrics.horizontalSizeClass === "regular" &&
117 usableWidth >= SPLIT_SIDEBAR_MIN_WIDTH + 320 &&
118 (input.nativeWorkspace || height >= 400)
119 ? // The native split floats its sidebar over an unfolded portrait Duo.
120 input.nativeWorkspace === true && metrics.hasHinge === true && width < height
121 ? "medium"
122 : "expanded"
123 : "compact"
124 : height < SPLIT_LAYOUT_MIN_HEIGHT || width < MEDIUM_LAYOUT_MIN_WIDTH
125 ? "compact"
126 : width >= EXPANDED_LAYOUT_MIN_WIDTH
127 ? "expanded"
128 : // Only the Android shell floats a sidebar over a page; iOS keeps its phone stack.
129 metrics
130 ? "compact"
131 : "medium";
132
133 if (variant === "compact") {
134 return {
135 variant,
136 usesSplitView: false,
137 listPaneWidth: null,
138 shellPadding: 0,
139 };
140 }
141
142 const division = metrics?.reservedRegions.find(
143 (region) =>
144 region.kind === "division" &&
145 region.height >= height / 2 &&
146 region.x - metrics.safeArea.left >= SPLIT_SIDEBAR_MIN_WIDTH &&
147 width - region.x - region.width - metrics.safeArea.right >= 320,
148 );
149 return {
150 variant,
151 usesSplitView: true,
152 listPaneWidth:
153 division?.x ??
154 clamp(Math.round(width * 0.32), SPLIT_SIDEBAR_MIN_WIDTH, SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH),
155 ...(division ? { listPaneGap: division.width } : {}),
156 shellPadding: 0,
157 };
158}
159
160export interface VerticalHinge {
161 readonly x: number;
162 readonly width: number;
163}
164
165/**
166 * Moves the list/detail split onto a book-posture hinge so neither pane straddles the crease.
167 * `leadingWidth` is chrome left of the list pane, such as the Android navigation rail.
168 */
169export function alignListPaneToHinge(input: {
170 readonly layout: Layout;
171 readonly hinge: VerticalHinge | null;
172 readonly viewportWidth: number;
173 readonly leadingWidth: number;
174}): Layout {
175 const { layout, hinge } = input;
176 if (!hinge || !layout.usesSplitView) return layout;
177 const listPaneWidth = hinge.x - input.leadingWidth;
178 if (
179 listPaneWidth < SPLIT_SIDEBAR_MIN_WIDTH ||
180 input.viewportWidth - hinge.x - hinge.width < 320
181 ) {
182 return layout;
183 }
184 return { ...layout, listPaneWidth, listPaneGap: hinge.width };
185}
186
187/**
188 * Whether the workspace root must keep the primary sidebar open. Native split
189 * columns keep a sidebar toggle in the empty detail header, so only they may
190 * collapse it there. Compact layouts stay pinned so a reveal request (such as
191 * focusing search) cannot flip the saved preference for a later split view.
192 */
193/** Home's list, or the Settings list in its place, with no detail selected. */
194export function isWorkspaceListPath(pathname: string): boolean {
195 return pathname === "/" || pathname === "/settings";
196}
197
198export function isPrimarySidebarPinned(input: {
199 readonly pathname: string;
200 readonly nativeWorkspace: boolean;
201 readonly usesSplitView: boolean;
202}): boolean {
203 return isWorkspaceListPath(input.pathname) && !(input.nativeWorkspace && input.usesSplitView);
204}
205
206export function deriveWorkspacePaneLayout(input: {
207 readonly layout: Layout;
208 readonly viewportWidth: number;
209 /** Width UIKit keeps out of its columns, such as the Duo's system bar. */
210 readonly horizontalSafeArea?: number;
211 readonly nativeWorkspace?: boolean;
212 /** Whether the sidebar floats over the content rather than taking its room; medium by default. */
213 readonly primarySidebarFloats?: boolean;
214 readonly primarySidebarPreferredVisible: boolean;
215 readonly auxiliaryPanePreferredVisible: boolean;
216 readonly auxiliaryPanePreferredWidth?: number;
217 /** The native inspector column's width; UIKit lays it out, not this layout. */
218 readonly nativeInspectorWidth?: number;
219}): WorkspacePaneLayout {
220 const viewportWidth = Math.max(0, input.viewportWidth);
221 const floats = input.primarySidebarFloats ?? input.layout.variant === "medium";
222 const preferredPrimarySidebarVisible =
223 input.layout.usesSplitView && input.primarySidebarPreferredVisible;
224 const preferredPrimarySidebarWidth =
225 preferredPrimarySidebarVisible && !floats
226 ? (input.layout.listPaneWidth ?? 0) + (input.layout.listPaneGap ?? 0)
227 : 0;
228 const fileInspector = deriveFileInspectorPaneLayout({
229 layout: input.layout,
230 viewportWidth,
231 preferredWidth: input.auxiliaryPanePreferredWidth,
232 reservedLeadingWidth: preferredPrimarySidebarWidth,
233 });
234 const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible;
235 const inspectorWidth = input.nativeWorkspace
236 ? (input.nativeInspectorWidth ?? null)
237 : fileInspector.width;
238 const primarySidebarSuppressedByAuxiliary =
239 preferredPrimarySidebarVisible &&
240 !floats &&
241 auxiliaryPaneVisible &&
242 inspectorWidth !== null &&
243 input.layout.listPaneWidth !== null &&
244 viewportWidth -
245 (input.nativeWorkspace ? (input.horizontalSafeArea ?? 0) : 0) -
246 input.layout.listPaneWidth -
247 (input.layout.listPaneGap ?? 0) -
248 inspectorWidth <
249 (input.nativeWorkspace ? NATIVE_INSPECTOR_MIN_MAIN_WIDTH : FILE_INSPECTOR_MIN_MAIN_WIDTH);
250 const primarySidebarVisible =
251 preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary;
252 const primarySidebarWidth = primarySidebarVisible ? preferredPrimarySidebarWidth : 0;
253
254 return {
255 primarySidebarVisible,
256 primarySidebarWhenExpanded: input.primarySidebarPreferredVisible,
257 primarySidebarSuppressedByAuxiliary,
258 contentPaneWidth: Math.max(0, viewportWidth - primarySidebarWidth),
259 supportsAuxiliaryPane: fileInspector.supported,
260 auxiliaryPaneVisible,
261 auxiliaryPaneWidth: fileInspector.width,
262 };
263}
264
265/**
266 * UIKit drops the inspector column, and refuses to show it again, once the page beside it would
267 * fall under its minimum inside the safe area (the Duo's system bar takes width from the window).
268 */
269export function nativeInspectorColumnWidth(input: {
270 readonly viewportWidth: number;
271 readonly horizontalSafeArea: number;
272 readonly fraction: number;
273}) {
274 const { viewportWidth } = input;
275 const maxWidth = Math.min(
276 viewportWidth * 0.6,
277 viewportWidth - input.horizontalSafeArea - NATIVE_INSPECTOR_MIN_MAIN_WIDTH,
278 );
279 return Math.max(260, Math.min(maxWidth, viewportWidth * input.fraction));
280}
281
282export function deriveFileInspectorPaneLayout(input: {
283 readonly layout: Layout;
284 readonly viewportWidth: number;
285 readonly preferredWidth?: number;
286 readonly reservedLeadingWidth?: number;
287}): FileInspectorPaneLayout {
288 const viewportWidth = Math.max(0, input.viewportWidth);
289 const reservedLeadingWidth = Number.isFinite(input.reservedLeadingWidth)
290 ? Math.max(0, input.reservedLeadingWidth ?? 0)
291 : 0;
292 const availableContentWidth = Math.max(0, viewportWidth - reservedLeadingWidth);
293 const supported =
294 input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH;
295
296 return {
297 supported,
298 width: supported
299 ? constrainAuxiliaryPaneWidth({
300 preferredWidth:
301 input.preferredWidth ??
302 clamp(
303 Math.round(availableContentWidth * 0.28),
304 AUXILIARY_PANE_MIN_WIDTH,
305 AUXILIARY_PANE_DEFAULT_MAX_WIDTH,
306 ),
307 availableWidth: availableContentWidth,
308 })
309 : null,
310 };
311}
312
313export function constrainAuxiliaryPaneWidth(input: {
314 readonly preferredWidth: number;
315 readonly availableWidth: number;
316}): number {
317 const safePreferredWidth = Number.isFinite(input.preferredWidth)
318 ? input.preferredWidth
319 : AUXILIARY_PANE_MIN_WIDTH;
320 const availableWidth = Number.isFinite(input.availableWidth)
321 ? Math.max(0, input.availableWidth)
322 : 0;
323 const maxWidth = Math.max(
324 AUXILIARY_PANE_MIN_WIDTH,
325 Math.min(AUXILIARY_PANE_MAX_WIDTH, availableWidth - FILE_INSPECTOR_MIN_MAIN_WIDTH),
326 );
327 return clamp(Math.round(safePreferredWidth), AUXILIARY_PANE_MIN_WIDTH, maxWidth);
328}
329
330export function deriveCenteredContentHorizontalPadding(input: {
331 readonly viewportWidth: number;
332 readonly maxContentWidth: number | null;
333 readonly minimumPadding: number;
334}): number {
335 const viewportWidth = Number.isFinite(input.viewportWidth) ? Math.max(0, input.viewportWidth) : 0;
336 const minimumPadding = Number.isFinite(input.minimumPadding)
337 ? Math.max(0, input.minimumPadding)
338 : 0;
339
340 if (
341 input.maxContentWidth === null ||
342 !Number.isFinite(input.maxContentWidth) ||
343 input.maxContentWidth <= 0
344 ) {
345 return minimumPadding;
346 }
347
348 return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2);
349}