pixl-cli 1.0.22 → 1.1.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.
Files changed (5) hide show
  1. package/cli.js +19 -25
  2. package/package.json +2 -7
  3. package/util.js +39 -0
  4. package/width.js +116 -0
  5. package/wrap.js +122 -0
package/cli.js CHANGED
@@ -6,10 +6,14 @@ var fs = require('fs');
6
6
  var readline = require('readline');
7
7
  var path = require('path');
8
8
  var chalk = require('chalk');
9
- var stringWidth = require('string-width');
10
- var widestLine = require('widest-line');
11
- var repeating = require('repeating');
12
- var wordWrap = require('word-wrap');
9
+
10
+ var Util = require('./util');
11
+ var Width = require('./width');
12
+ var wordWrap = require('./wrap');
13
+
14
+ var ansiPattern = Util.ansiPattern;
15
+ var stringWidth = Width.stringWidth;
16
+ var widestLine = Width.widestLine;
13
17
 
14
18
  var Tools = require('pixl-tools');
15
19
  var Args = require('pixl-args');
@@ -20,7 +24,7 @@ var cli = module.exports = {
20
24
  // CLI args hash
21
25
  args: args.get(),
22
26
 
23
- // expose some 3rd party utilities
27
+ // expose some utility functions and third-party modules
24
28
  chalk: chalk,
25
29
  stringWidth: stringWidth,
26
30
  widestLine: widestLine,
@@ -28,10 +32,7 @@ var cli = module.exports = {
28
32
  Tools: Tools,
29
33
 
30
34
  // for stripping colors:
31
- ansiPattern: new RegExp([
32
- '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
33
- '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))'
34
- ].join('|'), 'g'),
35
+ ansiPattern: ansiPattern,
35
36
 
36
37
  mapArgs: function(aliases) {
37
38
  // apply alias lookup to a set of args
@@ -99,7 +100,7 @@ var cli = module.exports = {
99
100
  repeat: function(text, amount) {
100
101
  // repeat string by specified number of times
101
102
  if (!amount || (amount < 0)) return "";
102
- return repeating(amount, ''+text);
103
+ return (''+text).repeat(amount);
103
104
  },
104
105
 
105
106
  space: function(amount) {
@@ -112,6 +113,12 @@ var cli = module.exports = {
112
113
  return text + this.space(width - stringWidth(text));
113
114
  },
114
115
 
116
+ emoji: function(emoji) {
117
+ // Draw the emoji, restore its starting cursor position, then explicitly move
118
+ // forward two cells. This overrides each terminal's automatic cursor advance.
119
+ return '\u001b7' + emoji + '\u001b8\u001b[2C';
120
+ },
121
+
115
122
  center: function(text, width) {
116
123
  // center string horizontally
117
124
  var self = this;
@@ -291,25 +298,12 @@ var cli = module.exports = {
291
298
  max_col_widths[longest_col_idx]--;
292
299
  }
293
300
 
294
- // finally prune affected columns, trying to preserve ANSI color inside column value
301
+ // finally prune affected columns by display width, preserving ANSI and emoji
295
302
  rows.forEach( function(cols, idx) {
296
303
  cols.forEach( function(col, idy) {
297
304
  col = '' + col;
298
305
  if (stringWidth(col) > max_col_widths[idy]) {
299
- var suffix = '';
300
- var prefix = '';
301
-
302
- while (col.match(/^(\u001b\[[^m]*?m)/)) {
303
- prefix += RegExp.$1;
304
- col = col.replace(/^(\u001b\[[^m]*?m)/, '');
305
- }
306
-
307
- while (col.match(/(\u001b\[[^m]*?m)$/)) {
308
- suffix = RegExp.$1 + suffix;
309
- col = col.replace(/(\u001b\[[^m]*?m)$/, '');
310
- }
311
-
312
- cols[idy] = prefix + col.substring(0, max_col_widths[idy] - 1) + '…' + suffix;
306
+ cols[idy] = Width.truncate(col, max_col_widths[idy], '');
313
307
  } // too wide
314
308
  });
315
309
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixl-cli",
3
- "version": "1.0.22",
3
+ "version": "1.1.1",
4
4
  "description": "Tools for building command-line apps for Node.js.",
5
5
  "author": "Joseph Huckaby <jhuckaby@gmail.com>",
6
6
  "homepage": "https://github.com/jhuckaby/pixl-cli",
@@ -22,14 +22,9 @@
22
22
  "table"
23
23
  ],
24
24
  "dependencies": {
25
- "pixl-class": "^1.0.3",
26
25
  "pixl-args": "^1.0.0",
27
26
  "pixl-tools": "^2.0.2",
28
- "chalk": "2.4.1",
29
- "string-width": "4.2.0",
30
- "widest-line": "3.0.0",
31
- "repeating": "3.0.0",
32
- "word-wrap": "1.2.4"
27
+ "chalk": "2.4.1"
33
28
  },
34
29
  "devDependencies": {}
35
30
  }
package/util.js ADDED
@@ -0,0 +1,39 @@
1
+ // Shared Unicode and terminal utilities for pixl-cli.
2
+
3
+ // Match ANSI terminal escape sequences so they can be ignored during measurement.
4
+ var ansiPattern = new RegExp([
5
+ '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
6
+ '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))'
7
+ ].join('|'), 'g');
8
+
9
+ // Split strings into user-perceived characters, including complete emoji sequences.
10
+ var graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
11
+
12
+ function splitAnsiGraphemes(text) {
13
+ // Split a string into complete ANSI sequences and Unicode grapheme clusters.
14
+ // ANSI sequences are kept intact so callers can preserve them while editing text.
15
+ var units = [];
16
+ var pattern = new RegExp(ansiPattern.source, 'g');
17
+ var offset = 0;
18
+ var match = null;
19
+
20
+ var addText = function(value) {
21
+ for (var item of graphemeSegmenter.segment(value)) {
22
+ units.push({ text: item.segment, ansi: false });
23
+ }
24
+ };
25
+
26
+ while ((match = pattern.exec(text))) {
27
+ if (match.index > offset) addText( text.substring(offset, match.index) );
28
+ units.push({ text: match[0], ansi: true });
29
+ offset = pattern.lastIndex;
30
+ }
31
+ if (offset < text.length) addText( text.substring(offset) );
32
+ return units;
33
+ }
34
+
35
+ module.exports = {
36
+ ansiPattern: ansiPattern,
37
+ graphemeSegmenter: graphemeSegmenter,
38
+ splitAnsiGraphemes: splitAnsiGraphemes
39
+ };
package/width.js ADDED
@@ -0,0 +1,116 @@
1
+ // Terminal display-width measurement for pixl-cli.
2
+
3
+ var Util = require('./util');
4
+ var ansiPattern = Util.ansiPattern;
5
+ var graphemeSegmenter = Util.graphemeSegmenter;
6
+
7
+ // These built-in Unicode properties let us identify emoji without carrying a large,
8
+ // generated lookup table. VS16 changes text-default symbols to emoji presentation.
9
+ var zeroWidthClusterPattern = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Nonspacing_Mark}|\p{Enclosing_Mark}|\p{Surrogate})+$/u;
10
+ var emojiPattern = /\p{Emoji}/u;
11
+ var emojiPresentationPattern = /\p{Emoji_Presentation}/u;
12
+ var emojiModifierPattern = /\p{Emoji_Modifier}/u;
13
+ var regionalIndicatorPattern = /\p{Regional_Indicator}/gu;
14
+ var extendedPictographicPattern = /\p{Extended_Pictographic}/gu;
15
+ var keycapPattern = /^[\d#*]\uFE0F?\u20E3$/;
16
+ var keycapBaseWithVs16Pattern = /^[\d#*]\uFE0F$/;
17
+
18
+ function stringWidth(text) {
19
+ // Measure Western Unicode and emoji terminal columns, ignoring ANSI styling.
20
+ if ((typeof(text) != 'string') || !text.length) return 0;
21
+ text = text.replace(ansiPattern, '');
22
+ if (!text.length) return 0;
23
+
24
+ // Printable ASCII needs no Unicode segmentation and is by far the common case.
25
+ if (text.match(/^[\u0020-\u007E]*$/)) return text.length;
26
+
27
+ var width = 0;
28
+ var segments = graphemeSegmenter.segment(text);
29
+ for (var item of segments) {
30
+ var segment = item.segment;
31
+ if (zeroWidthClusterPattern.test(segment)) continue;
32
+
33
+ // Native emoji generally occupy two terminal columns. The extra checks cover
34
+ // text-default emoji switched by VS16, flags, keycaps and unqualified sequences.
35
+ var regionalIndicators = segment.match(regionalIndicatorPattern);
36
+ var pictographs = segment.match(extendedPictographicPattern);
37
+ var isEmoji = false;
38
+
39
+ if (segment.includes('\u20E3')) {
40
+ isEmoji = keycapPattern.test(segment);
41
+ }
42
+ else if (regionalIndicators) {
43
+ isEmoji = (regionalIndicators.length >= 2);
44
+ }
45
+ else {
46
+ isEmoji = emojiPresentationPattern.test(segment) ||
47
+ (emojiPattern.test(segment) && segment.includes('\uFE0F') &&
48
+ !keycapBaseWithVs16Pattern.test(segment)) ||
49
+ (emojiPattern.test(segment) && emojiModifierPattern.test(segment)) ||
50
+ (segment.includes('\u200D') && pictographs && (pictographs.length >= 2));
51
+ }
52
+
53
+ width += isEmoji ? 2 : 1;
54
+ }
55
+
56
+ return width;
57
+ }
58
+
59
+ function widestLine(text) {
60
+ // Return the visual width of the widest line in a multi-line string.
61
+ var width = 0;
62
+ text.split(/\n/).forEach( function(line) {
63
+ width = Math.max( width, stringWidth(line) );
64
+ } );
65
+ return width;
66
+ }
67
+
68
+ function truncate(text, width, suffix) {
69
+ // Truncate a string to an exact terminal display width. Preserve complete ANSI
70
+ // sequences and grapheme clusters, and retain trailing SGR codes to reset styles.
71
+ if (typeof(text) != 'string') text = '' + text;
72
+ if (suffix == null) suffix = '';
73
+ if (stringWidth(text) <= width) return text;
74
+
75
+ var suffixWidth = stringWidth(suffix);
76
+ var contentWidth = Math.max(0, width - suffixWidth);
77
+ var outputWidth = 0;
78
+ var output = '';
79
+ var units = Util.splitAnsiGraphemes(text);
80
+ var trailingAnsi = [];
81
+
82
+ for (var idx = units.length - 1; idx >= 0; idx--) {
83
+ if (!units[idx].ansi) break;
84
+ if (units[idx].text.match(/^(?:\u001B\[|\u009B)[^m]*m$/)) {
85
+ trailingAnsi.unshift(units[idx].text);
86
+ }
87
+ }
88
+
89
+ for (var idx = 0, len = units.length; idx < len; idx++) {
90
+ var unit = units[idx];
91
+ if (unit.ansi) {
92
+ output += unit.text;
93
+ continue;
94
+ }
95
+
96
+ var unitWidth = stringWidth(unit.text);
97
+ if ((outputWidth + unitWidth) > contentWidth) break;
98
+ output += unit.text;
99
+ outputWidth += unitWidth;
100
+ }
101
+
102
+ // A wide grapheme may not fit the final available cell. Pad that cell so the
103
+ // ellipsis still lands at the exact requested width and tables remain aligned.
104
+ if (outputWidth < contentWidth) output += ' '.repeat(contentWidth - outputWidth);
105
+ return output + suffix + trailingAnsi.join('');
106
+ }
107
+
108
+ // Preserve the compatibility aliases provided by the old CommonJS dependencies.
109
+ stringWidth.default = stringWidth;
110
+ widestLine.default = widestLine;
111
+
112
+ module.exports = {
113
+ stringWidth: stringWidth,
114
+ widestLine: widestLine,
115
+ truncate: truncate
116
+ };
package/wrap.js ADDED
@@ -0,0 +1,122 @@
1
+ // Display-width-aware word wrapping for pixl-cli.
2
+
3
+ var Util = require('./util');
4
+ var stringWidth = require('./width').stringWidth;
5
+
6
+ function getWrapTokens(text) {
7
+ // Split text into alternating words and breakable whitespace, while preserving
8
+ // ANSI sequences as zero-width units in their original positions.
9
+ var units = Util.splitAnsiGraphemes(text);
10
+ units.forEach( function(unit) {
11
+ unit.width = unit.ansi ? 0 : stringWidth(unit.text);
12
+ unit.breakable = !unit.ansi && !!unit.text.match(/^(?:\s|\u200B)+$/u);
13
+ } );
14
+
15
+ var tokens = [];
16
+ var token = null;
17
+ var pending = [];
18
+ units.forEach( function(unit) {
19
+ // ANSI codes do not determine whether a token is a word or whitespace.
20
+ if (unit.ansi) {
21
+ if (token) token.units.push(unit);
22
+ else pending.push(unit);
23
+ return;
24
+ }
25
+
26
+ var type = unit.breakable ? 'break' : 'word';
27
+ if (!token || (token.type != type)) {
28
+ if (token) tokens.push(token);
29
+ token = { type: type, units: pending.concat([unit]) };
30
+ pending = [];
31
+ }
32
+ else token.units.push(unit);
33
+ } );
34
+
35
+ if (token) tokens.push(token);
36
+ else if (pending.length) tokens.push({ type: 'word', units: pending });
37
+ return tokens;
38
+ }
39
+
40
+ function joinWrapUnits(units) {
41
+ // Join tokenized graphemes and ANSI sequences back into their original string.
42
+ return units.map( function(unit) { return unit.text; } ).join('');
43
+ }
44
+
45
+ function trimWrapLines(text) {
46
+ // Trim spaces and tabs from each line without removing the initial indentation.
47
+ return text.split('\n').map( function(line) {
48
+ return line.replace(/[ \t]+$/, '');
49
+ } ).join('\n');
50
+ }
51
+
52
+ function wordWrap(text, options) {
53
+ // Wrap text using terminal display columns instead of JavaScript string length.
54
+ options = options || {};
55
+ if (text == null) return text;
56
+
57
+ var width = options.width || 50;
58
+ var indent = (typeof(options.indent) == 'string') ? options.indent : ' ';
59
+ var newline = options.newline || ('\n' + indent);
60
+ var escape = (typeof(options.escape) == 'function') ? options.escape : function(value) {
61
+ return value;
62
+ };
63
+ var tokens = getWrapTokens(text);
64
+ var lines = [];
65
+ var lineUnits = [];
66
+
67
+ var flushLine = function() {
68
+ var line = joinWrapUnits(lineUnits);
69
+ if (line.slice(-1) == '\n') line = line.slice(0, -1);
70
+ lines.push( escape(line) );
71
+ lineUnits = [];
72
+ };
73
+
74
+ if (options.cut === true) {
75
+ // Cut mode fills every line to the requested display width, even when that
76
+ // means breaking a word. Complete grapheme clusters always stay together.
77
+ var lineWidth = 0;
78
+ tokens.forEach( function(token) {
79
+ token.units.forEach( function(unit) {
80
+ if (unit.text.includes('\n')) {
81
+ if (stringWidth(joinWrapUnits(lineUnits))) flushLine();
82
+ else lineUnits = [];
83
+ lineWidth = 0;
84
+ return;
85
+ }
86
+
87
+ if (!unit.ansi && lineWidth && ((lineWidth + unit.width) > width)) {
88
+ flushLine();
89
+ lineWidth = 0;
90
+ }
91
+ lineUnits.push(unit);
92
+ lineWidth += unit.width;
93
+ } );
94
+ } );
95
+ }
96
+ else {
97
+ tokens.forEach( function(token) {
98
+ var tokenText = joinWrapUnits(token.units);
99
+
100
+ if (token.type == 'break') {
101
+ lineUnits = lineUnits.concat(token.units);
102
+ if (tokenText.includes('\n')) {
103
+ if (stringWidth(joinWrapUnits(lineUnits))) flushLine();
104
+ else lineUnits = [];
105
+ }
106
+ return;
107
+ }
108
+
109
+ var lineText = joinWrapUnits(lineUnits);
110
+ var candidateWidth = stringWidth(lineText + tokenText);
111
+ if (lineUnits.length && (candidateWidth > width)) flushLine();
112
+ lineUnits = lineUnits.concat(token.units);
113
+ } );
114
+ }
115
+
116
+ if (lineUnits.length) flushLine();
117
+ var result = indent + lines.join(newline);
118
+ if (options.trim === true) result = trimWrapLines(result);
119
+ return result;
120
+ }
121
+
122
+ module.exports = wordWrap;