aboutsummaryrefslogtreecommitdiffhomepage
path: root/api/services/zmk/layout.js
blob: c9141772ec0dbb0ebed5d6bd79a50b8745753d48 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
const isNumber = require('lodash/isNumber')

class InfoValidationError extends Error {
  constructor (errors) {
    super()
    this.name = 'InfoValidationError'
    this.errors = errors
  }
}

function renderTable (layout, layer, opts={}) {
  const {
    useQuotes = false,
    linePrefix = '',
    columnSeparator = ','
  } = opts
  const minWidth = useQuotes ? 9 : 7
  const table = layer.reduce((map, code, i) => {
    // TODO: this would be better as a loop over `layout`, checking for a
    // matching element in the `layer` array. Or, alternatively, an earlier
    // validation that asserts each layer is equal in length to the number of
    // keys in the layout.
    if (layout[i]) {
      const { row = 0, col } = layout[i]
      map[row] = map[row] || []
      map[row][col || map[row].length] = code
    }

    return map
  }, [])

  const columns = Math.max(...table.map(row => row.length))
  const columnIndices = '.'.repeat(columns-1).split('.').map((_, i) => i)
  const columnWidths = columnIndices.map(i => Math.max(
    ...table.map(row => (
      (row[i] || []).length
      + columnSeparator.length
      + (useQuotes ? 2 : 0) // wrapping with quotes adds 2 characters
      + (i === 6 ? 10 : 0) // sloppily add a little space between halves (right half starts at column 6)
    ))
  ))

  return table.map((row, rowIndex) => {
    const isLastRow = rowIndex === table.length - 1
    return linePrefix + columnIndices.map(i => {
      const noMoreValues = row.slice(i).every(col => col === undefined)
      const noFollowingValues = row.slice(i+1).every(col => col === undefined)
      const padding = Math.max(minWidth, columnWidths[i])

      if (noMoreValues) return ''
      if (!row[i]) return ' '.repeat(padding + 1)
      const column =  (useQuotes ? `"${row[i]}"` : row[i]).padStart(padding)
      const suffix = (isLastRow && noFollowingValues) ? '' : columnSeparator
      return column + suffix
    }).join('').replace(/\s+$/, '')
  }).join('\n')
}

function validateInfoJson(info) {
  const errors = []

  if (typeof info !== 'object' || info === null) {
    errors.push('info.json root must be an object')
  } else if (!info.layouts) {
    errors.push('info must define "layouts"')
  } else if (typeof info.layouts !== 'object' || info.layouts === null) {
    errors.push('layouts must be an object')
  } else if (Object.values(info.layouts).length === 0) {
    errors.push('layouts must define at least one layout')
  } else {
    for (let name in info.layouts) {
      const layout = info.layouts[name]
      if (typeof layout !== 'object' || layout === null) {
        errors.push(`layout ${name} must be an object`)
      } else if (!Array.isArray(layout.layout)) {
        errors.push(`layout ${name} must define "layout" array`)
      } else {
        const anyKeyHasPosition = layout.layout.some(key => (
          key?.row !== undefined ||
          key?.col !== undefined
        ))

        for (let i in layout.layout) {
          const key = layout.layout[i]
          const keyPath = `layouts[${name}].layout[${i}]`

          if (typeof key !== 'object' || key === null) {
            errors.push(`Key definition at ${keyPath} must be an object`)
          } else {
            const optionalNumberProps = ['u', 'h', 'r', 'rx', 'ry']
            if (!isNumber(key.x)) {
              errors.push(`Key definition at ${keyPath} must include "x" position`)
            }
            if (!isNumber(key.y)) {
              errors.push(`Key definition at ${keyPath} must include "y" position`)
            }
            for (let prop of optionalNumberProps) {
              if (prop in key && !isNumber(key[prop])) {
                errors.push(`Key definition at ${keyPath} optional "${prop}" must be number`)
              }
            }
            for (let prop of ['row', 'col']) {
              if (anyKeyHasPosition && !(prop in key)) {
                errors.push(`Key definition at ${keyPath} is missing "${prop}"`)
              } else if (prop in key && (!Number.isInteger(key[prop]) || key[prop] < 0)) {
                errors.push(`Key definition at ${keyPath} "${prop}" must be a non-negative integer`)
              }
            }
          }
        }
      }
    }
  }

  if (errors.length) {
    throw new InfoValidationError(errors)
  }
}

module.exports = {
  InfoValidationError,
  renderTable,
  validateInfoJson
}