console-toolkit 1.1.1 → 1.2.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/README.md CHANGED
@@ -89,6 +89,8 @@ BSD 3-Clause License
89
89
 
90
90
  ## Release history
91
91
 
92
- - 1.1.1: *Minor bugfixes in `Table`, some improvements, updated deps.*
93
- - 1.1.0: *Minor improvements, enhanced `Writer` and `Updater`.*
94
- - 1.0.0: *Initial release.*
92
+ - 1.2.1 *Added support for `Bun.stringWidth()`.*
93
+ - 1.2.0 *Refactored `strings`.*
94
+ - 1.1.1 *Minor bugfixes in `Table`, some improvements, updated deps.*
95
+ - 1.1.0 *Minor improvements, enhanced `Writer` and `Updater`.*
96
+ - 1.0.0 *Initial release.*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "console-toolkit",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Toolkit to produce a fancy console output (boxes, tables, charts, colors).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -41,6 +41,10 @@
41
41
  ],
42
42
  "author": "Eugene Lazutkin <eugene.lazutkin@gmail.com> (https://www.lazutkin.com/)",
43
43
  "license": "BSD-3-Clause",
44
+ "funding": {
45
+ "type": "github",
46
+ "url": "https://github.com/sponsors/uhop"
47
+ },
44
48
  "bugs": {
45
49
  "url": "https://github.com/uhop/console-toolkit/issues"
46
50
  },
@@ -54,6 +58,8 @@
54
58
  ]
55
59
  },
56
60
  "devDependencies": {
61
+ "emoji-regex": "^10.3.0",
62
+ "get-east-asian-width": "^1.2.0",
57
63
  "tape-six": "^0.9.6"
58
64
  }
59
65
  }
package/src/box.js CHANGED
@@ -78,8 +78,8 @@ export class Box {
78
78
  return new Box(this);
79
79
  }
80
80
 
81
- clip(width, includeLastCommand) {
82
- return new Box(clipStrings(this.box, width, includeLastCommand), true);
81
+ clip(width, options) {
82
+ return Box.make(clipStrings(this.box, width, options));
83
83
  }
84
84
 
85
85
  // padding
@@ -28,14 +28,23 @@ export class Updater {
28
28
  }
29
29
 
30
30
  startRefreshing(ms = 100) {
31
- if (this.intervalHandle || this.isDone || !this.writer.isTTY) return;
31
+ if (this.intervalHandle || this.isDone || !this.writer.isTTY) return this;
32
32
  this.intervalHandle = setInterval(this.update.bind(this), ms);
33
+ return this;
33
34
  }
34
35
 
35
36
  stopRefreshing() {
36
- if (!this.intervalHandle) return;
37
+ if (!this.intervalHandle) return this;
37
38
  clearInterval(this.intervalHandle);
38
39
  this.intervalHandle = null;
40
+ return this;
41
+ }
42
+
43
+ reset() {
44
+ this.stopRefreshing();
45
+ this.isDone = false;
46
+ this.lastHeight = 0;
47
+ return this;
39
48
  }
40
49
 
41
50
  getFrame(state, ...args) {
package/src/panel.js CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  optimize,
11
11
  toState
12
12
  } from './ansi/sgr-state.js';
13
+ import parse from './strings/parse.js';
14
+ import split, {size} from './strings/split.js';
13
15
  import Box from './box.js';
14
16
  import {addAliases} from './meta.js';
15
17
 
@@ -45,36 +47,15 @@ export class Panel {
45
47
  break main;
46
48
  }
47
49
 
48
- const {emptySymbol = '\x07'} = options || {},
49
- panel = new Panel(s.width, s.height);
50
-
51
- for (let i = 0, n = s.height; i < n; ++i) {
52
- const row = s.box[i],
53
- panelRow = panel.box[i];
54
- let start = 0,
55
- pos = 0,
56
- state = {};
57
- matchCsi.lastIndex = 0;
58
- for (const match of row.matchAll(matchCsi)) {
59
- const str = [...row.substring(start, match.index)];
60
- for (let j = 0; j < str.length; ++j) {
61
- panelRow[pos++] = str[j] === emptySymbol ? null : {symbol: str[j], state};
62
- }
63
- start = match.index + match[0].length;
64
- if (match[3] !== 'm') continue;
65
- state = addCommandsToState(state, match[1].split(';'));
66
- }
67
- const str = [...row.substring(start)];
68
- for (let j = 0; j < str.length; ++j) {
69
- panelRow[pos++] = str[j] === emptySymbol ? null : {symbol: str[j], state};
70
- }
71
- }
72
-
50
+ const panel = new Panel(s.width, s.height);
51
+ panel.put(0, 0, s, options);
73
52
  return panel;
74
53
  }
75
54
 
76
- toStrings({emptySymbol = ' ', emptyState = RESET_STATE} = {}) {
55
+ toStrings(options = {}) {
77
56
  if (!this.height || !this.width) return Box.makeBlank(this.width, this.height);
57
+
58
+ let {emptySymbol = ' ', emptyState = RESET_STATE} = options;
78
59
  emptyState = toState(emptyState);
79
60
 
80
61
  const s = new Array(this.height),
@@ -86,8 +67,9 @@ export class Panel {
86
67
  initState = {},
87
68
  state = initState;
88
69
  for (let j = 0; j < this.width; ++j) {
89
- const cell = panelRow[j] || emptyCell,
90
- newState = combineStates(state, cell.state),
70
+ const cell = panelRow[j] || emptyCell;
71
+ if (cell.ignore) continue;
72
+ const newState = combineStates(state, cell.state),
91
73
  commands = stateTransition(state, newState);
92
74
  row += stringifyCommands(commands) + cell.symbol;
93
75
  state = newState;
@@ -187,7 +169,7 @@ export class Panel {
187
169
  return this;
188
170
  }
189
171
 
190
- put(x, y, text, emptySymbol = '\x07') {
172
+ put(x, y, text, options = {}) {
191
173
  if (text instanceof Panel) return this.copyFrom(x, y, text.width, text.height, text);
192
174
 
193
175
  // normalize arguments
@@ -203,43 +185,50 @@ export class Panel {
203
185
  if (y >= this.height) return this;
204
186
  if (y + height > this.height) height = this.height - y;
205
187
 
188
+ const {emptySymbol = '\x07'} = options;
189
+
206
190
  // copy characters
207
191
  let state = {};
208
192
  for (let i = 0; i < height; ++i) {
209
193
  const row = this.box[y + i],
210
194
  s = box.box[i];
211
- let start = 0,
212
- pos = 0;
195
+ let pos = 0;
213
196
  matchCsi.lastIndex = 0;
214
- for (const match of s.matchAll(matchCsi)) {
215
- const str = [...s.substring(start, match.index)];
216
- for (let j = 0; j < str.length; ++j, ++pos) {
197
+ for (const {string, match} of parse(s, matchCsi)) {
198
+ const {graphemes} = split(string, options);
199
+ for (const grapheme of graphemes) {
217
200
  if (x + pos >= row.length) break;
218
- const cell = row[x + pos];
219
- row[x + pos] =
220
- str[j] === emptySymbol ? null : {symbol: str[j], state: cell ? combineStates(cell.state, state) : state};
201
+ if (grapheme.symbol === emptySymbol) {
202
+ row[x + pos] = null;
203
+ } else {
204
+ const cell = row[x + pos];
205
+ row[x + pos] = {
206
+ symbol: grapheme.symbol,
207
+ state: cell && !cell.ignore ? combineStates(cell.state, state) : state
208
+ };
209
+ }
210
+ ++pos;
211
+ if (grapheme.width === 2) {
212
+ if (x + pos < row.length) {
213
+ row[x + pos] = {ignore: true};
214
+ ++pos;
215
+ } else {
216
+ row[x + pos - 1] = null;
217
+ }
218
+ }
221
219
  }
222
- start = match.index + match[0].length;
223
- if (match[3] !== 'm') continue;
224
- state = addCommandsToState(state, match[1].split(';'));
225
- }
226
- const str = [...s.substring(start)];
227
- for (let j = 0; j < str.length; ++j, ++pos) {
228
- if (x + pos >= row.length) break;
229
- const cell = row[x + pos];
230
- row[x + pos] =
231
- str[j] === emptySymbol ? null : {symbol: str[j], state: cell ? combineStates(cell.state, state) : state};
220
+ if (match && match[3] === 'm') state = addCommandsToState(state, match[1].split(';'));
232
221
  }
233
222
  if (x + pos < row.length) {
234
223
  const cell = row[x + pos];
235
- if (cell) row[x + pos] = {symbol: cell.symbol, state: combineStates(state, cell.state)};
224
+ if (cell && !cell.ignore) row[x + pos] = {symbol: cell.symbol, state: combineStates(state, cell.state)};
236
225
  }
237
226
  }
238
227
 
239
228
  return this;
240
229
  }
241
230
 
242
- applyFn(x, y, width, height, fn) {
231
+ applyFn(x, y, width, height, fn, options) {
243
232
  // normalize arguments
244
233
 
245
234
  if (typeof x == 'function') {
@@ -265,16 +254,27 @@ export class Panel {
265
254
  for (let i = 0; i < height; ++i) {
266
255
  const row = this.box[y + i];
267
256
  for (let j = 0; j < width; ++j) {
268
- const cell = row[x + j],
269
- newCell = fn(x + j, y + i, cell);
270
- if (newCell !== undefined) row[x + j] = newCell;
257
+ const cell = row[x + j];
258
+ if (cell?.ignore) continue;
259
+ const newCell = fn(x + j, y + i, cell);
260
+ if (newCell !== undefined) {
261
+ if (cell) {
262
+ const symbolWidth = size(cell.symbol, options);
263
+ if (symbolWidth > 1 && x + j + 1 < row.length) row[x + j + 1] = null;
264
+ }
265
+ if (newCell) {
266
+ const symbolWidth = size(newCell.symbol, options);
267
+ if (symbolWidth > 1 && x + j + 1 < row.length) row[x + j + 1] = {ignore: true};
268
+ }
269
+ row[x + j] = newCell;
270
+ }
271
271
  }
272
272
  }
273
273
 
274
274
  return this;
275
275
  }
276
276
 
277
- fill(x, y, width, height, symbol, state = {}) {
277
+ fill(x, y, width, height, symbol, state = {}, options) {
278
278
  if (typeof x === 'string') {
279
279
  symbol = x;
280
280
  state = y || {};
@@ -290,7 +290,7 @@ export class Panel {
290
290
  } else {
291
291
  state = toState(state);
292
292
  }
293
- return this.applyFn(x, y, width, height, () => ({symbol, state}));
293
+ return this.applyFn(x, y, width, height, () => ({symbol, state}), options);
294
294
  }
295
295
 
296
296
  fillState(x, y, width, height, options) {
@@ -309,7 +309,14 @@ export class Panel {
309
309
  } else {
310
310
  state = toState(state);
311
311
  }
312
- return this.applyFn(x, y, width, height, (x, y, cell) => ({symbol: cell ? cell.symbol : emptySymbol, state}));
312
+ return this.applyFn(
313
+ x,
314
+ y,
315
+ width,
316
+ height,
317
+ (x, y, cell) => ({symbol: cell ? cell.symbol : emptySymbol, state}),
318
+ options
319
+ );
313
320
  }
314
321
 
315
322
  fillNonEmptyState(x, y, width, height, options) {
@@ -328,7 +335,7 @@ export class Panel {
328
335
  } else {
329
336
  state = toState(state);
330
337
  }
331
- return this.applyFn(x, y, width, height, (x, y, cell) => cell && {symbol: cell.symbol, state});
338
+ return this.applyFn(x, y, width, height, (x, y, cell) => cell && {symbol: cell.symbol, state}, options);
332
339
  }
333
340
 
334
341
  combineStateBefore(x, y, width, height, options) {
@@ -347,10 +354,16 @@ export class Panel {
347
354
  } else {
348
355
  state = toState(state);
349
356
  }
350
- return this.applyFn(x, y, width, height, (x, y, cell) =>
351
- cell
352
- ? {symbol: cell.symbol, state: combineStates(state, cell.state)}
353
- : {symbol: emptySymbol, state: combineStates(state, emptyState)}
357
+ return this.applyFn(
358
+ x,
359
+ y,
360
+ width,
361
+ height,
362
+ (x, y, cell) =>
363
+ cell
364
+ ? {symbol: cell.symbol, state: combineStates(state, cell.state)}
365
+ : {symbol: emptySymbol, state: combineStates(state, emptyState)},
366
+ options
354
367
  );
355
368
  }
356
369
 
@@ -370,14 +383,20 @@ export class Panel {
370
383
  } else {
371
384
  state = toState(state);
372
385
  }
373
- return this.applyFn(x, y, width, height, (x, y, cell) =>
374
- cell
375
- ? {symbol: cell.symbol, state: combineStates(cell.state, state)}
376
- : {symbol: emptySymbol, state: combineStates(emptyState, state)}
386
+ return this.applyFn(
387
+ x,
388
+ y,
389
+ width,
390
+ height,
391
+ (x, y, cell) =>
392
+ cell
393
+ ? {symbol: cell.symbol, state: combineStates(cell.state, state)}
394
+ : {symbol: emptySymbol, state: combineStates(emptyState, state)},
395
+ options
377
396
  );
378
397
  }
379
398
 
380
- clear(x, y, width, height) {
399
+ clear(x, y, width, height, options) {
381
400
  // normalize arguments
382
401
  if (typeof x != 'number') {
383
402
  x = y = 0;
@@ -394,7 +413,7 @@ export class Panel {
394
413
  height = this.height;
395
414
  }
396
415
 
397
- return this.applyFn(x, y, width, height, () => null);
416
+ return this.applyFn(x, y, width, height, () => null, options);
398
417
  }
399
418
 
400
419
  padLeft(n) {
@@ -0,0 +1,31 @@
1
+ import parse, {matchCsiNoGroups} from './parse.js';
2
+ import {split} from './split.js';
3
+
4
+ export const clip = (s, width, options = {}) => {
5
+ const {includeLastCommand = false, matcher = matchCsiNoGroups} = options;
6
+
7
+ let counter = 0;
8
+ for (const {start, string, match} of parse(s, matcher)) {
9
+ if (counter >= width)
10
+ return match ? s.substring(0, match.index + (includeLastCommand ? match[0].length : 0)) : s.substring(0, start);
11
+ const prev = split(string, options),
12
+ newCounter = counter + prev.width;
13
+ if (newCounter === width)
14
+ return match ? s.substring(0, match.index + (includeLastCommand ? match[0].length : 0)) : s;
15
+ if (newCounter < width) {
16
+ counter = newCounter;
17
+ continue;
18
+ }
19
+ let result = '';
20
+ for (const grapheme of prev.graphemes) {
21
+ if (counter + grapheme.width > width) break;
22
+ result += grapheme.symbol;
23
+ counter += grapheme.width;
24
+ }
25
+ return s.substring(0, start) + result;
26
+ }
27
+
28
+ return s;
29
+ };
30
+
31
+ export default clip;
@@ -0,0 +1,16 @@
1
+ export const matchCsiNoGroups = /\x1B\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g;
2
+ export const matchCsiNoSgrNoGroups = /\x1B\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x6C\x6E-\x7E]/g;
3
+
4
+ export function* parse(s, matcher = matchCsiNoGroups) {
5
+ s = String(s);
6
+ let start = 0;
7
+ matcher.lastIndex = 0;
8
+ for (const match of s.matchAll(matcher)) {
9
+ const string = s.substring(start, match.index);
10
+ yield {start, string, match};
11
+ start = match.index + match[0].length;
12
+ }
13
+ yield {start, string: s.substring(start), match: null};
14
+ }
15
+
16
+ export default parse;
@@ -0,0 +1,109 @@
1
+ // Loosely adapted from https://www.npmjs.com/package/string-width by
2
+ // [Sindre Sorhus](https://www.npmjs.com/~sindresorhus) under the MIT license.
3
+
4
+ let emojiRegex = null,
5
+ eastAsianWidth = null;
6
+ if (!globalThis.Bun) {
7
+ try {
8
+ emojiRegex = (await import('emoji-regex')).default();
9
+ } catch (error) {
10
+ // squelch
11
+ }
12
+ try {
13
+ eastAsianWidth = (await import('get-east-asian-width')).eastAsianWidth;
14
+ } catch (error) {
15
+ // squelch
16
+ }
17
+ }
18
+
19
+ const segmenter = new Intl.Segmenter();
20
+
21
+ export const split = (s, options = {}) => {
22
+ s = String(s);
23
+ if (!s) return {graphemes: [], width: 0};
24
+
25
+ const {ignoreControlSymbols = false, ambiguousAsWide = false} = options,
26
+ eastAsianWidthOptions = {ambiguousAsWide},
27
+ bunStringWidthOptions = {ambiguousAsNarrow: !ambiguousAsWide};
28
+
29
+ const graphemes = [];
30
+ let width = 0;
31
+ for (const {segment} of segmenter.segment(s)) {
32
+ const codePoint = segment.codePointAt(0);
33
+ // Control characters: C0, C1
34
+ if (ignoreControlSymbols && (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f))) continue;
35
+ // Combining characters
36
+ if (
37
+ (codePoint >= 0x300 && codePoint <= 0x36f) ||
38
+ (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
39
+ (codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
40
+ (codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
41
+ (codePoint >= 0xfe20 && codePoint <= 0xfe2f)
42
+ ) {
43
+ if (graphemes.length) graphemes[graphemes.length - 1].symbol += segment;
44
+ continue;
45
+ }
46
+ if (globalThis.Bun) {
47
+ const w = Bun.stringWidth(segment, bunStringWidthOptions);
48
+ graphemes.push({symbol: segment, width: w});
49
+ width += w;
50
+ continue;
51
+ }
52
+ if (emojiRegex && ((emojiRegex.lastIndex = 0), emojiRegex.test(segment))) {
53
+ graphemes.push({symbol: segment, width: 2});
54
+ width += 2;
55
+ continue;
56
+ }
57
+ if (eastAsianWidth) {
58
+ const w = eastAsianWidth(codePoint, eastAsianWidthOptions);
59
+ graphemes.push({symbol: segment, width: w});
60
+ width += w;
61
+ continue;
62
+ }
63
+ graphemes.push({symbol: segment, width: 1});
64
+ ++width;
65
+ }
66
+ return {graphemes, width};
67
+ };
68
+
69
+ export const size = (s, options = {}) => {
70
+ s = String(s);
71
+ if (!s) return 0;
72
+
73
+ const {ignoreControlSymbols = false, ambiguousAsWide = false} = options,
74
+ eastAsianWidthOptions = {ambiguousAsWide},
75
+ bunStringWidthOptions = {ambiguousAsNarrow: !ambiguousAsWide};
76
+
77
+ let width = 0;
78
+ for (const {segment} of segmenter.segment(s)) {
79
+ const codePoint = segment.codePointAt(0);
80
+ // Control characters: C0, C1
81
+ if (ignoreControlSymbols && (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f))) continue;
82
+ // Combining characters
83
+ if (
84
+ (codePoint >= 0x300 && codePoint <= 0x36f) ||
85
+ (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
86
+ (codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
87
+ (codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
88
+ (codePoint >= 0xfe20 && codePoint <= 0xfe2f)
89
+ ) {
90
+ continue;
91
+ }
92
+ if (globalThis.Bun) {
93
+ width += Bun.stringWidth(segment, bunStringWidthOptions);
94
+ continue;
95
+ }
96
+ if (emojiRegex && ((emojiRegex.lastIndex = 0), emojiRegex.test(segment))) {
97
+ width += 2;
98
+ continue;
99
+ }
100
+ if (eastAsianWidth) {
101
+ width += eastAsianWidth(codePoint, eastAsianWidthOptions);
102
+ continue;
103
+ }
104
+ ++width;
105
+ }
106
+ return width;
107
+ };
108
+
109
+ export default split;
package/src/strings.js CHANGED
@@ -1,49 +1,20 @@
1
- export const matchCsiNoGroups = /\x1B\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g;
2
- export const matchCsiNoSgrNoGroups = /\x1B\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x6C\x6E-\x7E]/g;
1
+ import {size} from './strings/split.js';
2
+ import parse, {matchCsiNoGroups, matchCsiNoSgrNoGroups} from './strings/parse.js';
3
+ import clip from './strings/clip.js';
3
4
 
4
- export const getLength = (s, matcher = matchCsiNoGroups) => {
5
- s = String(s);
6
- let counter = 0,
7
- start = 0;
8
- matcher.lastIndex = 0;
9
- for (const match of s.matchAll(matcher)) {
10
- counter += [...s.substring(start, match.index)].length;
11
- start = match.index + match[0].length;
5
+ export const getLength = (s, matcher) => {
6
+ let counter = 0;
7
+ for (const {string} of parse(s, matcher)) {
8
+ counter += size(string);
12
9
  }
13
- counter += [...s.substring(start)].length;
14
10
  return counter;
15
11
  };
16
12
 
17
- export const getMaxLength = (strings, matcher = matchCsiNoGroups) =>
18
- Math.max(0, ...strings.map(s => getLength(s, matcher)));
13
+ export const getMaxLength = (strings, matcher) =>
14
+ strings.reduce((acc, s) => Math.max(acc, getLength(s, matcher)), 0);
19
15
 
20
- export const clip = (s, width, includeLastCommand, matcher = matchCsiNoGroups) => {
21
- s = String(s);
22
- let counter = 0,
23
- start = 0;
24
- matcher.lastIndex = 0;
25
- for (const match of s.matchAll(matcher)) {
26
- if (counter >= width) return s.substring(0, match.index + (includeLastCommand ? match[0].length : 0));
27
- const prev = [...s.substring(start, match.index)];
28
- counter += prev.length;
29
- if (includeLastCommand ? counter > width : counter >= width) {
30
- const diff = width - counter,
31
- end = start + (diff ? prev.slice(0, prev.length + diff).join('').length : prev.length);
32
- return s.substring(0, end);
33
- }
34
- start = match.index + match[0].length;
35
- }
36
- if (counter >= width) return s.substring(0, start);
37
- const prev = [...s.substring(start)];
38
- if (counter + prev.length > width) {
39
- const end = start + prev.slice(0, width - counter).join('').length;
40
- return s.substring(0, end);
41
- }
42
- return s; // unchanged
43
- };
44
-
45
- export const clipStrings = (strings, width, includeLastCommand, matcher = matchCsiNoGroups) =>
46
- strings.map(s => clip(s, width, includeLastCommand, matcher));
16
+ export const clipStrings = (strings, width, options) =>
17
+ strings.map(s => clip(s, width, options));
47
18
 
48
19
  export const toStrings = s => {
49
20
  main: for (;;) {
@@ -70,3 +41,5 @@ export const toStrings = s => {
70
41
  }
71
42
  return [];
72
43
  };
44
+
45
+ export {clip, matchCsiNoGroups, matchCsiNoSgrNoGroups};