Improve SDL gamepad prompts and feedback

Use SDL button labels so shell prompts match the connected
controller instead of always using Xbox-style wording. Expose
trigger rumble, touchpad count, gyro, and accelerometer support
through the gamepad wrapper and surface the relevant capability
labels in the overlay.

Enable background gamepad events and add short rumble feedback
for major shell actions such as opening quick settings, opening
the exit prompt, and launching a game.
This commit is contained in:
Marco Allegretti 2026-04-21 09:57:51 +02:00
parent 55f778ebe8
commit d913c2ab0a
6 changed files with 204 additions and 3 deletions

View file

@ -6,9 +6,34 @@
#include <SDL3/SDL_gamepad.h> #include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_power.h> #include <SDL3/SDL_power.h>
#include <SDL3/SDL_properties.h> #include <SDL3/SDL_properties.h>
#include <SDL3/SDL_sensor.h>
#include <algorithm> #include <algorithm>
static QString gamepadButtonLabelToString(SDL_GamepadButtonLabel label)
{
switch (label) {
case SDL_GAMEPAD_BUTTON_LABEL_A:
return QStringLiteral("A");
case SDL_GAMEPAD_BUTTON_LABEL_B:
return QStringLiteral("B");
case SDL_GAMEPAD_BUTTON_LABEL_X:
return QStringLiteral("X");
case SDL_GAMEPAD_BUTTON_LABEL_Y:
return QStringLiteral("Y");
case SDL_GAMEPAD_BUTTON_LABEL_CROSS:
return QStringLiteral("Cross");
case SDL_GAMEPAD_BUTTON_LABEL_CIRCLE:
return QStringLiteral("Circle");
case SDL_GAMEPAD_BUTTON_LABEL_SQUARE:
return QStringLiteral("Square");
case SDL_GAMEPAD_BUTTON_LABEL_TRIANGLE:
return QStringLiteral("Triangle");
default:
return QStringLiteral("?");
}
}
GamepadDevice::GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent) GamepadDevice::GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent)
: QObject(parent) : QObject(parent)
, m_pad(pad) , m_pad(pad)
@ -76,6 +101,15 @@ bool GamepadDevice::hasRumble() const
return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false); return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false);
} }
bool GamepadDevice::hasTriggerRumble() const
{
if (!m_pad) {
return false;
}
SDL_PropertiesID props = SDL_GetGamepadProperties(m_pad);
return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN, false);
}
bool GamepadDevice::hasLED() const bool GamepadDevice::hasLED() const
{ {
if (!m_pad) { if (!m_pad) {
@ -85,6 +119,24 @@ bool GamepadDevice::hasLED() const
return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false); return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false);
} }
int GamepadDevice::touchpadCount() const
{
if (!m_pad) {
return 0;
}
return SDL_GetNumGamepadTouchpads(m_pad);
}
bool GamepadDevice::hasGyro() const
{
return m_pad && SDL_GamepadHasSensor(m_pad, SDL_SENSOR_GYRO);
}
bool GamepadDevice::hasAccelerometer() const
{
return m_pad && SDL_GamepadHasSensor(m_pad, SDL_SENSOR_ACCEL);
}
int GamepadDevice::playerIndex() const int GamepadDevice::playerIndex() const
{ {
if (!m_pad) { if (!m_pad) {
@ -114,6 +166,17 @@ bool GamepadDevice::rumble(int lowIntensity, int highIntensity, int durationMs)
return SDL_RumbleGamepad(m_pad, lo, hi, dur); return SDL_RumbleGamepad(m_pad, lo, hi, dur);
} }
bool GamepadDevice::rumbleTriggers(int leftIntensity, int rightIntensity, int durationMs)
{
if (!m_pad) {
return false;
}
auto left = static_cast<uint16_t>(std::clamp(leftIntensity, 0, 65535));
auto right = static_cast<uint16_t>(std::clamp(rightIntensity, 0, 65535));
auto dur = static_cast<uint32_t>(std::clamp(durationMs, 0, durationMs));
return SDL_RumbleGamepadTriggers(m_pad, left, right, dur);
}
bool GamepadDevice::setLED(int r, int g, int b) bool GamepadDevice::setLED(int r, int g, int b)
{ {
if (!m_pad) { if (!m_pad) {
@ -125,6 +188,39 @@ bool GamepadDevice::setLED(int r, int g, int b)
return SDL_SetGamepadLED(m_pad, cr, cg, cb); return SDL_SetGamepadLED(m_pad, cr, cg, cb);
} }
QString GamepadDevice::buttonLabel(int button) const
{
if (!m_pad) {
return QStringLiteral("?");
}
switch (button) {
case SDL_GAMEPAD_BUTTON_SOUTH:
case SDL_GAMEPAD_BUTTON_EAST:
case SDL_GAMEPAD_BUTTON_WEST:
case SDL_GAMEPAD_BUTTON_NORTH:
return gamepadButtonLabelToString(SDL_GetGamepadButtonLabel(m_pad, static_cast<SDL_GamepadButton>(button)));
case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER:
return type() == QLatin1String("playstation") ? QStringLiteral("L1") : type() == QLatin1String("nintendo") ? QStringLiteral("L") : QStringLiteral("LB");
case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER:
return type() == QLatin1String("playstation") ? QStringLiteral("R1") : type() == QLatin1String("nintendo") ? QStringLiteral("R") : QStringLiteral("RB");
case SDL_GAMEPAD_BUTTON_BACK:
return type() == QLatin1String("playstation") ? QStringLiteral("Create")
: type() == QLatin1String("nintendo") ? QStringLiteral("-")
: QStringLiteral("View");
case SDL_GAMEPAD_BUTTON_START:
return type() == QLatin1String("playstation") ? QStringLiteral("Options")
: type() == QLatin1String("nintendo") ? QStringLiteral("+")
: QStringLiteral("Menu");
case SDL_GAMEPAD_BUTTON_GUIDE:
return type() == QLatin1String("playstation") ? QStringLiteral("PS")
: type() == QLatin1String("nintendo") ? QStringLiteral("Home")
: QStringLiteral("Guide");
default:
return QStringLiteral("?");
}
}
SDL_Gamepad *GamepadDevice::sdlGamepad() const SDL_Gamepad *GamepadDevice::sdlGamepad() const
{ {
return m_pad; return m_pad;

View file

@ -20,7 +20,11 @@ class GamepadDevice : public QObject
Q_PROPERTY(QString type READ type CONSTANT) Q_PROPERTY(QString type READ type CONSTANT)
Q_PROPERTY(int batteryPercent READ batteryPercent NOTIFY batteryPercentChanged) Q_PROPERTY(int batteryPercent READ batteryPercent NOTIFY batteryPercentChanged)
Q_PROPERTY(bool hasRumble READ hasRumble CONSTANT) Q_PROPERTY(bool hasRumble READ hasRumble CONSTANT)
Q_PROPERTY(bool hasTriggerRumble READ hasTriggerRumble CONSTANT)
Q_PROPERTY(bool hasLED READ hasLED CONSTANT) Q_PROPERTY(bool hasLED READ hasLED CONSTANT)
Q_PROPERTY(int touchpadCount READ touchpadCount CONSTANT)
Q_PROPERTY(bool hasGyro READ hasGyro CONSTANT)
Q_PROPERTY(bool hasAccelerometer READ hasAccelerometer CONSTANT)
Q_PROPERTY(int playerIndex READ playerIndex WRITE setPlayerIndex NOTIFY playerIndexChanged) Q_PROPERTY(int playerIndex READ playerIndex WRITE setPlayerIndex NOTIFY playerIndexChanged)
public: public:
@ -32,12 +36,18 @@ public:
QString type() const; QString type() const;
int batteryPercent() const; int batteryPercent() const;
bool hasRumble() const; bool hasRumble() const;
bool hasTriggerRumble() const;
bool hasLED() const; bool hasLED() const;
int touchpadCount() const;
bool hasGyro() const;
bool hasAccelerometer() const;
int playerIndex() const; int playerIndex() const;
void setPlayerIndex(int index); void setPlayerIndex(int index);
Q_INVOKABLE bool rumble(int lowIntensity, int highIntensity, int durationMs); Q_INVOKABLE bool rumble(int lowIntensity, int highIntensity, int durationMs);
Q_INVOKABLE bool rumbleTriggers(int leftIntensity, int rightIntensity, int durationMs);
Q_INVOKABLE bool setLED(int r, int g, int b); Q_INVOKABLE bool setLED(int r, int g, int b);
Q_INVOKABLE QString buttonLabel(int button) const;
SDL_Gamepad *sdlGamepad() const; SDL_Gamepad *sdlGamepad() const;
void refreshBattery(); void refreshBattery();

View file

@ -6,6 +6,7 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3/SDL_gamepad.h> #include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_hints.h>
#include <QDebug> #include <QDebug>
#include <QQmlEngine> #include <QQmlEngine>
@ -114,6 +115,43 @@ GamepadDevice *GamepadManager::gamepadAt(int index) const
return m_gamepads.at(index); return m_gamepads.at(index);
} }
QString GamepadManager::buttonLabel(int button, int gamepadIndex) const
{
GamepadDevice *device = nullptr;
if (gamepadIndex >= 0 && gamepadIndex < m_gamepads.size()) {
device = m_gamepads.at(gamepadIndex);
} else {
device = primaryGamepad();
}
if (!device) {
switch (button) {
case SDL_GAMEPAD_BUTTON_SOUTH:
return QStringLiteral("A");
case SDL_GAMEPAD_BUTTON_EAST:
return QStringLiteral("B");
case SDL_GAMEPAD_BUTTON_WEST:
return QStringLiteral("X");
case SDL_GAMEPAD_BUTTON_NORTH:
return QStringLiteral("Y");
case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER:
return QStringLiteral("LB");
case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER:
return QStringLiteral("RB");
case SDL_GAMEPAD_BUTTON_BACK:
return QStringLiteral("Back");
case SDL_GAMEPAD_BUTTON_START:
return QStringLiteral("Start");
case SDL_GAMEPAD_BUTTON_GUIDE:
return QStringLiteral("Guide");
default:
return QStringLiteral("?");
}
}
return device->buttonLabel(button);
}
// --- Lifecycle --- // --- Lifecycle ---
void GamepadManager::start() void GamepadManager::start()
@ -121,11 +159,14 @@ void GamepadManager::start()
if (m_sdlInitialized) { if (m_sdlInitialized) {
return; return;
} }
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
if (!SDL_Init(SDL_INIT_GAMEPAD)) { if (!SDL_Init(SDL_INIT_GAMEPAD)) {
qWarning() << "GamepadManager: SDL_Init failed:" << SDL_GetError(); qWarning() << "GamepadManager: SDL_Init failed:" << SDL_GetError();
return; return;
} }
m_sdlInitialized = true; m_sdlInitialized = true;
SDL_SetGamepadEventsEnabled(true);
// Enumerate already-connected gamepads // Enumerate already-connected gamepads
int count = 0; int count = 0;

View file

@ -79,6 +79,7 @@ public:
GamepadDevice *primaryGamepad() const; GamepadDevice *primaryGamepad() const;
Q_INVOKABLE GamepadDevice *gamepadAt(int index) const; Q_INVOKABLE GamepadDevice *gamepadAt(int index) const;
Q_INVOKABLE QString buttonLabel(int button, int gamepadIndex = -1) const;
Q_SIGNALS: Q_SIGNALS:
void activeChanged(); void activeChanged();

View file

@ -26,17 +26,37 @@ Window {
signal gameStarted() signal gameStarted()
signal dismissRequested() signal dismissRequested()
readonly property string actionButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonA)
readonly property string backButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonB)
readonly property string closeButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonX)
readonly property string exitButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonY)
readonly property string leftShoulderLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonLeftShoulder)
readonly property string rightShoulderLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonRightShoulder)
readonly property string quickSettingsButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonBack)
readonly property string searchButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonStart)
function pulsePrimaryGamepad(lowIntensity, highIntensity, durationMs) {
var pad = GamingShell.GamepadManager.primaryGamepad
if (!pad || !pad.hasRumble) {
return
}
pad.rumble(lowIntensity, highIntensity, durationMs)
}
function requestExitGamingMode() { function requestExitGamingMode() {
pulsePrimaryGamepad(9000, 15000, 60)
exitGamingDialog.active = true exitGamingDialog.active = true
exitGamingDialog.item.open() exitGamingDialog.item.open()
} }
function launchGame(index) { function launchGame(index) {
pulsePrimaryGamepad(14000, 22000, 80)
GamingShell.GameLauncherProvider.launch(index) GamingShell.GameLauncherProvider.launch(index)
launchFade.restart() launchFade.restart()
} }
function launchGameByStorageId(storageId) { function launchGameByStorageId(storageId) {
pulsePrimaryGamepad(14000, 22000, 80)
GamingShell.GameLauncherProvider.launchByStorageId(storageId) GamingShell.GameLauncherProvider.launchByStorageId(storageId)
launchFade.restart() launchFade.restart()
} }
@ -160,6 +180,7 @@ Window {
break break
case GamingShell.GamepadManager.ButtonBack: case GamingShell.GamepadManager.ButtonBack:
quickSettings.toggle() quickSettings.toggle()
pulsePrimaryGamepad(7000, 11000, 40)
break break
} }
} }
@ -677,6 +698,7 @@ Window {
required property string name required property string name
required property int battery required property int battery
required property string type required property string type
required property var device
PC3.Label { PC3.Label {
text: name text: name
@ -688,6 +710,18 @@ Window {
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
opacity: 0.7 opacity: 0.7
} }
PC3.Label {
text: device.touchpadCount > 0 ? i18n("Touchpad") : ""
visible: device.touchpadCount > 0
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8
opacity: 0.6
}
PC3.Label {
text: device.hasGyro ? i18n("Gyro") : ""
visible: device.hasGyro
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8
opacity: 0.6
}
} }
} }
@ -696,8 +730,12 @@ Window {
// Gamepad legend // Gamepad legend
PC3.Label { PC3.Label {
text: runningGames.hasTasks text: runningGames.hasTasks
? i18n("A: Select X: Close B: Back Y: Exit ⊞: Settings ☰: Search") ? i18n("%1: Select %2: Close %3: Back %4: Exit %5: Settings %6: Search",
: i18n("A: Select B: Back Y: Exit LB/RB: Filter ⊞: Settings ☰: Search") actionButtonLabel, closeButtonLabel, backButtonLabel, exitButtonLabel,
quickSettingsButtonLabel, searchButtonLabel)
: i18n("%1: Select %2: Back %3: Exit %4/%5: Filter %6: Settings %7: Search",
actionButtonLabel, backButtonLabel, exitButtonLabel, leftShoulderLabel,
rightShoulderLabel, quickSettingsButtonLabel, searchButtonLabel)
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75
opacity: 0.5 opacity: 0.5
} }

View file

@ -22,6 +22,16 @@ Item {
anchors.fill: parent anchors.fill: parent
property bool opened: false property bool opened: false
readonly property string acceptButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonA)
readonly property string closeButtonLabel: GamingShell.GamepadManager.buttonLabel(GamingShell.GamepadManager.ButtonB)
function pulsePrimaryGamepad(lowIntensity, highIntensity, durationMs) {
var pad = GamingShell.GamepadManager.primaryGamepad
if (!pad || !pad.hasRumble) {
return
}
pad.rumble(lowIntensity, highIntensity, durationMs)
}
// Focusable controls for gamepad navigation // Focusable controls for gamepad navigation
property var _controls: [] property var _controls: []
@ -47,9 +57,11 @@ Item {
_buildControlsList() _buildControlsList()
_focusIndex = Math.max(0, Math.min(_focusIndex, _controls.length - 1)) _focusIndex = Math.max(0, Math.min(_focusIndex, _controls.length - 1))
_highlightCurrent() _highlightCurrent()
pulsePrimaryGamepad(7000, 11000, 40)
} }
function close() { function close() {
opened = false opened = false
pulsePrimaryGamepad(5000, 8000, 30)
} }
function toggle() { function toggle() {
if (opened) close(); else open() if (opened) close(); else open()
@ -92,11 +104,13 @@ Item {
var ctrl = _controls[_focusIndex] var ctrl = _controls[_focusIndex]
if (ctrl === profileRow) { if (ctrl === profileRow) {
ctrl.increase() ctrl.increase()
pulsePrimaryGamepad(6000, 9000, 35)
return return
} }
if (ctrl instanceof QQC2.Switch) { if (ctrl instanceof QQC2.Switch) {
ctrl.toggle() ctrl.toggle()
ctrl.toggled() ctrl.toggled()
pulsePrimaryGamepad(6000, 9000, 35)
} }
} }
@ -626,7 +640,8 @@ Item {
// ---- Gamepad legend ---- // ---- Gamepad legend ----
PC3.Label { PC3.Label {
Layout.fillWidth: true Layout.fillWidth: true
text: i18n("↕: Navigate ↔: Adjust A: Toggle B: Close") text: i18n("↕: Navigate ↔: Adjust %1: Toggle %2: Close",
acceptButtonLabel, closeButtonLabel)
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8 font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8
opacity: 0.5 opacity: 0.5
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter