diff --git a/app/app.pro b/app/app.pro index c84117d1..fdab6e9a 100644 --- a/app/app.pro +++ b/app/app.pro @@ -108,7 +108,8 @@ SOURCES += \ streaming/streamutils.cpp \ backend/autoupdatechecker.cpp \ path.cpp \ - settings/mappingmanager.cpp + settings/mappingmanager.cpp \ + gui/sdlgamepadkeynavigation.cpp HEADERS += \ utils.h \ @@ -130,7 +131,8 @@ HEADERS += \ streaming/streamutils.h \ backend/autoupdatechecker.h \ path.h \ - settings/mappingmanager.h + settings/mappingmanager.h \ + gui/sdlgamepadkeynavigation.h # Platform-specific renderers and decoders ffmpeg { diff --git a/app/gui/AppView.qml b/app/gui/AppView.qml index 5e42718a..1ebccddc 100644 --- a/app/gui/AppView.qml +++ b/app/gui/AppView.qml @@ -3,8 +3,8 @@ import QtQuick.Dialogs 1.2 import QtQuick.Controls 2.2 import AppModel 1.0 - import ComputerManager 1.0 +import SdlGamepadKeyNavigation 1.0 GridView { property int computerIndex @@ -36,12 +36,18 @@ GridView { currentIndex = -1 } + SdlGamepadKeyNavigation { + id: gamepadKeyNav + } + onVisibleChanged: { if (visible) { appModel.computerLost.connect(computerLost) + gamepadKeyNav.enable() } else { appModel.computerLost.disconnect(computerLost) + gamepadKeyNav.disable() } } @@ -119,20 +125,25 @@ GridView { anchors.fill: parent acceptedButtons: Qt.RightButton onClicked: { - // Right click - appContextMenu.open() + // popup() ensures the menu appears under the mouse cursor + appContextMenu.popup() } } + Keys.onMenuPressed: { + // We must use open() here so the menu is positioned on + // the ItemDelegate and not where the mouse cursor is + appContextMenu.open() + } + Menu { id: appContextMenu - MenuItem { + NavigableMenuItem { text: model.running ? "Resume Game" : "Launch Game" onTriggered: { appContextMenu.close() launchOrResumeSelectedApp() } - height: visible ? implicitHeight : 0 } NavigableMenuItem { text: "Quit Game" @@ -142,7 +153,6 @@ GridView { quitAppDialog.open() } visible: model.running - height: visible ? implicitHeight : 0 } } } diff --git a/app/gui/NavigableMenuItem.qml b/app/gui/NavigableMenuItem.qml index 5e2cadd5..acad39ce 100644 --- a/app/gui/NavigableMenuItem.qml +++ b/app/gui/NavigableMenuItem.qml @@ -2,6 +2,10 @@ import QtQuick 2.0 import QtQuick.Controls 2.2 MenuItem { + // Ensure focus can't be given to an invisible item + enabled: visible + height: visible ? implicitHeight : 0 + Keys.onReturnPressed: { triggered() } diff --git a/app/gui/PcView.qml b/app/gui/PcView.qml index 8b646551..a2b2df30 100644 --- a/app/gui/PcView.qml +++ b/app/gui/PcView.qml @@ -8,6 +8,7 @@ import ComputerModel 1.0 import ComputerManager 1.0 import StreamingPreferences 1.0 +import SdlGamepadKeyNavigation 1.0 GridView { property ComputerModel computerModel : createModel() @@ -32,6 +33,19 @@ GridView { id: prefs } + SdlGamepadKeyNavigation { + id: gamepadKeyNav + } + + onVisibleChanged: { + if (visible) { + gamepadKeyNav.enable() + } + else { + gamepadKeyNav.disable() + } + } + Component.onCompleted: { // Setup signals on CM ComputerManager.computerAddCompleted.connect(addComplete) @@ -144,7 +158,6 @@ GridView { text: "Wake PC" onTriggered: computerModel.wakeComputer(index) visible: !model.addPc && !model.online && model.wakeable - height: visible ? implicitHeight : 0 } NavigableMenuItem { text: "Delete PC" @@ -185,6 +198,7 @@ GridView { } } } else if (!model.online) { + // Using open() here because it may be activated by keyboard pcContextMenu.open() } } @@ -193,12 +207,20 @@ GridView { anchors.fill: parent acceptedButtons: Qt.RightButton; onClicked: { - // right click - if (!model.addPc) { // but only for actual PCs, not the add-pc option - pcContextMenu.open() + if (!model.addPc) { + // popup() ensures the menu appears under the mouse cursor + pcContextMenu.popup() } } } + + Keys.onMenuPressed: { + if (!model.addPc) { + // We must use open() here so the menu is positioned on + // the ItemDelegate and not where the mouse cursor is + pcContextMenu.open() + } + } } MessageDialog { diff --git a/app/gui/SettingsView.qml b/app/gui/SettingsView.qml index d7a1375e..d81d302c 100644 --- a/app/gui/SettingsView.qml +++ b/app/gui/SettingsView.qml @@ -3,6 +3,7 @@ import QtQuick.Controls 2.2 import StreamingPreferences 1.0 import ComputerManager 1.0 +import SdlGamepadKeyNavigation 1.0 ScrollView { id: settingsPage @@ -12,6 +13,25 @@ ScrollView { id: prefs } + // The StackView will trigger a visibility change when + // we're pushed onto it, causing our onVisibleChanged + // routine to run, but only if we start as invisible + visible: false + + SdlGamepadKeyNavigation { + id: gamepadKeyNav + } + + onVisibleChanged: { + if (visible) { + gamepadKeyNav.setSettingsMode(true) + gamepadKeyNav.enable() + } + else { + gamepadKeyNav.disable() + } + } + Component.onDestruction: { prefs.save() } diff --git a/app/gui/main.qml b/app/gui/main.qml index f81c2de4..ba56480c 100644 --- a/app/gui/main.qml +++ b/app/gui/main.qml @@ -43,6 +43,10 @@ ApplicationWindow { stackView.pop() } } + + Keys.onMenuPressed: { + settingsButton.clicked() + } } onVisibilityChanged: { diff --git a/app/gui/sdlgamepadkeynavigation.cpp b/app/gui/sdlgamepadkeynavigation.cpp new file mode 100644 index 00000000..347136db --- /dev/null +++ b/app/gui/sdlgamepadkeynavigation.cpp @@ -0,0 +1,177 @@ +#include "sdlgamepadkeynavigation.h" + +#include +#include +#include + +#include "settings/mappingmanager.h" + +SdlGamepadKeyNavigation::SdlGamepadKeyNavigation() + : m_Enabled(false), + m_SettingsMode(false) +{ + m_PollingTimer = new QTimer(this); + connect(m_PollingTimer, SIGNAL(timeout()), this, SLOT(onPollingTimerFired())); +} + +SdlGamepadKeyNavigation::~SdlGamepadKeyNavigation() +{ + disable(); +} + +void SdlGamepadKeyNavigation::enable() +{ + if (m_Enabled) { + return; + } + + // We have to initialize and uninitialize this in enable()/disable() + // because we need to get out of the way of the Session class. If it + // doesn't get to reinitialize the GC subsystem, it won't get initial + // arrival events. Additionally, there's a race condition between + // our QML objects being destroyed and SDL being deinitialized that + // this solves too. + if (SDL_InitSubSystem(SDL_INIT_GAMECONTROLLER) != 0) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, + "SDL_InitSubSystem(SDL_INIT_GAMECONTROLLER) failed: %s", + SDL_GetError()); + return; + } + + MappingManager mappingManager; + mappingManager.applyMappings(); + + // Drop all pending gamepad add events. SDL will generate these for us + // on first init of the GC subsystem. We can't depend on them due to + // overlapping lifetimes of SdlGamepadKeyNavigation instances, so we + // will attach ourselves. + SDL_FlushEvent(SDL_CONTROLLERDEVICEADDED); + + // Open all currently attached game controllers + for (int i = 0; i < SDL_NumJoysticks(); i++) { + if (SDL_IsGameController(i)) { + SDL_GameController* gc = SDL_GameControllerOpen(i); + if (gc != nullptr) { + m_Gamepads.append(gc); + } + } + } + + // Poll every 50 ms for a new joystick event + m_PollingTimer->start(50); + + m_Enabled = true; +} + +void SdlGamepadKeyNavigation::disable() +{ + if (!m_Enabled) { + return; + } + + m_PollingTimer->stop(); + + while (!m_Gamepads.isEmpty()) { + SDL_GameControllerClose(m_Gamepads[0]); + m_Gamepads.removeAt(0); + } + + SDL_QuitSubSystem(SDL_INIT_GAMECONTROLLER); + + m_Enabled = false; +} + +void SdlGamepadKeyNavigation::onPollingTimerFired() +{ + SDL_Event event; + + while (SDL_PollEvent(&event)) { + switch (event.type) { + case SDL_CONTROLLERBUTTONDOWN: + case SDL_CONTROLLERBUTTONUP: + { + QEvent::Type type = + event.type == SDL_CONTROLLERBUTTONDOWN ? + QEvent::Type::KeyPress : QEvent::Type::KeyRelease; + + switch (event.cbutton.button) { + case SDL_CONTROLLER_BUTTON_DPAD_UP: + if (m_SettingsMode) { + // Back-tab + sendKey(type, Qt::Key_Tab, Qt::ShiftModifier); + } + else { + sendKey(type, Qt::Key_Up); + } + break; + case SDL_CONTROLLER_BUTTON_DPAD_DOWN: + if (m_SettingsMode) { + sendKey(type, Qt::Key_Tab); + } + else { + sendKey(type, Qt::Key_Down); + } + break; + case SDL_CONTROLLER_BUTTON_DPAD_LEFT: + sendKey(type, Qt::Key_Left); + if (m_SettingsMode) { + // Some settings controls respond to left/right (like the slider) + // and others respond to up/down (like combo boxes). They seem to + // be mutually exclusive though so let's just send both. + sendKey(type, Qt::Key_Up); + } + break; + case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: + sendKey(type, Qt::Key_Right); + if (m_SettingsMode) { + // Some settings controls respond to left/right (like the slider) + // and others respond to up/down (like combo boxes). They seem to + // be mutually exclusive though so let's just send both. + sendKey(type, Qt::Key_Down); + } + break; + case SDL_CONTROLLER_BUTTON_A: + if (m_SettingsMode) { + sendKey(type, Qt::Key_Space); + } + else { + sendKey(type, Qt::Key_Return); + } + break; + case SDL_CONTROLLER_BUTTON_B: + sendKey(type, Qt::Key_Escape); + break; + case SDL_CONTROLLER_BUTTON_X: + case SDL_CONTROLLER_BUTTON_Y: + case SDL_CONTROLLER_BUTTON_START: + sendKey(type, Qt::Key_Menu); + break; + default: + break; + } + break; + } + case SDL_CONTROLLERDEVICEADDED: + SDL_GameController* gc = SDL_GameControllerOpen(event.cdevice.which); + if (gc != nullptr) { + m_Gamepads.append(gc); + } + break; + } + } +} + +void SdlGamepadKeyNavigation::sendKey(QEvent::Type type, Qt::Key key, Qt::KeyboardModifiers modifiers) +{ + QGuiApplication* app = static_cast(QGuiApplication::instance()); + QWindow* focusWindow = app->focusWindow(); + if (focusWindow != nullptr) { + QKeyEvent keyPressEvent(type, key, modifiers); + app->sendEvent(focusWindow, &keyPressEvent); + } +} + +void SdlGamepadKeyNavigation::setSettingsMode(bool settingsMode) +{ + m_SettingsMode = settingsMode; +} diff --git a/app/gui/sdlgamepadkeynavigation.h b/app/gui/sdlgamepadkeynavigation.h new file mode 100644 index 00000000..5dcbe53f --- /dev/null +++ b/app/gui/sdlgamepadkeynavigation.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#include + +class SdlGamepadKeyNavigation : public QObject +{ + Q_OBJECT + +public: + SdlGamepadKeyNavigation(); + + ~SdlGamepadKeyNavigation(); + + Q_INVOKABLE void enable(); + + Q_INVOKABLE void disable(); + + Q_INVOKABLE void setSettingsMode(bool settingsMode); + +private: + void sendKey(QEvent::Type type, Qt::Key key, Qt::KeyboardModifiers modifiers = Qt::NoModifier); + +private slots: + void onPollingTimerFired(); + +private: + QTimer* m_PollingTimer; + QList m_Gamepads; + bool m_Enabled; + bool m_SettingsMode; +}; diff --git a/app/main.cpp b/app/main.cpp index 39da63c3..cd24938c 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -24,6 +24,7 @@ #include "backend/autoupdatechecker.h" #include "streaming/session.h" #include "settings/streamingpreferences.h" +#include "gui/sdlgamepadkeynavigation.h" #if !defined(QT_DEBUG) && defined(Q_OS_WIN32) // Log to file for release Windows builds @@ -288,6 +289,7 @@ int main(int argc, char *argv[]) qmlRegisterType("ComputerModel", 1, 0, "ComputerModel"); qmlRegisterType("AppModel", 1, 0, "AppModel"); qmlRegisterType("StreamingPreferences", 1, 0, "StreamingPreferences"); + qmlRegisterType("SdlGamepadKeyNavigation", 1, 0, "SdlGamepadKeyNavigation"); qmlRegisterUncreatableType("Session", 1, 0, "Session", "Session cannot be created from QML"); qmlRegisterSingletonType("ComputerManager", 1, 0, "ComputerManager", @@ -338,6 +340,10 @@ int main(int argc, char *argv[]) SDL_GetError()); } + // Use atexit() to ensure SDL_Quit() is called. This avoids + // racing with object destruction where SDL may be used. + atexit(SDL_Quit); + // Avoid the default behavior of changing the timer resolution to 1 ms. // We don't want this all the time that Moonlight is open. We will set // it manually when we start streaming. @@ -345,7 +351,5 @@ int main(int argc, char *argv[]) int err = app.exec(); - SDL_Quit(); - return err; }