var stringWidth = require('string-width') var stripAnsi = require('strip-ansi') var wrap = require('wrap-ansi') var align = { right: alignRight, center: alignCenter } var top = 0 var right = 1 var bottom = 2 var left = 3 function UI (opts) { this.width = opts.width this.wrap = opts.wrap this.rows = [] } UI.prototype.span = function () { var cols = this.div.apply(this, arguments) cols.span = true } UI.prototype.resetOutput = function () { this.rows = [] } UI.prototype.div = function () { if (arguments.length === 0) this.div('') if (this.wrap && this._shouldApplyLayoutDSL.apply(this, arguments)) { return this._applyLayoutDSL(arguments[0]) } var cols = [] for (var i = 0, arg; (arg = arguments[i]) !== undefined; i++) { if (typeof arg === 'string') cols.push(this._colFromString(arg)) else cols.push(arg) } this.rows.push(cols) return cols } UI.prototype._shouldApplyLayoutDSL = function () { return arguments.length === 1 && typeof arguments[0] === 'string' && /[\t\n]/.test(arguments[0]) } UI.prototype._applyLayoutDSL = function (str) { var _this = this var rows = str.split('\n') var 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(function (row) { var columns = row.split('\t') 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(function (row) { var columns = row.split('\t') _this.div.apply(_this, columns.map(function (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] } UI.prototype._colFromString = function (str) { return { text: str, padding: this._measurePadding(str) } } UI.prototype._measurePadding = function (str) { // measure padding without ansi escape codes var noAnsi = stripAnsi(str) return [0, noAnsi.match(/\s*$/)[0].length, 0, noAnsi.match(/^\s*/)[0].length] } UI.prototype.toString = function () { var _this = this var lines = [] _this.rows.forEach(function (row, i) { _this.rowToString(row, lines) }) // don't display any lines with the // hidden flag set. lines = lines.filter(function (line) { return !line.hidden }) return lines.map(function (line) { return line.text }).join('\n') } UI.prototype.rowToString = function (row, lines) { var _this = this var padding var rrows = this._rasterize(row) var str = '' var ts var width var wrapWidth rrows.forEach(function (rrow, r) { str = '' rrow.forEach(function (col, c) { ts = '' // temporary string used during alignment/padding. width = row[c].width // the width with padding. wrapWidth = _this._negatePadding(row[c]) // the width without padding. ts += col for (var i = 0; i < wrapWidth - stringWidth(col); i++) { ts += ' ' } // 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 += new Array(width - stringWidth(ts)).join(' ') } // apply border and padding to string. padding = row[c].padding || [0, 0, 0, 0] if (padding[left]) str += new Array(padding[left] + 1).join(' ') str += addBorder(row[c], ts, '| ') str += ts str += addBorder(row[c], ts, ' |') if (padding[right]) str += new Array(padding[right] + 1).join(' ') // 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 } function addBorder (col, ts, style) { if (col.border) { if (/[.']-+[.']/.test(ts)) return '' else if (ts.trim().length) return style else return ' ' } return '' } // if the full 'source' can render in // the target line, do so. UI.prototype._renderInline = function (source, previousLine) { var leadingWhitespace = source.match(/^ */)[0].length var target = previousLine.text var 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() + new Array(leadingWhitespace - targetTextWidth + 1).join(' ') + source.trimLeft() } UI.prototype._rasterize = function (row) { var _this = this var i var rrow var rrows = [] var widths = this._columnWidths(row) var wrapped // word wrap all columns, and create // a data-structure that is easy to rasterize. row.forEach(function (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('.' + new Array(_this._negatePadding(col) + 3).join('-') + '.') wrapped.push("'" + new Array(_this._negatePadding(col) + 3).join('-') + "'") } // add top and bottom padding. if (col.padding) { for (i = 0; i < (col.padding[top] || 0); i++) wrapped.unshift('') for (i = 0; i < (col.padding[bottom] || 0); i++) wrapped.push('') } wrapped.forEach(function (str, r) { if (!rrows[r]) rrows.push([]) rrow = rrows[r] for (var i = 0; i < c; i++) { if (rrow[i] === undefined) rrow.push('') } rrow.push(str) }) }) return rrows } UI.prototype._negatePadding = function (col) { var wrapWidth = col.width if (col.padding) wrapWidth -= (col.padding[left] || 0) + (col.padding[right] || 0) if (col.border) wrapWidth -= 4 return wrapWidth } UI.prototype._columnWidths = function (row) { var _this = this var widths = [] var unset = row.length var unsetWidth var remainingWidth = this.width // column widths can be set in config. row.forEach(function (col, i) { if (col.width) { unset-- widths[i] = col.width remainingWidth -= col.width } else { widths[i] = undefined } }) // any unset widths should be calculated. if (unset) unsetWidth = Math.floor(remainingWidth / unset) widths.forEach(function (w, i) { if (!_this.wrap) widths[i] = row[i].width || stringWidth(row[i].text) else if (w === undefined) widths[i] = Math.max(unsetWidth, _minWidth(row[i])) }) return widths } // calculates the minimum width of // a column, based on padding preferences. function _minWidth (col) { var padding = col.padding || [] var minWidth = 1 + (padding[left] || 0) + (padding[right] || 0) if (col.border) minWidth += 4 return minWidth } function getWindowWidth () { if (typeof process === 'object' && process.stdout && process.stdout.columns) return process.stdout.columns } function alignRight (str, width) { str = str.trim() var padding = '' var strWidth = stringWidth(str) if (strWidth < width) { padding = new Array(width - strWidth + 1).join(' ') } return padding + str } function alignCenter (str, width) { str = str.trim() var padding = '' var strWidth = stringWidth(str.trim()) if (strWidth < width) { padding = new Array(parseInt((width - strWidth) / 2, 10) + 1).join(' ') } return padding + str } module.exports = function (opts) { opts = opts || {} return new UI({ width: (opts || {}).width || getWindowWidth() || 80, wrap: typeof opts.wrap === 'boolean' ? opts.wrap : true }) }