From 14fd5c45314f5fb8794c1963ef940a3ef853563a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Nojek?= <9399633+lukaszmn@users.noreply.github.com> Date: Wed, 6 May 2020 11:41:41 +0200 Subject: [PATCH] Support text wrapping (#46) --- example.js | 10 ++ index.js | 64 +++++++++-- package.json | 3 +- test.js | 309 ++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 372 insertions(+), 14 deletions(-) diff --git a/example.js b/example.js index 37c934f..bf0d1b0 100644 --- a/example.js +++ b/example.js @@ -44,3 +44,13 @@ console.log('\n\n' + boxen(chalk.black('unicorn'), { vertical: '|' } }) + '\n'); + +const sentences = 'Unbreakable_text_because_it_has_no_spaces '.repeat(5); +console.log('\n\n' + boxen(sentences, {align: 'left'}) + '\n'); + +console.log('\n\n' + boxen(sentences, {align: 'center'}) + '\n'); + +console.log('\n\n' + boxen(sentences, {align: 'right', padding: {left: 1, right: 1, top: 0, bottom: 0}}) + '\n'); + +const longWord = 'x'.repeat(process.stdout.columns + 20); +console.log('\n\n' + boxen(longWord, {align: 'center'}) + '\n'); diff --git a/index.js b/index.js index a54524c..a39265d 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,7 @@ const cliBoxes = require('cli-boxes'); const camelCase = require('camelcase'); const ansiAlign = require('ansi-align'); const termSize = require('term-size'); +const wrapAnsi = require('wrap-ansi'); const getObject = detail => { let object; @@ -95,13 +96,60 @@ module.exports = (text, options) => { const colorizeContent = content => options.backgroundColor ? getBGColorFn(options.backgroundColor)(content) : content; - text = ansiAlign(text, {align: options.align}); - const NL = '\n'; const PAD = ' '; + const {columns} = termSize(); + + text = ansiAlign(text, {align: options.align}); let lines = text.split(NL); + let contentWidth = widestLine(text) + padding.left + padding.right; + + const BORDERS_WIDTH = 2; + if (contentWidth + BORDERS_WIDTH > columns) { + contentWidth = columns - BORDERS_WIDTH; + const max = contentWidth - padding.left - padding.right; + const newLines = []; + for (const line of lines) { + const createdLines = wrapAnsi(line, max, {hard: true}); + const alignedLines = ansiAlign(createdLines, {align: options.align}); + const alignedLinesArr = alignedLines.split('\n'); + const longestLength = Math.max(...alignedLinesArr.map(s => s.length)); + + for (const alignedLine of alignedLinesArr) { + let paddedLine; + switch (options.align) { + case 'center': + paddedLine = PAD.repeat((max - longestLength) / 2) + alignedLine; + break; + case 'right': + paddedLine = PAD.repeat(max - longestLength) + alignedLine; + break; + default: + paddedLine = alignedLine; + break; + } + + newLines.push(paddedLine); + } + } + + lines = newLines; + } + + if (contentWidth + BORDERS_WIDTH + margin.left + margin.right > columns) { + // Let's assume we have margins: left = 3, right = 5, in total = 8 + const spaceForMargins = columns - contentWidth - BORDERS_WIDTH; + // Let's assume we have space = 4 + const multiplier = spaceForMargins / (margin.left + margin.right); + // Here: multiplier = 4/8 = 0.5 + margin.left = Math.floor(margin.left * multiplier); + margin.right = Math.floor(margin.right * multiplier); + // Left: 3 * 0.5 = 1.5 -> 1 + // Right: 6 * 0.5 = 3 + } + if (padding.top > 0) { lines = new Array(padding.top).fill('').concat(lines); } @@ -110,16 +158,14 @@ module.exports = (text, options) => { lines = lines.concat(new Array(padding.bottom).fill('')); } - const contentWidth = widestLine(text) + padding.left + padding.right; const paddingLeft = PAD.repeat(padding.left); - const {columns} = termSize(); let marginLeft = PAD.repeat(margin.left); if (options.float === 'center') { - const padWidth = Math.max((columns - contentWidth) / 2, 0); + const padWidth = Math.max((columns - contentWidth - BORDERS_WIDTH) / 2, 0); marginLeft = PAD.repeat(padWidth); } else if (options.float === 'right') { - const padWidth = Math.max(columns - contentWidth - margin.right - 2, 0); + const padWidth = Math.max(columns - contentWidth - margin.right - BORDERS_WIDTH, 0); marginLeft = PAD.repeat(padWidth); } @@ -128,12 +174,14 @@ module.exports = (text, options) => { const bottom = colorizeBorder(marginLeft + chars.bottomLeft + horizontal + chars.bottomRight + NL.repeat(margin.bottom)); const side = colorizeBorder(chars.vertical); + const LINE_SEPARATOR = (contentWidth + BORDERS_WIDTH + margin.left >= columns) ? '' : NL; + const middle = lines.map(line => { const paddingRight = PAD.repeat(contentWidth - stringWidth(line) - padding.left); return marginLeft + side + colorizeContent(paddingLeft + line + paddingRight) + side; - }).join(NL); + }).join(LINE_SEPARATOR); - return top + NL + middle + NL + bottom; + return top + LINE_SEPARATOR + middle + LINE_SEPARATOR + bottom; }; module.exports._borderStyles = cliBoxes; diff --git a/package.json b/package.json index 0341231..25223cb 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "string-width": "^4.1.0", "term-size": "^2.1.0", "type-fest": "^0.8.1", - "widest-line": "^3.1.0" + "widest-line": "^3.1.0", + "wrap-ansi": "^6.2.0" }, "devDependencies": { "ava": "^2.1.0", diff --git a/test.js b/test.js index 6f4a74f..fa4fd94 100644 --- a/test.js +++ b/test.js @@ -4,8 +4,8 @@ import boxen from '.'; chalk.level = 3; -const compare = (t, actual, expected) => { - t.is(actual.trim(), expected.trim()); +const compare = (t, actual, expected, message) => { + t.is(actual.trim(), expected.trim(), message); }; test('creates a box', t => { @@ -73,7 +73,7 @@ test('float option (left)', t => { }); test('float option (center)', t => { - const padSize = Math.ceil((process.stdout.columns - 2) / 2) - 1; + const padSize = Math.floor((process.stdout.columns - 5) / 2); const padding = ' '.repeat(padSize); compare(t, boxen('foo', { @@ -106,8 +106,8 @@ test('float option (center) ignored when content > columns', t => { float: 'right' }); - compare(t, gotWithCenter, gotWithLeft); - compare(t, gotWithCenter, gotWithRight); + compare(t, gotWithCenter, gotWithLeft, 'center vs left'); + compare(t, gotWithCenter, gotWithRight, 'center vs right'); }); test('float option (right)', t => { @@ -325,3 +325,302 @@ ${dimSide}foo${dimSide} ${dimBottomBorder} `); }); + +test('no wrapping when content = columns - 2 and no padding and no margin', t => { + const width = process.stdout.columns; + const longContent = 'x'.repeat(width - 2); + const box = boxen(longContent); + + // No endlines + t.false(box.includes('\n')); + + // Every line has full length + t.is(box.length, width * 3); + + // There are no spaces around (and in this case - within) + t.false(box.includes(' ')); +}); + +test('wrapping when content = columns - 1 and no padding and no margin', t => { + const width = process.stdout.columns; + const longContent = 'x'.repeat(width - 1); + const box = boxen(longContent); + + // No endlines + t.false(box.includes('\n')); + + // Every line has full length + t.is(box.length, width * 4); + + // There are no spaces around + t.is(box, box.trim()); +}); + +test('wrapping when content = columns - 2 and padding = 1 and no margin', t => { + const width = process.stdout.columns; + const longContent = 'x'.repeat(width - 2); + const box = boxen(longContent, {padding: 1}); + + // No endlines + t.false(box.includes('\n')); + + // Every line has full length: 3 normal lines + 1 wrapped + 2 padding = 6 lines + t.is(box.length, width * 6); + + // There are no spaces around + t.is(box, box.trim()); +}); + +test('ignore margins when content = columns - 2 and no padding', t => { + const width = process.stdout.columns; + const longContent = 'x'.repeat(width - 2); + const box = boxen(longContent, {margin: {left: 5, right: 5}}); + + // No endlines + t.false(box.includes('\n')); + + t.is(box.length, width * 3); + + // There are no spaces around (and in this case - within) + t.false(box.includes(' ')); +}); + +test('decrease margins when there is no space for them', t => { + const width = process.stdout.columns; + const longContent = 'x'.repeat(width - 8); + // This gives only 3 spaces of margin on each side, but 5 were requested + const box = boxen(longContent, {margin: {left: 5, right: 5}}); + + const boxWidth = width - 3; + // Minus 2 NL + t.is(box.length - 2, 3 * boxWidth); + + const lines = box.split('\n'); + for (const [index, line] of lines.entries()) { + t.is(line.length, boxWidth, 'Length of line #' + index); + } + + const expected = ' │' + 'x'.repeat(width - 8) + '│'; + t.is(lines[1], expected); +}); + +test('proportionally decrease margins when there is no space for them', t => { + const width = process.stdout.columns; + const longContent = 'x'.repeat(width - 10); + // This gives only 4 spaces of margin on each side, but 5/13 were requested + // Boxen should print 2 spaces on the left and leave 5 spaces on the right + const box = boxen(longContent, {margin: {left: 5, right: 13}}); + + const boxWidth = width - 6; + // Minus 2 NL + t.is(box.length - 2, boxWidth * 3); + + const lines = box.split('\n'); + for (const [index, line] of lines.entries()) { + t.is(line.length, boxWidth, 'Length of line #' + index); + } + + const expected = ' │' + 'x'.repeat(width - 10) + '│'; + t.is(lines[1], expected); +}); + +test('text is centered after wrapping', t => { + const width = process.stdout.columns; + const longContent = 'x'.repeat(width - 1); + const box = boxen(longContent, {align: 'center'}); + + t.is(box.length, width * 4); + + const lines = []; + for (let index = 0; index < 4; ++index) { + const line = box.slice(index * width, (index + 1) * width); + t.is(line.length, width, 'Length of line #' + index); + t.is(line, line.trim(), 'No margin of line #' + index); + if (index !== 2) { + t.false(line.includes(' '), 'No spaces in line #' + index); + } + + lines.push(line); + } + + const paddingLeft = Math.floor((width - 3) / 2); + const paddingRight = width - 3 - paddingLeft; + const expected = '│' + ' '.repeat(paddingLeft) + 'x' + ' '.repeat(paddingRight) + '│'; + t.is(lines[2], expected); +}); + +test('text is left-aligned after wrapping', t => { + const width = process.stdout.columns; + const longContent = 'x'.repeat(width - 1); + const box = boxen(longContent, {align: 'left'}); + + t.is(box.length, width * 4); + + const lines = []; + for (let index = 0; index < 4; ++index) { + const line = box.slice(index * width, (index + 1) * width); + t.is(line.length, width, 'Length of line #' + index); + t.is(line, line.trim(), 'No margin of line #' + index); + if (index !== 2) { + t.false(line.includes(' '), 'No spaces in line #' + index); + } + + lines.push(line); + } + + const padding = width - 3; + const expected = '│x' + ' '.repeat(padding) + '│'; + t.is(lines[2], expected); +}); + +test('text is right-aligned after wrapping', t => { + const width = process.stdout.columns; + const longContent = 'x'.repeat(width - 1); + const box = boxen(longContent, {align: 'right'}); + + t.is(box.length, width * 4); + + const lines = []; + for (let index = 0; index < 4; ++index) { + const line = box.slice(index * width, (index + 1) * width); + t.is(line.length, width, 'Length of line #' + index); + t.is(line, line.trim(), 'No margin of line #' + index); + if (index !== 2) { + t.false(line.includes(' '), 'No spaces in line #' + index); + } + + lines.push(line); + } + + const padding = width - 3; + const expected = '│' + ' '.repeat(padding) + 'x│'; + t.is(lines[2], expected); +}); + +test('text is centered after wrapping when using words', t => { + const width = process.stdout.columns || 120; + const sentence = 'x'.repeat(width / 3) + ' '; + const longContent = sentence.repeat(3).trim(); + const box = boxen(longContent, {align: 'center'}); + + t.is(box.length, width * 4); + + const lines = []; + for (let index = 0; index < 4; ++index) { + const line = box.slice(index * width, (index + 1) * width); + t.is(line.length, width, 'Length of line #' + index); + t.is(line, line.trim(), 'No margin of line #' + index); + + lines.push(line); + } + + const checkAlign = index => { + const line = lines[index]; + const lineWithoutBorders = line.slice(1, line.length - 1); + const paddingLeft = lineWithoutBorders.length - lineWithoutBorders.trimStart().length; + const paddingRight = lineWithoutBorders.length - lineWithoutBorders.trimEnd().length; + + t.true(paddingLeft > 0, 'Padding left in line #' + index); + t.true(paddingRight > 0, 'Padding right in line #' + index); + t.true(Math.abs(paddingLeft - paddingRight) <= 1, 'Left and right padding are not (almost) equal in line #' + index); + }; + + checkAlign(1); + checkAlign(2); +}); + +test('text is left-aligned after wrapping when using words', t => { + const width = process.stdout.columns || 120; + const sentence = 'x'.repeat(width / 3) + ' '; + const longContent = sentence.repeat(3).trim(); + const box = boxen(longContent, {align: 'left'}); + + t.is(box.length, width * 4); + + const lines = []; + for (let index = 0; index < 4; ++index) { + const line = box.slice(index * width, (index + 1) * width); + t.is(line.length, width, 'Length of line #' + index); + t.is(line, line.trim(), 'No margin of line #' + index); + + lines.push(line); + } + + const checkAlign = index => { + const line = lines[index]; + const lineWithoutBorders = line.slice(1, line.length - 1); + const paddingLeft = lineWithoutBorders.length - lineWithoutBorders.trimStart().length; + const paddingRight = lineWithoutBorders.length - lineWithoutBorders.trimEnd().length; + + t.is(paddingLeft, 0, 'Padding left in line #' + index); + t.true(paddingRight > 0, 'Padding right in line #' + index); + }; + + checkAlign(1); + checkAlign(2); +}); + +test('text is right-aligned after wrapping when using words', t => { + const width = process.stdout.columns || 120; + const sentence = 'x'.repeat(width / 3) + ' '; + const longContent = sentence.repeat(3).trim(); + const box = boxen(longContent, {align: 'right'}); + + t.is(box.length, width * 4); + + const lines = []; + for (let index = 0; index < 4; ++index) { + const line = box.slice(index * width, (index + 1) * width); + t.is(line.length, width, 'Length of line #' + index); + t.is(line, line.trim(), 'No margin of line #' + index); + + lines.push(line); + } + + const checkAlign = index => { + const line = lines[index]; + const lineWithoutBorders = line.slice(1, line.length - 1); + const paddingLeft = lineWithoutBorders.length - lineWithoutBorders.trimStart().length; + const paddingRight = lineWithoutBorders.length - lineWithoutBorders.trimEnd().length; + + t.true(paddingLeft > 0, 'Padding left in line #' + index); + t.is(paddingRight, 0, 'Padding right in line #' + index); + }; + + checkAlign(1); + checkAlign(2); +}); + +test('text is right-aligned after wrapping when using words, with padding', t => { + const width = process.stdout.columns || 120; + const sentence = 'x'.repeat(width / 3) + ' '; + const longContent = sentence.repeat(3).trim(); + const box = boxen(longContent, { + align: 'right', + padding: {left: 1, right: 1, top: 0, bottom: 0} + }); + + t.is(box.length, width * 4); + + const lines = []; + for (let index = 0; index < 4; ++index) { + const line = box.slice(index * width, (index + 1) * width); + t.is(line.length, width, 'Length of line #' + index); + t.is(line, line.trim(), 'No margin of line #' + index); + + lines.push(line); + } + + const checkAlign = index => { + const line = lines[index]; + const lineWithoutBorders = line.slice(1, line.length - 1); + const paddingLeft = lineWithoutBorders.length - lineWithoutBorders.trimStart().length; + const paddingRight = lineWithoutBorders.length - lineWithoutBorders.trimEnd().length; + + t.true(paddingLeft > 0, 'Padding left in line #' + index); + t.is(paddingRight, 1, 'Padding right in line #' + index); + }; + + checkAlign(1); + checkAlign(2); +});