pixl-cli 1.1.1 → 1.1.3

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/README.md +103 -0
  2. package/cli.js +227 -2
  3. package/package.json +1 -1
  4. package/util.js +29 -1
  5. package/width.js +20 -0
package/README.md CHANGED
@@ -20,6 +20,8 @@
20
20
  * [Displaying Info Boxes](#displaying-info-boxes)
21
21
  + [Centering Text](#centering-text)
22
22
  + [Word-Wrapping Text](#word-wrapping-text)
23
+ * [Displaying Definition Lists](#displaying-definition-lists)
24
+ * [Displaying Dashboard Grids](#displaying-dashboard-grids)
23
25
  * [Displaying Tables](#displaying-tables)
24
26
  * [Graphical Progress Bars](#graphical-progress-bars)
25
27
  + [Configuration](#configuration)
@@ -398,6 +400,105 @@ culpa qui officia deserunt mollit anim
398
400
  id est laborum.
399
401
  ```
400
402
 
403
+ ## Displaying Definition Lists
404
+
405
+ Call `cli.defList()` to render a two-column list of labels and values inside a box. Labels are bold by default, values are plain, and no vertical border is drawn between the columns. Colons are added to labels automatically when needed. Lines will automatically truncate with ellipsis as needed.
406
+
407
+ ```js
408
+ const cli = require('pixl-cli');
409
+
410
+ let event = {
411
+ id: "emm2wtlsxrtjz8v4",
412
+ title: "Simple Shell Test",
413
+ category: "general",
414
+ username: "admin"
415
+ };
416
+
417
+ cli.println( cli.defList([
418
+ [ "Event ID", event.id ],
419
+ [ "Title", event.title ],
420
+ [ "Category", event.category ],
421
+ [ "Author", event.username ]
422
+ ], {
423
+ labelStyles: ["yellow", "bold"],
424
+ textStyles: ["green"],
425
+ indent: 1
426
+ }) );
427
+ ```
428
+
429
+ This produces a definition list like this, with colors when the terminal supports them:
430
+
431
+ ```
432
+ ┌─────────────────────────────┐
433
+ │ Event ID: emm2wtlsxrtjz8v4 │
434
+ │ Title: Simple Shell Test │
435
+ │ Category: general │
436
+ │ Author: admin │
437
+ └─────────────────────────────┘
438
+ ```
439
+
440
+ The label and value columns are left-aligned. When the box would exceed the attached terminal width, long values are shortened with an ellipsis. The complete label column is preserved whenever possible. The `indent` is treated as a horizontal margin on both sides when calculating the available width.
441
+
442
+ You can customize the definition list with these options:
443
+
444
+ | Property Name | Description |
445
+ |---------------|-------------|
446
+ | `labelStyles` | An array of [chalk](https://www.npmjs.com/package/chalk) styles or functions for labels. Defaults to `["bold"]`. |
447
+ | `textStyles` | An array of styles or functions for values. Defaults to `[]`. |
448
+ | `borderStyles` | An array of styles or functions for the box border. Defaults to `["gray"]`. |
449
+ | `indent` | Horizontal margin in characters on both sides of the box. Defaults to `0`. |
450
+ | `gap` | Spaces between the label and value columns. Defaults to `1`. |
451
+ | `hspace` | Spaces between the content and each side of the box. Defaults to `1`. |
452
+ | `vspace` | Empty lines above and below the list inside the box. Defaults to `0`. |
453
+
454
+ ## Displaying Dashboard Grids
455
+
456
+ Call `cli.dashGrid()` to render a responsive grid of dashboard units. Each unit contains a centered value and label, and all units have the same dimensions regardless of their content. Pass an array of `[label, value]` pairs:
457
+
458
+ ```js
459
+ const cli = require('pixl-cli');
460
+
461
+ cli.println( cli.dashGrid([
462
+ [ "Conductors", 1 ],
463
+ [ "Servers", 2 ],
464
+ [ "Current Alerts", 0 ],
465
+ [ "Active Jobs", 0 ],
466
+ [ "Job Success Rate", "100%" ],
467
+ [ "Avg. Job Elapsed", "31 sec" ]
468
+ ], {
469
+ minCols: 3,
470
+ maxCols: 5,
471
+ gap: 1,
472
+ valueStyles: ["bold", "green"]
473
+ }) );
474
+ ```
475
+
476
+ At a width of 62 columns, the first row looks like this:
477
+
478
+ ```
479
+ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
480
+ │ 1 │ │ 2 │ │ 0 │
481
+ │ │ │ │ │ │
482
+ │ Conductors │ │ Servers │ │ Current Alerts │
483
+ └──────────────────┘ └──────────────────┘ └──────────────────┘
484
+ ```
485
+
486
+ The grid uses the current terminal width and flows additional units onto new rows. An incomplete final row ends after its last unit. Labels and values are limited to one line and automatically shortened with an ellipsis when necessary. ANSI styles and emoji are measured by their terminal display width.
487
+
488
+ You can customize the dashboard grid with these options:
489
+
490
+ | Property Name | Description |
491
+ |---------------|-------------|
492
+ | `width` | Overall layout width, including horizontal margins. Defaults to `cli.width()`, or `80` when no terminal width is available. |
493
+ | `unitWidth` | Target outer width used to select the responsive column count. Defaults to `20`. |
494
+ | `minCols` | Preferred minimum number of columns. Defaults to `3`. Extremely narrow terminals may use fewer to avoid overflow. |
495
+ | `maxCols` | Maximum number of columns. Defaults to `5`. |
496
+ | `gap` | Horizontal spaces between units. Grid rows use `gap - 1` blank lines. Defaults to `1`, producing no vertical blank line. |
497
+ | `indent` | Horizontal margin in characters on both sides of the grid. Defaults to `0`. |
498
+ | `valueStyles` | An array of [chalk](https://www.npmjs.com/package/chalk) styles or functions for values. Defaults to `["bold"]`. |
499
+ | `labelStyles` | An array of styles or functions for labels. Defaults to `["gray"]`. |
500
+ | `borderStyles` | An array of styles or functions for unit borders. Defaults to `["gray"]`. |
501
+
401
502
  ## Displaying Tables
402
503
 
403
504
  ![Table Example](https://pixlcore.com/software/pixl-cli/table.png)
@@ -687,6 +788,8 @@ The full list of methods and objects that are imported are:
687
788
  - `yesno()`
688
789
  - `table()`
689
790
  - `box()`
791
+ - `defList()`
792
+ - `dashGrid()`
690
793
  - `wrap()`
691
794
  - `center()`
692
795
  - `commify()`
package/cli.js CHANGED
@@ -176,7 +176,11 @@ var cli = module.exports = {
176
176
  output.push( indent + this.applyStyles("┌" + this.repeat("─", width) + "┐", styles) );
177
177
 
178
178
  // left, content, right
179
- var lines = text.split(/\n/);
179
+ // Styled text normally carries its SGR modes across newlines, but the styled
180
+ // right border resets those modes before the next content line. Make each
181
+ // line self-contained before inserting borders between them.
182
+ var safeText = (styles && styles.length) ? Util.preserveAnsiLineStyles(text) : text;
183
+ var lines = safeText.split(/\n/);
180
184
  while (vspace-- > 0) {
181
185
  lines.unshift( "" );
182
186
  lines.push( "" );
@@ -197,6 +201,227 @@ var cli = module.exports = {
197
201
  return output.join("\n");
198
202
  },
199
203
 
204
+ defList: function(rows, args) {
205
+ // Render a two-column definition list inside a box. The labels share a
206
+ // common width, but there is deliberately no border between the columns.
207
+ var self = this;
208
+ if (!args) args = {};
209
+
210
+ // Empty arrays are valid style overrides, so test for the property rather
211
+ // than using || here. Labels are bold by default and values are plain.
212
+ var labelStyles = ('labelStyles' in args) ? args.labelStyles : ["bold"];
213
+ var textStyles = ('textStyles' in args) ? args.textStyles : [];
214
+ var borderStyles = ('borderStyles' in args) ? args.borderStyles :
215
+ (('styles' in args) ? args.styles : ["gray"]);
216
+ var hspace = ('hspace' in args) ? args.hspace : 1;
217
+ var vspace = args.vspace || 0;
218
+
219
+ // Gaps are whole terminal cells. Invalid and negative values become zero.
220
+ var gap = ('gap' in args) ? Math.floor(Number(args.gap)) : 1;
221
+ if (!isFinite(gap) || (gap < 0)) gap = 0;
222
+ var indent = args.indent || "";
223
+ if (typeof(indent) == 'number') indent = this.space(indent);
224
+
225
+ // Normalize and style all cells before measuring them. This allows ANSI
226
+ // color, custom style functions and the cli.emoji() cursor hack to coexist
227
+ // with the display-width calculations below.
228
+ var items = (rows || []).map( function(row) {
229
+ var label = '' + row[0];
230
+ var value = '' + row[1];
231
+
232
+ // Definition lists are one item per line. Flatten accidental newlines so
233
+ // a cell cannot escape its row and disturb the surrounding box.
234
+ label = label.replace(/\r?\n/g, ' ').replace(/\s+$/, '');
235
+ value = value.replace(/\r?\n/g, ' ');
236
+ if (!label.replace(ansiPattern, '').match(/:$/)) label += ':';
237
+
238
+ return {
239
+ label: self.applyStyles(label, labelStyles),
240
+ value: self.applyStyles(value, textStyles)
241
+ };
242
+ } );
243
+
244
+ var labelWidth = 0;
245
+ var valueWidth = 0;
246
+
247
+ items.forEach( function(item) {
248
+ labelWidth = Math.max( labelWidth, stringWidth(item.label) );
249
+ valueWidth = Math.max( valueWidth, stringWidth(item.value) );
250
+ } );
251
+
252
+ // The indent acts as a horizontal margin, so reserve it on both sides.
253
+ // Also reserve both box borders, both padding areas, and the requested gap
254
+ // separating the label and value columns.
255
+ var terminalWidth = this.width();
256
+ var contentWidth = labelWidth + gap + valueWidth;
257
+
258
+ if (terminalWidth) {
259
+ var availableWidth = terminalWidth - (stringWidth(indent) * 2);
260
+ var availableContentWidth = Math.max(0, availableWidth - 2 - (hspace * 2));
261
+ contentWidth = Math.min(contentWidth, availableContentWidth);
262
+ }
263
+
264
+ // Keep the complete label column whenever possible, and give all remaining
265
+ // room to values. On extremely narrow terminals labels are shortened too,
266
+ // while still reserving one cell for a value when any room remains.
267
+ if (labelWidth + gap + 1 > contentWidth) {
268
+ labelWidth = Math.max(0, contentWidth - gap - 1);
269
+ }
270
+ valueWidth = Math.max(0, contentWidth - labelWidth - (labelWidth ? gap : 0));
271
+
272
+ var truncate = function(text, width) {
273
+ // Width.truncate() preserves ANSI resets and complete emoji graphemes.
274
+ if (width < 1) return '';
275
+ return (stringWidth(text) > width) ? Width.truncate(text, width, '…') : text;
276
+ };
277
+
278
+ var lines = items.map( function(item) {
279
+ var label = truncate(item.label, labelWidth);
280
+ var value = truncate(item.value, valueWidth);
281
+ if (!labelWidth) return value;
282
+ return self.pad(label, labelWidth) + self.space(gap) + value;
283
+ } );
284
+
285
+ // Let box() handle the border, padding, vertical spacing and final row
286
+ // padding. Passing the normalized indent keeps numeric margins consistent.
287
+ return this.box( lines.join("\n"), {
288
+ styles: borderStyles,
289
+ hspace: hspace,
290
+ vspace: vspace,
291
+ indent: indent
292
+ } );
293
+ },
294
+
295
+ dashGrid: function(rows, args) {
296
+ // Render a responsive grid of equal-sized dashboard units. Each unit has a
297
+ // centered value, a centered label, a blank spacer row and its own border.
298
+ var self = this;
299
+ if (!rows || !rows.length) return '';
300
+ if (!args) args = {};
301
+
302
+ // Normalize numeric layout options to non-negative whole terminal cells.
303
+ // Invalid values fall back to their documented defaults.
304
+ var wholeNumber = function(value, defaultValue, minimum) {
305
+ value = Math.floor(Number(value));
306
+ if (!isFinite(value)) value = defaultValue;
307
+ return Math.max(minimum, value);
308
+ };
309
+
310
+ var targetUnitWidth = wholeNumber(args.unitWidth, 20, 5);
311
+ var minCols = wholeNumber(args.minCols, 3, 1);
312
+ var maxCols = wholeNumber(args.maxCols, 5, 1);
313
+ var gap = wholeNumber(args.gap, 1, 0);
314
+ var indent = args.indent || '';
315
+ if (typeof(indent) == 'number') indent = this.space(indent);
316
+
317
+ // A maximum is a hard cap. If conflicting limits are supplied, lower the
318
+ // minimum to match rather than silently exceeding the requested maximum.
319
+ if (minCols > maxCols) minCols = maxCols;
320
+
321
+ // Empty style arrays are intentional overrides, so property checks are used
322
+ // instead of ||. Values are bold, labels and borders are gray by default.
323
+ var valueStyles = ('valueStyles' in args) ? args.valueStyles : ["bold"];
324
+ var labelStyles = ('labelStyles' in args) ? args.labelStyles : ["gray"];
325
+ var borderStyles = ('borderStyles' in args) ? args.borderStyles : ["gray"];
326
+
327
+ // An explicit width makes redirected output and tests deterministic. When
328
+ // attached to a terminal, cli.width() returns process.stdout.columns.
329
+ var layoutWidth = ('width' in args) ?
330
+ wholeNumber(args.width, 80, 0) : (this.width() || 80);
331
+ var availableWidth = Math.max(0, layoutWidth - (stringWidth(indent) * 2));
332
+ if (availableWidth < 2) return '';
333
+
334
+ // Start with the preferred responsive column count, bounded by the caller's
335
+ // limits and the number of units. unitWidth is a planning target; the actual
336
+ // width is calculated below after horizontal gaps have been reserved.
337
+ var numCols = Math.floor(availableWidth / targetUnitWidth);
338
+ numCols = Math.max(minCols, Math.min(maxCols, numCols));
339
+ numCols = Math.min(numCols, rows.length);
340
+
341
+ // Each useful unit needs five cells: two borders, two padding cells and one
342
+ // content cell. On a very narrow terminal, safety takes priority over the
343
+ // requested minimum column count so the grid never overflows.
344
+ var maxFittingCols = Math.floor((availableWidth + gap) / (5 + gap));
345
+ maxFittingCols = Math.max(1, maxFittingCols);
346
+ numCols = Math.max(1, Math.min(numCols, maxFittingCols));
347
+
348
+ // All units use the same exact outer width. Any indivisible remainder stays
349
+ // unused at the right edge, including on incomplete final rows.
350
+ var unitWidth = Math.floor(
351
+ (availableWidth - ((numCols - 1) * gap)) / numCols
352
+ );
353
+ if (unitWidth < 2) return '';
354
+
355
+ var innerWidth = unitWidth - 2;
356
+ var contentWidth = Math.max(0, innerWidth - 2);
357
+
358
+ var truncate = function(text) {
359
+ // Preserve ANSI styles, complete graphemes and cli.emoji() sequences.
360
+ if (contentWidth < 1) return '';
361
+ return (stringWidth(text) > contentWidth) ?
362
+ Width.truncate(text, contentWidth, '…') : text;
363
+ };
364
+
365
+ var centerCell = function(text) {
366
+ // Center by terminal display width rather than JavaScript string length.
367
+ var remain = Math.max(0, innerWidth - stringWidth(text));
368
+ var left = Math.floor(remain / 2);
369
+ return self.space(left) + text + self.space(remain - left);
370
+ };
371
+
372
+ var units = rows.map( function(row) {
373
+ var label = ('' + row[0]).replace(/\r?\n/g, ' ');
374
+ var value = ('' + row[1]).replace(/\r?\n/g, ' ');
375
+ return {
376
+ label: self.applyStyles(label, labelStyles),
377
+ value: self.applyStyles(value, valueStyles)
378
+ };
379
+ } );
380
+
381
+ var renderUnit = function(unit) {
382
+ // The interior layout is: value, blank, label.
383
+ var top = self.applyStyles(
384
+ '┌' + self.repeat('─', innerWidth) + '┐', borderStyles
385
+ );
386
+ var bottom = self.applyStyles(
387
+ '└' + self.repeat('─', innerWidth) + '┘', borderStyles
388
+ );
389
+ var leftBorder = self.applyStyles('│', borderStyles);
390
+ var rightBorder = self.applyStyles('│', borderStyles);
391
+ var blank = leftBorder + self.space(innerWidth) + rightBorder;
392
+
393
+ return [
394
+ top,
395
+ leftBorder + centerCell(truncate(unit.value)) + rightBorder,
396
+ blank,
397
+ leftBorder + centerCell(truncate(unit.label)) + rightBorder,
398
+ bottom
399
+ ];
400
+ };
401
+
402
+ var output = [];
403
+ for (var rowIdx = 0; rowIdx < units.length; rowIdx += numCols) {
404
+ var gridRow = units.slice(rowIdx, rowIdx + numCols).map(renderUnit);
405
+
406
+ // Join corresponding lines from each unit to form one complete grid row.
407
+ for (var lineIdx = 0; lineIdx < gridRow[0].length; lineIdx++) {
408
+ output.push(
409
+ indent + gridRow.map( function(unit) {
410
+ return unit[lineIdx];
411
+ } ).join(self.space(gap))
412
+ );
413
+ }
414
+
415
+ // Adjacent text lines already provide one row of vertical separation, so
416
+ // subtract one when translating the horizontal gap into blank lines.
417
+ if (rowIdx + numCols < units.length) {
418
+ for (var gapIdx = 0; gapIdx < Math.max(0, gap - 1); gapIdx++) output.push('');
419
+ }
420
+ }
421
+
422
+ return output.join("\n");
423
+ },
424
+
200
425
  applyStyles: function(text, styles) {
201
426
  // apply one or more chalk styles or functions to text string
202
427
  if (!styles) return text;
@@ -487,7 +712,7 @@ var cli = module.exports = {
487
712
  global.Tools = Tools;
488
713
 
489
714
  // bind wrap functions
490
- ["prompt", "yesno", "table", "box", "wrap", "center", "print", "println", "verbose", "verboseln", "warn", "warnln", "die", "dieln", "loadFile", "saveFile", "appendFile"].forEach( function(func) {
715
+ ["prompt", "yesno", "table", "box", "defList", "dashGrid", "wrap", "center", "print", "println", "verbose", "verboseln", "warn", "warnln", "die", "dieln", "loadFile", "saveFile", "appendFile"].forEach( function(func) {
491
716
  global[func] = self[func].bind(self);
492
717
  } );
493
718
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixl-cli",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
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",
package/util.js CHANGED
@@ -32,8 +32,36 @@ function splitAnsiGraphemes(text) {
32
32
  return units;
33
33
  }
34
34
 
35
+ function preserveAnsiLineStyles(text) {
36
+ // A styled multi-line string normally relies on terminal modes carrying across
37
+ // newline characters. Callers such as cli.box() insert independently styled
38
+ // borders between those lines, whose reset codes can cancel the content styles.
39
+ // Close all modes at each line ending, then restore the exact SGR state after the
40
+ // next border by replaying the original SGR history at the next line's start.
41
+ var lines = text.split('\n');
42
+ if (lines.length < 2) return text;
43
+
44
+ var sgrHistory = '';
45
+ var sgrPattern = /^(?:\u001B\[|\u009B)[0-9:;]*m$/;
46
+ var reset = '\u001b[0m';
47
+
48
+ return lines.map( function(line, idx) {
49
+ var reopen = sgrHistory;
50
+
51
+ // Only sequences from the original text enter the history. Synthetic reset
52
+ // and replay sequences added here must not accumulate on subsequent lines.
53
+ splitAnsiGraphemes(line).forEach( function(unit) {
54
+ if (unit.ansi && unit.text.match(sgrPattern)) sgrHistory += unit.text;
55
+ } );
56
+
57
+ if ((idx < lines.length - 1) && sgrHistory) line += reset;
58
+ return reopen + line;
59
+ } ).join('\n');
60
+ }
61
+
35
62
  module.exports = {
36
63
  ansiPattern: ansiPattern,
37
64
  graphemeSegmenter: graphemeSegmenter,
38
- splitAnsiGraphemes: splitAnsiGraphemes
65
+ splitAnsiGraphemes: splitAnsiGraphemes,
66
+ preserveAnsiLineStyles: preserveAnsiLineStyles
39
67
  };
package/width.js CHANGED
@@ -79,6 +79,26 @@ function truncate(text, width, suffix) {
79
79
  var units = Util.splitAnsiGraphemes(text);
80
80
  var trailingAnsi = [];
81
81
 
82
+ // cli.emoji() surrounds one grapheme with cursor save, restore and movement
83
+ // sequences. Treat this entire construct as one display unit so truncation
84
+ // can never keep the save sequence while discarding its matching restore.
85
+ for (var idx = 0; idx <= units.length - 4; idx++) {
86
+ if (
87
+ units[idx].ansi && (units[idx].text === '\u001b7') &&
88
+ !units[idx + 1].ansi &&
89
+ units[idx + 2].ansi && (units[idx + 2].text === '\u001b8') &&
90
+ units[idx + 3].ansi && (units[idx + 3].text === '\u001b[2C')
91
+ ) {
92
+ var emojiUnit = units.slice(idx, idx + 4).map( function(unit) {
93
+ return unit.text;
94
+ } ).join('');
95
+ units.splice(idx, 4, {
96
+ text: emojiUnit,
97
+ ansi: false
98
+ });
99
+ }
100
+ }
101
+
82
102
  for (var idx = units.length - 1; idx >= 0; idx--) {
83
103
  if (!units[idx].ansi) break;
84
104
  if (units[idx].text.match(/^(?:\u001B\[|\u009B)[^m]*m$/)) {