7.5 KiB
UI Navigation Design
The four scenarios
- Two-panel navigation — left sidebar selects a tab, right panel shows content; left/right crosses panels
- Modal focus — when a modal opens the parent stops receiving input; closing restores it
- Nested modals — multiple stacked modals; each blocks the one below; back unwinds the stack
- 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/focusLostsignals — 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()setsvisible = true, pushes to stack (ifcanClose), then emitsopened.close()pops from stack (ifcanClose), hides, then emitsclosed._ready()callsset_process_unhandled_input(false)forcanClosemenus — 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
SidebarMenubase classGameMenurefactored to extendSidebarMenu