diff options
Diffstat (limited to 'packages/cli/src/ui/components/shared')
| -rw-r--r-- | packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx | 279 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/shared/MaxSizedBox.tsx | 99 |
2 files changed, 220 insertions, 158 deletions
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx index 7abd19a2..2fa72f96 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx @@ -5,6 +5,7 @@ */ 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'; @@ -18,28 +19,32 @@ describe('<MaxSizedBox />', () => { it('renders children without truncation when they fit', () => { const { lastFrame } = render( - <MaxSizedBox maxWidth={80} maxHeight={10}> - <Box> - <Text>Hello, World!</Text> - </Box> - </MaxSizedBox>, + <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( - <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> + <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`); @@ -47,17 +52,19 @@ Line 3`); it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => { const { lastFrame } = render( - <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> + <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 ...`); @@ -65,11 +72,13 @@ Line 3`); it('wraps text that exceeds maxWidth', () => { const { lastFrame } = render( - <MaxSizedBox maxWidth={10} maxHeight={5}> - <Box> - <Text wrap="wrap">This is a long line of text</Text> - </Box> - </MaxSizedBox>, + <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 @@ -82,19 +91,21 @@ of text`); And has a line break. Leading spaces preserved.`; const { lastFrame } = render( - <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> + <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( @@ -118,11 +129,13 @@ Longer No Wrap: This it('handles words longer than maxWidth by splitting them', () => { const { lastFrame } = render( - <MaxSizedBox maxWidth={5} maxHeight={5}> - <Box> - <Text wrap="wrap">Supercalifragilisticexpialidocious</Text> - </Box> - </MaxSizedBox>, + <OverflowProvider> + <MaxSizedBox maxWidth={5} maxHeight={5}> + <Box> + <Text wrap="wrap">Supercalifragilisticexpialidocious</Text> + </Box> + </MaxSizedBox> + </OverflowProvider>, ); expect(lastFrame()).equals(`... … @@ -134,14 +147,16 @@ ious`); it('does not truncate when maxHeight is undefined', () => { const { lastFrame } = render( - <MaxSizedBox maxWidth={80} maxHeight={undefined}> - <Box> - <Text>Line 1</Text> - </Box> - <Box> - <Text>Line 2</Text> - </Box> - </MaxSizedBox>, + <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`); @@ -149,17 +164,19 @@ Line 2`); it('shows plural "lines" when more than one line is hidden', () => { const { lastFrame } = render( - <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> + <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`); @@ -167,17 +184,19 @@ Line 3`); it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => { const { lastFrame } = render( - <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> + <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 ...`); @@ -185,7 +204,9 @@ Line 3`); it('renders an empty box for empty children', () => { const { lastFrame } = render( - <MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>, + <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. @@ -194,11 +215,13 @@ Line 3`); it('wraps text with multi-byte unicode characters correctly', () => { const { lastFrame } = render( - <MaxSizedBox maxWidth={5} maxHeight={5}> - <Box> - <Text wrap="wrap">你好世界</Text> - </Box> - </MaxSizedBox>, + <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. @@ -209,11 +232,13 @@ Line 3`); it('wraps text with multi-byte emoji characters correctly', () => { const { lastFrame } = render( - <MaxSizedBox maxWidth={5} maxHeight={5}> - <Box> - <Text wrap="wrap">🐶🐶🐶🐶🐶</Text> - </Box> - </MaxSizedBox>, + <OverflowProvider> + <MaxSizedBox maxWidth={5} maxHeight={5}> + <Box> + <Text wrap="wrap">🐶🐶🐶🐶🐶</Text> + </Box> + </MaxSizedBox> + </OverflowProvider>, ); // Each "🐶" has a visual width of 2. @@ -225,17 +250,19 @@ Line 3`); it('accounts for additionalHiddenLinesCount', () => { const { lastFrame } = render( - <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> + <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 ... @@ -244,19 +271,21 @@ Line 3`); it('handles React.Fragment as a child', () => { const { lastFrame } = render( - <MaxSizedBox maxWidth={80} maxHeight={10}> - <> - <Box> - <Text>Line 1 from Fragment</Text> - </Box> + <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 2 from Fragment</Text> + <Text>Line 3 direct child</Text> </Box> - </> - <Box> - <Text>Line 3 direct child</Text> - </Box> - </MaxSizedBox>, + </MaxSizedBox> + </OverflowProvider>, ); expect(lastFrame()).equals(`Line 1 from Fragment Line 2 from Fragment @@ -270,11 +299,13 @@ Line 3 direct child`); ).join('\n'); const { lastFrame } = render( - <MaxSizedBox maxWidth={80} maxHeight={10}> - <Box> - <Text>{THIRTY_LINES}</Text> - </Box> - </MaxSizedBox>, + <OverflowProvider> + <MaxSizedBox maxWidth={80} maxHeight={10}> + <Box> + <Text>{THIRTY_LINES}</Text> + </Box> + </MaxSizedBox> + </OverflowProvider>, ); const expected = [ @@ -292,11 +323,13 @@ Line 3 direct child`); ).join('\n'); const { lastFrame } = render( - <MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom"> - <Box> - <Text>{THIRTY_LINES}</Text> - </Box> - </MaxSizedBox>, + <OverflowProvider> + <MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom"> + <Box> + <Text>{THIRTY_LINES}</Text> + </Box> + </MaxSizedBox> + </OverflowProvider>, ); const expected = [ diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx index 1b5b90aa..faa1052a 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx @@ -4,14 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { Fragment } from 'react'; +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; } @@ -95,6 +103,10 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({ overflowDirection = 'top', additionalHiddenLinesCount = 0, }) => { + const id = useId(); + const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {}; + + const laidOutStyledText: StyledText[][] = []; // 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 @@ -103,54 +115,71 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({ // 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.'); - } + let targetMaxHeight; + if (maxHeight !== undefined) { + targetMaxHeight = Math.max(Math.round(maxHeight), MINIMUM_MAX_HEIGHT); - const laidOutStyledText: StyledText[][] = []; - function visitRows(element: React.ReactNode) { - if (!React.isValidElement(element)) { - return; + if (maxWidth === undefined) { + throw new Error('maxWidth must be defined when maxHeight is set.'); } - if (element.type === Fragment) { - React.Children.forEach(element.props.children, visitRows); - return; - } - if (element.type === Box) { - layoutInkElementAsStyledText(element, maxWidth!, laidOutStyledText); - return; + 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); } - debugReportError('MaxSizedBox children must be <Box> elements', element); + React.Children.forEach(children, visitRows); } - React.Children.forEach(children, visitRows); - const contentWillOverflow = - (laidOutStyledText.length > maxHeight && maxHeight > 0) || + (targetMaxHeight !== undefined && + laidOutStyledText.length > targetMaxHeight) || additionalHiddenLinesCount > 0; - const visibleContentHeight = contentWillOverflow ? maxHeight - 1 : maxHeight; + const visibleContentHeight = + contentWillOverflow && targetMaxHeight !== undefined + ? targetMaxHeight - 1 + : targetMaxHeight; - const hiddenLinesCount = Math.max( - 0, - laidOutStyledText.length - visibleContentHeight, - ); + 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]); + + if (maxHeight === undefined) { + return ( + <Box width={maxWidth} height={maxHeight} flexDirection="column"> + {children} + </Box> + ); + } + const visibleStyledText = hiddenLinesCount > 0 ? overflowDirection === 'top' - ? laidOutStyledText.slice( - laidOutStyledText.length - visibleContentHeight, - ) + ? laidOutStyledText.slice(hiddenLinesCount, laidOutStyledText.length) : laidOutStyledText.slice(0, visibleContentHeight) : laidOutStyledText; |
