DisplayElement.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import Element from './Element.js'
  2. export default class DisplayElement extends Element {
  3. // A general class that handles dealing with screen coordinates, the tree
  4. // of elements, and other common stuff.
  5. //
  6. // This element doesn't handle any real rendering; just layouts. Placing
  7. // characters at specific positions should be implemented in subclasses.
  8. //
  9. // It's a subclass of EventEmitter, so you can make your own events within
  10. // the logic of your subclass.
  11. static drawValues = Symbol('drawValues')
  12. static lastDrawValues = Symbol('lastDrawValues')
  13. static scheduledDraw = Symbol('scheduledDraw')
  14. constructor() {
  15. super()
  16. this[DisplayElement.drawValues] = {}
  17. this[DisplayElement.lastDrawValues] = {}
  18. this[DisplayElement.scheduledDraw] = false
  19. this.visible = true
  20. this.x = 0
  21. this.y = 0
  22. this.w = 0
  23. this.h = 0
  24. this.hPadding = 0
  25. this.vPadding = 0
  26. // Note! This only applies to the parent, not the children. Useful for
  27. // when you want an element to cover the whole screen but allow mouse
  28. // events to pass through.
  29. this.clickThrough = false
  30. }
  31. drawTo(writable) {
  32. // Writes text to a "writable" - an object that has a "write" method.
  33. // Custom rendering should be handled as an override of this method in
  34. // subclasses of DisplayElement.
  35. }
  36. renderTo(writable) {
  37. // Like drawTo, but only calls drawTo if the element is visible. Use this
  38. // with your root element, not drawTo.
  39. if (!this.visible) {
  40. return
  41. }
  42. const causeRenderEl = this.shouldRender()
  43. if (causeRenderEl) {
  44. this.drawTo(writable)
  45. this.renderChildrenTo(writable)
  46. this.didRenderTo(writable)
  47. } else {
  48. this.renderChildrenTo(writable)
  49. }
  50. }
  51. shouldRender() {
  52. // WIP! Until this implementation is finished, always return true (or else
  53. // lots of rendering breaks).
  54. /*
  55. return (
  56. this[DisplayElement.scheduledDraw] ||
  57. [...this.directAncestors].find(el => el.shouldRender())
  58. )
  59. */
  60. return true
  61. }
  62. renderChildrenTo(writable) {
  63. // Renders all of the children to a writable.
  64. for (const child of this.children) {
  65. child.renderTo(writable)
  66. }
  67. }
  68. didRenderTo(writable) {
  69. // Called immediately after rendering this element AND all of its
  70. // children. If you need to do something when that happens, override this
  71. // method in your subclass.
  72. //
  73. // It's fine to draw more things to the writable here - just keep in mind
  74. // that it'll be drawn over this element and its children, but not any
  75. // elements drawn in the future.
  76. }
  77. fixLayout() {
  78. // Adjusts the layout of children in this element. If your subclass has
  79. // any children in it, you should override this method.
  80. }
  81. fixAllLayout() {
  82. // Runs fixLayout on this as well as all children.
  83. this.fixLayout()
  84. for (const child of this.children) {
  85. child.fixAllLayout()
  86. }
  87. }
  88. confirmDrawValuesExists() {
  89. if (!this[DisplayElement.drawValues]) {
  90. this[DisplayElement.drawValues] = {}
  91. }
  92. }
  93. getDep(key) {
  94. this.confirmDrawValuesExists()
  95. return this[DisplayElement.drawValues][key]
  96. }
  97. setDep(key, value) {
  98. this.confirmDrawValuesExists()
  99. const oldValue = this[DisplayElement.drawValues][key]
  100. if (value !== this[DisplayElement.drawValues][key]) {
  101. this[DisplayElement.drawValues][key] = value
  102. this.scheduleDraw()
  103. // Grumble: technically it's possible for a root element to not be an
  104. // actual Root. While we don't check for this case most of the time (even
  105. // though we ought to), we do here because it's not unlikely for draw
  106. // dependency values to be changed before the element is actually added
  107. // to a Root element.
  108. if (this.root.scheduleRender) {
  109. this.root.scheduleRender()
  110. }
  111. }
  112. return value
  113. }
  114. scheduleDrawWithoutPropertyChange() {
  115. // Utility function for when you need to schedule a draw without updating
  116. // any particular draw-dependency property on the element. Works by setting
  117. // an otherwise unused dep to a unique object. (We can't use a symbol here,
  118. // because then Object.entries doesn't notice it.)
  119. this.setDep('drawWithoutProperty', Math.random())
  120. }
  121. scheduleDraw() {
  122. this[DisplayElement.scheduledDraw] = true
  123. }
  124. unscheduleDraw() {
  125. this[DisplayElement.scheduledDraw] = false
  126. }
  127. hasScheduledDraw() {
  128. if (this[DisplayElement.scheduledDraw]) {
  129. for (const [ key, value ] of Object.entries(this[DisplayElement.drawValues])) {
  130. if (value !== this[DisplayElement.lastDrawValues][key]) {
  131. return true
  132. }
  133. }
  134. }
  135. return false
  136. }
  137. updateLastDrawValues() {
  138. Object.assign(this[DisplayElement.lastDrawValues], this[DisplayElement.drawValues])
  139. }
  140. centerInParent() {
  141. // Utility function to center this element in its parent. Must be called
  142. // only when it has a parent. Set the width and height of the element
  143. // before centering it!
  144. if (this.parent === null) {
  145. throw new Error('Cannot center in parent when parent is null')
  146. }
  147. this.x = Math.round((this.parent.contentW - this.w) / 2)
  148. this.y = Math.round((this.parent.contentH - this.h) / 2)
  149. }
  150. fillParent() {
  151. // Utility function to fill this element in its parent. Must be called
  152. // only when it has a parent.
  153. if (this.parent === null) {
  154. throw new Error('Cannot fill parent when parent is null')
  155. }
  156. this.x = 0
  157. this.y = 0
  158. this.w = this.parent.contentW
  159. this.h = this.parent.contentH
  160. }
  161. fitToParent() {
  162. // Utility function to position this element so that it stays within its
  163. // parent's bounds. Must be called only when it has a parent.
  164. //
  165. // This function is useful when (and only when) the right or bottom edge
  166. // of this element may be past the right or bottom edge of its parent.
  167. // In such a case, the element will first be moved left or up by the
  168. // distance that its edge exceeds that of its parent, so that its edge is
  169. // no longer past the parent's. Then, if the left or top edge of the
  170. // element is less than zero, i.e. outside the parent, it is set to zero
  171. // and the element's width or height is adjusted so that it does not go
  172. // past the bounds of the parent.
  173. if (this.x + this.w > this.parent.right) {
  174. const offendExtent = (this.x + this.w) - this.parent.contentW
  175. this.x -= offendExtent
  176. if (this.x < 0) {
  177. const offstartExtent = 0 - this.x
  178. this.w -= offstartExtent
  179. this.x = 0
  180. }
  181. }
  182. if (this.y + this.h > this.parent.bottom) {
  183. const offendExtent = (this.y + this.h) - this.parent.contentH
  184. this.y -= offendExtent
  185. if (this.y < 0) {
  186. const offstartExtent = 0 - this.y
  187. this.h -= offstartExtent
  188. this.y = 0
  189. }
  190. }
  191. }
  192. getElementAt(x, y) {
  193. // Gets the topmost element at the provided absolute coordinate.
  194. // Note that elements which are not visible or have the clickThrough
  195. // property set to true are not considered.
  196. const children = this.children.slice()
  197. // Start searching the last- (top-) rendered children first.
  198. children.reverse()
  199. for (const el of children) {
  200. if (!el.visible) {
  201. continue
  202. }
  203. const el2 = el.getElementAt(x, y)
  204. if (el2) {
  205. return el2
  206. }
  207. if (el.clickThrough) {
  208. continue
  209. }
  210. const { absX, absY, w, h } = el
  211. if (absX <= x && absX + w > x) {
  212. if (absY <= y && absY + h > y) {
  213. return el
  214. }
  215. }
  216. }
  217. return null
  218. }
  219. get x() { return this.getDep('x') }
  220. set x(v) { return this.setDep('x', v) }
  221. get y() { return this.getDep('y') }
  222. set y(v) { return this.setDep('y', v) }
  223. get hPadding() { return this.getDep('hPadding') }
  224. set hPadding(v) { return this.setDep('hPadding', v) }
  225. get vPadding() { return this.getDep('vPadding') }
  226. set vPadding(v) { return this.setDep('vPadding', v) }
  227. get visible() { return this.getDep('visible') }
  228. set visible(v) { return this.setDep('visible', v) }
  229. // Commented out because this doesn't fix any problems (at least ATM).
  230. // get parent() { return this.getDep('parent') }
  231. // set parent(v) { return this.setDep('parent', v) }
  232. get absX() {
  233. if (this.parent) {
  234. return this.parent.contentX + this.x
  235. } else {
  236. return this.x
  237. }
  238. }
  239. get absY() {
  240. if (this.parent) {
  241. return this.parent.contentY + this.y
  242. } else {
  243. return this.y
  244. }
  245. }
  246. // Where contents should be positioned.
  247. get contentX() { return this.absX + this.hPadding }
  248. get contentY() { return this.absY + this.vPadding }
  249. get contentW() { return this.w - this.hPadding * 2 }
  250. get contentH() { return this.h - this.vPadding * 2 }
  251. get left() { return this.x }
  252. get right() { return this.x + this.w }
  253. get top() { return this.y }
  254. get bottom() { return this.y + this.h }
  255. get absLeft() { return this.absX }
  256. get absRight() { return this.absX + this.w - 1 }
  257. get absTop() { return this.absY }
  258. get absBottom() { return this.absY + this.h - 1 }
  259. }