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