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  }