index.js 7.16 KB
var wrap = require('wordwrap'),
  align = {
    right: require('right-align'),
    center: require('center-align')
  },
  top = 0,
  right = 1,
  bottom = 2,
  left = 3

function UI (opts) {
  this.width = opts.width
  this.wrap = opts.wrap
  this.rows = []
}

UI.prototype.span = function () {
  var cols = this.div.apply(this, arguments)
  cols.span = true
}

UI.prototype.div = function () {
  if (arguments.length === 0) this.div('')
  if (this.wrap && this._shouldApplyLayoutDSL.apply(this, arguments)) {
    return this._applyLayoutDSL(arguments[0])
  }

  var cols = []

  for (var i = 0, arg; (arg = arguments[i]) !== undefined; i++) {
    if (typeof arg === 'string') cols.push(this._colFromString(arg))
    else cols.push(arg)
  }

  this.rows.push(cols)
  return cols
}

UI.prototype._shouldApplyLayoutDSL = function () {
  return arguments.length === 1 && typeof arguments[0] === 'string' &&
    /[\t\n]/.test(arguments[0])
}

UI.prototype._applyLayoutDSL = function (str) {
  var _this = this,
    rows = str.split('\n'),
    leftColumnWidth = 0

  // simple heuristic for layout, make sure the
  // second column lines up along the left-hand.
  // don't allow the first column to take up more
  // than 50% of the screen.
  rows.forEach(function (row) {
    var columns = row.split('\t')
    if (columns.length > 1 && columns[0].length > leftColumnWidth) {
      leftColumnWidth = Math.min(
        Math.floor(_this.width * 0.5),
        columns[0].length
      )
    }
  })

  // generate a table:
  //  replacing ' ' with padding calculations.
  //  using the algorithmically generated width.
  rows.forEach(function (row) {
    var columns = row.split('\t')
    _this.div.apply(_this, columns.map(function (r, i) {
      return {
        text: r.trim(),
        padding: [0, r.match(/\s*$/)[0].length, 0, r.match(/^\s*/)[0].length],
        width: (i === 0 && columns.length > 1) ? leftColumnWidth : undefined
      }
    }))
  })

  return this.rows[this.rows.length - 1]
}

UI.prototype._colFromString = function (str) {
  return {
    text: str
  }
}

UI.prototype.toString = function () {
  var _this = this,
    lines = []

  _this.rows.forEach(function (row, i) {
    _this.rowToString(row, lines)
  })

  // don't display any lines with the
  // hidden flag set.
  lines = lines.filter(function (line) {
    return !line.hidden
  })

  return lines.map(function (line) {
    return line.text
  }).join('\n')
}

UI.prototype.rowToString = function (row, lines) {
  var _this = this,
    paddingLeft,
    rrows = this._rasterize(row),
    str = '',
    ts,
    width,
    wrapWidth

  rrows.forEach(function (rrow, r) {
    str = ''
    rrow.forEach(function (col, c) {
      ts = '' // temporary string used during alignment/padding.
      width = row[c].width // the width with padding.
      wrapWidth = _this._negatePadding(row[c]) // the width without padding.

      for (var i = 0; i < Math.max(wrapWidth, col.length); i++) {
        ts += col.charAt(i) || ' '
      }

      // align the string within its column.
      if (row[c].align && row[c].align !== 'left' && _this.wrap) {
        ts = align[row[c].align](ts.trim() + '\n' + new Array(wrapWidth + 1).join(' '))
          .split('\n')[0]
        if (ts.length < wrapWidth) ts += new Array(width - ts.length).join(' ')
      }

      // add left/right padding and print string.
      paddingLeft = (row[c].padding || [0, 0, 0, 0])[left]
      if (paddingLeft) str += new Array(row[c].padding[left] + 1).join(' ')
      str += ts
      if (row[c].padding && row[c].padding[right]) str += new Array(row[c].padding[right] + 1).join(' ')

      // if prior row is span, try to render the
      // current row on the prior line.
      if (r === 0 && lines.length > 0) {
        str = _this._renderInline(str, lines[lines.length - 1], paddingLeft)
      }
    })

    // remove trailing whitespace.
    lines.push({
      text: str.replace(/ +$/, ''),
      span: row.span
    })
  })

  return lines
}

// if the full 'source' can render in
// the target line, do so.
UI.prototype._renderInline = function (source, previousLine, paddingLeft) {
  var target = previousLine.text,
    str = ''

  if (!previousLine.span) return source

  // if we're not applying wrapping logic,
  // just always append to the span.
  if (!this.wrap) {
    previousLine.hidden = true
    return target + source
  }

  for (var i = 0, tc, sc; i < Math.max(source.length, target.length); i++) {
    tc = target.charAt(i) || ' '
    sc = source.charAt(i) || ' '
    // we tried to overwrite a character in the other string.
    if (tc !== ' ' && sc !== ' ') return source
    // there is not enough whitespace to maintain padding.
    if (sc !== ' ' && i < paddingLeft + target.length) return source
    // :thumbsup:
    if (tc === ' ') str += sc
    else str += tc
  }

  previousLine.hidden = true

  return str
}

UI.prototype._rasterize = function (row) {
  var _this = this,
    i,
    rrow,
    rrows = [],
    widths = this._columnWidths(row),
    wrapped

  // word wrap all columns, and create
  // a data-structure that is easy to rasterize.
  row.forEach(function (col, c) {
    // leave room for left and right padding.
    col.width = widths[c]
    if (_this.wrap) wrapped = wrap.hard(_this._negatePadding(col))(col.text).split('\n')
    else wrapped = col.text.split('\n')

    // add top and bottom padding.
    if (col.padding) {
      for (i = 0; i < (col.padding[top] || 0); i++) wrapped.unshift('')
      for (i = 0; i < (col.padding[bottom] || 0); i++) wrapped.push('')
    }

    wrapped.forEach(function (str, r) {
      if (!rrows[r]) rrows.push([])

      rrow = rrows[r]

      for (var i = 0; i < c; i++) {
        if (rrow[i] === undefined) rrow.push('')
      }
      rrow.push(str)
    })
  })

  return rrows
}

UI.prototype._negatePadding = function (col) {
  var wrapWidth = col.width
  if (col.padding) wrapWidth -= (col.padding[left] || 0) + (col.padding[right] || 0)
  return wrapWidth
}

UI.prototype._columnWidths = function (row) {
  var _this = this,
    widths = [],
    unset = row.length,
    unsetWidth,
    remainingWidth = this.width

  // column widths can be set in config.
  row.forEach(function (col, i) {
    if (col.width) {
      unset--
      widths[i] = col.width
      remainingWidth -= col.width
    } else {
      widths[i] = undefined
    }
  })

  // any unset widths should be calculated.
  if (unset) unsetWidth = Math.floor(remainingWidth / unset)
  widths.forEach(function (w, i) {
    if (!_this.wrap) widths[i] = row[i].width || row[i].text.length
    else if (w === undefined) widths[i] = Math.max(unsetWidth, _minWidth(row[i]))
  })

  return widths
}

// calculates the minimum width of
// a column, based on padding preferences.
function _minWidth (col) {
  var padding = col.padding || []

  return 1 + (padding[left] || 0) + (padding[right] || 0)
}

module.exports = function (opts) {
  opts = opts || {}

  return new UI({
    width: (opts || {}).width || 80,
    wrap: typeof opts.wrap === 'boolean' ? opts.wrap : true
  })
}