mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
pre-release commit
This commit is contained in:
342
packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
Normal file
342
packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { MaxSizedBox, setMaxSizedBoxDebugging } from './MaxSizedBox.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('<MaxSizedBox />', () => {
|
||||
// Make sure MaxSizedBox logs errors on invalid configurations.
|
||||
// This is useful for debugging issues with the component.
|
||||
// It should be set to false in production for performance and to avoid
|
||||
// cluttering the console if there are ignorable issues.
|
||||
setMaxSizedBoxDebugging(true);
|
||||
|
||||
it('renders children without truncation when they fit', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||
<Box>
|
||||
<Text>Hello, World!</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals('Hello, World!');
|
||||
});
|
||||
|
||||
it('hides lines when content exceeds maxHeight', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 3</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`... first 2 lines hidden ...
|
||||
Line 3`);
|
||||
});
|
||||
|
||||
it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 3</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`Line 1
|
||||
... last 2 lines hidden ...`);
|
||||
});
|
||||
|
||||
it('wraps text that exceeds maxWidth', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={10} maxHeight={5}>
|
||||
<Box>
|
||||
<Text wrap="wrap">This is a long line of text</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`This is a
|
||||
long line
|
||||
of text`);
|
||||
});
|
||||
|
||||
it('handles mixed wrapping and non-wrapping segments', () => {
|
||||
const multilineText = `This part will wrap around.
|
||||
And has a line break.
|
||||
Leading spaces preserved.`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={20} maxHeight={20}>
|
||||
<Box>
|
||||
<Text>Example</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>No Wrap: </Text>
|
||||
<Text wrap="wrap">{multilineText}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Longer No Wrap: </Text>
|
||||
<Text wrap="wrap">This part will wrap around.</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(
|
||||
`Example
|
||||
No Wrap: This part
|
||||
will wrap
|
||||
around.
|
||||
And has a
|
||||
line break.
|
||||
Leading
|
||||
spaces
|
||||
preserved.
|
||||
Longer No Wrap: This
|
||||
part
|
||||
will
|
||||
wrap
|
||||
arou
|
||||
nd.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles words longer than maxWidth by splitting them', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||
<Box>
|
||||
<Text wrap="wrap">Supercalifragilisticexpialidocious</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`... …
|
||||
istic
|
||||
expia
|
||||
lidoc
|
||||
ious`);
|
||||
});
|
||||
|
||||
it('does not truncate when maxHeight is undefined', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`Line 1
|
||||
Line 2`);
|
||||
});
|
||||
|
||||
it('shows plural "lines" when more than one line is hidden', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 3</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`... first 2 lines hidden ...
|
||||
Line 3`);
|
||||
});
|
||||
|
||||
it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 3</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`Line 1
|
||||
... last 2 lines hidden ...`);
|
||||
});
|
||||
|
||||
it('renders an empty box for empty children', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
// Expect an empty string or a box with nothing in it.
|
||||
// Ink renders an empty box as an empty string.
|
||||
expect(lastFrame()).equals('');
|
||||
});
|
||||
|
||||
it('wraps text with multi-byte unicode characters correctly', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||
<Box>
|
||||
<Text wrap="wrap">你好世界</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
// "你好" has a visual width of 4. "世界" has a visual width of 4.
|
||||
// With maxWidth=5, it should wrap after the second character.
|
||||
expect(lastFrame()).equals(`你好
|
||||
世界`);
|
||||
});
|
||||
|
||||
it('wraps text with multi-byte emoji characters correctly', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||
<Box>
|
||||
<Text wrap="wrap">🐶🐶🐶🐶🐶</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
// Each "🐶" has a visual width of 2.
|
||||
// With maxWidth=5, it should wrap every 2 emojis.
|
||||
expect(lastFrame()).equals(`🐶🐶
|
||||
🐶🐶
|
||||
🐶`);
|
||||
});
|
||||
|
||||
it('accounts for additionalHiddenLinesCount', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 3</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
// 1 line is hidden by overflow, 5 are additionally hidden.
|
||||
expect(lastFrame()).equals(`... first 7 lines hidden ...
|
||||
Line 3`);
|
||||
});
|
||||
|
||||
it('handles React.Fragment as a child', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||
<>
|
||||
<Box>
|
||||
<Text>Line 1 from Fragment</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2 from Fragment</Text>
|
||||
</Box>
|
||||
</>
|
||||
<Box>
|
||||
<Text>Line 3 direct child</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`Line 1 from Fragment
|
||||
Line 2 from Fragment
|
||||
Line 3 direct child`);
|
||||
});
|
||||
|
||||
it('clips a long single text child from the top', () => {
|
||||
const THIRTY_LINES = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||
<Box>
|
||||
<Text>{THIRTY_LINES}</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
const expected = [
|
||||
'... first 21 lines hidden ...',
|
||||
...Array.from({ length: 9 }, (_, i) => `Line ${22 + i}`),
|
||||
].join('\n');
|
||||
|
||||
expect(lastFrame()).equals(expected);
|
||||
});
|
||||
|
||||
it('clips a long single text child from the bottom', () => {
|
||||
const THIRTY_LINES = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
|
||||
<Box>
|
||||
<Text>{THIRTY_LINES}</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
const expected = [
|
||||
...Array.from({ length: 9 }, (_, i) => `Line ${i + 1}`),
|
||||
'... last 21 lines hidden ...',
|
||||
].join('\n');
|
||||
|
||||
expect(lastFrame()).equals(expected);
|
||||
});
|
||||
});
|
||||
547
packages/cli/src/ui/components/shared/MaxSizedBox.tsx
Normal file
547
packages/cli/src/ui/components/shared/MaxSizedBox.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { Fragment, useEffect, useId } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import stringWidth from 'string-width';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { toCodePoints } from '../../utils/textUtils.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
|
||||
let enableDebugLog = false;
|
||||
|
||||
/**
|
||||
* Minimum height for the MaxSizedBox component.
|
||||
* This ensures there is room for at least one line of content as well as the
|
||||
* message that content was truncated.
|
||||
*/
|
||||
export const MINIMUM_MAX_HEIGHT = 2;
|
||||
|
||||
export function setMaxSizedBoxDebugging(value: boolean) {
|
||||
enableDebugLog = value;
|
||||
}
|
||||
|
||||
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,
|
||||
}) => {
|
||||
const id = useId();
|
||||
const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {};
|
||||
|
||||
const laidOutStyledText: StyledText[][] = [];
|
||||
const targetMaxHeight = Math.max(
|
||||
Math.round(maxHeight ?? Number.MAX_SAFE_INTEGER),
|
||||
MINIMUM_MAX_HEIGHT,
|
||||
);
|
||||
|
||||
if (maxWidth === undefined) {
|
||||
throw new Error('maxWidth must be defined when maxHeight is set.');
|
||||
}
|
||||
function visitRows(element: React.ReactNode) {
|
||||
if (!React.isValidElement<{ children?: React.ReactNode }>(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 =
|
||||
(targetMaxHeight !== undefined &&
|
||||
laidOutStyledText.length > targetMaxHeight) ||
|
||||
additionalHiddenLinesCount > 0;
|
||||
const visibleContentHeight =
|
||||
contentWillOverflow && targetMaxHeight !== undefined
|
||||
? targetMaxHeight - 1
|
||||
: targetMaxHeight;
|
||||
|
||||
const hiddenLinesCount =
|
||||
visibleContentHeight !== undefined
|
||||
? Math.max(0, laidOutStyledText.length - visibleContentHeight)
|
||||
: 0;
|
||||
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
|
||||
|
||||
useEffect(() => {
|
||||
if (totalHiddenLines > 0) {
|
||||
addOverflowingId?.(id);
|
||||
} else {
|
||||
removeOverflowingId?.(id);
|
||||
}
|
||||
|
||||
return () => {
|
||||
removeOverflowingId?.(id);
|
||||
};
|
||||
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
|
||||
|
||||
const visibleStyledText =
|
||||
hiddenLinesCount > 0
|
||||
? overflowDirection === 'top'
|
||||
? laidOutStyledText.slice(hiddenLinesCount, laidOutStyledText.length)
|
||||
: 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<{ children?: React.ReactNode }>(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 as {
|
||||
children?: React.ReactNode | undefined;
|
||||
readonly flexDirection?:
|
||||
| 'row'
|
||||
| 'column'
|
||||
| 'row-reverse'
|
||||
| 'column-reverse'
|
||||
| undefined;
|
||||
};
|
||||
// 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 !== undefined &&
|
||||
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 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<{ children?: React.ReactNode }>(element)) {
|
||||
debugReportError('Invalid element.', element);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.type === Fragment) {
|
||||
React.Children.forEach(element.props.children, (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);
|
||||
}
|
||||
for (const line of lines) {
|
||||
output.push(line);
|
||||
}
|
||||
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();
|
||||
}
|
||||
for (const line of lines) {
|
||||
output.push(line);
|
||||
}
|
||||
}
|
||||
157
packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
Normal file
157
packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Text, Box, useInput } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
/**
|
||||
* Represents a single option for the RadioButtonSelect.
|
||||
* Requires a label for display and a value to be returned on selection.
|
||||
*/
|
||||
export interface RadioSelectItem<T> {
|
||||
label: string;
|
||||
value: T;
|
||||
disabled?: boolean;
|
||||
themeNameDisplay?: string;
|
||||
themeTypeDisplay?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the RadioButtonSelect component.
|
||||
* @template T The type of the value associated with each radio item.
|
||||
*/
|
||||
export interface RadioButtonSelectProps<T> {
|
||||
/** An array of items to display as radio options. */
|
||||
items: Array<RadioSelectItem<T>>;
|
||||
/** The initial index selected */
|
||||
initialIndex?: number;
|
||||
/** Function called when an item is selected. Receives the `value` of the selected item. */
|
||||
onSelect: (value: T) => void;
|
||||
/** Function called when an item is highlighted. Receives the `value` of the selected item. */
|
||||
onHighlight?: (value: T) => void;
|
||||
/** Whether this select input is currently focused and should respond to input. */
|
||||
isFocused?: boolean;
|
||||
/** Whether to show the scroll arrows. */
|
||||
showScrollArrows?: boolean;
|
||||
/** The maximum number of items to show at once. */
|
||||
maxItemsToShow?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom component that displays a list of items with radio buttons,
|
||||
* supporting scrolling and keyboard navigation.
|
||||
*
|
||||
* @template T The type of the value associated with each radio item.
|
||||
*/
|
||||
export function RadioButtonSelect<T>({
|
||||
items,
|
||||
initialIndex = 0,
|
||||
onSelect,
|
||||
onHighlight,
|
||||
isFocused,
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
}: RadioButtonSelectProps<T>): React.JSX.Element {
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const newScrollOffset = Math.max(
|
||||
0,
|
||||
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
|
||||
);
|
||||
if (activeIndex < scrollOffset) {
|
||||
setScrollOffset(activeIndex);
|
||||
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newScrollOffset);
|
||||
}
|
||||
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (input === 'k' || key.upArrow) {
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
}
|
||||
if (input === 'j' || key.downArrow) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
}
|
||||
if (key.return) {
|
||||
onSelect(items[activeIndex]!.value);
|
||||
}
|
||||
|
||||
// Enable selection directly from number keys.
|
||||
if (/^[1-9]$/.test(input)) {
|
||||
const targetIndex = Number.parseInt(input, 10) - 1;
|
||||
if (targetIndex >= 0 && targetIndex < visibleItems.length) {
|
||||
const selectedItem = visibleItems[targetIndex];
|
||||
if (selectedItem) {
|
||||
onSelect?.(selectedItem.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused && items.length > 0 },
|
||||
);
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showScrollArrows && (
|
||||
<Text color={scrollOffset > 0 ? Colors.Foreground : Colors.Gray}>
|
||||
▲
|
||||
</Text>
|
||||
)}
|
||||
{visibleItems.map((item, index) => {
|
||||
const itemIndex = scrollOffset + index;
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
|
||||
let textColor = Colors.Foreground;
|
||||
if (isSelected) {
|
||||
textColor = Colors.AccentGreen;
|
||||
} else if (item.disabled) {
|
||||
textColor = Colors.Gray;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
|
||||
{isSelected ? '●' : '○'}
|
||||
</Text>
|
||||
</Box>
|
||||
{item.themeNameDisplay && item.themeTypeDisplay ? (
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{item.themeNameDisplay}{' '}
|
||||
<Text color={Colors.Gray}>{item.themeTypeDisplay}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{showScrollArrows && (
|
||||
<Text
|
||||
color={
|
||||
scrollOffset + maxItemsToShow < items.length
|
||||
? Colors.Foreground
|
||||
: Colors.Gray
|
||||
}
|
||||
>
|
||||
▼
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1340
packages/cli/src/ui/components/shared/text-buffer.test.ts
Normal file
1340
packages/cli/src/ui/components/shared/text-buffer.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1389
packages/cli/src/ui/components/shared/text-buffer.ts
Normal file
1389
packages/cli/src/ui/components/shared/text-buffer.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user