summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/components/shared/MaxSizedBox.tsx')
-rw-r--r--packages/cli/src/ui/components/shared/MaxSizedBox.tsx511
1 files changed, 511 insertions, 0 deletions
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
new file mode 100644
index 00000000..fe73c250
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
@@ -0,0 +1,511 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { Fragment } from 'react';
+import { Box, Text } from 'ink';
+import stringWidth from 'string-width';
+import { Colors } from '../../colors.js';
+import { toCodePoints } from '../../utils/textUtils.js';
+
+const enableDebugLog = true;
+
+function debugReportError(message: string, element: React.ReactNode) {
+ if (!enableDebugLog) return;
+
+ if (!React.isValidElement(element)) {
+ console.error(
+ message,
+ `Invalid element: '${String(element)}' typeof=${typeof element}`,
+ );
+ return;
+ }
+
+ let sourceMessage = '<Unknown file>';
+ try {
+ const elementWithSource = element as {
+ _source?: { fileName?: string; lineNumber?: number };
+ };
+ const fileName = elementWithSource._source?.fileName;
+ const lineNumber = elementWithSource._source?.lineNumber;
+ sourceMessage = fileName ? `${fileName}:${lineNumber}` : '<Unknown file>';
+ } catch (error) {
+ console.error('Error while trying to get file name:', error);
+ }
+
+ console.error(message, `${String(element.type)}. Source: ${sourceMessage}`);
+}
+interface MaxSizedBoxProps {
+ children?: React.ReactNode;
+ maxWidth?: number;
+ maxHeight: number | undefined;
+ overflowDirection?: 'top' | 'bottom';
+ additionalHiddenLinesCount?: number;
+}
+
+/**
+ * A React component that constrains the size of its children and provides
+ * content-aware truncation when the content exceeds the specified `maxHeight`.
+ *
+ * `MaxSizedBox` requires a specific structure for its children to correctly
+ * measure and render the content:
+ *
+ * 1. **Direct children must be `<Box>` elements.** Each `<Box>` represents a
+ * single row of content.
+ * 2. **Row `<Box>` elements must contain only `<Text>` elements.** These
+ * `<Text>` elements can be nested and there are no restrictions to Text
+ * element styling other than that non-wrapping text elements must be
+ * before wrapping text elements.
+ *
+ * **Constraints:**
+ * - **Box Properties:** Custom properties on the child `<Box>` elements are
+ * ignored. In debug mode, runtime checks will report errors for any
+ * unsupported properties.
+ * - **Text Wrapping:** Within a single row, `<Text>` elements with no wrapping
+ * (e.g., headers, labels) must appear before any `<Text>` elements that wrap.
+ * - **Element Types:** Runtime checks will warn if unsupported element types
+ * are used as children.
+ *
+ * @example
+ * <MaxSizedBox maxWidth={80} maxHeight={10}>
+ * <Box>
+ * <Text>This is the first line.</Text>
+ * </Box>
+ * <Box>
+ * <Text color="cyan" wrap="truncate">Non-wrapping Header: </Text>
+ * <Text>This is the rest of the line which will wrap if it's too long.</Text>
+ * </Box>
+ * <Box>
+ * <Text>
+ * Line 3 with <Text color="yellow">nested styled text</Text> inside of it.
+ * </Text>
+ * </Box>
+ * </MaxSizedBox>
+ */
+export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
+ children,
+ maxWidth,
+ maxHeight,
+ overflowDirection = 'top',
+ additionalHiddenLinesCount = 0,
+}) => {
+ // When maxHeight is not set, we render the content normally rather
+ // than using our custom layout logic. This should slightly improve
+ // performance for the case where there is no height limit and is
+ // a useful debugging tool to ensure that our layouts are consist
+ // with the expected layout when there is no height limit.
+ // In the future we might choose to still apply our layout logic
+ // even in this case particularlly if there are cases where we
+ // intentionally diverse how certain layouts are rendered.
+ if (maxHeight === undefined) {
+ return (
+ <Box width={maxWidth} height={maxHeight} flexDirection="column">
+ {children}
+ </Box>
+ );
+ }
+
+ if (maxWidth === undefined) {
+ throw new Error('maxWidth must be defined when maxHeight is set.');
+ }
+
+ const laidOutStyledText: StyledText[][] = [];
+ function visitRows(element: React.ReactNode) {
+ if (!React.isValidElement(element)) {
+ return;
+ }
+ if (element.type === Fragment) {
+ React.Children.forEach(element.props.children, visitRows);
+ return;
+ }
+ if (element.type === Box) {
+ layoutInkElementAsStyledText(element, maxWidth!, laidOutStyledText);
+ return;
+ }
+
+ debugReportError('MaxSizedBox children must be <Box> elements', element);
+ }
+
+ React.Children.forEach(children, visitRows);
+
+ const contentWillOverflow =
+ (laidOutStyledText.length > maxHeight && maxHeight > 0) ||
+ additionalHiddenLinesCount > 0;
+ const visibleContentHeight = contentWillOverflow ? maxHeight - 1 : maxHeight;
+
+ const hiddenLinesCount = Math.max(
+ 0,
+ laidOutStyledText.length - visibleContentHeight,
+ );
+ const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
+
+ const visibleStyledText =
+ hiddenLinesCount > 0
+ ? overflowDirection === 'top'
+ ? laidOutStyledText.slice(
+ laidOutStyledText.length - visibleContentHeight,
+ )
+ : laidOutStyledText.slice(0, visibleContentHeight)
+ : laidOutStyledText;
+
+ const visibleLines = visibleStyledText.map((line, index) => (
+ <Box key={index}>
+ {line.length > 0 ? (
+ line.map((segment, segIndex) => (
+ <Text key={segIndex} {...segment.props}>
+ {segment.text}
+ </Text>
+ ))
+ ) : (
+ <Text> </Text>
+ )}
+ </Box>
+ ));
+
+ return (
+ <Box flexDirection="column" width={maxWidth} flexShrink={0}>
+ {totalHiddenLines > 0 && overflowDirection === 'top' && (
+ <Text color={Colors.Gray} wrap="truncate">
+ ... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
+ hidden ...
+ </Text>
+ )}
+ {visibleLines}
+ {totalHiddenLines > 0 && overflowDirection === 'bottom' && (
+ <Text color={Colors.Gray} wrap="truncate">
+ ... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
+ hidden ...
+ </Text>
+ )}
+ </Box>
+ );
+};
+
+// Define a type for styled text segments
+interface StyledText {
+ text: string;
+ props: Record<string, unknown>;
+}
+
+/**
+ * Single row of content within the MaxSizedBox.
+ *
+ * A row can contain segments that are not wrapped, followed by segments that
+ * are. This is a minimal implementation that only supports the functionality
+ * needed today.
+ */
+interface Row {
+ noWrapSegments: StyledText[];
+ segments: StyledText[];
+}
+
+/**
+ * Flattens the child elements of MaxSizedBox into an array of `Row` objects.
+ *
+ * This function expects a specific child structure to function correctly:
+ * 1. The top-level child of `MaxSizedBox` should be a single `<Box>`. This
+ * outer box is primarily for structure and is not directly rendered.
+ * 2. Inside the outer `<Box>`, there should be one or more children. Each of
+ * these children must be a `<Box>` that represents a row.
+ * 3. Inside each "row" `<Box>`, the children must be `<Text>` components.
+ *
+ * The structure should look like this:
+ * <MaxSizedBox>
+ * <Box> // Row 1
+ * <Text>...</Text>
+ * <Text>...</Text>
+ * </Box>
+ * <Box> // Row 2
+ * <Text>...</Text>
+ * </Box>
+ * </MaxSizedBox>
+ *
+ * It is an error for a <Text> child without wrapping to appear after a
+ * <Text> child with wrapping within the same row Box.
+ *
+ * @param element The React node to flatten.
+ * @returns An array of `Row` objects.
+ */
+function visitBoxRow(element: React.ReactNode): Row {
+ if (!React.isValidElement(element) || element.type !== Box) {
+ debugReportError(
+ `All children of MaxSizedBox must be <Box> elements`,
+ element,
+ );
+ return {
+ noWrapSegments: [{ text: '<ERROR>', props: {} }],
+ segments: [],
+ };
+ }
+
+ if (enableDebugLog) {
+ const boxProps = element.props;
+ // Ensure the Box has no props other than the default ones and key.
+ let maxExpectedProps = 4;
+ if (boxProps.children !== undefined) {
+ // Allow the key prop, which is automatically added by React.
+ maxExpectedProps += 1;
+ }
+ if (boxProps.flexDirection !== 'row') {
+ debugReportError(
+ 'MaxSizedBox children must have flexDirection="row".',
+ element,
+ );
+ }
+ if (Object.keys(boxProps).length > maxExpectedProps) {
+ debugReportError(
+ `Boxes inside MaxSizedBox must not have additional props. ${Object.keys(
+ boxProps,
+ ).join(', ')}`,
+ element,
+ );
+ }
+ }
+
+ const row: Row = {
+ noWrapSegments: [],
+ segments: [],
+ };
+
+ let hasSeenWrapped = false;
+
+ function visitRowChild(
+ element: React.ReactNode,
+ parentProps: Record<string, unknown> | undefined,
+ ) {
+ if (element === null) {
+ return;
+ }
+ if (typeof element === 'string' || typeof element === 'number') {
+ const text = String(element);
+ // Ignore empty strings as they don't need to be rendered.
+ if (!text) {
+ return;
+ }
+
+ const segment: StyledText = { text, props: parentProps ?? {} };
+
+ // Check the 'wrap' property from the merged props to decide the segment type.
+ if (parentProps === undefined || parentProps.wrap === 'wrap') {
+ hasSeenWrapped = true;
+ row.segments.push(segment);
+ } else {
+ if (!hasSeenWrapped) {
+ row.noWrapSegments.push(segment);
+ } else {
+ // put in in the wrapped segment as the row is already stuck in wrapped mode.
+ row.segments.push(segment);
+ debugReportError(
+ 'Text elements without wrapping cannot appear after elements with wrapping in the same row.',
+ element,
+ );
+ }
+ }
+ return;
+ }
+
+ if (!React.isValidElement(element)) {
+ debugReportError('Invalid element.', element);
+ return;
+ }
+
+ if (element.type === Fragment) {
+ const fragmentChildren = element.props.children;
+ React.Children.forEach(fragmentChildren, (child) =>
+ visitRowChild(child, parentProps),
+ );
+ return;
+ }
+
+ if (element.type !== Text) {
+ debugReportError(
+ 'Children of a row Box must be <Text> elements.',
+ element,
+ );
+ return;
+ }
+
+ // Merge props from parent <Text> elements. Child props take precedence.
+ const { children, ...currentProps } = element.props;
+ const mergedProps =
+ parentProps === undefined
+ ? currentProps
+ : { ...parentProps, ...currentProps };
+ React.Children.forEach(children, (child) =>
+ visitRowChild(child, mergedProps),
+ );
+ }
+
+ React.Children.forEach(element.props.children, (child) =>
+ visitRowChild(child, undefined),
+ );
+
+ return row;
+}
+
+function layoutInkElementAsStyledText(
+ element: React.ReactElement,
+ maxWidth: number,
+ output: StyledText[][],
+) {
+ const row = visitBoxRow(element);
+ if (row.segments.length === 0 && row.noWrapSegments.length === 0) {
+ // Return a single empty line if there are no segments to display
+ output.push([]);
+ return;
+ }
+
+ const lines: StyledText[][] = [];
+ const nonWrappingContent: StyledText[] = [];
+ let noWrappingWidth = 0;
+
+ // First, lay out the non-wrapping segments
+ row.noWrapSegments.forEach((segment) => {
+ nonWrappingContent.push(segment);
+ noWrappingWidth += stringWidth(segment.text);
+ });
+
+ if (row.segments.length === 0) {
+ // This is a bit of a special case when there are no segments that allow
+ // wrapping. It would be ideal to unify.
+ const lines: StyledText[][] = [];
+ let currentLine: StyledText[] = [];
+ nonWrappingContent.forEach((segment) => {
+ const textLines = segment.text.split('\n');
+ textLines.forEach((text, index) => {
+ if (index > 0) {
+ lines.push(currentLine);
+ currentLine = [];
+ }
+ if (text) {
+ currentLine.push({ text, props: segment.props });
+ }
+ });
+ });
+ if (
+ currentLine.length > 0 ||
+ (nonWrappingContent.length > 0 &&
+ nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n'))
+ ) {
+ lines.push(currentLine);
+ }
+ output.push(...lines);
+ return;
+ }
+
+ const availableWidth = maxWidth - noWrappingWidth;
+
+ if (availableWidth < 1) {
+ // No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy.
+ output.push(nonWrappingContent);
+ return;
+ }
+
+ // Now, lay out the wrapping segments
+ let wrappingPart: StyledText[] = [];
+ let wrappingPartWidth = 0;
+
+ function addWrappingPartToLines() {
+ if (lines.length === 0) {
+ lines.push([...nonWrappingContent, ...wrappingPart]);
+ } else {
+ if (noWrappingWidth > 0) {
+ lines.push([
+ ...[{ text: ' '.repeat(noWrappingWidth), props: {} }],
+ ...wrappingPart,
+ ]);
+ } else {
+ lines.push(wrappingPart);
+ }
+ }
+ wrappingPart = [];
+ wrappingPartWidth = 0;
+ }
+
+ function addToWrappingPart(text: string, props: Record<string, unknown>) {
+ if (
+ wrappingPart.length > 0 &&
+ wrappingPart[wrappingPart.length - 1].props === props
+ ) {
+ wrappingPart[wrappingPart.length - 1].text += text;
+ } else {
+ wrappingPart.push({ text, props });
+ }
+ }
+
+ row.segments.forEach((segment) => {
+ const linesFromSegment = segment.text.split('\n');
+
+ linesFromSegment.forEach((lineText, lineIndex) => {
+ if (lineIndex > 0) {
+ addWrappingPartToLines();
+ }
+
+ const words = lineText.split(/(\s+)/); // Split by whitespace
+
+ words.forEach((word) => {
+ if (!word) return;
+ const wordWidth = stringWidth(word);
+
+ if (
+ wrappingPartWidth + wordWidth > availableWidth &&
+ wrappingPartWidth > 0
+ ) {
+ addWrappingPartToLines();
+ if (/^\s+$/.test(word)) {
+ return;
+ }
+ }
+
+ if (wordWidth > availableWidth) {
+ // Word is too long, needs to be split across lines
+ const wordAsCodePoints = toCodePoints(word);
+ let remainingWordAsCodePoints = wordAsCodePoints;
+ while (remainingWordAsCodePoints.length > 0) {
+ let splitIndex = 0;
+ let currentSplitWidth = 0;
+ for (const char of remainingWordAsCodePoints) {
+ const charWidth = stringWidth(char);
+ if (
+ wrappingPartWidth + currentSplitWidth + charWidth >
+ availableWidth
+ ) {
+ break;
+ }
+ currentSplitWidth += charWidth;
+ splitIndex++;
+ }
+
+ if (splitIndex > 0) {
+ const part = remainingWordAsCodePoints
+ .slice(0, splitIndex)
+ .join('');
+ addToWrappingPart(part, segment.props);
+ wrappingPartWidth += stringWidth(part);
+ remainingWordAsCodePoints =
+ remainingWordAsCodePoints.slice(splitIndex);
+ }
+
+ if (remainingWordAsCodePoints.length > 0) {
+ addWrappingPartToLines();
+ }
+ }
+ } else {
+ addToWrappingPart(word, segment.props);
+ wrappingPartWidth += wordWidth;
+ }
+ });
+ });
+ // Split omits a trailing newline, so we need to handle it here
+ if (segment.text.endsWith('\n')) {
+ addWrappingPartToLines();
+ }
+ });
+
+ if (wrappingPart.length > 0) {
+ addWrappingPartToLines();
+ }
+ output.push(...lines);
+}