@lewishowles/cli-style 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { foreground } from "../formatters/ansi.js";
1
+ import { foreground, visibleWidth } from "../formatters/ansi.js";
2
2
 
3
3
  // Dividers use enough width to read as a section break in compact output.
4
4
  const defaultWidth = 40;
@@ -33,7 +33,7 @@ export function divider(options = {}) {
33
33
  const character = options.character ?? defaultCharacter;
34
34
  const label = options.label ?? "";
35
35
  const width = options.dividerWidth ?? defaultWidth;
36
- const lineWidth = Math.max(width - label.length - labelGap(label), 0);
36
+ const lineWidth = Math.max(width - visibleWidth(label) - labelGap(label), 0);
37
37
  const line = character.repeat(lineWidth);
38
38
  const output = label === "" ? line : `${label} ${line}`;
39
39
 
@@ -1,4 +1,4 @@
1
- import { foreground, style } from "../formatters/ansi.js";
1
+ import { foreground, restoreStyles, style } from "../formatters/ansi.js";
2
2
  import { getResultSymbol, resultTypes } from "../theme/results.js";
3
3
  import { labelledLine } from "./labelled-line.js";
4
4
  import { panel } from "./panel.js";
@@ -48,7 +48,13 @@ export function emptyState(title = "No results", detail = "", options = {}) {
48
48
  return `${prefix}${detailText}`;
49
49
  }
50
50
 
51
- const renderedPrefix = style(foreground(prefix, "muted", options), "bold", options);
51
+ // The prefix with its bold and muted tone switched back on after a coloured title.
52
+ const restoredPrefix = restoreStyles(prefix, [
53
+ style("", "bold", options),
54
+ foreground("", "muted", options),
55
+ ]);
56
+
57
+ const renderedPrefix = style(foreground(restoredPrefix, "muted", options), "bold", options);
52
58
 
53
59
  return `${renderedPrefix}${detailText}`;
54
60
  }
@@ -1,4 +1,4 @@
1
- import { foreground, style } from "../formatters/ansi.js";
1
+ import { foreground, restoreStyles, style, visibleWidth } from "../formatters/ansi.js";
2
2
  import { wrapText } from "../formatters/wrap.js";
3
3
 
4
4
  // Falls back to the informational tone when no colour token is given.
@@ -44,20 +44,19 @@ export function labelledLine(label = "", message = "", options = {}) {
44
44
  return `${prefix}${messageSeparator}${messageText}`;
45
45
  }
46
46
 
47
- const wrapWidth = resolveWrapWidth(prefixText, options);
47
+ const wrapWidth = resolveWrapWidth(prefix, options);
48
48
  const messageLines = wrapText(messageText, wrapWidth);
49
- const continuationIndent = " ".repeat(prefixText.length + messageSeparator.length);
49
+ const continuationIndent = " ".repeat(visibleWidth(prefix) + visibleWidth(messageSeparator));
50
50
  const wrappedMessage = messageLines.join(`\n${continuationIndent}`);
51
51
 
52
52
  return `${prefix}${messageSeparator}${wrappedMessage}`;
53
53
  }
54
54
 
55
55
  /**
56
- * Colour the icon/label prefix, sized from its plain-text length so the
57
- * continuation indent lines up whether or not colour is applied.
56
+ * Colour the icon and label prefix in the line's tone when colour is on.
58
57
  *
59
58
  * @param {string} prefix
60
- * Plain-text icon and label prefix.
59
+ * Icon and label prefix. The icon or label may already hold colour.
61
60
  * @param {object} options
62
61
  * Rendering options.
63
62
  * @returns {string}
@@ -72,13 +71,24 @@ function renderPrefix(prefix, options) {
72
71
  return prefix;
73
72
  }
74
73
 
75
- const colouredPrefix = foreground(prefix, resolveColour(options), options);
74
+ // The caller's tone, or "info" when none is given.
75
+ const tone = resolveColour(options);
76
+
77
+ // The prefix with bold and the line's colour switched back on after a coloured icon or
78
+ // label, so the rest of the prefix keeps them.
79
+ const restoredPrefix = restoreStyles(prefix, [
80
+ style("", "bold", options),
81
+ foreground("", tone, options),
82
+ ]);
83
+
84
+ // The whole prefix in the line's colour, ready to be made bold.
85
+ const colouredPrefix = foreground(restoredPrefix, tone, options);
76
86
 
77
87
  return style(colouredPrefix, "bold", options);
78
88
  }
79
89
 
80
90
  /**
81
- * Build the plain-text icon and label prefix.
91
+ * Build the icon and label prefix. The icon or label may already hold colour.
82
92
  *
83
93
  * @param {string} label
84
94
  * Label text.
@@ -115,7 +125,7 @@ function resolveColour(options) {
115
125
  * Work out how wide the message can be before it wraps.
116
126
  *
117
127
  * @param {string} prefix
118
- * Plain-text icon and label prefix, used to size the continuation indent.
128
+ * The icon and label prefix as it will be printed, including any colour.
119
129
  * @param {object} options
120
130
  * Rendering options.
121
131
  * @returns {number|undefined}
@@ -127,7 +137,7 @@ function resolveWrapWidth(prefix, options) {
127
137
  }
128
138
 
129
139
  if (Number.isFinite(options.width)) {
130
- return Math.max(1, options.width - prefix.length - messageSeparator.length);
140
+ return Math.max(1, options.width - visibleWidth(prefix) - visibleWidth(messageSeparator));
131
141
  }
132
142
 
133
143
  return undefined;
@@ -1,4 +1,4 @@
1
- import { background, foreground, style } from "../formatters/ansi.js";
1
+ import { background, foreground, restoreStyles, style, visibleWidth } from "../formatters/ansi.js";
2
2
  import { wrapText } from "../formatters/wrap.js";
3
3
  import { profiles } from "../profiles/profiles.js";
4
4
  import { panelColours } from "../theme/colours.js";
@@ -64,23 +64,38 @@ export function panel(options = {}) {
64
64
  * @param {object} options
65
65
  * Rendering options.
66
66
  * @returns {string[]}
67
- * The panel lines, each exactly `width` columns wide.
67
+ * The panel lines, each exactly `width` columns wide, unless a wide character
68
+ * does not fit in a panel at its minimum width.
68
69
  */
69
70
  function renderLine(line, width, isTitle, options) {
70
71
  const accent = options.unicode === false ? "|" : "▌";
71
72
  const contentWidth = width - 5;
73
+ // Whether this line is drawn with ANSI colour.
74
+ const useColour = options.colour === true;
72
75
 
73
76
  return wrapText(line, contentWidth).map((visibleLine) => {
74
- const content = ` ${visibleLine.padEnd(contentWidth, " ")} `;
77
+ // Spaces that fill the line to the content width on screen. A wide character can
78
+ // overflow a very narrow panel, so the count never drops below zero.
79
+ const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(visibleLine)));
80
+ // The line with its inner margins, ready for the panel colours.
81
+ const content = ` ${visibleLine}${padding} `;
75
82
 
76
- if (options.colour !== true) {
83
+ if (!useColour) {
77
84
  return `${accent}${content}`;
78
85
  }
79
86
 
80
87
  const renderedAccent = renderPanelColour(accent, options);
81
88
 
89
+ // The content with the panel colours switched back on after each reset inside it, so
90
+ // the panel background carries on to the right edge.
91
+ const restoredContent = restoreStyles(content, [
92
+ background("", panelColours.background, options),
93
+ foreground("", panelColours.body, options),
94
+ isTitle ? style("", "bold", options) : "",
95
+ ]);
96
+
82
97
  const renderedContent = foreground(
83
- background(content, panelColours.background, options),
98
+ background(restoredContent, panelColours.background, options),
84
99
  panelColours.body,
85
100
  options,
86
101
  );
@@ -1,3 +1,4 @@
1
+ import { visibleWidth } from "../formatters/ansi.js";
1
2
  import { getResultSymbol } from "../theme/results.js";
2
3
  import { row } from "./row.js";
3
4
 
@@ -21,7 +22,7 @@ export function rowGroup(options = {}) {
21
22
  }
22
23
 
23
24
  const labelWidth = Math.max(
24
- ...rows.map((entry) => displayedLabelLength(entry, options)),
25
+ ...rows.map((entry) => displayedLabelWidth(entry, options)),
25
26
  options.labelWidth ?? 0,
26
27
  );
27
28
 
@@ -59,18 +60,19 @@ function normaliseRows(rows) {
59
60
  }
60
61
 
61
62
  /**
62
- * Return the visible width of a row label.
63
+ * Count the columns a row's label fills, including its result symbol when it
64
+ * has one.
63
65
  *
64
66
  * @param {object} entry
65
67
  * Row entry.
66
68
  * @param {object} options
67
69
  * Group rendering options.
68
70
  * @returns {number}
69
- * Displayed label length.
71
+ * The number of terminal columns the label takes up.
70
72
  */
71
- function displayedLabelLength(entry, options) {
73
+ function displayedLabelWidth(entry, options) {
72
74
  if (typeof entry.result !== "string" || entry.result === "") {
73
- return entry.label.length;
75
+ return visibleWidth(entry.label);
74
76
  }
75
77
 
76
78
  const entryOptions = {
@@ -80,5 +82,5 @@ function displayedLabelLength(entry, options) {
80
82
 
81
83
  const symbol = getResultSymbol(entry.result, entryOptions);
82
84
 
83
- return [symbol, entry.label].join(" ").length;
85
+ return visibleWidth([symbol, entry.label].join(" "));
84
86
  }
@@ -1,4 +1,4 @@
1
- import { foreground } from "../formatters/ansi.js";
1
+ import { foreground, padVisible, visibleWidth } from "../formatters/ansi.js";
2
2
  import { wrapText } from "../formatters/wrap.js";
3
3
  import { getResultSymbol, getResultToken } from "../theme/results.js";
4
4
 
@@ -49,8 +49,8 @@ const minimumWrapWidth = 10;
49
49
  export function row(label, value = "", options = {}) {
50
50
  const resultToken = resolveResultToken(options);
51
51
  const displayLabel = renderDisplayLabel(label, resultToken, options);
52
- const labelWidth = Math.max(displayLabel.length, options.labelWidth ?? 0);
53
- const labelText = displayLabel.padEnd(labelWidth, " ");
52
+ const labelWidth = Math.max(visibleWidth(displayLabel), options.labelWidth ?? 0);
53
+ const labelText = padVisible(displayLabel, labelWidth);
54
54
  const renderedLabel = renderLabel(labelText, options);
55
55
 
56
56
  if (value === "") {
@@ -66,7 +66,7 @@ export function row(label, value = "", options = {}) {
66
66
  }
67
67
 
68
68
  const availableWrapWidth = Number.isFinite(options.width)
69
- ? options.width - labelWidth - separator.length
69
+ ? options.width - labelWidth - visibleWidth(separator)
70
70
  : defaultWrapWidth;
71
71
 
72
72
  const hasExplicitWrapWidth = options.wrapWidth !== undefined && options.wrapWidth !== null;
@@ -78,7 +78,7 @@ export function row(label, value = "", options = {}) {
78
78
  const wrapWidth = options.wrapWidth ?? availableWrapWidth;
79
79
 
80
80
  const valueLines = wrapText(value, wrapWidth);
81
- const continuationIndent = " ".repeat(labelWidth + separator.length);
81
+ const continuationIndent = " ".repeat(labelWidth + visibleWidth(separator));
82
82
  const renderedValueLines = valueLines.map((line) => renderValue(line, options));
83
83
  const wrappedValue = renderedValueLines.join(`\n${continuationIndent}`);
84
84
 
@@ -1,4 +1,4 @@
1
- import { foreground, style } from "../formatters/ansi.js";
1
+ import { foreground, restoreStyles, style } from "../formatters/ansi.js";
2
2
 
3
3
  // Inline spans use the same informational tone as hints by default.
4
4
  const defaultTone = "info";
@@ -27,7 +27,16 @@ export function span(value = "", tone = defaultTone, options = {}) {
27
27
  return value;
28
28
  }
29
29
 
30
- const renderedValue = foreground(value, tone, options);
30
+ // The text weight, bold unless the options ask for another.
31
+ const weight = options.weight ?? defaultWeight;
31
32
 
32
- return style(renderedValue, options.weight ?? defaultWeight, options);
33
+ // The text with the span's weight and tone switched back on after any coloured text.
34
+ const restoredValue = restoreStyles(value, [
35
+ style("", weight, options),
36
+ foreground("", tone, options),
37
+ ]);
38
+
39
+ const renderedValue = foreground(restoredValue, tone, options);
40
+
41
+ return style(renderedValue, weight, options);
33
42
  }
@@ -1,4 +1,4 @@
1
- import { foreground, style } from "../formatters/ansi.js";
1
+ import { foreground, restoreStyles, style } from "../formatters/ansi.js";
2
2
  import { getResultSymbol, getResultToken, resultTypes } from "../theme/results.js";
3
3
 
4
4
  /**
@@ -33,7 +33,13 @@ export function status(resultType = resultTypes.UNKNOWN, detail = "", options =
33
33
  return `${prefix}${detailText}`;
34
34
  }
35
35
 
36
- const colouredPrefix = foreground(prefix, token.tone, options);
36
+ // The prefix with its bold and tone switched back on after a coloured label.
37
+ const restoredPrefix = restoreStyles(prefix, [
38
+ style("", "bold", options),
39
+ foreground("", token.tone, options),
40
+ ]);
41
+
42
+ const colouredPrefix = foreground(restoredPrefix, token.tone, options);
37
43
  const boldPrefix = style(colouredPrefix, "bold", options);
38
44
 
39
45
  return `${boldPrefix}${detailText}`;
@@ -1,4 +1,4 @@
1
- import { foreground } from "../formatters/ansi.js";
1
+ import { foreground, padVisible, sliceVisible, visibleWidth } from "../formatters/ansi.js";
2
2
  import { defaultWidth, minimumWidth, normaliseWidth } from "../formatters/width.js";
3
3
  import { tableColours } from "../theme/colours.js";
4
4
 
@@ -112,8 +112,8 @@ function normaliseRows(rows) {
112
112
  function columnWidths(columns, rows) {
113
113
  return columns.map((column) => {
114
114
  const naturalWidth = Math.max(
115
- column.label.length,
116
- ...rows.map((row) => cellValue(row[column.key]).length),
115
+ visibleWidth(column.label),
116
+ ...rows.map((row) => visibleWidth(cellValue(row[column.key]))),
117
117
  );
118
118
 
119
119
  const maxWidth = columnMaxWidth(column);
@@ -151,24 +151,38 @@ function columnMaxWidth(column) {
151
151
  * @param {number} width
152
152
  * Column width.
153
153
  * @returns {string[]}
154
- * Lines the cell renders as; truncated to one line with a trailing
155
- * ellipsis unless the column's overflow is 'wrap'.
154
+ * Lines the cell renders as, each no wider on screen than the column
155
+ * unless one character is wider than the whole column. A value that does
156
+ * not fit is cut to one line with a trailing ellipsis, or split across
157
+ * lines when the column's overflow is 'wrap'. Coloured text keeps its
158
+ * colour on every line.
156
159
  */
157
160
  function columnLines(value, column, width) {
158
- if (value.length <= width) {
161
+ if (visibleWidth(value) <= width) {
159
162
  return [value];
160
163
  }
161
164
 
162
165
  if (column.overflow === "wrap") {
163
- return Array.from(
164
- {
165
- length: Math.ceil(value.length / width),
166
- },
167
- (_, index) => value.slice(index * width, (index + 1) * width),
168
- );
166
+ const lines = [];
167
+
168
+ let remaining = value;
169
+
170
+ while (remaining !== "") {
171
+ let { head, tail } = sliceVisible(remaining, width);
172
+
173
+ if (visibleWidth(head) === 0 && tail !== "") {
174
+ // In a one-column cell, a two-column character gets a line of its own so wrapping always moves forward.
175
+ ({ head, tail } = sliceVisible(remaining, 2));
176
+ }
177
+
178
+ lines.push(head);
179
+ remaining = tail;
180
+ }
181
+
182
+ return lines;
169
183
  }
170
184
 
171
- return [`${value.slice(0, width - 1)}…`];
185
+ return [`${sliceVisible(value, width - 1).head}…`];
172
186
  }
173
187
 
174
188
  /**
@@ -242,7 +256,7 @@ function renderTable(columns, rows, widths, options) {
242
256
  * Padded column text.
243
257
  */
244
258
  function padColumn(value, width, index, columnCount) {
245
- return index === columnCount - 1 ? value : value.padEnd(width, " ");
259
+ return index === columnCount - 1 ? value : padVisible(value, width);
246
260
  }
247
261
 
248
262
  /**
@@ -258,13 +272,13 @@ function padColumn(value, width, index, columnCount) {
258
272
  * Rendered key/value blocks.
259
273
  */
260
274
  function renderBlocks(columns, rows, options) {
261
- const labelWidth = Math.max(...columns.map((column) => column.label.length));
275
+ const labelWidth = Math.max(...columns.map((column) => visibleWidth(column.label)));
262
276
 
263
277
  return rows
264
278
  .map((row) =>
265
279
  columns
266
280
  .map((column) => {
267
- const label = renderHeader(column.label.padEnd(labelWidth, " "), options);
281
+ const label = renderHeader(padVisible(column.label, labelWidth), options);
268
282
  const value = renderCell(cellValue(row[column.key]), 0, options);
269
283
 
270
284
  return `${label}${columnSeparator}${value}`;