« get me outta code hell

Root.js « ui - tui-lib - Pure Node.js library for making visual command-line programs (ala vim, ncdu)
about summary refs log tree commit diff
path: root/ui/Root.js
blob: 2b13203bace6975747c06cea39b9b9729690b677 (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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
const ansi = require('../util/ansi')
const telc = require('../util/telchars')

const DisplayElement = require('./DisplayElement')

const Form = require('./form/Form')

module.exports = class Root extends DisplayElement {
  // An element to be used as the root of a UI. Handles lots of UI and
  // socket stuff.

  constructor(interfacer, writable = null) {
    super()

    this.interfacer = interfacer
    this.writable = writable || interfacer

    this.selectedElement = null

    this.cursorBlinkOffset = Date.now()

    this.oldSelectionStates = []

    interfacer.on('inputData', buf => this.handleData(buf))

    this.renderCount = 0
  }

  handleData(buffer) {
    if (telc.isMouse(buffer)) {
      const allData = telc.parseMouse(buffer)
      const { button, line, col } = allData
      const topEl = this.getElementAt(col - 1, line - 1)
      if (topEl) {
        //console.log('Clicked', topEl.constructor.name, 'of', topEl.parent.constructor.name)
        this.eachAncestor(topEl, el => {
          if (typeof el.clicked === 'function') {
            return el.clicked(button, allData) === false
          }
        })
      }
    } else {
      this.eachAncestor(this.selectedElement, el => {
        if (typeof el.keyPressed === 'function') {
          const shouldBreak = (el.keyPressed(buffer) === false)
          if (shouldBreak) {
            return true
          }
          el.emit('keypressed', buffer)
        }
      })
    }
  }

  eachAncestor(topEl, func) {
    // Handy function for doing something to an element and all its ancestors,
    // allowing for the passed function to return false to break the loop and
    // stop propagation.

    if (topEl) {
      const els = [topEl, ...topEl.directAncestors]
      for (const el of els) {
        const shouldBreak = func(el)
        if (shouldBreak) {
          break
        }
      }
    }
  }

  drawTo(writable) {
    writable.write(ansi.moveCursor(0, 0))
    writable.write(' '.repeat(this.w * this.h))
  }

  scheduleRender() {
    if (!this.scheduledRender) {
      setTimeout(() => {
        this.scheduledRender = false
        this.render()
      })
      this.scheduledRender = true
    }
  }

  render() {
    this.renderTo(this.writable)
  }

  renderNow() {
    this.renderNowTo(this.writable)
  }

  renderTo(writable) {
    if (this.anyDescendantShouldRender()) {
      this.renderNowTo(writable)
    }
  }

  renderNowTo(writable) {
    if (writable) {
      this.renderCount++
      super.renderTo(writable)
      // Since shouldRender is false, super.renderTo won't call didRenderTo for
      // us. We need to do that ourselves.
      this.didRenderTo(writable)
    }
  }

  anyDescendantShouldRender() {
    let render = false
    this.eachDescendant(el => {
      // If we already know we're going to render, checking the element's
      // scheduled-draw status (which involves iterating over each of its draw
      // dependency properties) is redundant.
      if (render) {
        return
      }
      render = el.hasScheduledDraw()
    })
    return render
  }

  shouldRender() {
    // We need to return false here because otherwise all children will render,
    // since they'll see the root as an ancestor who needs to be rendered. Bad!
    return false
  }

  didRenderTo(writable) {
    this.eachDescendant(el => {
      el.unscheduleDraw()
      el.updateLastDrawValues()
    })

    /*
    writable.write(ansi.moveCursorRaw(1, 1))
    writable.write('Renders: ' + this.renderCount)
    */

    // Render the cursor, based on the cursorX and cursorY of the currently
    // selected element.
    if (this.selectedElement && this.selectedElement.cursorVisible) {
      /*
      if ((Date.now() - this.cursorBlinkOffset) % 1000 < 500) {
        writable.write(ansi.moveCursor(
          this.selectedElement.absCursorY, this.selectedElement.absCursorX))
        writable.write(ansi.invert())
        writable.write('I')
        writable.write(ansi.resetAttributes())
      }
      */

      writable.write(ansi.showCursor())
      writable.write(ansi.moveCursorRaw(
        this.selectedElement.absCursorY, this.selectedElement.absCursorX))
    } else {
      writable.write(ansi.hideCursor())
    }

    this.emit('rendered')
  }

  cursorMoved() {
    // Resets the blinking animation for the cursor. Call this whenever you
    // move the cursor.

    this.cursorBlinkOffset = Date.now()
  }

  select(el, {fromForm = false} = {}) {
    // Select an element. Calls the unfocus method on the already-selected
    // element, if there is one.

    // If the element is part of a form, just be lazy and pass control to that
    // form...unless the form itself asked us to select the element!
    // TODO: This is so that if an element is selected, its parent form will
    // automatically see that and correctly update its curIndex... but what if
    // the element is an input of a form which is NOT its parent?
    const parent = el.parent
    if (!fromForm && parent instanceof Form && parent.inputs.includes(el)) {
      parent.selectInput(el)
      return
    }

    const oldSelected = this.selectedElement
    const newSelected = el

    // Relevant elements that COULD have their "isSelected" state change.
    const relevantElements = ([
      ...(oldSelected ? [...oldSelected.directAncestors, oldSelected] : []),
      ...(newSelected ? newSelected.directAncestors : [])
    ]

      // We ignore elements where isSelected is undefined, because they aren't
      // built to handle being selected, and they break the compare-old-and-new-
      // state code below.
      .filter(el => typeof el.isSelected !== 'undefined')

      // Get rid of duplicates - including any that occurred in the already
      // existing array of selection states. (We only care about the oldest
      // selection state, i.e. the one when we did the first .select().)
      .reduce((acc, el) => {
        // Duplicates from relevant elements of current .select()
        if (acc.includes(el)) return acc
        // Duplicates from already existing selection states
        if (this.oldSelectionStates.some(x => x[0] === el)) return acc
        return acc.concat([el])
      }, []))

    // Keep track of whether those elements were selected before we call the
    // newly selected element's selected() function. We store these on a
    // property because we might actually be adding to it from a previous
    // root.select() call, if that one itself caused this root.select().
    // One all root.select()s in the "chain" (as it is) have finished, we'll
    // go through these states and call the appropriate .select/unselect()
    // functions on each element whose .isSelected changed.
    const selectionStates = relevantElements.map(el => [el, el.isSelected])
    this.oldSelectionStates = this.oldSelectionStates.concat(selectionStates)

    this.selectedElement = el

    // Same stuff as in the for loop below. We always call selected() on the
    // passed element, even if it was already selected before.
    if (el.selected) el.selected()
    if (typeof el.focused === 'function') el.focused()

    // If the selection changed as a result of the element's selected()
    // function, stop here. We will leave calling the appropriate functions on
    // the elements in the oldSelectionStates array to the final .select(),
    // i.e. the one which caused no change in selected element.
    if (this.selectedElement !== newSelected) return

    // Compare the old "isSelected" state of every relevant element with their
    // current "isSelected" state, and call the respective selected/unselected
    // functions. (Also call focused and unfocused for some sense of trying to
    // not break old programs, but, like, old programs are going to be broken
    // anyways.)
    const states = this.oldSelectionStates.slice()
    for (const [ el, wasSelected ] of states) {
      // Now that we'll have processed it, we don't want it in the array
      // anymore.
      this.oldSelectionStates.shift()

      const { isSelected } = el
      if (isSelected && !wasSelected) {
        // Don't call these functions if this element is the newly selected
        // one, because we already called them above!
        if (el !== newSelected) {
          if (el.selected) el.selected()
          if (typeof el.focused === 'function') el.focused()
        }
      } else if (wasSelected && !isSelected) {
        if (el.unselected) el.unselected()
        if (typeof el.unfocused === 'function') el.unfocused()
      }

      // If the (un)selected() handler actually selected a different element
      // itself, then further processing of new selected states is irrelevant,
      // so stop here. (We return instead of breaking the for loop because
      // anything after this loop would have already been handled by the call
      // to Root.select() from the (un)selected() handler.)
      if (this.selectedElement !== newSelected) {
        return
      }
    }

    this.cursorMoved()
  }

  isChildOrSelfSelected(el) {
    if (!this.selectedElement) return false
    if (this.selectedElement === el) return true
    if (this.selectedElement.directAncestors.includes(el)) return true
    return false
  }

  get selectedElement() { return this.getDep('selectedElement') }
  set selectedElement(v) { return this.setDep('selectedElement', v) }
}