From 63f6a497cba61299a1c24aa96795a55479740ac6 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Sun, 22 Jun 2025 00:54:10 +0000 Subject: Jacob314/overflow notification and one MaxSizedBox bug fix (#1288) --- .../src/ui/components/shared/MaxSizedBox.test.tsx | 279 ++++++++++++--------- .../cli/src/ui/components/shared/MaxSizedBox.tsx | 101 +++++--- 2 files changed, 221 insertions(+), 159 deletions(-) (limited to 'packages/cli/src/ui/components/shared') 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('', () => { it('renders children without truncation when they fit', () => { const { lastFrame } = render( - - - Hello, World! - - , + + + + Hello, World! + + + , ); expect(lastFrame()).equals('Hello, World!'); }); it('hides lines when content exceeds maxHeight', () => { const { lastFrame } = render( - - - Line 1 - - - Line 2 - - - Line 3 - - , + + + + Line 1 + + + Line 2 + + + Line 3 + + + , ); 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( - - - Line 1 - - - Line 2 - - - Line 3 - - , + + + + Line 1 + + + Line 2 + + + Line 3 + + + , ); expect(lastFrame()).equals(`Line 1 ... last 2 lines hidden ...`); @@ -65,11 +72,13 @@ Line 3`); it('wraps text that exceeds maxWidth', () => { const { lastFrame } = render( - - - This is a long line of text - - , + + + + This is a long line of text + + + , ); expect(lastFrame()).equals(`This is a @@ -82,19 +91,21 @@ of text`); And has a line break. Leading spaces preserved.`; const { lastFrame } = render( - - - Example - - - No Wrap: - {multilineText} - - - Longer No Wrap: - This part will wrap around. - - , + + + + Example + + + No Wrap: + {multilineText} + + + Longer No Wrap: + This part will wrap around. + + + , ); expect(lastFrame()).equals( @@ -118,11 +129,13 @@ Longer No Wrap: This it('handles words longer than maxWidth by splitting them', () => { const { lastFrame } = render( - - - Supercalifragilisticexpialidocious - - , + + + + Supercalifragilisticexpialidocious + + + , ); expect(lastFrame()).equals(`... … @@ -134,14 +147,16 @@ ious`); it('does not truncate when maxHeight is undefined', () => { const { lastFrame } = render( - - - Line 1 - - - Line 2 - - , + + + + Line 1 + + + Line 2 + + + , ); 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( - - - Line 1 - - - Line 2 - - - Line 3 - - , + + + + Line 1 + + + Line 2 + + + Line 3 + + + , ); 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( - - - Line 1 - - - Line 2 - - - Line 3 - - , + + + + Line 1 + + + Line 2 + + + Line 3 + + + , ); 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( - , + + + , ); // 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( - - - 你好世界 - - , + + + + 你好世界 + + + , ); // "你好" 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( - - - 🐶🐶🐶🐶🐶 - - , + + + + 🐶🐶🐶🐶🐶 + + + , ); // Each "🐶" has a visual width of 2. @@ -225,17 +250,19 @@ Line 3`); it('accounts for additionalHiddenLinesCount', () => { const { lastFrame } = render( - - - Line 1 - - - Line 2 - - - Line 3 - - , + + + + Line 1 + + + Line 2 + + + Line 3 + + + , ); // 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( - - <> - - Line 1 from Fragment - + + + <> + + Line 1 from Fragment + + + Line 2 from Fragment + + - Line 2 from Fragment + Line 3 direct child - - - Line 3 direct child - - , + + , ); expect(lastFrame()).equals(`Line 1 from Fragment Line 2 from Fragment @@ -270,11 +299,13 @@ Line 3 direct child`); ).join('\n'); const { lastFrame } = render( - - - {THIRTY_LINES} - - , + + + + {THIRTY_LINES} + + + , ); const expected = [ @@ -292,11 +323,13 @@ Line 3 direct child`); ).join('\n'); const { lastFrame } = render( - - - {THIRTY_LINES} - - , + + + + {THIRTY_LINES} + + + , ); 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 = ({ 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 = ({ // 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 ( - - {children} - - ); - } - - 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 elements', element); } - debugReportError('MaxSizedBox children must be 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 hiddenLinesCount = Math.max( - 0, - laidOutStyledText.length - visibleContentHeight, - ); + 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]); + + if (maxHeight === undefined) { + return ( + + {children} + + ); + } + const visibleStyledText = hiddenLinesCount > 0 ? overflowDirection === 'top' - ? laidOutStyledText.slice( - laidOutStyledText.length - visibleContentHeight, - ) + ? laidOutStyledText.slice(hiddenLinesCount, laidOutStyledText.length) : laidOutStyledText.slice(0, visibleContentHeight) : laidOutStyledText; -- cgit v1.2.3