Launcher: controller reaches every panel; pad pointer without cursor warps

- Settings' and Mods' rails, pages and lists are NavFlattened children, so
  the D-pad crosses between them instead of treating each as one item that
  only B could leave.
- The pad's pointer no longer moves the system cursor. Under Proton that
  move came back late as a mouse move and dropped the pointer; now a mouse
  move counts only when it lands somewhere new.
- The first R3 only shows the pointer, so a stray click does not press
  whatever the mouse last rested on.
This commit is contained in:
NorthDroplet committed 2026-10-05 14:38:48 +00:00
1 parent 360338e6e1
commit 8c7c30e3bb
7 files changed
+96 -73

No files matched your search

+56 -28
View File
@@ -31,12 +31,11 @@ struct Seen {
// One launcher frame: the pad goes in before NewFrame, as gui.cpp does it.
// Two buttons stacked at the top left, and a menu popup opened on request.
Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, std::optional<ImVec2>* warp = nullptr) {
Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool panels = false) {
auto& io = ImGui::GetIO();
io.DisplaySize = ImVec2(800, 600);
io.DeltaTime = 1.0f / 60.0f;
const auto moved = feed.update(io, pad, 1.0f);
if (warp) *warp = moved;
feed.update(io, pad, 1.0f);
ImGui::NewFrame();
Seen seen;
ImGui::SetNextWindowPos(ImVec2(0, 0));
@@ -46,6 +45,17 @@ Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, std::opti
if (ImGui::InvisibleButton("first", ImVec2(200, 50), ImGuiButtonFlags_EnableNav)) seen.pressed = "first";
ImGui::SetCursorScreenPos(ImVec2(100, 200));
if (ImGui::InvisibleButton("second", ImVec2(200, 50), ImGuiButtonFlags_EnableNav)) seen.pressed = "second";
if (panels) {
// A page's rail and content, side by side, as Settings and Mods lay them out.
ImGui::SetCursorScreenPos(ImVec2(400, 200));
ImGui::BeginChild("##rail", ImVec2(150, 200), ImGuiChildFlags_NavFlattened);
if (ImGui::Button("tab", ImVec2(120, 40))) seen.pressed = "tab";
ImGui::EndChild();
ImGui::SetCursorScreenPos(ImVec2(580, 200));
ImGui::BeginChild("##content", ImVec2(200, 200), ImGuiChildFlags_NavFlattened);
if (ImGui::Button("option", ImVec2(120, 40))) seen.pressed = "option";
ImGui::EndChild();
}
if (open_menu) ImGui::OpenPopup("##menu");
if (ImGui::BeginPopup("##menu")) {
seen.menu_open = true;
@@ -145,38 +155,55 @@ int main() {
check(ImGui::IsKeyDown(ImGuiKey_Escape), "An idle pad does not let go of the keyboard's Escape");
}
{
fresh();
PadFeed feed;
constexpr std::uint16_t dpad_right = 0x0008;
for (int i = 0; i < 2; ++i) frame(feed, pad(), false, true);
const auto tap = [&](std::uint16_t button) { frame(feed, pad(button), false, true); frame(feed, pad(), false, true); };
tap(dpad_down); // onto "first"
tap(dpad_down); // "second", level with the panels
tap(dpad_right); // the rail
tap(dpad_right); // the content
std::string pressed = frame(feed, pad(button_a), false, true).pressed;
const auto released = frame(feed, pad(), false, true).pressed;
check((pressed.empty() ? released : pressed) == "option",
"The D-pad crosses from a page's rail into its content");
}
// ------------------------------------------------ trackpad pointer
{
fresh();
PadFeed feed;
ImGui::GetIO().AddMousePosEvent(400, 300);
auto& io = ImGui::GetIO();
io.AddMousePosEvent(400, 300);
feed.mouse_moved(ImVec2(400, 300));
frame(feed, pad());
std::optional<ImVec2> warp;
frame(feed, pad(0, 3000, 0), false, &warp);
check(!warp && !feed.pointing(), "A thumb resting near the middle does not move the pointer");
frame(feed, pad(0, 3000, 0));
check(!feed.pointing() && io.MousePos.x == 400, "A thumb resting near the middle does not move the pointer");
frame(feed, pad(0, 16000, 0), false, &warp);
check(feed.pointing() && ImGui::GetIO().MouseDrawCursor, "Tilting takes the pointer and draws it");
check(warp && warp->x > 400 && warp->y == 300, "Tilting right moves the pointer right, from the mouse's place");
for (int i = 0; i < 120; ++i) frame(feed, pad(0, 32767, 32767), false, &warp);
check(warp && warp->x == 799 && warp->y == 0, "The pointer stays inside the window");
frame(feed, pad(0, 16000, 0));
frame(feed, pad());
check(ImGui::GetIO().MousePos.x == 799 && ImGui::GetIO().MousePos.y == 0, "ImGui's mouse is where the pad left it");
check(feed.pointing() && io.MouseDrawCursor, "Tilting takes the pointer and draws it");
check(io.MousePos.x > 400 && io.MousePos.y == 300, "Tilting right moves the pointer right, from the mouse's place");
for (int i = 0; i < 120; ++i) frame(feed, pad(0, 32767, 32767));
frame(feed, pad());
check(io.MousePos.x == 799 && io.MousePos.y == 0, "The pointer stays inside the window");
// The system cursor reporting a stale place (Proton, gamescope) does not take the pointer back.
ImGui::GetIO().AddMousePosEvent(10, 10);
frame(feed, pad(), false, &warp);
// The system cursor stays where the mouse left it, and is reported every frame.
io.AddMousePosEvent(400, 300);
frame(feed, pad());
check(!warp && ImGui::GetIO().MousePos.x == 799, "Without tilt the pointer stays put");
frame(feed, pad());
check(io.MousePos.x == 799, "The system cursor's old place does not take the pointer back");
feed.mouse_moved(ImVec2(400, 300)); // the same place, repeated by Windows
check(feed.pointing(), "A mouse move repeated in the same place keeps the pointer");
feed.mouse_moved(ImVec2(799, 0)); // the echo of our own cursor move
check(feed.pointing(), "The echo of our own cursor move keeps the pointer");
feed.mouse_moved(ImVec2(50, 60)); // the mouse, or a finger on the screen
ImGui::GetIO().AddMousePosEvent(50, 60);
feed.mouse_moved(ImVec2(50, 60)); // the mouse, or a finger on the screen
io.AddMousePosEvent(50, 60);
frame(feed, pad());
frame(feed, pad());
check(!feed.pointing() && !ImGui::GetIO().MouseDrawCursor, "The mouse takes the pointer back");
check(ImGui::GetIO().MousePos.x == 50, "The mouse's place wins once it moved");
check(!feed.pointing() && !io.MouseDrawCursor, "The mouse takes the pointer back");
check(io.MousePos.x == 50, "The mouse's place wins once it moved");
}
{
fresh();
@@ -185,14 +212,15 @@ int main() {
frame(feed, pad());
frame(feed, pad());
std::string pressed = frame(feed, pad(right_thumb)).pressed;
check(ImGui::GetIO().MouseDown[0], "The trackpad's click (R3) holds the left button");
const auto released = frame(feed, pad()).pressed;
if (pressed.empty()) pressed = released;
pressed += frame(feed, pad()).pressed;
check(feed.pointing() && pressed.empty() && !ImGui::GetIO().MouseDown[0],
"The first trackpad click (R3) only shows the pointer");
pressed = frame(feed, pad(right_thumb)).pressed;
check(ImGui::GetIO().MouseDown[0], "The next click holds the left button");
pressed += frame(feed, pad()).pressed;
check(pressed == "second", "The trackpad's click presses what the pointer is over");
check(!ImGui::GetIO().MouseDown[0], "Letting go of R3 lets go of the left button");
frame(feed, pad(0, 20000, 0));
check(feed.pointing(), "The pointer is the pad's");
press(feed, dpad_down);
check(!feed.pointing() && ImGui::GetIO().NavVisible, "The D-pad hands the screen back to the focus");
}
+16 -19
View File
@@ -29,8 +29,7 @@ constexpr float left_dead_zone = 7849.0f / 32767.0f;
constexpr float right_dead_zone = 0.2f;
// Pointer speed at full tilt, in design pixels a second.
constexpr float pointer_speed = 1400.0f;
// A mouse move this close to the pad's pointer is the echo of moving the
// system cursor there; one further away is the mouse or a finger.
// A mouse move this close to the last one is the same place reported again.
constexpr float pointer_slack = 2.0f;
float axis(std::int16_t value) { return std::clamp(static_cast<float>(value) / 32767.0f, -1.0f, 1.0f); }
@@ -52,13 +51,13 @@ bool imgui_cancels() {
return top && !(top->Flags & ImGuiWindowFlags_Modal);
}
bool moved_away(ImVec2 position, ImVec2 pointer) {
return std::abs(position.x - pointer.x) > pointer_slack || std::abs(position.y - pointer.y) > pointer_slack;
bool moved_away(ImVec2 position, ImVec2 before) {
return std::abs(position.x - before.x) > pointer_slack || std::abs(position.y - before.y) > pointer_slack;
}
} // namespace
std::optional<ImVec2> PadFeed::update(ImGuiIO& io, const PadState& input, float scale) {
void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) {
// A pad that went away lets go of everything it held.
const PadState pad = input.connected ? input : PadState{};
const auto changed = static_cast<std::uint16_t>(pad.buttons ^ buttons_);
@@ -90,12 +89,13 @@ std::optional<ImVec2> PadFeed::update(ImGuiIO& io, const PadState& input, float
if ((pressed & focus_bits) || std::abs(left_x) > left_dead_zone || std::abs(left_y) > left_dead_zone)
pointing_ = false;
bool warp = false;
const float x = axis(pad.right_x), y = -axis(pad.right_y); // screen Y grows downward
const float tilt = std::min(1.0f, std::sqrt(x * x + y * y));
// The first R3 only shows the pointer: a stray click must not press
// whatever the mouse last rested on.
const bool click = pointing_ && (pressed & right_thumb);
if (!pointing_ && (tilt > right_dead_zone || (pressed & right_thumb))) {
pointing_ = true;
warp = true;
// From wherever the mouse left it, which may be outside the window.
pointer_ = ImGui::IsMousePosValid(&io.MousePos) ? io.MousePos
: ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f);
@@ -107,31 +107,28 @@ std::optional<ImVec2> PadFeed::update(ImGuiIO& io, const PadState& input, float
const float step = speed * speed * pointer_speed * scale * io.DeltaTime / tilt;
pointer_.x += x * step;
pointer_.y += y * step;
warp = true;
}
if (pointing_) {
pointer_.x = std::clamp(pointer_.x, 0.0f, std::max(0.0f, io.DisplaySize.x - 1));
pointer_.y = std::clamp(pointer_.y, 0.0f, std::max(0.0f, io.DisplaySize.y - 1));
// Every frame, after the backend's own: it may report a system cursor
// that Proton or gamescope did not move along with ours.
// Every frame, after the backend's own report of the system cursor,
// which stays where the mouse left it.
io.AddMouseSourceEvent(ImGuiMouseSource_Mouse);
io.AddMousePosEvent(pointer_.x, pointer_.y);
}
// The release is sent even when the mouse took the pointer meanwhile: the
// press was ours.
if (changed & right_thumb) {
if (click || (clicking_ && (changed & right_thumb))) {
clicking_ = click;
io.AddMouseSourceEvent(ImGuiMouseSource_Mouse);
io.AddMouseButtonEvent(ImGuiMouseButton_Left, (pad.buttons & right_thumb) != 0);
io.AddMouseButtonEvent(ImGuiMouseButton_Left, click);
}
io.MouseDrawCursor = pointing_;
if (warp) return pointer_;
return std::nullopt;
}
void PadFeed::mouse_moved(ImVec2 position) {
// Our own cursor move comes back as a mouse move too; only one that lands
// somewhere else is the mouse or a finger.
if (pointing_ && moved_away(position, pointer_)) pointing_ = false;
// Windows repeats a mouse move where the mouse already was when a window
// changes under it; only a move to a new place is the mouse or a finger.
if (pointing_ && moved_away(position, mouse_)) pointing_ = false;
mouse_ = position;
}
} // namespace dingosdk::launcher_gui
+8 -8
View File
@@ -3,7 +3,6 @@
#include <imgui.h>
#include <cstdint>
#include <optional>
// Controller input for the launcher window. Kept free of Windows headers so
// the mapping can be tested anywhere; gui_gamepad.cpp reads the pads.
@@ -29,15 +28,14 @@ struct PadState {
class PadFeed {
public:
// Call once a frame after the backends' NewFrame and before
// ImGui::NewFrame(), which takes the events in. Returns where the OS
// cursor should move (client area pixels) when the pad moved the pointer.
std::optional<ImVec2> update(ImGuiIO& io, const PadState& pad, float scale);
// ImGui::NewFrame(), which takes the events in.
void update(ImGuiIO& io, const PadState& pad, float scale);
// A mouse move or a touch the window received (WM_MOUSEMOVE, client area
// pixels). One that is not the echo of our own cursor move takes the
// pointer back from the pad.
// pixels). A move to a new place takes the pointer back from the pad.
void mouse_moved(ImVec2 position);
// The right stick owns the pointer; ImGui draws it, since the Steam
// Deck's Game Mode may not show the system cursor.
// The right stick owns the pointer. ImGui draws it and the system cursor
// stays put: under Proton a moved system cursor comes back late as a
// mouse move, which would look like the mouse taking over.
bool pointing() const { return pointing_; }
private:
@@ -45,7 +43,9 @@ private:
ImGuiKey back_key_{ImGuiKey_Escape};
bool connected_{};
bool pointing_{};
bool clicking_{}; // R3 holds the left button
ImVec2 pointer_{};
ImVec2 mouse_{-1, -1}; // where the last mouse move was
};
} // namespace dingosdk::launcher_gui
+1 -7
View File
@@ -472,13 +472,7 @@ int run(const launcher_app::Session& session, const std::vector<std::wstring>& a
// A pad pressed while another window has the focus is not meant for us;
// it reads as let go, so nothing stays held while we are in the background.
const bool foreground = GetForegroundWindow() == window;
if (const auto pointer = pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale)) {
// The system cursor follows the pad's pointer, so the two agree when the
// mouse or the touch screen is used next. ImGui draws the pointer itself,
// so a cursor the system will not move (Proton, gamescope) costs nothing.
POINT point{static_cast<LONG>(pointer->x), static_cast<LONG>(pointer->y)};
if (ClientToScreen(window, &point)) SetCursorPos(point.x, point.y);
}
pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale);
ImGui::NewFrame();
frame(launcher, fonts, window, ui, mods_panel);
ImGui::Render();
+8 -6
View File
@@ -571,7 +571,7 @@ void installed_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, co
// ------------------------------------------------ the list, and under it how its order works
const float hint = ImGui::GetTextLineHeightWithSpacing();
const float body = std::max(S(120), height - (ImGui::GetCursorPosY() - top) - hint);
ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_Borders);
ImGui::BeginChild("##mod_list", ImVec2(0, body), ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened);
const auto note = [](const char* first, const char* second = nullptr) {
ImGui::Spacing();
ImGui::Indent(S(14));
@@ -670,7 +670,8 @@ void mod_overview(const Fonts& fonts, ModsPanel& panel, const thunderstore::Inst
ImGui::Spacing();
ImGui::BeginChild("##mod_overview_body",
ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight())));
ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight())),
ImGuiChildFlags_NavFlattened);
ImGui::PushTextWrapPos(0);
if (!mod.outdated.empty())
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger),
@@ -850,7 +851,7 @@ void mods_broken_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui&
const float footer = ImGui::GetFrameHeight() + ImGui::GetTextLineHeight() + S(56);
ImGui::BeginChild("##broken_list", ImVec2(0, frame.y - ImGui::GetCursorPosY() - footer),
ImGuiChildFlags_Borders);
ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened);
for (const auto& problem : problems) {
ImGui::PushFont(fonts.bold);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(color::danger));
@@ -934,7 +935,7 @@ void mods_outdated_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, U
const float footer = ImGui::GetFrameHeight() + S(44);
ImGui::BeginChild("##outdated_list", ImVec2(0, frame.y - ImGui::GetCursorPosY() - footer),
ImGuiChildFlags_Borders);
ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened);
for (const auto* package : pending) {
const auto found = installed.find(thunderstore::folder_for(package->full_name));
ImGui::PushFont(fonts.bold);
@@ -1013,12 +1014,13 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo
const float rail_top = top + title_size * 2 + S(16);
ImGui::SetCursorPos(ImVec2(rail_x, rail_top));
ImGui::BeginChild("##rail", ImVec2(rail_width, bottom - rail_top));
// Flattened, so a controller's D-pad crosses from the rail into the list and back.
ImGui::BeginChild("##rail", ImVec2(rail_width, bottom - rail_top), ImGuiChildFlags_NavFlattened);
const bool leave = rail(fonts, panel, window, rail_width, pending, installing);
ImGui::EndChild();
ImGui::SetCursorPos(ImVec2(content_x, top));
ImGui::BeginChild("##content", ImVec2(frame.x - content_x - S(28), bottom - top));
ImGui::BeginChild("##content", ImVec2(frame.x - content_x - S(28), bottom - top), ImGuiChildFlags_NavFlattened);
// With nothing to say, the list runs all the way down to BACK's bottom
// edge; a message takes two lines off it until it is gone.
const float status = panel.message.empty() ? S(4) : ImGui::GetTextLineHeight() * 2 + S(12);
+3 -2
View File
@@ -474,7 +474,7 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float
const auto packages = visible_packages(store, installed);
// ------------------------------------------------ the list
ImGui::BeginChild("##store_list", ImVec2(0, body), ImGuiChildFlags_Borders);
ImGui::BeginChild("##store_list", ImVec2(0, body), ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened);
const auto note = [](const char* text) {
ImGui::Spacing();
ImGui::Indent(S(14));
@@ -653,7 +653,8 @@ void package_overview(const Fonts& fonts, ModsPanel& panel, ImVec2 size, bool in
ImGui::Spacing();
ImGui::BeginChild("##overview_body",
ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight())));
ImVec2(0, std::max(S(80), extent.y - ImGui::GetCursorPosY() - S(24) - ImGui::GetFrameHeight())),
ImGuiChildFlags_NavFlattened);
ImGui::PushTextWrapPos(0);
if (package.deprecated)
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::warning),
+4 -3
View File
@@ -123,7 +123,8 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui
// ------------------------------------------------ rail
const float rail_top = top + title_size + S(20);
ImGui::SetCursorPos(ImVec2(rail_x, rail_top));
ImGui::BeginChild("##settings_rail", ImVec2(rail_width, bottom - rail_top));
// Flattened, so a controller's D-pad crosses from the rail into the page and back.
ImGui::BeginChild("##settings_rail", ImVec2(rail_width, bottom - rail_top), ImGuiChildFlags_NavFlattened);
static constexpr std::array<const char*, 4> names{"GAME", "DISPLAY", "KEYS", "ADVANCED"};
ImGui::BeginDisabled(binding);
for (int i = 0; i < static_cast<int>(names.size()); ++i)
@@ -140,9 +141,9 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui
// Kept to a readable column: settings are sentences, not a table.
ImGui::SetCursorPos(ImVec2(content_x, top));
ImGui::BeginChild("##settings_content",
ImVec2(std::min(S(760), frame.x - content_x - S(28)), bottom - top));
ImVec2(std::min(S(760), frame.x - content_x - S(28)), bottom - top), ImGuiChildFlags_NavFlattened);
const float footer = ImGui::GetTextLineHeight() + S(16);
ImGui::BeginChild("##settings_page", ImVec2(0, ImGui::GetWindowHeight() - footer));
ImGui::BeginChild("##settings_page", ImVec2(0, ImGui::GetWindowHeight() - footer), ImGuiChildFlags_NavFlattened);
switch (ui.settings_tab) {
case 0: {
section_caption(fonts, "GAME FOLDER");