index.js 8.1 KB

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