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_power.h>
#include <SDL3/SDL_properties.h>
#include <SDL3/SDL_sensor.h>
#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)
: QObject(parent)
, m_pad(pad)
@ -76,6 +101,15 @@ bool GamepadDevice::hasRumble() const
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
{
if (!m_pad) {
@ -85,6 +119,24 @@ bool GamepadDevice::hasLED() const
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
{
if (!m_pad) {
@ -114,6 +166,17 @@ bool GamepadDevice::rumble(int lowIntensity, int highIntensity, int durationMs)
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)
{
if (!m_pad) {
@ -125,6 +188,39 @@ bool GamepadDevice::setLED(int r, int g, int b)
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
{
return m_pad;

View file

@ -20,7 +20,11 @@ class GamepadDevice : public QObject
Q_PROPERTY(QString type READ type CONSTANT)
Q_PROPERTY(int batteryPercent READ batteryPercent NOTIFY batteryPercentChanged)
Q_PROPERTY(bool hasRumble READ hasRumble CONSTANT)
Q_PROPERTY(bool hasTriggerRumble READ hasTriggerRumble 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)
public:
@ -32,12 +36,18 @@ public:
QString type() const;
int batteryPercent() const;
bool hasRumble() const;
bool hasTriggerRumble() const;
bool hasLED() const;
int touchpadCount() const;
bool hasGyro() const;
bool hasAccelerometer() const;
int playerIndex() const;
void setPlayerIndex(int index);
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 QString buttonLabel(int button) const;
SDL_Gamepad *sdlGamepad() const;
void refreshBattery();

View file

@ -6,6 +6,7 @@
#include <SDL3/SDL.h>
#include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_hints.h>
#include <QDebug>
#include <QQmlEngine>
@ -114,6 +115,43 @@ GamepadDevice *GamepadManager::gamepadAt(int index) const
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 ---
void GamepadManager::start()
@ -121,11 +159,14 @@ void GamepadManager::start()
if (m_sdlInitialized) {
return;
}
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
if (!SDL_Init(SDL_INIT_GAMEPAD)) {
qWarning() << "GamepadManager: SDL_Init failed:" << SDL_GetError();
return;
}
m_sdlInitialized = true;
SDL_SetGamepadEventsEnabled(true);
// Enumerate already-connected gamepads
int count = 0;

View file

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

View file

@ -26,17 +26,37 @@ Window {
signal gameStarted()
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() {
pulsePrimaryGamepad(9000, 15000, 60)
exitGamingDialog.active = true
exitGamingDialog.item.open()
}
function launchGame(index) {
pulsePrimaryGamepad(14000, 22000, 80)
GamingShell.GameLauncherProvider.launch(index)
launchFade.restart()
}
function launchGameByStorageId(storageId) {
pulsePrimaryGamepad(14000, 22000, 80)
GamingShell.GameLauncherProvider.launchByStorageId(storageId)
launchFade.restart()
}
@ -160,6 +180,7 @@ Window {
break
case GamingShell.GamepadManager.ButtonBack:
quickSettings.toggle()
pulsePrimaryGamepad(7000, 11000, 40)
break
}
}
@ -677,6 +698,7 @@ Window {
required property string name
required property int battery
required property string type
required property var device
PC3.Label {
text: name
@ -688,6 +710,18 @@ Window {
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85
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
PC3.Label {
text: runningGames.hasTasks
? i18n("A: Select X: Close B: Back Y: Exit ⊞: Settings ☰: Search")
: i18n("A: Select B: Back Y: Exit LB/RB: Filter ⊞: Settings ☰: Search")
? i18n("%1: Select %2: Close %3: Back %4: Exit %5: Settings %6: 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
opacity: 0.5
}

View file

@ -22,6 +22,16 @@ Item {
anchors.fill: parent
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
property var _controls: []
@ -47,9 +57,11 @@ Item {
_buildControlsList()
_focusIndex = Math.max(0, Math.min(_focusIndex, _controls.length - 1))
_highlightCurrent()
pulsePrimaryGamepad(7000, 11000, 40)
}
function close() {
opened = false
pulsePrimaryGamepad(5000, 8000, 30)
}
function toggle() {
if (opened) close(); else open()
@ -92,11 +104,13 @@ Item {
var ctrl = _controls[_focusIndex]
if (ctrl === profileRow) {
ctrl.increase()
pulsePrimaryGamepad(6000, 9000, 35)
return
}
if (ctrl instanceof QQC2.Switch) {
ctrl.toggle()
ctrl.toggled()
pulsePrimaryGamepad(6000, 9000, 35)
}
}
@ -626,7 +640,8 @@ Item {
// ---- Gamepad legend ----
PC3.Label {
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
opacity: 0.5
horizontalAlignment: Text.AlignHCenter