Gamepad-aware cursor handling in server browser

Add gamepad-aware pointer handling so the server browser hides the mouse cursor when a controller is connected and only shows it after mouse movement/click. Changes include:

- PrismaUI: track browser pointer mode, prefer gamepad detection, suppress native CursorMenu/MenuCursor when in gamepad mode, unfocus the Ultralight view to prevent PrismaUI drawing its cursor, and restore pointer mode on mouse activity. Integrate pointer-mode initialization on browser show and call PollGamepad + UpdateBrowserPointerMode in the permanent task loop.
- MenuInput: expose IsGamepadConnected(), provide PollGamepad() wrapper and simplify Register() logging.
- UI: add window.CO_App.setPointerMode to toggle a co-gamepad-mode class and clear hover state; add CSS to hide the HTML cursor when co-gamepad-mode is active.
- docs: add dev-log entries describing the PrismaUI Ultralight cursor fix and the new gamepad-aware server browser behavior.

These changes ensure consistent cursor behavior between mouse and controller input, prevent the Ultralight cursor from appearing in controller mode, and restore the cursor after mouse interaction.
This commit is contained in:
2026-06-18 14:37:31 +12:00
parent f720d35d2a
commit 1353e5a52c
6 changed files with 201 additions and 12 deletions
+52
View File
@@ -7,6 +7,58 @@ failed experiments, successful tests, and next steps.
---
## 2026-06-18 - Fix PrismaUI Ultralight cursor in gamepad mode
### Summary
The custom PrismaUI cursor (`cursor.png`) still appeared when navigating with a controller. The third `Focus()` argument is `disableFocusMenu`, not cursor visibility; PrismaUI draws its cursor only while input capture is active after `Focus()`.
### Files Changed
- `plugin/src/F4TPrismaUI.cpp`
### Details
- Gamepad mode now calls `Unfocus()` so PrismaUI disables input capture and skips `DrawCursor()`.
- Mouse mode calls `Focus(view, false, true)` (`disableFocusMenu=true`) so PrismaUI handles the pip-boy cursor without opening `FocusMenu`.
- Suppress native `CursorMenu` / `MenuCursor` each frame in gamepad mode because PrismaUI re-enables them when capture is off.
### Testing
- Plugin rebuild required; in-game test: controller open → no Ultralight cursor; mouse move → cursor returns.
### Known Issues
- Keyboard navigation in the browser may not reach Ultralight while unfocused in gamepad mode; controller navigation via XInput polling is unaffected.
### Next Steps
- Verify in-game with controller and mouse.
---
## 2026-06-18 - Gamepad-aware cursor in server browser
### Summary
Hide the mouse cursor when a controller is connected and the server browser is open, matching Fallout 4 main-menu behavior. The cursor appears only after mouse movement or a mouse click.
### Files Changed
- `plugin/src/F4TPrismaUI.cpp`
- `plugin/src/F4TMenuInput.cpp`
- `plugin/include/F4TMenuInput.h`
- `ui/views/CommonwealthOnline/browser/app.js`
- `ui/views/CommonwealthOnline/browser/styles.css`
### Details
- On browser open with a gamepad connected: unregister `MenuCursor`, hide `CursorMenu`, and unfocus the PrismaUI view (see follow-up entry above for Ultralight cursor fix).
- Detect mouse movement/clicks each frame and restore the cursor + hover highlights.
- CSS `co-gamepad-mode` hides any HTML cursor styling while in controller mode.
### Testing
- Plugin rebuild succeeded; in-game test with controller + mouse required.
### Known Issues
- None currently known.
### Next Steps
- Verify cursor stays hidden on open with controller and appears after moving the mouse.
---
## 2026-06-18 - XInput polling for server browser gamepad
### Summary
+2
View File
@@ -10,6 +10,8 @@ namespace F4T::MenuInput
{
void Register();
void SetBrowserActive(bool a_active);
bool IsGamepadConnected();
void PollGamepad();
bool ForwardButtonEvent(const RE::ButtonEvent* a_event);
bool ForwardThumbstickEvent(const RE::ThumbstickEvent* a_event);
}
+13 -10
View File
@@ -210,7 +210,7 @@ namespace F4T::MenuInput
g_menuInputBlockLayer.reset();
}
void PollGamepad()
void PollGamepadInternal()
{
if (!g_browserActive.load()) {
return;
@@ -326,21 +326,24 @@ namespace F4T::MenuInput
return forwarded;
}
bool IsGamepadConnected()
{
const auto* inputDeviceManager = RE::BSInputDeviceManager::GetSingleton();
return inputDeviceManager && inputDeviceManager->IsGamepadConnected();
}
void Register()
{
if (g_registered.exchange(true)) {
return;
}
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
taskInterface->AddTaskPermanent([]() {
PollGamepad();
});
REX::INFO("[MenuInput] Registered XInput polling for server browser.");
} else {
REX::WARN("[MenuInput] F4SE task interface unavailable; gamepad polling disabled.");
}
REX::INFO("[MenuInput] Browser gamepad forwarding registered.");
}
void PollGamepad()
{
PollGamepadInternal();
}
void SetBrowserActive(bool a_active)
+120 -1
View File
@@ -12,6 +12,7 @@
#include <cstdio>
#include <cstring>
#include <cmath>
#include <nlohmann/json.hpp>
#include <string_view>
@@ -31,6 +32,10 @@ namespace F4T::PrismaUI
bool g_menuWatchTaskStarted = false;
bool g_pendingShow = false;
int g_cursorRestoreFramesLeft = 0;
bool g_browserMouseCursorVisible = true;
bool g_browserPreferGamepad = false;
bool g_hasLastCursorPos = false;
POINT g_lastCursorPos{};
std::string GetLogPrefix()
{
@@ -49,6 +54,112 @@ namespace F4T::PrismaUI
REX::WARN(std::string_view{ message });
}
void SuppressNativeGameCursor()
{
auto* ui = RE::UI::GetSingleton();
if (!ui) {
return;
}
if (auto cursorMenu = ui->GetMenu<RE::CursorMenu>()) {
if (cursorMenu->uiMovie && cursorMenu->uiMovie->GetVisible()) {
cursorMenu->uiMovie->SetVisible(false);
}
}
if (auto* menuCursor = RE::MenuCursor::GetSingleton()) {
menuCursor->UnregisterCursor();
}
}
void ApplyBrowserPointerMode(bool a_mouseCursorVisible)
{
g_browserMouseCursorVisible = a_mouseCursorVisible;
if (g_api && g_api->IsValid(g_serverBrowserView) && g_visible) {
if (a_mouseCursorVisible) {
// Third Focus arg is disableFocusMenu, not cursor visibility. PrismaUI draws
// cursor.png only while input capture is active (after Focus).
g_api->Focus(g_serverBrowserView, false, true);
} else {
// Unfocus disables PrismaUI input capture so its Ultralight cursor is not drawn.
g_api->Unfocus(g_serverBrowserView);
SuppressNativeGameCursor();
}
const auto* mode = a_mouseCursorVisible ? "mouse" : "gamepad";
const auto script = std::format(
"if(window.CO_App&&window.CO_App.setPointerMode){{window.CO_App.setPointerMode('{}');}}",
mode);
g_api->Invoke(g_serverBrowserView, script.c_str());
} else if (!a_mouseCursorVisible) {
SuppressNativeGameCursor();
}
}
void ResetBrowserPointerTracking()
{
g_hasLastCursorPos = false;
g_lastCursorPos = {};
}
void UpdateBrowserPointerMode()
{
if (!g_visible) {
return;
}
if (!g_browserPreferGamepad) {
if (!g_browserMouseCursorVisible) {
ApplyBrowserPointerMode(true);
}
return;
}
const auto* renderWindow = RE::BSGraphics::GetCurrentRendererWindow();
if (!renderWindow || !renderWindow->hwnd) {
return;
}
POINT cursor{};
if (!::GetCursorPos(&cursor)) {
return;
}
::ScreenToClient(reinterpret_cast<HWND>(renderWindow->hwnd), &cursor);
const auto mouseButtonDown =
(GetAsyncKeyState(VK_LBUTTON) & 0x8000) != 0 ||
(GetAsyncKeyState(VK_RBUTTON) & 0x8000) != 0 ||
(GetAsyncKeyState(VK_MBUTTON) & 0x8000) != 0;
bool mouseMoved = false;
if (g_hasLastCursorPos) {
const auto deltaX = std::abs(cursor.x - g_lastCursorPos.x);
const auto deltaY = std::abs(cursor.y - g_lastCursorPos.y);
mouseMoved = deltaX > 1 || deltaY > 1;
}
g_lastCursorPos = cursor;
g_hasLastCursorPos = true;
if (!g_browserMouseCursorVisible && (mouseMoved || mouseButtonDown)) {
ApplyBrowserPointerMode(true);
return;
}
if (!g_browserMouseCursorVisible) {
// PrismaUI re-shows CursorMenu each frame when input capture is off; keep it hidden.
SuppressNativeGameCursor();
}
}
void InitializeBrowserPointerMode()
{
g_browserPreferGamepad = F4T::MenuInput::IsGamepadConnected();
ResetBrowserPointerTracking();
}
void RestoreNativeGameCursor()
{
auto* ui = RE::UI::GetSingleton();
@@ -82,6 +193,10 @@ namespace F4T::PrismaUI
return;
}
if (!g_browserMouseCursorVisible) {
return;
}
const auto* renderWindow = RE::BSGraphics::GetCurrentRendererWindow();
if (!renderWindow || !renderWindow->hwnd) {
return;
@@ -156,8 +271,9 @@ namespace F4T::PrismaUI
g_visible = true;
F4T::MenuInput::SetBrowserActive(true);
F4T::MainMenuInject::SetMainMenuInputSuppressed(true);
InitializeBrowserPointerMode();
g_api->Show(g_serverBrowserView);
g_api->Focus(g_serverBrowserView, false, false);
ApplyBrowserPointerMode(!g_browserPreferGamepad);
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserShown();
g_api->Invoke(
g_serverBrowserView,
@@ -312,6 +428,9 @@ namespace F4T::PrismaUI
}
taskInterface->AddTaskPermanent([]() {
F4T::MenuInput::PollGamepad();
UpdateBrowserPointerMode();
static std::uint32_t hoverSyncCounter = 0;
if (++hoverSyncCounter % 2 != 0) {
return;
+9 -1
View File
@@ -614,7 +614,15 @@
window.CO_App = {
joinSelected: joinSelected,
getState: function () { return state; }
getState: function () { return state; },
setPointerMode: function (mode) {
if (!root) return;
var gamepad = mode === "gamepad";
root.classList.toggle("co-gamepad-mode", gamepad);
if (window.CO_Hover && CO_Hover.clear) {
CO_Hover.clear();
}
}
};
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
@@ -80,6 +80,11 @@ body {
cursor: var(--co-cursor);
}
.co-root.co-gamepad-mode,
.co-root.co-gamepad-mode * {
cursor: none !important;
}
.co-root {
position: relative;
width: 100vw;