@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.
- package/CHANGELOG.md +25 -1
- package/README.md +22 -1
- package/adapters/python/README.md +14 -0
- package/adapters/python/cli_style/__init__.py +2 -0
- package/adapters/python/cli_style/_wrappers.py +55 -1
- package/adapters/python/pyproject.toml +1 -1
- package/adapters/swift/Sources/CliStyle/Wrappers.swift +47 -0
- package/package.json +4 -1
- package/scripts/generate-adapters.js +25 -4
- package/src/catalogue/renderer-catalogue.js +44 -4
- package/src/cli/render-command.js +2 -0
- package/src/formatters/ansi.js +187 -2
- package/src/formatters/wrap.js +24 -18
- package/src/gallery/render-gallery.js +97 -13
- package/src/index.js +1 -0
- package/src/patterns/quote-block.js +70 -0
- package/src/primitives/bar-chart.js +3 -3
- package/src/primitives/chip.js +10 -2
- package/src/primitives/divider.js +2 -2
- package/src/primitives/feedback.js +8 -2
- package/src/primitives/labelled-line.js +20 -10
- package/src/primitives/panel.js +20 -5
- package/src/primitives/row-group.js +8 -6
- package/src/primitives/row.js +5 -5
- package/src/primitives/span.js +12 -3
- package/src/primitives/status.js +8 -2
- package/src/primitives/table.js +30 -16
package/src/formatters/ansi.js
CHANGED
|
@@ -1,11 +1,42 @@
|
|
|
1
|
+
import { eastAsianWidth } from "get-east-asian-width";
|
|
2
|
+
|
|
1
3
|
import { resolveColourValue } from "../theme/colours.js";
|
|
2
4
|
|
|
3
5
|
// ANSI reset sequence used after coloured output.
|
|
4
6
|
const reset = "\u001b[0m";
|
|
5
7
|
|
|
6
|
-
//
|
|
8
|
+
// The escape character that starts every ANSI sequence.
|
|
7
9
|
const escapeCharacter = String.fromCharCode(27);
|
|
8
|
-
|
|
10
|
+
|
|
11
|
+
// The bell character that can end an OSC sequence, such as a terminal hyperlink.
|
|
12
|
+
const bellCharacter = String.fromCharCode(7);
|
|
13
|
+
|
|
14
|
+
// Matches ANSI CSI sequences, such as colours, and OSC sequences, such as hyperlinks, which take up no columns on screen.
|
|
15
|
+
export const ansiPattern = new RegExp(
|
|
16
|
+
`${escapeCharacter}(?:\\[[0-?]*[ -/]*[@-~]|\\][^${bellCharacter}${escapeCharacter}]*(?:${bellCharacter}|${escapeCharacter}\\\\))`,
|
|
17
|
+
"gu",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// Matches the same sequences as a captured group, so splitting text on it keeps each sequence as its own piece.
|
|
21
|
+
const ansiSplitPattern = new RegExp(`(${ansiPattern.source})`, "u");
|
|
22
|
+
|
|
23
|
+
// Matches a terminal link sequence and captures its address, which is empty when the sequence ends a link.
|
|
24
|
+
const hyperlinkPattern = new RegExp(
|
|
25
|
+
`^${escapeCharacter}\\]8;[^;]*;([^${bellCharacter}${escapeCharacter}]*)`,
|
|
26
|
+
"u",
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// The sequences that clear every colour and text style at once.
|
|
30
|
+
const clearStyleCodes = [reset, "\u001b[m"];
|
|
31
|
+
|
|
32
|
+
// Splits text into the characters people see, so a joined emoji or an accented letter counts as one.
|
|
33
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
34
|
+
|
|
35
|
+
// Matches emoji presentation characters or the selector that requests emoji presentation.
|
|
36
|
+
const emojiPattern = /[\p{Emoji_Presentation}\uFE0F]/u;
|
|
37
|
+
|
|
38
|
+
// Matches a cluster made only of combining marks or invisible characters, such as a lone joiner, which take no columns.
|
|
39
|
+
const zeroWidthPattern = /^[\p{Mark}\p{Default_Ignorable_Code_Point}]+$/u;
|
|
9
40
|
|
|
10
41
|
// ANSI style sequences supported before primitive renderers exist.
|
|
11
42
|
const styleCodes = {
|
|
@@ -73,6 +104,36 @@ export function style(value, style, options = {}) {
|
|
|
73
104
|
return `${styleCode}${value}${reset}`;
|
|
74
105
|
}
|
|
75
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Switch the given styles back on after every full reset inside text, so an outer style
|
|
109
|
+
* carries on past coloured text placed inside it. Coloured text ends with a full reset,
|
|
110
|
+
* written as `\u001b[0m` or the shorter `\u001b[m`, which would otherwise turn the outer
|
|
111
|
+
* style off as well.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} value
|
|
114
|
+
* Text that may hold coloured parts.
|
|
115
|
+
* @param {string[]} stylesToRestore
|
|
116
|
+
* The styles to switch back on, each rendered on an empty string, such as
|
|
117
|
+
* `style("", "bold", options)`. An empty entry adds nothing.
|
|
118
|
+
* @returns {string}
|
|
119
|
+
* The text with the styles switched back on after each full reset.
|
|
120
|
+
*/
|
|
121
|
+
export function restoreStyles(value, stylesToRestore) {
|
|
122
|
+
// The codes that turn the styles on, with each closing reset cut off.
|
|
123
|
+
const openingCodes = stylesToRestore
|
|
124
|
+
.map((styleToRestore) => styleToRestore.slice(0, -reset.length))
|
|
125
|
+
.join("");
|
|
126
|
+
|
|
127
|
+
// The text with the styles switched back on after each reset form handled so far.
|
|
128
|
+
let restoredValue = value;
|
|
129
|
+
|
|
130
|
+
for (const clearStyleCode of clearStyleCodes) {
|
|
131
|
+
restoredValue = restoredValue.replaceAll(clearStyleCode, `${clearStyleCode}${openingCodes}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return restoredValue;
|
|
135
|
+
}
|
|
136
|
+
|
|
76
137
|
/**
|
|
77
138
|
* Remove ANSI escape sequences from text.
|
|
78
139
|
*
|
|
@@ -85,6 +146,130 @@ export function stripAnsi(value) {
|
|
|
85
146
|
return value.replace(ansiPattern, "");
|
|
86
147
|
}
|
|
87
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Count the terminal columns that text fills on screen. ANSI sequences take no
|
|
151
|
+
* columns, and East Asian wide characters and emoji take two.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} value
|
|
154
|
+
* The text to measure, which may contain ANSI sequences.
|
|
155
|
+
* @returns {number}
|
|
156
|
+
* The number of columns the text fills.
|
|
157
|
+
*/
|
|
158
|
+
export function visibleWidth(value) {
|
|
159
|
+
let width = 0;
|
|
160
|
+
|
|
161
|
+
for (const { segment } of graphemeSegmenter.segment(stripAnsi(value))) {
|
|
162
|
+
if (zeroWidthPattern.test(segment)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (eastAsianWidth(segment.codePointAt(0)) === 2 || emojiPattern.test(segment)) {
|
|
167
|
+
width += 2;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
width += 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return width;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Add trailing spaces until text fills a number of terminal columns. Colour
|
|
179
|
+
* codes take no space and wide characters take two, so coloured and wide text
|
|
180
|
+
* pads to the same width as plain text.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} value
|
|
183
|
+
* The text to pad, which may contain ANSI colour codes.
|
|
184
|
+
* @param {number} width
|
|
185
|
+
* The number of columns the text should fill.
|
|
186
|
+
* @returns {string}
|
|
187
|
+
* The padded text, or the text unchanged when it already fills the width.
|
|
188
|
+
*/
|
|
189
|
+
export function padVisible(value, width) {
|
|
190
|
+
return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Split text at a terminal column without breaking a colour code, a link or a
|
|
195
|
+
* character. A character that would cross the column, such as a two-column 界
|
|
196
|
+
* at the edge, starts the remaining text instead.
|
|
197
|
+
*
|
|
198
|
+
* When the cut falls inside coloured or linked text, the first piece ends by
|
|
199
|
+
* closing the colour and the link, and the remaining text starts by reopening
|
|
200
|
+
* them, so each piece can be printed on a line of its own.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} value
|
|
203
|
+
* The text to split, which may contain ANSI colour codes and terminal links.
|
|
204
|
+
* @param {number} width
|
|
205
|
+
* The most columns the first piece may fill.
|
|
206
|
+
* @returns {{ head: string, tail: string }}
|
|
207
|
+
* The first piece and the remaining text, which is empty when the whole
|
|
208
|
+
* value fits.
|
|
209
|
+
*/
|
|
210
|
+
export function sliceVisible(value, width) {
|
|
211
|
+
// Splitting on a captured pattern puts plain text at even indexes and ANSI sequences at odd ones.
|
|
212
|
+
const parts = value.split(ansiSplitPattern);
|
|
213
|
+
|
|
214
|
+
// The link open at the current position, with the sequences that close and reopen it.
|
|
215
|
+
let activeLink = null;
|
|
216
|
+
// The colour and style codes in effect at the current position, in the order they were set.
|
|
217
|
+
let activeStyles = [];
|
|
218
|
+
// The columns filled so far.
|
|
219
|
+
let columns = 0;
|
|
220
|
+
// The position in the original value reached so far.
|
|
221
|
+
let cursor = 0;
|
|
222
|
+
|
|
223
|
+
for (const [index, part] of parts.entries()) {
|
|
224
|
+
if (index % 2 === 0) {
|
|
225
|
+
for (const { segment } of graphemeSegmenter.segment(part)) {
|
|
226
|
+
const segmentWidth = visibleWidth(segment);
|
|
227
|
+
|
|
228
|
+
if (columns + segmentWidth > width) {
|
|
229
|
+
const head =
|
|
230
|
+
value.slice(0, cursor) +
|
|
231
|
+
(activeLink?.closer ?? "") +
|
|
232
|
+
(activeStyles.length > 0 ? reset : "");
|
|
233
|
+
|
|
234
|
+
const tail = activeStyles.join("") + (activeLink?.opener ?? "") + value.slice(cursor);
|
|
235
|
+
|
|
236
|
+
return { head, tail };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
columns += segmentWidth;
|
|
240
|
+
cursor += segment.length;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (clearStyleCodes.includes(part)) {
|
|
247
|
+
activeStyles = [];
|
|
248
|
+
} else if (part.startsWith(`${escapeCharacter}[`) && part.endsWith("m")) {
|
|
249
|
+
activeStyles.push(part);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const hyperlink = part.match(hyperlinkPattern);
|
|
253
|
+
|
|
254
|
+
if (hyperlink !== null) {
|
|
255
|
+
// The closing sequence ends the same way as the opening one, with a bell or an escape and backslash.
|
|
256
|
+
const terminator = part.endsWith(bellCharacter) ? bellCharacter : `${escapeCharacter}\\`;
|
|
257
|
+
|
|
258
|
+
activeLink =
|
|
259
|
+
hyperlink[1] === ""
|
|
260
|
+
? null
|
|
261
|
+
: {
|
|
262
|
+
closer: `${escapeCharacter}]8;;${terminator}`,
|
|
263
|
+
opener: part,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
cursor += part.length;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { head: value, tail: "" };
|
|
271
|
+
}
|
|
272
|
+
|
|
88
273
|
/**
|
|
89
274
|
* Wrap text in ANSI colour codes.
|
|
90
275
|
*
|
package/src/formatters/wrap.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import { ansiPattern, visibleWidth } from "./ansi.js";
|
|
2
|
+
|
|
1
3
|
// Width used when a caller doesn't provide one.
|
|
2
4
|
const defaultWrapWidth = 64;
|
|
3
|
-
const escapeCharacter = String.fromCharCode(27);
|
|
4
|
-
const bellCharacter = String.fromCharCode(7);
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
// Splits text into the characters people see, so wrapping never breaks a joined emoji apart.
|
|
7
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
8
|
+
|
|
9
|
+
// Line breaks end the current wrapped line and take no columns. Windows CR+LF arrives from the
|
|
10
|
+
// segmenter as one character.
|
|
11
|
+
const lineBreaks = new Set(["\n", "\r\n"]);
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Wrap text to a maximum visible width, treating ANSI escape sequences as zero-width.
|
|
@@ -27,7 +28,7 @@ export function wrapText(text, width = defaultWrapWidth) {
|
|
|
27
28
|
let line = [];
|
|
28
29
|
|
|
29
30
|
for (const token of tokens) {
|
|
30
|
-
if (token.value
|
|
31
|
+
if (lineBreaks.has(token.value)) {
|
|
31
32
|
lines.push(renderTokens(line));
|
|
32
33
|
line = [];
|
|
33
34
|
continue;
|
|
@@ -35,7 +36,7 @@ export function wrapText(text, width = defaultWrapWidth) {
|
|
|
35
36
|
|
|
36
37
|
line.push(token);
|
|
37
38
|
|
|
38
|
-
while (
|
|
39
|
+
while (tokensWidth(line) > wrapWidth) {
|
|
39
40
|
const whitespaceIndex = findLastWhitespace(line);
|
|
40
41
|
|
|
41
42
|
if (whitespaceIndex > 0) {
|
|
@@ -77,7 +78,7 @@ function resolveWrapWidth(width) {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/**
|
|
80
|
-
* Split text into
|
|
81
|
+
* Split text into the characters people see and ANSI escape sequences.
|
|
81
82
|
*
|
|
82
83
|
* @param {string} text
|
|
83
84
|
* Text to tokenise.
|
|
@@ -89,7 +90,7 @@ function tokenise(text) {
|
|
|
89
90
|
|
|
90
91
|
let textStart = 0;
|
|
91
92
|
|
|
92
|
-
for (const match of text.matchAll(
|
|
93
|
+
for (const match of text.matchAll(ansiPattern)) {
|
|
93
94
|
appendTextTokens(tokens, text.slice(textStart, match.index));
|
|
94
95
|
tokens.push({
|
|
95
96
|
value: match[0],
|
|
@@ -104,7 +105,9 @@ function tokenise(text) {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
|
-
* Append one token per character of plain text to a token list
|
|
108
|
+
* Append one token per visible character of plain text to a token list, keeping
|
|
109
|
+
* joined emoji and accented letters whole. Line breaks, including Windows CR+LF,
|
|
110
|
+
* take no columns.
|
|
108
111
|
*
|
|
109
112
|
* @param {object[]} tokens
|
|
110
113
|
* Token list to append to.
|
|
@@ -113,10 +116,10 @@ function tokenise(text) {
|
|
|
113
116
|
* @returns {void}
|
|
114
117
|
*/
|
|
115
118
|
function appendTextTokens(tokens, text) {
|
|
116
|
-
for (const value of text) {
|
|
119
|
+
for (const { segment: value } of graphemeSegmenter.segment(text)) {
|
|
117
120
|
tokens.push({
|
|
118
121
|
value,
|
|
119
|
-
visibleWidth: value
|
|
122
|
+
visibleWidth: lineBreaks.has(value) ? 0 : visibleWidth(value),
|
|
120
123
|
});
|
|
121
124
|
}
|
|
122
125
|
}
|
|
@@ -129,7 +132,7 @@ function appendTextTokens(tokens, text) {
|
|
|
129
132
|
* @returns {number}
|
|
130
133
|
* Total visible width.
|
|
131
134
|
*/
|
|
132
|
-
function
|
|
135
|
+
function tokensWidth(tokens) {
|
|
133
136
|
return tokens.reduce((width, token) => width + token.visibleWidth, 0);
|
|
134
137
|
}
|
|
135
138
|
|
|
@@ -155,6 +158,7 @@ function findLastWhitespace(tokens) {
|
|
|
155
158
|
|
|
156
159
|
/**
|
|
157
160
|
* Find the index to split at when a line has no whitespace to break on.
|
|
161
|
+
* A single character wider than the limit stays whole on its own line.
|
|
158
162
|
*
|
|
159
163
|
* @param {object[]} tokens
|
|
160
164
|
* Tokens to search.
|
|
@@ -167,11 +171,13 @@ function findHardSplitIndex(tokens, width) {
|
|
|
167
171
|
let currentWidth = 0;
|
|
168
172
|
|
|
169
173
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
170
|
-
currentWidth
|
|
174
|
+
const nextWidth = currentWidth + tokens[index].visibleWidth;
|
|
171
175
|
|
|
172
|
-
if (currentWidth
|
|
173
|
-
return index
|
|
176
|
+
if (nextWidth > width && currentWidth > 0) {
|
|
177
|
+
return index;
|
|
174
178
|
}
|
|
179
|
+
|
|
180
|
+
currentWidth = nextWidth;
|
|
175
181
|
}
|
|
176
182
|
|
|
177
183
|
return tokens.length;
|
|
@@ -6,8 +6,9 @@ import { confirmationResult } from "../patterns/confirmation-result.js";
|
|
|
6
6
|
import { diagnosticReport } from "../patterns/diagnostic-report.js";
|
|
7
7
|
import { diffBlock } from "../patterns/diff-block.js";
|
|
8
8
|
import { nextStepBlock } from "../patterns/next-step-block.js";
|
|
9
|
+
import { quoteBlock } from "../patterns/quote-block.js";
|
|
9
10
|
import { taskSummary } from "../patterns/task-summary.js";
|
|
10
|
-
import {
|
|
11
|
+
import { foreground, sliceVisible, visibleWidth } from "../formatters/ansi.js";
|
|
11
12
|
import { renderSpinnerFrame } from "../interactive/spinner.js";
|
|
12
13
|
import { barChart } from "../primitives/bar-chart.js";
|
|
13
14
|
import { bulletList } from "../primitives/bullet-list.js";
|
|
@@ -133,6 +134,7 @@ export const galleryFixtures = [
|
|
|
133
134
|
"confirmation-result",
|
|
134
135
|
"next-step-block",
|
|
135
136
|
"diff-block",
|
|
137
|
+
"quote-block",
|
|
136
138
|
"sparkline",
|
|
137
139
|
"spinner",
|
|
138
140
|
];
|
|
@@ -301,27 +303,26 @@ function clampGalleryOutput(output, width) {
|
|
|
301
303
|
}
|
|
302
304
|
|
|
303
305
|
/**
|
|
304
|
-
*
|
|
306
|
+
* Cut one gallery line to the gallery width, measured in screen columns.
|
|
305
307
|
*
|
|
306
308
|
* @param {string} line
|
|
307
|
-
*
|
|
309
|
+
* The output line, which may contain colour codes and wide characters.
|
|
308
310
|
* @param {number} width
|
|
309
|
-
*
|
|
311
|
+
* The most columns the line may fill.
|
|
310
312
|
* @returns {string}
|
|
311
|
-
*
|
|
313
|
+
* The line unchanged when it fits, or cut with a trailing `~` that marks
|
|
314
|
+
* the cut. Colour codes and links stay whole and are closed before the `~`.
|
|
312
315
|
*/
|
|
313
316
|
function clampLine(line, width) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (plainLine.length <= width) {
|
|
317
|
+
if (visibleWidth(line) <= width) {
|
|
317
318
|
return line;
|
|
318
319
|
}
|
|
319
320
|
|
|
320
321
|
if (width <= 1) {
|
|
321
|
-
return
|
|
322
|
+
return sliceVisible(line, width).head;
|
|
322
323
|
}
|
|
323
324
|
|
|
324
|
-
return `${
|
|
325
|
+
return `${sliceVisible(line, width - 1).head}~`;
|
|
325
326
|
}
|
|
326
327
|
|
|
327
328
|
/**
|
|
@@ -600,6 +601,19 @@ function renderPatterns(options, fixture) {
|
|
|
600
601
|
"diagnostic-report": ["Diagnostic report", report],
|
|
601
602
|
"diff-block": ["Diff block", diff],
|
|
602
603
|
"next-step-block": ["Next-step block", nextStep],
|
|
604
|
+
"quote-block": [
|
|
605
|
+
"Quote block",
|
|
606
|
+
quoteBlock(
|
|
607
|
+
{
|
|
608
|
+
lines: [
|
|
609
|
+
"A command can return a long explanation that wraps while the marker stays beside each continuation line.",
|
|
610
|
+
"A second line keeps the same marker.",
|
|
611
|
+
],
|
|
612
|
+
title: "Command output",
|
|
613
|
+
},
|
|
614
|
+
options,
|
|
615
|
+
),
|
|
616
|
+
],
|
|
603
617
|
reporter: ["Reporter", reporterOutput],
|
|
604
618
|
"task-summary": ["Task summary", summary],
|
|
605
619
|
};
|
|
@@ -635,6 +649,9 @@ function renderPatterns(options, fixture) {
|
|
|
635
649
|
"Diff block",
|
|
636
650
|
frameExample(diff, options),
|
|
637
651
|
"",
|
|
652
|
+
"Quote block",
|
|
653
|
+
frameExample(fixtures["quote-block"][1], options),
|
|
654
|
+
"",
|
|
638
655
|
"Compact data table",
|
|
639
656
|
frameExample(compactTable, options),
|
|
640
657
|
"",
|
|
@@ -742,9 +759,21 @@ function renderPrimitives(options) {
|
|
|
742
759
|
"",
|
|
743
760
|
"Chips",
|
|
744
761
|
renderChipExamples(options),
|
|
762
|
+
chip(`${foreground("coloured", "danger", options)} label`, "success", options),
|
|
745
763
|
"",
|
|
746
764
|
"Statuses",
|
|
747
765
|
...renderStatusExamples(options),
|
|
766
|
+
status(resultTypes.SUCCESS, "caller-coloured label", {
|
|
767
|
+
...options,
|
|
768
|
+
label: `${foreground("Coloured", "danger", options)} status`,
|
|
769
|
+
}),
|
|
770
|
+
"",
|
|
771
|
+
"Dividers",
|
|
772
|
+
divider({
|
|
773
|
+
...options,
|
|
774
|
+
dividerWidth: 32,
|
|
775
|
+
label: foreground("界面", "info", options),
|
|
776
|
+
}),
|
|
748
777
|
"",
|
|
749
778
|
"Panels",
|
|
750
779
|
...renderPanelExamples(options),
|
|
@@ -786,6 +815,15 @@ function renderPrimitives(options) {
|
|
|
786
815
|
icon: "!",
|
|
787
816
|
tone: "warning",
|
|
788
817
|
}),
|
|
818
|
+
labelledLine(
|
|
819
|
+
foreground("界面", "info", options),
|
|
820
|
+
"Coloured wide labels keep wrapped text aligned.",
|
|
821
|
+
{
|
|
822
|
+
...options,
|
|
823
|
+
icon: foreground("界", "warning", options),
|
|
824
|
+
width: 32,
|
|
825
|
+
},
|
|
826
|
+
),
|
|
789
827
|
emptyState("No results", "tone: muted", options),
|
|
790
828
|
errorBlock("Failed", ["tone: danger"], {
|
|
791
829
|
...options,
|
|
@@ -818,6 +856,16 @@ function renderPrimitives(options) {
|
|
|
818
856
|
...options,
|
|
819
857
|
labelWidth: 8,
|
|
820
858
|
}),
|
|
859
|
+
row(
|
|
860
|
+
foreground("界面", "info", options),
|
|
861
|
+
"Coloured wide labels and separators align wrapped text.",
|
|
862
|
+
{
|
|
863
|
+
...options,
|
|
864
|
+
labelWidth: 8,
|
|
865
|
+
separator: foreground(" => ", "warning", options),
|
|
866
|
+
width: 36,
|
|
867
|
+
},
|
|
868
|
+
),
|
|
821
869
|
row("Bundle", "is 22.3 KB, above the 12.0 KB budget", {
|
|
822
870
|
...options,
|
|
823
871
|
labelWidth: 8,
|
|
@@ -861,6 +909,10 @@ function renderPrimitives(options) {
|
|
|
861
909
|
label: "Checks",
|
|
862
910
|
value: "type-check, unit, build",
|
|
863
911
|
},
|
|
912
|
+
{
|
|
913
|
+
label: foreground("界面", "info", options),
|
|
914
|
+
value: "Coloured wide labels share the value column",
|
|
915
|
+
},
|
|
864
916
|
{
|
|
865
917
|
label: "Bundle",
|
|
866
918
|
result: resultTypes.FAILED,
|
|
@@ -886,6 +938,10 @@ function renderPrimitives(options) {
|
|
|
886
938
|
function renderBulletListExamples(options) {
|
|
887
939
|
return [
|
|
888
940
|
bulletList(["Install dependencies", "Run the unit tests", "Review the output"], options),
|
|
941
|
+
bulletList([foreground("界面 item wraps beside its bullet", "info", options)], {
|
|
942
|
+
...options,
|
|
943
|
+
width: 18,
|
|
944
|
+
}),
|
|
889
945
|
bulletList(["This item wraps so continuation lines stay aligned with the item text."], {
|
|
890
946
|
...options,
|
|
891
947
|
width: 40,
|
|
@@ -941,6 +997,10 @@ function renderTableExample(options) {
|
|
|
941
997
|
check: "unit",
|
|
942
998
|
result: "warning",
|
|
943
999
|
},
|
|
1000
|
+
{
|
|
1001
|
+
check: "界面",
|
|
1002
|
+
result: foreground("passed", "success", options),
|
|
1003
|
+
},
|
|
944
1004
|
],
|
|
945
1005
|
});
|
|
946
1006
|
}
|
|
@@ -972,7 +1032,7 @@ function renderOverflowTableExample(options, overflow) {
|
|
|
972
1032
|
],
|
|
973
1033
|
rows: [
|
|
974
1034
|
{
|
|
975
|
-
detail: "packages/cli-style/src/primitives/table.js",
|
|
1035
|
+
detail: foreground("界面/packages/cli-style/src/primitives/table.js", "success", options),
|
|
976
1036
|
source: "Table",
|
|
977
1037
|
},
|
|
978
1038
|
],
|
|
@@ -991,6 +1051,10 @@ function renderBarChartExample(options) {
|
|
|
991
1051
|
return barChart({
|
|
992
1052
|
...options,
|
|
993
1053
|
rows: [
|
|
1054
|
+
{
|
|
1055
|
+
label: foreground("界面", "info", options),
|
|
1056
|
+
value: 8,
|
|
1057
|
+
},
|
|
994
1058
|
{
|
|
995
1059
|
label: "positive",
|
|
996
1060
|
value: 10,
|
|
@@ -1097,7 +1161,7 @@ function renderProgressExamples(options) {
|
|
|
1097
1161
|
}
|
|
1098
1162
|
|
|
1099
1163
|
/**
|
|
1100
|
-
* Render
|
|
1164
|
+
* Render one panel for each tone, then a panel holding coloured output.
|
|
1101
1165
|
*
|
|
1102
1166
|
* @param {object} options
|
|
1103
1167
|
* Rendering options.
|
|
@@ -1105,7 +1169,8 @@ function renderProgressExamples(options) {
|
|
|
1105
1169
|
* Panel examples.
|
|
1106
1170
|
*/
|
|
1107
1171
|
function renderPanelExamples(options) {
|
|
1108
|
-
|
|
1172
|
+
// One panel for each tone.
|
|
1173
|
+
const toneExamples = panelToneExamples.map((tone) =>
|
|
1109
1174
|
panel({
|
|
1110
1175
|
...options,
|
|
1111
1176
|
lines: [`tone: ${tone}`],
|
|
@@ -1113,6 +1178,25 @@ function renderPanelExamples(options) {
|
|
|
1113
1178
|
tone,
|
|
1114
1179
|
}),
|
|
1115
1180
|
);
|
|
1181
|
+
|
|
1182
|
+
// A panel holding a coloured status line and coloured values, to show the panel
|
|
1183
|
+
// background carrying on after them.
|
|
1184
|
+
const nestedStylesExample = panel({
|
|
1185
|
+
...options,
|
|
1186
|
+
lines: [
|
|
1187
|
+
status(resultTypes.SUCCESS, "Checks passed", options),
|
|
1188
|
+
rowGroup({
|
|
1189
|
+
...options,
|
|
1190
|
+
rows: [
|
|
1191
|
+
{ label: "Source", value: "main", valueColour: "success" },
|
|
1192
|
+
{ label: "Checks", value: "unit", valueColour: "success" },
|
|
1193
|
+
],
|
|
1194
|
+
}),
|
|
1195
|
+
],
|
|
1196
|
+
title: "Nested styles",
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
return [...toneExamples, nestedStylesExample];
|
|
1116
1200
|
}
|
|
1117
1201
|
|
|
1118
1202
|
/**
|
package/src/index.js
CHANGED
|
@@ -38,6 +38,7 @@ export { confirmationResult } from "./patterns/confirmation-result.js";
|
|
|
38
38
|
export { diagnosticReport } from "./patterns/diagnostic-report.js";
|
|
39
39
|
export { diffBlock } from "./patterns/diff-block.js";
|
|
40
40
|
export { nextStepBlock } from "./patterns/next-step-block.js";
|
|
41
|
+
export { quoteBlock } from "./patterns/quote-block.js";
|
|
41
42
|
export { taskSummary } from "./patterns/task-summary.js";
|
|
42
43
|
export { barChart } from "./primitives/bar-chart.js";
|
|
43
44
|
export { bulletList } from "./primitives/bullet-list.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { foreground, stripAnsi } from "../formatters/ansi.js";
|
|
2
|
+
import { wrapText } from "../formatters/wrap.js";
|
|
3
|
+
import { profiles } from "../profiles/profiles.js";
|
|
4
|
+
import { isNonEmptyString, isRecord, renderSection } from "./helpers.js";
|
|
5
|
+
|
|
6
|
+
// The total width, including the marker and its space, used when no width is given.
|
|
7
|
+
const defaultWidth = 66;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Render another program's output with a marker beside each line, under an optional heading.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} quote
|
|
13
|
+
* Quoted output to show.
|
|
14
|
+
* @param {string[]} quote.lines
|
|
15
|
+
* Output lines, including any blank lines to preserve.
|
|
16
|
+
* @param {string} quote.title
|
|
17
|
+
* Optional heading above the output.
|
|
18
|
+
* @param {number} quote.width
|
|
19
|
+
* Optional total width, including the marker and its space.
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* Rendering options, such as the profile, Unicode support, and a fallback width.
|
|
22
|
+
* @returns {object|string}
|
|
23
|
+
* The quote unchanged for the json profile. Otherwise the marked lines as one string,
|
|
24
|
+
* or an empty string when there are no lines to show.
|
|
25
|
+
*/
|
|
26
|
+
export function quoteBlock(quote, options = {}) {
|
|
27
|
+
if (options.profile === profiles.JSON) {
|
|
28
|
+
return quote;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!isRecord(quote) || !Array.isArray(quote.lines)) {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Lines that are not strings are skipped so one bad entry does not hide the rest.
|
|
36
|
+
const lines = quote.lines.filter((line) => typeof line === "string");
|
|
37
|
+
|
|
38
|
+
if (lines.length === 0) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The width from the quote wins over the options, and an invalid width falls back to the default.
|
|
43
|
+
const requestedWidth = quote.width ?? options.width ?? defaultWidth;
|
|
44
|
+
|
|
45
|
+
const width =
|
|
46
|
+
Number.isInteger(requestedWidth) && requestedWidth > 0 ? requestedWidth : defaultWidth;
|
|
47
|
+
|
|
48
|
+
// The room left for text once the marker and its space are drawn.
|
|
49
|
+
const contentWidth = Math.max(width - 2, 1);
|
|
50
|
+
|
|
51
|
+
// Plain and CI output are written for logs and searches, so they get no colour and the ASCII marker.
|
|
52
|
+
const isPlainOutput = options.profile === profiles.CI || options.profile === profiles.PLAIN;
|
|
53
|
+
|
|
54
|
+
// The ASCII marker is also used whenever Unicode is off.
|
|
55
|
+
const marker = options.unicode === false || isPlainOutput ? "|" : "│";
|
|
56
|
+
|
|
57
|
+
// The marker is muted wherever colour is allowed.
|
|
58
|
+
const renderedMarker = isPlainOutput ? marker : foreground(marker, "muted", options);
|
|
59
|
+
|
|
60
|
+
// Each wrapped part gets its own marker, so continuation lines stay visibly quoted.
|
|
61
|
+
const renderedLines = lines.flatMap((line) =>
|
|
62
|
+
wrapText(stripAnsi(line), contentWidth).map((part) => `${renderedMarker} ${part}`),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (!isNonEmptyString(quote.title)) {
|
|
66
|
+
return renderedLines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return renderSection(stripAnsi(quote.title), renderedLines, options, false);
|
|
70
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { foreground } from "../formatters/ansi.js";
|
|
1
|
+
import { foreground, padVisible, visibleWidth } from "../formatters/ansi.js";
|
|
2
2
|
import { chartColours } from "../theme/colours.js";
|
|
3
3
|
|
|
4
4
|
// Chart bars use the same compact default width as progress bars.
|
|
@@ -27,7 +27,7 @@ export function barChart(options = {}) {
|
|
|
27
27
|
return "";
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
const labelWidth = Math.max(...rows.map((row) => row.label
|
|
30
|
+
const labelWidth = Math.max(...rows.map((row) => visibleWidth(row.label)));
|
|
31
31
|
const maximum = Math.max(...rows.map((row) => Math.abs(row.value)), 1);
|
|
32
32
|
const width = normaliseWidth(options.barWidth);
|
|
33
33
|
|
|
@@ -93,7 +93,7 @@ function renderRow(row, labelWidth, maximum, width, options) {
|
|
|
93
93
|
const barWidth = Math.round((Math.abs(row.value) / maximum) * width);
|
|
94
94
|
const symbol = options.unicode === false ? "#" : "█";
|
|
95
95
|
const bar = symbol.repeat(barWidth).padEnd(width, " ");
|
|
96
|
-
const label = row.label
|
|
96
|
+
const label = padVisible(row.label, labelWidth);
|
|
97
97
|
|
|
98
98
|
if (options.colour !== true) {
|
|
99
99
|
return `${label} ${bar} ${row.value}`;
|
package/src/primitives/chip.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { background, foreground, style } from "../formatters/ansi.js";
|
|
1
|
+
import { background, foreground, restoreStyles, style } from "../formatters/ansi.js";
|
|
2
2
|
import { chipColours } from "../theme/colours.js";
|
|
3
3
|
|
|
4
4
|
// Chip style used when the requested tone is not configured.
|
|
@@ -25,7 +25,15 @@ export function chip(label, tone = fallbackTone, options = {}) {
|
|
|
25
25
|
return `[${label}]`;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// The label with the chip's fill, bold and tone switched back on after any coloured text,
|
|
29
|
+
// so the rest of the chip keeps its look.
|
|
30
|
+
const restoredLabel = restoreStyles(label, [
|
|
31
|
+
background("", token.background, options),
|
|
32
|
+
style("", "bold", options),
|
|
33
|
+
foreground("", token.foreground, options),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const paddedLabel = ` ${restoredLabel} `;
|
|
29
37
|
const colouredLabel = foreground(paddedLabel, token.foreground, options);
|
|
30
38
|
const boldLabel = style(colouredLabel, "bold", options);
|
|
31
39
|
|