Launcher: controller starts on PLAY and the open tab; rows and side moves in lists

- The focus starts on PLAY when the launcher opens with a pad connected, and
  on the open tab (GAME, MY MODS) when the pad opens Settings or the Mod
  manager.
- List rows act as one item: up and down go from row to row in one press,
  and a row's buttons take the focus only from the row (D-pad right).
- D-pad left and right between the rail and a list: ImGui looks to the side
  only along a thin band through the focus and missed items level with the
  gap between two rows, so a side move that finds nothing looks again across
  the focus's full height.
- The left stick scrolls the panel under the pointer directly instead of
  through wheel events, which queued behind the pointer's moves.
- On a Steam Deck (SteamDeck=1) the right stick gets a trackpad's feel: small
  swipes move the pointer, and the glide after the thumb lifts is cut.
This commit is contained in:
NorthDroplet committed 2026-10-07 20:57:08 +00:00
1 parent 21d5a0ba4b
commit 7b093429a2
8 files changed
+255 -28

No files matched your search

+112
View File
@@ -22,6 +22,7 @@ void check(bool condition, const char* what) {
++failures;
}
constexpr std::uint16_t dpad_up = 0x0001, dpad_left = 0x0004;
constexpr std::uint16_t dpad_down = 0x0002, dpad_right = 0x0008, right_thumb = 0x0080, button_a = 0x1000, button_b = 0x2000;
struct Seen {
@@ -39,6 +40,7 @@ Seen frame(PadFeed& feed, const PadState& pad, bool open_menu = false, bool pane
io.DeltaTime = 1.0f / 60.0f;
feed.update(io, pad, 1.0f);
ImGui::NewFrame();
feed.after_new_frame();
Seen seen;
ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(ImVec2(800, 600));
@@ -99,6 +101,56 @@ std::string press(PadFeed& feed, std::uint16_t button) {
return pressed.empty() ? released : pressed;
}
// The Mod manager's layout: a rail of tabs beside a list whose rows carry
// buttons, the rail's tiles level with the gaps between rows.
std::string mods_page(PadFeed& feed, const PadState& state) {
auto& io = ImGui::GetIO();
io.DisplaySize = ImVec2(900, 700);
io.DeltaTime = 1.0f / 60.0f;
feed.update(io, state, 1.0f);
ImGui::NewFrame();
feed.after_new_frame();
std::string pressed;
ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(ImVec2(900, 700));
ImGui::Begin("##page", nullptr, ImGuiWindowFlags_NoDecoration);
ImGui::SetCursorPos(ImVec2(20, 100));
ImGui::BeginChild("##rail", ImVec2(200, 400), ImGuiChildFlags_NavFlattened);
if (ImGui::Button("MY MODS", ImVec2(150, 40))) pressed = "MY MODS";
dingosdk::launcher_gui::default_focus();
if (ImGui::Button("GET MODS", ImVec2(150, 40))) pressed = "GET MODS";
ImGui::EndChild();
ImGui::SetCursorPos(ImVec2(260, 100));
ImGui::BeginChild("##content", ImVec2(600, 500), ImGuiChildFlags_NavFlattened);
for (int row = 0; row < 12; ++row) {
ImGui::PushID(row);
dingosdk::launcher_gui::begin_row();
const ImVec2 start = ImGui::GetCursorScreenPos();
if (ImGui::Selectable("##row", false, ImGuiSelectableFlags_AllowOverlap, ImVec2(560, 60)))
pressed = "row " + std::to_string(row);
const ImVec2 next = ImGui::GetCursorScreenPos();
dingosdk::launcher_gui::row_buttons();
ImGui::SetCursorScreenPos(ImVec2(start.x + 380, start.y + 14));
if (ImGui::Button("INSTALL", ImVec2(100, 32))) pressed = "install " + std::to_string(row);
dingosdk::launcher_gui::end_row();
ImGui::SetCursorScreenPos(next);
ImGui::PopID();
}
ImGui::EndChild();
ImGui::End();
ImGui::Render();
return pressed;
}
// Taps a D-pad direction on the Mod manager, then A: what A pressed is where the focus went.
std::string mods_tap(PadFeed& feed, std::uint16_t button) {
mods_page(feed, pad(button));
for (int i = 0; i < 3; ++i) mods_page(feed, pad());
std::string pressed = mods_page(feed, pad(button_a));
pressed += mods_page(feed, pad());
return pressed;
}
void fresh() {
if (ImGui::GetCurrentContext()) ImGui::DestroyContext();
ImGui::CreateContext();
@@ -138,6 +190,33 @@ int main() {
check(!(ImGui::GetIO().BackendFlags & ImGuiBackendFlags_HasGamepad), "An unplugged pad is no gamepad");
}
// ------------------------------------------------ the Mod manager
{
fresh();
PadFeed feed;
mods_page(feed, pad());
mods_page(feed, pad());
check(GImGui->NavCursorVisible && mods_tap(feed, 0) == "MY MODS",
"With a pad connected the page opens with the focus on its tab");
check(mods_tap(feed, dpad_down) == "GET MODS", "Down goes to the next tab");
const auto row = mods_tap(feed, dpad_right);
check(row.rfind("row ", 0) == 0, "Right goes from a tab level with a gap between rows into the list");
check(mods_tap(feed, dpad_left) == "GET MODS", "Left goes from the list back to the tabs");
mods_tap(feed, dpad_right);
const int first = std::stoi(mods_tap(feed, dpad_down).substr(4));
check(mods_tap(feed, dpad_down) == "row " + std::to_string(first + 1), "Down goes to the next row in one press");
check(mods_tap(feed, dpad_up) == "row " + std::to_string(first), "Up goes to the row above in one press");
check(mods_tap(feed, dpad_right) == "install " + std::to_string(first), "Right goes to the row's own button");
check(mods_tap(feed, dpad_up) == "row " + std::to_string(first - 1), "Up from a row's button goes to the row above");
}
{
fresh();
PadFeed feed;
mods_page(feed, PadState{});
mods_page(feed, PadState{});
check(!GImGui->NavCursorVisible, "Without a pad nothing shows a focus");
}
// ------------------------------------------------ back
{
fresh();
@@ -227,6 +306,39 @@ int main() {
check(scroll > 100, "The left stick scrolls the panel under the mouse");
}
// ------------------------------------------------ the Steam Deck's trackpad
{
fresh();
PadFeed stick;
PadFeed trackpad(true);
ImGui::GetIO().AddMousePosEvent(400, 300);
frame(trackpad, pad());
for (int i = 0; i < 10; ++i) frame(trackpad, pad(0, 3300, 0)); // a slow swipe
frame(trackpad, pad());
check(ImGui::GetIO().MousePos.x > 405, "A slow swipe on the trackpad moves the pointer");
fresh();
ImGui::GetIO().AddMousePosEvent(400, 300);
frame(stick, pad());
for (int i = 0; i < 10; ++i) frame(stick, pad(0, 3300, 0));
frame(stick, pad());
check(!stick.pointing() && ImGui::GetIO().MousePos.x == 400, "The same small tilt of a thumbstick does not");
fresh();
PadFeed glide(true);
ImGui::GetIO().AddMousePosEvent(100, 300);
frame(glide, pad());
for (int i = 0; i < 5; ++i) frame(glide, pad(0, 20000, 0)); // the swipe
float tilt = 20000;
float stopped = 0;
for (int i = 0; i < 30; ++i) { // the thumb lifted: the tilt only fades
tilt *= 0.9f;
frame(glide, pad(0, static_cast<std::int16_t>(tilt), 0));
if (i == 6) stopped = ImGui::GetIO().MousePos.x;
}
frame(glide, pad());
check(stopped > 100 && ImGui::GetIO().MousePos.x == stopped, "The trackpad's glide after the thumb lifts does not carry the pointer on");
}
// ------------------------------------------------ trackpad pointer
{
fresh();
+97 -25
View File
@@ -24,15 +24,25 @@ constexpr std::uint16_t focus_bits = dpad_up | dpad_down | dpad_left | dpad_righ
// XInput's left stick dead zone.
constexpr float left_dead_zone = 7849.0f / 32767.0f;
// Larger on the right: a thumb resting on the Deck's trackpad is never quite
// centred, and the pointer must not creep under it.
constexpr float right_dead_zone = 0.2f;
// Pointer speed at full tilt, in design pixels a second.
constexpr float pointer_speed = 1400.0f;
// Scrolling at full tilt: design pixels a second for the focused panel, and
// mouse wheel notches a second (ImGui scrolls five lines a notch) under a pointer.
// Scrolling at full tilt, in design pixels a second.
constexpr float scroll_speed = 1200.0f;
constexpr float wheel_speed = 16.0f;
// How the right stick drives the pointer.
struct PointerFeel {
float dead_zone;
float speed; // design pixels a second at full tilt
bool squared; // a small tilt is slow, for placing the pointer precisely
bool cut_glide; // stop once the tilt only fades
};
// A thumbstick sets a speed. A resting thumb is never quite centred, so the
// dead zone is large.
constexpr PointerFeel stick_feel{0.2f, 1400.0f, true, false};
// The Steam Deck's trackpad, in Steam's gamepad layouts, tilts the stick by how
// fast the thumb swipes and keeps gliding after it lifts, fading out. Small
// swipes must move the pointer, and the glide must not carry it on.
constexpr PointerFeel trackpad_feel{0.06f, 2600.0f, false, true};
// Frames of a steadily fading tilt that make it a glide rather than the thumb.
constexpr int glide_frames = 4;
// A mouse move this close to the last one is the same place reported again.
constexpr float pointer_slack = 2.0f;
@@ -41,20 +51,17 @@ float axis(std::int16_t value) { return std::clamp(static_cast<float>(value) / 3
// The left stick scrolls whatever the user is looking at: the panel holding
// the pad's focus while the focus is shown, else the one under the pointer.
// ImGui's own stick scrolling only ever moves the focused window, which under a
// pointer is usually the page itself, with nothing to scroll.
void scroll(ImGuiIO& io, float tilt, float scale) {
// pointer is usually the page itself, with nothing to scroll. It scrolls the
// panel directly: wheel events would queue behind the pointer's moves and lag.
void scroll(const ImGuiIO& io, float tilt, float scale) {
const float amount = std::copysign(std::clamp((std::abs(tilt) - left_dead_zone) / (1 - left_dead_zone), 0.0f, 1.0f), tilt);
if (amount == 0.0f) return;
const ImGuiContext& g = *GImGui;
if (g.NavCursorVisible && g.NavHighlightItemUnderNav) {
// The nearest panel around the focus that can scroll.
ImGuiWindow* window = g.NavWindow;
while (window && window->ScrollMax.y <= 0 && (window->Flags & ImGuiWindowFlags_ChildWindow)) window = window->ParentWindow;
if (window && window->ScrollMax.y > 0)
ImGui::SetScrollY(window, window->Scroll.y - amount * scroll_speed * scale * io.DeltaTime);
return;
}
io.AddMouseWheelEvent(0, amount * wheel_speed * io.DeltaTime);
ImGuiWindow* window = g.NavCursorVisible && g.NavHighlightItemUnderNav ? g.NavWindow : g.HoveredWindow;
// The nearest panel around it that can scroll.
while (window && window->ScrollMax.y <= 0 && (window->Flags & ImGuiWindowFlags_ChildWindow)) window = window->ParentWindow;
if (window && window->ScrollMax.y > 0)
ImGui::SetScrollY(window, window->Scroll.y - amount * scroll_speed * scale * io.DeltaTime);
}
// B goes to ImGui's own cancel while ImGui has something to cancel: a combo or
@@ -99,22 +106,28 @@ void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) {
// a pointer moves, so the pointer goes away instead. Scrolling keeps both.
if (pressed & focus_bits) pointing_ = false;
const PointerFeel& feel = trackpad_ ? trackpad_feel : stick_feel;
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));
if (feel.cut_glide) {
// A glide only ever fades; the thumb speeds up again or stops.
if (tilt <= feel.dead_zone || tilt > last_tilt_ + 0.01f) fading_ = 0;
else if (tilt < last_tilt_) ++fading_;
last_tilt_ = tilt;
}
const bool steering = tilt > feel.dead_zone && fading_ < glide_frames;
// 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))) {
if (!pointing_ && (steering || (pressed & right_thumb))) {
pointing_ = 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);
}
if (pointing_ && tilt > right_dead_zone) {
// Squared, so a small tilt places the pointer precisely and a full one
// crosses the window in about a second.
const float speed = (tilt - right_dead_zone) / (1 - right_dead_zone);
const float step = speed * speed * pointer_speed * scale * io.DeltaTime / tilt;
if (pointing_ && steering) {
const float speed = (tilt - feel.dead_zone) / (1 - feel.dead_zone);
const float step = (feel.squared ? speed * speed : speed) * feel.speed * scale * io.DeltaTime / tilt;
pointer_.x += x * step;
pointer_.y += y * step;
}
@@ -134,6 +147,65 @@ void PadFeed::update(ImGuiIO& io, const PadState& input, float scale) {
io.MouseDrawCursor = pointing_;
}
void PadFeed::after_new_frame() {
ImGuiContext& g = *GImGui;
// ImGui looks to the side only along a thin band through the middle of the
// focus, so it misses an item level with the gap between two list rows: the
// rail's tiles beside a list. A side move that found nothing looks again
// across the focus's full height.
if (side_dir_ != ImGuiDir_None && g.NavId == side_from_ && g.NavWindow && !g.NavMoveSubmitted) {
const ImGuiDir dir = side_dir_;
side_dir_ = ImGuiDir_None;
ImGui::NavMoveRequestSubmit(dir, dir, ImGuiNavMoveFlags_None,
ImGuiScrollFlags_KeepVisibleEdgeX | ImGuiScrollFlags_KeepVisibleEdgeY);
g.NavScoringRect = ImGui::WindowRectRelToAbs(g.NavWindow, g.NavWindow->NavRectRel[g.NavLayer]);
last_nav_id_ = g.NavId;
return;
}
side_dir_ = ImGuiDir_None;
if (g.NavMoveSubmitted && (g.NavMoveDir == ImGuiDir_Left || g.NavMoveDir == ImGuiDir_Right)) {
side_dir_ = g.NavMoveDir;
// ImGui clears the focus for the move when the item is wider than its
// panel; the move then lands on that same item, which is no move either.
side_from_ = g.NavId ? g.NavId : last_nav_id_;
}
last_nav_id_ = g.NavId;
}
namespace {
bool g_row_focused{};
} // namespace
void default_focus() {
ImGui::SetItemDefaultFocus();
ImGuiContext& g = *GImGui;
// With a pad there, the focus starts on it straight away as the window
// appears: when the launcher starts, and when the pad opened this page.
// (Not whenever nothing has the focus: ImGui clears it for a frame while
// the focus moves off a row wider than its panel.)
const bool pad = (g.IO.BackendFlags & ImGuiBackendFlags_HasGamepad) != 0;
const bool pad_led = g.NavCursorVisible && g.NavHighlightItemUnderNav;
if (!pad || !ImGui::IsWindowAppearing() || !(pad_led || g.FrameCount <= 2)) return;
ImGui::SetFocusID(g.LastItemData.ID, g.CurrentWindow);
g.NavCursorVisible = true;
g.NavHighlightItemUnderNav = true;
}
void begin_row() {
const ImGuiID scope = ImGui::GetID("##row_focus");
g_row_focused = GImGui->NavFocusScopeId == scope;
ImGui::PushFocusScope(scope);
}
void row_buttons() {
ImGui::PushItemFlag(ImGuiItemFlags_NoNav, !g_row_focused);
}
void end_row() {
ImGui::PopItemFlag();
ImGui::PopFocusScope();
}
void PadFeed::mouse_moved(ImVec2 position) {
// 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.
+24
View File
@@ -28,9 +28,14 @@ struct PadState {
// click is R3, so this is what makes the trackpad a mouse in the launcher.
class PadFeed {
public:
// `trackpad`: the right stick is a Steam Deck's trackpad, not a thumbstick.
explicit PadFeed(bool trackpad = false) : trackpad_(trackpad) {}
// Call once a frame after the backends' NewFrame and before
// ImGui::NewFrame(), which takes the events in.
void update(ImGuiIO& io, const PadState& pad, float scale);
// Call right after ImGui::NewFrame(): looks again for a D-pad left or right
// that ImGui found nothing for.
void after_new_frame();
// A mouse move or a touch the window received (WM_MOUSEMOVE, client area
// pixels). A move to a new place takes the pointer back from the pad.
void mouse_moved(ImVec2 position);
@@ -40,7 +45,13 @@ public:
bool pointing() const { return pointing_; }
private:
bool trackpad_{};
std::uint16_t buttons_{};
float last_tilt_{};
int fading_{}; // frames the right stick's tilt only faded
ImGuiDir side_dir_{ImGuiDir_None}; // a D-pad left or right ImGui looked for last frame
ImGuiID side_from_{};
ImGuiID last_nav_id_{};
ImGuiKey back_key_{ImGuiKey_Escape};
bool pointing_{};
bool clicking_{}; // R3 holds the left button
@@ -48,4 +59,17 @@ private:
ImVec2 mouse_{-1, -1}; // where the last mouse move was
};
// Makes the item just submitted the one a controller starts on in its window.
// With a pad connected, the focus moves there at once when nothing has it yet
// or when the pad opened the page.
void default_focus();
// A list row a controller treats as one item: up and down go from row to row
// in one press, and the buttons on a row take the focus only from the row
// itself (D-pad right). begin_row() before the row's list_row, row_buttons()
// after it, end_row() after the row's last button.
void begin_row();
void row_buttons();
void end_row();
} // namespace dingosdk::launcher_gui
+5 -1
View File
@@ -435,7 +435,10 @@ int run(const launcher_app::Session& session, const std::vector<std::wstring>& a
const auto ui_storage = std::make_unique<Ui>();
auto& ui = *ui_storage;
ModsPanel mods_panel;
PadFeed pad_feed;
// The Steam Deck's right trackpad reaches the launcher as the right stick
// (Steam sets SteamDeck=1 for what it starts there), which wants a
// trackpad's feel rather than a thumbstick's.
PadFeed pad_feed(GetEnvironmentVariableW(L"SteamDeck", nullptr, 0) > 0);
g_pad_feed = &pad_feed;
bool running = true;
HANDLE game{};
@@ -490,6 +493,7 @@ int run(const launcher_app::Session& session, const std::vector<std::wstring>& a
const bool foreground = GetForegroundWindow() == window;
pad_feed.update(ImGui::GetIO(), foreground ? read_pad() : PadState{}, g_scale);
ImGui::NewFrame();
pad_feed.after_new_frame();
frame(launcher, fonts, window, ui, mods_panel);
// While the pad moves the focus, the mouse cursor (the Deck's trackpad in its
// desktop layout) hides; the next mouse move hides the focus and brings it back.
+4 -1
View File
@@ -1,6 +1,7 @@
#include "gui_internal.h"
#include "mod_manager.h"␍
#include "mod_manager.h"
#include "problem.h"
#include <cmath>
@@ -463,6 +464,8 @@ void frame(Launcher& launcher, const Fonts& fonts, HWND window, Ui& ui, ModsPane
? (ui.mods_pending == 1 ? std::string("1 MOD UPDATE") : std::format("{} MOD UPDATES", ui.mods_pending))
: std::string();
if (action_tile(draw, fonts, primary, primary_size, label, detail, enabled && !modal, secondary, play_mark)) act();
// A controller starts on PLAY.
if (!modal) default_focus();
if (settings_tile(draw, fonts, settings, ImVec2(column, S(84)), !modal)) ui.settings = true;
if (time - ui.mods_checked > 5 && !ui.mods) {
std::size_t enabled_mods = 0, installed = 0;
+6
View File
@@ -129,10 +129,13 @@ bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width,
: pending.size() == 1 ? std::string("1 update on Thunderstore")
: std::format("{} updates on Thunderstore", pending.size())))
panel.tab = 0;
// A controller starts on the open tab's tile.
if (panel.tab == 0) default_focus();
if (nav_tile(fonts, width, "GET MODS", panel.tab == 1,
store.loaded ? std::to_string(store.packages.size()) : std::string("..."), false,
store.loaded ? std::string() : std::string("Loading the Thunderstore listing")))
panel.tab = 1;
if (panel.tab == 1) default_focus();
ImGui::Dummy(ImVec2(0, S(8)));
if (!pending.empty()) {
@@ -316,8 +319,10 @@ void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Ins
const ImVec2 start = ImGui::GetCursorScreenPos();
const float width = ImGui::GetContentRegionAvail().x;
const bool ticked = panel.marked.contains(mod.name);
begin_row();
const bool pressed = list_row("##row", width, tall, ticked);
bool menu = ImGui::IsItemClicked(ImGuiMouseButton_Right);
row_buttons();
if (pressed) {
// Ctrl and Shift pick, as they do in a file list; a plain click opens the mod.
if (ImGui::GetIO().KeyShift) mark(panel, view, position, true, true);
@@ -399,6 +404,7 @@ void installed_row(const Fonts& fonts, ModsPanel& panel, const thunderstore::Ins
if (const auto detail = summary(panel, mod); !detail.empty())
draw->AddText(fonts.body, fonts.body->FontSize, ImVec2(text_x, start.y + S(37)), color::muted, detail.c_str(),
nullptr, 0, &clip);
end_row();
if (menu) ImGui::OpenPopup("##menu");
if (ImGui::BeginPopup("##menu")) {
+3
View File
@@ -518,10 +518,12 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float
const ImVec2 start = ImGui::GetCursorScreenPos();
const float width = ImGui::GetContentRegionAvail().x;
// The row itself opens the overview; the widgets on it keep their clicks.
begin_row();
if (list_row("##row", width, tall, ticked)) {
store.selected = package.full_name;
store.overview = false;
}
row_buttons();
const float right = start.x + width;
const float text_x = start.x + S(82);
mod_icon(panel, &package, ImVec2(start.x + S(14), start.y + S(11)), S(56));
@@ -572,6 +574,7 @@ void browse_page(Launcher& launcher, const Fonts& fonts, ModsPanel& panel, float
std::format("v{}{} / {} downloads / {}", version.number,
version.file_size ? " / " + size_text(version.file_size) : std::string(),
count_text(package.downloads), date_text(package.date_updated)).c_str());
end_row();
ImGui::PopID();
});
ImGui::EndDisabled();
+4 -1
View File
@@ -127,9 +127,12 @@ void settings_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui
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)
for (int i = 0; i < static_cast<int>(names.size()); ++i) {
if (nav_tile(fonts, rail_width, names[static_cast<std::size_t>(i)], ui.settings_tab == i))
ui.settings_tab = i;
// A controller starts on the open tab's tile.
if (ui.settings_tab == i) default_focus();
}
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - S(38));
push_primary_button();
if (ImGui::Button("\xe2\x86\x90 BACK", ImVec2(-1, S(34)))) open = false;