| '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 |
| }) |
| } |