import { scaledTypographyLineHeight } from "./appearancePreferences";import { MOBILE_TYPOGRAPHY } from "./typography"; function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max);} /** * Use available space, not device or orientation labels, to choose the shell. * * The height floor deliberately keeps every current iPhone in the compact shell * when it rotates to landscape, while still allowing iPad and foldable-sized * windows to adopt the persistent sidebar as they resize. */export const SPLIT_LAYOUT_MIN_WIDTH = 720;export const SPLIT_LAYOUT_MIN_HEIGHT = 600; export const SPLIT_SIDEBAR_MIN_WIDTH = 280;const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960;export const CHAT_CONTENT_MAX_WIDTH = 960;// min-h-8 uses the 14px rem configured in metro.config.js.export const THREAD_WORK_ROW_MIN_HEIGHT = 28; export function deriveThreadWorkLogSizing(input: { readonly baseFontSize: number; readonly fontScale: number;}) { const lineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.footnote, input.baseFontSize); return { // Different text metrics can share the same minimum row height. textSizeKey: `${input.baseFontSize}:${input.fontScale}`, estimatedRowHeight: Math.max( THREAD_WORK_ROW_MIN_HEIGHT, Math.ceil(lineHeight * input.fontScale), ), // Native text can exceed its authored line height with accessibility scaling. // Leave those rows measured instead of promising LegendList an exact size. fixedRowHeight: input.fontScale <= 1 && lineHeight <= THREAD_WORK_ROW_MIN_HEIGHT ? THREAD_WORK_ROW_MIN_HEIGHT : undefined, };} export const AUXILIARY_PANE_MIN_WIDTH = 260;export const AUXILIARY_PANE_MAX_WIDTH = 480;const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320;const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820;const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; export type LayoutVariant = "compact" | "split"; export interface Layout { readonly variant: LayoutVariant; readonly usesSplitView: boolean; readonly listPaneWidth: number | null; readonly shellPadding: number;} export interface WorkspacePaneLayout { readonly primarySidebarVisible: boolean; readonly primarySidebarSuppressedByAuxiliary: boolean; readonly contentPaneWidth: number; readonly supportsAuxiliaryPane: boolean; readonly auxiliaryPaneVisible: boolean; readonly auxiliaryPaneWidth: number | null;} export interface FileInspectorPaneLayout { readonly supported: boolean; readonly width: number | null;} export function deriveThreadFeedInitialContentInset(input: { readonly platform: string; readonly usesNativeAutomaticInsets: boolean; readonly bottomContentInset: number;}): { readonly bottom: number } | undefined { if (input.platform !== "android" || input.usesNativeAutomaticInsets) { return undefined; } return { bottom: Math.max(0, input.bottomContentInset) };} export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { const { width, height } = input; const wideEnoughForSplit = width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT; if (!wideEnoughForSplit) { return { variant: "compact", usesSplitView: false, listPaneWidth: null, shellPadding: 0, }; } return { variant: "split", usesSplitView: true, listPaneWidth: clamp( Math.round(width * 0.32), SPLIT_SIDEBAR_MIN_WIDTH, SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH, ), shellPadding: 0, };} export function deriveWorkspacePaneLayout(input: { readonly layout: Layout; readonly viewportWidth: number; readonly primarySidebarPreferredVisible: boolean; readonly auxiliaryPanePreferredVisible: boolean; readonly auxiliaryPaneRole?: WorkspaceAuxiliaryPaneRole; readonly auxiliaryPanePreferredWidth?: number;}): WorkspacePaneLayout { const viewportWidth = Math.max(0, input.viewportWidth); const auxiliaryPaneRole = input.auxiliaryPaneRole ?? "supplementary"; const preferredPrimarySidebarVisible = input.layout.usesSplitView && input.primarySidebarPreferredVisible; const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0; if (auxiliaryPaneRole === "inspector") { const fileInspector = deriveFileInspectorPaneLayout({ layout: input.layout, viewportWidth, preferredWidth: input.auxiliaryPanePreferredWidth, reservedLeadingWidth: preferredPrimarySidebarWidth, }); const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible; const primarySidebarSuppressedByAuxiliary = preferredPrimarySidebarVisible && auxiliaryPaneVisible && fileInspector.width !== null && input.layout.listPaneWidth !== null && viewportWidth - input.layout.listPaneWidth - fileInspector.width < FILE_INSPECTOR_MIN_MAIN_WIDTH; const primarySidebarVisible = preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0; return { primarySidebarVisible, primarySidebarSuppressedByAuxiliary, contentPaneWidth: Math.max(0, viewportWidth - primarySidebarWidth), supportsAuxiliaryPane: fileInspector.supported, auxiliaryPaneVisible, auxiliaryPaneWidth: fileInspector.width, }; } const contentPaneWidth = Math.max(0, viewportWidth - preferredPrimarySidebarWidth); const supportsAuxiliaryPane = input.layout.usesSplitView && contentPaneWidth >= AUXILIARY_PANE_MIN_CONTENT_WIDTH; const auxiliaryPaneVisible = supportsAuxiliaryPane && input.auxiliaryPanePreferredVisible; const defaultAuxiliaryPaneWidth = clamp( Math.round(contentPaneWidth * 0.28), AUXILIARY_PANE_MIN_WIDTH, AUXILIARY_PANE_DEFAULT_MAX_WIDTH, ); return { primarySidebarVisible: preferredPrimarySidebarVisible, primarySidebarSuppressedByAuxiliary: false, contentPaneWidth, supportsAuxiliaryPane, auxiliaryPaneVisible, auxiliaryPaneWidth: supportsAuxiliaryPane ? constrainAuxiliaryPaneWidth({ preferredWidth: input.auxiliaryPanePreferredWidth ?? defaultAuxiliaryPaneWidth, availableWidth: contentPaneWidth, }) : null, };} export function deriveFileInspectorPaneLayout(input: { readonly layout: Layout; readonly viewportWidth: number; readonly preferredWidth?: number; readonly reservedLeadingWidth?: number;}): FileInspectorPaneLayout { const viewportWidth = Math.max(0, input.viewportWidth); const reservedLeadingWidth = Number.isFinite(input.reservedLeadingWidth) ? Math.max(0, input.reservedLeadingWidth ?? 0) : 0; const availableContentWidth = Math.max(0, viewportWidth - reservedLeadingWidth); const supported = input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH; return { supported, width: supported ? constrainAuxiliaryPaneWidth({ preferredWidth: input.preferredWidth ?? clamp( Math.round(availableContentWidth * 0.28), AUXILIARY_PANE_MIN_WIDTH, AUXILIARY_PANE_DEFAULT_MAX_WIDTH, ), availableWidth: availableContentWidth, }) : null, };} /** * Keep an auxiliary pane within native-feeling bounds without squeezing its * neighboring content below a usable reading/editor width. */export function constrainAuxiliaryPaneWidth(input: { readonly preferredWidth: number; readonly availableWidth: number;}): number { const safePreferredWidth = Number.isFinite(input.preferredWidth) ? input.preferredWidth : AUXILIARY_PANE_MIN_WIDTH; const availableWidth = Number.isFinite(input.availableWidth) ? Math.max(0, input.availableWidth) : 0; const maxWidth = Math.max( AUXILIARY_PANE_MIN_WIDTH, Math.min(AUXILIARY_PANE_MAX_WIDTH, availableWidth - FILE_INSPECTOR_MIN_MAIN_WIDTH), ); return clamp(Math.round(safePreferredWidth), AUXILIARY_PANE_MIN_WIDTH, maxWidth);} export function deriveCenteredContentHorizontalPadding(input: { readonly viewportWidth: number; readonly maxContentWidth: number | null; readonly minimumPadding: number;}): number { const viewportWidth = Number.isFinite(input.viewportWidth) ? Math.max(0, input.viewportWidth) : 0; const minimumPadding = Number.isFinite(input.minimumPadding) ? Math.max(0, input.minimumPadding) : 0; if ( input.maxContentWidth === null || !Number.isFinite(input.maxContentWidth) || input.maxContentWidth <= 0 ) { return minimumPadding; } return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2);}
import { scaledTypographyLineHeight } from "./appearancePreferences";
import { MOBILE_TYPOGRAPHY } from "./typography";
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* Use available space, not device or orientation labels, to choose the shell.
*
* The height floor deliberately keeps every current iPhone in the compact shell
* when it rotates to landscape, while still allowing iPad and foldable-sized
* windows to adopt the persistent sidebar as they resize.
*/
export const SPLIT_LAYOUT_MIN_WIDTH = 720;
export const SPLIT_LAYOUT_MIN_HEIGHT = 600;
export const SPLIT_SIDEBAR_MIN_WIDTH = 280;
const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380;
export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960;
export const CHAT_CONTENT_MAX_WIDTH = 960;
// min-h-8 uses the 14px rem configured in metro.config.js.
export const THREAD_WORK_ROW_MIN_HEIGHT = 28;
export function deriveThreadWorkLogSizing(input: {
readonly baseFontSize: number;
readonly fontScale: number;
}) {
const lineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.footnote, input.baseFontSize);
return {
// Different text metrics can share the same minimum row height.
textSizeKey: `${input.baseFontSize}:${input.fontScale}`,
estimatedRowHeight: Math.max(
THREAD_WORK_ROW_MIN_HEIGHT,
Math.ceil(lineHeight * input.fontScale),
),
// Native text can exceed its authored line height with accessibility scaling.
// Leave those rows measured instead of promising LegendList an exact size.
fixedRowHeight:
input.fontScale <= 1 && lineHeight <= THREAD_WORK_ROW_MIN_HEIGHT
? THREAD_WORK_ROW_MIN_HEIGHT
: undefined,
};
}
export const AUXILIARY_PANE_MIN_WIDTH = 260;
export const AUXILIARY_PANE_MAX_WIDTH = 480;
const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320;
const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820;
const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560;
export type LayoutVariant = "compact" | "split";
export interface Layout {
readonly variant: LayoutVariant;
readonly usesSplitView: boolean;
readonly listPaneWidth: number | null;
readonly shellPadding: number;
}
export interface WorkspacePaneLayout {
readonly primarySidebarVisible: boolean;
readonly primarySidebarSuppressedByAuxiliary: boolean;
readonly contentPaneWidth: number;
readonly supportsAuxiliaryPane: boolean;
readonly auxiliaryPaneVisible: boolean;
readonly auxiliaryPaneWidth: number | null;
}
export interface FileInspectorPaneLayout {
readonly supported: boolean;
readonly width: number | null;
}
export function deriveThreadFeedInitialContentInset(input: {
readonly platform: string;
readonly usesNativeAutomaticInsets: boolean;
readonly bottomContentInset: number;
}): { readonly bottom: number } | undefined {
if (input.platform !== "android" || input.usesNativeAutomaticInsets) {
return undefined;
}
return { bottom: Math.max(0, input.bottomContentInset) };
}
export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector";
export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout {
const { width, height } = input;
const wideEnoughForSplit = width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT;
if (!wideEnoughForSplit) {
return {
variant: "compact",
usesSplitView: false,
listPaneWidth: null,
shellPadding: 0,
};
}
return {
variant: "split",
usesSplitView: true,
listPaneWidth: clamp(
Math.round(width * 0.32),
SPLIT_SIDEBAR_MIN_WIDTH,
SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH,
),
shellPadding: 0,
};
}
export function deriveWorkspacePaneLayout(input: {
readonly layout: Layout;
readonly viewportWidth: number;
readonly primarySidebarPreferredVisible: boolean;
readonly auxiliaryPanePreferredVisible: boolean;
readonly auxiliaryPaneRole?: WorkspaceAuxiliaryPaneRole;
readonly auxiliaryPanePreferredWidth?: number;
}): WorkspacePaneLayout {
const viewportWidth = Math.max(0, input.viewportWidth);
const auxiliaryPaneRole = input.auxiliaryPaneRole ?? "supplementary";
const preferredPrimarySidebarVisible =
input.layout.usesSplitView && input.primarySidebarPreferredVisible;
const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible
? (input.layout.listPaneWidth ?? 0)
: 0;
if (auxiliaryPaneRole === "inspector") {
const fileInspector = deriveFileInspectorPaneLayout({
layout: input.layout,
viewportWidth,
preferredWidth: input.auxiliaryPanePreferredWidth,
reservedLeadingWidth: preferredPrimarySidebarWidth,
});
const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible;
const primarySidebarSuppressedByAuxiliary =
preferredPrimarySidebarVisible &&
auxiliaryPaneVisible &&
fileInspector.width !== null &&
input.layout.listPaneWidth !== null &&
viewportWidth - input.layout.listPaneWidth - fileInspector.width <
FILE_INSPECTOR_MIN_MAIN_WIDTH;
const primarySidebarVisible =
preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary;
const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0;
return {
primarySidebarVisible,
primarySidebarSuppressedByAuxiliary,
contentPaneWidth: Math.max(0, viewportWidth - primarySidebarWidth),
supportsAuxiliaryPane: fileInspector.supported,
auxiliaryPaneVisible,
auxiliaryPaneWidth: fileInspector.width,
};
}
const contentPaneWidth = Math.max(0, viewportWidth - preferredPrimarySidebarWidth);
const supportsAuxiliaryPane =
input.layout.usesSplitView && contentPaneWidth >= AUXILIARY_PANE_MIN_CONTENT_WIDTH;
const auxiliaryPaneVisible = supportsAuxiliaryPane && input.auxiliaryPanePreferredVisible;
const defaultAuxiliaryPaneWidth = clamp(
Math.round(contentPaneWidth * 0.28),
AUXILIARY_PANE_MIN_WIDTH,
AUXILIARY_PANE_DEFAULT_MAX_WIDTH,
);
return {
primarySidebarVisible: preferredPrimarySidebarVisible,
primarySidebarSuppressedByAuxiliary: false,
contentPaneWidth,
supportsAuxiliaryPane,
auxiliaryPaneVisible,
auxiliaryPaneWidth: supportsAuxiliaryPane
? constrainAuxiliaryPaneWidth({
preferredWidth: input.auxiliaryPanePreferredWidth ?? defaultAuxiliaryPaneWidth,
availableWidth: contentPaneWidth,
})
: null,
};
}
export function deriveFileInspectorPaneLayout(input: {
readonly layout: Layout;
readonly viewportWidth: number;
readonly preferredWidth?: number;
readonly reservedLeadingWidth?: number;
}): FileInspectorPaneLayout {
const viewportWidth = Math.max(0, input.viewportWidth);
const reservedLeadingWidth = Number.isFinite(input.reservedLeadingWidth)
? Math.max(0, input.reservedLeadingWidth ?? 0)
: 0;
const availableContentWidth = Math.max(0, viewportWidth - reservedLeadingWidth);
const supported =
input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH;
return {
supported,
width: supported
? constrainAuxiliaryPaneWidth({
preferredWidth:
input.preferredWidth ??
clamp(
Math.round(availableContentWidth * 0.28),
AUXILIARY_PANE_MIN_WIDTH,
AUXILIARY_PANE_DEFAULT_MAX_WIDTH,
),
availableWidth: availableContentWidth,
})
: null,
};
}
/**
* Keep an auxiliary pane within native-feeling bounds without squeezing its
* neighboring content below a usable reading/editor width.
*/
export function constrainAuxiliaryPaneWidth(input: {
readonly preferredWidth: number;
readonly availableWidth: number;
}): number {
const safePreferredWidth = Number.isFinite(input.preferredWidth)
? input.preferredWidth
: AUXILIARY_PANE_MIN_WIDTH;
const availableWidth = Number.isFinite(input.availableWidth)
? Math.max(0, input.availableWidth)
: 0;
const maxWidth = Math.max(
AUXILIARY_PANE_MIN_WIDTH,
Math.min(AUXILIARY_PANE_MAX_WIDTH, availableWidth - FILE_INSPECTOR_MIN_MAIN_WIDTH),
);
return clamp(Math.round(safePreferredWidth), AUXILIARY_PANE_MIN_WIDTH, maxWidth);
}
export function deriveCenteredContentHorizontalPadding(input: {
readonly viewportWidth: number;
readonly maxContentWidth: number | null;
readonly minimumPadding: number;
}): number {
const viewportWidth = Number.isFinite(input.viewportWidth) ? Math.max(0, input.viewportWidth) : 0;
const minimumPadding = Number.isFinite(input.minimumPadding)
? Math.max(0, input.minimumPadding)
: 0;
if (
input.maxContentWidth === null ||
!Number.isFinite(input.maxContentWidth) ||
input.maxContentWidth <= 0
) {
return minimumPadding;
}
return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2);
}