• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1const { info, debug } = require('./debug');
2const utils = require('./utils');
3
4class Cell {
5  /**
6   * A representation of a cell within the table.
7   * Implementations must have `init` and `draw` methods,
8   * as well as `colSpan`, `rowSpan`, `desiredHeight` and `desiredWidth` properties.
9   * @param options
10   * @constructor
11   */
12  constructor(options) {
13    this.setOptions(options);
14
15    /**
16     * Each cell will have it's `x` and `y` values set by the `layout-manager` prior to
17     * `init` being called;
18     * @type {Number}
19     */
20    this.x = null;
21    this.y = null;
22  }
23
24  setOptions(options) {
25    if (['boolean', 'number', 'string'].indexOf(typeof options) !== -1) {
26      options = { content: '' + options };
27    }
28    options = options || {};
29    this.options = options;
30    let content = options.content;
31    if (['boolean', 'number', 'string'].indexOf(typeof content) !== -1) {
32      this.content = String(content);
33    } else if (!content) {
34      this.content = this.options.href || '';
35    } else {
36      throw new Error('Content needs to be a primitive, got: ' + typeof content);
37    }
38    this.colSpan = options.colSpan || 1;
39    this.rowSpan = options.rowSpan || 1;
40    if (this.options.href) {
41      Object.defineProperty(this, 'href', {
42        get() {
43          return this.options.href;
44        },
45      });
46    }
47  }
48
49  mergeTableOptions(tableOptions, cells) {
50    this.cells = cells;
51
52    let optionsChars = this.options.chars || {};
53    let tableChars = tableOptions.chars;
54    let chars = (this.chars = {});
55    CHAR_NAMES.forEach(function (name) {
56      setOption(optionsChars, tableChars, name, chars);
57    });
58
59    this.truncate = this.options.truncate || tableOptions.truncate;
60
61    let style = (this.options.style = this.options.style || {});
62    let tableStyle = tableOptions.style;
63    setOption(style, tableStyle, 'padding-left', this);
64    setOption(style, tableStyle, 'padding-right', this);
65    this.head = style.head || tableStyle.head;
66    this.border = style.border || tableStyle.border;
67
68    this.fixedWidth = tableOptions.colWidths[this.x];
69    this.lines = this.computeLines(tableOptions);
70
71    this.desiredWidth = utils.strlen(this.content) + this.paddingLeft + this.paddingRight;
72    this.desiredHeight = this.lines.length;
73  }
74
75  computeLines(tableOptions) {
76    const tableWordWrap = tableOptions.wordWrap || tableOptions.textWrap;
77    const { wordWrap = tableWordWrap } = this.options;
78    if (this.fixedWidth && wordWrap) {
79      this.fixedWidth -= this.paddingLeft + this.paddingRight;
80      if (this.colSpan) {
81        let i = 1;
82        while (i < this.colSpan) {
83          this.fixedWidth += tableOptions.colWidths[this.x + i];
84          i++;
85        }
86      }
87      const { wrapOnWordBoundary: tableWrapOnWordBoundary = true } = tableOptions;
88      const { wrapOnWordBoundary = tableWrapOnWordBoundary } = this.options;
89      return this.wrapLines(utils.wordWrap(this.fixedWidth, this.content, wrapOnWordBoundary));
90    }
91    return this.wrapLines(this.content.split('\n'));
92  }
93
94  wrapLines(computedLines) {
95    const lines = utils.colorizeLines(computedLines);
96    if (this.href) {
97      return lines.map((line) => utils.hyperlink(this.href, line));
98    }
99    return lines;
100  }
101
102  /**
103   * Initializes the Cells data structure.
104   *
105   * @param tableOptions - A fully populated set of tableOptions.
106   * In addition to the standard default values, tableOptions must have fully populated the
107   * `colWidths` and `rowWidths` arrays. Those arrays must have lengths equal to the number
108   * of columns or rows (respectively) in this table, and each array item must be a Number.
109   *
110   */
111  init(tableOptions) {
112    let x = this.x;
113    let y = this.y;
114    this.widths = tableOptions.colWidths.slice(x, x + this.colSpan);
115    this.heights = tableOptions.rowHeights.slice(y, y + this.rowSpan);
116    this.width = this.widths.reduce(sumPlusOne, -1);
117    this.height = this.heights.reduce(sumPlusOne, -1);
118
119    this.hAlign = this.options.hAlign || tableOptions.colAligns[x];
120    this.vAlign = this.options.vAlign || tableOptions.rowAligns[y];
121
122    this.drawRight = x + this.colSpan == tableOptions.colWidths.length;
123  }
124
125  /**
126   * Draws the given line of the cell.
127   * This default implementation defers to methods `drawTop`, `drawBottom`, `drawLine` and `drawEmpty`.
128   * @param lineNum - can be `top`, `bottom` or a numerical line number.
129   * @param spanningCell - will be a number if being called from a RowSpanCell, and will represent how
130   * many rows below it's being called from. Otherwise it's undefined.
131   * @returns {String} The representation of this line.
132   */
133  draw(lineNum, spanningCell) {
134    if (lineNum == 'top') return this.drawTop(this.drawRight);
135    if (lineNum == 'bottom') return this.drawBottom(this.drawRight);
136    let content = utils.truncate(this.content, 10, this.truncate);
137    if (!lineNum) {
138      info(`${this.y}-${this.x}: ${this.rowSpan - lineNum}x${this.colSpan} Cell ${content}`);
139    } else {
140      // debug(`${lineNum}-${this.x}: 1x${this.colSpan} RowSpanCell ${content}`);
141    }
142    let padLen = Math.max(this.height - this.lines.length, 0);
143    let padTop;
144    switch (this.vAlign) {
145      case 'center':
146        padTop = Math.ceil(padLen / 2);
147        break;
148      case 'bottom':
149        padTop = padLen;
150        break;
151      default:
152        padTop = 0;
153    }
154    if (lineNum < padTop || lineNum >= padTop + this.lines.length) {
155      return this.drawEmpty(this.drawRight, spanningCell);
156    }
157    let forceTruncation = this.lines.length > this.height && lineNum + 1 >= this.height;
158    return this.drawLine(lineNum - padTop, this.drawRight, forceTruncation, spanningCell);
159  }
160
161  /**
162   * Renders the top line of the cell.
163   * @param drawRight - true if this method should render the right edge of the cell.
164   * @returns {String}
165   */
166  drawTop(drawRight) {
167    let content = [];
168    if (this.cells) {
169      //TODO: cells should always exist - some tests don't fill it in though
170      this.widths.forEach(function (width, index) {
171        content.push(this._topLeftChar(index));
172        content.push(utils.repeat(this.chars[this.y == 0 ? 'top' : 'mid'], width));
173      }, this);
174    } else {
175      content.push(this._topLeftChar(0));
176      content.push(utils.repeat(this.chars[this.y == 0 ? 'top' : 'mid'], this.width));
177    }
178    if (drawRight) {
179      content.push(this.chars[this.y == 0 ? 'topRight' : 'rightMid']);
180    }
181    return this.wrapWithStyleColors('border', content.join(''));
182  }
183
184  _topLeftChar(offset) {
185    let x = this.x + offset;
186    let leftChar;
187    if (this.y == 0) {
188      leftChar = x == 0 ? 'topLeft' : offset == 0 ? 'topMid' : 'top';
189    } else {
190      if (x == 0) {
191        leftChar = 'leftMid';
192      } else {
193        leftChar = offset == 0 ? 'midMid' : 'bottomMid';
194        if (this.cells) {
195          //TODO: cells should always exist - some tests don't fill it in though
196          let spanAbove = this.cells[this.y - 1][x] instanceof Cell.ColSpanCell;
197          if (spanAbove) {
198            leftChar = offset == 0 ? 'topMid' : 'mid';
199          }
200          if (offset == 0) {
201            let i = 1;
202            while (this.cells[this.y][x - i] instanceof Cell.ColSpanCell) {
203              i++;
204            }
205            if (this.cells[this.y][x - i] instanceof Cell.RowSpanCell) {
206              leftChar = 'leftMid';
207            }
208          }
209        }
210      }
211    }
212    return this.chars[leftChar];
213  }
214
215  wrapWithStyleColors(styleProperty, content) {
216    if (this[styleProperty] && this[styleProperty].length) {
217      try {
218        let colors = require('@colors/colors/safe');
219        for (let i = this[styleProperty].length - 1; i >= 0; i--) {
220          colors = colors[this[styleProperty][i]];
221        }
222        return colors(content);
223      } catch (e) {
224        return content;
225      }
226    } else {
227      return content;
228    }
229  }
230
231  /**
232   * Renders a line of text.
233   * @param lineNum - Which line of text to render. This is not necessarily the line within the cell.
234   * There may be top-padding above the first line of text.
235   * @param drawRight - true if this method should render the right edge of the cell.
236   * @param forceTruncationSymbol - `true` if the rendered text should end with the truncation symbol even
237   * if the text fits. This is used when the cell is vertically truncated. If `false` the text should
238   * only include the truncation symbol if the text will not fit horizontally within the cell width.
239   * @param spanningCell - a number of if being called from a RowSpanCell. (how many rows below). otherwise undefined.
240   * @returns {String}
241   */
242  drawLine(lineNum, drawRight, forceTruncationSymbol, spanningCell) {
243    let left = this.chars[this.x == 0 ? 'left' : 'middle'];
244    if (this.x && spanningCell && this.cells) {
245      let cellLeft = this.cells[this.y + spanningCell][this.x - 1];
246      while (cellLeft instanceof ColSpanCell) {
247        cellLeft = this.cells[cellLeft.y][cellLeft.x - 1];
248      }
249      if (!(cellLeft instanceof RowSpanCell)) {
250        left = this.chars['rightMid'];
251      }
252    }
253    let leftPadding = utils.repeat(' ', this.paddingLeft);
254    let right = drawRight ? this.chars['right'] : '';
255    let rightPadding = utils.repeat(' ', this.paddingRight);
256    let line = this.lines[lineNum];
257    let len = this.width - (this.paddingLeft + this.paddingRight);
258    if (forceTruncationSymbol) line += this.truncate || '…';
259    let content = utils.truncate(line, len, this.truncate);
260    content = utils.pad(content, len, ' ', this.hAlign);
261    content = leftPadding + content + rightPadding;
262    return this.stylizeLine(left, content, right);
263  }
264
265  stylizeLine(left, content, right) {
266    left = this.wrapWithStyleColors('border', left);
267    right = this.wrapWithStyleColors('border', right);
268    if (this.y === 0) {
269      content = this.wrapWithStyleColors('head', content);
270    }
271    return left + content + right;
272  }
273
274  /**
275   * Renders the bottom line of the cell.
276   * @param drawRight - true if this method should render the right edge of the cell.
277   * @returns {String}
278   */
279  drawBottom(drawRight) {
280    let left = this.chars[this.x == 0 ? 'bottomLeft' : 'bottomMid'];
281    let content = utils.repeat(this.chars.bottom, this.width);
282    let right = drawRight ? this.chars['bottomRight'] : '';
283    return this.wrapWithStyleColors('border', left + content + right);
284  }
285
286  /**
287   * Renders a blank line of text within the cell. Used for top and/or bottom padding.
288   * @param drawRight - true if this method should render the right edge of the cell.
289   * @param spanningCell - a number of if being called from a RowSpanCell. (how many rows below). otherwise undefined.
290   * @returns {String}
291   */
292  drawEmpty(drawRight, spanningCell) {
293    let left = this.chars[this.x == 0 ? 'left' : 'middle'];
294    if (this.x && spanningCell && this.cells) {
295      let cellLeft = this.cells[this.y + spanningCell][this.x - 1];
296      while (cellLeft instanceof ColSpanCell) {
297        cellLeft = this.cells[cellLeft.y][cellLeft.x - 1];
298      }
299      if (!(cellLeft instanceof RowSpanCell)) {
300        left = this.chars['rightMid'];
301      }
302    }
303    let right = drawRight ? this.chars['right'] : '';
304    let content = utils.repeat(' ', this.width);
305    return this.stylizeLine(left, content, right);
306  }
307}
308
309class ColSpanCell {
310  /**
311   * A Cell that doesn't do anything. It just draws empty lines.
312   * Used as a placeholder in column spanning.
313   * @constructor
314   */
315  constructor() {}
316
317  draw(lineNum) {
318    if (typeof lineNum === 'number') {
319      debug(`${this.y}-${this.x}: 1x1 ColSpanCell`);
320    }
321    return '';
322  }
323
324  init() {}
325
326  mergeTableOptions() {}
327}
328
329class RowSpanCell {
330  /**
331   * A placeholder Cell for a Cell that spans multiple rows.
332   * It delegates rendering to the original cell, but adds the appropriate offset.
333   * @param originalCell
334   * @constructor
335   */
336  constructor(originalCell) {
337    this.originalCell = originalCell;
338  }
339
340  init(tableOptions) {
341    let y = this.y;
342    let originalY = this.originalCell.y;
343    this.cellOffset = y - originalY;
344    this.offset = findDimension(tableOptions.rowHeights, originalY, this.cellOffset);
345  }
346
347  draw(lineNum) {
348    if (lineNum == 'top') {
349      return this.originalCell.draw(this.offset, this.cellOffset);
350    }
351    if (lineNum == 'bottom') {
352      return this.originalCell.draw('bottom');
353    }
354    debug(`${this.y}-${this.x}: 1x${this.colSpan} RowSpanCell for ${this.originalCell.content}`);
355    return this.originalCell.draw(this.offset + 1 + lineNum);
356  }
357
358  mergeTableOptions() {}
359}
360
361function firstDefined(...args) {
362  return args.filter((v) => v !== undefined && v !== null).shift();
363}
364
365// HELPER FUNCTIONS
366function setOption(objA, objB, nameB, targetObj) {
367  let nameA = nameB.split('-');
368  if (nameA.length > 1) {
369    nameA[1] = nameA[1].charAt(0).toUpperCase() + nameA[1].substr(1);
370    nameA = nameA.join('');
371    targetObj[nameA] = firstDefined(objA[nameA], objA[nameB], objB[nameA], objB[nameB]);
372  } else {
373    targetObj[nameB] = firstDefined(objA[nameB], objB[nameB]);
374  }
375}
376
377function findDimension(dimensionTable, startingIndex, span) {
378  let ret = dimensionTable[startingIndex];
379  for (let i = 1; i < span; i++) {
380    ret += 1 + dimensionTable[startingIndex + i];
381  }
382  return ret;
383}
384
385function sumPlusOne(a, b) {
386  return a + b + 1;
387}
388
389let CHAR_NAMES = [
390  'top',
391  'top-mid',
392  'top-left',
393  'top-right',
394  'bottom',
395  'bottom-mid',
396  'bottom-left',
397  'bottom-right',
398  'left',
399  'left-mid',
400  'mid',
401  'mid-mid',
402  'right',
403  'right-mid',
404  'middle',
405];
406
407module.exports = Cell;
408module.exports.ColSpanCell = ColSpanCell;
409module.exports.RowSpanCell = RowSpanCell;
410