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.

index.js 7.9KB

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