UI improvements
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user