You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
355 lines
8.0 KiB
355 lines
8.0 KiB
2 months ago
|
'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
|
||
|
})
|
||
|
}
|