// Copyright (c) 2026 Dominic Masters // // This software is released under the MIT License. // https://opensource.org/licenses/MIT // Gamepad/keyboard focus-navigation, replacing the old global uifocus_t // stack. A Menu's children still render/update through the normal tree // (add()), but navigation uses its own `items` list -- addItem() calls // add() and also tracks the widget as focusable, so decorative elements // (a title Label, a Rectangle background) just use add() directly and // never enter navigation. // // open()/close() push/pop this Menu on a small global stack -- only the // topmost entry consumes CANCEL/ACCEPT/direction input each tick, so a // Menu opened on top of another (e.g. a Settings sub-page menu, or a // Confirm dialog) doesn't fight it for input; everything underneath // keeps rendering/updating, just doesn't react to input until it's // topmost again. class Menu extends UIElement { init() { this.items = []; this.cols = 1; this.index = 0; this.onSelected = null; this.onChanged = null; this.onClosed = null; this._heldDir = null; this._heldTime = 0; } addItem(widget) { this.add(widget); this.items.push(widget); return widget; } open() { if(Menu._stack.indexOf(this) !== -1) return; Menu._stack.push(this); const item = this.items[this.index]; if(item && item.setHighlighted) item.setHighlighted(true); } // Closes this Menu and anything opened on top of it, matching the old // uifocus_t's uiFocusPopItem (popping a mid-stack item also pops // everything above it). close() { const idx = Menu._stack.indexOf(this); if(idx === -1) return; while(Menu._stack.length > idx) { Menu._stack.pop()._onClosed(); } } // Safety net: dispose() has no override-preserving trampoline the way // render()/update() do (see UIElement's dispose() docs), so a bare // this.menu.dispose() would leave a disposed instance stuck at the top // of Menu._stack forever if the caller forgot to close() first -- // always close() (a no-op if never opened) before the native cascade. dispose() { this.close(); super.dispose(); } _onClosed() { for(let i = 0; i < this.items.length; i++) { const item = this.items[i]; if(item.setHighlighted) item.setHighlighted(false); } if(this.onClosed) this.onClosed(); } update() { this.updateChildren(); if(Menu.top() !== this) return; if(Input.pressed(InputBind.CANCEL)) { this.close(); return; } if(Input.pressed(InputBind.ACCEPT) && this.onSelected) { this.onSelected(this.index, this.items[this.index]); } this._handleDirection(); } _handleDirection() { const dir = this._currentDirection(); if(!dir) { this._heldDir = null; this._heldTime = 0; return; } if(dir !== this._heldDir) { this._heldDir = dir; this._heldTime = 0; this._moveDirection(dir.dx, dir.dy); return; } this._heldTime += Time.delta; if(this._heldTime >= Menu.HOLD_DELAY) { this._heldTime -= Menu.HOLD_REPEAT; this._moveDirection(dir.dx, dir.dy); } } // Identity-stable direction markers so _handleDirection's `dir !== // this._heldDir` check works without allocating a fresh object (and // therefore never matching) every tick. _currentDirection() { if(Input.isDown(InputBind.LEFT)) return Menu.DIR_LEFT; if(Input.isDown(InputBind.RIGHT)) return Menu.DIR_RIGHT; if(Input.isDown(InputBind.UP)) return Menu.DIR_UP; if(Input.isDown(InputBind.DOWN)) return Menu.DIR_DOWN; return null; } // Gives the currently-highlighted item first refusal (a Slider/ // Dropdown/Checkbox consuming LEFT/RIGHT itself), falling back to // moving the cursor -- mirrors uifocus_t's per-item `direction` // callback. _moveDirection(dx, dy) { if(this.items.length === 0) return; const item = this.items[this.index]; if(item && typeof item.directionInput === 'function') { if(item.directionInput(dx, dy)) return; } this._moveIndex(dx, dy); } _moveIndex(dx, dy) { const count = this.items.length; if(count === 0) return; const cols = Math.max(1, this.cols); const rows = Math.ceil(count / cols); const col = ((this.index % cols) + dx + cols) % cols; const row = ((Math.floor(this.index / cols)) + dy + rows) % rows; let next = row * cols + col; if(next >= count) next = count - 1; this._setIndex(next); } _setIndex(next) { if(next === this.index) return; const oldItem = this.items[this.index]; if(oldItem && oldItem.setHighlighted) oldItem.setHighlighted(false); this.index = next; const newItem = this.items[this.index]; if(newItem && newItem.setHighlighted) newItem.setHighlighted(true); if(this.onChanged) this.onChanged(this.index, newItem); } } Menu._stack = []; Menu.top = function() { return Menu._stack.length > 0 ? Menu._stack[Menu._stack.length - 1] : null; }; Menu.HOLD_DELAY = 0.5; Menu.HOLD_REPEAT = 0.1; Menu.DIR_LEFT = { dx: -1, dy: 0 }; Menu.DIR_RIGHT = { dx: 1, dy: 0 }; Menu.DIR_UP = { dx: 0, dy: -1 }; Menu.DIR_DOWN = { dx: 0, dy: 1 }; module.exports = Menu;