Add controller support and safe main menu hooks
Enable gamepad navigation for the PrismaUI server browser and install MainMenu input hooks safely. Poll XInput each game-thread tick and forward button/stick changes to the browser (ForwardButtonEvent / ForwardThumbstickEvent), allocate an input-enable layer to suppress vanilla kMenu events while the browser is open, and register polling via the F4SE task interface. Replace direct vtable writes that caused a loading-screen crash with REL::Relocation::write_vfunc on the MainMenu BSInputEventUser secondary vtable and install OnButton/OnThumbstick hooks once at plugin Install(). Add MainMenu API to suppress inputEventHandling and call it when showing/hiding the browser. Also update PrismaUI browser JS to support controller focus for direct-connect, filter activation/cycling, improved controller prompts, and add CSS for focused direct-connect fields. Files touched include plugin/src/F4TMenuInput.cpp, plugin/src/F4TMainMenuInject.cpp, plugin/include headers, PrismaUI view JS/components, styles, and documentation.
This commit is contained in:
@@ -397,25 +397,46 @@ listWidget.Invoke("addEventListener", nullptr, args.data(), args.size());
|
|||||||
|
|
||||||
## Step 5 — Handle gamepad / Enter with `OnButtonEvent`
|
## Step 5 — Handle gamepad / Enter with `OnButtonEvent`
|
||||||
|
|
||||||
Mouse clicks go through `ITEM_PRESS`. Keyboard Enter and gamepad A go through `StartMenuBase::OnButtonEvent` (vtable index **`0x08`**).
|
Mouse clicks go through `ITEM_PRESS`. Keyboard Enter and gamepad A go through `IMenu::OnButtonEvent`, which lives on the **`BSInputEventUser` secondary vtable** (not the primary `StartMenuBase` vtable).
|
||||||
|
|
||||||
|
Do **not** hook `StartMenuBase::VTABLE[0x08]` for button input — on the primary vtable that index is `IMenu::SetIsTopButtonBar`, so the main menu will keep receiving navigation while your overlay is open.
|
||||||
|
|
||||||
|
Patch the MainMenu input vtable when the plugin loads (once), using `REL::Relocation` and `write_vfunc` so the write goes through `WriteSafeData` (the vtable lives in read-only memory — **do not** assign to vtable slots directly):
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
void OnButtonEventHook(RE::IMenu* a_this, const RE::ButtonEvent* a_event)
|
constexpr auto kBSInputOnThumbstickVtableIndex = 0x04;
|
||||||
|
constexpr auto kBSInputOnButtonVtableIndex = 0x08;
|
||||||
|
|
||||||
|
void InstallMainMenuInputHooks()
|
||||||
{
|
{
|
||||||
if (a_this && a_event && a_event->QJustPressed() && !a_event->QDisabled()) {
|
// MainMenu::VTABLE[1] is the BSInputEventUser secondary vtable.
|
||||||
if (a_event->QUserEvent() == "Accept"sv) {
|
REL::Relocation<std::uintptr_t> inputVtable{ RE::MainMenu::VTABLE[1] };
|
||||||
auto* ui = RE::UI::GetSingleton();
|
g_originalOnThumbstick = inputVtable.write_vfunc(
|
||||||
auto mainMenu = ui ? ui->GetMenu<RE::MainMenu>() : nullptr;
|
kBSInputOnThumbstickVtableIndex, &MainMenuOnThumbstickEventHook);
|
||||||
if (mainMenu && mainMenu.get() == a_this &&
|
g_originalOnButton = inputVtable.write_vfunc(
|
||||||
TryHandleCustomSelection(mainMenu.get())) {
|
kBSInputOnButtonVtableIndex, &MainMenuOnButtonEventHook);
|
||||||
return; // consumed — do not call vanilla
|
}
|
||||||
}
|
```
|
||||||
|
|
||||||
|
While your overlay is open, also set `mainMenu->inputEventHandlingEnabled = false` so `ProcessUserEvent` does not keep driving the vanilla list.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void MainMenuOnButtonEventHook(RE::BSInputEventUser* a_this, const RE::ButtonEvent* a_event)
|
||||||
|
{
|
||||||
|
if (BrowserIsVisible()) {
|
||||||
|
ForwardBrowserButton(a_event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a_event && a_event->QJustPressed() && a_event->QUserEvent() == "Accept"sv) {
|
||||||
|
auto* menu = reinterpret_cast<RE::IMenu*>(reinterpret_cast<char*>(a_this) - kBSInputEventUserOffset);
|
||||||
|
auto mainMenu = RE::UI::GetSingleton()->GetMenu<RE::MainMenu>();
|
||||||
|
if (mainMenu && mainMenu.get() == menu && TryHandleCustomSelection(mainMenu.get())) {
|
||||||
|
return; // consumed — do not call vanilla
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (g_originalOnButtonEvent) {
|
CallOriginalOnButtonEvent(a_this, a_event);
|
||||||
g_originalOnButtonEvent(a_this, a_event);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+105
@@ -7,6 +7,111 @@ failed experiments, successful tests, and next steps.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-18 - XInput polling for server browser gamepad
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Gamepad input still did not reach the PrismaUI browser because Fallout routes controller through the main-menu input stack, while keyboard arrows worked via the web view `keydown` handler. Added direct XInput polling while the browser is open.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `plugin/src/F4TMenuInput.cpp`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- Poll `XInputGetState` each game-thread tick while the server browser is active and forward button/stick changes through the existing `controllerInput` JS channel.
|
||||||
|
- Allocate a `BSInputEnableLayer` that disables `kMenu` user events while the browser is open so the title menu stops consuming gamepad input.
|
||||||
|
- Menu vtable hooks now only block/supplement menu-translated keyboard events; gamepad is handled by polling.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Plugin rebuild succeeded; in-game controller retest required.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- None currently known.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Verify D-pad, A/B/X/Y, shoulders, and left stick navigate/close the server browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-18 - Fix loading-screen crash from vtable patch
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Fixed a crash during the "Please stand by" / main-menu load caused by writing hook pointers directly into the read-only `BSInputEventUser` vtable.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `plugin/src/F4TMainMenuInject.cpp`
|
||||||
|
- `docs/MainMenu_Button_Injection_Guide.md`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- `CommonwealthOnline.log` stopped after `Running catch-up injection for already-open MainMenu` — crash was in the old `InstallMainMenuInputHooks` before the success log.
|
||||||
|
- Replaced raw `slots[i] = hook` with `REL::Relocation{ RE::MainMenu::VTABLE[1] }.write_vfunc(...)`.
|
||||||
|
- Hooks install once at plugin `Install()` instead of per menu open/close.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Plugin rebuild succeeded; in-game retest required.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- None currently known.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Confirm title screen loads and controller drives the server browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-18 - Fix Multiplayer Menu Controller Input Capture
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Fixed controller input staying on the vanilla main menu while the server browser was open. The previous hooks targeted the wrong vtable slots on `StartMenuBase` (index `0x08` is `SetIsTopButtonBar`, not `OnButtonEvent`).
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `plugin/src/F4TMainMenuInject.cpp`
|
||||||
|
- `plugin/include/F4TMainMenuInject.h`
|
||||||
|
- `plugin/src/F4TPrismaUI.cpp`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- Hook `BSInputEventUser::OnButtonEvent` / `OnThumbstickEvent` through the MainMenu secondary vtable at object offset `0x10`.
|
||||||
|
- While the browser is visible, forward gamepad/keyboard menu events to the PrismaUI browser and suppress vanilla `ProcessUserEvent` via `inputEventHandlingEnabled = false`.
|
||||||
|
- Restore hooks when MainMenu closes.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Plugin rebuild succeeded; in-game controller test still required.
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- None currently known beyond prior direct-connect text entry limitation.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Verify in-game with a controller on the title screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-18 - Multiplayer Menu Controller Support
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Wired full gamepad and keyboard-menu navigation for the PrismaUI server browser while it is open over the title-screen main menu. Main-menu input is now consumed so the vanilla list does not steal focus, and the browser UI handles tabs, filters, server list, and direct connect from a controller.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `plugin/src/F4TMenuInput.cpp`
|
||||||
|
- `plugin/include/F4TMenuInput.h`
|
||||||
|
- `plugin/src/F4TMainMenuInject.cpp`
|
||||||
|
- `ui/views/CommonwealthOnline/browser/app.js`
|
||||||
|
- `ui/views/CommonwealthOnline/browser/components/controller-prompts.js`
|
||||||
|
- `ui/views/CommonwealthOnline/browser/components/direct-connect.js`
|
||||||
|
- `ui/views/CommonwealthOnline/browser/styles.css`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- Added `ForwardButtonEvent` / `ForwardThumbstickEvent` helpers that map gamepad buttons, left-stick directions, and menu user events (`Accept`, `Cancel`, `Up`, etc.) into the existing `controllerInput` browser event channel.
|
||||||
|
- Hooked `StartMenuBase::OnThumbstickEvent` and extended `OnButtonEvent` to capture input while the server browser is visible, blocking vanilla main-menu navigation underneath.
|
||||||
|
- Browser UI: direct-connect field focus, filter change on A, updated controller prompts, and keyboard nav parity for direct connect.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Not run in-game locally (requires Fallout 4 + F4SE + rebuilt plugin + staged PrismaUI views).
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- Text entry in Direct Connect still requires keyboard/mouse; controller can select fields and press A to connect with existing values.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Add on-screen keyboard or character entry flow for direct-connect address editing with a gamepad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-06-17 - Fallout 4/Data Build Layout + Creation Kit Assets
|
## 2026-06-17 - Fallout 4/Data Build Layout + Creation Kit Assets
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ namespace RE
|
|||||||
namespace F4T::MainMenuInject
|
namespace F4T::MainMenuInject
|
||||||
{
|
{
|
||||||
bool IsMainMenuOnMainPanel(const RE::UI* a_ui);
|
bool IsMainMenuOnMainPanel(const RE::UI* a_ui);
|
||||||
|
void SetMainMenuInputSuppressed(bool a_suppressed);
|
||||||
void Install();
|
void Install();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
namespace RE
|
||||||
|
{
|
||||||
|
class ButtonEvent;
|
||||||
|
class ThumbstickEvent;
|
||||||
|
}
|
||||||
|
|
||||||
namespace F4T::MenuInput
|
namespace F4T::MenuInput
|
||||||
{
|
{
|
||||||
void Register();
|
void Register();
|
||||||
void SetBrowserActive(bool a_active);
|
void SetBrowserActive(bool a_active);
|
||||||
|
bool ForwardButtonEvent(const RE::ButtonEvent* a_event);
|
||||||
|
bool ForwardThumbstickEvent(const RE::ThumbstickEvent* a_event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "pch.h"
|
#include "pch.h"
|
||||||
|
|
||||||
#include "F4TMainMenuInject.h"
|
#include "F4TMainMenuInject.h"
|
||||||
|
#include "F4TMenuInput.h"
|
||||||
#include "F4TNetworking.h"
|
#include "F4TNetworking.h"
|
||||||
#include "F4TPrismaUI.h"
|
#include "F4TPrismaUI.h"
|
||||||
|
|
||||||
@@ -24,7 +25,9 @@ namespace F4T::MainMenuInject
|
|||||||
constexpr auto kLoadIndex = 4;
|
constexpr auto kLoadIndex = 4;
|
||||||
constexpr auto kResyncThrottleFrames = 30;
|
constexpr auto kResyncThrottleFrames = 30;
|
||||||
constexpr auto kItemPressListenerPriority = 100;
|
constexpr auto kItemPressListenerPriority = 100;
|
||||||
constexpr auto kOnButtonEventVtableIndex = 0x08;
|
constexpr auto kBSInputEventUserOffset = 0x10;
|
||||||
|
constexpr auto kBSInputOnThumbstickVtableIndex = 0x04;
|
||||||
|
constexpr auto kBSInputOnButtonVtableIndex = 0x08;
|
||||||
constexpr auto kEntryListPath = "MainPanel_mc.List_mc.entryList";
|
constexpr auto kEntryListPath = "MainPanel_mc.List_mc.entryList";
|
||||||
constexpr auto kListWidgetPath = "MainPanel_mc.List_mc";
|
constexpr auto kListWidgetPath = "MainPanel_mc.List_mc";
|
||||||
constexpr std::array<std::string_view, 3> kItemPressEventCandidates{
|
constexpr std::array<std::string_view, 3> kItemPressEventCandidates{
|
||||||
@@ -36,7 +39,7 @@ namespace F4T::MainMenuInject
|
|||||||
bool g_hooksInstalled = false;
|
bool g_hooksInstalled = false;
|
||||||
bool g_resyncTaskStarted = false;
|
bool g_resyncTaskStarted = false;
|
||||||
bool g_itemPressListenerRegistered = false;
|
bool g_itemPressListenerRegistered = false;
|
||||||
bool g_buttonEventHookInstalled = false;
|
bool g_mainMenuInputHooksInstalled = false;
|
||||||
int g_resyncFrameCounter = 0;
|
int g_resyncFrameCounter = 0;
|
||||||
int g_injectFailLogCooldown = 0;
|
int g_injectFailLogCooldown = 0;
|
||||||
|
|
||||||
@@ -618,29 +621,87 @@ namespace F4T::MainMenuInject
|
|||||||
eventNames.size()));
|
eventNames.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
using OnButtonEventFn = void (*)(RE::IMenu*, const RE::ButtonEvent*);
|
std::uintptr_t g_originalMainMenuOnButtonEvent = 0;
|
||||||
OnButtonEventFn g_originalOnButtonEvent = nullptr;
|
std::uintptr_t g_originalMainMenuOnThumbstickEvent = 0;
|
||||||
|
|
||||||
void OnButtonEventHook(RE::IMenu* a_this, const RE::ButtonEvent* a_event)
|
RE::IMenu* MainMenuFromInputUser(RE::BSInputEventUser* a_inputUser)
|
||||||
{
|
{
|
||||||
|
if (!a_inputUser) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reinterpret_cast<RE::IMenu*>(reinterpret_cast<char*>(a_inputUser) - kBSInputEventUserOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
using MainMenuOnButtonEventFn = void (*)(RE::BSInputEventUser*, const RE::ButtonEvent*);
|
||||||
|
using MainMenuOnThumbstickEventFn = void (*)(RE::BSInputEventUser*, const RE::ThumbstickEvent*);
|
||||||
|
|
||||||
|
void MainMenuOnButtonEventHook(RE::BSInputEventUser* a_this, const RE::ButtonEvent* a_event)
|
||||||
|
{
|
||||||
|
if (F4T::PrismaUI::IsBrowserVisible()) {
|
||||||
|
F4T::MenuInput::ForwardButtonEvent(a_event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (a_this && a_event && a_event->QJustPressed() && !a_event->QDisabled()) {
|
if (a_this && a_event && a_event->QJustPressed() && !a_event->QDisabled()) {
|
||||||
const auto& userEvent = a_event->QUserEvent();
|
const auto& userEvent = a_event->QUserEvent();
|
||||||
if (userEvent == "Accept"sv) {
|
if (userEvent == "Accept"sv) {
|
||||||
const auto* ui = RE::UI::GetSingleton();
|
const auto* ui = RE::UI::GetSingleton();
|
||||||
const auto mainMenu = ui ? ui->GetMenu<RE::MainMenu>() : nullptr;
|
const auto mainMenu = ui ? ui->GetMenu<RE::MainMenu>() : nullptr;
|
||||||
if (mainMenu && mainMenu.get() == a_this && TryHandleMultiplayerSelection(mainMenu.get())) {
|
const auto* menu = MainMenuFromInputUser(a_this);
|
||||||
|
if (mainMenu && mainMenu.get() == menu && TryHandleMultiplayerSelection(mainMenu.get())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (g_originalOnButtonEvent) {
|
if (g_originalMainMenuOnButtonEvent != 0) {
|
||||||
g_originalOnButtonEvent(a_this, a_event);
|
reinterpret_cast<MainMenuOnButtonEventFn>(g_originalMainMenuOnButtonEvent)(a_this, a_event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainMenuOnThumbstickEventHook(RE::BSInputEventUser* a_this, const RE::ThumbstickEvent* a_event)
|
||||||
|
{
|
||||||
|
if (F4T::PrismaUI::IsBrowserVisible()) {
|
||||||
|
F4T::MenuInput::ForwardThumbstickEvent(a_event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_originalMainMenuOnThumbstickEvent != 0) {
|
||||||
|
reinterpret_cast<MainMenuOnThumbstickEventFn>(g_originalMainMenuOnThumbstickEvent)(a_this, a_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void InstallMainMenuInputHooks()
|
||||||
|
{
|
||||||
|
if (g_mainMenuInputHooksInstalled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MainMenu::VTABLE[1] is the BSInputEventUser secondary vtable.
|
||||||
|
REL::Relocation<std::uintptr_t> inputVtable{ RE::MainMenu::VTABLE[1] };
|
||||||
|
g_originalMainMenuOnThumbstickEvent = inputVtable.write_vfunc(
|
||||||
|
kBSInputOnThumbstickVtableIndex,
|
||||||
|
&MainMenuOnThumbstickEventHook);
|
||||||
|
g_originalMainMenuOnButtonEvent = inputVtable.write_vfunc(
|
||||||
|
kBSInputOnButtonVtableIndex,
|
||||||
|
&MainMenuOnButtonEventHook);
|
||||||
|
|
||||||
|
if (g_originalMainMenuOnButtonEvent == 0) {
|
||||||
|
LogWarning("MainMenu input OnButtonEvent hook failed to install.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_mainMenuInputHooksInstalled = true;
|
||||||
|
LogInfo("Installed MainMenu input hooks on BSInputEventUser vtable.");
|
||||||
|
}
|
||||||
|
|
||||||
void PollMultiplayerActivation()
|
void PollMultiplayerActivation()
|
||||||
{
|
{
|
||||||
|
if (F4T::PrismaUI::IsBrowserVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const auto* ui = RE::UI::GetSingleton();
|
const auto* ui = RE::UI::GetSingleton();
|
||||||
if (!ui || !ui->GetMenuOpen<RE::MainMenu>() || !IsMainMenuOnMainPanel(ui)) {
|
if (!ui || !ui->GetMenuOpen<RE::MainMenu>() || !IsMainMenuOnMainPanel(ui)) {
|
||||||
return;
|
return;
|
||||||
@@ -768,6 +829,17 @@ namespace F4T::MainMenuInject
|
|||||||
return IsMainStateForMenu(mainMenu.get());
|
return IsMainStateForMenu(mainMenu.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SetMainMenuInputSuppressed(bool a_suppressed)
|
||||||
|
{
|
||||||
|
const auto* ui = RE::UI::GetSingleton();
|
||||||
|
const auto mainMenu = ui ? ui->GetMenu<RE::MainMenu>() : nullptr;
|
||||||
|
if (!mainMenu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainMenu->inputEventHandlingEnabled = !a_suppressed;
|
||||||
|
}
|
||||||
|
|
||||||
void Install()
|
void Install()
|
||||||
{
|
{
|
||||||
if (g_hooksInstalled) {
|
if (g_hooksInstalled) {
|
||||||
@@ -783,12 +855,7 @@ namespace F4T::MainMenuInject
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
g_originalOnButtonEvent = reinterpret_cast<OnButtonEventFn>(
|
InstallMainMenuInputHooks();
|
||||||
startMenuVtable.write_vfunc(kOnButtonEventVtableIndex, &OnButtonEventHook));
|
|
||||||
g_buttonEventHookInstalled = g_originalOnButtonEvent != nullptr;
|
|
||||||
if (!g_buttonEventHookInstalled) {
|
|
||||||
LogWarning("StartMenuBase::OnButtonEvent hook failed to install; Accept on MULTIPLAYER may not open the browser.");
|
|
||||||
}
|
|
||||||
|
|
||||||
auto* ui = RE::UI::GetSingleton();
|
auto* ui = RE::UI::GetSingleton();
|
||||||
if (ui) {
|
if (ui) {
|
||||||
|
|||||||
+311
-58
@@ -3,107 +3,360 @@
|
|||||||
#include "F4TMenuInput.h"
|
#include "F4TMenuInput.h"
|
||||||
#include "F4TServerBrowserBridge.h"
|
#include "F4TServerBrowserBridge.h"
|
||||||
|
|
||||||
#include <F4SE/InputMap.h>
|
#include <REX/W32/XINPUT.h>
|
||||||
|
|
||||||
|
#include <Windows.h>
|
||||||
|
|
||||||
namespace F4T::MenuInput
|
namespace F4T::MenuInput
|
||||||
{
|
{
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
std::atomic_bool g_browserActive{ false };
|
std::atomic_bool g_browserActive{ false };
|
||||||
std::atomic_bool g_sinkRegistered{ false };
|
std::atomic_bool g_registered{ false };
|
||||||
|
RE::BSTSmartPointer<RE::BSInputEnableLayer> g_menuInputBlockLayer;
|
||||||
|
|
||||||
const char* GamepadButtonName(std::uint32_t a_keyCode)
|
using XInputGetStateFn = DWORD(WINAPI*)(DWORD, REX::W32::XINPUT_STATE*);
|
||||||
|
|
||||||
|
struct GamepadButtonDef
|
||||||
{
|
{
|
||||||
switch (a_keyCode) {
|
std::uint16_t mask;
|
||||||
case F4SE::InputMap::kGamepadButtonOffset_DPAD_UP:
|
const char* name;
|
||||||
return "dpad_up";
|
};
|
||||||
case F4SE::InputMap::kGamepadButtonOffset_DPAD_DOWN:
|
|
||||||
return "dpad_down";
|
constexpr GamepadButtonDef kGamepadButtons[] = {
|
||||||
case F4SE::InputMap::kGamepadButtonOffset_DPAD_LEFT:
|
{ REX::W32::XINPUT_GAMEPAD_DPAD_UP, "dpad_up" },
|
||||||
return "dpad_left";
|
{ REX::W32::XINPUT_GAMEPAD_DPAD_DOWN, "dpad_down" },
|
||||||
case F4SE::InputMap::kGamepadButtonOffset_DPAD_RIGHT:
|
{ REX::W32::XINPUT_GAMEPAD_DPAD_LEFT, "dpad_left" },
|
||||||
return "dpad_right";
|
{ REX::W32::XINPUT_GAMEPAD_DPAD_RIGHT, "dpad_right" },
|
||||||
case F4SE::InputMap::kGamepadButtonOffset_A:
|
{ REX::W32::XINPUT_GAMEPAD_A, "a" },
|
||||||
|
{ REX::W32::XINPUT_GAMEPAD_B, "b" },
|
||||||
|
{ REX::W32::XINPUT_GAMEPAD_X, "x" },
|
||||||
|
{ REX::W32::XINPUT_GAMEPAD_Y, "y" },
|
||||||
|
{ REX::W32::XINPUT_GAMEPAD_LEFT_SHOULDER, "lb" },
|
||||||
|
{ REX::W32::XINPUT_GAMEPAD_RIGHT_SHOULDER, "rb" },
|
||||||
|
};
|
||||||
|
|
||||||
|
bool g_buttonHeld[std::size(kGamepadButtons)]{};
|
||||||
|
const char* g_activeStickButton = nullptr;
|
||||||
|
std::chrono::steady_clock::time_point g_stickRepeatTime{};
|
||||||
|
|
||||||
|
XInputGetStateFn GetXInputGetState()
|
||||||
|
{
|
||||||
|
static XInputGetStateFn fn = nullptr;
|
||||||
|
static bool tried = false;
|
||||||
|
if (!tried) {
|
||||||
|
tried = true;
|
||||||
|
const char* dllNames[] = { "xinput1_4.dll", "xinput1_3.dll", "xinput9_1_0.dll" };
|
||||||
|
for (const auto* dllName : dllNames) {
|
||||||
|
const auto module = ::LoadLibraryA(dllName);
|
||||||
|
if (!module) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn = reinterpret_cast<XInputGetStateFn>(::GetProcAddress(module, "XInputGetState"));
|
||||||
|
if (fn) {
|
||||||
|
REX::INFO("[MenuInput] Using {} for gamepad polling.", dllName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fn) {
|
||||||
|
REX::WARN("[MenuInput] XInputGetState unavailable; gamepad browser navigation disabled.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* UserEventToButton(std::string_view a_userEvent)
|
||||||
|
{
|
||||||
|
if (a_userEvent == "Accept"sv) {
|
||||||
return "a";
|
return "a";
|
||||||
case F4SE::InputMap::kGamepadButtonOffset_B:
|
}
|
||||||
|
if (a_userEvent == "Cancel"sv || a_userEvent == "MenuCancel"sv) {
|
||||||
return "b";
|
return "b";
|
||||||
case F4SE::InputMap::kGamepadButtonOffset_X:
|
}
|
||||||
|
if (a_userEvent == "XButton"sv) {
|
||||||
return "x";
|
return "x";
|
||||||
case F4SE::InputMap::kGamepadButtonOffset_Y:
|
}
|
||||||
|
if (a_userEvent == "YButton"sv) {
|
||||||
return "y";
|
return "y";
|
||||||
case F4SE::InputMap::kGamepadButtonOffset_LEFT_SHOULDER:
|
}
|
||||||
|
if (a_userEvent == "LeftShoulder"sv || a_userEvent == "LBumper"sv) {
|
||||||
return "lb";
|
return "lb";
|
||||||
case F4SE::InputMap::kGamepadButtonOffset_RIGHT_SHOULDER:
|
}
|
||||||
|
if (a_userEvent == "RightShoulder"sv || a_userEvent == "RBumper"sv) {
|
||||||
return "rb";
|
return "rb";
|
||||||
|
}
|
||||||
|
if (a_userEvent == "Up"sv) {
|
||||||
|
return "dpad_up";
|
||||||
|
}
|
||||||
|
if (a_userEvent == "Down"sv) {
|
||||||
|
return "dpad_down";
|
||||||
|
}
|
||||||
|
if (a_userEvent == "Left"sv) {
|
||||||
|
return "dpad_left";
|
||||||
|
}
|
||||||
|
if (a_userEvent == "Right"sv) {
|
||||||
|
return "dpad_right";
|
||||||
|
}
|
||||||
|
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* ThumbstickDirectionToButton(RE::DIRECTION_VAL a_direction)
|
||||||
|
{
|
||||||
|
switch (a_direction) {
|
||||||
|
case RE::DIRECTION_VAL::kUp:
|
||||||
|
return "dpad_up";
|
||||||
|
case RE::DIRECTION_VAL::kDown:
|
||||||
|
return "dpad_down";
|
||||||
|
case RE::DIRECTION_VAL::kLeft:
|
||||||
|
return "dpad_left";
|
||||||
|
case RE::DIRECTION_VAL::kRight:
|
||||||
|
return "dpad_right";
|
||||||
default:
|
default:
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class GamepadInputHandler :
|
const char* StickAxesToButton(std::int16_t a_x, std::int16_t a_y)
|
||||||
public RE::BSInputEventUser
|
|
||||||
{
|
{
|
||||||
public:
|
constexpr auto deadzone = REX::W32::XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE;
|
||||||
static GamepadInputHandler* GetSingleton()
|
const auto up = a_y > deadzone;
|
||||||
{
|
const auto down = a_y < -static_cast<std::int16_t>(deadzone);
|
||||||
static GamepadInputHandler instance;
|
const auto left = a_x < -static_cast<std::int16_t>(deadzone);
|
||||||
return std::addressof(instance);
|
const auto right = a_x > deadzone;
|
||||||
|
|
||||||
|
if (up && !left && !right) {
|
||||||
|
return "dpad_up";
|
||||||
|
}
|
||||||
|
if (down && !left && !right) {
|
||||||
|
return "dpad_down";
|
||||||
|
}
|
||||||
|
if (left && !up && !down) {
|
||||||
|
return "dpad_left";
|
||||||
|
}
|
||||||
|
if (right && !up && !down) {
|
||||||
|
return "dpad_right";
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ShouldHandleEvent(const RE::InputEvent* a_event) override
|
return nullptr;
|
||||||
{
|
}
|
||||||
if (!g_browserActive.load() || !a_event) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return a_event->eventType == RE::INPUT_EVENT_TYPE::kButton &&
|
void ForwardButtonPhase(const char* a_button, const char* a_phase)
|
||||||
a_event->device == RE::INPUT_DEVICE::kGamepad;
|
{
|
||||||
|
if (a_button && a_phase) {
|
||||||
|
F4T::ServerBrowser::ServerBrowserBridge::ForwardControllerInput(a_button, a_phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncPolledGamepadBaseline()
|
||||||
|
{
|
||||||
|
for (auto& held : g_buttonHeld) {
|
||||||
|
held = false;
|
||||||
|
}
|
||||||
|
g_activeStickButton = nullptr;
|
||||||
|
g_stickRepeatTime = {};
|
||||||
|
|
||||||
|
const auto* xinputGetState = GetXInputGetState();
|
||||||
|
if (!xinputGetState) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnButtonEvent(const RE::ButtonEvent* a_event) override
|
REX::W32::XINPUT_STATE state{};
|
||||||
{
|
if (xinputGetState(0, std::addressof(state)) != ERROR_SUCCESS) {
|
||||||
if (!a_event || !g_browserActive.load()) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const auto keyCode = static_cast<std::uint32_t>(a_event->GetBSButtonCode());
|
const auto buttons = state.gamepad.buttons;
|
||||||
const auto* buttonName = GamepadButtonName(keyCode);
|
for (std::size_t i = 0; i < std::size(kGamepadButtons); ++i) {
|
||||||
if (!buttonName) {
|
g_buttonHeld[i] = (buttons & kGamepadButtons[i].mask) != 0;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const char* phase = nullptr;
|
g_activeStickButton = StickAxesToButton(state.gamepad.thumbLX, state.gamepad.thumbLY);
|
||||||
if (a_event->QJustPressed()) {
|
g_stickRepeatTime = std::chrono::steady_clock::now();
|
||||||
phase = "down";
|
}
|
||||||
} else if (a_event->QReleased()) {
|
|
||||||
phase = "up";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase) {
|
void AcquireMenuInputBlock()
|
||||||
F4T::ServerBrowser::ServerBrowserBridge::ForwardControllerInput(buttonName, phase);
|
{
|
||||||
|
if (g_menuInputBlockLayer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* inputEnableManager = RE::BSInputEnableManager::GetSingleton();
|
||||||
|
if (!inputEnableManager) {
|
||||||
|
REX::WARN("[MenuInput] BSInputEnableManager unavailable; main menu may keep gamepad focus.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RE::BSTSmartPointer<RE::BSInputEnableLayer> layer;
|
||||||
|
if (!inputEnableManager->AllocateNewLayer(layer, "CommonwealthOnline_ServerBrowser")) {
|
||||||
|
REX::WARN("[MenuInput] Failed to allocate input layer for server browser.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputEnableManager->EnableUserEvent(
|
||||||
|
layer->layerID,
|
||||||
|
RE::UserEvents::USER_EVENT_FLAG::kMenu,
|
||||||
|
false,
|
||||||
|
RE::UserEvents::SENDER_ID::kScript);
|
||||||
|
|
||||||
|
g_menuInputBlockLayer = std::move(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReleaseMenuInputBlock()
|
||||||
|
{
|
||||||
|
g_menuInputBlockLayer.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PollGamepad()
|
||||||
|
{
|
||||||
|
if (!g_browserActive.load()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto* xinputGetState = GetXInputGetState();
|
||||||
|
if (!xinputGetState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
REX::W32::XINPUT_STATE state{};
|
||||||
|
if (xinputGetState(0, std::addressof(state)) != ERROR_SUCCESS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto buttons = state.gamepad.buttons;
|
||||||
|
for (std::size_t i = 0; i < std::size(kGamepadButtons); ++i) {
|
||||||
|
const auto down = (buttons & kGamepadButtons[i].mask) != 0;
|
||||||
|
if (down != g_buttonHeld[i]) {
|
||||||
|
ForwardButtonPhase(kGamepadButtons[i].name, down ? "down" : "up");
|
||||||
|
g_buttonHeld[i] = down;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
const auto* stickButton = StickAxesToButton(state.gamepad.thumbLX, state.gamepad.thumbLY);
|
||||||
|
if (stickButton != g_activeStickButton) {
|
||||||
|
if (g_activeStickButton) {
|
||||||
|
ForwardButtonPhase(g_activeStickButton, "up");
|
||||||
|
}
|
||||||
|
|
||||||
|
g_activeStickButton = stickButton;
|
||||||
|
if (stickButton) {
|
||||||
|
ForwardButtonPhase(stickButton, "down");
|
||||||
|
}
|
||||||
|
|
||||||
|
g_stickRepeatTime = std::chrono::steady_clock::now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stickButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
if (now - g_stickRepeatTime < 400ms) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ForwardButtonPhase(stickButton, "down");
|
||||||
|
g_stickRepeatTime = now - 320ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ForwardButtonEvent(const RE::ButtonEvent* a_event)
|
||||||
|
{
|
||||||
|
if (!a_event || !g_browserActive.load()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a_event->device == RE::INPUT_DEVICE::kGamepad) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto* buttonName = UserEventToButton(a_event->QUserEvent());
|
||||||
|
if (!buttonName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* phase = nullptr;
|
||||||
|
if (a_event->QJustPressed()) {
|
||||||
|
phase = "down";
|
||||||
|
} else if (a_event->QReleased()) {
|
||||||
|
phase = "up";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase) {
|
||||||
|
ForwardButtonPhase(buttonName, phase);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ForwardThumbstickEvent(const RE::ThumbstickEvent* a_event)
|
||||||
|
{
|
||||||
|
if (!a_event || !g_browserActive.load()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a_event->idCode != static_cast<std::int32_t>(RE::ThumbstickEvent::THUMBSTICK_ID::kLeft)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a_event->currDir == a_event->prevDir) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool forwarded = false;
|
||||||
|
if (a_event->prevDir != RE::DIRECTION_VAL::kNone) {
|
||||||
|
if (const auto* buttonName = ThumbstickDirectionToButton(a_event->prevDir)) {
|
||||||
|
ForwardButtonPhase(buttonName, "up");
|
||||||
|
forwarded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a_event->currDir != RE::DIRECTION_VAL::kNone) {
|
||||||
|
if (const auto* buttonName = ThumbstickDirectionToButton(a_event->currDir)) {
|
||||||
|
ForwardButtonPhase(buttonName, "down");
|
||||||
|
forwarded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return forwarded;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Register()
|
void Register()
|
||||||
{
|
{
|
||||||
if (g_sinkRegistered.exchange(true)) {
|
if (g_registered.exchange(true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto* menuControls = RE::MenuControls::GetSingleton();
|
const auto* taskInterface = F4SE::GetTaskInterface();
|
||||||
if (menuControls) {
|
if (taskInterface) {
|
||||||
menuControls->handlers.push_back(GamepadInputHandler::GetSingleton());
|
taskInterface->AddTaskPermanent([]() {
|
||||||
REX::INFO("[MenuInput] Registered gamepad handler for server browser.");
|
PollGamepad();
|
||||||
|
});
|
||||||
|
REX::INFO("[MenuInput] Registered XInput polling for server browser.");
|
||||||
} else {
|
} else {
|
||||||
REX::WARN("[MenuInput] MenuControls unavailable; gamepad navigation disabled.");
|
REX::WARN("[MenuInput] F4SE task interface unavailable; gamepad polling disabled.");
|
||||||
g_sinkRegistered.store(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void SetBrowserActive(bool a_active)
|
void SetBrowserActive(bool a_active)
|
||||||
{
|
{
|
||||||
g_browserActive.store(a_active);
|
g_browserActive.store(a_active);
|
||||||
|
if (a_active) {
|
||||||
|
SyncPolledGamepadBaseline();
|
||||||
|
AcquireMenuInputBlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& held : g_buttonHeld) {
|
||||||
|
held = false;
|
||||||
|
}
|
||||||
|
g_activeStickButton = nullptr;
|
||||||
|
g_stickRepeatTime = {};
|
||||||
|
ReleaseMenuInputBlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ namespace F4T::PrismaUI
|
|||||||
const bool wasVisible = g_visible;
|
const bool wasVisible = g_visible;
|
||||||
g_visible = false;
|
g_visible = false;
|
||||||
F4T::MenuInput::SetBrowserActive(false);
|
F4T::MenuInput::SetBrowserActive(false);
|
||||||
|
F4T::MainMenuInject::SetMainMenuInputSuppressed(false);
|
||||||
|
|
||||||
if (!g_api || !g_api->IsValid(g_serverBrowserView)) {
|
if (!g_api || !g_api->IsValid(g_serverBrowserView)) {
|
||||||
return;
|
return;
|
||||||
@@ -154,6 +155,7 @@ namespace F4T::PrismaUI
|
|||||||
g_pendingShow = false;
|
g_pendingShow = false;
|
||||||
g_visible = true;
|
g_visible = true;
|
||||||
F4T::MenuInput::SetBrowserActive(true);
|
F4T::MenuInput::SetBrowserActive(true);
|
||||||
|
F4T::MainMenuInject::SetMainMenuInputSuppressed(true);
|
||||||
g_api->Show(g_serverBrowserView);
|
g_api->Show(g_serverBrowserView);
|
||||||
g_api->Focus(g_serverBrowserView, false, false);
|
g_api->Focus(g_serverBrowserView, false, false);
|
||||||
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserShown();
|
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserShown();
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
connectionStatus: "Online",
|
connectionStatus: "Online",
|
||||||
isRefreshing: false,
|
isRefreshing: false,
|
||||||
directConnect: { address: "127.0.0.1", port: "7777" },
|
directConnect: { address: "127.0.0.1", port: "7777" },
|
||||||
|
directConnectFieldIndex: 0,
|
||||||
joinOverlay: null
|
joinOverlay: null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,7 +237,10 @@
|
|||||||
state.selectedTabIndex = CO_Tabs.TAB_ORDER.indexOf(tabName);
|
state.selectedTabIndex = CO_Tabs.TAB_ORDER.indexOf(tabName);
|
||||||
if (state.selectedTabIndex < 0) state.selectedTabIndex = 0;
|
if (state.selectedTabIndex < 0) state.selectedTabIndex = 0;
|
||||||
if (tabName === "server-browser") state.focusZone = "server-list";
|
if (tabName === "server-browser") state.focusZone = "server-list";
|
||||||
else if (tabName === "direct-connect") state.focusZone = "tabs";
|
else if (tabName === "direct-connect") {
|
||||||
|
state.focusZone = "tabs";
|
||||||
|
state.directConnectFieldIndex = 0;
|
||||||
|
}
|
||||||
sendAction("switchTab", { tabName: tabName });
|
sendAction("switchTab", { tabName: tabName });
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -334,11 +338,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e) {
|
function onKeyDown(e) {
|
||||||
if (state.joinOverlay && state.joinOverlay.error && e.key === "Escape") {
|
if (state.joinOverlay && state.joinOverlay.error) {
|
||||||
hideJoinOverlay();
|
if (e.key === "Escape" || e.key === "Enter" || e.key === "b" || e.key === "B") {
|
||||||
return;
|
hideJoinOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (state.activeTab === "direct-connect") return;
|
if (e.repeat) return;
|
||||||
handleControllerButton(mapKeyToButton(e.key), "down");
|
handleControllerButton(mapKeyToButton(e.key), "down");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +359,9 @@
|
|||||||
function handleControllerButton(button, phase) {
|
function handleControllerButton(button, phase) {
|
||||||
if (!button || phase !== "down") return;
|
if (!button || phase !== "down") return;
|
||||||
if (state.joinOverlay) {
|
if (state.joinOverlay) {
|
||||||
if (button === "b") hideJoinOverlay();
|
if (button === "b" || (state.joinOverlay.error && button === "a")) {
|
||||||
|
hideJoinOverlay();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +380,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.activeTab === "direct-connect") {
|
if (state.activeTab === "direct-connect") {
|
||||||
|
if (button === "b") {
|
||||||
|
sendAction("closeBrowser", {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button === "dpad_up") {
|
||||||
|
state.directConnectFieldIndex = Math.max(0, state.directConnectFieldIndex - 1);
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button === "dpad_down") {
|
||||||
|
state.directConnectFieldIndex = Math.min(1, state.directConnectFieldIndex + 1);
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (button === "a") {
|
if (button === "a") {
|
||||||
var vals = CO_DirectConnect.getValues(root);
|
var vals = CO_DirectConnect.getValues(root);
|
||||||
joinDirectLocal(vals.address, vals.port);
|
joinDirectLocal(vals.address, vals.port);
|
||||||
@@ -396,6 +418,35 @@
|
|||||||
navigateFocus(button);
|
navigateFocus(button);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cycleFilterValue(filterKey, options) {
|
||||||
|
var current = state.filters[filterKey];
|
||||||
|
var index = options.indexOf(current);
|
||||||
|
if (index < 0) index = 0;
|
||||||
|
var next = options[(index + 1) % options.length];
|
||||||
|
state.filters[filterKey] = next;
|
||||||
|
sendAction("setFilter", { filterName: filterKey, value: next });
|
||||||
|
applyLocalFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateFocusedFilter() {
|
||||||
|
var maxFilter = CO_Filters.FILTER_ROWS.length;
|
||||||
|
if (state.selectedFilterIndex === maxFilter) {
|
||||||
|
resetLocalFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = CO_Filters.FILTER_ROWS[state.selectedFilterIndex];
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
if (row.type === "select") {
|
||||||
|
cycleFilterValue(row.key, row.options);
|
||||||
|
} else if (row.type === "checkbox") {
|
||||||
|
state.filters[row.key] = !state.filters[row.key];
|
||||||
|
sendAction("setFilter", { filterName: row.key, value: state.filters[row.key] });
|
||||||
|
applyLocalFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function joinSelected() {
|
function joinSelected() {
|
||||||
var sel = getSelectedServer();
|
var sel = getSelectedServer();
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -448,7 +499,7 @@
|
|||||||
var maxFilter = CO_Filters.FILTER_ROWS.length;
|
var maxFilter = CO_Filters.FILTER_ROWS.length;
|
||||||
if (button === "dpad_up") state.selectedFilterIndex = Math.max(0, state.selectedFilterIndex - 1);
|
if (button === "dpad_up") state.selectedFilterIndex = Math.max(0, state.selectedFilterIndex - 1);
|
||||||
if (button === "dpad_down") state.selectedFilterIndex = Math.min(maxFilter, state.selectedFilterIndex + 1);
|
if (button === "dpad_down") state.selectedFilterIndex = Math.min(maxFilter, state.selectedFilterIndex + 1);
|
||||||
if (button === "a" && state.selectedFilterIndex === maxFilter) resetLocalFilters();
|
if (button === "a") activateFocusedFilter();
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@
|
|||||||
getPrompts: function (state) {
|
getPrompts: function (state) {
|
||||||
if (state.joinOverlay) {
|
if (state.joinOverlay) {
|
||||||
if (state.joinOverlay.error) {
|
if (state.joinOverlay.error) {
|
||||||
return [{ key: "B", label: "Dismiss" }];
|
return [
|
||||||
|
{ key: "B", label: "Dismiss" },
|
||||||
|
{ key: "A", label: "Dismiss" }
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -32,12 +35,13 @@
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
var base = [
|
if (state.focusZone === "filters") {
|
||||||
{ key: "A", label: "Join" },
|
var isReset = state.selectedFilterIndex === CO_Filters.FILTER_ROWS.length;
|
||||||
{ key: "X", label: "Refresh" },
|
return [
|
||||||
{ key: "Y", label: "Favorite" },
|
{ key: "A", label: isReset ? "Reset" : "Change" },
|
||||||
{ key: "B", label: "Back" }
|
{ key: "B", label: "Back" }
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (state.focusZone === "tabs") {
|
if (state.focusZone === "tabs") {
|
||||||
return [
|
return [
|
||||||
@@ -47,7 +51,12 @@
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
return [
|
||||||
|
{ key: "A", label: "Join" },
|
||||||
|
{ key: "X", label: "Refresh" },
|
||||||
|
{ key: "Y", label: "Favorite" },
|
||||||
|
{ key: "B", label: "Back" }
|
||||||
|
];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -11,13 +11,15 @@
|
|||||||
|
|
||||||
var address = state.directConnect.address || "127.0.0.1";
|
var address = state.directConnect.address || "127.0.0.1";
|
||||||
var port = state.directConnect.port || "7777";
|
var port = state.directConnect.port || "7777";
|
||||||
|
var addressFocused = state.directConnectFieldIndex === 0 ? " focused" : "";
|
||||||
|
var portFocused = state.directConnectFieldIndex === 1 ? " focused" : "";
|
||||||
|
|
||||||
container.innerHTML =
|
container.innerHTML =
|
||||||
'<div class="co-direct-row">' +
|
'<div class="co-direct-row' + addressFocused + '">' +
|
||||||
'<label for="co-dc-address">Server Address</label>' +
|
'<label for="co-dc-address">Server Address</label>' +
|
||||||
'<input class="co-filter-input" id="co-dc-address" type="text" value="' + escapeAttr(address) + '">' +
|
'<input class="co-filter-input" id="co-dc-address" type="text" value="' + escapeAttr(address) + '">' +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
'<div class="co-direct-row">' +
|
'<div class="co-direct-row' + portFocused + '">' +
|
||||||
'<label for="co-dc-port">Port</label>' +
|
'<label for="co-dc-port">Port</label>' +
|
||||||
'<input class="co-filter-input" id="co-dc-port" type="text" value="' + escapeAttr(port) + '">' +
|
'<input class="co-filter-input" id="co-dc-port" type="text" value="' + escapeAttr(port) + '">' +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
|
|||||||
@@ -630,6 +630,11 @@ body {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.co-direct-row.focused .co-filter-input {
|
||||||
|
outline: 1px solid var(--co-green);
|
||||||
|
background: rgba(var(--co-r), var(--co-g), var(--co-b), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.co-direct-row label {
|
.co-direct-row label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: var(--co-font-xs);
|
font-size: var(--co-font-xs);
|
||||||
|
|||||||
Reference in New Issue
Block a user