diff --git a/.claude/docs/ui.md b/.claude/docs/ui.md index 1c07b75..1d1fc3f 100644 --- a/.claude/docs/ui.md +++ b/.claude/docs/ui.md @@ -10,7 +10,7 @@ RootUI (Control, fullscreen, always visible) │ └── GameMenuItemsTab ├── ChatBoxContainer │ └── InteractIndicator -├── ModalBackdrop ← shared backdrop; repositions dynamically +├── ModalBackdrop ← shared backdrop; z_index managed by FocusStack ├── PauseMenu │ ├── PauseMain │ └── PauseSettings @@ -18,7 +18,7 @@ RootUI (Control, fullscreen, always visible) └── MainMenuConfirmDialog ``` -Child order matters — later siblings render on top. `ModalBackdrop` shifts its own position in the tree at runtime to sit just below whichever modal overlay is currently topmost (see [ModalBackdrop](#modalbackdrop)). +Child order matters — later siblings render on top. `ModalBackdrop` stays at a fixed tree position; its `z_index` is driven automatically by the FocusStack (see [ModalBackdrop](#modalbackdrop)). `RootUI` is a permanent child of `RootScene` and registers itself with the `UI` singleton on `_enter_tree`. Access its children through the `UI` singleton. @@ -33,24 +33,38 @@ Child order matters — later siblings render on top. `ModalBackdrop` shifts its | `UI.PAUSE_MENU` | `PauseMenu` | Pause overlay; also pauses the scene tree | | `UI.QUIT_DIALOG` | `QuitConfirmDialog` | "Quit to desktop?" confirm; call `.open()` to show | | `UI.MAIN_MENU_DIALOG` | `ConfirmDialog` | "Return to main menu?" confirm; call `.open()` to show | -| `UI.BACKDROP` | `ModalBackdrop` | Shared semi-transparent backdrop; managed automatically | +| `UI.FOCUS_STACK` | `UIFocusStack` | Ordered stack of open `ClosableMenu` layers; only the top layer processes input | +| `UI.BACKDROP` | `ModalBackdrop` | Shared semi-transparent backdrop; driven by `FOCUS_STACK.activeLayerChanged` | | `UI.dialogueActive` | `bool` | `true` for the entire duration of a `DialogueAction` | | `UI.activeConversation` | `bool` | `true` only during a `CONVERSATION`-mode dialogue | | `UI.chatBoxContainer` | `Control` | Parent node for world-space dialogue textboxes | -`dialogueActive` is set by `DialogueManager` signals — it is broader than any single textbox being visible. Movement blocks on `dialogueActive` and on `UI.GAME_MENU.isOpen()`. +`dialogueActive` is set by `DialogueManager` signals — it is broader than any single textbox being visible. Movement and camera orbit block when `UI.FOCUS_STACK.top() != null` or `UI.activeConversation` is true. ## ClosableMenu -Base class for any togglable panel. Extends `Control`; the `isOpen:bool` export drives `visible`. +Base class for all togglable panels. Extends `Control`; the `isOpen:bool` export drives `visible`. ```gdscript -menu.open() # shows, emits opened -menu.close() # hides, emits closed +menu.open() # shows, pushes to FocusStack (if canClose), emits opened +menu.close() # pops from FocusStack (if canClose), hides, emits closed menu.toggle() ``` -Signals: `opened`, `closed`. +**Key exports / properties:** + +| Name | Default | Notes | +|---|---|---| +| `isOpen:bool` | `false` | Property — read directly (`menu.isOpen`), not a method | +| `canClose:bool` | `true` | When true, open/close interact with `UI.FOCUS_STACK` and input is only processed while on top. When false, the menu is passive — shown/hidden externally, never enters the stack. | + +**Signals:** `opened`, `closed`, `focusGained`, `focusLost`. + +**Focus management** (only relevant when `canClose = true`): +- `_grabInitialFocus()` — virtual; override to place focus on the correct element on first open. +- `_savedFocusNode` — focus owner is captured on `_onFocusLost()` and restored on `_onFocusGained()`, so pressing back from a sub-dialog returns focus to the button that opened it. +- A `gui_focus_changed` focus trap runs while the layer is on top — if focus escapes to a node outside this layer, it is snapped back. +- `set_process_unhandled_input(false)` is set in `_ready()` for canClose menus; input is only re-enabled via `_onFocusGained()` while the layer is on top of the stack. All new menus that need standard show/hide behaviour should extend `ClosableMenu`. @@ -83,26 +97,20 @@ func _ready() -> void: 1. Create a `.tscn` with `ConfirmDialog.gd` as script; set label text in the scene 2. Add `btnYes`/`btnNo` node path exports pointing to your two buttons 3. Add the instance to `RootUI.tscn` after `PauseMenu` (so it renders on top) -4. Register it with the backdrop in `RootUI._ready()`: `modalBackdrop.register(myDialog)` -5. Expose via `RootUI.gd` export + `UISingleton.gd` accessor if other systems need it -6. Connect `myDialog.confirmed` wherever the action should fire +4. Expose via `RootUI.gd` export + `UISingleton.gd` accessor if other systems need it +5. Connect `myDialog.confirmed` wherever the action should fire + +No manual backdrop registration needed — `ModalBackdrop` activates automatically when any `ClosableMenu` in RootUI enters the FocusStack. ## ModalBackdrop `res://ui/component/ModalBackdrop.gd` — a single fullscreen semi-transparent `ColorRect` shared across all modal overlays. -**How it works:** each registered overlay's `opened`/`closed` signals are connected. When any overlay opens, the backdrop becomes visible and `move_child()`s itself in the scene tree to sit immediately before the highest-index open overlay. When all overlays close, it hides. +**How it works:** `ModalBackdrop` connects to `UI.FOCUS_STACK.activeLayerChanged` in `_ready()`. When a layer becomes active it checks whether that layer is a direct sibling (i.e. a child of RootUI). If yes: backdrop becomes visible, sets `mouse_filter = MOUSE_FILTER_STOP` (blocking all clicks on anything behind it), and sets `z_index = layer.z_index - 5`. If the top layer is from a different parent (e.g. settings inside the main menu scene), or the stack empties, the backdrop hides. -**Result:** only one backdrop is ever visible, and it always sits between the game world (or lower overlays) and the frontmost open overlay — even when overlays are stacked (e.g. `QuitConfirmDialog` over `PauseMenu`). +**Result:** only one backdrop is ever visible, it always renders between the game world and the frontmost RootUI-level overlay, and it blocks mouse events from reaching anything behind it. -**Registering a new overlay:** - -```gdscript -# In RootUI._ready() — overlay must be a direct child of RootUI and have opened/closed signals -modalBackdrop.register(myOverlay) -``` - -`PauseMenu`, `QuitConfirmDialog`, and `MainMenuConfirmDialog` are all registered. +No registration is required — `ModalBackdrop` is self-contained. Do not call `register()` on it. ## AdvancedRichText @@ -130,15 +138,15 @@ JRPG-style in-game menu at `res://ui/gamemenu/`. Open with the `menu` input (**T Both tabs are populated dynamically on open; call `refresh()` on the active tab directly if data changes while the menu is already open. -**Key methods on `GameMenu`:** +**Key members on `GameMenu`:** -| Method | Effect | +| Member | Notes | |---|---| -| `open()` | Shows menu, refreshes active tab, grabs sidebar focus | -| `close()` | Hides menu | -| `isOpen() -> bool` | Visibility state | +| `open()` | Shows menu, refreshes active tab, pushes to FocusStack, grabs sidebar focus | +| `close()` | Pops from FocusStack, hides menu | +| `isOpen:bool` | Property — read directly, not a method | -`ui_cancel` or `menu` closes the menu. The `menu` input opens it only when `UI.dialogueActive` is false and the textbox is closed. +`ui_cancel` or `menu` closes the menu. `menu` is handled in `_input` (always fires) and opens the menu only when `UI.FOCUS_STACK.top() == null` and `UI.dialogueActive` is false. To add a new tab: add a value to `GameMenu.Tab`, create a tab scene/script under `ui/gamemenu/`, instance it in `GameMenu.tscn` as a sibling of the other tabs, add an `@export` for it in `GameMenu.gd`, and add the `match` branch in `_selectTab()`. @@ -146,17 +154,18 @@ To add a new tab: add a value to `GameMenu.Tab`, create a tab scene/script under `PauseMenu` wraps `PauseMain` (button list) and `PauseSettings` (settings tabs). Opening it calls `get_tree().paused = true`; closing restores it. -| Method | Effect | +| Member | Notes | |---|---| -| `PauseMenu.open()` | Pauses tree, shows container, opens PauseMain, emits `opened` | -| `PauseMenu.close()` | Unpauses tree, hides everything, emits `closed` | -| `PauseMenu.isOpen() -> bool` | Visibility state | +| `PauseMenu.open()` | Pushes to FocusStack, pauses tree, opens PauseMain, emits `opened` | +| `PauseMenu.close()` | Unpauses tree, closes sub-panels, pops from FocusStack, emits `closed` | +| `PauseMenu.isOpen:bool` | Property — read directly, not a method | **`ui_cancel` behaviour inside PauseMenu:** -- If `QuitConfirmDialog` or `MainMenuConfirmDialog` is open → ignored (the dialog handles it) - If `PauseSettings` is open → closes settings, reopens PauseMain - Otherwise → closes PauseMenu +(`QuitConfirmDialog` and `MainMenuConfirmDialog` sit above PauseMenu on the FocusStack and consume `ui_cancel` themselves — PauseMenu's `_unhandled_input` does not fire while they are open.) + **PauseMain buttons:** | Button | Behaviour | @@ -166,9 +175,9 @@ To add a new tab: add a value to `GameMenu.Tab`, create a tab scene/script under | Main Menu | Opens `MainMenuConfirmDialog`; on confirm → `SCENE.setScene(INITIAL)` | | Quit Game | Opens `QuitConfirmDialog`; on confirm → `get_tree().quit()` | -When either confirm dialog cancels, focus returns to the button that opened it. +When either confirm dialog closes, focus automatically returns to the button that opened it via `_savedFocusNode` in the FocusStack. -**Cannot open on main menu:** `Pause.gd` checks `SCENE.currentScene == INITIAL` and skips opening when already closed. +**Cannot open on main menu:** `Pause.gd` checks `SCENE.currentScene == INITIAL` and skips opening. ## Main menu @@ -204,7 +213,9 @@ Access via `UI.DEBUG_MENU`. Starts hidden. ## Adding a new menu 1. Create a scene whose root extends `ClosableMenu` (or `Control` if open/close isn't needed) -2. Add it as a child of `RootUI.tscn` — position after `PauseMenu` if it should render above it -3. Export a typed reference on `RootUI.gd` and wire it in the Inspector -4. Expose via a getter on `UISingleton.gd` if other systems need access (follow the `PAUSE_MENU` / `GAME_MENU` pattern) -5. If it needs a backdrop, register it: `modalBackdrop.register(myMenu)` in `RootUI._ready()` +2. Override `_grabInitialFocus()` to place focus on the first interactive element on open +3. Add it as a child of `RootUI.tscn` — position after `PauseMenu` if it should render above it +4. Export a typed reference on `RootUI.gd` and wire it in the Inspector +5. Expose via a getter on `UISingleton.gd` if other systems need access (follow the `PAUSE_MENU` / `GAME_MENU` pattern) + +`ModalBackdrop` activates automatically for any `ClosableMenu` that is a direct child of `RootUI`. No registration step needed. Internal sub-panels (like `PauseSettings`) should extend `Control` directly and not enter the FocusStack. diff --git a/ui/component/ClosableMenu.gd b/ui/component/ClosableMenu.gd index 43e8891..08f45e6 100644 --- a/ui/component/ClosableMenu.gd +++ b/ui/component/ClosableMenu.gd @@ -67,6 +67,11 @@ func _onFocusLost() -> void: func _onViewportFocusChanged(control:Control) -> void: if control == null or is_ancestor_of(control): return + var node:Node = control + while node != null: + if node is Popup: + return + node = node.get_parent() if _savedFocusNode != null and is_instance_valid(_savedFocusNode): _savedFocusNode.grab_focus() else: diff --git a/ui/component/TabMenu.gd b/ui/component/TabMenu.gd new file mode 100644 index 0000000..b01080a --- /dev/null +++ b/ui/component/TabMenu.gd @@ -0,0 +1,43 @@ +class_name TabMenu extends Control + +@export var tabs:TabBar +@export var tabControls:Array[Control] + +func _ready() -> void: + tabs.tab_changed.connect(_onTabChanged) + _onTabChanged(tabs.current_tab) + +func _notification(what:int) -> void: + if what == NOTIFICATION_VISIBILITY_CHANGED and visible: + tabs.grab_focus() + +func _input(event:InputEvent) -> void: + if !is_visible_in_tree(): + return + if event.is_action_pressed("tab_next"): + tabs.current_tab = (tabs.current_tab + 1) % tabs.tab_count + get_viewport().set_input_as_handled() + elif event.is_action_pressed("tab_prev"): + tabs.current_tab = (tabs.current_tab - 1 + tabs.tab_count) % tabs.tab_count + get_viewport().set_input_as_handled() + elif tabs.has_focus() and event.is_action_pressed("ui_accept"): + var idx:int = tabs.current_tab + if idx >= 0 and idx < tabControls.size() and _focusFirstIn(tabControls[idx]): + get_viewport().set_input_as_handled() + +func _onTabChanged(tabIndex:int) -> void: + for control in tabControls: + control.visible = false + if tabIndex >= 0 and tabIndex < tabControls.size(): + tabControls[tabIndex].visible = true + +func _focusFirstIn(container:Control) -> bool: + for child in container.get_children(): + if not child is Control: + continue + if child.focus_mode != Control.FOCUS_NONE and child.is_visible_in_tree(): + child.grab_focus() + return true + if _focusFirstIn(child): + return true + return false diff --git a/ui/component/TabMenu.gd.uid b/ui/component/TabMenu.gd.uid new file mode 100644 index 0000000..77775cd --- /dev/null +++ b/ui/component/TabMenu.gd.uid @@ -0,0 +1 @@ +uid://dacm5qwmmkcsm diff --git a/ui/pause/PauseMain.gd b/ui/pause/PauseMain.gd index baef3cc..ea0668b 100644 --- a/ui/pause/PauseMain.gd +++ b/ui/pause/PauseMain.gd @@ -14,8 +14,6 @@ func _ready() -> void: btnSettings.pressed.connect(settingsRequested.emit) btnMainMenu.pressed.connect(_showMainMenuConfirm) btnQuit.pressed.connect(_showQuitConfirm) - UI.QUIT_DIALOG.closed.connect(_onQuitDialogClosed) - UI.MAIN_MENU_DIALOG.closed.connect(_onMainMenuDialogClosed) func _showQuitConfirm() -> void: UI.QUIT_DIALOG.open() @@ -23,14 +21,6 @@ func _showQuitConfirm() -> void: func _showMainMenuConfirm() -> void: UI.MAIN_MENU_DIALOG.open() -func _onQuitDialogClosed() -> void: - if isOpen(): - btnQuit.grab_focus() - -func _onMainMenuDialogClosed() -> void: - if isOpen(): - btnMainMenu.grab_focus() - func open() -> void: visible = true btnResume.grab_focus() diff --git a/ui/settings/SettingsMenu.gd b/ui/settings/SettingsMenu.gd index f3c9770..1d03bea 100644 --- a/ui/settings/SettingsMenu.gd +++ b/ui/settings/SettingsMenu.gd @@ -1,9 +1,7 @@ -class_name SettingsMenu extends Control +class_name SettingsMenu extends TabMenu const TEXT_SPEED_VALUES:Array[float] = [0.2, 1.0, 2.0] -@export var tabs:TabBar -@export var tabControls:Array[Control] @export var checkInvertX:CheckBox @export var checkInvertY:CheckBox @export var sliderControllerSpeed:HSlider @@ -11,7 +9,7 @@ const TEXT_SPEED_VALUES:Array[float] = [0.2, 1.0, 2.0] @export var optionTextSpeed:OptionButton func _ready() -> void: - tabs.tab_changed.connect(onTabChanged) + super._ready() checkInvertX.button_pressed = SETTINGS.invertCameraX checkInvertY.button_pressed = SETTINGS.invertCameraY checkInvertX.toggled.connect(func(v:bool): SETTINGS.invertCameraX = v) @@ -22,39 +20,9 @@ func _ready() -> void: sliderMouseSpeed.value_changed.connect(func(v:float): SETTINGS.cameraSpeedMouse = v) optionTextSpeed.select(_textSpeedToIndex(SETTINGS.textSpeed)) optionTextSpeed.item_selected.connect(func(idx:int): SETTINGS.textSpeed = TEXT_SPEED_VALUES[idx]) - onTabChanged(tabs.current_tab) func _textSpeedToIndex(speed:float) -> int: match speed: 0.2: return 0 2.0: return 2 _: return 1 - -func _notification(what:int) -> void: - if what == NOTIFICATION_VISIBILITY_CHANGED and visible: - tabs.grab_focus() - -func _input(event:InputEvent) -> void: - if !is_visible_in_tree(): - return - if event.is_action_pressed("tab_next"): - tabs.current_tab = (tabs.current_tab + 1) % tabs.tab_count - get_viewport().set_input_as_handled() - elif event.is_action_pressed("tab_prev"): - tabs.current_tab = (tabs.current_tab - 1 + tabs.tab_count) % tabs.tab_count - get_viewport().set_input_as_handled() - -func onTabChanged(tabIndex:int) -> void: - for control in tabControls: - control.visible = false - if tabIndex >= 0 and tabIndex < tabControls.size(): - tabControls[tabIndex].visible = true - _focusFirstIn(tabControls[tabIndex]) - -func _focusFirstIn(container:Control) -> void: - if !is_visible_in_tree(): - return - for child in container.get_children(): - if child is Control and child.focus_mode != Control.FOCUS_NONE: - child.grab_focus() - return