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