summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/shared
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/components/shared')
-rw-r--r--packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx279
-rw-r--r--packages/cli/src/ui/components/shared/MaxSizedBox.tsx99
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;