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