From 994aab57b6402543b07ebb7cfb06fd580e43233f Mon Sep 17 00:00:00 2001 From: Dominic Griesel Date: Mon, 13 Mar 2023 20:19:53 +0100 Subject: [PATCH] feat: lines --- src/components/Line.tsx | 68 ++++++++++++++++++ src/dom.ts | 4 +- src/global.d.ts | 19 +++++ src/index.ts | 2 + src/reconciler.ts | 4 ++ src/render-line.ts | 29 ++++++++ src/render-node-to-output.ts | 6 ++ src/styles.ts | 62 +++++++++++++--- test/lines.tsx | 136 +++++++++++++++++++++++++++++++++++ 9 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 src/components/Line.tsx create mode 100644 src/render-line.ts create mode 100644 test/lines.tsx diff --git a/src/components/Line.tsx b/src/components/Line.tsx new file mode 100644 index 000000000..be0d1e862 --- /dev/null +++ b/src/components/Line.tsx @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +/* eslint-disable react/no-unused-prop-types */ +import React, {forwardRef} from 'react'; +import {type Styles} from '../styles.js'; +import {type DOMElement} from '../dom.js'; + +export type Props = Pick< + Styles, + | 'position' + | 'marginTop' + | 'marginBottom' + | 'marginLeft' + | 'marginRight' + | 'borderStyle' + | 'borderColor' + | 'height' + | 'width' +> & { + orientation?: 'horizontal' | 'vertical'; + + /** + * Margin on all sides. Equivalent to setting `marginTop`, `marginBottom`, `marginLeft` and `marginRight`. + * + * @default 0 + */ + readonly margin?: number; + + /** + * Horizontal margin. Equivalent to setting `marginLeft` and `marginRight`. + * + * @default 0 + */ + readonly marginX?: number; + + /** + * Vertical margin. Equivalent to setting `marginTop` and `marginBottom`. + * + * @default 0 + */ + readonly marginY?: number; +}; + +/** + * Line renders a horizontal or vertical line with the given border style and color. + */ +const Line = forwardRef(({orientation, ...style}, ref) => { + const transformedStyle = { + ...style, + marginLeft: style.marginLeft || style.marginX || style.margin || 0, + marginRight: style.marginRight || style.marginX || style.margin || 0, + marginTop: style.marginTop || style.marginY || style.margin || 0, + marginBottom: style.marginBottom || style.marginY || style.margin || 0, + width: orientation === 'horizontal' ? style.width : 1, + height: orientation === 'vertical' ? style.height : 1 + }; + + return ( + + ); +}); + +Line.displayName = 'Line'; + +Line.defaultProps = { + orientation: 'horizontal' +}; + +export default Line; diff --git a/src/dom.ts b/src/dom.ts index 3db2c1279..457a0f0ab 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -16,6 +16,7 @@ export type TextName = '#text'; export type ElementNames = | 'ink-root' | 'ink-box' + | 'ink-line' | 'ink-text' | 'ink-virtual-text'; @@ -159,7 +160,8 @@ export const setStyle = (node: DOMNode, style: Styles): void => { node.style = style; if (node.yogaNode) { - applyStyles(node.yogaNode, style); + // @ts-expect-error we did check that node.yogaNode exists + applyStyles(node, style); } }; diff --git a/src/global.d.ts b/src/global.d.ts index e147db5fc..7aa3baa93 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -9,6 +9,7 @@ declare global { interface IntrinsicElements { 'ink-box': Ink.Box; 'ink-text': Ink.Text; + 'ink-line': Ink.Line; } } } @@ -22,6 +23,24 @@ declare namespace Ink { style?: Except; }; + type Line = { + key?: Key; + ref?: LegacyRef; + orientation?: 'horizontal' | 'vertical'; + style?: Pick< + Styles, + | 'position' + | 'marginTop' + | 'marginBottom' + | 'marginLeft' + | 'marginRight' + | 'borderStyle' + | 'borderColor' + | 'width' + | 'height' + >; + }; + type Text = { children?: ReactNode; key?: Key; diff --git a/src/index.ts b/src/index.ts index b81c39cda..c71fa896c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ export type {RenderOptions, Instance} from './render.js'; export {default as render} from './render.js'; export type {Props as BoxProps} from './components/Box.js'; export {default as Box} from './components/Box.js'; +export type {Props as LineProps} from './components/Line.js'; +export {default as Line} from './components/Line.js'; export type {Props as TextProps} from './components/Text.js'; export {default as Text} from './components/Text.js'; export type {Props as AppProps} from './components/AppContext.js'; diff --git a/src/reconciler.ts b/src/reconciler.ts index b224cedd4..a36cc6daf 100644 --- a/src/reconciler.ts +++ b/src/reconciler.ts @@ -107,6 +107,10 @@ export default createReconciler< throw new Error(` can’t be nested inside component`); } + if (hostContext.isInsideText && originalType === 'ink-line') { + throw new Error(` can’t be nested inside component`); + } + const type = originalType === 'ink-text' && hostContext.isInsideText ? 'ink-virtual-text' diff --git a/src/render-line.ts b/src/render-line.ts new file mode 100644 index 000000000..401e050f3 --- /dev/null +++ b/src/render-line.ts @@ -0,0 +1,29 @@ +import cliBoxes from 'cli-boxes'; +import colorize from './colorize.js'; +import {type DOMNode} from './dom.js'; +import type Output from './output.js'; + +const renderLine = ( + x: number, + y: number, + node: DOMNode, + output: Output +): void => { + if (typeof node.style.borderStyle === 'string') { + const width = Math.max(1, node.yogaNode!.getComputedWidth()); + const height = Math.max(1, node.yogaNode!.getComputedHeight()); + const color = node.style.borderColor; + const box = cliBoxes[node.style.borderStyle]; + + const border = + (node as any).attributes.orientation === 'vertical' + ? // Vertical line + (colorize(box.left, color, 'foreground') + '\n').repeat(height) + : // Horizontal line + colorize(box.top.repeat(width), color, 'foreground'); + + output.write(x, y, border, {transformers: []}); + } +}; + +export default renderLine; diff --git a/src/render-node-to-output.ts b/src/render-node-to-output.ts index b43a17030..ad790cc29 100644 --- a/src/render-node-to-output.ts +++ b/src/render-node-to-output.ts @@ -7,6 +7,7 @@ import squashTextNodes from './squash-text-nodes.js'; import renderBorder from './render-border.js'; import {type DOMElement} from './dom.js'; import type Output from './output.js'; +import renderLine from './render-line.js'; // If parent container is ``, text nodes will be treated as separate nodes in // the tree and will have their own coordinates in the layout. @@ -89,6 +90,11 @@ const renderNodeToOutput = ( return; } + if (node.nodeName === 'ink-line') { + renderLine(x, y, node, output); + return; + } + let clipped = false; if (node.nodeName === 'ink-box') { diff --git a/src/styles.ts b/src/styles.ts index 3d9b1fe77..f0a921d78 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -3,6 +3,7 @@ import Yoga, {type YogaNode} from 'yoga-layout-prebuilt'; import {type Boxes} from 'cli-boxes'; import {type LiteralUnion} from 'type-fest'; import {type ForegroundColorName} from 'chalk'; +import {DOMNode} from './dom.js'; export type Styles = { readonly textWrap?: @@ -153,7 +154,12 @@ export type Styles = { readonly overflowY?: 'visible' | 'hidden'; }; -const applyPositionStyles = (node: Yoga.YogaNode, style: Styles): void => { +const applyPositionStyles = ( + domNode: DOMNode & {yogaNode: YogaNode}, + style: Styles +): void => { + const node = domNode.yogaNode; + if ('position' in style) { node.setPositionType( style.position === 'absolute' @@ -163,7 +169,12 @@ const applyPositionStyles = (node: Yoga.YogaNode, style: Styles): void => { } }; -const applyMarginStyles = (node: Yoga.YogaNode, style: Styles): void => { +const applyMarginStyles = ( + domNode: DOMNode & {yogaNode: YogaNode}, + style: Styles +): void => { + const node = domNode.yogaNode; + if ('marginLeft' in style) { node.setMargin(Yoga.EDGE_START, style.marginLeft || 0); } @@ -181,7 +192,12 @@ const applyMarginStyles = (node: Yoga.YogaNode, style: Styles): void => { } }; -const applyPaddingStyles = (node: Yoga.YogaNode, style: Styles): void => { +const applyPaddingStyles = ( + domNode: DOMNode & {yogaNode: YogaNode}, + style: Styles +): void => { + const node = domNode.yogaNode; + if ('paddingLeft' in style) { node.setPadding(Yoga.EDGE_LEFT, style.paddingLeft || 0); } @@ -199,7 +215,12 @@ const applyPaddingStyles = (node: Yoga.YogaNode, style: Styles): void => { } }; -const applyFlexStyles = (node: YogaNode, style: Styles): void => { +const applyFlexStyles = ( + domNode: DOMNode & {yogaNode: YogaNode}, + style: Styles +): void => { + const node = domNode.yogaNode; + if ('flexGrow' in style) { node.setFlexGrow(style.flexGrow ?? 0); } @@ -298,7 +319,12 @@ const applyFlexStyles = (node: YogaNode, style: Styles): void => { } }; -const applyDimensionStyles = (node: YogaNode, style: Styles): void => { +const applyDimensionStyles = ( + domNode: DOMNode & {yogaNode: YogaNode}, + style: Styles +): void => { + const node = domNode.yogaNode; + if ('width' in style) { if (typeof style.width === 'number') { node.setWidth(style.width); @@ -336,7 +362,12 @@ const applyDimensionStyles = (node: YogaNode, style: Styles): void => { } }; -const applyDisplayStyles = (node: YogaNode, style: Styles): void => { +const applyDisplayStyles = ( + domNode: DOMNode & {yogaNode: YogaNode}, + style: Styles +): void => { + const node = domNode.yogaNode; + if ('display' in style) { node.setDisplay( style.display === 'flex' ? Yoga.DISPLAY_FLEX : Yoga.DISPLAY_NONE @@ -344,9 +375,19 @@ const applyDisplayStyles = (node: YogaNode, style: Styles): void => { } }; -const applyBorderStyles = (node: YogaNode, style: Styles): void => { +const applyBorderStyles = ( + domNode: DOMNode & {yogaNode: YogaNode}, + style: Styles +): void => { + const node = domNode.yogaNode; + if ('borderStyle' in style) { - const borderWidth = typeof style.borderStyle === 'string' ? 1 : 0; + const borderWidth = + domNode.nodeName === 'ink-line' + ? 0 + : typeof style.borderStyle === 'string' + ? 1 + : 0; node.setBorder(Yoga.EDGE_TOP, borderWidth); node.setBorder(Yoga.EDGE_BOTTOM, borderWidth); @@ -355,7 +396,10 @@ const applyBorderStyles = (node: YogaNode, style: Styles): void => { } }; -const styles = (node: YogaNode, style: Styles = {}): void => { +const styles = ( + node: DOMNode & {yogaNode: YogaNode}, + style: Styles = {} +): void => { applyPositionStyles(node, style); applyMarginStyles(node, style); applyPaddingStyles(node, style); diff --git a/test/lines.tsx b/test/lines.tsx new file mode 100644 index 000000000..cf0f92ff3 --- /dev/null +++ b/test/lines.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import test from 'ava'; +import boxes from 'cli-boxes'; +import {Box, Line, Newline, Text} from '../src/index.js'; +import {renderToString} from './helpers/render-to-string.js'; +import boxen from 'boxen'; + +test('simple horizontal line', t => { + const output = renderToString(); + + t.is(output, boxes.single.top.repeat(100)); +}); + +test('horizontal line with margin', t => { + const output = renderToString( + <> + Before + + After + + ); + + t.is(output, 'Before\n\n\n ' + boxes.single.top.repeat(98) + '\n\n\nAfter'); +}); + +test('horizontal line in a box', t => { + const output = renderToString( + + + + ); + + t.is(output, '\n\n\n' + boxes.double.top.repeat(5) + '\n\n\n'); +}); + +test('vertical line in a box', t => { + const output = renderToString( + + + + ); + + t.is(output, (boxes.double.left + '\n').repeat(20).trimEnd()); +}); + +test('flexbox layout 1', t => { + const output = renderToString( + + + + A + + B + + + + ); + + const l = boxes.single.left; + t.is( + output, + boxen(`${l}A${l}B${l}`, { + borderStyle: 'double', + height: 7, + width: 51 + }) + ); +}); + +test('flexbox layout 2', t => { + const output = renderToString( + + + + + AA + + + + BB + + + + + ); + + const l = boxes.single.left; + t.is( + output, + boxen(`${l}A${l}B${l}\n${l}A${l}B${l}`, { + borderStyle: 'double', + height: 7, + width: 51 + }) + ); +}); + +test('flexbox layout 3', t => { + const output = renderToString( + + + + + + + + ); + + const l = boxes.single.left; + t.is( + output, + boxen(`${l}${l}${l}`, { + borderStyle: 'double', + height: 7, + width: 51 + }) + ); +});