Repository: sindresorhus/boxen (js)

Issue:
Text wrapping doesn't account for margin
From what I can see there is no way currently to make this work:
<img width="595" alt="Screenshot 2021-04-04 at 19 19 53" src="https://user-images.githubusercontent.com/9800850/113522955-028efd80-95a5-11eb-8c4f-f37ead21fff7.png">

The problem is that our CLI includes a prefix for each line and `boxen` doesn't know that. I've wanted to include the margin in the options so I would just move the box to the right by the appropriate amount of characters (known to me). However, this doesn't work - since text wrapping etc happens before margin is even looked at.

Pull request description:
Code refactoring
This PR has for objective to provide a little code refactoring in order to make the library more easily maintainable/scalable/readable.
Effectively the main function of the library was around 150 lines long and it renders it really hard to read/understand.

So I decided to divide it a little, now the code is separated into two 'main functions' : 
- The main function from before without the content handling part.
- And the makeContentText function wich now handles the whole content part.

This new function takes in 4 arguments: 
- text: wich is the box's content text provided by the user
- padding: the optional padding desired
- columns: the maximum column count the content should take
- align: the optional text alignment

This allows for more flexibility regarding the width of the content. It'll allow an easy implementation for issue #51. (PR coming soon). Also the way this function is done/used fixes #53 and all kind of similar bugs where the box would exceed the terminal's size. See images below:

![Image comparison of old/new version](https://i.imgur.com/jzKJ95V.png)

This pretty much sums up the purpose of this PR. It's kind of a preparation for other PRs I'm preparing


---

Fixes #53

Patch:
diff --git a/index.js b/index.js
index f4737f6..f62f871 100644
--- a/index.js
+++ b/index.js
@@ -7,6 +7,10 @@ const camelCase = require('camelcase');
 const ansiAlign = require('ansi-align');
 const wrapAnsi = require('wrap-ansi');
 
+const NL = '\n';
+const PAD = ' ';
+const BORDERS_WIDTH = 2;
+
 const terminalColumns = () => {
 	const {env, stdout, stderr} = process;
 
@@ -71,6 +75,79 @@ const getBorderChars = borderStyle => {
 	return chararacters;
 };
 
+const makeContentText = (text, padding, columns, align) => {
+	text = ansiAlign(text, {align});
+	let lines = text.split(NL);
+	const textWidth = widestLine(text);
+
+	const max = columns - padding.left - padding.right;
+
+	if (textWidth > max) {
+		const newLines = [];
+		for (const line of lines) {
+			const createdLines = wrapAnsi(line, max, {hard: true});
+			const alignedLines = ansiAlign(createdLines, {align});
+			const alignedLinesArray = alignedLines.split('\n');
+			const longestLength = Math.max(...alignedLinesArray.map(s => stringWidth(s)));
+
+			for (const alignedLine of alignedLinesArray) {
+				let paddedLine;
+				switch (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 (align === 'center' && textWidth < max) {
+		lines = lines.map(line => PAD.repeat((max - textWidth) / 2) + line);
+	} else if (align === 'right' && textWidth < max) {
+		lines = lines.map(line => PAD.repeat(max - textWidth) + line);
+	}
+
+	const paddingLeft = PAD.repeat(padding.left);
+	const paddingRight = PAD.repeat(padding.right);
+
+	lines = lines.map(line => paddingLeft + line + paddingRight);
+
+	lines = lines.map(line => {
+		if (columns - stringWidth(line) > 0) {
+			switch (align) {
+				case 'center':
+					return line + PAD.repeat(columns - stringWidth(line));
+				case 'right':
+					return line + PAD.repeat(columns - stringWidth(line));
+				default:
+					return line + PAD.repeat(columns - stringWidth(line));
+			}
+		}
+
+		return line;
+	});
+
+	if (padding.top > 0) {
+		lines = new Array(padding.top).fill(PAD.repeat(columns)).concat(lines);
+	}
+
+	if (padding.bottom > 0) {
+		lines = lines.concat(new Array(padding.bottom).fill(PAD.repeat(columns)));
+	}
+
+	return lines.join(NL);
+};
+
 const isHex = color => color.match(/^#(?:[0-f]{3}){1,2}$/i);
 const isColorValid = color => typeof color === 'string' && ((chalk[color]) || isHex(color));
 const getColorFn = color => isHex(color) ? chalk.hex(color) : chalk[color];
@@ -105,77 +182,35 @@ module.exports = (text, options) => {
 
 	const colorizeContent = content => options.backgroundColor ? getBGColorFn(options.backgroundColor)(content) : content;
 
-	const NL = '\n';
-	const PAD = ' ';
 	const columns = terminalColumns();
 
-	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 alignedLinesArray = alignedLines.split('\n');
-			const longestLength = Math.max(...alignedLinesArray.map(s => stringWidth(s)));
-
-			for (const alignedLine of alignedLinesArray) {
-				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;
-	}
+	let contentWidth = widestLine(wrapAnsi(text, columns - BORDERS_WIDTH, {hard: true})) + padding.left + padding.right;
 
-	if (contentWidth + BORDERS_WIDTH + margin.left + margin.right > columns) {
+	if ((margin.left && margin.right) && 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);
+		margin.left = Math.max(0, Math.floor(margin.left * multiplier));
+		margin.right = Math.max(0, 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);
-	}
+	// Prevent content from exceeding the console's width
+	contentWidth = Math.min(contentWidth, columns - BORDERS_WIDTH - margin.left - margin.right);
 
-	if (padding.bottom > 0) {
-		lines = lines.concat(new Array(padding.bottom).fill(''));
-	}
+	text = makeContentText(text, padding, contentWidth, options.align);
 
-	const paddingLeft = PAD.repeat(padding.left);
 	let marginLeft = PAD.repeat(margin.left);
 
 	if (options.float === 'center') {
-		const padWidth = Math.max((columns - contentWidth - BORDERS_WIDTH) / 2, 0);
-		marginLeft = PAD.repeat(padWidth);
+		const marginWidth = Math.max((columns - contentWidth - BORDERS_WIDTH) / 2, 0);
+		marginLeft = PAD.repeat(marginWidth);
 	} else if (options.float === 'right') {
-		const padWidth = Math.max(columns - contentWidth - margin.right - BORDERS_WIDTH, 0);
-		marginLeft = PAD.repeat(padWidth);
+		const marginWidth = Math.max(columns - contentWidth - margin.right - BORDERS_WIDTH, 0);
+		marginLeft = PAD.repeat(marginWidth);
 	}
 
 	const horizontal = chars.horizontal.repeat(contentWidth);
@@ -185,9 +220,10 @@ module.exports = (text, options) => {
 
 	const LINE_SEPARATOR = (contentWidth + BORDERS_WIDTH + margin.left >= columns) ? '' : NL;
 
+	const lines = text.split(NL);
+
 	const middle = lines.map(line => {
-		const paddingRight = PAD.repeat(contentWidth - stringWidth(line) - padding.left);
-		return marginLeft + side + colorizeContent(paddingLeft + line + paddingRight) + side;
+		return marginLeft + side + colorizeContent(line) + side;
 	}).join(LINE_SEPARATOR);
 
 	return top + LINE_SEPARATOR + middle + LINE_SEPARATOR + bottom;


Test patch:
diff --git a/test.js b/test.js
index 1967983..d786357 100644
--- a/test.js
+++ b/test.js
@@ -525,133 +525,162 @@ test('text is right-aligned after wrapping', t => {
 	t.is(lines[2], expected);
 });
 
-// TODO: Find out why it fails on GitHub Actions.
-if (!process.env.CI) {
-	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, -1);
-			const paddingLeft = lineWithoutBorders.length - lineWithoutBorders.trimStart().length;
-			const paddingRight = lineWithoutBorders.length - lineWithoutBorders.trimEnd().length;
+test('box not overflowing terminal', t => {
+	const width = process.stdout.columns || 120;
+	const longContent = 'x'.repeat(width * 4);
+	const box = boxen(longContent);
 
-			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);
-		};
+	const lines = [];
+	for (let index = 1; index < 6; ++index) {
+		const line = box.slice(index * width, (index + 1) * width);
+		lines.push(line);
+	}
 
-		checkAlign(1);
-		checkAlign(2);
-	});
-}
+	for (const line of lines) {
+		t.is(line[0], '│', 'First character of line isn\'t box border');
+		t.is(line[width - 1], '│', 'Last character of line isn\'t box border');
+	}
+});
 
-test('text is left-aligned after wrapping when using words', t => {
+test('box not overflowing terminal 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: 'left'});
+	const longContent = 'x'.repeat(width * 4);
+	const box = boxen(longContent, {padding: 2});
 
-	t.is(box.length, width * 4);
+	const lines = [];
+	for (let index = 1; index < 10; ++index) {
+		const line = box.slice(index * width, (index + 1) * width);
+		lines.push(line);
+	}
+
+	for (const line of lines) {
+		t.is(line[0], '│', 'First character of line isn\'t box border');
+		t.is(line[width - 1], '│', 'Last character of line isn\'t box border');
+	}
+});
+
+test('box not overflowing terminal with padding and margin', t => {
+	const width = process.stdout.columns || 120;
+	const longContent = 'x'.repeat(width * 4);
+	const box = boxen(longContent, {padding: 2, margin: {left: 2, right: 2}});
 
 	const lines = [];
-	for (let index = 0; index < 4; ++index) {
+	for (let index = 1; index < 10; ++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);
+	}
+
+	for (const line of lines) {
+		t.is(line[0], '│', 'First character of line isn\'t box border');
+		t.is(line[width - 1], '│', 'Last character of line isn\'t box border');
+	}
+});
+
+test('box not overflowing terminal with words and margin', t => {
+	const width = process.stdout.columns || 120;
+	const word = 'x'.repeat(width / 2) + ' ';
+	const longContent = word.repeat(5);
+	const box = boxen(longContent, {margin: {left: 2, right: 2}});
 
+	const lines = [];
+	for (let index = 1; index < 6; ++index) {
+		const line = box.slice(index * (word.length + 4), (index + 1) * (word.length + 4));
 		lines.push(line);
 	}
 
-	const checkAlign = index => {
+	for (const line of lines) {
+		t.is(line.trim()[0], '│', 'First character of line isn\'t box border');
+		t.is(line.trim()[word.length], '│', 'Last character of line isn\'t box border');
+	}
+});
+
+test('text is centered after wrapping when using words', t => {
+	const width = process.stdout.columns || 120;
+	const sentence = 'x'.repeat(width / 4) + ' ';
+	const longContent = sentence.repeat(4).trim();
+	const box = boxen(longContent, {align: 'center'});
+
+	const lines = box.split('\n');
+
+	const checkAlign = ({index, leftPad, rightPad}) => {
 		const line = lines[index];
 		const lineWithoutBorders = line.slice(1, -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);
+		t.is(paddingLeft, leftPad, 'Padding left in line #' + index);
+		t.is(paddingRight, rightPad, 'Padding right in line #' + index);
 	};
 
-	checkAlign(1);
-	checkAlign(2);
+	checkAlign({index: 1, leftPad: 0, rightPad: 0});
+	checkAlign({index: 2, leftPad: sentence.length, rightPad: sentence.length});
 });
 
-test('text is right-aligned after wrapping when using words', t => {
+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: 'right'});
+	const sentence = 'x'.repeat(width / 4) + ' ';
+	const longContent = sentence.repeat(4).trim();
+	const box = boxen(longContent);
 
-	t.is(box.length, width * 4);
+	const lines = box.split('\n');
 
-	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);
+	const checkAlign = ({index, leftPad, rightPad}) => {
+		const line = lines[index];
+		const lineWithoutBorders = line.slice(1, -1);
+		const paddingLeft = lineWithoutBorders.length - lineWithoutBorders.trimStart().length;
+		const paddingRight = lineWithoutBorders.length - lineWithoutBorders.trimEnd().length;
 
-		lines.push(line);
-	}
+		t.is(paddingLeft, leftPad, 'Padding left in line #' + index);
+		t.is(paddingRight, rightPad, 'Padding right in line #' + index);
+	};
 
-	const checkAlign = index => {
+	checkAlign({index: 1, leftPad: 0, rightPad: 0});
+	checkAlign({index: 2, leftPad: 0, rightPad: sentence.length * 2});
+});
+
+test('text is right-aligned after wrapping when using words', t => {
+	const width = process.stdout.columns || 120;
+	const sentence = 'x'.repeat(width / 4) + ' ';
+	const longContent = sentence.repeat(4).trim();
+	const box = boxen(longContent, {align: 'right'});
+
+	const lines = box.split('\n');
+
+	const checkAlign = ({index, leftPad, rightPad}) => {
 		const line = lines[index];
 		const lineWithoutBorders = line.slice(1, -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);
+		t.is(paddingLeft, leftPad, 'Padding left in line #' + index);
+		t.is(paddingRight, rightPad, 'Padding right in line #' + index);
 	};
 
-	checkAlign(1);
-	checkAlign(2);
+	checkAlign({index: 1, leftPad: 0, rightPad: 0});
+	checkAlign({index: 2, leftPad: sentence.length * 2, rightPad: 0});
 });
 
 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 sentence = 'x'.repeat(width / 4) + ' ';
+	const longContent = sentence.repeat(4).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 lines = box.split('\n');
 
-	const checkAlign = index => {
+	const checkAlign = ({index, leftPad, rightPad}) => {
 		const line = lines[index];
 		const lineWithoutBorders = line.slice(1, -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);
+		t.is(paddingLeft, leftPad, 'Padding left in line #' + index);
+		t.is(paddingRight, rightPad, 'Padding right in line #' + index);
 	};
 
-	checkAlign(1);
-	checkAlign(2);
+	checkAlign({index: 1, leftPad: 1, rightPad: 1});
+	checkAlign({index: 2, leftPad: (sentence.length * 2) + 1, rightPad: 1});
 });