index.js 8.0 KB

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