'use strict' const stringWidth = require('string-width') const stripAnsi = require('strip-ansi') const wrap = require('wrap-ansi') const align = { right: alignRight, center: alignCenter } const top = 0 const right = 1 const bottom = 2 const left = 3 class UI { constructor (opts) { this.width = opts.width this.wrap = opts.wrap this.rows = [] } span (...args) { const cols = this.div(...args) cols.span = true } resetOutput () { this.rows = [] } div (...args) { if (args.length === 0) { this.div('') } if (this.wrap && this._shouldApplyLayoutDSL(...args)) { return this._applyLayoutDSL(args[0]) } const cols = args.map(arg => { if (typeof arg === 'string') { return this._colFromString(arg) } return arg }) this.rows.push(cols) return cols } _shouldApplyLayoutDSL (...args) { return args.length === 1 && typeof args[0] === 'string' && /[\t\n]/.test(args[0]) } _applyLayoutDSL (str) { const rows = str.split('\n').map(row => row.split('\t')) let leftColumnWidth = 0 // simple heuristic for layout, make sure the // second column lines up along the left-hand. // don't allow the first column to take up more // than 50% of the screen. rows.forEach(columns => { if (columns.length > 1 && stringWidth(columns[0]) > leftColumnWidth) { leftColumnWidth = Math.min( Math.floor(this.width * 0.5), stringWidth(columns[0]) ) } }) // generate a table: // replacing ' ' with padding calculations. // using the algorithmically generated width. rows.forEach(columns => { this.div(...columns.map((r, i) => { return { text: r.trim(), padding: this._measurePadding(r), width: (i === 0 && columns.length > 1) ? leftColumnWidth : undefined } })) }) return this.rows[this.rows.length - 1] } _colFromString (text) { return { text, padding: this._measurePadding(text) } } _measurePadding (str) { // measure padding without ansi escape codes const noAnsi = stripAnsi(str) return [0, noAnsi.match(/\s*$/)[0].length, 0, noAnsi.match(/^\s*/)[0].length] } toString () { const lines = [] this.rows.forEach(row => { this.rowToString(row, lines) }) // don't display any lines with the // hidden flag set. return lines .filter(line => !line.hidden) .map(line => line.text) .join('\n') } rowToString (row, lines) { this._rasterize(row).forEach((rrow, r) => { let str = '' rrow.forEach((col, c) => { const { width } = row[c] // the width with padding. const wrapWidth = this._negatePadding(row[c]) // the width without padding. let ts = col // temporary string used during alignment/padding. if (wrapWidth > stringWidth(col)) { ts += ' '.repeat(wrapWidth - stringWidth(col)) } // align the string within its column. if (row[c].align && row[c].align !== 'left' && this.wrap) { ts = align[row[c].align](ts, wrapWidth) if (stringWidth(ts) < wrapWidth) { ts += ' '.repeat(width - stringWidth(ts) - 1) } } // apply border and padding to string. const padding = row[c].padding || [0, 0, 0, 0] if (padding[left]) { str += ' '.repeat(padding[left]) } str += addBorder(row[c], ts, '| ') str += ts str += addBorder(row[c], ts, ' |') if (padding[right]) { str += ' '.repeat(padding[right]) } // if prior row is span, try to render the // current row on the prior line. if (r === 0 && lines.length > 0) { str = this._renderInline(str, lines[lines.length - 1]) } }) // remove trailing whitespace. lines.push({ text: str.replace(/ +$/, ''), span: row.span }) }) return lines } // if the full 'source' can render in // the target line, do so. _renderInline (source, previousLine) { const leadingWhitespace = source.match(/^ */)[0].length const target = previousLine.text const targetTextWidth = stringWidth(target.trimRight()) if (!previousLine.span) { return source } // if we're not applying wrapping logic, // just always append to the span. if (!this.wrap) { previousLine.hidden = true return target + source } if (leadingWhitespace < targetTextWidth) { return source } previousLine.hidden = true return target.trimRight() + ' '.repeat(leadingWhitespace - targetTextWidth) + source.trimLeft() } _rasterize (row) { const rrows = [] const widths = this._columnWidths(row) let wrapped // word wrap all columns, and create // a data-structure that is easy to rasterize. row.forEach((col, c) => { // leave room for left and right padding. col.width = widths[c] if (this.wrap) { wrapped = wrap(col.text, this._negatePadding(col), { hard: true }).split('\n') } else { wrapped = col.text.split('\n') } if (col.border) { wrapped.unshift('.' + '-'.repeat(this._negatePadding(col) + 2) + '.') wrapped.push("'" + '-'.repeat(this._negatePadding(col) + 2) + "'") } // add top and bottom padding. if (col.padding) { wrapped.unshift(...new Array(col.padding[top] || 0).fill('')) wrapped.push(...new Array(col.padding[bottom] || 0).fill('')) } wrapped.forEach((str, r) => { if (!rrows[r]) { rrows.push([]) } const rrow = rrows[r] for (let i = 0; i < c; i++) { if (rrow[i] === undefined) { rrow.push('') } } rrow.push(str) }) }) return rrows } _negatePadding (col) { let wrapWidth = col.width if (col.padding) { wrapWidth -= (col.padding[left] || 0) + (col.padding[right] || 0) } if (col.border) { wrapWidth -= 4 } return wrapWidth } _columnWidths (row) { if (!this.wrap) { return row.map(col => { return col.width || stringWidth(col.text) }) } let unset = row.length let remainingWidth = this.width // column widths can be set in config. const widths = row.map(col => { if (col.width) { unset-- remainingWidth -= col.width return col.width } return undefined }) // any unset widths should be calculated. const unsetWidth = unset ? Math.floor(remainingWidth / unset) : 0 return widths.map((w, i) => { if (w === undefined) { return Math.max(unsetWidth, _minWidth(row[i])) } return w }) } } function addBorder (col, ts, style) { if (col.border) { if (/[.']-+[.']/.test(ts)) { return '' } if (ts.trim().length !== 0) { return style } return ' ' } return '' } // calculates the minimum width of // a column, based on padding preferences. function _minWidth (col) { const padding = col.padding || [] const minWidth = 1 + (padding[left] || 0) + (padding[right] || 0) if (col.border) { return minWidth + 4 } return minWidth } function getWindowWidth () { /* istanbul ignore next: depends on terminal */ if (typeof process === 'object' && process.stdout && process.stdout.columns) { return process.stdout.columns } } function alignRight (str, width) { str = str.trim() const strWidth = stringWidth(str) if (strWidth < width) { return ' '.repeat(width - strWidth) + str } return str } function alignCenter (str, width) { str = str.trim() const strWidth = stringWidth(str) /* istanbul ignore next */ if (strWidth >= width) { return str } return ' '.repeat((width - strWidth) >> 1) + str } module.exports = function (opts = {}) { return new UI({ width: opts.width || getWindowWidth() || /* istanbul ignore next */ 80, wrap: opts.wrap !== false }) }