Files
2026-06-14 10:19:31 -05:00

7.5 KiB

UI Navigation Design


The four scenarios

  1. Two-panel navigation — left sidebar selects a tab, right panel shows content; left/right crosses panels
  2. Modal focus — when a modal opens the parent stops receiving input; closing restores it
  3. Nested modals — multiple stacked modals; each blocks the one below; back unwinds the stack
  4. Mouse handling — only the topmost active layer accepts clicks; background layers are blocked

Core concept: Focus Stack

UIFocusStack (ui/UIFocusStack.gd) is a RefCounted that tracks an ordered stack of open ClosableMenu layers. Only the topmost layer processes input. When a layer is pushed, the one below is paused via set_process_unhandled_input(false); when popped, it resumes. The topmost layer always renders on top — z_index is set automatically on push/pop so visual order always matches input priority.

Lives at UI.FOCUS_STACK. Key methods: push(layer), pop(), top() -> ClosableMenu, isTop(layer) -> bool. Emits activeLayerChanged(layer) whenever the top changes; null means the stack is empty (world has focus).

Stack (bottom → top):
  [GameMenu]            z_index 10  ← paused while QuitDialog is open
  [QuitConfirmDialog]   z_index 20  ← top, owns input, renders on top
  [ModalBackdrop]       z_index 15  ← sits between them visually

ClosableMenu

ClosableMenu (ui/component/ClosableMenu.gd) is the base for all interactive menus. Key additions:

  • canClose:bool = true — when true, open()/close() push/pop the FocusStack. When false, the menu is purely passive (shown/hidden by code, no input ownership). Set in the Inspector or overridden in _ready().
  • focusGained / focusLost signals — emitted when the stack pushes/pops this layer.
  • _savedFocusNode — the focused Control captured in _onFocusLost(). Restored in _onFocusGained() so that pressing back from a dialog returns focus to the button that triggered it.
  • _grabInitialFocus() — override in subclasses to place focus on the right element when first opened (when no saved node exists).
  • open() sets visible = true, pushes to stack (if canClose), then emits opened.
  • close() pops from stack (if canClose), hides, then emits closed.
  • _ready() calls set_process_unhandled_input(false) for canClose menus — they start silenced and only enable input when on top of the stack.
Menu canClose Reason
GameMenu true Player opens and closes it
ConfirmDialog true Player dismisses it
PauseMenu true Player opens/closes via pause bind
DialogueTextbox false Dialogue system controls its lifetime
PauseSettings n/a Does not extend ClosableMenu — internal sub-panel

Z-indexing

UIFocusStack is the sole owner of z_index for all ClosableMenu layers. On push, z_index = stack_depth * 10. On pop, z_index resets to 0. ModalBackdrop always sits at (top z_index) - 5.

Never set z_index manually on a ClosableMenu — the stack manages it.


ModalBackdrop

ModalBackdrop (ui/component/ModalBackdrop.gd) connects to UI.FOCUS_STACK.activeLayerChanged in _ready(). When a layer is active it becomes visible, sets mouse_filter = MOUSE_FILTER_STOP (blocking all clicks on anything behind it), and sets its own z_index to (top layer z_index) - 5. When the stack empties it hides and resets to MOUSE_FILTER_IGNORE.

The register() method and _openOverlays tracking are removed — the FocusStack signal replaces that entirely. RootUI._ready() no longer calls register().


Scenario 1: Two-Panel Navigation (SidebarMenu)

SidebarMenu (to be created at ui/component/SidebarMenu.gd) extends ClosableMenu and manages two internal panels: a left sidebar (tab selector) and a right content panel. An _activePanel enum (SIDEBAR / CONTENT) tracks which panel currently owns controller/keyboard navigation. Both panels live in the same focus layer — no stack push/pop when crossing between them.

Controller / keyboard — routed through _unhandled_input, which checks _activePanel:

  • While SIDEBAR: UP/DOWN navigate sidebar items and update the content preview. RIGHT or ACCEPT calls _enterContent(). BACK closes the menu.
  • While CONTENT: UP/DOWN navigate content items (wraps). LEFT or BACK calls _exitContent(). ACCEPT activates the item.

Mouse — ignores _activePanel entirely. pressed signals on items fire regardless of which panel the controller is in. Each item's press handler calls _enterContent() or _exitContent() as appropriate before processing the selection — these are no-ops if the panel is already active.

ContentPanel protocol — each right-panel tab must implement grabFirstFocus(), releaseFocus(), and getSelectedIndex() -> int. No enforced base class; convention only.

GameMenu will extend SidebarMenu once SidebarMenu is built.


Scenario 2 & 3: Modal Focus and Nested Modals

Both are the same mechanism — one push vs. multiple. Opening a sub-layer calls layer.open() which pushes it; the layer below automatically loses input. Closing calls close() which pops; the layer below automatically resumes and _savedFocusNode is restored to wherever focus was when the sub-layer opened.

For 3-deep nesting (MainMenu → LoadGameModal → ConfirmDialog), each BACK unwinds one level. No special logic — the stack handles it.


Scenario 4: Mouse Handling

ModalBackdrop with MOUSE_FILTER_STOP eats all mouse events aimed at anything behind it. The topmost layer (higher z_index) renders above the backdrop and receives clicks normally. Works at any nesting depth.

Within a SidebarMenu, both panels are in the same layer so no backdrop is between them — mouse clicks always work on either panel.


World input and movement blocking

EntityMovement._canMove() and OverworldCamera._canOrbit() now check UI.FOCUS_STACK.top() != null — if any layer is active, movement and camera orbit are blocked. This replaces the previous ad-hoc UI.GAME_MENU.isOpen() check.

UI.activeConversation is kept separately for dialogue-mode blocking (dialogue does not use the FocusStack).


What was changed

File Change
ui/UIFocusStack.gd New — FocusStack manager
ui/component/ClosableMenu.gd Added canClose, focus signals, _onFocusGained/Lost, _savedFocusNode, _grabInitialFocus
ui/UISingleton.gd Added FOCUS_STACK (initialized via preload in _ready)
ui/component/ModalBackdrop.gd Rewritten — connects to FocusStack signal, sets MOUSE_FILTER_STOP and z_index
ui/RootUI.gd Removed modalBackdrop.register() calls
ui/component/ConfirmDialog.gd Extends ClosableMenu; _grabInitialFocus focuses No button; removed !isOpen guard
ui/gamemenu/GameMenu.gd Extends ClosableMenu; uses _grabInitialFocus; "menu" toggle via _input
ui/pause/PauseMenu.gd Extends ClosableMenu; removed visibility/dialog guards from _unhandled_input
ui/pause/PauseSettings.gd Unchanged — stays as Control (internal sub-panel, not in stack)
ui/mainmenu/MainMenu.gd settingsMenu.open() replaces direct isOpen set; removed _onSettingsOpened stub
scene/Pause.gd menu.isOpen()menu.isOpen (property)
overworld/entity/EntityMovement.gd _canMove checks FOCUS_STACK.top() != null
overworld/camera/OverworldCamera.gd _canOrbit checks FOCUS_STACK.top() != null

Still to implement

  • SidebarMenu base class
  • GameMenu refactored to extend SidebarMenu