Skip to content

Commit

Permalink
Add wrapping/truncating for <Box> text content
Browse files Browse the repository at this point in the history
  • Loading branch information
Vadim Demedes committed Mar 27, 2019
1 parent dcaaafa commit e8e6811
Show file tree
Hide file tree
Showing 11 changed files with 172 additions and 33 deletions.
6 changes: 6 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ export interface BoxProps {
| "flex-end"
| "space-between"
| "space-around";
readonly textWrap?:
| "wrap"
| "truncate"
| "truncate-start"
| "truncate-middle"
| "truncate-end";
}

/**
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"auto-bind": "^2.0.0",
"chalk": "^2.4.1",
"cli-cursor": "^2.1.0",
"cli-truncate": "^1.1.0",
"lodash.throttle": "^4.1.1",
"log-update": "^3.0.0",
"prop-types": "^15.6.2",
Expand All @@ -53,6 +54,7 @@
"slice-ansi": "^1.0.0",
"string-length": "^2.0.0",
"widest-line": "^2.0.0",
"wrap-ansi": "^5.0.0",
"yoga-layout-prebuilt": "^1.9.3"
},
"devDependencies": {
Expand Down
25 changes: 25 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,31 @@ Type: `number`

Sets a minimum height of the element. Percentages aren't supported yet, see https://github.com/facebook/yoga/issues/872.

##### Wrapping

###### textWrap

Type: `string`<br>
Values: `wrap` `truncate` `truncate-start` `truncate-middle` `truncate-end`

This property tells Ink to wrap or truncate text content of `<Box>` if its width is larger than container. If `wrap` is passed, Ink will wrap text and split it into multiple lines. If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off.

*Note:* Ink doesn't wrap text by default.

```jsx
<Box textWrap="wrap">Hello World</Box>
//=> 'Hello\nWorld'

// `truncate` is an alias to `truncate-end`
<Box textWrap="truncate">Hello World</Box>
//=> 'Hello…'

<Box textWrap="truncate-middle">Hello World</Box>
//=> 'He…ld'

<Box textWrap="truncate-start">Hello World</Box>
//=> '…World'
```

##### Padding

Expand Down
8 changes: 8 additions & 0 deletions src/apply-styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ const applyFlexStyles = (node, style) => {
};

const applyDimensionStyles = (node, style) => {
if (hasOwnProperty(style, 'width')) {
node.setWidth(style.width);
}

if (hasOwnProperty(style, 'height')) {
node.setHeight(style.height);
}

if (hasOwnProperty(style, 'minWidth')) {
node.setMinWidth(style.minWidth);
}
Expand Down
31 changes: 3 additions & 28 deletions src/build-layout.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import Yoga from 'yoga-layout-prebuilt';
import widestLine from 'widest-line';
import applyStyles from './apply-styles';

const measureText = text => {
const width = widestLine(text);
const height = text.split('\n').length;

return {width, height};
};
import measureText from './measure-text';

// Traverse the node tree, create Yoga nodes and assign styles to each Yoga node
const buildLayout = (node, options) => {
Expand Down Expand Up @@ -40,32 +33,14 @@ const buildLayout = (node, options) => {
applyStyles(yogaNode, style);

// Nodes with only text have a child Yoga node dedicated for that text
if (node.textContent) {
const {width, height} = measureText(node.textContent);
if (node.textContent || node.nodeValue) {
const {width, height} = measureText(node.textContent || node.nodeValue);
yogaNode.setWidth(style.width || width);
yogaNode.setHeight(style.height || height);

return node;
}

// Text node
if (node.nodeValue) {
const {width, height} = measureText(node.nodeValue);
yogaNode.setWidth(width);
yogaNode.setHeight(height);

return node;
}

// Nodes with other nodes as children
if (style.width) {
yogaNode.setWidth(style.width);
}

if (style.height) {
yogaNode.setHeight(style.height);
}

if (Array.isArray(node.childNodes) && node.childNodes.length > 0) {
const childNodes = node.childNodes.filter(childNode => {
return skipStaticElements ? !childNode.unstable__static : true;
Expand Down
1 change: 1 addition & 0 deletions src/components/Box.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default class Box extends PureComponent {
flexBasis: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
alignItems: PropTypes.oneOf(['flex-start', 'center', 'flex-end']),
justifyContent: PropTypes.oneOf(['flex-start', 'center', 'flex-end', 'space-between', 'space-around']),
textWrap: PropTypes.oneOf(['wrap', 'truncate', 'truncate-start', 'truncate-middle', 'truncate-end']),
unstable__transformChildren: PropTypes.func,
children: PropTypes.node
};
Expand Down
8 changes: 8 additions & 0 deletions src/measure-text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import widestLine from 'widest-line';

export default text => {
const width = widestLine(text);
const height = text.split('\n').length;

return {width, height};
};
7 changes: 6 additions & 1 deletion src/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Output from './output';
import {createNode, appendStaticNode} from './dom';
import buildLayout from './build-layout';
import renderNodeToOutput from './render-node-to-output';
import wrapText from './wrap-text';

// Since <Static> components can be placed anywhere in the tree, this helper finds and returns them
const getStaticNodes = element => {
Expand Down Expand Up @@ -49,14 +50,16 @@ export default ({terminalWidth}) => {
let staticOutput;
if (staticElements.length === 1) {
const rootNode = createNode('root');
appendStaticNode(rootNode, staticElements[0], {woot: true});
appendStaticNode(rootNode, staticElements[0]);

const {yogaNode: staticYogaNode} = buildLayout(rootNode, {
config,
terminalWidth,
skipStaticElements: false
});

staticYogaNode.calculateLayout(Yoga.UNDEFINED, Yoga.UNDEFINED, Yoga.DIRECTION_LTR);
wrapText(rootNode);
staticYogaNode.calculateLayout(Yoga.UNDEFINED, Yoga.UNDEFINED, Yoga.DIRECTION_LTR);

// Save current Yoga node tree to free up memory later
Expand All @@ -76,6 +79,8 @@ export default ({terminalWidth}) => {
skipStaticElements: true
});

yogaNode.calculateLayout(Yoga.UNDEFINED, Yoga.UNDEFINED, Yoga.DIRECTION_LTR);
wrapText(node);
yogaNode.calculateLayout(Yoga.UNDEFINED, Yoga.UNDEFINED, Yoga.DIRECTION_LTR);

// Save current node tree to free up memory later
Expand Down
59 changes: 59 additions & 0 deletions src/wrap-text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import wrapAnsi from 'wrap-ansi';
import cliTruncate from 'cli-truncate';
import measureText from './measure-text';

const wrapText = node => {
if (node.textContent && typeof node.parentNode.style.textWrap === 'string') {
const {yogaNode} = node;
const parentYogaNode = node.parentNode.yogaNode;

const maxWidth = parentYogaNode.getComputedWidth() - (parentYogaNode.getComputedPadding() * 2);
const currentWidth = yogaNode.getComputedWidth();

if (currentWidth > maxWidth) {
const {textWrap} = node.parentNode.style;
let wrappedText = node.textContent;

if (textWrap === 'wrap') {
wrappedText = wrapAnsi(node.textContent, maxWidth, {
trim: false,
hard: true
});
}

if (textWrap.startsWith('truncate')) {
let position;

if (textWrap === 'truncate' || textWrap === 'truncate-end') {
position = 'end';
}

if (textWrap === 'truncate-middle') {
position = 'middle';
}

if (textWrap === 'truncate-start') {
position = 'start';
}

wrappedText = cliTruncate(node.textContent, maxWidth, {position});
}

const {width, height} = measureText(wrappedText);
node.textContent = wrappedText;

yogaNode.setWidth(width);
yogaNode.setHeight(height);
}

return;
}

if (Array.isArray(node.childNodes) && node.childNodes.length > 0) {
for (const childNode of node.childNodes) {
wrapText(childNode);
}
}
};

export default wrapText;
50 changes: 50 additions & 0 deletions test/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,56 @@ test('text with fragment', t => {
t.is(output, 'Hello World');
});

test('wrap text', t => {
const output = renderToString((
<Box textWrap="wrap">
Hello World
</Box>
), {columns: 7});

t.is(output, 'Hello\nWorld');
});

test('don\'t wrap text if there is enough space', t => {
const output = renderToString((
<Box textWrap="wrap">
Hello World
</Box>
), {columns: 20});

t.is(output, 'Hello World');
});

test('truncate text in the end', t => {
const output = renderToString((
<Box textWrap="truncate">
Hello World
</Box>
), {columns: 7});

t.is(output, 'Hello …');
});

test('truncate text in the middle', t => {
const output = renderToString((
<Box textWrap="truncate-middle">
Hello World
</Box>
), {columns: 7});

t.is(output, 'Hel…rld');
});

test('truncate text in the beginning', t => {
const output = renderToString((
<Box textWrap="truncate-start">
Hello World
</Box>
), {columns: 7});

t.is(output, '… World');
});

test('number', t => {
const output = renderToString(<Box>{1}</Box>);

Expand Down
8 changes: 4 additions & 4 deletions test/helpers/render-to-string.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import {render} from '../..';

// Fake process.stdout
class Stream {
constructor() {
constructor({columns}) {
this.output = '';
this.columns = 100;
this.columns = columns || 100;
}

write(str) {
Expand All @@ -16,8 +16,8 @@ class Stream {
}
}

export default node => {
const stream = new Stream();
export default (node, {columns} = {}) => {
const stream = new Stream({columns});

render(node, {
stdout: stream,
Expand Down

0 comments on commit e8e6811

Please sign in to comment.