1var stringWidth = require('string-width') 2var stripAnsi = require('strip-ansi') 3var wrap = require('wrap-ansi') 4var align = { 5 right: alignRight, 6 center: alignCenter 7} 8var top = 0 9var right = 1 10var bottom = 2 11var left = 3 12 13function UI (opts) { 14 this.width = opts.width 15 this.wrap = opts.wrap 16 this.rows = [] 17} 18 19UI.prototype.span = function () { 20 var cols = this.div.apply(this, arguments) 21 cols.span = true 22} 23 24UI.prototype.resetOutput = function () { 25 this.rows = [] 26} 27 28UI.prototype.div = function () { 29 if (arguments.length === 0) this.div('') 30 if (this.wrap && this._shouldApplyLayoutDSL.apply(this, arguments)) { 31 return this._applyLayoutDSL(arguments[0]) 32 } 33 34 var cols = [] 35 36 for (var i = 0, arg; (arg = arguments[i]) !== undefined; i++) { 37 if (typeof arg === 'string') cols.push(this._colFromString(arg)) 38 else cols.push(arg) 39 } 40 41 this.rows.push(cols) 42 return cols 43} 44 45UI.prototype._shouldApplyLayoutDSL = function () { 46 return arguments.length === 1 && typeof arguments[0] === 'string' && 47 /[\t\n]/.test(arguments[0]) 48} 49 50UI.prototype._applyLayoutDSL = function (str) { 51 var _this = this 52 var rows = str.split('\n') 53 var leftColumnWidth = 0 54 55 // simple heuristic for layout, make sure the 56 // second column lines up along the left-hand. 57 // don't allow the first column to take up more 58 // than 50% of the screen. 59 rows.forEach(function (row) { 60 var columns = row.split('\t') 61 if (columns.length > 1 && stringWidth(columns[0]) > leftColumnWidth) { 62 leftColumnWidth = Math.min( 63 Math.floor(_this.width * 0.5), 64 stringWidth(columns[0]) 65 ) 66 } 67 }) 68 69 // generate a table: 70 // replacing ' ' with padding calculations. 71 // using the algorithmically generated width. 72 rows.forEach(function (row) { 73 var columns = row.split('\t') 74 _this.div.apply(_this, columns.map(function (r, i) { 75 return { 76 text: r.trim(), 77 padding: _this._measurePadding(r), 78 width: (i === 0 && columns.length > 1) ? leftColumnWidth : undefined 79 } 80 })) 81 }) 82 83 return this.rows[this.rows.length - 1] 84} 85 86UI.prototype._colFromString = function (str) { 87 return { 88 text: str, 89 padding: this._measurePadding(str) 90 } 91} 92 93UI.prototype._measurePadding = function (str) { 94 // measure padding without ansi escape codes 95 var noAnsi = stripAnsi(str) 96 return [0, noAnsi.match(/\s*$/)[0].length, 0, noAnsi.match(/^\s*/)[0].length] 97} 98 99UI.prototype.toString = function () { 100 var _this = this 101 var lines = [] 102 103 _this.rows.forEach(function (row, i) { 104 _this.rowToString(row, lines) 105 }) 106 107 // don't display any lines with the 108 // hidden flag set. 109 lines = lines.filter(function (line) { 110 return !line.hidden 111 }) 112 113 return lines.map(function (line) { 114 return line.text 115 }).join('\n') 116} 117 118UI.prototype.rowToString = function (row, lines) { 119 var _this = this 120 var padding 121 var rrows = this._rasterize(row) 122 var str = '' 123 var ts 124 var width 125 var wrapWidth 126 127 rrows.forEach(function (rrow, r) { 128 str = '' 129 rrow.forEach(function (col, c) { 130 ts = '' // temporary string used during alignment/padding. 131 width = row[c].width // the width with padding. 132 wrapWidth = _this._negatePadding(row[c]) // the width without padding. 133 134 ts += col 135 136 for (var i = 0; i < wrapWidth - stringWidth(col); i++) { 137 ts += ' ' 138 } 139 140 // align the string within its column. 141 if (row[c].align && row[c].align !== 'left' && _this.wrap) { 142 ts = align[row[c].align](ts, wrapWidth) 143 if (stringWidth(ts) < wrapWidth) ts += new Array(width - stringWidth(ts)).join(' ') 144 } 145 146 // apply border and padding to string. 147 padding = row[c].padding || [0, 0, 0, 0] 148 if (padding[left]) str += new Array(padding[left] + 1).join(' ') 149 str += addBorder(row[c], ts, '| ') 150 str += ts 151 str += addBorder(row[c], ts, ' |') 152 if (padding[right]) str += new Array(padding[right] + 1).join(' ') 153 154 // if prior row is span, try to render the 155 // current row on the prior line. 156 if (r === 0 && lines.length > 0) { 157 str = _this._renderInline(str, lines[lines.length - 1]) 158 } 159 }) 160 161 // remove trailing whitespace. 162 lines.push({ 163 text: str.replace(/ +$/, ''), 164 span: row.span 165 }) 166 }) 167 168 return lines 169} 170 171function addBorder (col, ts, style) { 172 if (col.border) { 173 if (/[.']-+[.']/.test(ts)) return '' 174 else if (ts.trim().length) return style 175 else return ' ' 176 } 177 return '' 178} 179 180// if the full 'source' can render in 181// the target line, do so. 182UI.prototype._renderInline = function (source, previousLine) { 183 var leadingWhitespace = source.match(/^ */)[0].length 184 var target = previousLine.text 185 var targetTextWidth = stringWidth(target.trimRight()) 186 187 if (!previousLine.span) return source 188 189 // if we're not applying wrapping logic, 190 // just always append to the span. 191 if (!this.wrap) { 192 previousLine.hidden = true 193 return target + source 194 } 195 196 if (leadingWhitespace < targetTextWidth) return source 197 198 previousLine.hidden = true 199 200 return target.trimRight() + new Array(leadingWhitespace - targetTextWidth + 1).join(' ') + source.trimLeft() 201} 202 203UI.prototype._rasterize = function (row) { 204 var _this = this 205 var i 206 var rrow 207 var rrows = [] 208 var widths = this._columnWidths(row) 209 var wrapped 210 211 // word wrap all columns, and create 212 // a data-structure that is easy to rasterize. 213 row.forEach(function (col, c) { 214 // leave room for left and right padding. 215 col.width = widths[c] 216 if (_this.wrap) wrapped = wrap(col.text, _this._negatePadding(col), { hard: true }).split('\n') 217 else wrapped = col.text.split('\n') 218 219 if (col.border) { 220 wrapped.unshift('.' + new Array(_this._negatePadding(col) + 3).join('-') + '.') 221 wrapped.push("'" + new Array(_this._negatePadding(col) + 3).join('-') + "'") 222 } 223 224 // add top and bottom padding. 225 if (col.padding) { 226 for (i = 0; i < (col.padding[top] || 0); i++) wrapped.unshift('') 227 for (i = 0; i < (col.padding[bottom] || 0); i++) wrapped.push('') 228 } 229 230 wrapped.forEach(function (str, r) { 231 if (!rrows[r]) rrows.push([]) 232 233 rrow = rrows[r] 234 235 for (var i = 0; i < c; i++) { 236 if (rrow[i] === undefined) rrow.push('') 237 } 238 rrow.push(str) 239 }) 240 }) 241 242 return rrows 243} 244 245UI.prototype._negatePadding = function (col) { 246 var wrapWidth = col.width 247 if (col.padding) wrapWidth -= (col.padding[left] || 0) + (col.padding[right] || 0) 248 if (col.border) wrapWidth -= 4 249 return wrapWidth 250} 251 252UI.prototype._columnWidths = function (row) { 253 var _this = this 254 var widths = [] 255 var unset = row.length 256 var unsetWidth 257 var remainingWidth = this.width 258 259 // column widths can be set in config. 260 row.forEach(function (col, i) { 261 if (col.width) { 262 unset-- 263 widths[i] = col.width 264 remainingWidth -= col.width 265 } else { 266 widths[i] = undefined 267 } 268 }) 269 270 // any unset widths should be calculated. 271 if (unset) unsetWidth = Math.floor(remainingWidth / unset) 272 widths.forEach(function (w, i) { 273 if (!_this.wrap) widths[i] = row[i].width || stringWidth(row[i].text) 274 else if (w === undefined) widths[i] = Math.max(unsetWidth, _minWidth(row[i])) 275 }) 276 277 return widths 278} 279 280// calculates the minimum width of 281// a column, based on padding preferences. 282function _minWidth (col) { 283 var padding = col.padding || [] 284 var minWidth = 1 + (padding[left] || 0) + (padding[right] || 0) 285 if (col.border) minWidth += 4 286 return minWidth 287} 288 289function getWindowWidth () { 290 if (typeof process === 'object' && process.stdout && process.stdout.columns) return process.stdout.columns 291} 292 293function alignRight (str, width) { 294 str = str.trim() 295 var padding = '' 296 var strWidth = stringWidth(str) 297 298 if (strWidth < width) { 299 padding = new Array(width - strWidth + 1).join(' ') 300 } 301 302 return padding + str 303} 304 305function alignCenter (str, width) { 306 str = str.trim() 307 var padding = '' 308 var strWidth = stringWidth(str.trim()) 309 310 if (strWidth < width) { 311 padding = new Array(parseInt((width - strWidth) / 2, 10) + 1).join(' ') 312 } 313 314 return padding + str 315} 316 317module.exports = function (opts) { 318 opts = opts || {} 319 320 return new UI({ 321 width: (opts || {}).width || getWindowWidth() || 80, 322 wrap: typeof opts.wrap === 'boolean' ? opts.wrap : true 323 }) 324} 325