• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1var stringWidth = require('string-width')
2var stripAnsi = require('strip-ansi')
3var wrap = require('wrap-ansi')
4var align = {
5  right: alignRight,
6  center: alignCenter
7}
8var top = 0
9var right = 1
10var bottom = 2
11var left = 3
12
13function UI (opts) {
14  this.width = opts.width
15  this.wrap = opts.wrap
16  this.rows = []
17}
18
19UI.prototype.span = function () {
20  var cols = this.div.apply(this, arguments)
21  cols.span = true
22}
23
24UI.prototype.resetOutput = function () {
25  this.rows = []
26}
27
28UI.prototype.div = function () {
29  if (arguments.length === 0) this.div('')
30  if (this.wrap && this._shouldApplyLayoutDSL.apply(this, arguments)) {
31    return this._applyLayoutDSL(arguments[0])
32  }
33
34  var cols = []
35
36  for (var i = 0, arg; (arg = arguments[i]) !== undefined; i++) {
37    if (typeof arg === 'string') cols.push(this._colFromString(arg))
38    else cols.push(arg)
39  }
40
41  this.rows.push(cols)
42  return cols
43}
44
45UI.prototype._shouldApplyLayoutDSL = function () {
46  return arguments.length === 1 && typeof arguments[0] === 'string' &&
47    /[\t\n]/.test(arguments[0])
48}
49
50UI.prototype._applyLayoutDSL = function (str) {
51  var _this = this
52  var rows = str.split('\n')
53  var leftColumnWidth = 0
54
55  // simple heuristic for layout, make sure the
56  // second column lines up along the left-hand.
57  // don't allow the first column to take up more
58  // than 50% of the screen.
59  rows.forEach(function (row) {
60    var columns = row.split('\t')
61    if (columns.length > 1 && stringWidth(columns[0]) > leftColumnWidth) {
62      leftColumnWidth = Math.min(
63        Math.floor(_this.width * 0.5),
64        stringWidth(columns[0])
65      )
66    }
67  })
68
69  // generate a table:
70  //  replacing ' ' with padding calculations.
71  //  using the algorithmically generated width.
72  rows.forEach(function (row) {
73    var columns = row.split('\t')
74    _this.div.apply(_this, columns.map(function (r, i) {
75      return {
76        text: r.trim(),
77        padding: _this._measurePadding(r),
78        width: (i === 0 && columns.length > 1) ? leftColumnWidth : undefined
79      }
80    }))
81  })
82
83  return this.rows[this.rows.length - 1]
84}
85
86UI.prototype._colFromString = function (str) {
87  return {
88    text: str,
89    padding: this._measurePadding(str)
90  }
91}
92
93UI.prototype._measurePadding = function (str) {
94  // measure padding without ansi escape codes
95  var noAnsi = stripAnsi(str)
96  return [0, noAnsi.match(/\s*$/)[0].length, 0, noAnsi.match(/^\s*/)[0].length]
97}
98
99UI.prototype.toString = function () {
100  var _this = this
101  var lines = []
102
103  _this.rows.forEach(function (row, i) {
104    _this.rowToString(row, lines)
105  })
106
107  // don't display any lines with the
108  // hidden flag set.
109  lines = lines.filter(function (line) {
110    return !line.hidden
111  })
112
113  return lines.map(function (line) {
114    return line.text
115  }).join('\n')
116}
117
118UI.prototype.rowToString = function (row, lines) {
119  var _this = this
120  var padding
121  var rrows = this._rasterize(row)
122  var str = ''
123  var ts
124  var width
125  var wrapWidth
126
127  rrows.forEach(function (rrow, r) {
128    str = ''
129    rrow.forEach(function (col, c) {
130      ts = '' // temporary string used during alignment/padding.
131      width = row[c].width // the width with padding.
132      wrapWidth = _this._negatePadding(row[c]) // the width without padding.
133
134      ts += col
135
136      for (var i = 0; i < wrapWidth - stringWidth(col); i++) {
137        ts += ' '
138      }
139
140      // align the string within its column.
141      if (row[c].align && row[c].align !== 'left' && _this.wrap) {
142        ts = align[row[c].align](ts, wrapWidth)
143        if (stringWidth(ts) < wrapWidth) ts += new Array(width - stringWidth(ts)).join(' ')
144      }
145
146      // apply border and padding to string.
147      padding = row[c].padding || [0, 0, 0, 0]
148      if (padding[left]) str += new Array(padding[left] + 1).join(' ')
149      str += addBorder(row[c], ts, '| ')
150      str += ts
151      str += addBorder(row[c], ts, ' |')
152      if (padding[right]) str += new Array(padding[right] + 1).join(' ')
153
154      // if prior row is span, try to render the
155      // current row on the prior line.
156      if (r === 0 && lines.length > 0) {
157        str = _this._renderInline(str, lines[lines.length - 1])
158      }
159    })
160
161    // remove trailing whitespace.
162    lines.push({
163      text: str.replace(/ +$/, ''),
164      span: row.span
165    })
166  })
167
168  return lines
169}
170
171function addBorder (col, ts, style) {
172  if (col.border) {
173    if (/[.']-+[.']/.test(ts)) return ''
174    else if (ts.trim().length) return style
175    else return '  '
176  }
177  return ''
178}
179
180// if the full 'source' can render in
181// the target line, do so.
182UI.prototype._renderInline = function (source, previousLine) {
183  var leadingWhitespace = source.match(/^ */)[0].length
184  var target = previousLine.text
185  var targetTextWidth = stringWidth(target.trimRight())
186
187  if (!previousLine.span) return source
188
189  // if we're not applying wrapping logic,
190  // just always append to the span.
191  if (!this.wrap) {
192    previousLine.hidden = true
193    return target + source
194  }
195
196  if (leadingWhitespace < targetTextWidth) return source
197
198  previousLine.hidden = true
199
200  return target.trimRight() + new Array(leadingWhitespace - targetTextWidth + 1).join(' ') + source.trimLeft()
201}
202
203UI.prototype._rasterize = function (row) {
204  var _this = this
205  var i
206  var rrow
207  var rrows = []
208  var widths = this._columnWidths(row)
209  var wrapped
210
211  // word wrap all columns, and create
212  // a data-structure that is easy to rasterize.
213  row.forEach(function (col, c) {
214    // leave room for left and right padding.
215    col.width = widths[c]
216    if (_this.wrap) wrapped = wrap(col.text, _this._negatePadding(col), { hard: true }).split('\n')
217    else wrapped = col.text.split('\n')
218
219    if (col.border) {
220      wrapped.unshift('.' + new Array(_this._negatePadding(col) + 3).join('-') + '.')
221      wrapped.push("'" + new Array(_this._negatePadding(col) + 3).join('-') + "'")
222    }
223
224    // add top and bottom padding.
225    if (col.padding) {
226      for (i = 0; i < (col.padding[top] || 0); i++) wrapped.unshift('')
227      for (i = 0; i < (col.padding[bottom] || 0); i++) wrapped.push('')
228    }
229
230    wrapped.forEach(function (str, r) {
231      if (!rrows[r]) rrows.push([])
232
233      rrow = rrows[r]
234
235      for (var i = 0; i < c; i++) {
236        if (rrow[i] === undefined) rrow.push('')
237      }
238      rrow.push(str)
239    })
240  })
241
242  return rrows
243}
244
245UI.prototype._negatePadding = function (col) {
246  var wrapWidth = col.width
247  if (col.padding) wrapWidth -= (col.padding[left] || 0) + (col.padding[right] || 0)
248  if (col.border) wrapWidth -= 4
249  return wrapWidth
250}
251
252UI.prototype._columnWidths = function (row) {
253  var _this = this
254  var widths = []
255  var unset = row.length
256  var unsetWidth
257  var remainingWidth = this.width
258
259  // column widths can be set in config.
260  row.forEach(function (col, i) {
261    if (col.width) {
262      unset--
263      widths[i] = col.width
264      remainingWidth -= col.width
265    } else {
266      widths[i] = undefined
267    }
268  })
269
270  // any unset widths should be calculated.
271  if (unset) unsetWidth = Math.floor(remainingWidth / unset)
272  widths.forEach(function (w, i) {
273    if (!_this.wrap) widths[i] = row[i].width || stringWidth(row[i].text)
274    else if (w === undefined) widths[i] = Math.max(unsetWidth, _minWidth(row[i]))
275  })
276
277  return widths
278}
279
280// calculates the minimum width of
281// a column, based on padding preferences.
282function _minWidth (col) {
283  var padding = col.padding || []
284  var minWidth = 1 + (padding[left] || 0) + (padding[right] || 0)
285  if (col.border) minWidth += 4
286  return minWidth
287}
288
289function getWindowWidth () {
290  if (typeof process === 'object' && process.stdout && process.stdout.columns) return process.stdout.columns
291}
292
293function alignRight (str, width) {
294  str = str.trim()
295  var padding = ''
296  var strWidth = stringWidth(str)
297
298  if (strWidth < width) {
299    padding = new Array(width - strWidth + 1).join(' ')
300  }
301
302  return padding + str
303}
304
305function alignCenter (str, width) {
306  str = str.trim()
307  var padding = ''
308  var strWidth = stringWidth(str.trim())
309
310  if (strWidth < width) {
311    padding = new Array(parseInt((width - strWidth) / 2, 10) + 1).join(' ')
312  }
313
314  return padding + str
315}
316
317module.exports = function (opts) {
318  opts = opts || {}
319
320  return new UI({
321    width: (opts || {}).width || getWindowWidth() || 80,
322    wrap: typeof opts.wrap === 'boolean' ? opts.wrap : true
323  })
324}
325