From d901815c9d938d517379dc0df5f89733b07fded2 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sun, 19 Apr 2026 13:51:00 +0200 Subject: [PATCH 01/26] Add gaming mode setting and quick settings tiles Add a gamingModeEnabled bool and a gamingDismissHintEnabled bool to the shell settings plugin, persisted in plasmamobilerc [General], following the same pattern as convergenceModeEnabled. Two new quick settings tiles: - org.kde.plasma.quicksetting.gaming: toggles gaming mode on/off, shows explicit on/off label matching the convergence tile style. - org.kde.plasma.quicksetting.gaminghint: shown while gaming mode is active; dismisses the Game Center hint. Both tiles are added to the default group in quicksettings config. --- .../quicksettingsconfig.cpp | 2 + .../mobileshellsettings.cpp | 28 +++++++++++ .../shellsettingsplugin/mobileshellsettings.h | 12 +++++ quicksettings/CMakeLists.txt | 2 + quicksettings/gaming/Messages.sh | 6 +++ quicksettings/gaming/contents/ui/main.qml | 46 +++++++++++++++++++ quicksettings/gaming/metadata.json | 18 ++++++++ quicksettings/gaminghint/Messages.sh | 5 ++ quicksettings/gaminghint/contents/ui/main.qml | 18 ++++++++ quicksettings/gaminghint/metadata.json | 18 ++++++++ 10 files changed, 155 insertions(+) create mode 100644 quicksettings/gaming/Messages.sh create mode 100644 quicksettings/gaming/contents/ui/main.qml create mode 100644 quicksettings/gaming/metadata.json create mode 100644 quicksettings/gaminghint/Messages.sh create mode 100644 quicksettings/gaminghint/contents/ui/main.qml create mode 100644 quicksettings/gaminghint/metadata.json diff --git a/components/quicksettingsplugin/quicksettingsconfig.cpp b/components/quicksettingsplugin/quicksettingsconfig.cpp index e40a378b..eb8bda7b 100644 --- a/components/quicksettingsplugin/quicksettingsconfig.cpp +++ b/components/quicksettingsplugin/quicksettingsconfig.cpp @@ -45,6 +45,8 @@ QList QuickSettingsConfig::enabledQuickSettings() const QStringLiteral("org.kde.plasma.quicksetting.screenshot"), QStringLiteral("org.kde.plasma.quicksetting.powermenu"), QStringLiteral("org.kde.plasma.quicksetting.donotdisturb"), + QStringLiteral("org.kde.plasma.quicksetting.gaming"), + QStringLiteral("org.kde.plasma.quicksetting.gaminghint"), QStringLiteral("org.kde.plasma.quicksetting.caffeine"), QStringLiteral("org.kde.plasma.quicksetting.keyboardtoggle"), QStringLiteral("org.kde.plasma.quicksetting.hotspot")}); diff --git a/components/shellsettingsplugin/mobileshellsettings.cpp b/components/shellsettingsplugin/mobileshellsettings.cpp index 88beb01e..29ff0077 100644 --- a/components/shellsettingsplugin/mobileshellsettings.cpp +++ b/components/shellsettingsplugin/mobileshellsettings.cpp @@ -44,6 +44,8 @@ MobileShellSettings::MobileShellSettings(QObject *parent) Q_EMIT actionDrawerTopRightModeChanged(); Q_EMIT convergenceModeEnabledChanged(); Q_EMIT autoHidePanelsEnabledChanged(); + Q_EMIT gamingModeEnabledChanged(); + Q_EMIT gamingDismissHintEnabledChanged(); Q_EMIT allowLogoutChanged(); } if (group.name() == LOCKSCREEN_CONFIG_GROUP) { @@ -248,6 +250,32 @@ void MobileShellSettings::setAutoHidePanelsEnabled(bool enabled) m_config->sync(); } +bool MobileShellSettings::gamingModeEnabled() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("gamingModeEnabled", false); +} + +void MobileShellSettings::setGamingModeEnabled(bool enabled) +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + group.writeEntry("gamingModeEnabled", enabled, KConfigGroup::Notify); + m_config->sync(); +} + +bool MobileShellSettings::gamingDismissHintEnabled() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("gamingDismissHintEnabled", true); +} + +void MobileShellSettings::setGamingDismissHintEnabled(bool enabled) +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + group.writeEntry("gamingDismissHintEnabled", enabled, KConfigGroup::Notify); + m_config->sync(); +} + void MobileShellSettings::updateNavigationBarsInPlasma() { // Do not update panels when not in Plasma Mobile diff --git a/components/shellsettingsplugin/mobileshellsettings.h b/components/shellsettingsplugin/mobileshellsettings.h index 64be056b..a88d96d3 100644 --- a/components/shellsettingsplugin/mobileshellsettings.h +++ b/components/shellsettingsplugin/mobileshellsettings.h @@ -52,6 +52,10 @@ class MobileShellSettings : public QObject // Auto Hide Panels Q_PROPERTY(bool autoHidePanelsEnabled READ autoHidePanelsEnabled WRITE setAutoHidePanelsEnabled NOTIFY autoHidePanelsEnabledChanged) + // Gaming mode + Q_PROPERTY(bool gamingModeEnabled READ gamingModeEnabled WRITE setGamingModeEnabled NOTIFY gamingModeEnabledChanged) + Q_PROPERTY(bool gamingDismissHintEnabled READ gamingDismissHintEnabled WRITE setGamingDismissHintEnabled NOTIFY gamingDismissHintEnabledChanged) + // logout dialog Q_PROPERTY(bool allowLogout READ allowLogout READ allowLogout NOTIFY allowLogoutChanged) @@ -264,6 +268,12 @@ public: */ void setAutoHidePanelsEnabled(bool enabled); + bool gamingModeEnabled() const; + void setGamingModeEnabled(bool enabled); + + bool gamingDismissHintEnabled() const; + void setGamingDismissHintEnabled(bool enabled); + /** * Whether logout button is shown in the logout/shutdown dialog. */ @@ -310,6 +320,8 @@ Q_SIGNALS: void quickSettingsColumnsChanged(); void convergenceModeEnabledChanged(); void autoHidePanelsEnabledChanged(); + void gamingModeEnabledChanged(); + void gamingDismissHintEnabledChanged(); void allowLogoutChanged(); void lockscreenLeftButtonActionChanged(); void lockscreenRightButtonActionChanged(); diff --git a/quicksettings/CMakeLists.txt b/quicksettings/CMakeLists.txt index a560bdaf..0869f509 100644 --- a/quicksettings/CMakeLists.txt +++ b/quicksettings/CMakeLists.txt @@ -8,6 +8,8 @@ plasma_install_package(bluetooth org.kde.plasma.quicksetting.bluetooth quicksett plasma_install_package(caffeine org.kde.plasma.quicksetting.caffeine quicksettings) plasma_install_package(docked org.kde.plasma.quicksetting.docked quicksettings) plasma_install_package(donotdisturb org.kde.plasma.quicksetting.donotdisturb quicksettings) +plasma_install_package(gaming org.kde.plasma.quicksetting.gaming quicksettings) +plasma_install_package(gaminghint org.kde.plasma.quicksetting.gaminghint quicksettings) plasma_install_package(autohidepanels org.kde.plasma.quicksetting.autohidepanels quicksettings) plasma_install_package(keyboardtoggle org.kde.plasma.quicksetting.keyboardtoggle quicksettings) plasma_install_package(kscreenosd org.kde.plasma.quicksetting.kscreenosd quicksettings) diff --git a/quicksettings/gaming/Messages.sh b/quicksettings/gaming/Messages.sh new file mode 100644 index 00000000..37df498c --- /dev/null +++ b/quicksettings/gaming/Messages.sh @@ -0,0 +1,6 @@ +#! /usr/bin/env bash + +# SPDX-FileCopyrightText: 2026 Marco Allegretti +# SPDX-License-Identifier: EUPL-1.2 + +$XGETTEXT `find . -name \*.js -o -name \*.qml -o -name \*.cpp` -o $podir/plasma_org.kde.plasma.quicksetting.gaming.pot diff --git a/quicksettings/gaming/contents/ui/main.qml b/quicksettings/gaming/contents/ui/main.qml new file mode 100644 index 00000000..a390c193 --- /dev/null +++ b/quicksettings/gaming/contents/ui/main.qml @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick 2.15 + +import org.kde.kirigami as Kirigami + +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.quicksettingsplugin as QS + +QS.QuickSetting { + id: root + + text: i18n("Gaming Mode") + icon: "input-gaming" + status: enabled ? i18n("Active") : i18n("Inactive") + enabled: ShellSettings.Settings.gamingModeEnabled + + function requestDisable() { + confirmDisableDialog.active = true; + confirmDisableDialog.item.open(); + } + + function toggle() { + if (ShellSettings.Settings.gamingModeEnabled) { + requestDisable(); + return; + } + + ShellSettings.Settings.gamingModeEnabled = true; + } + + Loader { + id: confirmDisableDialog + active: false + + sourceComponent: Kirigami.PromptDialog { + title: i18n("Exit Gaming Mode") + subtitle: i18n("Switch back to the normal shell layout?") + standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.Cancel + + onAccepted: ShellSettings.Settings.gamingModeEnabled = false + onClosed: confirmDisableDialog.active = false + } + } +} diff --git a/quicksettings/gaming/metadata.json b/quicksettings/gaming/metadata.json new file mode 100644 index 00000000..a86169b0 --- /dev/null +++ b/quicksettings/gaming/metadata.json @@ -0,0 +1,18 @@ +{ + "KPackageStructure": "KPackage/GenericQML", + "KPlugin": { + "Authors": [ + { + "Email": "mightymarco4@gmail.com", + "Name": "Marco Allegretti" + } + ], + "Description": "Quick setting to toggle gaming mode for Plasma Mobile", + "Icon": "input-gaming", + "Id": "org.kde.plasma.quicksetting.gaming", + "License": "EUPL-1.2", + "Name": "Gaming Mode", + "Version": "0.1", + "Website": "" + } +} diff --git a/quicksettings/gaminghint/Messages.sh b/quicksettings/gaminghint/Messages.sh new file mode 100644 index 00000000..73c7528f --- /dev/null +++ b/quicksettings/gaminghint/Messages.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Marco Allegretti +# SPDX-License-Identifier: EUPL-1.2 + +$XGETTEXT *.json contents/ui/*.qml -o $podir/plasma_mobile_qt.pot diff --git a/quicksettings/gaminghint/contents/ui/main.qml b/quicksettings/gaminghint/contents/ui/main.qml new file mode 100644 index 00000000..f6623072 --- /dev/null +++ b/quicksettings/gaminghint/contents/ui/main.qml @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick 2.15 + +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.quicksettingsplugin as QS + +QS.QuickSetting { + text: i18n("Game Center Hint") + icon: "dialog-information" + status: "" + enabled: ShellSettings.Settings.gamingDismissHintEnabled + + function toggle() { + ShellSettings.Settings.gamingDismissHintEnabled = !ShellSettings.Settings.gamingDismissHintEnabled; + } +} diff --git a/quicksettings/gaminghint/metadata.json b/quicksettings/gaminghint/metadata.json new file mode 100644 index 00000000..7095774f --- /dev/null +++ b/quicksettings/gaminghint/metadata.json @@ -0,0 +1,18 @@ +{ + "KPackageStructure": "KPackage/GenericQML", + "KPlugin": { + "Authors": [ + { + "Email": "mightymarco4@gmail.com", + "Name": "Marco Allegretti" + } + ], + "Description": "Quick setting to toggle the Game Center dismiss hint banner", + "Icon": "dialog-information", + "Id": "org.kde.plasma.quicksetting.gaminghint", + "License": "EUPL-1.2", + "Name": "Game Center Hint", + "Version": "0.1", + "Website": "" + } +} From b0739dd9a72dec03a634bf7f9b9874b1cb618ab8 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sun, 19 Apr 2026 13:51:08 +0200 Subject: [PATCH 02/26] Suppress shell chrome in gaming mode Hide the navigation panel, status bar, and app drawer while gamingModeEnabled is active. The KWin convergent-windows script skips its window policy so game windows are not forcibly tiled or maximized. The Home button in gaming mode re-opens the Game Center overlay rather than the app drawer. A configurable hint nudges the user toward the HUD button after launching a game. --- containments/homescreens/folio/qml/main.qml | 91 ++++++++++++++++++- containments/panel/qml/main.qml | 16 +++- containments/taskpanel/qml/main.qml | 17 +++- .../convergentwindows/contents/ui/main.qml | 16 ++++ 4 files changed, 134 insertions(+), 6 deletions(-) diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index f667410a..088e6792 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -25,12 +25,32 @@ import org.kde.kirigamiaddons.components as KirigamiAddonsComponents import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio +import "./gaming" + import "./private" ContainmentItem { id: root property Folio.HomeScreen folio: root.plasmoid + // Tracks whether the Game Center grid is visible within gaming mode. + // Starts true when gaming mode turns on; set to false by a game launch. + property bool gameCenterOpen: false + property bool showGameCenterHint: false + + Timer { + id: gameCenterHintTimer + interval: 2600 + onTriggered: root.showGameCenterHint = false + } + + Connections { + target: ShellSettings.Settings + function onGamingModeEnabledChanged() { + root.gameCenterOpen = ShellSettings.Settings.gamingModeEnabled + } + } + Component.onCompleted: { folio.FolioSettings.load(); folio.FavouritesModel.load(); @@ -89,6 +109,12 @@ ContainmentItem { MobileShellState.ShellDBusClient.closeActionDrawer(); } + if (ShellSettings.Settings.gamingModeEnabled) { + // In gaming mode Home/Menu should reopen the Game Center overlay. + root.gameCenterOpen = true; + return; + } + if (ShellSettings.Settings.convergenceModeEnabled) { // Convergence: toggle the app drawer as a layer-shell overlay // without disturbing open windows. @@ -186,7 +212,7 @@ ContainmentItem { // task panel containment; this window only provides the visible dock. Window { id: dockOverlay - visible: ShellSettings.Settings.convergenceModeEnabled + visible: ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled color: "transparent" width: Screen.width height: Kirigami.Units.gridUnit * 3 @@ -286,6 +312,7 @@ ContainmentItem { Window { id: drawerOverlay visible: ShellSettings.Settings.convergenceModeEnabled + && !ShellSettings.Settings.gamingModeEnabled && folio.HomeScreenState.appDrawerOpenProgress > 0 color: "transparent" width: Screen.width @@ -645,6 +672,68 @@ ContainmentItem { } } + // Game Center overlay — full-screen grid of games shown when gaming mode + // is active. Sits at LayerTop so it covers running application windows + // without going above system notifications. + GameCenterOverlay { + id: gameCenterOverlay + folio: root.folio + visible: ShellSettings.Settings.gamingModeEnabled && root.gameCenterOpen + + onGameStarted: root.gameCenterOpen = false + onDismissRequested: { + root.gameCenterOpen = false + if (ShellSettings.Settings.gamingDismissHintEnabled) { + root.showGameCenterHint = true + gameCenterHintTimer.restart() + } + } + + onVisibleChanged: { + if (!visible) { + folio.ApplicationListSearchModel.categoryFilter = "" + } + } + } + + // Small persistent button at the top-right corner of the screen that lets + // the user return to the Game Center after launching a game. + GamingHUD { + visible: ShellSettings.Settings.gamingModeEnabled && !root.gameCenterOpen + onOpenRequested: root.gameCenterOpen = true + } + + Rectangle { + id: gameCenterHint + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Kirigami.Units.gridUnit * 2 + visible: root.showGameCenterHint && ShellSettings.Settings.gamingDismissHintEnabled + opacity: visible ? 1 : 0 + z: 2000 + radius: Kirigami.Units.cornerRadius + color: Qt.rgba(0, 0, 0, 0.65) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.2) + + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } + } + + implicitWidth: hintText.implicitWidth + Kirigami.Units.gridUnit * 2 + implicitHeight: hintText.implicitHeight + Kirigami.Units.largeSpacing + + PlasmaComponents.Label { + id: hintText + anchors.centerIn: parent + text: i18n("Gaming mode is still on. Use Home or the gamepad icon to reopen Game Center.") + color: "white" + wrapMode: Text.WordWrap + width: Math.min(root.width * 0.8, Kirigami.Units.gridUnit * 30) + horizontalAlignment: Text.AlignHCenter + } + } + MobileShell.HomeScreen { id: homeScreen anchors.fill: parent diff --git a/containments/panel/qml/main.qml b/containments/panel/qml/main.qml index 3944ee9f..2e151540 100644 --- a/containments/panel/qml/main.qml +++ b/containments/panel/qml/main.qml @@ -34,11 +34,17 @@ ContainmentItem { // Whether the startup feedback is showing readonly property bool showingStartupFeedback: MobileShellState.ShellDBusObject.startupFeedbackModel.activeWindowIsStartupFeedback + readonly property bool gamingMode: ShellSettings.Settings.gamingModeEnabled + // Whether an app is maximized and showing (does not include startup feedback) readonly property bool showingApp: windowMaximizedTracker.showingWindow && !showingStartupFeedback // Whether the currently showing app is in "fullscreen" readonly property bool fullscreen: { + if (gamingMode) { + return true; + } + // In convergence mode the status bar is always visible, like a desktop panel. if (ShellSettings.Settings.convergenceModeEnabled) { return false; @@ -69,7 +75,7 @@ ContainmentItem { } } - readonly property real panelHeight: MobileShell.Constants.topPanelHeight + readonly property real panelHeight: gamingMode ? 0 : MobileShell.Constants.topPanelHeight onPanelHeightChanged: setWindowProperties() function setWindowProperties() { @@ -123,6 +129,11 @@ ContainmentItem { function onConvergenceModeEnabledChanged() { root.setWindowProperties(); } + + function onGamingModeEnabledChanged() { + root.setWindowProperties(); + MobileShellState.ShellDBusClient.panelState = ShellSettings.Settings.gamingModeEnabled ? "hidden" : "default"; + } } Component.onCompleted: { @@ -136,7 +147,7 @@ ContainmentItem { // MaximizeArea by the panel height. Window { id: topBarSpaceReserver - visible: ShellSettings.Settings.convergenceModeEnabled + visible: ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled color: "transparent" flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput height: root.panelHeight @@ -152,6 +163,7 @@ ContainmentItem { // Visual panel component StatusPanel { id: statusPanel + visible: !ShellSettings.Settings.gamingModeEnabled anchors.fill: parent containmentItem: root } diff --git a/containments/taskpanel/qml/main.qml b/containments/taskpanel/qml/main.qml index 9f3aa4cf..f9f4bb87 100644 --- a/containments/taskpanel/qml/main.qml +++ b/containments/taskpanel/qml/main.qml @@ -38,12 +38,14 @@ ContainmentItem { readonly property bool inLandscape: MobileShell.Constants.navigationPanelOnSide(Screen.width, Screen.height) - readonly property real navigationPanelHeight: MobileShell.Constants.navigationPanelThickness + readonly property bool gamingMode: ShellSettings.Settings.gamingModeEnabled + + readonly property real navigationPanelHeight: gamingMode ? 0 : MobileShell.Constants.navigationPanelThickness onNavigationPanelHeightChanged: setWindowProperties() readonly property real intendedWindowThickness: navigationPanelHeight readonly property real intendedWindowLength: inLandscape ? Screen.height : Screen.width - readonly property real intendedWindowOffset: inLandscape ? MobileShell.Constants.topPanelHeight : 0; // offset for top panel + readonly property real intendedWindowOffset: (inLandscape && !gamingMode) ? MobileShell.Constants.topPanelHeight : 0; // offset for top panel readonly property int intendedWindowLocation: inLandscape ? PlasmaCore.Types.RightEdge : PlasmaCore.Types.BottomEdge onIntendedWindowLengthChanged: maximizeTimer.restart() // ensure it always takes up the full length of the screen @@ -136,6 +138,11 @@ ContainmentItem { function onConvergenceModeEnabledChanged() { root.setWindowProperties(); } + + function onGamingModeEnabledChanged() { + root.setWindowProperties(); + navigationPanel.offset = ShellSettings.Settings.gamingModeEnabled ? root.navigationPanelHeight : 0; + } } Component.onCompleted: setWindowProperties(); @@ -153,6 +160,7 @@ ContainmentItem { Window { id: dockSpaceReserver visible: ShellSettings.Settings.convergenceModeEnabled + && !ShellSettings.Settings.gamingModeEnabled && !(ShellSettings.Settings.autoHidePanelsEnabled && windowMaximizedTracker.showingWindow) color: "transparent" @@ -177,7 +185,9 @@ ContainmentItem { return (windowMaximizedTracker.showingWindow || isCurrentWindowFullscreen) && !showingStartupFeedback } readonly property alias isCurrentWindowFullscreen: windowMaximizedTracker.isCurrentWindowFullscreen - readonly property bool fullscreen: isCurrentWindowFullscreen || (ShellSettings.Settings.autoHidePanelsEnabled && opaqueBar) + readonly property bool fullscreen: ShellSettings.Settings.gamingModeEnabled + || isCurrentWindowFullscreen + || (ShellSettings.Settings.autoHidePanelsEnabled && opaqueBar) WindowPlugin.WindowMaximizedTracker { id: windowMaximizedTracker @@ -205,6 +215,7 @@ ContainmentItem { Item { id: navigationPanel + visible: !ShellSettings.Settings.gamingModeEnabled anchors.fill: parent property real offset: 0 diff --git a/kwin/scripts/convergentwindows/contents/ui/main.qml b/kwin/scripts/convergentwindows/contents/ui/main.qml index 4bc06e1e..ce00568b 100644 --- a/kwin/scripts/convergentwindows/contents/ui/main.qml +++ b/kwin/scripts/convergentwindows/contents/ui/main.qml @@ -60,6 +60,12 @@ Loader { return; } + if (ShellSettings.Settings.gamingModeEnabled) { + window.noBorder = true; + window.setMaximize(true, true); + return; + } + if (ShellSettings.Settings.convergenceModeEnabled) { window.noBorder = false; } else { @@ -128,6 +134,16 @@ Loader { } } } + + function onGamingModeEnabledChanged() { + const windows = KWinComponents.Workspace.windows; + + for (let i = 0; i < windows.length; i++) { + if (windows[i].normalWindow) { + root.run(windows[i]); + } + } + } } Connections { From 6ac9c58d9394abcf32fc2b06439a968428bfd643 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sun, 19 Apr 2026 13:51:20 +0200 Subject: [PATCH 03/26] Add Game Center overlay Full-screen layer-shell overlay containing a game library grid (XDG Game category, filtered via ApplicationListSearchModel) and a panel of running tasks built on TaskManager.TasksModel. Keyboard-navigable with directional focus between the running tasks row and the game grid. A persistent HUD button lets the user return after launching a game. Exiting gaming mode requires an explicit confirmation dialog. The overlay is a Window with LayerShell.LayerTop so it sits above running application windows without covering system notifications. --- containments/homescreens/folio/CMakeLists.txt | 8 + .../folio/qml/gaming/GameCenterOverlay.qml | 183 +++++++++++++++++ .../homescreens/folio/qml/gaming/GameTile.qml | 92 +++++++++ .../folio/qml/gaming/GamingHUD.qml | 49 +++++ .../folio/qml/gaming/RunningGamesView.qml | 193 ++++++++++++++++++ 5 files changed, 525 insertions(+) create mode 100644 containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml create mode 100644 containments/homescreens/folio/qml/gaming/GameTile.qml create mode 100644 containments/homescreens/folio/qml/gaming/GamingHUD.qml create mode 100644 containments/homescreens/folio/qml/gaming/RunningGamesView.qml diff --git a/containments/homescreens/folio/CMakeLists.txt b/containments/homescreens/folio/CMakeLists.txt index 2acc25a9..4262ca97 100644 --- a/containments/homescreens/folio/CMakeLists.txt +++ b/containments/homescreens/folio/CMakeLists.txt @@ -76,6 +76,14 @@ ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES PATH settings ) +ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES + qml/gaming/GameCenterOverlay.qml + qml/gaming/GameTile.qml + qml/gaming/GamingHUD.qml + qml/gaming/RunningGamesView.qml + PATH gaming +) + target_link_libraries(org.kde.plasma.mobile.homescreen.folio PRIVATE Qt::Gui Qt::Qml diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml new file mode 100644 index 00000000..478466a0 --- /dev/null +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import QtQuick.Window + +import org.kde.kirigami as Kirigami +import org.kde.plasma.components 3.0 as PC3 +import org.kde.plasma.private.mobileshell as MobileShell +import org.kde.plasma.private.mobileshell.state as MobileShellState +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.layershell 1.0 as LayerShell + +import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio + +Window { + id: root + + required property var folio + + signal gameStarted() + signal dismissRequested() + + function requestExitGamingMode() { + exitGamingDialog.active = true + exitGamingDialog.item.open() + } + + width: Screen.width + height: Screen.height + color: "transparent" + flags: Qt.FramelessWindowHint + + LayerShell.Window.scope: "gaming-overlay" + LayerShell.Window.layer: LayerShell.Window.LayerTop + LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorBottom + | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight + LayerShell.Window.exclusionZone: -1 + LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityOnDemand + + // Animate opacity on show/hide + opacity: visible ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } + } + + onVisibleChanged: { + if (visible) { + root.folio.ApplicationListSearchModel.categoryFilter = "Game" + if (runningGames.hasTasks) { + runningGames.focusFirstTask() + } else { + grid.forceActiveFocus() + } + } + } + + Rectangle { + anchors.fill: parent + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Window + color: Qt.rgba(Kirigami.Theme.backgroundColor.r, + Kirigami.Theme.backgroundColor.g, + Kirigami.Theme.backgroundColor.b, 0.92) + } + + FocusScope { + id: contentRoot + anchors.fill: parent + focus: root.visible + + // Escape only dismisses the overlay; exiting gaming mode is explicit. + Keys.onEscapePressed: root.dismissRequested() + + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.largeSpacing * 2 + spacing: Kirigami.Units.largeSpacing + + // ---- header ---- + RowLayout { + Layout.fillWidth: true + + Kirigami.Heading { + Layout.fillWidth: true + text: i18n("Game Center") + level: 1 + } + + QQC2.ToolButton { + icon.name: "window-close" + text: i18n("Exit Gaming Mode") + display: QQC2.AbstractButton.TextBesideIcon + Keys.onReturnPressed: clicked() + onClicked: root.requestExitGamingMode() + } + } + + // ---- running games ---- + RunningGamesView { + id: runningGames + Layout.fillWidth: true + onTaskActivated: root.gameStarted() + onMoveDownRequested: grid.forceActiveFocus() + } + + // ---- game grid ---- + Kirigami.Heading { + level: 2 + text: i18n("Library") + } + + GridView { + id: grid + + Layout.fillWidth: true + Layout.fillHeight: true + + // Filter the app list to the Games XDG category. + // ApplicationListSearchModel.categoryFilter is declared in + // applicationlistmodel.h and filters on the CategoriesRole of + // ApplicationListModel. + model: root.folio.ApplicationListSearchModel + + readonly property real minCellSize: Kirigami.Units.gridUnit * 7 + readonly property int columns: Math.max(2, Math.floor(width / minCellSize)) + + cellWidth: Math.floor(width / columns) + cellHeight: cellWidth + Kirigami.Units.gridUnit * 2 + + keyNavigationEnabled: true + highlightMoveDuration: 0 + highlight: null + + onActiveFocusChanged: { + if (activeFocus && count > 0 && currentIndex < 0) { + currentIndex = 0 + } + } + + Keys.onUpPressed: { + if (runningGames.hasTasks) { + runningGames.focusFirstTask() + } + } + + Keys.onReturnPressed: currentItem && currentItem.launch() + Keys.onEnterPressed: currentItem && currentItem.launch() + Keys.onEscapePressed: root.dismissRequested() + + delegate: Item { + width: grid.cellWidth + height: grid.cellHeight + + GameTile { + anchors.fill: parent + folio: root.folio + application: model.delegate ? model.delegate.application : null + isCurrent: GridView.isCurrentItem && grid.activeFocus + onLaunchRequested: root.gameStarted() + } + } + } + } + } + + Loader { + id: exitGamingDialog + active: false + anchors.fill: parent + + sourceComponent: Kirigami.PromptDialog { + title: i18n("Exit Gaming Mode") + subtitle: i18n("Keep gaming mode enabled, or switch back to the normal shell layout?") + standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.Cancel + + onAccepted: ShellSettings.Settings.gamingModeEnabled = false + onClosed: exitGamingDialog.active = false + } + } +} diff --git a/containments/homescreens/folio/qml/gaming/GameTile.qml b/containments/homescreens/folio/qml/gaming/GameTile.qml new file mode 100644 index 00000000..2a65ce4d --- /dev/null +++ b/containments/homescreens/folio/qml/gaming/GameTile.qml @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 + +import org.kde.kirigami as Kirigami +import org.kde.plasma.private.mobileshell as MobileShell +import org.kde.plasma.private.mobileshell.state as MobileShellState +import org.kde.plasma.plasmoid + +import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio +import org.kde.plasma.components 3.0 as PC3 + +QQC2.ItemDelegate { + id: root + + required property var folio + required property Folio.FolioApplication application + required property bool isCurrent + + signal launchRequested() + + Keys.onReturnPressed: clicked() + Keys.onEnterPressed: clicked() + + onClicked: { + if (!application) return + if (application.icon !== "" && !application.running) { + MobileShellState.ShellDBusClient.openAppLaunchAnimationWithPosition( + Plasmoid.screen, + application.icon, + application.name, + application.storageId, + iconItem.Kirigami.ScenePosition.x + iconItem.width / 2, + iconItem.Kirigami.ScenePosition.y + iconItem.height / 2, + Math.min(iconItem.width, iconItem.height)) + } + MobileShell.AppLaunch.launchOrActivateApp(application.storageId) + launchRequested() + } + + function launch() { + clicked() + } + + background: Rectangle { + Kirigami.Theme.colorSet: Kirigami.Theme.Button + color: root.isCurrent + ? Kirigami.Theme.highlightColor + : (root.hovered ? Kirigami.Theme.hoverColor : "transparent") + radius: Kirigami.Units.cornerRadius + + Behavior on color { + ColorAnimation { duration: Kirigami.Units.shortDuration } + } + } + + contentItem: ColumnLayout { + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + id: iconItem + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.huge + implicitHeight: Kirigami.Units.iconSizes.huge + source: root.application ? root.application.icon : "" + + scale: root.isCurrent ? 1.08 : 1.0 + Behavior on scale { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } + } + } + + PC3.Label { + id: nameLabel + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + text: root.application ? root.application.name : "" + maximumLineCount: 2 + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + color: root.isCurrent ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor + + Behavior on color { + ColorAnimation { duration: Kirigami.Units.shortDuration } + } + } + } +} diff --git a/containments/homescreens/folio/qml/gaming/GamingHUD.qml b/containments/homescreens/folio/qml/gaming/GamingHUD.qml new file mode 100644 index 00000000..fd52aeac --- /dev/null +++ b/containments/homescreens/folio/qml/gaming/GamingHUD.qml @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Window + +import org.kde.kirigami as Kirigami +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.layershell 1.0 as LayerShell + +Window { + id: root + + signal openRequested() + + width: Kirigami.Units.gridUnit * 4 + height: Kirigami.Units.gridUnit * 2 + color: "transparent" + flags: Qt.FramelessWindowHint + + LayerShell.Window.scope: "gaming-hud" + LayerShell.Window.layer: LayerShell.Window.LayerOverlay + LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorRight + LayerShell.Window.exclusionZone: 0 + LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone + + opacity: visible ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } + } + + Rectangle { + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + radius: height / 2 + color: Qt.rgba(0, 0, 0, 0.55) + + QQC2.ToolButton { + anchors.centerIn: parent + icon.name: "input-gaming" + icon.color: "white" + display: QQC2.AbstractButton.IconOnly + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: i18n("Game Center") + onClicked: root.openRequested() + } + } +} diff --git a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml new file mode 100644 index 00000000..469a4d7b --- /dev/null +++ b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 + +import org.kde.kirigami as Kirigami +import org.kde.plasma.components 3.0 as PC3 +import org.kde.taskmanager as TaskManager + +import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio + +Item { + id: root + + implicitHeight: taskList.count > 0 ? column.implicitHeight : 0 + readonly property bool hasTasks: taskList.count > 0 + + signal taskActivated() + signal moveDownRequested() + + function focusFirstTask() { + if (!hasTasks) { + return; + } + taskList.currentIndex = Math.max(0, taskList.currentIndex) + taskList.positionViewAtIndex(taskList.currentIndex, ListView.Visible) + taskList.forceActiveFocus() + } + + TaskManager.VirtualDesktopInfo { id: vdInfo } + TaskManager.ActivityInfo { id: actInfo } + + TaskManager.TasksModel { + id: tasks + filterByVirtualDesktop: true + filterByActivity: true + filterNotMaximized: false + filterByScreen: true + filterHidden: false + virtualDesktop: vdInfo.currentDesktop + activity: actInfo.currentActivity + groupMode: TaskManager.TasksModel.GroupApplications + } + + Behavior on implicitHeight { + NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } + } + + ColumnLayout { + id: column + anchors.left: parent.left + anchors.right: parent.right + spacing: Kirigami.Units.smallSpacing + visible: taskList.count > 0 + + Kirigami.Heading { + level: 2 + text: i18n("Running") + } + + ListView { + id: taskList + Layout.fillWidth: true + implicitHeight: contentHeight + model: tasks + orientation: ListView.Horizontal + spacing: Kirigami.Units.largeSpacing + clip: true + keyNavigationEnabled: true + currentIndex: 0 + + onActiveFocusChanged: { + if (activeFocus && count > 0 && currentIndex < 0) { + currentIndex = 0 + } + } + + Keys.onLeftPressed: { + if (count <= 0) { + return + } + currentIndex = Math.max(0, currentIndex - 1) + positionViewAtIndex(currentIndex, ListView.Contain) + } + + Keys.onRightPressed: { + if (count <= 0) { + return + } + currentIndex = Math.min(count - 1, currentIndex + 1) + positionViewAtIndex(currentIndex, ListView.Contain) + } + + Keys.onDownPressed: root.moveDownRequested() + Keys.onReturnPressed: currentItem && currentItem.activate() + Keys.onEnterPressed: currentItem && currentItem.activate() + + delegate: QQC2.ItemDelegate { + id: taskItem + + required property var decoration + required property var winIdList + + width: Kirigami.Units.gridUnit * 8 + height: Kirigami.Units.gridUnit * 5 + + readonly property var modelIndex: tasks.makeModelIndex(index) + readonly property bool isCurrent: ListView.isCurrentItem && taskList.activeFocus + readonly property string titleText: typeof model !== "undefined" && model.display ? model.display : "" + + function activate() { + tasks.requestActivate(taskItem.modelIndex) + root.taskActivated() + } + + onClicked: { + taskList.currentIndex = index + activate() + } + + Keys.onReturnPressed: activate() + Keys.onEnterPressed: activate() + + Rectangle { + anchors.fill: parent + radius: Kirigami.Units.cornerRadius + color: taskItem.isCurrent + ? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, + Kirigami.Theme.highlightColor.b, 0.25) + : resumeArea.containsPress + ? Qt.rgba(Kirigami.Theme.highlightColor.r, Kirigami.Theme.highlightColor.g, + Kirigami.Theme.highlightColor.b, 0.3) + : resumeArea.containsMouse + ? Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, + Kirigami.Theme.textColor.b, 0.1) + : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, + Kirigami.Theme.textColor.b, 0.06) + + Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.large + implicitHeight: Kirigami.Units.iconSizes.large + source: taskItem.decoration + } + + PC3.Label { + Layout.fillWidth: true + text: taskItem.titleText + maximumLineCount: 1 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + } + } + + // Close button (top-right corner) + QQC2.ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Kirigami.Units.smallSpacing / 2 + width: Kirigami.Units.iconSizes.small + height: width + icon.name: "window-close-symbolic" + icon.width: Kirigami.Units.iconSizes.small + icon.height: Kirigami.Units.iconSizes.small + display: QQC2.AbstractButton.IconOnly + onClicked: tasks.requestClose(taskItem.modelIndex) + } + + MouseArea { + id: resumeArea + anchors.fill: parent + hoverEnabled: true + // Leave room for the close button + onClicked: { + taskList.currentIndex = index + taskItem.activate() + } + } + } + } + } +} From 50ea5976ceb27c7b02f51d99926ccc4394b91ebb Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sun, 19 Apr 2026 14:10:51 +0200 Subject: [PATCH 04/26] Fix HUD crash, clarify exit dialog, hide hint tile LayerShell windows with non-spanning anchors (AnchorTop|AnchorRight) crash the compositor when made invisible because Qt briefly resizes the surface to 0 before unmapping it. Wrap GamingHUD in a Loader so the window is destroyed rather than hidden. Replace the generic 'Yes / Cancel' exit dialog with 'Keep Playing / Leave' in both the Game Center header and the QS tile, matching the mental model of a couch user. The 'Launch Hint' QS tile is now hidden (available: false) when gaming mode is off so it does not clutter the regular action drawer. --- .../folio/qml/gaming/GameCenterOverlay.qml | 19 ++++++++++++++----- containments/homescreens/folio/qml/main.qml | 11 ++++++++--- quicksettings/gaming/contents/ui/main.qml | 19 ++++++++++++++----- quicksettings/gaminghint/contents/ui/main.qml | 3 ++- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index 478466a0..ebe6a6c4 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -172,11 +172,20 @@ Window { anchors.fill: parent sourceComponent: Kirigami.PromptDialog { - title: i18n("Exit Gaming Mode") - subtitle: i18n("Keep gaming mode enabled, or switch back to the normal shell layout?") - standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.Cancel - - onAccepted: ShellSettings.Settings.gamingModeEnabled = false + id: theExitDialog + title: i18n("Leave gaming mode?") + subtitle: i18n("Your games will keep running in the background.") + standardButtons: Kirigami.Dialog.NoButton + customFooterActions: [ + Kirigami.Action { + text: i18n("Keep Playing") + onTriggered: theExitDialog.close() + }, + Kirigami.Action { + text: i18n("Leave") + onTriggered: ShellSettings.Settings.gamingModeEnabled = false + } + ] onClosed: exitGamingDialog.active = false } } diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index 088e6792..e645c4d9 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -698,9 +698,14 @@ ContainmentItem { // Small persistent button at the top-right corner of the screen that lets // the user return to the Game Center after launching a game. - GamingHUD { - visible: ShellSettings.Settings.gamingModeEnabled && !root.gameCenterOpen - onOpenRequested: root.gameCenterOpen = true + // Wrapped in a Loader so the LayerShell Window is destroyed (not merely + // hidden) when not needed — hiding a LayerShell window with AnchorTop|Right + // but no fixed height causes a Wayland protocol error (height=0). + Loader { + active: ShellSettings.Settings.gamingModeEnabled && !root.gameCenterOpen + sourceComponent: GamingHUD { + onOpenRequested: root.gameCenterOpen = true + } } Rectangle { diff --git a/quicksettings/gaming/contents/ui/main.qml b/quicksettings/gaming/contents/ui/main.qml index a390c193..6d022f1c 100644 --- a/quicksettings/gaming/contents/ui/main.qml +++ b/quicksettings/gaming/contents/ui/main.qml @@ -35,11 +35,20 @@ QS.QuickSetting { active: false sourceComponent: Kirigami.PromptDialog { - title: i18n("Exit Gaming Mode") - subtitle: i18n("Switch back to the normal shell layout?") - standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.Cancel - - onAccepted: ShellSettings.Settings.gamingModeEnabled = false + id: theConfirmDialog + title: i18n("Leave gaming mode?") + subtitle: i18n("Your games will keep running in the background.") + standardButtons: Kirigami.Dialog.NoButton + customFooterActions: [ + Kirigami.Action { + text: i18n("Keep Playing") + onTriggered: theConfirmDialog.close() + }, + Kirigami.Action { + text: i18n("Leave") + onTriggered: ShellSettings.Settings.gamingModeEnabled = false + } + ] onClosed: confirmDisableDialog.active = false } } diff --git a/quicksettings/gaminghint/contents/ui/main.qml b/quicksettings/gaminghint/contents/ui/main.qml index f6623072..fbf548b1 100644 --- a/quicksettings/gaminghint/contents/ui/main.qml +++ b/quicksettings/gaminghint/contents/ui/main.qml @@ -7,10 +7,11 @@ import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings import org.kde.plasma.private.mobileshell.quicksettingsplugin as QS QS.QuickSetting { - text: i18n("Game Center Hint") + text: i18n("Launch Hint") icon: "dialog-information" status: "" enabled: ShellSettings.Settings.gamingDismissHintEnabled + available: ShellSettings.Settings.gamingModeEnabled function toggle() { ShellSettings.Settings.gamingDismissHintEnabled = !ShellSettings.Settings.gamingDismissHintEnabled; From 70fa861b307f066355099bb3e377148b05807d94 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 08:57:03 +0200 Subject: [PATCH 05/26] Harden gaming mode and fix startup crash Strut windows committed zero height to layer-shell during early init, causing a protocol error and session exit. Wrap height and exclusionZone in Math.max(1, ...) for top bar and dock. Persisted gamingModeEnabled=true with hardcoded gameCenterOpen=false hid panels with no overlay visible. Initialize gameCenterOpen from the stored setting. Fix HUD visibility regression from an emergency visible:true override. Various null-guard and positioning fixes from static analysis across the gaming shell QML. --- Nebra.code-workspace | 7 +++ .../mobileshell/components/applethost.cpp | 4 +- .../qml/navigationpanel/NavigationPanel.qml | 16 ++++- .../homescreens/folio/qml/FavouritesBar.qml | 8 ++- .../folio/qml/gaming/GameCenterOverlay.qml | 6 +- .../homescreens/folio/qml/gaming/GameTile.qml | 2 +- .../folio/qml/gaming/GamingHUD.qml | 13 +++- .../folio/qml/gaming/RunningGamesView.qml | 1 + containments/homescreens/folio/qml/main.qml | 15 +++-- containments/panel/qml/StatusPanel.qml | 1 + containments/panel/qml/main.qml | 6 +- containments/taskpanel/qml/main.qml | 6 +- .../convergentwindows/contents/ui/main.qml | 59 ++++++++++++------- quicksettings/gaming/Messages.sh | 12 +++- quicksettings/gaming/contents/ui/main.qml | 7 ++- quicksettings/gaminghint/Messages.sh | 12 +++- quicksettings/gaminghint/contents/ui/main.qml | 4 +- screenshots/SHIFT-touch.png.license | 2 +- 18 files changed, 129 insertions(+), 52 deletions(-) create mode 100644 Nebra.code-workspace diff --git a/Nebra.code-workspace b/Nebra.code-workspace new file mode 100644 index 00000000..ef9f5d27 --- /dev/null +++ b/Nebra.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/components/mobileshell/components/applethost.cpp b/components/mobileshell/components/applethost.cpp index 9fe7cf16..5968e343 100644 --- a/components/mobileshell/components/applethost.cpp +++ b/components/mobileshell/components/applethost.cpp @@ -86,13 +86,15 @@ QQuickItem *AppletHost::fullRepresentationFor(const QString &pluginId) m_containment->addApplet(applet); auto *item = PlasmaQuick::AppletQuickItem::itemForApplet(applet); - m_items.insert(pluginId, item); if (!item) { qWarning() << "AppletHost: no AppletQuickItem for" << pluginId; + applet->deleteLater(); return nullptr; } + m_items.insert(pluginId, item); + item->setPreloadFullRepresentation(true); auto *fullRepItem = item->fullRepresentationItem(); diff --git a/components/mobileshell/qml/navigationpanel/NavigationPanel.qml b/components/mobileshell/qml/navigationpanel/NavigationPanel.qml index 80e0b13a..758aaff5 100644 --- a/components/mobileshell/qml/navigationpanel/NavigationPanel.qml +++ b/components/mobileshell/qml/navigationpanel/NavigationPanel.qml @@ -165,6 +165,7 @@ Item { shrinkSize: 0 onClicked: { + if (!root.taskModel) return; root.taskModel.requestActivate(root.taskModel.makeModelIndex(taskDelegate.index)); } @@ -180,18 +181,27 @@ Item { Controls.MenuItem { text: taskDelegate.model.IsMinimized ? i18n("Restore") : i18n("Minimize") icon.name: taskDelegate.model.IsMinimized ? "window-restore" : "window-minimize" - onTriggered: root.taskModel.requestToggleMinimized(root.taskModel.makeModelIndex(taskDelegate.index)) + onTriggered: { + if (!root.taskModel) return; + root.taskModel.requestToggleMinimized(root.taskModel.makeModelIndex(taskDelegate.index)) + } } Controls.MenuItem { text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize") icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize" - onTriggered: root.taskModel.requestToggleMaximized(root.taskModel.makeModelIndex(taskDelegate.index)) + onTriggered: { + if (!root.taskModel) return; + root.taskModel.requestToggleMaximized(root.taskModel.makeModelIndex(taskDelegate.index)) + } } Controls.MenuSeparator {} Controls.MenuItem { text: i18n("Close") icon.name: "window-close" - onTriggered: root.taskModel.requestClose(root.taskModel.makeModelIndex(taskDelegate.index)) + onTriggered: { + if (!root.taskModel) return; + root.taskModel.requestClose(root.taskModel.makeModelIndex(taskDelegate.index)) + } } } diff --git a/containments/homescreens/folio/qml/FavouritesBar.qml b/containments/homescreens/folio/qml/FavouritesBar.qml index 3e338c08..485d9173 100644 --- a/containments/homescreens/folio/qml/FavouritesBar.qml +++ b/containments/homescreens/folio/qml/FavouritesBar.qml @@ -706,12 +706,16 @@ MouseArea { x: { if (!targetDelegate) return 0 var delegateGlobal = targetDelegate.mapToGlobal(0, 0) - return Math.max(0, Math.min(Screen.width - width, delegateGlobal.x + (targetDelegate.width - width) / 2)) + var win = targetDelegate.Window.window + var scrW = win && win.screen ? win.screen.width : Screen.width + return Math.max(0, Math.min(scrW - width, delegateGlobal.x + (targetDelegate.width - width) / 2)) } y: { if (!targetDelegate) return 0 var delegateGlobal = targetDelegate.mapToGlobal(0, 0) - return Math.max(0, Math.min(Screen.height - height, delegateGlobal.y - height - Kirigami.Units.smallSpacing)) + var win = targetDelegate.Window.window + var scrH = win && win.screen ? win.screen.height : Screen.height + return Math.max(0, Math.min(scrH - height, delegateGlobal.y - height - Kirigami.Units.smallSpacing)) } onShowingChanged: { diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index ebe6a6c4..5caf136e 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -94,6 +94,7 @@ Window { text: i18n("Exit Gaming Mode") display: QQC2.AbstractButton.TextBesideIcon Keys.onReturnPressed: clicked() + Keys.onEnterPressed: clicked() onClicked: root.requestExitGamingMode() } } @@ -183,7 +184,10 @@ Window { }, Kirigami.Action { text: i18n("Leave") - onTriggered: ShellSettings.Settings.gamingModeEnabled = false + onTriggered: { + ShellSettings.Settings.gamingModeEnabled = false + theExitDialog.close() + } } ] onClosed: exitGamingDialog.active = false diff --git a/containments/homescreens/folio/qml/gaming/GameTile.qml b/containments/homescreens/folio/qml/gaming/GameTile.qml index 2a65ce4d..ef821540 100644 --- a/containments/homescreens/folio/qml/gaming/GameTile.qml +++ b/containments/homescreens/folio/qml/gaming/GameTile.qml @@ -27,7 +27,7 @@ QQC2.ItemDelegate { onClicked: { if (!application) return - if (application.icon !== "" && !application.running) { + if (application.icon && typeof application.icon === "string" && application.icon.length > 0 && !application.running) { MobileShellState.ShellDBusClient.openAppLaunchAnimationWithPosition( Plasmoid.screen, application.icon, diff --git a/containments/homescreens/folio/qml/gaming/GamingHUD.qml b/containments/homescreens/folio/qml/gaming/GamingHUD.qml index fd52aeac..6c04e3b8 100644 --- a/containments/homescreens/folio/qml/gaming/GamingHUD.qml +++ b/containments/homescreens/folio/qml/gaming/GamingHUD.qml @@ -14,8 +14,11 @@ Window { signal openRequested() - width: Kirigami.Units.gridUnit * 4 - height: Kirigami.Units.gridUnit * 2 + // Guard against startup timing where Kirigami units may briefly be 0/NaN. + // LayerShell surfaces must never be committed with zero size. + readonly property real safeGridUnit: ((Kirigami.Units.gridUnit || 0) > 0) ? Kirigami.Units.gridUnit : 16 + width: safeGridUnit * 4 + height: safeGridUnit * 2 color: "transparent" flags: Qt.FramelessWindowHint @@ -25,7 +28,11 @@ Window { LayerShell.Window.exclusionZone: 0 LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone - opacity: visible ? 1 : 0 + // Driven by the Loader in folio/qml/main.qml — set false to fade out + // before the Loader destroys the window. + property bool showing: true + + opacity: showing ? 1 : 0 Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } } diff --git a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml index 469a4d7b..97f1c113 100644 --- a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml +++ b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml @@ -181,6 +181,7 @@ Item { id: resumeArea anchors.fill: parent hoverEnabled: true + z: -1 // Leave room for the close button onClicked: { taskList.currentIndex = index diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index e645c4d9..15e7dabd 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -34,8 +34,9 @@ ContainmentItem { property Folio.HomeScreen folio: root.plasmoid // Tracks whether the Game Center grid is visible within gaming mode. - // Starts true when gaming mode turns on; set to false by a game launch. - property bool gameCenterOpen: false + // If gaming mode is already enabled at startup, open it immediately so + // the user is never left without controls. + property bool gameCenterOpen: ShellSettings.Settings.gamingModeEnabled property bool showGameCenterHint: false Timer { @@ -52,6 +53,7 @@ ContainmentItem { } Component.onCompleted: { + root.gameCenterOpen = ShellSettings.Settings.gamingModeEnabled folio.FolioSettings.load(); folio.FavouritesModel.load(); folio.PageListModel.load(); @@ -698,12 +700,13 @@ ContainmentItem { // Small persistent button at the top-right corner of the screen that lets // the user return to the Game Center after launching a game. - // Wrapped in a Loader so the LayerShell Window is destroyed (not merely - // hidden) when not needed — hiding a LayerShell window with AnchorTop|Right - // but no fixed height causes a Wayland protocol error (height=0). + // Keep the Loader active for the full duration of gaming mode so the + // opacity Behavior in GamingHUD can animate both fade-in and fade-out. Loader { - active: ShellSettings.Settings.gamingModeEnabled && !root.gameCenterOpen + active: ShellSettings.Settings.gamingModeEnabled sourceComponent: GamingHUD { + visible: showing + showing: !root.gameCenterOpen onOpenRequested: root.gameCenterOpen = true } } diff --git a/containments/panel/qml/StatusPanel.qml b/containments/panel/qml/StatusPanel.qml index 091db624..11f78247 100644 --- a/containments/panel/qml/StatusPanel.qml +++ b/containments/panel/qml/StatusPanel.qml @@ -131,6 +131,7 @@ Item { context: Qt.ApplicationShortcut onActivated: { if (drawer.actionDrawer.intendedToBeVisible) { + drawer.actionDrawer.intendedToBeVisible = false; drawer.actionDrawer.close(); } else { drawer.actionDrawer.openToPinnedMode = false; diff --git a/containments/panel/qml/main.qml b/containments/panel/qml/main.qml index 2e151540..7effa258 100644 --- a/containments/panel/qml/main.qml +++ b/containments/panel/qml/main.qml @@ -132,7 +132,7 @@ ContainmentItem { function onGamingModeEnabledChanged() { root.setWindowProperties(); - MobileShellState.ShellDBusClient.panelState = ShellSettings.Settings.gamingModeEnabled ? "hidden" : "default"; + MobileShellState.ShellDBusClient.panelState = ShellSettings.Settings.gamingModeEnabled ? "hidden" : (fullscreen ? "hidden" : "default"); } } @@ -150,13 +150,13 @@ ContainmentItem { visible: ShellSettings.Settings.convergenceModeEnabled && !ShellSettings.Settings.gamingModeEnabled color: "transparent" flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput - height: root.panelHeight + height: Math.max(1, root.panelHeight) width: 1 LayerShell.Window.scope: "topbar-space" LayerShell.Window.layer: LayerShell.Window.LayerBottom LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight - LayerShell.Window.exclusionZone: root.panelHeight + LayerShell.Window.exclusionZone: Math.max(1, root.panelHeight) LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone } diff --git a/containments/taskpanel/qml/main.qml b/containments/taskpanel/qml/main.qml index f9f4bb87..31cde951 100644 --- a/containments/taskpanel/qml/main.qml +++ b/containments/taskpanel/qml/main.qml @@ -141,7 +141,7 @@ ContainmentItem { function onGamingModeEnabledChanged() { root.setWindowProperties(); - navigationPanel.offset = ShellSettings.Settings.gamingModeEnabled ? root.navigationPanelHeight : 0; + navigationPanel.offset = ShellSettings.Settings.gamingModeEnabled ? MobileShell.Constants.navigationPanelThickness : 0; } } @@ -166,13 +166,13 @@ ContainmentItem { color: "transparent" flags: Qt.FramelessWindowHint | Qt.WindowTransparentForInput // height is set by layer-shell anchoring; provide a fallback. - height: Kirigami.Units.gridUnit * 3 + height: Math.max(1, MobileShell.Constants.navigationPanelThickness) width: 1 // layer-shell stretches it via AnchorLeft|AnchorRight LayerShell.Window.scope: "dock-space" LayerShell.Window.layer: LayerShell.Window.LayerBottom LayerShell.Window.anchors: LayerShell.Window.AnchorBottom | LayerShell.Window.AnchorLeft | LayerShell.Window.AnchorRight - LayerShell.Window.exclusionZone: Kirigami.Units.gridUnit * 3 + LayerShell.Window.exclusionZone: Math.max(1, MobileShell.Constants.navigationPanelThickness) LayerShell.Window.keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone } diff --git a/kwin/scripts/convergentwindows/contents/ui/main.qml b/kwin/scripts/convergentwindows/contents/ui/main.qml index ce00568b..deb73722 100644 --- a/kwin/scripts/convergentwindows/contents/ui/main.qml +++ b/kwin/scripts/convergentwindows/contents/ui/main.qml @@ -11,9 +11,9 @@ Loader { property var currentWindow - // Window that needs geometry clamping after un-maximize in convergence - // mode. Set in onMaximizedChanged and consumed by the timer below. - property var pendingConstrainWindow: null + // Windows awaiting geometry clamping after un-maximize in convergence + // mode. Using an array so concurrent un-maximizes are not lost. + property var pendingConstrainWindows: [] // After a window is un-maximized in convergence mode, the dockSpaceReserver // LayerShell surface needs one Wayland roundtrip to (re)commit its exclusive @@ -24,27 +24,30 @@ Loader { id: constrainAfterRestoreTimer interval: 200 onTriggered: { - const window = root.pendingConstrainWindow - root.pendingConstrainWindow = null - if (!window || window.deleted || !window.normalWindow) return - if (!ShellSettings.Settings.convergenceModeEnabled) return + const windows = root.pendingConstrainWindows.slice() + root.pendingConstrainWindows = [] + for (const window of windows) { + if (!window || window.deleted || !window.normalWindow) continue + if (!ShellSettings.Settings.convergenceModeEnabled) continue + if (ShellSettings.Settings.gamingModeEnabled) continue - const output = window.output - const desktop = window.desktops[0] - if (!output) return - if (!desktop) return + const output = window.output + const desktop = window.desktops[0] + if (!output) continue + if (!desktop) continue - const maxRect = KWinComponents.Workspace.clientArea( - KWinComponents.Workspace.MaximizeArea, output, desktop) - const geo = window.frameGeometry - const maxBottom = maxRect.y + maxRect.height + const maxRect = KWinComponents.Workspace.clientArea( + KWinComponents.Workspace.MaximizeArea, output, desktop) + const geo = window.frameGeometry + const maxBottom = maxRect.y + maxRect.height - if (geo.y + geo.height > maxBottom) { - // Clip the bottom edge to MaximizeArea; preserve top position - // and width. Ensure height is at least 100px to avoid - // pathological cases where the window starts above maxRect. - const newH = Math.max(100, maxBottom - geo.y) - window.frameGeometry = Qt.rect(geo.x, geo.y, geo.width, newH) + if (geo.y + geo.height > maxBottom) { + // Clip the bottom edge to MaximizeArea; preserve top position + // and width. Ensure height is at least 100px to avoid + // pathological cases where the window starts above maxRect. + const newH = Math.max(100, maxBottom - geo.y) + window.frameGeometry = Qt.rect(geo.x, geo.y, geo.width, newH) + } } } } @@ -97,6 +100,9 @@ Loader { target: currentWindow function onFullScreenChanged() { + if (!currentWindow) { + return; + } currentWindow.interactiveMoveResizeFinished.connect((currentWindow) => { root.run(currentWindow); }); @@ -104,6 +110,9 @@ Loader { } function onMaximizedChanged() { + if (!currentWindow) { + return; + } if (!currentWindow.maximizable) { return; } @@ -116,7 +125,7 @@ Loader { // zone is re-committed over a Wayland roundtrip. if (ShellSettings.Settings.convergenceModeEnabled && ShellSettings.Settings.autoHidePanelsEnabled) { - root.pendingConstrainWindow = currentWindow + root.pendingConstrainWindows.push(currentWindow) constrainAfterRestoreTimer.restart() } } @@ -150,6 +159,9 @@ Loader { target: KWinComponents.Workspace function onWindowAdded(window) { + if (!window) { + return; + } if (window.normalWindow) { window.interactiveMoveResizeFinished.connect((window) => { root.run(window); @@ -159,6 +171,9 @@ Loader { } function onWindowActivated(window) { + if (!window) { + return; + } if (window.normalWindow) { currentWindow = window; window.interactiveMoveResizeFinished.connect((window) => { diff --git a/quicksettings/gaming/Messages.sh b/quicksettings/gaming/Messages.sh index 37df498c..f1cdd746 100644 --- a/quicksettings/gaming/Messages.sh +++ b/quicksettings/gaming/Messages.sh @@ -3,4 +3,14 @@ # SPDX-FileCopyrightText: 2026 Marco Allegretti # SPDX-License-Identifier: EUPL-1.2 -$XGETTEXT `find . -name \*.js -o -name \*.qml -o -name \*.cpp` -o $podir/plasma_org.kde.plasma.quicksetting.gaming.pot +set -e +: "${XGETTEXT:?XGETTEXT is not set}" +: "${podir:?podir is not set}" + +mapfile -t files < <(find . \( -name '*.js' -o -name '*.qml' -o -name '*.cpp' \) -print) +if [[ ${#files[@]} -eq 0 ]]; then + echo "Messages.sh: no input files found" >&2 + exit 1 +fi + +"$XGETTEXT" "${files[@]}" -o "$podir/plasma_org.kde.plasma.quicksetting.gaming.pot" diff --git a/quicksettings/gaming/contents/ui/main.qml b/quicksettings/gaming/contents/ui/main.qml index 6d022f1c..a99e9660 100644 --- a/quicksettings/gaming/contents/ui/main.qml +++ b/quicksettings/gaming/contents/ui/main.qml @@ -18,7 +18,6 @@ QS.QuickSetting { function requestDisable() { confirmDisableDialog.active = true; - confirmDisableDialog.item.open(); } function toggle() { @@ -33,6 +32,7 @@ QS.QuickSetting { Loader { id: confirmDisableDialog active: false + onLoaded: item.open() sourceComponent: Kirigami.PromptDialog { id: theConfirmDialog @@ -46,7 +46,10 @@ QS.QuickSetting { }, Kirigami.Action { text: i18n("Leave") - onTriggered: ShellSettings.Settings.gamingModeEnabled = false + onTriggered: { + ShellSettings.Settings.gamingModeEnabled = false + theConfirmDialog.close() + } } ] onClosed: confirmDisableDialog.active = false diff --git a/quicksettings/gaminghint/Messages.sh b/quicksettings/gaminghint/Messages.sh index 73c7528f..51cc9cbd 100644 --- a/quicksettings/gaminghint/Messages.sh +++ b/quicksettings/gaminghint/Messages.sh @@ -2,4 +2,14 @@ # SPDX-FileCopyrightText: 2026 Marco Allegretti # SPDX-License-Identifier: EUPL-1.2 -$XGETTEXT *.json contents/ui/*.qml -o $podir/plasma_mobile_qt.pot +set -e +: "${XGETTEXT:?XGETTEXT is not set}" +: "${podir:?podir is not set}" + +shopt -s nullglob +files=(*.json contents/ui/*.qml) +if [[ ${#files[@]} -eq 0 ]]; then + echo "Messages.sh: no input files found" >&2 + exit 1 +fi +"$XGETTEXT" "${files[@]}" -o "$podir/plasma_mobile_qt.pot" diff --git a/quicksettings/gaminghint/contents/ui/main.qml b/quicksettings/gaminghint/contents/ui/main.qml index fbf548b1..54073fa1 100644 --- a/quicksettings/gaminghint/contents/ui/main.qml +++ b/quicksettings/gaminghint/contents/ui/main.qml @@ -9,8 +9,8 @@ import org.kde.plasma.private.mobileshell.quicksettingsplugin as QS QS.QuickSetting { text: i18n("Launch Hint") icon: "dialog-information" - status: "" - enabled: ShellSettings.Settings.gamingDismissHintEnabled + status: ShellSettings.Settings.gamingDismissHintEnabled ? i18n("On") : i18n("Off") + enabled: true available: ShellSettings.Settings.gamingModeEnabled function toggle() { diff --git a/screenshots/SHIFT-touch.png.license b/screenshots/SHIFT-touch.png.license index d31163d3..e1e3a62d 100644 --- a/screenshots/SHIFT-touch.png.license +++ b/screenshots/SHIFT-touch.png.license @@ -1,2 +1,2 @@ -SPDX-FileCopyrightText: 2026 Marco Allegretti. +SPDX-FileCopyrightText: 2026 Marco Allegretti SPDX-License-Identifier: EUPL-1.2 From 1bf8cfb2ba9309425127d60d366df26e957bb93e Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 09:43:11 +0200 Subject: [PATCH 06/26] Add gamingshellplugin with SDL3 and game library SDL3-backed gamepad manager polls at 60Hz, handles hotplug, exposes battery/rumble/LED per device. Game launcher aggregates XDG desktop entries, Steam appmanifests, and Flatpak into a single sorted model. Game Center uses the new model instead of folio's search model. D-pad/A/B/Y navigate and launch. Guide button toggles the overlay. Battery and controller status shown in the HUD. --- CMakeLists.txt | 1 + components/CMakeLists.txt | 1 + components/gamingshellplugin/CMakeLists.txt | 24 ++ .../gamelauncherprovider.cpp | 284 ++++++++++++++++++ .../gamingshellplugin/gamelauncherprovider.h | 66 ++++ .../gamingshellplugin/gamepaddevice.cpp | 135 +++++++++ components/gamingshellplugin/gamepaddevice.h | 53 ++++ .../gamingshellplugin/gamepadmanager.cpp | 261 ++++++++++++++++ components/gamingshellplugin/gamepadmanager.h | 89 ++++++ .../folio/qml/gaming/GameCenterOverlay.qml | 176 ++++++++++- .../folio/qml/gaming/GamingHUD.qml | 32 +- containments/homescreens/folio/qml/main.qml | 20 +- 12 files changed, 1116 insertions(+), 26 deletions(-) create mode 100644 components/gamingshellplugin/CMakeLists.txt create mode 100644 components/gamingshellplugin/gamelauncherprovider.cpp create mode 100644 components/gamingshellplugin/gamelauncherprovider.h create mode 100644 components/gamingshellplugin/gamepaddevice.cpp create mode 100644 components/gamingshellplugin/gamepaddevice.h create mode 100644 components/gamingshellplugin/gamepadmanager.cpp create mode 100644 components/gamingshellplugin/gamepadmanager.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 90b5e991..909e32c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,7 @@ set_package_properties(PlasmaWaylandProtocols PROPERTIES find_package(LibKWorkspace CONFIG REQUIRED) find_package(Libudev REQUIRED) +find_package(SDL3 REQUIRED) find_package(KWinDBusInterface) set_package_properties(KWinDBusInterface PROPERTIES DESCRIPTION "KWin DBus interface" diff --git a/components/CMakeLists.txt b/components/CMakeLists.txt index f7af544a..54a46bdb 100644 --- a/components/CMakeLists.txt +++ b/components/CMakeLists.txt @@ -14,3 +14,4 @@ add_subdirectory(wallpaperimageplugin) add_subdirectory(dpmsplugin) add_subdirectory(screenbrightnessplugin) add_subdirectory(raiselockscreenplugin) +add_subdirectory(gamingshellplugin) diff --git a/components/gamingshellplugin/CMakeLists.txt b/components/gamingshellplugin/CMakeLists.txt new file mode 100644 index 00000000..0e823be6 --- /dev/null +++ b/components/gamingshellplugin/CMakeLists.txt @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 Marco Allegretti +# SPDX-License-Identifier: EUPL-1.2 + +find_package(SDL3 REQUIRED) + +ecm_add_qml_module(gamingshellplugin URI org.kde.plasma.private.mobileshell.gamingshellplugin GENERATE_PLUGIN_SOURCE) + +target_sources(gamingshellplugin PRIVATE + gamepadmanager.cpp + gamepaddevice.cpp + gamelauncherprovider.cpp +) + +target_link_libraries(gamingshellplugin PRIVATE + Qt::Core + Qt::Qml + Qt::Quick + KF6::I18n + KF6::Service + KF6::CoreAddons + SDL3::SDL3 +) + +ecm_finalize_qml_module(gamingshellplugin) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp new file mode 100644 index 00000000..1b1fafb5 --- /dev/null +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "gamelauncherprovider.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +GameLauncherProvider::GameLauncherProvider(QObject *parent) + : QAbstractListModel(parent) +{ + connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh); + refresh(); +} + +int GameLauncherProvider::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_games.size(); +} + +QVariant GameLauncherProvider::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_games.size()) { + return {}; + } + const auto &g = m_games.at(index.row()); + switch (role) { + case NameRole: + return g.name; + case IconRole: + return g.icon; + case SourceRole: + return g.source; + case StorageIdRole: + return g.storageId; + case LaunchCommandRole: + return g.launchCommand; + case ArtworkRole: + return g.artwork; + case InstalledRole: + return g.installed; + } + return {}; +} + +QHash GameLauncherProvider::roleNames() const +{ + return { + {NameRole, "name"}, + {IconRole, "icon"}, + {SourceRole, "source"}, + {StorageIdRole, "storageId"}, + {LaunchCommandRole, "launchCommand"}, + {ArtworkRole, "artwork"}, + {InstalledRole, "installed"}, + }; +} + +int GameLauncherProvider::count() const +{ + return m_games.size(); +} + +bool GameLauncherProvider::loading() const +{ + return m_loading; +} + +void GameLauncherProvider::refresh() +{ + m_loading = true; + Q_EMIT loadingChanged(); + + beginResetModel(); + m_games.clear(); + + loadDesktopGames(); + loadSteamGames(); + loadFlatpakGames(); + + // Sort alphabetically, case-insensitive + std::sort(m_games.begin(), m_games.end(), [](const GameEntry &a, const GameEntry &b) { + return a.name.compare(b.name, Qt::CaseInsensitive) < 0; + }); + + endResetModel(); + + m_loading = false; + Q_EMIT loadingChanged(); + Q_EMIT countChanged(); +} + +void GameLauncherProvider::launch(int index) +{ + if (index < 0 || index >= m_games.size()) { + return; + } + const auto &g = m_games.at(index); + + if (g.source == QLatin1String("desktop")) { + // Launch via KService for proper activation tracking + auto service = KService::serviceByStorageId(g.storageId); + if (service) { + // Use QProcess to launch the exec line — KIO::ApplicationLauncherJob + // would be better but requires KIOWidgets which is heavy for a plugin. + QStringList args = KShell::splitArgs(service->exec()); + if (!args.isEmpty()) { + QString program = args.takeFirst(); + QProcess::startDetached(program, args); + } + } + } else { + // Steam, Flatpak, etc. — run the launch command directly + QStringList parts = g.launchCommand.split(QLatin1Char(' ')); + if (!parts.isEmpty()) { + QString program = parts.takeFirst(); + QProcess::startDetached(program, parts); + } + } + + Q_EMIT gameLaunched(g.name); +} + +// --- XDG .desktop games --- + +void GameLauncherProvider::loadDesktopGames() +{ + const auto services = KService::allServices(); + for (const auto &service : services) { + if (service->noDisplay() || service->exec().isEmpty()) { + continue; + } + const QStringList cats = service->categories(); + bool isGame = false; + for (const auto &cat : cats) { + if (cat.compare(QLatin1String("Game"), Qt::CaseInsensitive) == 0) { + isGame = true; + break; + } + } + if (!isGame) { + continue; + } + + GameEntry entry; + entry.name = service->name(); + entry.icon = service->icon(); + entry.source = QStringLiteral("desktop"); + entry.storageId = service->storageId(); + entry.launchCommand = service->exec(); + entry.installed = true; + m_games.append(entry); + } +} + +// --- Steam library --- + +void GameLauncherProvider::loadSteamGames() +{ + // Look for Steam library folders + const QStringList steamRoots = { + QDir::homePath() + QStringLiteral("/.steam/steam"), + QDir::homePath() + QStringLiteral("/.local/share/Steam"), + // Flatpak Steam + QDir::homePath() + QStringLiteral("/.var/app/com.valvesoftware.Steam/.steam/steam"), + QDir::homePath() + QStringLiteral("/.var/app/com.valvesoftware.Steam/.local/share/Steam"), + }; + + QStringList libraryPaths; + for (const auto &root : steamRoots) { + const QString vdfPath = root + QStringLiteral("/steamapps/libraryfolders.vdf"); + QFile vdf(vdfPath); + if (!vdf.open(QIODevice::ReadOnly | QIODevice::Text)) { + continue; + } + // Simple parse: look for "path" lines + static const QRegularExpression pathRe(QStringLiteral("\"path\"\\s+\"([^\"]+)\"")); + QTextStream stream(&vdf); + while (!stream.atEnd()) { + const QString line = stream.readLine(); + auto match = pathRe.match(line); + if (match.hasMatch()) { + libraryPaths.append(match.captured(1)); + } + } + } + + // Scan each library path for appmanifest_*.acf + static const QRegularExpression nameRe(QStringLiteral("\"name\"\\s+\"([^\"]+)\"")); + static const QRegularExpression appidRe(QStringLiteral("\"appid\"\\s+\"(\\d+)\"")); + + for (const auto &libPath : std::as_const(libraryPaths)) { + QDir steamapps(libPath + QStringLiteral("/steamapps")); + if (!steamapps.exists()) { + continue; + } + const auto manifests = steamapps.entryList({QStringLiteral("appmanifest_*.acf")}, QDir::Files); + for (const auto &manifest : manifests) { + QFile f(steamapps.filePath(manifest)); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + continue; + } + QString appName; + QString appId; + QTextStream ts(&f); + while (!ts.atEnd()) { + const QString line = ts.readLine(); + if (appName.isEmpty()) { + auto m = nameRe.match(line); + if (m.hasMatch()) { + appName = m.captured(1); + } + } + if (appId.isEmpty()) { + auto m = appidRe.match(line); + if (m.hasMatch()) { + appId = m.captured(1); + } + } + if (!appName.isEmpty() && !appId.isEmpty()) { + break; + } + } + + if (appName.isEmpty() || appId.isEmpty()) { + continue; + } + + // Skip Steamworks Common Redistributables and Proton/tools + if (appId == QLatin1String("228980")) { + continue; + } + + GameEntry entry; + entry.name = appName; + entry.icon = QStringLiteral("steam"); + entry.source = QStringLiteral("steam"); + entry.storageId = QStringLiteral("steam://rungameid/") + appId; + entry.launchCommand = QStringLiteral("steam steam://rungameid/") + appId; + entry.installed = true; + + // Check for grid artwork + for (const auto &root : steamRoots) { + const QString gridDir = root + QStringLiteral("/appcache/librarycache/") + appId; + const QStringList artSuffixes = { + QStringLiteral("_library_600x900.jpg"), + QStringLiteral("_header.jpg"), + }; + for (const auto &suffix : artSuffixes) { + const QString artPath = gridDir + suffix; + if (QFile::exists(artPath)) { + entry.artwork = artPath; + break; + } + } + if (!entry.artwork.isEmpty()) { + break; + } + } + + m_games.append(entry); + } + } +} + +// --- Flatpak games (non-Steam) --- + +void GameLauncherProvider::loadFlatpakGames() +{ + // Flatpak games that export .desktop files with Game category + // are already picked up by loadDesktopGames() via KService. + // This method is a hook for future Flatpak-specific enrichment + // (e.g. querying flatpak metadata for games that don't set + // the Game category properly). +} diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h new file mode 100644 index 00000000..b570bc81 --- /dev/null +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#include +#include +#include +#include + +class GameLauncherProvider : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + +public: + explicit GameLauncherProvider(QObject *parent = nullptr); + + enum Roles { + NameRole = Qt::UserRole + 1, + IconRole, + SourceRole, // "desktop", "steam", "flatpak" + StorageIdRole, // .desktop file name or launch URI + LaunchCommandRole, + ArtworkRole, // path to banner/grid image (empty if none) + InstalledRole, + }; + Q_ENUM(Roles) + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + int count() const; + bool loading() const; + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void launch(int index); + +Q_SIGNALS: + void countChanged(); + void loadingChanged(); + void gameLaunched(const QString &name); + +private: + struct GameEntry { + QString name; + QString icon; + QString source; + QString storageId; + QString launchCommand; + QString artwork; + bool installed = true; + }; + + void loadDesktopGames(); + void loadSteamGames(); + void loadFlatpakGames(); + + QList m_games; + bool m_loading = false; +}; diff --git a/components/gamingshellplugin/gamepaddevice.cpp b/components/gamingshellplugin/gamepaddevice.cpp new file mode 100644 index 00000000..95208682 --- /dev/null +++ b/components/gamingshellplugin/gamepaddevice.cpp @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "gamepaddevice.h" + +#include +#include +#include + +GamepadDevice::GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent) + : QObject(parent) + , m_pad(pad) + , m_id(id) +{ + refreshBattery(); +} + +GamepadDevice::~GamepadDevice() +{ + if (m_pad) { + SDL_CloseGamepad(m_pad); + m_pad = nullptr; + } +} + +int GamepadDevice::deviceId() const +{ + return m_id; +} + +QString GamepadDevice::name() const +{ + if (!m_pad) { + return {}; + } + const char *n = SDL_GetGamepadName(m_pad); + return n ? QString::fromUtf8(n) : QString(); +} + +QString GamepadDevice::type() const +{ + if (!m_pad) { + return QStringLiteral("unknown"); + } + switch (SDL_GetGamepadType(m_pad)) { + case SDL_GAMEPAD_TYPE_XBOX360: + case SDL_GAMEPAD_TYPE_XBOXONE: + return QStringLiteral("xbox"); + case SDL_GAMEPAD_TYPE_PS3: + case SDL_GAMEPAD_TYPE_PS4: + case SDL_GAMEPAD_TYPE_PS5: + return QStringLiteral("playstation"); + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR: + return QStringLiteral("nintendo"); + default: + return QStringLiteral("generic"); + } +} + +int GamepadDevice::batteryPercent() const +{ + return m_batteryPercent; +} + +bool GamepadDevice::hasRumble() const +{ + if (!m_pad) { + return false; + } + SDL_PropertiesID props = SDL_GetGamepadProperties(m_pad); + return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false); +} + +bool GamepadDevice::hasLED() const +{ + if (!m_pad) { + return false; + } + SDL_PropertiesID props = SDL_GetGamepadProperties(m_pad); + return SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false); +} + +int GamepadDevice::playerIndex() const +{ + if (!m_pad) { + return -1; + } + return SDL_GetGamepadPlayerIndex(m_pad); +} + +void GamepadDevice::setPlayerIndex(int index) +{ + if (!m_pad) { + return; + } + SDL_SetGamepadPlayerIndex(m_pad, index); + Q_EMIT playerIndexChanged(); +} + +bool GamepadDevice::rumble(int lowFreqMs, int highFreqMs, int durationMs) +{ + if (!m_pad) { + return false; + } + return SDL_RumbleGamepad(m_pad, static_cast(lowFreqMs), static_cast(highFreqMs), static_cast(durationMs)); +} + +bool GamepadDevice::setLED(int r, int g, int b) +{ + if (!m_pad) { + return false; + } + return SDL_SetGamepadLED(m_pad, static_cast(r), static_cast(g), static_cast(b)); +} + +SDL_Gamepad *GamepadDevice::sdlGamepad() const +{ + return m_pad; +} + +void GamepadDevice::refreshBattery() +{ + if (!m_pad) { + return; + } + int pct = -1; + SDL_GetGamepadPowerInfo(m_pad, &pct); + if (pct != m_batteryPercent) { + m_batteryPercent = pct; + Q_EMIT batteryPercentChanged(); + } +} diff --git a/components/gamingshellplugin/gamepaddevice.h b/components/gamingshellplugin/gamepaddevice.h new file mode 100644 index 00000000..637ace61 --- /dev/null +++ b/components/gamingshellplugin/gamepaddevice.h @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#include +#include +#include + +struct SDL_Gamepad; + +class GamepadDevice : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + + Q_PROPERTY(int deviceId READ deviceId CONSTANT) + Q_PROPERTY(QString name READ name CONSTANT) + 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 hasLED READ hasLED CONSTANT) + Q_PROPERTY(int playerIndex READ playerIndex WRITE setPlayerIndex NOTIFY playerIndexChanged) + +public: + explicit GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent = nullptr); + ~GamepadDevice() override; + + int deviceId() const; + QString name() const; + QString type() const; + int batteryPercent() const; + bool hasRumble() const; + bool hasLED() const; + int playerIndex() const; + void setPlayerIndex(int index); + + Q_INVOKABLE bool rumble(int lowFreqMs, int highFreqMs, int durationMs); + Q_INVOKABLE bool setLED(int r, int g, int b); + + SDL_Gamepad *sdlGamepad() const; + void refreshBattery(); + +Q_SIGNALS: + void batteryPercentChanged(); + void playerIndexChanged(); + +private: + SDL_Gamepad *m_pad = nullptr; + int m_id = 0; + int m_batteryPercent = -1; +}; diff --git a/components/gamingshellplugin/gamepadmanager.cpp b/components/gamingshellplugin/gamepadmanager.cpp new file mode 100644 index 00000000..c3958558 --- /dev/null +++ b/components/gamingshellplugin/gamepadmanager.cpp @@ -0,0 +1,261 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "gamepadmanager.h" +#include "gamepaddevice.h" + +#include +#include + +#include + +GamepadManager::GamepadManager(QObject *parent) + : QAbstractListModel(parent) +{ + m_pollTimer.setInterval(16); // ~60 Hz + connect(&m_pollTimer, &QTimer::timeout, this, &GamepadManager::poll); +} + +GamepadManager::~GamepadManager() +{ + stop(); +} + +// --- QAbstractListModel --- + +int GamepadManager::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_gamepads.size(); +} + +QVariant GamepadManager::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_gamepads.size()) { + return {}; + } + auto *dev = m_gamepads.at(index.row()); + switch (role) { + case DeviceRole: + return QVariant::fromValue(dev); + case NameRole: + return dev->name(); + case TypeRole: + return dev->type(); + case BatteryRole: + return dev->batteryPercent(); + } + return {}; +} + +QHash GamepadManager::roleNames() const +{ + return { + {DeviceRole, "device"}, + {NameRole, "name"}, + {TypeRole, "type"}, + {BatteryRole, "battery"}, + }; +} + +// --- Properties --- + +bool GamepadManager::active() const +{ + return m_active; +} + +void GamepadManager::setActive(bool active) +{ + if (m_active == active) { + return; + } + m_active = active; + if (active) { + start(); + } else { + stop(); + } + Q_EMIT activeChanged(); +} + +int GamepadManager::count() const +{ + return m_gamepads.size(); +} + +bool GamepadManager::hasGamepad() const +{ + return !m_gamepads.isEmpty(); +} + +GamepadDevice *GamepadManager::primaryGamepad() const +{ + return m_gamepads.isEmpty() ? nullptr : m_gamepads.first(); +} + +GamepadDevice *GamepadManager::gamepadAt(int index) const +{ + if (index < 0 || index >= m_gamepads.size()) { + return nullptr; + } + return m_gamepads.at(index); +} + +// --- Lifecycle --- + +void GamepadManager::start() +{ + if (m_sdlInitialized) { + return; + } + if (!SDL_Init(SDL_INIT_GAMEPAD)) { + qWarning() << "GamepadManager: SDL_Init failed:" << SDL_GetError(); + return; + } + m_sdlInitialized = true; + + // Enumerate already-connected gamepads + int count = 0; + SDL_JoystickID *ids = SDL_GetGamepads(&count); + if (ids) { + for (int i = 0; i < count; ++i) { + addGamepad(ids[i]); + } + SDL_free(ids); + } + + m_pollTimer.start(); +} + +void GamepadManager::stop() +{ + m_pollTimer.stop(); + + if (!m_gamepads.isEmpty()) { + beginResetModel(); + qDeleteAll(m_gamepads); + m_gamepads.clear(); + endResetModel(); + Q_EMIT countChanged(); + Q_EMIT primaryGamepadChanged(); + } + + if (m_sdlInitialized) { + SDL_QuitSubSystem(SDL_INIT_GAMEPAD); + m_sdlInitialized = false; + } +} + +// --- Event polling --- + +void GamepadManager::poll() +{ + SDL_Event event; + while (SDL_PollEvent(&event)) { + switch (event.type) { + case SDL_EVENT_GAMEPAD_ADDED: + addGamepad(event.gdevice.which); + break; + + case SDL_EVENT_GAMEPAD_REMOVED: + removeGamepad(event.gdevice.which); + break; + + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: { + int idx = indexForInstanceId(event.gbutton.which); + if (idx >= 0) { + Q_EMIT buttonPressed(event.gbutton.button, idx); + } + break; + } + + case SDL_EVENT_GAMEPAD_BUTTON_UP: { + int idx = indexForInstanceId(event.gbutton.which); + if (idx >= 0) { + Q_EMIT buttonReleased(event.gbutton.button, idx); + } + break; + } + + case SDL_EVENT_GAMEPAD_AXIS_MOTION: { + int idx = indexForInstanceId(event.gaxis.which); + if (idx >= 0) { + float normalized = static_cast(event.gaxis.value) / 32767.0f; + Q_EMIT axisChanged(event.gaxis.axis, normalized, idx); + } + break; + } + + default: + break; + } + } + + // Refresh battery state periodically (every ~5 seconds = 300 frames) + static int batteryCounter = 0; + if (++batteryCounter >= 300) { + batteryCounter = 0; + for (auto *dev : std::as_const(m_gamepads)) { + dev->refreshBattery(); + } + } +} + +// --- Hotplug --- + +void GamepadManager::addGamepad(int instanceId) +{ + // Already tracked? + if (indexForInstanceId(instanceId) >= 0) { + return; + } + + SDL_Gamepad *pad = SDL_OpenGamepad(instanceId); + if (!pad) { + qWarning() << "GamepadManager: failed to open gamepad" << instanceId << SDL_GetError(); + return; + } + + auto *device = new GamepadDevice(pad, instanceId, this); + int row = m_gamepads.size(); + beginInsertRows(QModelIndex(), row, row); + m_gamepads.append(device); + endInsertRows(); + + Q_EMIT countChanged(); + if (m_gamepads.size() == 1) { + Q_EMIT primaryGamepadChanged(); + } + + qDebug() << "GamepadManager: connected" << device->name() << "(" << device->type() << ")"; +} + +void GamepadManager::removeGamepad(int instanceId) +{ + int idx = indexForInstanceId(instanceId); + if (idx < 0) { + return; + } + + beginRemoveRows(QModelIndex(), idx, idx); + auto *dev = m_gamepads.takeAt(idx); + endRemoveRows(); + + qDebug() << "GamepadManager: disconnected" << dev->name(); + dev->deleteLater(); + + Q_EMIT countChanged(); + if (idx == 0) { + Q_EMIT primaryGamepadChanged(); + } +} + +int GamepadManager::indexForInstanceId(int instanceId) const +{ + for (int i = 0; i < m_gamepads.size(); ++i) { + if (m_gamepads.at(i)->deviceId() == instanceId) { + return i; + } + } + return -1; +} diff --git a/components/gamingshellplugin/gamepadmanager.h b/components/gamingshellplugin/gamepadmanager.h new file mode 100644 index 00000000..eaab5fa2 --- /dev/null +++ b/components/gamingshellplugin/gamepadmanager.h @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#include +#include +#include + +class GamepadDevice; + +class GamepadManager : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool active READ active WRITE setActive NOTIFY activeChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(GamepadDevice *primaryGamepad READ primaryGamepad NOTIFY primaryGamepadChanged) + Q_PROPERTY(bool hasGamepad READ hasGamepad NOTIFY countChanged) + +public: + explicit GamepadManager(QObject *parent = nullptr); + ~GamepadManager() override; + + enum Roles { + DeviceRole = Qt::UserRole + 1, + NameRole, + TypeRole, + BatteryRole, + }; + Q_ENUM(Roles) + + // Buttons matching SDL_GamepadButton, re-exported for QML + enum Button { + ButtonA, + ButtonB, + ButtonX, + ButtonY, + ButtonBack, + ButtonGuide, + ButtonStart, + ButtonLeftStick, + ButtonRightStick, + ButtonLeftShoulder, + ButtonRightShoulder, + ButtonDPadUp, + ButtonDPadDown, + ButtonDPadLeft, + ButtonDPadRight, + ButtonMisc1, + }; + Q_ENUM(Button) + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + bool active() const; + void setActive(bool active); + int count() const; + bool hasGamepad() const; + GamepadDevice *primaryGamepad() const; + + Q_INVOKABLE GamepadDevice *gamepadAt(int index) const; + +Q_SIGNALS: + void activeChanged(); + void countChanged(); + void primaryGamepadChanged(); + + void buttonPressed(int button, int gamepadIndex); + void buttonReleased(int button, int gamepadIndex); + void axisChanged(int axis, float value, int gamepadIndex); + +private: + void start(); + void stop(); + void poll(); + void addGamepad(int instanceId); + void removeGamepad(int instanceId); + int indexForInstanceId(int instanceId) const; + + bool m_active = false; + bool m_sdlInitialized = false; + QTimer m_pollTimer; + QList m_gamepads; +}; diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index 5caf136e..e19b2de5 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -11,6 +11,7 @@ import org.kde.plasma.components 3.0 as PC3 import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell.state as MobileShellState import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell import org.kde.layershell 1.0 as LayerShell import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio @@ -48,7 +49,7 @@ Window { onVisibleChanged: { if (visible) { - root.folio.ApplicationListSearchModel.categoryFilter = "Game" + GamingShell.GameLauncherProvider.refresh() if (runningGames.hasTasks) { runningGames.focusFirstTask() } else { @@ -57,6 +58,53 @@ Window { } } + // Translate gamepad buttons to focus navigation + Connections { + target: GamingShell.GamepadManager + enabled: root.visible + + function onButtonPressed(button, gamepadIndex) { + switch (button) { + case GamingShell.GamepadManager.ButtonDPadUp: + if (grid.activeFocus) { + if (grid.currentIndex < grid.columns && runningGames.hasTasks) { + runningGames.focusFirstTask() + } else { + grid.moveCurrentIndexUp() + } + } + break + case GamingShell.GamepadManager.ButtonDPadDown: + if (taskList.activeFocus || runningGames.activeFocus) { + grid.forceActiveFocus() + } else if (grid.activeFocus) { + grid.moveCurrentIndexDown() + } + break + case GamingShell.GamepadManager.ButtonDPadLeft: + if (grid.activeFocus) grid.moveCurrentIndexLeft() + break + case GamingShell.GamepadManager.ButtonDPadRight: + if (grid.activeFocus) grid.moveCurrentIndexRight() + break + case GamingShell.GamepadManager.ButtonA: + if (grid.activeFocus && grid.currentItem) { + GamingShell.GameLauncherProvider.launch(grid.currentIndex) + root.gameStarted() + } else if (taskList.activeFocus && taskList.currentItem) { + taskList.currentItem.activate() + } + break + case GamingShell.GamepadManager.ButtonB: + root.dismissRequested() + break + case GamingShell.GamepadManager.ButtonY: + root.requestExitGamingMode() + break + } + } + } + Rectangle { anchors.fill: parent Kirigami.Theme.inherit: false @@ -119,11 +167,7 @@ Window { Layout.fillWidth: true Layout.fillHeight: true - // Filter the app list to the Games XDG category. - // ApplicationListSearchModel.categoryFilter is declared in - // applicationlistmodel.h and filters on the CategoriesRole of - // ApplicationListModel. - model: root.folio.ApplicationListSearchModel + model: GamingShell.GameLauncherProvider readonly property real minCellSize: Kirigami.Units.gridUnit * 7 readonly property int columns: Math.max(2, Math.floor(width / minCellSize)) @@ -147,23 +191,129 @@ Window { } } - Keys.onReturnPressed: currentItem && currentItem.launch() - Keys.onEnterPressed: currentItem && currentItem.launch() + Keys.onReturnPressed: { + if (currentIndex >= 0) { + GamingShell.GameLauncherProvider.launch(currentIndex) + root.gameStarted() + } + } + Keys.onEnterPressed: Keys.onReturnPressed(event) Keys.onEscapePressed: root.dismissRequested() delegate: Item { width: grid.cellWidth height: grid.cellHeight - GameTile { + required property int index + required property string name + required property string icon + required property string source + + QQC2.ItemDelegate { anchors.fill: parent - folio: root.folio - application: model.delegate ? model.delegate.application : null - isCurrent: GridView.isCurrentItem && grid.activeFocus - onLaunchRequested: root.gameStarted() + + readonly property bool isCurrent: GridView.isCurrentItem && grid.activeFocus + + background: Rectangle { + Kirigami.Theme.colorSet: Kirigami.Theme.Button + color: parent.isCurrent + ? Kirigami.Theme.highlightColor + : (parent.hovered ? Kirigami.Theme.hoverColor : "transparent") + radius: Kirigami.Units.cornerRadius + Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } } + } + + contentItem: ColumnLayout { + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.huge + implicitHeight: Kirigami.Units.iconSizes.huge + source: icon + + scale: parent.parent.isCurrent ? 1.08 : 1.0 + Behavior on scale { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } + } + } + + PC3.Label { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + text: name + maximumLineCount: 2 + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + color: parent.parent.isCurrent + ? Kirigami.Theme.highlightedTextColor + : Kirigami.Theme.textColor + } + + // Source badge + PC3.Label { + Layout.alignment: Qt.AlignHCenter + text: source === "steam" ? "Steam" + : source === "flatpak" ? "Flatpak" + : "" + visible: source !== "desktop" + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 + opacity: 0.6 + } + } + + onClicked: { + GamingShell.GameLauncherProvider.launch(index) + root.gameStarted() + } } } } + + // ---- controller status bar ---- + RowLayout { + Layout.fillWidth: true + visible: GamingShell.GamepadManager.hasGamepad + spacing: Kirigami.Units.largeSpacing + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + source: "input-gaming" + } + + Repeater { + model: GamingShell.GamepadManager + + RowLayout { + spacing: Kirigami.Units.smallSpacing + required property string name + required property int battery + required property string type + + PC3.Label { + text: name + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + } + PC3.Label { + text: battery >= 0 ? battery + "%" : "" + visible: battery >= 0 + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + opacity: 0.7 + } + } + } + + Item { Layout.fillWidth: true } + + // Gamepad legend + PC3.Label { + text: i18n("A: Select B: Back Y: Exit") + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 + opacity: 0.5 + } + } } } diff --git a/containments/homescreens/folio/qml/gaming/GamingHUD.qml b/containments/homescreens/folio/qml/gaming/GamingHUD.qml index 6c04e3b8..b39fae4a 100644 --- a/containments/homescreens/folio/qml/gaming/GamingHUD.qml +++ b/containments/homescreens/folio/qml/gaming/GamingHUD.qml @@ -7,6 +7,7 @@ import QtQuick.Window import org.kde.kirigami as Kirigami import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell import org.kde.layershell 1.0 as LayerShell Window { @@ -43,14 +44,31 @@ Window { radius: height / 2 color: Qt.rgba(0, 0, 0, 0.55) - QQC2.ToolButton { + Row { anchors.centerIn: parent - icon.name: "input-gaming" - icon.color: "white" - display: QQC2.AbstractButton.IconOnly - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.text: i18n("Game Center") - onClicked: root.openRequested() + spacing: Kirigami.Units.smallSpacing + + QQC2.ToolButton { + icon.name: "input-gaming" + icon.color: "white" + display: QQC2.AbstractButton.IconOnly + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: i18n("Game Center") + onClicked: root.openRequested() + } + + // Show primary gamepad battery when connected + QQC2.Label { + visible: GamingShell.GamepadManager.hasGamepad + && GamingShell.GamepadManager.primaryGamepad + && GamingShell.GamepadManager.primaryGamepad.batteryPercent >= 0 + text: GamingShell.GamepadManager.primaryGamepad + ? GamingShell.GamepadManager.primaryGamepad.batteryPercent + "%" + : "" + color: "white" + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8 + anchors.verticalCenter: parent.verticalCenter + } } } } diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index 15e7dabd..d1b7ffbc 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -16,6 +16,7 @@ import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell.state as MobileShellState import org.kde.plasma.private.mobileshell.windowplugin as WindowPlugin import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell import org.kde.layershell 1.0 as LayerShell import org.kde.plasma.private.sessions 2.0 @@ -49,11 +50,24 @@ ContainmentItem { target: ShellSettings.Settings function onGamingModeEnabledChanged() { root.gameCenterOpen = ShellSettings.Settings.gamingModeEnabled + GamingShell.GamepadManager.active = ShellSettings.Settings.gamingModeEnabled + } + } + + // Gamepad Guide button toggles Game Center overlay + Connections { + target: GamingShell.GamepadManager + enabled: ShellSettings.Settings.gamingModeEnabled + function onButtonPressed(button, gamepadIndex) { + if (button === GamingShell.GamepadManager.ButtonGuide) { + root.gameCenterOpen = !root.gameCenterOpen + } } } Component.onCompleted: { root.gameCenterOpen = ShellSettings.Settings.gamingModeEnabled + GamingShell.GamepadManager.active = ShellSettings.Settings.gamingModeEnabled folio.FolioSettings.load(); folio.FavouritesModel.load(); folio.PageListModel.load(); @@ -690,12 +704,6 @@ ContainmentItem { gameCenterHintTimer.restart() } } - - onVisibleChanged: { - if (!visible) { - folio.ApplicationListSearchModel.categoryFilter = "" - } - } } // Small persistent button at the top-right corner of the screen that lets From 976c770af43a3c58578ef81e57d7412c89efbfb6 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 10:03:41 +0200 Subject: [PATCH 07/26] Add cover art, search, filters, and recently played Grid tiles now show Steam library artwork when available, falling back to icon+label for games without cover art. Cell proportions adjusted to 2:3 for portrait covers. Search bar filters the library by name. Source tabs filter by All/Steam/Desktop. Both properties live in C++ so QML just binds filterString and sourceFilter. "Continue Playing" row shows the last 5 launched games with artwork, persisted across sessions via plasmamobilerc. Remove orphaned GameTile.qml (replaced by inline delegate). --- components/gamingshellplugin/CMakeLists.txt | 1 + .../gamelauncherprovider.cpp | 166 +++++++++++- .../gamingshellplugin/gamelauncherprovider.h | 23 +- containments/homescreens/folio/CMakeLists.txt | 1 - .../folio/qml/gaming/GameCenterOverlay.qml | 248 +++++++++++++++--- .../homescreens/folio/qml/gaming/GameTile.qml | 92 ------- 6 files changed, 393 insertions(+), 138 deletions(-) delete mode 100644 containments/homescreens/folio/qml/gaming/GameTile.qml diff --git a/components/gamingshellplugin/CMakeLists.txt b/components/gamingshellplugin/CMakeLists.txt index 0e823be6..803d5d99 100644 --- a/components/gamingshellplugin/CMakeLists.txt +++ b/components/gamingshellplugin/CMakeLists.txt @@ -18,6 +18,7 @@ target_link_libraries(gamingshellplugin PRIVATE KF6::I18n KF6::Service KF6::CoreAddons + KF6::ConfigCore SDL3::SDL3 ) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index 1b1fafb5..287c7c10 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -3,7 +3,9 @@ #include "gamelauncherprovider.h" +#include #include +#include #include #include @@ -15,8 +17,11 @@ #include #include +static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed"); + GameLauncherProvider::GameLauncherProvider(QObject *parent) : QAbstractListModel(parent) + , m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc"))) { connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh); refresh(); @@ -80,23 +85,22 @@ void GameLauncherProvider::refresh() m_loading = true; Q_EMIT loadingChanged(); - beginResetModel(); - m_games.clear(); + m_allGames.clear(); loadDesktopGames(); loadSteamGames(); loadFlatpakGames(); + loadRecentTimestamps(); // Sort alphabetically, case-insensitive - std::sort(m_games.begin(), m_games.end(), [](const GameEntry &a, const GameEntry &b) { + std::sort(m_allGames.begin(), m_allGames.end(), [](const GameEntry &a, const GameEntry &b) { return a.name.compare(b.name, Qt::CaseInsensitive) < 0; }); - endResetModel(); + applyFilter(); m_loading = false; Q_EMIT loadingChanged(); - Q_EMIT countChanged(); } void GameLauncherProvider::launch(int index) @@ -128,6 +132,54 @@ void GameLauncherProvider::launch(int index) } Q_EMIT gameLaunched(g.name); + + // Record timestamp for "recently played" + saveRecentTimestamp(g.storageId, QDateTime::currentDateTime()); + + // Update the in-memory entry so recentGames() picks it up immediately + for (auto &entry : m_allGames) { + if (entry.storageId == g.storageId) { + entry.lastPlayed = QDateTime::currentDateTime(); + break; + } + } +} + +void GameLauncherProvider::launchByStorageId(const QString &storageId) +{ + for (int i = 0; i < m_allGames.size(); ++i) { + if (m_allGames.at(i).storageId == storageId) { + // Find the index in the filtered model, or launch from allGames directly + for (int j = 0; j < m_games.size(); ++j) { + if (m_games.at(j).storageId == storageId) { + launch(j); + return; + } + } + // Not in filtered view — launch directly from allGames + const auto &g = m_allGames.at(i); + if (g.source == QLatin1String("desktop")) { + auto service = KService::serviceByStorageId(g.storageId); + if (service) { + QStringList args = KShell::splitArgs(service->exec()); + if (!args.isEmpty()) { + QString program = args.takeFirst(); + QProcess::startDetached(program, args); + } + } + } else { + QStringList parts = g.launchCommand.split(QLatin1Char(' ')); + if (!parts.isEmpty()) { + QString program = parts.takeFirst(); + QProcess::startDetached(program, parts); + } + } + Q_EMIT gameLaunched(g.name); + saveRecentTimestamp(g.storageId, QDateTime::currentDateTime()); + m_allGames[i].lastPlayed = QDateTime::currentDateTime(); + return; + } + } } // --- XDG .desktop games --- @@ -158,7 +210,7 @@ void GameLauncherProvider::loadDesktopGames() entry.storageId = service->storageId(); entry.launchCommand = service->exec(); entry.installed = true; - m_games.append(entry); + m_allGames.append(entry); } } @@ -267,7 +319,7 @@ void GameLauncherProvider::loadSteamGames() } } - m_games.append(entry); + m_allGames.append(entry); } } } @@ -282,3 +334,103 @@ void GameLauncherProvider::loadFlatpakGames() // (e.g. querying flatpak metadata for games that don't set // the Game category properly). } + +QString GameLauncherProvider::filterString() const +{ + return m_filterString; +} + +void GameLauncherProvider::setFilterString(const QString &filter) +{ + if (m_filterString == filter) { + return; + } + m_filterString = filter; + Q_EMIT filterStringChanged(); + applyFilter(); +} + +QString GameLauncherProvider::sourceFilter() const +{ + return m_sourceFilter; +} + +void GameLauncherProvider::setSourceFilter(const QString &source) +{ + if (m_sourceFilter == source) { + return; + } + m_sourceFilter = source; + Q_EMIT sourceFilterChanged(); + applyFilter(); +} + +void GameLauncherProvider::applyFilter() +{ + beginResetModel(); + m_games.clear(); + + for (const auto &g : std::as_const(m_allGames)) { + if (!m_sourceFilter.isEmpty() && g.source != m_sourceFilter) { + continue; + } + if (!m_filterString.isEmpty() && !g.name.contains(m_filterString, Qt::CaseInsensitive)) { + continue; + } + m_games.append(g); + } + + endResetModel(); + Q_EMIT countChanged(); +} + +void GameLauncherProvider::loadRecentTimestamps() +{ + const KConfigGroup group(m_config, s_recentGroup); + for (auto &entry : m_allGames) { + const QString key = entry.storageId; + if (group.hasKey(key)) { + entry.lastPlayed = group.readEntry(key, QDateTime()); + } + } +} + +void GameLauncherProvider::saveRecentTimestamp(const QString &storageId, const QDateTime &when) +{ + KConfigGroup group(m_config, s_recentGroup); + group.writeEntry(storageId, when); + group.sync(); +} + +QVariantList GameLauncherProvider::recentGames(int limit) const +{ + // Gather entries that have been launched at least once + QList recent; + for (const auto &g : m_allGames) { + if (g.lastPlayed.isValid()) { + recent.append(&g); + } + } + + // Most recent first + std::sort(recent.begin(), recent.end(), [](const GameEntry *a, const GameEntry *b) { + return a->lastPlayed > b->lastPlayed; + }); + + if (recent.size() > limit) { + recent.resize(limit); + } + + QVariantList result; + result.reserve(recent.size()); + for (const auto *g : recent) { + QVariantMap map; + map[QStringLiteral("name")] = g->name; + map[QStringLiteral("icon")] = g->icon; + map[QStringLiteral("source")] = g->source; + map[QStringLiteral("storageId")] = g->storageId; + map[QStringLiteral("artwork")] = g->artwork; + result.append(map); + } + return result; +} diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index b570bc81..8fc96539 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -4,10 +4,13 @@ #pragma once #include +#include #include #include #include +#include + class GameLauncherProvider : public QAbstractListModel { Q_OBJECT @@ -16,6 +19,8 @@ class GameLauncherProvider : public QAbstractListModel Q_PROPERTY(int count READ count NOTIFY countChanged) Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged) + Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged) public: explicit GameLauncherProvider(QObject *parent = nullptr); @@ -37,13 +42,21 @@ public: int count() const; bool loading() const; + QString filterString() const; + void setFilterString(const QString &filter); + QString sourceFilter() const; + void setSourceFilter(const QString &source); Q_INVOKABLE void refresh(); Q_INVOKABLE void launch(int index); + Q_INVOKABLE void launchByStorageId(const QString &storageId); + Q_INVOKABLE QVariantList recentGames(int limit = 5) const; Q_SIGNALS: void countChanged(); void loadingChanged(); + void filterStringChanged(); + void sourceFilterChanged(); void gameLaunched(const QString &name); private: @@ -54,13 +67,21 @@ private: QString storageId; QString launchCommand; QString artwork; + QDateTime lastPlayed; bool installed = true; }; void loadDesktopGames(); void loadSteamGames(); void loadFlatpakGames(); + void loadRecentTimestamps(); + void saveRecentTimestamp(const QString &storageId, const QDateTime &when); + void applyFilter(); - QList m_games; + QList m_allGames; + QList m_games; // filtered view + QString m_filterString; + QString m_sourceFilter; // empty = all, or "desktop"/"steam"/"flatpak" + KSharedConfigPtr m_config; bool m_loading = false; }; diff --git a/containments/homescreens/folio/CMakeLists.txt b/containments/homescreens/folio/CMakeLists.txt index 4262ca97..ce9cf8e6 100644 --- a/containments/homescreens/folio/CMakeLists.txt +++ b/containments/homescreens/folio/CMakeLists.txt @@ -78,7 +78,6 @@ ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES qml/gaming/GameCenterOverlay.qml - qml/gaming/GameTile.qml qml/gaming/GamingHUD.qml qml/gaming/RunningGamesView.qml PATH gaming diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index e19b2de5..61d8436c 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -49,6 +49,8 @@ Window { onVisibleChanged: { if (visible) { + GamingShell.GameLauncherProvider.filterString = "" + GamingShell.GameLauncherProvider.sourceFilter = "" GamingShell.GameLauncherProvider.refresh() if (runningGames.hasTasks) { runningGames.focusFirstTask() @@ -155,12 +157,123 @@ Window { onMoveDownRequested: grid.forceActiveFocus() } - // ---- game grid ---- - Kirigami.Heading { - level: 2 - text: i18n("Library") + // ---- continue playing ---- + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: recentList.count > 0 && !runningGames.hasTasks + + Kirigami.Heading { + level: 2 + text: i18n("Continue Playing") + } + + ListView { + id: recentList + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 5 + orientation: ListView.Horizontal + spacing: Kirigami.Units.largeSpacing + clip: true + + model: root.visible ? GamingShell.GameLauncherProvider.recentGames(5) : [] + + delegate: QQC2.ItemDelegate { + width: Kirigami.Units.gridUnit * 7 + height: recentList.height + + required property var modelData + + readonly property bool hasArt: modelData.artwork && modelData.artwork.length > 0 + + background: Rectangle { + radius: Kirigami.Units.cornerRadius + color: parent.hovered ? Kirigami.Theme.hoverColor : "transparent" + } + + contentItem: ColumnLayout { + spacing: Kirigami.Units.smallSpacing + + Image { + Layout.fillWidth: true + Layout.fillHeight: true + source: hasArt ? "file://" + modelData.artwork : "" + fillMode: Image.PreserveAspectCrop + visible: hasArt + asynchronous: true + } + + Kirigami.Icon { + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.large + implicitHeight: Kirigami.Units.iconSizes.large + source: modelData.icon + visible: !hasArt + } + + PC3.Label { + Layout.fillWidth: true + text: modelData.name + maximumLineCount: 1 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + } + } + + onClicked: { + GamingShell.GameLauncherProvider.launchByStorageId(modelData.storageId) + root.gameStarted() + } + } + } } + // ---- search + filter ---- + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.largeSpacing + + Kirigami.SearchField { + id: searchField + Layout.fillWidth: true + placeholderText: i18n("Search games…") + onTextChanged: GamingShell.GameLauncherProvider.filterString = text + + Keys.onEscapePressed: { + if (text.length > 0) { + clear() + } else { + root.dismissRequested() + } + } + Keys.onDownPressed: grid.forceActiveFocus() + } + + QQC2.TabBar { + id: sourceFilterBar + Layout.alignment: Qt.AlignVCenter + + QQC2.TabButton { + text: i18n("All") + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "" + } + QQC2.TabButton { + text: "Steam" + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "steam" + } + QQC2.TabButton { + text: i18n("Desktop") + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "desktop" + } + } + } + + // ---- game grid ---- + GridView { id: grid @@ -169,11 +282,11 @@ Window { model: GamingShell.GameLauncherProvider - readonly property real minCellSize: Kirigami.Units.gridUnit * 7 + readonly property real minCellSize: Kirigami.Units.gridUnit * 8 readonly property int columns: Math.max(2, Math.floor(width / minCellSize)) cellWidth: Math.floor(width / columns) - cellHeight: cellWidth + Kirigami.Units.gridUnit * 2 + cellHeight: Math.floor(cellWidth * 1.5) + Kirigami.Units.gridUnit * 2 keyNavigationEnabled: true highlightMoveDuration: 0 @@ -208,9 +321,14 @@ Window { required property string name required property string icon required property string source + required property string artwork + + readonly property bool hasArt: artwork.length > 0 QQC2.ItemDelegate { anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + padding: 0 readonly property bool isCurrent: GridView.isCurrentItem && grid.activeFocus @@ -223,43 +341,99 @@ Window { Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } } } - contentItem: ColumnLayout { - spacing: Kirigami.Units.smallSpacing + contentItem: Item { + // ---- cover art tile ---- + ColumnLayout { + anchors.fill: parent + spacing: 0 + visible: hasArt - Kirigami.Icon { - Layout.alignment: Qt.AlignHCenter - implicitWidth: Kirigami.Units.iconSizes.huge - implicitHeight: Kirigami.Units.iconSizes.huge - source: icon + Image { + Layout.fillWidth: true + Layout.fillHeight: true + source: hasArt ? "file://" + artwork : "" + fillMode: Image.PreserveAspectCrop + smooth: true + asynchronous: true - scale: parent.parent.isCurrent ? 1.08 : 1.0 - Behavior on scale { - NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } + // Rounded top corners via layer + layer.enabled: true + layer.effect: Item { + Rectangle { + anchors.fill: parent + radius: Kirigami.Units.cornerRadius + } + } + + scale: parent.parent.parent.isCurrent ? 1.03 : 1.0 + Behavior on scale { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } + } + } + + // Title beneath artwork + PC3.Label { + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 2 + text: name + maximumLineCount: 1 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + leftPadding: Kirigami.Units.smallSpacing + rightPadding: Kirigami.Units.smallSpacing + color: parent.parent.parent.isCurrent + ? Kirigami.Theme.highlightedTextColor + : Kirigami.Theme.textColor } } - PC3.Label { - Layout.alignment: Qt.AlignHCenter - Layout.fillWidth: true - text: name - maximumLineCount: 2 - wrapMode: Text.Wrap - horizontalAlignment: Text.AlignHCenter - elide: Text.ElideRight - color: parent.parent.isCurrent - ? Kirigami.Theme.highlightedTextColor - : Kirigami.Theme.textColor - } + // ---- fallback icon tile ---- + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + visible: !hasArt + spacing: Kirigami.Units.smallSpacing - // Source badge - PC3.Label { - Layout.alignment: Qt.AlignHCenter - text: source === "steam" ? "Steam" - : source === "flatpak" ? "Flatpak" - : "" - visible: source !== "desktop" - font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 - opacity: 0.6 + Item { Layout.fillHeight: true } + + Kirigami.Icon { + Layout.alignment: Qt.AlignHCenter + implicitWidth: Kirigami.Units.iconSizes.huge + implicitHeight: Kirigami.Units.iconSizes.huge + source: icon + + scale: parent.parent.parent.isCurrent ? 1.08 : 1.0 + Behavior on scale { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } + } + } + + PC3.Label { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + text: name + maximumLineCount: 2 + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + color: parent.parent.parent.isCurrent + ? Kirigami.Theme.highlightedTextColor + : Kirigami.Theme.textColor + } + + // Source badge + PC3.Label { + Layout.alignment: Qt.AlignHCenter + text: source === "steam" ? "Steam" + : source === "flatpak" ? "Flatpak" + : "" + visible: source !== "desktop" + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 + opacity: 0.6 + } + + Item { Layout.fillHeight: true } } } diff --git a/containments/homescreens/folio/qml/gaming/GameTile.qml b/containments/homescreens/folio/qml/gaming/GameTile.qml deleted file mode 100644 index ef821540..00000000 --- a/containments/homescreens/folio/qml/gaming/GameTile.qml +++ /dev/null @@ -1,92 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Marco Allegretti -// SPDX-License-Identifier: EUPL-1.2 - -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls as QQC2 - -import org.kde.kirigami as Kirigami -import org.kde.plasma.private.mobileshell as MobileShell -import org.kde.plasma.private.mobileshell.state as MobileShellState -import org.kde.plasma.plasmoid - -import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio -import org.kde.plasma.components 3.0 as PC3 - -QQC2.ItemDelegate { - id: root - - required property var folio - required property Folio.FolioApplication application - required property bool isCurrent - - signal launchRequested() - - Keys.onReturnPressed: clicked() - Keys.onEnterPressed: clicked() - - onClicked: { - if (!application) return - if (application.icon && typeof application.icon === "string" && application.icon.length > 0 && !application.running) { - MobileShellState.ShellDBusClient.openAppLaunchAnimationWithPosition( - Plasmoid.screen, - application.icon, - application.name, - application.storageId, - iconItem.Kirigami.ScenePosition.x + iconItem.width / 2, - iconItem.Kirigami.ScenePosition.y + iconItem.height / 2, - Math.min(iconItem.width, iconItem.height)) - } - MobileShell.AppLaunch.launchOrActivateApp(application.storageId) - launchRequested() - } - - function launch() { - clicked() - } - - background: Rectangle { - Kirigami.Theme.colorSet: Kirigami.Theme.Button - color: root.isCurrent - ? Kirigami.Theme.highlightColor - : (root.hovered ? Kirigami.Theme.hoverColor : "transparent") - radius: Kirigami.Units.cornerRadius - - Behavior on color { - ColorAnimation { duration: Kirigami.Units.shortDuration } - } - } - - contentItem: ColumnLayout { - spacing: Kirigami.Units.smallSpacing - - Kirigami.Icon { - id: iconItem - Layout.alignment: Qt.AlignHCenter - implicitWidth: Kirigami.Units.iconSizes.huge - implicitHeight: Kirigami.Units.iconSizes.huge - source: root.application ? root.application.icon : "" - - scale: root.isCurrent ? 1.08 : 1.0 - Behavior on scale { - NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } - } - } - - PC3.Label { - id: nameLabel - Layout.alignment: Qt.AlignHCenter - Layout.fillWidth: true - text: root.application ? root.application.name : "" - maximumLineCount: 2 - wrapMode: Text.Wrap - horizontalAlignment: Text.AlignHCenter - elide: Text.ElideRight - color: root.isCurrent ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor - - Behavior on color { - ColorAnimation { duration: Kirigami.Units.shortDuration } - } - } - } -} From 2a171f3964bae955dca98e023cc24294a500d527 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 10:32:30 +0200 Subject: [PATCH 08/26] Fix overlay rendering, hover blink, and dead-end state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cover art tile used layer.effect with a plain Item instead of a ShaderEffect, which broke the overlay rendering. Replace with a clipped Rectangle wrapper. Remove Behavior on color from the grid delegate background — GridView currentIndex changes on hover caused the highlight color to flash visibly between cells. Fix dangling taskList reference in the gamepad ButtonA and DPadDown handlers (taskList lives inside RunningGamesView). Auto-reopen Game Center when the last window closes in gaming mode so the user is never stranded on a bare wallpaper. --- .../folio/qml/gaming/GameCenterOverlay.qml | 32 +++++++------------ containments/homescreens/folio/qml/main.qml | 12 +++++++ 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index 61d8436c..c3b226d2 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -77,7 +77,7 @@ Window { } break case GamingShell.GamepadManager.ButtonDPadDown: - if (taskList.activeFocus || runningGames.activeFocus) { + if (runningGames.activeFocus) { grid.forceActiveFocus() } else if (grid.activeFocus) { grid.moveCurrentIndexDown() @@ -93,8 +93,6 @@ Window { if (grid.activeFocus && grid.currentItem) { GamingShell.GameLauncherProvider.launch(grid.currentIndex) root.gameStarted() - } else if (taskList.activeFocus && taskList.currentItem) { - taskList.currentItem.activate() } break case GamingShell.GamepadManager.ButtonB: @@ -338,7 +336,6 @@ Window { ? Kirigami.Theme.highlightColor : (parent.hovered ? Kirigami.Theme.hoverColor : "transparent") radius: Kirigami.Units.cornerRadius - Behavior on color { ColorAnimation { duration: Kirigami.Units.shortDuration } } } contentItem: Item { @@ -348,26 +345,19 @@ Window { spacing: 0 visible: hasArt - Image { + Rectangle { Layout.fillWidth: true Layout.fillHeight: true - source: hasArt ? "file://" + artwork : "" - fillMode: Image.PreserveAspectCrop - smooth: true - asynchronous: true + radius: Kirigami.Units.cornerRadius + clip: true + color: "transparent" - // Rounded top corners via layer - layer.enabled: true - layer.effect: Item { - Rectangle { - anchors.fill: parent - radius: Kirigami.Units.cornerRadius - } - } - - scale: parent.parent.parent.isCurrent ? 1.03 : 1.0 - Behavior on scale { - NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } + Image { + anchors.fill: parent + source: hasArt ? "file://" + artwork : "" + fillMode: Image.PreserveAspectCrop + smooth: true + asynchronous: true } } diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index d1b7ffbc..2e0744f9 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -107,6 +107,18 @@ ContainmentItem { screenGeometry: Plasmoid.containment.screenGeometry } + // In gaming mode, reopen Game Center when the last window goes away + // so the user is never stranded on a bare wallpaper. + Connections { + target: windowMaximizedTracker + enabled: ShellSettings.Settings.gamingModeEnabled + function onShowingWindowChanged() { + if (!windowMaximizedTracker.showingWindow && !root.gameCenterOpen) { + root.gameCenterOpen = true + } + } + } + // Close app drawer when a new window appears Connections { target: WindowPlugin.WindowUtil From d58f691c7aaab10c1bc2ed850a8fcb9e424a8d1b Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 11:23:55 +0200 Subject: [PATCH 09/26] Add left-stick navigation and launch fade transition Expose SDL_GamepadAxis as an Axis enum in GamepadManager so QML can identify axis events by name. Convert left-stick deflection into repeated grid navigation events with a 150ms interval and 0.4 deadzone. First movement fires immediately when the stick crosses the threshold. Wrap game launches in a brief fade-to-black curtain (250ms) before dismissing the overlay, giving visual feedback that the launch is in progress. --- components/gamingshellplugin/gamepadmanager.h | 11 ++ .../folio/qml/gaming/GameCenterOverlay.qml | 103 ++++++++++++++++-- 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/components/gamingshellplugin/gamepadmanager.h b/components/gamingshellplugin/gamepadmanager.h index eaab5fa2..ba81fe2e 100644 --- a/components/gamingshellplugin/gamepadmanager.h +++ b/components/gamingshellplugin/gamepadmanager.h @@ -53,6 +53,17 @@ public: }; Q_ENUM(Button) + // Axes matching SDL_GamepadAxis + enum Axis { + AxisLeftX, + AxisLeftY, + AxisRightX, + AxisRightY, + AxisLeftTrigger, + AxisRightTrigger, + }; + Q_ENUM(Axis) + int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role) const override; QHash roleNames() const override; diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index c3b226d2..fc94a8ee 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -29,6 +29,16 @@ Window { exitGamingDialog.item.open() } + function launchGame(index) { + GamingShell.GameLauncherProvider.launch(index) + launchFade.restart() + } + + function launchGameByStorageId(storageId) { + GamingShell.GameLauncherProvider.launchByStorageId(storageId) + launchFade.restart() + } + width: Screen.width height: Screen.height color: "transparent" @@ -91,8 +101,7 @@ Window { break case GamingShell.GamepadManager.ButtonA: if (grid.activeFocus && grid.currentItem) { - GamingShell.GameLauncherProvider.launch(grid.currentIndex) - root.gameStarted() + root.launchGame(grid.currentIndex) } break case GamingShell.GamepadManager.ButtonB: @@ -103,6 +112,56 @@ Window { break } } + + function onAxisChanged(axis, value, gamepadIndex) { + if (axis === GamingShell.GamepadManager.AxisLeftX) { + stickState.leftX = value + } else if (axis === GamingShell.GamepadManager.AxisLeftY) { + stickState.leftY = value + } + } + } + + // Left-stick navigation state + repeat timer + QtObject { + id: stickState + property real leftX: 0 + property real leftY: 0 + readonly property real deadzone: 0.4 + } + + function navigateByStick() { + if (stickState.leftY < -stickState.deadzone) { + if (grid.activeFocus) { + if (grid.currentIndex < grid.columns && runningGames.hasTasks) { + runningGames.focusFirstTask() + } else { + grid.moveCurrentIndexUp() + } + } + } else if (stickState.leftY > stickState.deadzone) { + if (runningGames.activeFocus) { + grid.forceActiveFocus() + } else if (grid.activeFocus) { + grid.moveCurrentIndexDown() + } + } + if (stickState.leftX < -stickState.deadzone && grid.activeFocus) { + grid.moveCurrentIndexLeft() + } else if (stickState.leftX > stickState.deadzone && grid.activeFocus) { + grid.moveCurrentIndexRight() + } + } + + Timer { + id: stickNavTimer + interval: 150 + repeat: true + running: root.visible + && (Math.abs(stickState.leftX) > stickState.deadzone + || Math.abs(stickState.leftY) > stickState.deadzone) + onRunningChanged: if (running) root.navigateByStick() + onTriggered: root.navigateByStick() } Rectangle { @@ -219,10 +278,7 @@ Window { } } - onClicked: { - GamingShell.GameLauncherProvider.launchByStorageId(modelData.storageId) - root.gameStarted() - } + onClicked: root.launchGameByStorageId(modelData.storageId) } } } @@ -304,8 +360,7 @@ Window { Keys.onReturnPressed: { if (currentIndex >= 0) { - GamingShell.GameLauncherProvider.launch(currentIndex) - root.gameStarted() + root.launchGame(currentIndex) } } Keys.onEnterPressed: Keys.onReturnPressed(event) @@ -427,10 +482,7 @@ Window { } } - onClicked: { - GamingShell.GameLauncherProvider.launch(index) - root.gameStarted() - } + onClicked: root.launchGame(index) } } } @@ -481,6 +533,33 @@ Window { } } + // Launch transition: brief fade to black, then dismiss + Rectangle { + id: launchCurtain + anchors.fill: parent + color: "black" + opacity: 0 + z: 100 + + Behavior on opacity { + NumberAnimation { duration: 250; easing.type: Easing.InQuad } + } + } + + Timer { + id: launchFade + interval: 300 + onTriggered: { + launchCurtain.opacity = 0 + root.gameStarted() + } + onRunningChanged: { + if (running) { + launchCurtain.opacity = 1 + } + } + } + Loader { id: exitGamingDialog active: false From 764a8708f65bcb76da076c5ba5844e3bcb047221 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 11:24:03 +0200 Subject: [PATCH 10/26] Deduplicate games found from multiple sources When a game appears as both a Steam manifest and an XDG desktop entry, drop the desktop duplicate. Steam entries are preferred because they carry cover artwork and route through the Steam launcher for Proton compatibility. --- .../gamelauncherprovider.cpp | 23 +++++++++++++++++++ .../gamingshellplugin/gamelauncherprovider.h | 1 + 2 files changed, 24 insertions(+) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index 287c7c10..50ad3727 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -92,6 +92,10 @@ void GameLauncherProvider::refresh() loadFlatpakGames(); loadRecentTimestamps(); + // Deduplicate: when the same game appears from multiple sources, + // prefer Steam (has artwork + Proton handling) over desktop. + deduplicateGames(); + // Sort alphabetically, case-insensitive std::sort(m_allGames.begin(), m_allGames.end(), [](const GameEntry &a, const GameEntry &b) { return a.name.compare(b.name, Qt::CaseInsensitive) < 0; @@ -182,6 +186,25 @@ void GameLauncherProvider::launchByStorageId(const QString &storageId) } } +void GameLauncherProvider::deduplicateGames() +{ + // Build a set of names from Steam entries (case-insensitive). + QSet steamNames; + for (const auto &g : std::as_const(m_allGames)) { + if (g.source == QLatin1String("steam")) { + steamNames.insert(g.name.toLower()); + } + } + + // Remove desktop entries whose name matches a Steam entry. + m_allGames.erase(std::remove_if(m_allGames.begin(), + m_allGames.end(), + [&steamNames](const GameEntry &g) { + return g.source == QLatin1String("desktop") && steamNames.contains(g.name.toLower()); + }), + m_allGames.end()); +} + // --- XDG .desktop games --- void GameLauncherProvider::loadDesktopGames() diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index 8fc96539..3783ce26 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -74,6 +74,7 @@ private: void loadDesktopGames(); void loadSteamGames(); void loadFlatpakGames(); + void deduplicateGames(); void loadRecentTimestamps(); void saveRecentTimestamp(const QString &storageId, const QDateTime &when); void applyFilter(); From a1044567cdbf0cd407f1b086a4daefd23f06eea9 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 11:34:25 +0200 Subject: [PATCH 11/26] Map shoulder buttons, Start, and right stick LB/RB cycle through source filter tabs (All/Steam/Desktop). Start toggles focus between the search field and the grid. Right stick Y-axis smoothly scrolls the grid at 60 Hz with speed proportional to deflection. Update the gamepad legend to show the new bindings. --- .../folio/qml/gaming/GameCenterOverlay.qml | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index fc94a8ee..2cdbb28b 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -110,6 +110,19 @@ Window { case GamingShell.GamepadManager.ButtonY: root.requestExitGamingMode() break + case GamingShell.GamepadManager.ButtonLeftShoulder: + root.cycleSourceFilter(-1) + break + case GamingShell.GamepadManager.ButtonRightShoulder: + root.cycleSourceFilter(1) + break + case GamingShell.GamepadManager.ButtonStart: + if (searchField.activeFocus) { + grid.forceActiveFocus() + } else { + searchField.forceActiveFocus() + } + break } } @@ -118,6 +131,8 @@ Window { stickState.leftX = value } else if (axis === GamingShell.GamepadManager.AxisLeftY) { stickState.leftY = value + } else if (axis === GamingShell.GamepadManager.AxisRightY) { + stickState.rightY = value } } } @@ -127,6 +142,7 @@ Window { id: stickState property real leftX: 0 property real leftY: 0 + property real rightY: 0 readonly property real deadzone: 0.4 } @@ -164,6 +180,32 @@ Window { onTriggered: root.navigateByStick() } + // Right stick: smooth scroll the grid view + Timer { + id: stickScrollTimer + interval: 16 // ~60 Hz for smooth scrolling + repeat: true + running: root.visible && Math.abs(stickState.rightY) > stickState.deadzone + onTriggered: { + // Scale scroll speed with deflection, max ~12px per frame + grid.contentY = Math.max(grid.originY, + Math.min(grid.contentY + stickState.rightY * 12, + grid.contentHeight - grid.height)) + } + } + + // Cycle through source filter tabs (All → Steam → Desktop → All …) + readonly property var _sourceFilters: ["", "steam", "desktop"] + function cycleSourceFilter(direction) { + var current = _sourceFilters.indexOf( + GamingShell.GameLauncherProvider.sourceFilter) + if (current < 0) current = 0 + var next = (current + direction + _sourceFilters.length) + % _sourceFilters.length + GamingShell.GameLauncherProvider.sourceFilter = _sourceFilters[next] + sourceFilterBar.currentIndex = next + } + Rectangle { anchors.fill: parent Kirigami.Theme.inherit: false @@ -525,7 +567,7 @@ Window { // Gamepad legend PC3.Label { - text: i18n("A: Select B: Back Y: Exit") + text: i18n("A: Select B: Back Y: Exit LB/RB: Filter ☰: Search") font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 opacity: 0.5 } From daa8fc7d8a158c3af227d8b8395280255f8bb614 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 11:39:50 +0200 Subject: [PATCH 12/26] Extract common launch logic into launchEntry Both launch() and launchByStorageId() duplicated the process spawn, signal emission, and timestamp bookkeeping. Move it to a single private launchEntry(GameEntry&) method. --- .../gamelauncherprovider.cpp | 85 ++++++------------- .../gamingshellplugin/gamelauncherprovider.h | 1 + 2 files changed, 29 insertions(+), 57 deletions(-) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index 50ad3727..a0072565 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -112,14 +112,31 @@ void GameLauncherProvider::launch(int index) if (index < 0 || index >= m_games.size()) { return; } - const auto &g = m_games.at(index); + // Find the matching entry in m_allGames so the timestamp update persists + const QString &sid = m_games.at(index).storageId; + for (auto &entry : m_allGames) { + if (entry.storageId == sid) { + launchEntry(entry); + return; + } + } +} - if (g.source == QLatin1String("desktop")) { - // Launch via KService for proper activation tracking - auto service = KService::serviceByStorageId(g.storageId); +void GameLauncherProvider::launchByStorageId(const QString &storageId) +{ + for (auto &entry : m_allGames) { + if (entry.storageId == storageId) { + launchEntry(entry); + return; + } + } +} + +void GameLauncherProvider::launchEntry(GameEntry &entry) +{ + if (entry.source == QLatin1String("desktop")) { + auto service = KService::serviceByStorageId(entry.storageId); if (service) { - // Use QProcess to launch the exec line — KIO::ApplicationLauncherJob - // would be better but requires KIOWidgets which is heavy for a plugin. QStringList args = KShell::splitArgs(service->exec()); if (!args.isEmpty()) { QString program = args.takeFirst(); @@ -127,63 +144,17 @@ void GameLauncherProvider::launch(int index) } } } else { - // Steam, Flatpak, etc. — run the launch command directly - QStringList parts = g.launchCommand.split(QLatin1Char(' ')); + QStringList parts = entry.launchCommand.split(QLatin1Char(' ')); if (!parts.isEmpty()) { QString program = parts.takeFirst(); QProcess::startDetached(program, parts); } } - Q_EMIT gameLaunched(g.name); - - // Record timestamp for "recently played" - saveRecentTimestamp(g.storageId, QDateTime::currentDateTime()); - - // Update the in-memory entry so recentGames() picks it up immediately - for (auto &entry : m_allGames) { - if (entry.storageId == g.storageId) { - entry.lastPlayed = QDateTime::currentDateTime(); - break; - } - } -} - -void GameLauncherProvider::launchByStorageId(const QString &storageId) -{ - for (int i = 0; i < m_allGames.size(); ++i) { - if (m_allGames.at(i).storageId == storageId) { - // Find the index in the filtered model, or launch from allGames directly - for (int j = 0; j < m_games.size(); ++j) { - if (m_games.at(j).storageId == storageId) { - launch(j); - return; - } - } - // Not in filtered view — launch directly from allGames - const auto &g = m_allGames.at(i); - if (g.source == QLatin1String("desktop")) { - auto service = KService::serviceByStorageId(g.storageId); - if (service) { - QStringList args = KShell::splitArgs(service->exec()); - if (!args.isEmpty()) { - QString program = args.takeFirst(); - QProcess::startDetached(program, args); - } - } - } else { - QStringList parts = g.launchCommand.split(QLatin1Char(' ')); - if (!parts.isEmpty()) { - QString program = parts.takeFirst(); - QProcess::startDetached(program, parts); - } - } - Q_EMIT gameLaunched(g.name); - saveRecentTimestamp(g.storageId, QDateTime::currentDateTime()); - m_allGames[i].lastPlayed = QDateTime::currentDateTime(); - return; - } - } + Q_EMIT gameLaunched(entry.name); + const auto now = QDateTime::currentDateTime(); + saveRecentTimestamp(entry.storageId, now); + entry.lastPlayed = now; } void GameLauncherProvider::deduplicateGames() diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index 3783ce26..f7839594 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -78,6 +78,7 @@ private: void loadRecentTimestamps(); void saveRecentTimestamp(const QString &storageId, const QDateTime &when); void applyFilter(); + void launchEntry(GameEntry &entry); QList m_allGames; QList m_games; // filtered view From 4a9a5b2a978c4ea47275d82fdf41c973ff9d3b85 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 11:39:58 +0200 Subject: [PATCH 13/26] Add empty state, gamepad task controls, and X: Close Show a PlaceholderMessage in the grid when no games are found, with context-aware text for empty library vs no search results. Map gamepad A to activate and X to close in RunningGamesView. Update the legend bar to show X: Close when running tasks are visible. --- .../folio/qml/gaming/GameCenterOverlay.qml | 26 +++++++++++++++++-- .../folio/qml/gaming/RunningGamesView.qml | 13 ++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index 2cdbb28b..5c5c2637 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -100,10 +100,17 @@ Window { if (grid.activeFocus) grid.moveCurrentIndexRight() break case GamingShell.GamepadManager.ButtonA: - if (grid.activeFocus && grid.currentItem) { + if (runningGames.activeFocus) { + runningGames.activateCurrent() + } else if (grid.activeFocus && grid.currentItem) { root.launchGame(grid.currentIndex) } break + case GamingShell.GamepadManager.ButtonX: + if (runningGames.activeFocus) { + runningGames.closeCurrent() + } + break case GamingShell.GamepadManager.ButtonB: root.dismissRequested() break @@ -388,6 +395,19 @@ Window { highlightMoveDuration: 0 highlight: null + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + width: parent.width - Kirigami.Units.gridUnit * 4 + visible: grid.count === 0 && !GamingShell.GameLauncherProvider.loading + icon.name: "games-none" + text: searchField.text.length > 0 + ? i18n("No games match your search") + : i18n("No games found") + explanation: searchField.text.length > 0 + ? "" + : i18n("Install games or check that they have the Game category in their .desktop file") + } + onActiveFocusChanged: { if (activeFocus && count > 0 && currentIndex < 0) { currentIndex = 0 @@ -567,7 +587,9 @@ Window { // Gamepad legend PC3.Label { - text: i18n("A: Select B: Back Y: Exit LB/RB: Filter ☰: Search") + text: runningGames.hasTasks + ? i18n("A: Select X: Close B: Back Y: Exit LB/RB: Filter ☰: Search") + : i18n("A: Select B: Back Y: Exit LB/RB: Filter ☰: Search") font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 opacity: 0.5 } diff --git a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml index 97f1c113..608d080d 100644 --- a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml +++ b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml @@ -29,6 +29,19 @@ Item { taskList.forceActiveFocus() } + function activateCurrent() { + if (taskList.currentItem) { + taskList.currentItem.activate() + } + } + + function closeCurrent() { + if (taskList.currentItem) { + var idx = taskList.currentIndex + tasks.requestClose(tasks.makeModelIndex(idx)) + } + } + TaskManager.VirtualDesktopInfo { id: vdInfo } TaskManager.ActivityInfo { id: actInfo } From bcb985c6528cb18f6201dc52390ee59868dbfed6 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 20 Apr 2026 14:09:33 +0200 Subject: [PATCH 14/26] Add quick settings panel and game sources Slide-out panel with brightness, volume, Wi-Fi, Bluetooth, and airplane mode. Fully gamepad-navigable; D-pad and stick input routed to the panel while open. System status bar (clock, battery, connectivity) added to game center header. Lutris games discovered from ~/.local/share/lutris/pga.db, Heroic from ~/.config/heroic/store_cache/ JSON files. Deduplication extended to cover all launcher sources. Desktop entries launched via KIO::ApplicationLauncherJob to expand Exec field codes. Generic commands parsed with KShell::splitArgs. SQLite uses RAII cleanup guard. Clamped SDL axis, rumble, and LED values. Renamed rumble params from frequency to intensity. GamepadManager uses singleton factory. setPlayerIndex checks SDL return. FavouritesBar popup uses screen virtualX/Y for multi-monitor. --- components/gamingshellplugin/CMakeLists.txt | 3 + .../gamelauncherprovider.cpp | 200 ++++++++- .../gamingshellplugin/gamelauncherprovider.h | 2 + .../gamingshellplugin/gamepaddevice.cpp | 19 +- components/gamingshellplugin/gamepaddevice.h | 2 +- .../gamingshellplugin/gamepadmanager.cpp | 20 +- components/gamingshellplugin/gamepadmanager.h | 5 + containments/homescreens/folio/CMakeLists.txt | 1 + .../homescreens/folio/qml/FavouritesBar.qml | 12 +- .../folio/qml/gaming/GameCenterOverlay.qml | 122 +++++- .../folio/qml/gaming/GamingQuickSettings.qml | 412 ++++++++++++++++++ 11 files changed, 767 insertions(+), 31 deletions(-) create mode 100644 containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml diff --git a/components/gamingshellplugin/CMakeLists.txt b/components/gamingshellplugin/CMakeLists.txt index 803d5d99..97d75dc8 100644 --- a/components/gamingshellplugin/CMakeLists.txt +++ b/components/gamingshellplugin/CMakeLists.txt @@ -2,6 +2,7 @@ # SPDX-License-Identifier: EUPL-1.2 find_package(SDL3 REQUIRED) +find_package(Qt6 REQUIRED COMPONENTS Sql) ecm_add_qml_module(gamingshellplugin URI org.kde.plasma.private.mobileshell.gamingshellplugin GENERATE_PLUGIN_SOURCE) @@ -15,7 +16,9 @@ target_link_libraries(gamingshellplugin PRIVATE Qt::Core Qt::Qml Qt::Quick + Qt::Sql KF6::I18n + KF6::KIOGui KF6::Service KF6::CoreAddons KF6::ConfigCore diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index a0072565..91915600 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -4,6 +4,7 @@ #include "gamelauncherprovider.h" #include +#include #include #include #include @@ -12,8 +13,13 @@ #include #include #include +#include +#include +#include #include #include +#include +#include #include #include @@ -90,6 +96,8 @@ void GameLauncherProvider::refresh() loadDesktopGames(); loadSteamGames(); loadFlatpakGames(); + loadLutrisGames(); + loadHeroicGames(); loadRecentTimestamps(); // Deduplicate: when the same game appears from multiple sources, @@ -137,14 +145,14 @@ void GameLauncherProvider::launchEntry(GameEntry &entry) if (entry.source == QLatin1String("desktop")) { auto service = KService::serviceByStorageId(entry.storageId); if (service) { - QStringList args = KShell::splitArgs(service->exec()); - if (!args.isEmpty()) { - QString program = args.takeFirst(); - QProcess::startDetached(program, args); - } + auto *job = new KIO::ApplicationLauncherJob(service); + job->start(); } + } else if (entry.launchCommand.contains(QStringLiteral("://"))) { + // Protocol handler (e.g. heroic://launch/...) — open via xdg-open + QProcess::startDetached(QStringLiteral("xdg-open"), {entry.launchCommand}); } else { - QStringList parts = entry.launchCommand.split(QLatin1Char(' ')); + QStringList parts = KShell::splitArgs(entry.launchCommand); if (!parts.isEmpty()) { QString program = parts.takeFirst(); QProcess::startDetached(program, parts); @@ -159,19 +167,20 @@ void GameLauncherProvider::launchEntry(GameEntry &entry) void GameLauncherProvider::deduplicateGames() { - // Build a set of names from Steam entries (case-insensitive). - QSet steamNames; + // Build a set of names from dedicated launcher entries (case-insensitive). + // These have better artwork and metadata, so they win over plain .desktop entries. + QSet launcherNames; for (const auto &g : std::as_const(m_allGames)) { - if (g.source == QLatin1String("steam")) { - steamNames.insert(g.name.toLower()); + if (g.source == QLatin1String("steam") || g.source == QLatin1String("lutris") || g.source == QLatin1String("heroic")) { + launcherNames.insert(g.name.toLower()); } } - // Remove desktop entries whose name matches a Steam entry. + // Remove desktop entries whose name matches a launcher entry. m_allGames.erase(std::remove_if(m_allGames.begin(), m_allGames.end(), - [&steamNames](const GameEntry &g) { - return g.source == QLatin1String("desktop") && steamNames.contains(g.name.toLower()); + [&launcherNames](const GameEntry &g) { + return g.source == QLatin1String("desktop") && launcherNames.contains(g.name.toLower()); }), m_allGames.end()); } @@ -329,6 +338,171 @@ void GameLauncherProvider::loadFlatpakGames() // the Game category properly). } +// --- Lutris library (SQLite) --- + +void GameLauncherProvider::loadLutrisGames() +{ + const QString dbPath = QDir::homePath() + QStringLiteral("/.local/share/lutris/pga.db"); + if (!QFile::exists(dbPath)) { + return; + } + + // Use a unique connection name to avoid conflicting with other code. + // RAII guard ensures QSqlDatabase::removeDatabase runs on every exit path. + const QString connName = QStringLiteral("lutris_games_%1").arg(reinterpret_cast(this)); + const auto dbCleanup = qScopeGuard([&connName]() { + QSqlDatabase::removeDatabase(connName); + }); + { + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName); + db.setDatabaseName(dbPath); + db.setConnectOptions(QStringLiteral("QSQLITE_OPEN_READONLY")); + + if (!db.open()) { + qWarning() << "GameLauncherProvider: cannot open Lutris DB" << dbPath; + return; + } + + QSqlQuery query(db); + query.prepare(QStringLiteral("SELECT name, slug, runner, coverart, id FROM games WHERE installed = 1")); + + if (!query.exec()) { + qWarning() << "GameLauncherProvider: Lutris query failed"; + db.close(); + return; + } + + const QString coverBase = QDir::homePath() + QStringLiteral("/.local/share/lutris/coverart/"); + + while (query.next()) { + GameEntry entry; + entry.name = query.value(0).toString(); + const QString slug = query.value(1).toString(); + const QString runner = query.value(2).toString(); + const QString coverart = query.value(3).toString(); + const int gameId = query.value(4).toInt(); + + entry.source = QStringLiteral("lutris"); + entry.storageId = QStringLiteral("lutris:%1").arg(slug); + entry.icon = QStringLiteral("lutris"); + entry.launchCommand = QStringLiteral("lutris lutris:rungameid/%1").arg(gameId); + entry.installed = true; + + // Cover art: Lutris stores covers in ~/.local/share/lutris/coverart/ + if (!coverart.isEmpty()) { + entry.artwork = coverart; + } else { + const QString coverFile = coverBase + slug + QStringLiteral(".jpg"); + if (QFile::exists(coverFile)) { + entry.artwork = coverFile; + } + } + + m_allGames.append(entry); + } + + db.close(); + } + // dbCleanup guard handles QSqlDatabase::removeDatabase(connName) +} + +// --- Heroic Games Launcher (JSON) --- + +void GameLauncherProvider::loadHeroicGames() +{ + // Heroic stores library caches for different stores + const QString heroicBase = QDir::homePath() + QStringLiteral("/.config/heroic"); + if (!QDir(heroicBase).exists()) { + return; + } + + // Check both GOG and Epic (Legendary) library caches + const QStringList libFiles = { + heroicBase + QStringLiteral("/store_cache/gog_library.json"), + heroicBase + QStringLiteral("/store_cache/legendary_library.json"), + heroicBase + QStringLiteral("/store_cache/nile_library.json"), + }; + + for (const auto &libPath : libFiles) { + QFile libFile(libPath); + if (!libFile.open(QIODevice::ReadOnly)) { + continue; + } + + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(libFile.readAll(), &err); + if (err.error != QJsonParseError::NoError) { + qWarning() << "GameLauncherProvider: JSON parse error in" << libPath << err.errorString(); + continue; + } + + // Heroic library JSON: { "library": [ { "app_name": ..., "title": ..., ... } ] } + // or it can be a plain array at the top level + QJsonArray games; + if (doc.isArray()) { + games = doc.array(); + } else if (doc.isObject()) { + games = doc.object().value(QStringLiteral("library")).toArray(); + if (games.isEmpty()) { + games = doc.object().value(QStringLiteral("games")).toArray(); + } + } + + const bool isGog = libPath.contains(QStringLiteral("gog")); + const bool isNile = libPath.contains(QStringLiteral("nile")); + + for (const auto &val : games) { + const QJsonObject obj = val.toObject(); + const QString appName = obj.value(QStringLiteral("app_name")).toString(); + const QString title = obj.value(QStringLiteral("title")).toString(); + + if (title.isEmpty()) { + continue; + } + + // Check if installed + const auto isInstalled = obj.value(QStringLiteral("is_installed")); + if (isInstalled.isBool() && !isInstalled.toBool()) { + continue; + } + + GameEntry entry; + entry.name = title; + entry.source = QStringLiteral("heroic"); + entry.storageId = QStringLiteral("heroic:%1").arg(appName); + entry.icon = QStringLiteral("heroic"); + entry.installed = true; + + // Launch via Heroic protocol handler + if (isGog) { + entry.launchCommand = QStringLiteral("heroic://launch/gog/%1").arg(appName); + } else if (isNile) { + entry.launchCommand = QStringLiteral("heroic://launch/nile/%1").arg(appName); + } else { + entry.launchCommand = QStringLiteral("heroic://launch/legendary/%1").arg(appName); + } + + // Cover art: Heroic caches artwork + const QString artPath = obj.value(QStringLiteral("art_cover")).toString(); + if (!artPath.isEmpty() && QFile::exists(artPath)) { + entry.artwork = artPath; + } else { + // Try Heroic's thumbnail cache + const QString thumbDir = heroicBase + QStringLiteral("/images/") + appName + QStringLiteral("/"); + const QDir thumbs(thumbDir); + if (thumbs.exists()) { + const auto images = thumbs.entryList({QStringLiteral("*.jpg"), QStringLiteral("*.png"), QStringLiteral("*.webp")}, QDir::Files); + if (!images.isEmpty()) { + entry.artwork = thumbDir + images.first(); + } + } + } + + m_allGames.append(entry); + } + } +} + QString GameLauncherProvider::filterString() const { return m_filterString; diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index f7839594..56c76626 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -74,6 +74,8 @@ private: void loadDesktopGames(); void loadSteamGames(); void loadFlatpakGames(); + void loadLutrisGames(); + void loadHeroicGames(); void deduplicateGames(); void loadRecentTimestamps(); void saveRecentTimestamp(const QString &storageId, const QDateTime &when); diff --git a/components/gamingshellplugin/gamepaddevice.cpp b/components/gamingshellplugin/gamepaddevice.cpp index 95208682..d558f29e 100644 --- a/components/gamingshellplugin/gamepaddevice.cpp +++ b/components/gamingshellplugin/gamepaddevice.cpp @@ -7,6 +7,8 @@ #include #include +#include + GamepadDevice::GamepadDevice(SDL_Gamepad *pad, int id, QObject *parent) : QObject(parent) , m_pad(pad) @@ -96,16 +98,20 @@ void GamepadDevice::setPlayerIndex(int index) if (!m_pad) { return; } - SDL_SetGamepadPlayerIndex(m_pad, index); - Q_EMIT playerIndexChanged(); + if (SDL_SetGamepadPlayerIndex(m_pad, index)) { + Q_EMIT playerIndexChanged(); + } } -bool GamepadDevice::rumble(int lowFreqMs, int highFreqMs, int durationMs) +bool GamepadDevice::rumble(int lowIntensity, int highIntensity, int durationMs) { if (!m_pad) { return false; } - return SDL_RumbleGamepad(m_pad, static_cast(lowFreqMs), static_cast(highFreqMs), static_cast(durationMs)); + auto lo = static_cast(std::clamp(lowIntensity, 0, 65535)); + auto hi = static_cast(std::clamp(highIntensity, 0, 65535)); + auto dur = static_cast(std::clamp(durationMs, 0, durationMs)); + return SDL_RumbleGamepad(m_pad, lo, hi, dur); } bool GamepadDevice::setLED(int r, int g, int b) @@ -113,7 +119,10 @@ bool GamepadDevice::setLED(int r, int g, int b) if (!m_pad) { return false; } - return SDL_SetGamepadLED(m_pad, static_cast(r), static_cast(g), static_cast(b)); + auto cr = static_cast(std::clamp(r, 0, 255)); + auto cg = static_cast(std::clamp(g, 0, 255)); + auto cb = static_cast(std::clamp(b, 0, 255)); + return SDL_SetGamepadLED(m_pad, cr, cg, cb); } SDL_Gamepad *GamepadDevice::sdlGamepad() const diff --git a/components/gamingshellplugin/gamepaddevice.h b/components/gamingshellplugin/gamepaddevice.h index 637ace61..c4534f2c 100644 --- a/components/gamingshellplugin/gamepaddevice.h +++ b/components/gamingshellplugin/gamepaddevice.h @@ -36,7 +36,7 @@ public: int playerIndex() const; void setPlayerIndex(int index); - Q_INVOKABLE bool rumble(int lowFreqMs, int highFreqMs, int durationMs); + Q_INVOKABLE bool rumble(int lowIntensity, int highIntensity, int durationMs); Q_INVOKABLE bool setLED(int r, int g, int b); SDL_Gamepad *sdlGamepad() const; diff --git a/components/gamingshellplugin/gamepadmanager.cpp b/components/gamingshellplugin/gamepadmanager.cpp index c3958558..aefe0d31 100644 --- a/components/gamingshellplugin/gamepadmanager.cpp +++ b/components/gamingshellplugin/gamepadmanager.cpp @@ -8,6 +8,9 @@ #include #include +#include + +#include GamepadManager::GamepadManager(QObject *parent) : QAbstractListModel(parent) @@ -21,6 +24,16 @@ GamepadManager::~GamepadManager() stop(); } +GamepadManager *GamepadManager::create(QQmlEngine *qmlEngine, QJSEngine *jsEngine) +{ + Q_UNUSED(jsEngine) + static GamepadManager *s_instance = nullptr; + if (!s_instance) { + s_instance = new GamepadManager(qmlEngine); + } + return s_instance; +} + // --- QAbstractListModel --- int GamepadManager::rowCount(const QModelIndex &parent) const @@ -180,7 +193,7 @@ void GamepadManager::poll() case SDL_EVENT_GAMEPAD_AXIS_MOTION: { int idx = indexForInstanceId(event.gaxis.which); if (idx >= 0) { - float normalized = static_cast(event.gaxis.value) / 32767.0f; + float normalized = std::clamp(static_cast(event.gaxis.value) / 32767.0f, -1.0f, 1.0f); Q_EMIT axisChanged(event.gaxis.axis, normalized, idx); } break; @@ -192,9 +205,8 @@ void GamepadManager::poll() } // Refresh battery state periodically (every ~5 seconds = 300 frames) - static int batteryCounter = 0; - if (++batteryCounter >= 300) { - batteryCounter = 0; + if (++m_batteryCounter >= 300) { + m_batteryCounter = 0; for (auto *dev : std::as_const(m_gamepads)) { dev->refreshBattery(); } diff --git a/components/gamingshellplugin/gamepadmanager.h b/components/gamingshellplugin/gamepadmanager.h index ba81fe2e..76b2d7ba 100644 --- a/components/gamingshellplugin/gamepadmanager.h +++ b/components/gamingshellplugin/gamepadmanager.h @@ -7,6 +7,8 @@ #include #include +class QQmlEngine; +class QJSEngine; class GamepadDevice; class GamepadManager : public QAbstractListModel @@ -24,6 +26,8 @@ public: explicit GamepadManager(QObject *parent = nullptr); ~GamepadManager() override; + static GamepadManager *create(QQmlEngine *qmlEngine, QJSEngine *jsEngine); + enum Roles { DeviceRole = Qt::UserRole + 1, NameRole, @@ -95,6 +99,7 @@ private: bool m_active = false; bool m_sdlInitialized = false; + int m_batteryCounter = 0; QTimer m_pollTimer; QList m_gamepads; }; diff --git a/containments/homescreens/folio/CMakeLists.txt b/containments/homescreens/folio/CMakeLists.txt index ce9cf8e6..c27e5a96 100644 --- a/containments/homescreens/folio/CMakeLists.txt +++ b/containments/homescreens/folio/CMakeLists.txt @@ -79,6 +79,7 @@ ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES ecm_target_qml_sources(org.kde.plasma.mobile.homescreen.folio SOURCES qml/gaming/GameCenterOverlay.qml qml/gaming/GamingHUD.qml + qml/gaming/GamingQuickSettings.qml qml/gaming/RunningGamesView.qml PATH gaming ) diff --git a/containments/homescreens/folio/qml/FavouritesBar.qml b/containments/homescreens/folio/qml/FavouritesBar.qml index 485d9173..31f1be81 100644 --- a/containments/homescreens/folio/qml/FavouritesBar.qml +++ b/containments/homescreens/folio/qml/FavouritesBar.qml @@ -707,15 +707,19 @@ MouseArea { if (!targetDelegate) return 0 var delegateGlobal = targetDelegate.mapToGlobal(0, 0) var win = targetDelegate.Window.window - var scrW = win && win.screen ? win.screen.width : Screen.width - return Math.max(0, Math.min(scrW - width, delegateGlobal.x + (targetDelegate.width - width) / 2)) + var screenLeft = win && win.screen ? win.screen.virtualX : 0 + var screenRight = screenLeft + (win && win.screen ? win.screen.width : Screen.width) + var centered = delegateGlobal.x + (targetDelegate.width - width) / 2 + return Math.max(screenLeft, Math.min(screenRight - width, centered)) } y: { if (!targetDelegate) return 0 var delegateGlobal = targetDelegate.mapToGlobal(0, 0) var win = targetDelegate.Window.window - var scrH = win && win.screen ? win.screen.height : Screen.height - return Math.max(0, Math.min(scrH - height, delegateGlobal.y - height - Kirigami.Units.smallSpacing)) + var screenTop = win && win.screen ? win.screen.virtualY : 0 + var screenBottom = screenTop + (win && win.screen ? win.screen.height : Screen.height) + var above = delegateGlobal.y - height - Kirigami.Units.smallSpacing + return Math.max(screenTop, Math.min(screenBottom - height, above)) } onShowingChanged: { diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index 5c5c2637..25e52c27 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -12,7 +12,9 @@ import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell.state as MobileShellState import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell +import org.kde.plasma.private.mobileshell.screenbrightnessplugin as ScreenBrightness import org.kde.layershell 1.0 as LayerShell +import org.kde.plasma.clock import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio @@ -76,6 +78,32 @@ Window { enabled: root.visible function onButtonPressed(button, gamepadIndex) { + // Route to quick settings panel when open + if (quickSettings.opened) { + switch (button) { + case GamingShell.GamepadManager.ButtonDPadUp: + quickSettings.gamepadUp() + return + case GamingShell.GamepadManager.ButtonDPadDown: + quickSettings.gamepadDown() + return + case GamingShell.GamepadManager.ButtonDPadLeft: + quickSettings.gamepadLeft() + return + case GamingShell.GamepadManager.ButtonDPadRight: + quickSettings.gamepadRight() + return + case GamingShell.GamepadManager.ButtonA: + quickSettings.gamepadAccept() + return + case GamingShell.GamepadManager.ButtonB: + case GamingShell.GamepadManager.ButtonBack: + quickSettings.close() + return + } + return // eat all other buttons while panel is open + } + switch (button) { case GamingShell.GamepadManager.ButtonDPadUp: if (grid.activeFocus) { @@ -130,6 +158,9 @@ Window { searchField.forceActiveFocus() } break + case GamingShell.GamepadManager.ButtonBack: + quickSettings.toggle() + break } } @@ -154,6 +185,21 @@ Window { } function navigateByStick() { + // Route stick to quick settings when open + if (quickSettings.opened) { + if (stickState.leftY < -stickState.deadzone) { + quickSettings.gamepadUp() + } else if (stickState.leftY > stickState.deadzone) { + quickSettings.gamepadDown() + } + if (stickState.leftX < -stickState.deadzone) { + quickSettings.gamepadLeft() + } else if (stickState.leftX > stickState.deadzone) { + quickSettings.gamepadRight() + } + return + } + if (stickState.leftY < -stickState.deadzone) { if (grid.activeFocus) { if (grid.currentIndex < grid.columns && runningGames.hasTasks) { @@ -202,7 +248,7 @@ Window { } // Cycle through source filter tabs (All → Steam → Desktop → All …) - readonly property var _sourceFilters: ["", "steam", "desktop"] + readonly property var _sourceFilters: ["", "steam", "desktop", "lutris", "heroic"] function cycleSourceFilter(direction) { var current = _sourceFilters.indexOf( GamingShell.GameLauncherProvider.sourceFilter) @@ -238,13 +284,63 @@ Window { // ---- header ---- RowLayout { Layout.fillWidth: true + spacing: Kirigami.Units.largeSpacing Kirigami.Heading { - Layout.fillWidth: true text: i18n("Game Center") level: 1 } + Item { Layout.fillWidth: true } + + // ---- system status indicators ---- + RowLayout { + spacing: Kirigami.Units.smallSpacing + Layout.alignment: Qt.AlignVCenter + + Clock { id: wallClock } + + PC3.Label { + text: Qt.formatTime(wallClock.dateTime, + MobileShell.ShellUtil.isSystem24HourFormat ? "h:mm" : "h:mm ap") + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.9 + opacity: 0.8 + } + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + source: MobileShell.AudioInfo.icon + visible: MobileShell.AudioInfo.isVisible + opacity: 0.7 + } + + MobileShell.InternetIndicator { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + opacity: 0.7 + } + + MobileShell.BluetoothIndicator { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + opacity: 0.7 + } + + MobileShell.BatteryIndicator { + textPixelSize: Kirigami.Units.gridUnit * 0.55 + opacity: 0.7 + } + } + + // ---- quick settings button ---- + QQC2.ToolButton { + icon.name: "configure" + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: i18n("Quick Settings") + onClicked: quickSettings.open() + } + QQC2.ToolButton { icon.name: "window-close" text: i18n("Exit Gaming Mode") @@ -372,6 +468,16 @@ Window { width: implicitWidth onClicked: GamingShell.GameLauncherProvider.sourceFilter = "desktop" } + QQC2.TabButton { + text: "Lutris" + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "lutris" + } + QQC2.TabButton { + text: "Heroic" + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "heroic" + } } } @@ -534,6 +640,8 @@ Window { Layout.alignment: Qt.AlignHCenter text: source === "steam" ? "Steam" : source === "flatpak" ? "Flatpak" + : source === "lutris" ? "Lutris" + : source === "heroic" ? "Heroic" : "" visible: source !== "desktop" font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 @@ -588,8 +696,8 @@ Window { // Gamepad legend PC3.Label { text: runningGames.hasTasks - ? i18n("A: Select X: Close B: Back Y: Exit LB/RB: Filter ☰: Search") - : i18n("A: Select B: Back Y: Exit LB/RB: Filter ☰: Search") + ? i18n("A: Select X: Close B: Back Y: Exit ⊞: Settings ☰: Search") + : i18n("A: Select B: Back Y: Exit LB/RB: Filter ⊞: Settings ☰: Search") font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 opacity: 0.5 } @@ -597,6 +705,12 @@ Window { } } + // Quick settings slide-out panel + GamingQuickSettings { + id: quickSettings + z: 50 + } + // Launch transition: brief fade to black, then dismiss Rectangle { id: launchCurtain diff --git a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml new file mode 100644 index 00000000..937a694a --- /dev/null +++ b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml @@ -0,0 +1,412 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 + +import org.kde.kirigami as Kirigami +import org.kde.plasma.components 3.0 as PC3 +import org.kde.plasma.private.mobileshell as MobileShell +import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell +import org.kde.plasma.private.mobileshell.screenbrightnessplugin as ScreenBrightness +import org.kde.plasma.private.volume +import org.kde.plasma.networkmanagement as PlasmaNM +import org.kde.bluezqt 1.0 as BluezQt + +Item { + id: root + anchors.fill: parent + + property bool opened: false + + // Focusable controls for gamepad navigation + property var _controls: [] + property int _focusIndex: 0 + + function _buildControlsList() { + var list = [] + if (screenBrightness.brightnessAvailable) list.push(brightnessSlider) + if (PreferredDevice.sink) list.push(volumeSlider) + list.push(wifiSwitch) + list.push(btSwitch) + list.push(airplaneSwitch) + _controls = list + } + + function open() { + opened = true + _buildControlsList() + _focusIndex = 0 + _highlightCurrent() + } + function close() { + opened = false + } + function toggle() { + if (opened) close(); else open() + } + + function _highlightCurrent() { + if (_controls.length > 0 && _focusIndex >= 0 && _focusIndex < _controls.length) { + _controls[_focusIndex].forceActiveFocus() + } + } + + // Gamepad input handlers called from GameCenterOverlay + function gamepadUp() { + if (_focusIndex > 0) { + _focusIndex-- + _highlightCurrent() + } + } + function gamepadDown() { + if (_focusIndex < _controls.length - 1) { + _focusIndex++ + _highlightCurrent() + } + } + function gamepadLeft() { + var ctrl = _controls[_focusIndex] + if (ctrl instanceof PC3.Slider) { + ctrl.decrease() + ctrl.moved() + } + } + function gamepadRight() { + var ctrl = _controls[_focusIndex] + if (ctrl instanceof PC3.Slider) { + ctrl.increase() + ctrl.moved() + } + } + function gamepadAccept() { + var ctrl = _controls[_focusIndex] + if (ctrl instanceof QQC2.Switch) { + ctrl.toggle() + ctrl.toggled() + } + } + + // Eat clicks on the dimmed backdrop + MouseArea { + anchors.fill: parent + visible: root.opened + onClicked: root.close() + } + + // Dim backdrop + Rectangle { + anchors.fill: parent + color: "black" + opacity: root.opened ? 0.4 : 0 + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } + } + } + + // Panel sliding in from the right + Rectangle { + id: panel + width: Math.min(root.width * 0.35, Kirigami.Units.gridUnit * 22) + height: root.height + anchors.top: root.top + anchors.bottom: root.bottom + + x: root.opened ? root.width - width : root.width + + Behavior on x { + NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } + } + + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Window + color: Qt.rgba(Kirigami.Theme.backgroundColor.r, + Kirigami.Theme.backgroundColor.g, + Kirigami.Theme.backgroundColor.b, 0.96) + + // Subtle left border + Rectangle { + width: 1 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + color: Kirigami.Theme.disabledTextColor + opacity: 0.3 + } + + ScreenBrightness.ScreenBrightnessUtil { + id: screenBrightness + } + + PlasmaNM.Handler { + id: nmHandler + } + + PlasmaNM.EnabledConnections { + id: enabledConnections + } + + Flickable { + anchors.fill: parent + anchors.margins: Kirigami.Units.largeSpacing * 2 + contentHeight: settingsColumn.implicitHeight + clip: true + + ColumnLayout { + id: settingsColumn + width: parent.width + spacing: Kirigami.Units.largeSpacing * 2 + + // ---- Header ---- + RowLayout { + Layout.fillWidth: true + + Kirigami.Heading { + text: i18n("Quick Settings") + level: 2 + Layout.fillWidth: true + } + + QQC2.ToolButton { + icon.name: "window-close-symbolic" + onClicked: root.close() + } + } + + Kirigami.Separator { Layout.fillWidth: true } + + // ---- Brightness ---- + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: screenBrightness.brightnessAvailable + + PC3.Label { + text: i18n("Brightness") + font.bold: true + } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + source: "low-brightness" + } + + PC3.Slider { + id: brightnessSlider + Layout.fillWidth: true + from: 1 + to: screenBrightness.maxBrightness + stepSize: Math.max(1, Math.round(screenBrightness.maxBrightness / 20)) + value: screenBrightness.brightness + onMoved: screenBrightness.brightness = value + + Timer { + interval: 0 + running: true + repeat: false + onTriggered: brightnessSlider.value = Qt.binding(() => screenBrightness.brightness) + } + + // Focus highlight + Rectangle { + anchors.fill: parent + anchors.margins: -Kirigami.Units.smallSpacing + radius: Kirigami.Units.smallSpacing + color: "transparent" + border.color: Kirigami.Theme.highlightColor + border.width: parent.activeFocus ? 2 : 0 + } + } + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + source: "high-brightness" + } + } + } + + // ---- Volume ---- + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: PreferredDevice.sink != null + + PC3.Label { + text: i18n("Volume") + font.bold: true + } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + source: "audio-volume-low" + } + + PC3.Slider { + id: volumeSlider + Layout.fillWidth: true + from: PulseAudio.MinimalVolume + to: PulseAudio.NormalVolume + stepSize: PulseAudio.NormalVolume / 20 + value: PreferredDevice.sink ? PreferredDevice.sink.volume : 0 + onMoved: { + if (PreferredDevice.sink) { + PreferredDevice.sink.volume = value + PreferredDevice.sink.muted = (value === 0) + } + } + + // Focus highlight + Rectangle { + anchors.fill: parent + anchors.margins: -Kirigami.Units.smallSpacing + radius: Kirigami.Units.smallSpacing + color: "transparent" + border.color: Kirigami.Theme.highlightColor + border.width: parent.activeFocus ? 2 : 0 + } + } + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + source: "audio-volume-high" + } + } + } + + Kirigami.Separator { Layout.fillWidth: true } + + // ---- Connectivity toggles ---- + PC3.Label { + text: i18n("Connectivity") + font.bold: true + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + rowSpacing: Kirigami.Units.smallSpacing + columnSpacing: Kirigami.Units.largeSpacing + + // WiFi toggle + QQC2.Switch { + id: wifiSwitch + text: i18n("Wi-Fi") + checked: enabledConnections.wirelessEnabled + onToggled: nmHandler.enableWireless(checked) + } + + // Bluetooth toggle + QQC2.Switch { + id: btSwitch + text: i18n("Bluetooth") + checked: !BluezQt.Manager.bluetoothBlocked + onToggled: BluezQt.Manager.bluetoothBlocked = !checked + } + + // Airplane mode + QQC2.Switch { + id: airplaneSwitch + text: i18n("Airplane Mode") + checked: PlasmaNM.Configuration.airplaneModeEnabled + onToggled: { + nmHandler.enableAirplaneMode(!PlasmaNM.Configuration.airplaneModeEnabled) + PlasmaNM.Configuration.airplaneModeEnabled = !PlasmaNM.Configuration.airplaneModeEnabled + } + } + } + + Kirigami.Separator { Layout.fillWidth: true } + + // ---- Battery info ---- + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: MobileShell.BatteryInfo.isVisible + + PC3.Label { + text: i18n("Battery") + font.bold: true + } + + RowLayout { + spacing: Kirigami.Units.smallSpacing + + MobileShell.BatteryIndicator { + textPixelSize: Kirigami.Units.gridUnit * 0.7 + } + + PC3.Label { + text: MobileShell.BatteryInfo.pluggedIn ? i18n("Charging") : "" + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + opacity: 0.7 + } + } + } + + // ---- Controller info ---- + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: GamingShell.GamepadManager.hasGamepad + + PC3.Label { + text: i18n("Controllers") + font.bold: true + } + + Repeater { + model: GamingShell.GamepadManager + + RowLayout { + spacing: Kirigami.Units.smallSpacing + required property string name + required property int battery + required property string type + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + source: "input-gaming" + } + + PC3.Label { + text: name + } + + PC3.Label { + text: battery >= 0 ? battery + "%" : i18n("Wired") + opacity: 0.7 + } + } + } + } + + Kirigami.Separator { Layout.fillWidth: true } + + // ---- Gamepad legend ---- + PC3.Label { + Layout.fillWidth: true + text: i18n("↕: Navigate ↔: Adjust A: Toggle B: Close") + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8 + opacity: 0.5 + horizontalAlignment: Text.AlignHCenter + } + + Item { Layout.fillHeight: true } + } + } + } +} From 62243b7f6484e2bb63f04cc38e4303c28f64b371 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Tue, 21 Apr 2026 09:08:03 +0200 Subject: [PATCH 15/26] Add gaming session control backends Add PowerProfileControl and GameModeControl singletons and wire them into gaming mode lifecycle handling. When gaming mode turns on, keep DND on, switch to the performance profile when available, and request GameMode. Restore previous state when gaming mode turns off. Add an overlayEnabled property in GameLauncherProvider so the launcher can toggle MangoHud environment variables from QML. --- components/gamingshellplugin/CMakeLists.txt | 5 +- .../gamelauncherprovider.cpp | 23 ++++ .../gamingshellplugin/gamelauncherprovider.h | 5 + .../gamingshellplugin/gamemodecontrol.cpp | 64 +++++++++ .../gamingshellplugin/gamemodecontrol.h | 46 +++++++ .../gamingshellplugin/powerprofilecontrol.cpp | 127 ++++++++++++++++++ .../gamingshellplugin/powerprofilecontrol.h | 44 ++++++ containments/homescreens/folio/qml/main.qml | 44 +++++- 8 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 components/gamingshellplugin/gamemodecontrol.cpp create mode 100644 components/gamingshellplugin/gamemodecontrol.h create mode 100644 components/gamingshellplugin/powerprofilecontrol.cpp create mode 100644 components/gamingshellplugin/powerprofilecontrol.h diff --git a/components/gamingshellplugin/CMakeLists.txt b/components/gamingshellplugin/CMakeLists.txt index 97d75dc8..d9846ad6 100644 --- a/components/gamingshellplugin/CMakeLists.txt +++ b/components/gamingshellplugin/CMakeLists.txt @@ -2,7 +2,7 @@ # SPDX-License-Identifier: EUPL-1.2 find_package(SDL3 REQUIRED) -find_package(Qt6 REQUIRED COMPONENTS Sql) +find_package(Qt6 REQUIRED COMPONENTS Sql DBus) ecm_add_qml_module(gamingshellplugin URI org.kde.plasma.private.mobileshell.gamingshellplugin GENERATE_PLUGIN_SOURCE) @@ -10,10 +10,13 @@ target_sources(gamingshellplugin PRIVATE gamepadmanager.cpp gamepaddevice.cpp gamelauncherprovider.cpp + powerprofilecontrol.cpp + gamemodecontrol.cpp ) target_link_libraries(gamingshellplugin PRIVATE Qt::Core + Qt::DBus Qt::Qml Qt::Quick Qt::Sql diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index 91915600..5aede406 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -533,6 +533,29 @@ void GameLauncherProvider::setSourceFilter(const QString &source) applyFilter(); } +bool GameLauncherProvider::overlayEnabled() const +{ + return m_overlayEnabled; +} + +void GameLauncherProvider::setOverlayEnabled(bool enabled) +{ + if (m_overlayEnabled == enabled) { + return; + } + m_overlayEnabled = enabled; + Q_EMIT overlayEnabledChanged(); + + // Set/unset MangoHud environment variables for child processes + if (enabled) { + qputenv("MANGOHUD", "1"); + qputenv("MANGOHUD_DLSYM", "1"); + } else { + qunsetenv("MANGOHUD"); + qunsetenv("MANGOHUD_DLSYM"); + } +} + void GameLauncherProvider::applyFilter() { beginResetModel(); diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index 56c76626..62a95463 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -21,6 +21,7 @@ class GameLauncherProvider : public QAbstractListModel Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged) Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged) + Q_PROPERTY(bool overlayEnabled READ overlayEnabled WRITE setOverlayEnabled NOTIFY overlayEnabledChanged) public: explicit GameLauncherProvider(QObject *parent = nullptr); @@ -46,6 +47,8 @@ public: void setFilterString(const QString &filter); QString sourceFilter() const; void setSourceFilter(const QString &source); + bool overlayEnabled() const; + void setOverlayEnabled(bool enabled); Q_INVOKABLE void refresh(); Q_INVOKABLE void launch(int index); @@ -57,6 +60,7 @@ Q_SIGNALS: void loadingChanged(); void filterStringChanged(); void sourceFilterChanged(); + void overlayEnabledChanged(); void gameLaunched(const QString &name); private: @@ -88,4 +92,5 @@ private: QString m_sourceFilter; // empty = all, or "desktop"/"steam"/"flatpak" KSharedConfigPtr m_config; bool m_loading = false; + bool m_overlayEnabled = false; }; diff --git a/components/gamingshellplugin/gamemodecontrol.cpp b/components/gamingshellplugin/gamemodecontrol.cpp new file mode 100644 index 00000000..20778cf2 --- /dev/null +++ b/components/gamingshellplugin/gamemodecontrol.cpp @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "gamemodecontrol.h" + +#include +#include +#include + +#include // getpid() + +static const QString s_service = QStringLiteral("com.feralinteractive.GameMode"); +static const QString s_path = QStringLiteral("/com/feralinteractive/GameMode"); +static const QString s_iface = QStringLiteral("com.feralinteractive.GameMode"); + +GameModeControl::GameModeControl(QObject *parent) + : QObject(parent) + , m_iface(new QDBusInterface(s_service, s_path, s_iface, QDBusConnection::sessionBus(), this)) +{ + m_available = m_iface->isValid(); + if (!m_available) { + qDebug() << "GameModeControl: Feral GameMode not available"; + } +} + +bool GameModeControl::available() const +{ + return m_available; +} + +bool GameModeControl::active() const +{ + return m_active; +} + +void GameModeControl::requestStart() +{ + if (!m_available || m_active) { + return; + } + + QDBusReply reply = m_iface->call(QStringLiteral("RegisterGame"), static_cast(getpid())); + if (reply.isValid() && reply.value() == 0) { + m_active = true; + Q_EMIT activeChanged(); + } else { + qWarning() << "GameModeControl: RegisterGame failed:" << reply.error().message(); + } +} + +void GameModeControl::requestEnd() +{ + if (!m_available || !m_active) { + return; + } + + QDBusReply reply = m_iface->call(QStringLiteral("UnregisterGame"), static_cast(getpid())); + if (reply.isValid() && reply.value() == 0) { + m_active = false; + Q_EMIT activeChanged(); + } else { + qWarning() << "GameModeControl: UnregisterGame failed:" << reply.error().message(); + } +} diff --git a/components/gamingshellplugin/gamemodecontrol.h b/components/gamingshellplugin/gamemodecontrol.h new file mode 100644 index 00000000..ff17c716 --- /dev/null +++ b/components/gamingshellplugin/gamemodecontrol.h @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#include +#include +#include + +/** + * D-Bus client for Feral GameMode (com.feralinteractive.GameMode). + * + * Calling requestStart() tells the daemon to apply performance + * optimizations (CPU governor, I/O priority, GPU perf mode, etc.) + * for the calling process. requestEnd() reverses them. + * + * GameMode is optional — if the daemon is not installed the calls + * are silently ignored. + */ +class GameModeControl : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool available READ available NOTIFY availableChanged) + Q_PROPERTY(bool active READ active NOTIFY activeChanged) + +public: + explicit GameModeControl(QObject *parent = nullptr); + + bool available() const; + bool active() const; + + Q_INVOKABLE void requestStart(); + Q_INVOKABLE void requestEnd(); + +Q_SIGNALS: + void availableChanged(); + void activeChanged(); + +private: + QDBusInterface *m_iface = nullptr; + bool m_available = false; + bool m_active = false; +}; diff --git a/components/gamingshellplugin/powerprofilecontrol.cpp b/components/gamingshellplugin/powerprofilecontrol.cpp new file mode 100644 index 00000000..dc42cdb0 --- /dev/null +++ b/components/gamingshellplugin/powerprofilecontrol.cpp @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#include "powerprofilecontrol.h" + +#include +#include +#include +#include +#include + +static const QString s_service = QStringLiteral("net.hadess.PowerProfiles"); +static const QString s_path = QStringLiteral("/net/hadess/PowerProfiles"); +static const QString s_iface = QStringLiteral("net.hadess.PowerProfiles"); +static const QString s_propIface = QStringLiteral("org.freedesktop.DBus.Properties"); + +PowerProfileControl::PowerProfileControl(QObject *parent) + : QObject(parent) + , m_iface(new QDBusInterface(s_service, s_path, s_iface, QDBusConnection::systemBus(), this)) +{ + if (m_iface->isValid()) { + m_available = true; + fetchState(); + + // Subscribe to property changes + QDBusConnection::systemBus() + .connect(s_service, s_path, s_propIface, QStringLiteral("PropertiesChanged"), this, SLOT(onPropertiesChanged(QString, QVariantMap, QStringList))); + } else { + qDebug() << "PowerProfileControl: power-profiles-daemon not available"; + } +} + +QString PowerProfileControl::activeProfile() const +{ + return m_activeProfile; +} + +void PowerProfileControl::setActiveProfile(const QString &profile) +{ + if (!m_available || profile == m_activeProfile) { + return; + } + if (!m_profiles.contains(profile)) { + return; + } + + // Write via org.freedesktop.DBus.Properties.Set + QDBusInterface propIface(s_service, s_path, s_propIface, QDBusConnection::systemBus()); + propIface.call(QStringLiteral("Set"), s_iface, QStringLiteral("ActiveProfile"), QVariant::fromValue(QDBusVariant(profile))); +} + +QStringList PowerProfileControl::profiles() const +{ + return m_profiles; +} + +bool PowerProfileControl::available() const +{ + return m_available; +} + +void PowerProfileControl::fetchState() +{ + // Read ActiveProfile + QDBusInterface propIface(s_service, s_path, s_propIface, QDBusConnection::systemBus()); + + QDBusReply profileReply = propIface.call(QStringLiteral("Get"), s_iface, QStringLiteral("ActiveProfile")); + if (profileReply.isValid()) { + const QString profile = profileReply.value().variant().toString(); + if (profile != m_activeProfile) { + m_activeProfile = profile; + Q_EMIT activeProfileChanged(); + } + } + + // Read Profiles — array of dicts, each with a "Profile" key + QDBusReply profilesReply = propIface.call(QStringLiteral("Get"), s_iface, QStringLiteral("Profiles")); + if (profilesReply.isValid()) { + QStringList profiles; + const QVariant profilesVariant = profilesReply.value().variant(); + if (profilesVariant.canConvert()) { + const QVariantList list = profilesVariant.toList(); + for (const QVariant &item : list) { + const QVariantMap map = item.toMap(); + QString profileName; + if (map.contains(QStringLiteral("Profile"))) { + QVariant value = map.value(QStringLiteral("Profile")); + if (value.canConvert()) { + value = value.value().variant(); + } + profileName = value.toString(); + } + if (!profileName.isEmpty()) { + profiles.append(profileName); + } + } + } + if (profiles != m_profiles) { + m_profiles = profiles; + Q_EMIT profilesChanged(); + } + } +} + +void PowerProfileControl::onPropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &invalidated) +{ + Q_UNUSED(invalidated) + if (interface != s_iface) { + return; + } + + if (changed.contains(QStringLiteral("ActiveProfile"))) { + QVariant value = changed.value(QStringLiteral("ActiveProfile")); + if (value.canConvert()) { + value = value.value().variant(); + } + const QString profile = value.toString(); + if (profile != m_activeProfile) { + m_activeProfile = profile; + Q_EMIT activeProfileChanged(); + } + } + + if (changed.contains(QStringLiteral("Profiles"))) { + fetchState(); + } +} diff --git a/components/gamingshellplugin/powerprofilecontrol.h b/components/gamingshellplugin/powerprofilecontrol.h new file mode 100644 index 00000000..42b56c0d --- /dev/null +++ b/components/gamingshellplugin/powerprofilecontrol.h @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 Marco Allegretti +// SPDX-License-Identifier: EUPL-1.2 + +#pragma once + +#include +#include +#include +#include + +class PowerProfileControl : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QString activeProfile READ activeProfile WRITE setActiveProfile NOTIFY activeProfileChanged) + Q_PROPERTY(QStringList profiles READ profiles NOTIFY profilesChanged) + Q_PROPERTY(bool available READ available NOTIFY availableChanged) + +public: + explicit PowerProfileControl(QObject *parent = nullptr); + + QString activeProfile() const; + void setActiveProfile(const QString &profile); + QStringList profiles() const; + bool available() const; + +Q_SIGNALS: + void activeProfileChanged(); + void profilesChanged(); + void availableChanged(); + +private Q_SLOTS: + void onPropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &invalidated); + +private: + void fetchState(); + + QDBusInterface *m_iface = nullptr; + QString m_activeProfile; + QStringList m_profiles; + bool m_available = false; +}; diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index 2e0744f9..cfa4b45f 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -40,6 +40,44 @@ ContainmentItem { property bool gameCenterOpen: ShellSettings.Settings.gamingModeEnabled property bool showGameCenterHint: false + // State saved when gaming mode activates, restored when it deactivates + property string _savedPowerProfile: "" + property bool _savedDnd: false + property bool _gamingSessionActive: false + + function _applyGamingModeState(enabled) { + root.gameCenterOpen = enabled + GamingShell.GamepadManager.active = enabled + + if (enabled === root._gamingSessionActive) { + return + } + + if (enabled) { + // Save current state and apply gaming optimizations + root._savedDnd = MobileShellState.ShellDBusClient.doNotDisturb + MobileShellState.ShellDBusClient.doNotDisturb = true + + if (GamingShell.PowerProfileControl.available) { + root._savedPowerProfile = GamingShell.PowerProfileControl.activeProfile + GamingShell.PowerProfileControl.activeProfile = "performance" + } + + GamingShell.GameModeControl.requestStart() + root._gamingSessionActive = true + } else { + // Restore previous state + MobileShellState.ShellDBusClient.doNotDisturb = root._savedDnd + + if (GamingShell.PowerProfileControl.available && root._savedPowerProfile.length > 0) { + GamingShell.PowerProfileControl.activeProfile = root._savedPowerProfile + } + + GamingShell.GameModeControl.requestEnd() + root._gamingSessionActive = false + } + } + Timer { id: gameCenterHintTimer interval: 2600 @@ -49,8 +87,7 @@ ContainmentItem { Connections { target: ShellSettings.Settings function onGamingModeEnabledChanged() { - root.gameCenterOpen = ShellSettings.Settings.gamingModeEnabled - GamingShell.GamepadManager.active = ShellSettings.Settings.gamingModeEnabled + root._applyGamingModeState(ShellSettings.Settings.gamingModeEnabled) } } @@ -66,8 +103,7 @@ ContainmentItem { } Component.onCompleted: { - root.gameCenterOpen = ShellSettings.Settings.gamingModeEnabled - GamingShell.GamepadManager.active = ShellSettings.Settings.gamingModeEnabled + root._applyGamingModeState(ShellSettings.Settings.gamingModeEnabled) folio.FolioSettings.load(); folio.FavouritesModel.load(); folio.PageListModel.load(); From 55f778ebe86f53f81098a6c0ec1c4c6e5b6a9941 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Tue, 21 Apr 2026 09:08:49 +0200 Subject: [PATCH 16/26] Refine gaming quick settings visuals and toggles Expose missing gaming controls directly in the panel so they remain reachable in convergence mode. Add toggles for DND, Launch Hint, Night Color, and Perf Overlay with gamepad focus order updates. Restyle brightness and volume sliders with rounded Plasma-themed tracks and handles to remove harsh bar visuals while keeping existing behavior unchanged. --- .../folio/qml/gaming/GamingQuickSettings.qml | 269 ++++++++++++++++-- 1 file changed, 248 insertions(+), 21 deletions(-) diff --git a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml index 937a694a..5c7cebae 100644 --- a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml +++ b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml @@ -9,10 +9,13 @@ import org.kde.kirigami as Kirigami import org.kde.plasma.components 3.0 as PC3 import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell.gamingshellplugin as GamingShell +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings import org.kde.plasma.private.mobileshell.screenbrightnessplugin as ScreenBrightness import org.kde.plasma.private.volume import org.kde.plasma.networkmanagement as PlasmaNM import org.kde.bluezqt 1.0 as BluezQt +import org.kde.plasma.private.mobileshell.state as MobileShellState +import org.kde.plasma.quicksetting.nightcolor as NightColor Item { id: root @@ -26,8 +29,13 @@ Item { function _buildControlsList() { var list = [] + if (GamingShell.PowerProfileControl.available && performanceSection._availableProfiles.length > 0) list.push(profileRow) if (screenBrightness.brightnessAvailable) list.push(brightnessSlider) if (PreferredDevice.sink) list.push(volumeSlider) + list.push(dndSwitch) + list.push(launchHintSwitch) + list.push(nightColorSwitch) + list.push(overlaySwitch) list.push(wifiSwitch) list.push(btSwitch) list.push(airplaneSwitch) @@ -37,7 +45,7 @@ Item { function open() { opened = true _buildControlsList() - _focusIndex = 0 + _focusIndex = Math.max(0, Math.min(_focusIndex, _controls.length - 1)) _highlightCurrent() } function close() { @@ -68,26 +76,38 @@ Item { } function gamepadLeft() { var ctrl = _controls[_focusIndex] - if (ctrl instanceof PC3.Slider) { + if (typeof ctrl.decrease === "function") { ctrl.decrease() - ctrl.moved() + if (typeof ctrl.moved === "function") ctrl.moved() } } function gamepadRight() { var ctrl = _controls[_focusIndex] - if (ctrl instanceof PC3.Slider) { + if (typeof ctrl.increase === "function") { ctrl.increase() - ctrl.moved() + if (typeof ctrl.moved === "function") ctrl.moved() } } function gamepadAccept() { var ctrl = _controls[_focusIndex] + if (ctrl === profileRow) { + ctrl.increase() + return + } if (ctrl instanceof QQC2.Switch) { ctrl.toggle() ctrl.toggled() } } + onOpenedChanged: { + if (opened) { + _buildControlsList() + _focusIndex = Math.max(0, Math.min(_focusIndex, _controls.length - 1)) + _highlightCurrent() + } + } + // Eat clicks on the dimmed backdrop MouseArea { anchors.fill: parent @@ -176,6 +196,96 @@ Item { Kirigami.Separator { Layout.fillWidth: true } + // ---- Performance Profile ---- + ColumnLayout { + id: performanceSection + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: GamingShell.PowerProfileControl.available + && _availableProfiles.length > 0 + + PC3.Label { + text: i18n("Performance") + font.bold: true + } + + // Ordered low-to-high so gamepad left=slower, right=faster + readonly property var _profileOrder: ["power-saver", "balanced", "performance"] + readonly property var _availableProfiles: { + var ordered = [] + for (var i = 0; i < _profileOrder.length; i++) { + if (GamingShell.PowerProfileControl.profiles.indexOf(_profileOrder[i]) >= 0) { + ordered.push(_profileOrder[i]) + } + } + return ordered + } + + Item { + id: profileRow + focus: true + Layout.fillWidth: true + Layout.preferredHeight: profileButtons.implicitHeight + + function decrease() { + var profiles = parent._availableProfiles + var idx = profiles.indexOf(GamingShell.PowerProfileControl.activeProfile) + if (idx > 0) { + GamingShell.PowerProfileControl.activeProfile = profiles[idx - 1] + } + } + function increase() { + var profiles = parent._availableProfiles + var idx = profiles.indexOf(GamingShell.PowerProfileControl.activeProfile) + if (idx >= 0 && idx < profiles.length - 1) { + GamingShell.PowerProfileControl.activeProfile = profiles[idx + 1] + } + } + + Rectangle { + anchors.fill: parent + anchors.margins: -Kirigami.Units.smallSpacing + radius: Kirigami.Units.smallSpacing + color: "transparent" + border.color: Kirigami.Theme.highlightColor + border.width: parent.activeFocus ? 2 : 0 + } + + RowLayout { + id: profileButtons + anchors.left: parent.left + anchors.right: parent.right + spacing: Kirigami.Units.smallSpacing + + Repeater { + model: performanceSection._availableProfiles + + QQC2.Button { + Layout.fillWidth: true + text: { + switch (modelData) { + case "performance": return i18n("Performance") + case "balanced": return i18n("Balanced") + case "power-saver": return i18n("Power Saver") + default: return modelData + } + } + icon.name: { + switch (modelData) { + case "performance": return "speedometer" + case "balanced": return "system-suspend-hibernate" + case "power-saver": return "battery-profile-powersave" + default: return "" + } + } + highlighted: GamingShell.PowerProfileControl.activeProfile === modelData + onClicked: GamingShell.PowerProfileControl.activeProfile = modelData + } + } + } + } + } + // ---- Brightness ---- ColumnLayout { Layout.fillWidth: true @@ -213,14 +323,42 @@ Item { onTriggered: brightnessSlider.value = Qt.binding(() => screenBrightness.brightness) } - // Focus highlight - Rectangle { - anchors.fill: parent - anchors.margins: -Kirigami.Units.smallSpacing - radius: Kirigami.Units.smallSpacing - color: "transparent" - border.color: Kirigami.Theme.highlightColor - border.width: parent.activeFocus ? 2 : 0 + // Keep Plasma/Kirigami colors while using a cleaner rounded style. + background: Rectangle { + x: brightnessSlider.leftPadding + y: brightnessSlider.topPadding + brightnessSlider.availableHeight / 2 - height / 2 + width: brightnessSlider.availableWidth + height: Kirigami.Units.smallSpacing + 2 + radius: height / 2 + color: Kirigami.Theme.alternateBackgroundColor + + Rectangle { + width: parent.width * brightnessSlider.visualPosition + height: parent.height + radius: parent.radius + color: Kirigami.Theme.highlightColor + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: "transparent" + border.color: Kirigami.Theme.highlightColor + border.width: brightnessSlider.activeFocus ? 1 : 0 + } + } + + handle: Rectangle { + x: brightnessSlider.leftPadding + brightnessSlider.visualPosition * (brightnessSlider.availableWidth - width) + y: brightnessSlider.topPadding + brightnessSlider.availableHeight / 2 - height / 2 + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + radius: width / 2 + color: Kirigami.Theme.backgroundColor + border.color: brightnessSlider.pressed + ? Kirigami.Theme.highlightColor + : Kirigami.Theme.disabledTextColor + border.width: brightnessSlider.activeFocus || brightnessSlider.pressed ? 2 : 1 } } @@ -267,14 +405,42 @@ Item { } } - // Focus highlight - Rectangle { - anchors.fill: parent - anchors.margins: -Kirigami.Units.smallSpacing - radius: Kirigami.Units.smallSpacing - color: "transparent" - border.color: Kirigami.Theme.highlightColor - border.width: parent.activeFocus ? 2 : 0 + // Keep Plasma/Kirigami colors while using a cleaner rounded style. + background: Rectangle { + x: volumeSlider.leftPadding + y: volumeSlider.topPadding + volumeSlider.availableHeight / 2 - height / 2 + width: volumeSlider.availableWidth + height: Kirigami.Units.smallSpacing + 2 + radius: height / 2 + color: Kirigami.Theme.alternateBackgroundColor + + Rectangle { + width: parent.width * volumeSlider.visualPosition + height: parent.height + radius: parent.radius + color: Kirigami.Theme.highlightColor + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: "transparent" + border.color: Kirigami.Theme.highlightColor + border.width: volumeSlider.activeFocus ? 1 : 0 + } + } + + handle: Rectangle { + x: volumeSlider.leftPadding + volumeSlider.visualPosition * (volumeSlider.availableWidth - width) + y: volumeSlider.topPadding + volumeSlider.availableHeight / 2 - height / 2 + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + radius: width / 2 + color: Kirigami.Theme.backgroundColor + border.color: volumeSlider.pressed + ? Kirigami.Theme.highlightColor + : Kirigami.Theme.disabledTextColor + border.width: volumeSlider.activeFocus || volumeSlider.pressed ? 2 : 1 } } @@ -288,6 +454,67 @@ Item { Kirigami.Separator { Layout.fillWidth: true } + // ---- Gaming Tweaks ---- + PC3.Label { + text: i18n("Gaming") + font.bold: true + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + rowSpacing: Kirigami.Units.smallSpacing + columnSpacing: Kirigami.Units.largeSpacing + + QQC2.Switch { + id: dndSwitch + text: i18n("Do Not Disturb") + checked: MobileShellState.ShellDBusClient.doNotDisturb + onToggled: MobileShellState.ShellDBusClient.doNotDisturb = checked + } + + QQC2.Switch { + id: launchHintSwitch + text: i18n("Launch Hint") + checked: ShellSettings.Settings.gamingDismissHintEnabled + onToggled: ShellSettings.Settings.gamingDismissHintEnabled = checked + } + + QQC2.Switch { + id: nightColorSwitch + text: i18n("Night Color") + checked: NightColor.NightColorUtil.enabled + onToggled: NightColor.NightColorUtil.enabled = checked + } + + QQC2.Switch { + id: overlaySwitch + text: i18n("Perf Overlay") + checked: GamingShell.GameLauncherProvider.overlayEnabled + onToggled: GamingShell.GameLauncherProvider.overlayEnabled = checked + } + + // GameMode status (auto-managed, read-only indicator) + RowLayout { + spacing: Kirigami.Units.smallSpacing + visible: GamingShell.GameModeControl.available + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + source: "games-achievements" + } + PC3.Label { + text: GamingShell.GameModeControl.active + ? i18n("GameMode active") + : i18n("GameMode standby") + opacity: 0.7 + } + } + } + + Kirigami.Separator { Layout.fillWidth: true } + // ---- Connectivity toggles ---- PC3.Label { text: i18n("Connectivity") From d913c2ab0ab24f902b1e7cf9e35181d88c3a84be Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Tue, 21 Apr 2026 09:57:51 +0200 Subject: [PATCH 17/26] 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. --- .../gamingshellplugin/gamepaddevice.cpp | 96 +++++++++++++++++++ components/gamingshellplugin/gamepaddevice.h | 10 ++ .../gamingshellplugin/gamepadmanager.cpp | 41 ++++++++ components/gamingshellplugin/gamepadmanager.h | 1 + .../folio/qml/gaming/GameCenterOverlay.qml | 42 +++++++- .../folio/qml/gaming/GamingQuickSettings.qml | 17 +++- 6 files changed, 204 insertions(+), 3 deletions(-) diff --git a/components/gamingshellplugin/gamepaddevice.cpp b/components/gamingshellplugin/gamepaddevice.cpp index d558f29e..4403554a 100644 --- a/components/gamingshellplugin/gamepaddevice.cpp +++ b/components/gamingshellplugin/gamepaddevice.cpp @@ -6,9 +6,34 @@ #include #include #include +#include #include +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(std::clamp(leftIntensity, 0, 65535)); + auto right = static_cast(std::clamp(rightIntensity, 0, 65535)); + auto dur = static_cast(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(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; diff --git a/components/gamingshellplugin/gamepaddevice.h b/components/gamingshellplugin/gamepaddevice.h index c4534f2c..b4e9acfc 100644 --- a/components/gamingshellplugin/gamepaddevice.h +++ b/components/gamingshellplugin/gamepaddevice.h @@ -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(); diff --git a/components/gamingshellplugin/gamepadmanager.cpp b/components/gamingshellplugin/gamepadmanager.cpp index aefe0d31..023d8e49 100644 --- a/components/gamingshellplugin/gamepadmanager.cpp +++ b/components/gamingshellplugin/gamepadmanager.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -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; diff --git a/components/gamingshellplugin/gamepadmanager.h b/components/gamingshellplugin/gamepadmanager.h index 76b2d7ba..620fc64a 100644 --- a/components/gamingshellplugin/gamepadmanager.h +++ b/components/gamingshellplugin/gamepadmanager.h @@ -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(); diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index 25e52c27..2aa43185 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -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 } diff --git a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml index 5c7cebae..8bac0afd 100644 --- a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml +++ b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml @@ -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 From 4dd0e17afb7909050bfbbe63d31a9bdf2373f724 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Wed, 22 Apr 2026 11:36:49 +0200 Subject: [PATCH 18/26] Track launch state in Game Center Record recent launches only after the launcher reports success instead of optimistically on every attempt. For desktop entries, wait for the ApplicationLauncherJob result; for detached commands, use startDetached's return value and show an inline error when startup fails. Parse Steam libraryfolders and app manifests as KeyValues instead of matching individual lines. This makes Steam discovery less brittle when the files contain comments, nested blocks, or different formatting. Keep Continue Playing visible alongside Running and show a temporary launching state so the overlay does not pretend a task already exists. Rename the GameMode status text to say what the shell actually knows. --- .../gamelauncherprovider.cpp | 381 ++++++++++++++++-- .../gamingshellplugin/gamelauncherprovider.h | 20 + .../folio/qml/gaming/GameCenterOverlay.qml | 62 ++- .../folio/qml/gaming/GamingQuickSettings.qml | 4 +- .../folio/qml/gaming/RunningGamesView.qml | 1 + 5 files changed, 419 insertions(+), 49 deletions(-) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index 5aede406..4c465f23 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -23,13 +25,203 @@ #include #include +#include + static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed"); +namespace +{ +struct VdfNode { + QHash values; + QHash> children; +}; + +class VdfTokenizer +{ +public: + enum class TokenType { + End, + String, + OpenBrace, + CloseBrace, + Invalid, + }; + + struct Token { + TokenType type = TokenType::End; + QString text; + }; + + explicit VdfTokenizer(QStringView input) + : m_input(input) + { + } + + Token next() + { + skipWhitespaceAndComments(); + + if (m_pos >= m_input.size()) { + return {}; + } + + const QChar current = m_input.at(m_pos); + if (current == QLatin1Char('{')) { + ++m_pos; + return {TokenType::OpenBrace, {}}; + } + if (current == QLatin1Char('}')) { + ++m_pos; + return {TokenType::CloseBrace, {}}; + } + if (current == QLatin1Char('"')) { + return {TokenType::String, readQuotedString()}; + } + + return {TokenType::String, readBareString()}; + } + + int position() const + { + return m_pos; + } + +private: + void skipWhitespaceAndComments() + { + while (m_pos < m_input.size()) { + const QChar current = m_input.at(m_pos); + if (current.isSpace()) { + ++m_pos; + continue; + } + if (current == QLatin1Char('/') && m_pos + 1 < m_input.size() && m_input.at(m_pos + 1) == QLatin1Char('/')) { + m_pos += 2; + while (m_pos < m_input.size() && m_input.at(m_pos) != QLatin1Char('\n')) { + ++m_pos; + } + continue; + } + break; + } + } + + QString readQuotedString() + { + QString result; + ++m_pos; + + while (m_pos < m_input.size()) { + const QChar current = m_input.at(m_pos++); + if (current == QLatin1Char('"')) { + return result; + } + if (current == QLatin1Char('\\') && m_pos < m_input.size()) { + const QChar escaped = m_input.at(m_pos++); + switch (escaped.unicode()) { + case 'n': + result.append(QLatin1Char('\n')); + break; + case 't': + result.append(QLatin1Char('\t')); + break; + case 'r': + result.append(QLatin1Char('\r')); + break; + case '\\': + case '"': + result.append(escaped); + break; + default: + result.append(escaped); + break; + } + continue; + } + result.append(current); + } + + return result; + } + + QString readBareString() + { + const int start = m_pos; + while (m_pos < m_input.size()) { + const QChar current = m_input.at(m_pos); + if (current.isSpace() || current == QLatin1Char('{') || current == QLatin1Char('}') || current == QLatin1Char('"')) { + break; + } + if (current == QLatin1Char('/') && m_pos + 1 < m_input.size() && m_input.at(m_pos + 1) == QLatin1Char('/')) { + break; + } + ++m_pos; + } + return m_input.sliced(start, m_pos - start).toString(); + } + + QStringView m_input; + int m_pos = 0; +}; + +bool parseVdf(const QString &input, VdfNode &root, QString *error) +{ + VdfTokenizer tokenizer(input); + QList stack = {&root}; + + while (true) { + const auto key = tokenizer.next(); + if (key.type == VdfTokenizer::TokenType::End) { + if (stack.size() != 1 && error) { + *error = QStringLiteral("unexpected end of file"); + } + return stack.size() == 1; + } + if (key.type == VdfTokenizer::TokenType::CloseBrace) { + if (stack.size() == 1) { + if (error) { + *error = QStringLiteral("unexpected closing brace at position %1").arg(tokenizer.position()); + } + return false; + } + stack.removeLast(); + continue; + } + if (key.type != VdfTokenizer::TokenType::String || key.text.isEmpty()) { + if (error) { + *error = QStringLiteral("invalid key at position %1").arg(tokenizer.position()); + } + return false; + } + + const auto value = tokenizer.next(); + if (value.type == VdfTokenizer::TokenType::String) { + stack.last()->values.insert(key.text, value.text); + continue; + } + if (value.type == VdfTokenizer::TokenType::OpenBrace) { + auto child = std::make_shared(); + stack.last()->children.insert(key.text, child); + stack.append(child.get()); + continue; + } + + if (error) { + *error = QStringLiteral("expected value for key '%1'").arg(key.text); + } + return false; + } +} +} // namespace + GameLauncherProvider::GameLauncherProvider(QObject *parent) : QAbstractListModel(parent) , m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc"))) { connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh); + m_pendingLaunchTimer.setInterval(15000); + m_pendingLaunchTimer.setSingleShot(true); + connect(&m_pendingLaunchTimer, &QTimer::timeout, this, &GameLauncherProvider::clearPendingLaunch); refresh(); } @@ -86,6 +278,21 @@ bool GameLauncherProvider::loading() const return m_loading; } +bool GameLauncherProvider::launchPending() const +{ + return m_launchPending; +} + +QString GameLauncherProvider::pendingLaunchName() const +{ + return m_pendingLaunchName; +} + +QString GameLauncherProvider::lastLaunchError() const +{ + return m_lastLaunchError; +} + void GameLauncherProvider::refresh() { m_loading = true; @@ -142,27 +349,45 @@ void GameLauncherProvider::launchByStorageId(const QString &storageId) void GameLauncherProvider::launchEntry(GameEntry &entry) { + clearLastLaunchError(); + if (entry.source == QLatin1String("desktop")) { auto service = KService::serviceByStorageId(entry.storageId); - if (service) { - auto *job = new KIO::ApplicationLauncherJob(service); - job->start(); + if (!service) { + markLaunchFailed(entry.name, QStringLiteral("Desktop entry is no longer available")); + return; } + + auto *job = new KIO::ApplicationLauncherJob(service); + connect(job, &KJob::result, this, [this, job, storageId = entry.storageId, name = entry.name]() { + if (job->error() != 0) { + markLaunchFailed(name, job->errorString()); + return; + } + markLaunchSucceeded(storageId, name); + }); + job->start(); } else if (entry.launchCommand.contains(QStringLiteral("://"))) { // Protocol handler (e.g. heroic://launch/...) — open via xdg-open - QProcess::startDetached(QStringLiteral("xdg-open"), {entry.launchCommand}); + if (!QProcess::startDetached(QStringLiteral("xdg-open"), {entry.launchCommand})) { + markLaunchFailed(entry.name, QStringLiteral("Unable to start xdg-open")); + return; + } + markLaunchSucceeded(entry.storageId, entry.name); } else { QStringList parts = KShell::splitArgs(entry.launchCommand); - if (!parts.isEmpty()) { - QString program = parts.takeFirst(); - QProcess::startDetached(program, parts); + if (parts.isEmpty()) { + markLaunchFailed(entry.name, QStringLiteral("Launch command is empty")); + return; } - } - Q_EMIT gameLaunched(entry.name); - const auto now = QDateTime::currentDateTime(); - saveRecentTimestamp(entry.storageId, now); - entry.lastPlayed = now; + QString program = parts.takeFirst(); + if (!QProcess::startDetached(program, parts)) { + markLaunchFailed(entry.name, QStringLiteral("Unable to start %1").arg(program)); + return; + } + markLaunchSucceeded(entry.storageId, entry.name); + } } void GameLauncherProvider::deduplicateGames() @@ -237,21 +462,30 @@ void GameLauncherProvider::loadSteamGames() if (!vdf.open(QIODevice::ReadOnly | QIODevice::Text)) { continue; } - // Simple parse: look for "path" lines - static const QRegularExpression pathRe(QStringLiteral("\"path\"\\s+\"([^\"]+)\"")); - QTextStream stream(&vdf); - while (!stream.atEnd()) { - const QString line = stream.readLine(); - auto match = pathRe.match(line); - if (match.hasMatch()) { - libraryPaths.append(match.captured(1)); + const QString content = QString::fromUtf8(vdf.readAll()); + VdfNode document; + QString error; + if (!parseVdf(content, document, &error)) { + qWarning() << "GameLauncherProvider: cannot parse Steam libraryfolders" << vdfPath << error; + continue; + } + + const VdfNode *libraries = nullptr; + if (document.children.contains(QStringLiteral("libraryfolders"))) { + libraries = document.children.value(QStringLiteral("libraryfolders")).get(); + } else { + libraries = &document; + } + + for (auto it = libraries->children.cbegin(); it != libraries->children.cend(); ++it) { + const QString path = it.value()->values.value(QStringLiteral("path")); + if (!path.isEmpty()) { + libraryPaths.append(path); } } } - // Scan each library path for appmanifest_*.acf - static const QRegularExpression nameRe(QStringLiteral("\"name\"\\s+\"([^\"]+)\"")); - static const QRegularExpression appidRe(QStringLiteral("\"appid\"\\s+\"(\\d+)\"")); + libraryPaths.removeDuplicates(); for (const auto &libPath : std::as_const(libraryPaths)) { QDir steamapps(libPath + QStringLiteral("/steamapps")); @@ -264,28 +498,19 @@ void GameLauncherProvider::loadSteamGames() if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { continue; } - QString appName; - QString appId; - QTextStream ts(&f); - while (!ts.atEnd()) { - const QString line = ts.readLine(); - if (appName.isEmpty()) { - auto m = nameRe.match(line); - if (m.hasMatch()) { - appName = m.captured(1); - } - } - if (appId.isEmpty()) { - auto m = appidRe.match(line); - if (m.hasMatch()) { - appId = m.captured(1); - } - } - if (!appName.isEmpty() && !appId.isEmpty()) { - break; - } + VdfNode manifestData; + QString error; + if (!parseVdf(QString::fromUtf8(f.readAll()), manifestData, &error)) { + qWarning() << "GameLauncherProvider: cannot parse Steam manifest" << manifest << error; + continue; } + const VdfNode *appState = + manifestData.children.contains(QStringLiteral("AppState")) ? manifestData.children.value(QStringLiteral("AppState")).get() : &manifestData; + + const QString appName = appState->values.value(QStringLiteral("name")); + const QString appId = appState->values.value(QStringLiteral("appid")); + if (appName.isEmpty() || appId.isEmpty()) { continue; } @@ -593,6 +818,76 @@ void GameLauncherProvider::saveRecentTimestamp(const QString &storageId, const Q group.sync(); } +void GameLauncherProvider::clearPendingLaunch() +{ + if (!m_launchPending && m_pendingLaunchName.isEmpty()) { + return; + } + + m_pendingLaunchTimer.stop(); + m_launchPending = false; + m_pendingLaunchName.clear(); + Q_EMIT launchPendingChanged(); +} + +void GameLauncherProvider::clearLastLaunchError() +{ + if (m_lastLaunchError.isEmpty()) { + return; + } + + m_lastLaunchError.clear(); + Q_EMIT lastLaunchErrorChanged(); +} + +GameLauncherProvider::GameEntry *GameLauncherProvider::findEntryByStorageId(const QString &storageId) +{ + for (auto &entry : m_allGames) { + if (entry.storageId == storageId) { + return &entry; + } + } + return nullptr; +} + +void GameLauncherProvider::markLaunchSucceeded(const QString &storageId, const QString &name) +{ + if (auto *entry = findEntryByStorageId(storageId)) { + const auto now = QDateTime::currentDateTime(); + saveRecentTimestamp(entry->storageId, now); + entry->lastPlayed = now; + } + + setPendingLaunch(name); + Q_EMIT gameLaunched(name); +} + +void GameLauncherProvider::markLaunchFailed(const QString &name, const QString &error) +{ + clearPendingLaunch(); + + const QString message = error.isEmpty() ? tr("Unable to launch %1").arg(name) : tr("Unable to launch %1: %2").arg(name, error); + + if (m_lastLaunchError != message) { + m_lastLaunchError = message; + Q_EMIT lastLaunchErrorChanged(); + } + + Q_EMIT gameLaunchFailed(name, message); +} + +void GameLauncherProvider::setPendingLaunch(const QString &name) +{ + const bool changed = !m_launchPending || m_pendingLaunchName != name; + m_launchPending = true; + m_pendingLaunchName = name; + m_pendingLaunchTimer.start(); + + if (changed) { + Q_EMIT launchPendingChanged(); + } +} + QVariantList GameLauncherProvider::recentGames(int limit) const { // Gather entries that have been launched at least once diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index 62a95463..c9fa156b 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -22,6 +23,9 @@ class GameLauncherProvider : public QAbstractListModel Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged) Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged) Q_PROPERTY(bool overlayEnabled READ overlayEnabled WRITE setOverlayEnabled NOTIFY overlayEnabledChanged) + Q_PROPERTY(bool launchPending READ launchPending NOTIFY launchPendingChanged) + Q_PROPERTY(QString pendingLaunchName READ pendingLaunchName NOTIFY launchPendingChanged) + Q_PROPERTY(QString lastLaunchError READ lastLaunchError NOTIFY lastLaunchErrorChanged) public: explicit GameLauncherProvider(QObject *parent = nullptr); @@ -49,11 +53,16 @@ public: void setSourceFilter(const QString &source); bool overlayEnabled() const; void setOverlayEnabled(bool enabled); + bool launchPending() const; + QString pendingLaunchName() const; + QString lastLaunchError() const; Q_INVOKABLE void refresh(); Q_INVOKABLE void launch(int index); Q_INVOKABLE void launchByStorageId(const QString &storageId); Q_INVOKABLE QVariantList recentGames(int limit = 5) const; + Q_INVOKABLE void clearPendingLaunch(); + Q_INVOKABLE void clearLastLaunchError(); Q_SIGNALS: void countChanged(); @@ -61,7 +70,10 @@ Q_SIGNALS: void filterStringChanged(); void sourceFilterChanged(); void overlayEnabledChanged(); + void launchPendingChanged(); + void lastLaunchErrorChanged(); void gameLaunched(const QString &name); + void gameLaunchFailed(const QString &name, const QString &error); private: struct GameEntry { @@ -85,6 +97,10 @@ private: void saveRecentTimestamp(const QString &storageId, const QDateTime &when); void applyFilter(); void launchEntry(GameEntry &entry); + GameEntry *findEntryByStorageId(const QString &storageId); + void markLaunchSucceeded(const QString &storageId, const QString &name); + void markLaunchFailed(const QString &name, const QString &error); + void setPendingLaunch(const QString &name); QList m_allGames; QList m_games; // filtered view @@ -93,4 +109,8 @@ private: KSharedConfigPtr m_config; bool m_loading = false; bool m_overlayEnabled = false; + bool m_launchPending = false; + QString m_pendingLaunchName; + QString m_lastLaunchError; + QTimer m_pendingLaunchTimer; }; diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index 2aa43185..8af494af 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -22,6 +22,7 @@ Window { id: root required property var folio + property int launchTaskBaseline: 0 signal gameStarted() signal dismissRequested() @@ -51,14 +52,14 @@ Window { function launchGame(index) { pulsePrimaryGamepad(14000, 22000, 80) + launchTaskBaseline = runningGames.taskCount GamingShell.GameLauncherProvider.launch(index) - launchFade.restart() } function launchGameByStorageId(storageId) { pulsePrimaryGamepad(14000, 22000, 80) + launchTaskBaseline = runningGames.taskCount GamingShell.GameLauncherProvider.launchByStorageId(storageId) - launchFade.restart() } width: Screen.width @@ -79,6 +80,25 @@ Window { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } } + Connections { + target: GamingShell.GameLauncherProvider + + function onGameLaunched(name) { + launchFade.restart() + } + + function onGameLaunchFailed(name, error) { + launchErrorTimer.restart() + } + } + + Timer { + id: launchErrorTimer + interval: 6000 + repeat: false + onTriggered: GamingShell.GameLauncherProvider.clearLastLaunchError() + } + onVisibleChanged: { if (visible) { GamingShell.GameLauncherProvider.filterString = "" @@ -376,15 +396,49 @@ Window { RunningGamesView { id: runningGames Layout.fillWidth: true - onTaskActivated: root.gameStarted() + onTaskActivated: { + GamingShell.GameLauncherProvider.clearPendingLaunch() + root.gameStarted() + } onMoveDownRequested: grid.forceActiveFocus() + onTaskCountChanged: { + if (GamingShell.GameLauncherProvider.launchPending + && taskCount > root.launchTaskBaseline) { + GamingShell.GameLauncherProvider.clearPendingLaunch() + } + } + } + + Kirigami.InlineMessage { + Layout.fillWidth: true + type: Kirigami.MessageType.Error + text: GamingShell.GameLauncherProvider.lastLaunchError + visible: text.length > 0 + } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: GamingShell.GameLauncherProvider.launchPending + + Kirigami.Icon { + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + source: "system-run" + } + + PC3.Label { + Layout.fillWidth: true + text: i18n("Launching %1…", GamingShell.GameLauncherProvider.pendingLaunchName) + opacity: 0.75 + } } // ---- continue playing ---- ColumnLayout { Layout.fillWidth: true spacing: Kirigami.Units.smallSpacing - visible: recentList.count > 0 && !runningGames.hasTasks + visible: recentList.count > 0 Kirigami.Heading { level: 2 diff --git a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml index 8bac0afd..ea38664e 100644 --- a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml +++ b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml @@ -520,8 +520,8 @@ Item { } PC3.Label { text: GamingShell.GameModeControl.active - ? i18n("GameMode active") - : i18n("GameMode standby") + ? i18n("GameMode requested") + : i18n("GameMode not requested") opacity: 0.7 } } diff --git a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml index 608d080d..4f6cae2a 100644 --- a/containments/homescreens/folio/qml/gaming/RunningGamesView.qml +++ b/containments/homescreens/folio/qml/gaming/RunningGamesView.qml @@ -16,6 +16,7 @@ Item { implicitHeight: taskList.count > 0 ? column.implicitHeight : 0 readonly property bool hasTasks: taskList.count > 0 + readonly property int taskCount: taskList.count signal taskActivated() signal moveDownRequested() From eb177e039406e77d81ba3e98b348ef6b69a96f7f Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Wed, 22 Apr 2026 12:42:55 +0200 Subject: [PATCH 19/26] Harden VDF token parsing Handle unterminated quoted strings as invalid tokens and stop returning raw pointers to entries in the game list. --- .../gamelauncherprovider.cpp | 34 +++++++++++++------ .../gamingshellplugin/gamelauncherprovider.h | 4 ++- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index 4c465f23..3a5dbe99 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -75,7 +75,12 @@ public: return {TokenType::CloseBrace, {}}; } if (current == QLatin1Char('"')) { - return {TokenType::String, readQuotedString()}; + bool terminated = false; + const QString text = readQuotedString(&terminated); + if (!terminated) { + return {TokenType::Invalid, text}; + } + return {TokenType::String, text}; } return {TokenType::String, readBareString()}; @@ -106,14 +111,21 @@ private: } } - QString readQuotedString() + QString readQuotedString(bool *terminated) { QString result; ++m_pos; + if (terminated) { + *terminated = false; + } + while (m_pos < m_input.size()) { const QChar current = m_input.at(m_pos++); if (current == QLatin1Char('"')) { + if (terminated) { + *terminated = true; + } return result; } if (current == QLatin1Char('\\') && m_pos < m_input.size()) { @@ -840,22 +852,24 @@ void GameLauncherProvider::clearLastLaunchError() Q_EMIT lastLaunchErrorChanged(); } -GameLauncherProvider::GameEntry *GameLauncherProvider::findEntryByStorageId(const QString &storageId) +int GameLauncherProvider::findEntryIndexByStorageId(const QString &storageId) const { - for (auto &entry : m_allGames) { - if (entry.storageId == storageId) { - return &entry; + for (int index = 0; index < m_allGames.size(); ++index) { + if (m_allGames.at(index).storageId == storageId) { + return index; } } - return nullptr; + return -1; } void GameLauncherProvider::markLaunchSucceeded(const QString &storageId, const QString &name) { - if (auto *entry = findEntryByStorageId(storageId)) { + const int entryIndex = findEntryIndexByStorageId(storageId); + if (entryIndex >= 0) { + auto &entry = m_allGames[entryIndex]; const auto now = QDateTime::currentDateTime(); - saveRecentTimestamp(entry->storageId, now); - entry->lastPlayed = now; + saveRecentTimestamp(entry.storageId, now); + entry.lastPlayed = now; } setPendingLaunch(name); diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index c9fa156b..c9d3dd09 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -97,7 +97,9 @@ private: void saveRecentTimestamp(const QString &storageId, const QDateTime &when); void applyFilter(); void launchEntry(GameEntry &entry); - GameEntry *findEntryByStorageId(const QString &storageId); + // Returns the current m_allGames index for the storage id. + // Callers must re-lookup after any mutation that can rebuild or reorder the list. + int findEntryIndexByStorageId(const QString &storageId) const; void markLaunchSucceeded(const QString &storageId, const QString &name); void markLaunchFailed(const QString &name, const QString &error); void setPendingLaunch(const QString &name); From a8e76002f4d500699941dc7890d95091c0b2df5b Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Fri, 24 Apr 2026 09:35:28 +0200 Subject: [PATCH 20/26] Improve Waydroid app management Handle unavailable Waydroid sessions more cleanly and add KCM controls for launching apps, Game Shell visibility, and compatibility overrides. --- .../waydroidapplicationlistmodel.cpp | 11 + .../waydroidapplicationlistmodel.h | 1 + .../waydroiddbusclient.cpp | 407 ++++++++++++++++-- .../waydroiddbusclient.h | 41 +- .../waydroiddbusobject.cpp | 82 +++- .../waydroiddbusobject.h | 11 +- .../ui/WaydroidApplicationsPage.qml | 169 +++++++- .../ui/WaydroidConfigurationForm.qml | 89 ++++ ...roidGooglePlayProtectConfigurationPage.qml | 25 +- kcms/waydroidintegration/ui/main.qml | 4 +- 10 files changed, 792 insertions(+), 48 deletions(-) diff --git a/components/waydroidintegrationplugin/waydroidapplicationlistmodel.cpp b/components/waydroidintegrationplugin/waydroidapplicationlistmodel.cpp index 09acae2a..a203009e 100644 --- a/components/waydroidintegrationplugin/waydroidapplicationlistmodel.cpp +++ b/components/waydroidintegrationplugin/waydroidapplicationlistmodel.cpp @@ -35,6 +35,17 @@ void WaydroidApplicationListModel::initializeApplications(const QList roleNames() const override; void initializeApplications(const QList &applicationObjectPaths); + void clearApplications(); public Q_SLOTS: void addApplication(const QDBusObjectPath &objectPath); diff --git a/components/waydroidintegrationplugin/waydroiddbusclient.cpp b/components/waydroidintegrationplugin/waydroiddbusclient.cpp index 45f343c4..3dbe1185 100644 --- a/components/waydroidintegrationplugin/waydroiddbusclient.cpp +++ b/components/waydroidintegrationplugin/waydroiddbusclient.cpp @@ -6,24 +6,38 @@ #include "waydroiddbusclient.h" +#include + #include #include +#include #include +#include using namespace Qt::StringLiterals; +static const QString s_waydroidGamingGroup = QStringLiteral("WaydroidGaming"); +static const QString s_gameShellPackagesKey = QStringLiteral("gameShellPackages"); + WaydroidDBusClient::WaydroidDBusClient(QObject *parent) : QObject{parent} , m_interface{new OrgKdePlasmashellWaydroidInterface{u"org.kde.plasmashell"_s, u"/Waydroid"_s, QDBusConnection::sessionBus(), this}} , m_watcher{new QDBusServiceWatcher{u"org.kde.plasmashell"_s, QDBusConnection::sessionBus(), QDBusServiceWatcher::WatchForOwnerChange, this}} , m_applicationListModel{new WaydroidApplicationListModel{this}} + , m_config{KSharedConfig::openConfig(QStringLiteral("plasmamobilerc"))} { + m_configWatcher = KConfigWatcher::create(m_config); + connect(m_configWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group) { + if (group.name() == s_waydroidGamingGroup) { + m_config->reparseConfiguration(); + reloadGameShellPackages(); + } + }); + reloadGameShellPackages(); + // Check if the service is already running if (QDBusConnection::sessionBus().interface()->isServiceRegistered(u"org.kde.plasmashell"_s)) { - m_connected = true; - if (m_interface->isValid()) { - connectSignals(); - } + checkWaydroidObject(); } connect(m_watcher, &QDBusServiceWatcher::serviceOwnerChanged, this, [this](const QString &service, const QString &oldOwner, const QString &newOwner) { @@ -31,12 +45,11 @@ WaydroidDBusClient::WaydroidDBusClient(QObject *parent) if (newOwner.isEmpty()) { // Service stopped m_connected = false; + m_connectionCheckPending = false; + resetState(); } else if (oldOwner.isEmpty()) { // Service started - m_connected = true; - if (m_interface->isValid()) { - connectSignals(); - } + checkWaydroidObject(); } } }); @@ -44,26 +57,32 @@ WaydroidDBusClient::WaydroidDBusClient(QObject *parent) void WaydroidDBusClient::connectSignals() { - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::statusChanged, this, &WaydroidDBusClient::updateStatus); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::downloadStatusChanged, this, [this](double downloaded, double total, double speed) { - Q_EMIT downloadStatusChanged(downloaded, total, speed); - }); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::sessionStatusChanged, this, &WaydroidDBusClient::updateSessionStatus); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::systemTypeChanged, this, &WaydroidDBusClient::updateSystemType); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ipAddressChanged, this, &WaydroidDBusClient::updateIpAddress); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::androidIdChanged, this, &WaydroidDBusClient::updateAndroidId); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::multiWindowsChanged, this, &WaydroidDBusClient::updateMultiWindows); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::suspendChanged, this, &WaydroidDBusClient::updateSuspend); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ueventChanged, this, &WaydroidDBusClient::updateUevent); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFinished, this, [this](const QString message) { - Q_EMIT actionFinished(message); - }); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFailed, this, [this](const QString message) { - Q_EMIT actionFailed(message); - }); - connect(m_interface, &OrgKdePlasmashellWaydroidInterface::errorOccurred, this, [this](const QString title, const QString message) { - Q_EMIT errorOccurred(title, message); - }); + if (!m_signalsConnected) { + m_signalsConnected = true; + + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::statusChanged, this, &WaydroidDBusClient::updateStatus); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::downloadStatusChanged, this, [this](double downloaded, double total, double speed) { + Q_EMIT downloadStatusChanged(downloaded, total, speed); + }); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::sessionStatusChanged, this, &WaydroidDBusClient::updateSessionStatus); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::systemTypeChanged, this, &WaydroidDBusClient::updateSystemType); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ipAddressChanged, this, &WaydroidDBusClient::updateIpAddress); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::androidIdChanged, this, &WaydroidDBusClient::updateAndroidId); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::multiWindowsChanged, this, &WaydroidDBusClient::updateMultiWindows); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::suspendChanged, this, &WaydroidDBusClient::updateSuspend); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::ueventChanged, this, &WaydroidDBusClient::updateUevent); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::fakeTouchChanged, this, &WaydroidDBusClient::updateFakeTouch); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::fakeWifiChanged, this, &WaydroidDBusClient::updateFakeWifi); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFinished, this, [this](const QString message) { + Q_EMIT actionFinished(message); + }); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::actionFailed, this, [this](const QString message) { + Q_EMIT actionFailed(message); + }); + connect(m_interface, &OrgKdePlasmashellWaydroidInterface::errorOccurred, this, [this](const QString title, const QString message) { + Q_EMIT errorOccurred(title, message); + }); + } initializeApplicationListModel(); updateStatus(); @@ -74,6 +93,118 @@ void WaydroidDBusClient::connectSignals() updateMultiWindows(); updateSuspend(); updateUevent(); + updateFakeTouch(); + updateFakeWifi(); +} + +void WaydroidDBusClient::checkWaydroidObject() +{ + if (m_connectionCheckPending) { + return; + } + + m_connectionCheckPending = true; + + const QDBusMessage message = + QDBusMessage::createMethodCall(u"org.kde.plasmashell"_s, u"/Waydroid"_s, u"org.freedesktop.DBus.Introspectable"_s, u"Introspect"_s); + auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, &WaydroidDBusClient::onWaydroidObjectCheckFinished); +} + +void WaydroidDBusClient::onWaydroidObjectCheckFinished(QDBusPendingCallWatcher *watcher) +{ + m_connectionCheckPending = false; + + QDBusPendingReply reply = *watcher; + if (!reply.isValid()) { + m_connected = false; + watcher->deleteLater(); + resetState(); + scheduleWaydroidObjectCheck(); + return; + } + + m_connected = true; + watcher->deleteLater(); + + if (m_interface->isValid()) { + connectSignals(); + } +} + +void WaydroidDBusClient::handleUnavailableReply() +{ + if (!m_connected) { + return; + } + + m_connected = false; + resetState(); + scheduleWaydroidObjectCheck(); +} + +void WaydroidDBusClient::resetState() +{ + if (m_status != NotSupported) { + m_status = NotSupported; + Q_EMIT statusChanged(); + } + + if (m_sessionStatus != SessionStopped) { + m_sessionStatus = SessionStopped; + Q_EMIT sessionStatusChanged(); + } + + if (m_systemType != UnknownSystemType) { + m_systemType = UnknownSystemType; + Q_EMIT systemTypeChanged(); + } + + if (!m_ipAddress.isEmpty()) { + m_ipAddress.clear(); + Q_EMIT ipAddressChanged(); + } + + if (!m_androidId.isEmpty()) { + m_androidId.clear(); + Q_EMIT androidIdChanged(); + } + + if (m_multiWindows) { + m_multiWindows = false; + Q_EMIT multiWindowsChanged(); + } + + if (m_suspend) { + m_suspend = false; + Q_EMIT suspendChanged(); + } + + if (m_uevent) { + m_uevent = false; + Q_EMIT ueventChanged(); + } + + if (!m_fakeTouch.isEmpty()) { + m_fakeTouch.clear(); + Q_EMIT fakeTouchChanged(); + } + + if (!m_fakeWifi.isEmpty()) { + m_fakeWifi.clear(); + Q_EMIT fakeWifiChanged(); + } + + m_applicationListModel->clearApplications(); +} + +void WaydroidDBusClient::scheduleWaydroidObjectCheck() +{ + if (!QDBusConnection::sessionBus().interface()->isServiceRegistered(u"org.kde.plasmashell"_s)) { + return; + } + + QTimer::singleShot(1000, this, &WaydroidDBusClient::checkWaydroidObject); } void WaydroidDBusClient::initializeApplicationListModel() @@ -85,6 +216,7 @@ void WaydroidDBusClient::initializeApplicationListModel() QDBusPendingReply> reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch applications:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -133,6 +265,10 @@ WaydroidApplicationListModel *WaydroidDBusClient::applicationListModel() const QCoro::Task WaydroidDBusClient::setMultiWindowsTask(const bool multiWindows) { + if (!m_connected) { + co_return; + } + co_await m_interface->setMultiWindows(multiWindows); } @@ -148,6 +284,10 @@ bool WaydroidDBusClient::multiWindows() const QCoro::Task WaydroidDBusClient::setSuspendTask(const bool suspend) { + if (!m_connected) { + co_return; + } + co_await m_interface->setSuspend(suspend); } @@ -163,6 +303,10 @@ bool WaydroidDBusClient::suspend() const QCoro::Task WaydroidDBusClient::setUeventTask(const bool uevent) { + if (!m_connected) { + co_return; + } + co_await m_interface->setUevent(uevent); } @@ -173,6 +317,10 @@ QCoro::QmlTask WaydroidDBusClient::setUevent(const bool uevent) QCoro::Task WaydroidDBusClient::refreshSessionInfoTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->refreshSessionInfo(); } @@ -183,6 +331,10 @@ QCoro::QmlTask WaydroidDBusClient::refreshSessionInfo() QCoro::Task WaydroidDBusClient::refreshAndroidIdTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->refreshAndroidId(); } @@ -193,6 +345,10 @@ QCoro::QmlTask WaydroidDBusClient::refreshAndroidId() QCoro::Task WaydroidDBusClient::refreshApplicationsTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->refreshApplications(); } @@ -201,13 +357,87 @@ QCoro::QmlTask WaydroidDBusClient::refreshApplications() return refreshApplicationsTask(); } +bool WaydroidDBusClient::gameShellEnabledForPackage(const QString &packageName) const +{ + return m_gameShellPackages.contains(packageName); +} + +void WaydroidDBusClient::setGameShellEnabledForPackage(const QString &packageName, bool enabled) +{ + QStringList packages = m_gameShellPackages; + packages.removeAll(packageName); + if (enabled) { + packages.append(packageName); + } + packages.removeDuplicates(); + packages.sort(); + + if (packages == m_gameShellPackages) { + return; + } + + KConfigGroup group(m_config, s_waydroidGamingGroup); + group.writeEntry(s_gameShellPackagesKey, packages, KConfigGroup::Notify); + m_config->sync(); + + m_gameShellPackages = packages; + Q_EMIT gameShellPackagesChanged(); +} + bool WaydroidDBusClient::uevent() const { return m_uevent; } +QCoro::Task WaydroidDBusClient::setFakeTouchTask(const QString &fakeTouch) +{ + if (!m_connected) { + co_return; + } + + co_await m_interface->setFakeTouch(fakeTouch); +} + +QCoro::QmlTask WaydroidDBusClient::setFakeTouch(const QString &fakeTouch) +{ + return setFakeTouchTask(fakeTouch); +} + +QString WaydroidDBusClient::fakeTouch() const +{ + return m_fakeTouch; +} + +QCoro::Task WaydroidDBusClient::setFakeWifiTask(const QString &fakeWifi) +{ + if (!m_connected) { + co_return; + } + + co_await m_interface->setFakeWifi(fakeWifi); +} + +QCoro::QmlTask WaydroidDBusClient::setFakeWifi(const QString &fakeWifi) +{ + return setFakeWifiTask(fakeWifi); +} + +QString WaydroidDBusClient::fakeWifi() const +{ + return m_fakeWifi; +} + +QStringList WaydroidDBusClient::gameShellPackages() const +{ + return m_gameShellPackages; +} + QCoro::Task WaydroidDBusClient::initializeTask(const SystemType systemType, const RomType romType, const bool forced) { + if (!m_connected) { + co_return; + } + co_await m_interface->initialize(systemType, romType, forced); } @@ -218,6 +448,10 @@ QCoro::QmlTask WaydroidDBusClient::initialize(const SystemType systemType, const QCoro::Task WaydroidDBusClient::startSessionTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->startSession(); } @@ -228,6 +462,10 @@ QCoro::QmlTask WaydroidDBusClient::startSession() QCoro::Task WaydroidDBusClient::stopSessionTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->stopSession(); } @@ -238,6 +476,10 @@ QCoro::QmlTask WaydroidDBusClient::stopSession() QCoro::Task WaydroidDBusClient::resetWaydroidTask() { + if (!m_connected) { + co_return; + } + co_await m_interface->resetWaydroid(); } @@ -248,6 +490,10 @@ QCoro::QmlTask WaydroidDBusClient::resetWaydroid() QCoro::Task WaydroidDBusClient::installApkTask(const QString apkFile) { + if (!m_connected) { + co_return; + } + co_await m_interface->installApk(apkFile); } @@ -256,8 +502,26 @@ QCoro::QmlTask WaydroidDBusClient::installApk(const QString apkFile) return installApkTask(apkFile); } +QCoro::Task WaydroidDBusClient::launchApplicationTask(const QString appId) +{ + if (!m_connected) { + co_return; + } + + co_await m_interface->launchApplication(appId); +} + +QCoro::QmlTask WaydroidDBusClient::launchApplication(const QString appId) +{ + return launchApplicationTask(appId); +} + QCoro::Task WaydroidDBusClient::deleteApplicationTask(const QString appId) { + if (!m_connected) { + co_return; + } + co_await m_interface->deleteApplication(appId); } @@ -266,6 +530,20 @@ QCoro::QmlTask WaydroidDBusClient::deleteApplication(const QString appId) return deleteApplicationTask(appId); } +QCoro::Task WaydroidDBusClient::refreshSupportsInfoTask() +{ + if (!m_connected) { + co_return; + } + + co_await m_interface->refreshSupportsInfo(); +} + +QCoro::QmlTask WaydroidDBusClient::refreshSupportsInfo() +{ + return refreshSupportsInfoTask(); +} + void WaydroidDBusClient::updateStatus() { auto reply = m_interface->status(); @@ -275,6 +553,7 @@ void WaydroidDBusClient::updateStatus() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch status:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -299,6 +578,7 @@ void WaydroidDBusClient::updateSessionStatus() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch sessionStatus:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -323,6 +603,7 @@ void WaydroidDBusClient::updateSystemType() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch systemType:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -347,6 +628,7 @@ void WaydroidDBusClient::updateIpAddress() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch ipAddress:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -371,6 +653,7 @@ void WaydroidDBusClient::updateAndroidId() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch androidId:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -395,6 +678,7 @@ void WaydroidDBusClient::updateMultiWindows() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch multiWindows:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -419,6 +703,7 @@ void WaydroidDBusClient::updateSuspend() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch suspend:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -443,6 +728,7 @@ void WaydroidDBusClient::updateUevent() QDBusPendingReply reply = *watcher; if (!reply.isValid()) { qDebug() << "WaydroidDBusClient: Failed to fetch uevent:" << reply.error().message(); + handleUnavailableReply(); watcher->deleteLater(); return; } @@ -458,7 +744,72 @@ void WaydroidDBusClient::updateUevent() }); } +void WaydroidDBusClient::updateFakeTouch() +{ + auto reply = m_interface->fakeTouch(); + auto watcher = new QDBusPendingCallWatcher(reply, this); + + connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](auto watcher) { + QDBusPendingReply reply = *watcher; + if (!reply.isValid()) { + qDebug() << "WaydroidDBusClient: Failed to fetch fakeTouch:" << reply.error().message(); + handleUnavailableReply(); + watcher->deleteLater(); + return; + } + + const QString fakeTouch = reply.argumentAt<0>(); + + if (m_fakeTouch != fakeTouch) { + m_fakeTouch = fakeTouch; + Q_EMIT fakeTouchChanged(); + } + + watcher->deleteLater(); + }); +} + +void WaydroidDBusClient::updateFakeWifi() +{ + auto reply = m_interface->fakeWifi(); + auto watcher = new QDBusPendingCallWatcher(reply, this); + + connect(watcher, &QDBusPendingCallWatcher::finished, this, [this](auto watcher) { + QDBusPendingReply reply = *watcher; + if (!reply.isValid()) { + qDebug() << "WaydroidDBusClient: Failed to fetch fakeWifi:" << reply.error().message(); + handleUnavailableReply(); + watcher->deleteLater(); + return; + } + + const QString fakeWifi = reply.argumentAt<0>(); + + if (m_fakeWifi != fakeWifi) { + m_fakeWifi = fakeWifi; + Q_EMIT fakeWifiChanged(); + } + + watcher->deleteLater(); + }); +} + void WaydroidDBusClient::copyToClipboard(const QString text) { qGuiApp->clipboard()->setText(text); } + +void WaydroidDBusClient::reloadGameShellPackages() +{ + const KConfigGroup group(m_config, s_waydroidGamingGroup); + QStringList packages = group.readEntry(s_gameShellPackagesKey, QStringList{}); + packages.removeDuplicates(); + packages.sort(); + + if (m_gameShellPackages == packages) { + return; + } + + m_gameShellPackages = packages; + Q_EMIT gameShellPackagesChanged(); +} diff --git a/components/waydroidintegrationplugin/waydroiddbusclient.h b/components/waydroidintegrationplugin/waydroiddbusclient.h index 1b707936..7836f2f0 100644 --- a/components/waydroidintegrationplugin/waydroiddbusclient.h +++ b/components/waydroidintegrationplugin/waydroiddbusclient.h @@ -10,8 +10,12 @@ #include "waydroidapplicationlistmodel.h" #include "waydroiddbusobject.h" +#include +#include + #include #include +#include #include #include #include @@ -32,6 +36,9 @@ class WaydroidDBusClient : public QObject Q_PROPERTY(bool multiWindows READ multiWindows WRITE setMultiWindows NOTIFY multiWindowsChanged) Q_PROPERTY(bool suspend READ suspend WRITE setSuspend NOTIFY suspendChanged) Q_PROPERTY(bool uevent READ uevent WRITE setUevent NOTIFY ueventChanged) + Q_PROPERTY(QString fakeTouch READ fakeTouch WRITE setFakeTouch NOTIFY fakeTouchChanged) + Q_PROPERTY(QString fakeWifi READ fakeWifi WRITE setFakeWifi NOTIFY fakeWifiChanged) + Q_PROPERTY(QStringList gameShellPackages READ gameShellPackages NOTIFY gameShellPackagesChanged) Q_PROPERTY(WaydroidApplicationListModel *applicationListModel READ applicationListModel CONSTANT) public: @@ -98,16 +105,25 @@ public: QCoro::QmlTask setSuspend(const bool suspend); [[nodiscard]] bool uevent() const; QCoro::QmlTask setUevent(const bool uevent); + [[nodiscard]] QString fakeTouch() const; + QCoro::QmlTask setFakeTouch(const QString &fakeTouch); + [[nodiscard]] QString fakeWifi() const; + QCoro::QmlTask setFakeWifi(const QString &fakeWifi); + [[nodiscard]] QStringList gameShellPackages() const; Q_INVOKABLE QCoro::QmlTask initialize(const SystemType systemType, const RomType romType, const bool forced = false); Q_INVOKABLE QCoro::QmlTask startSession(); Q_INVOKABLE QCoro::QmlTask stopSession(); Q_INVOKABLE QCoro::QmlTask resetWaydroid(); Q_INVOKABLE QCoro::QmlTask installApk(const QString apkFile); + Q_INVOKABLE QCoro::QmlTask launchApplication(const QString appId); Q_INVOKABLE QCoro::QmlTask deleteApplication(const QString appId); + Q_INVOKABLE QCoro::QmlTask refreshSupportsInfo(); Q_INVOKABLE QCoro::QmlTask refreshSessionInfo(); Q_INVOKABLE QCoro::QmlTask refreshAndroidId(); Q_INVOKABLE QCoro::QmlTask refreshApplications(); + Q_INVOKABLE bool gameShellEnabledForPackage(const QString &packageName) const; + Q_INVOKABLE void setGameShellEnabledForPackage(const QString &packageName, bool enabled); Q_INVOKABLE void copyToClipboard(const QString text); @@ -122,6 +138,9 @@ Q_SIGNALS: void multiWindowsChanged(); void suspendChanged(); void ueventChanged(); + void fakeTouchChanged(); + void fakeWifiChanged(); + void gameShellPackagesChanged(); void actionFinished(const QString message); void actionFailed(const QString message); @@ -136,12 +155,14 @@ private Q_SLOTS: void updateMultiWindows(); void updateSuspend(); void updateUevent(); + void updateFakeTouch(); + void updateFakeWifi(); private: OrgKdePlasmashellWaydroidInterface *m_interface; QDBusServiceWatcher *m_watcher; - Status m_status{NotInitialized}; + Status m_status{NotSupported}; SessionStatus m_sessionStatus{SessionStopped}; SystemType m_systemType{UnknownSystemType}; QString m_ipAddress{""}; @@ -152,22 +173,40 @@ private: bool m_multiWindows{false}; bool m_suspend{false}; bool m_uevent{false}; + QString m_fakeTouch; + QString m_fakeWifi; + KSharedConfig::Ptr m_config; + KConfigWatcher::Ptr m_configWatcher; + QStringList m_gameShellPackages; bool m_connected{false}; + bool m_signalsConnected{false}; + bool m_connectionCheckPending{false}; void connectSignals(); void initializeApplicationListModel(); + void checkWaydroidObject(); + void handleUnavailableReply(); + void onWaydroidObjectCheckFinished(QDBusPendingCallWatcher *watcher); + void resetState(); + void scheduleWaydroidObjectCheck(); QCoro::Task initializeTask(const SystemType systemType, const RomType romType, const bool forced = false); QCoro::Task startSessionTask(); QCoro::Task stopSessionTask(); QCoro::Task resetWaydroidTask(); QCoro::Task installApkTask(const QString apkFile); + QCoro::Task launchApplicationTask(const QString appId); QCoro::Task deleteApplicationTask(const QString appId); + QCoro::Task refreshSupportsInfoTask(); QCoro::Task setMultiWindowsTask(const bool multiWindows); QCoro::Task setSuspendTask(const bool suspend); QCoro::Task setUeventTask(const bool uevent); + QCoro::Task setFakeTouchTask(const QString &fakeTouch); + QCoro::Task setFakeWifiTask(const QString &fakeWifi); QCoro::Task refreshSessionInfoTask(); QCoro::Task refreshAndroidIdTask(); QCoro::Task refreshApplicationsTask(); + + void reloadGameShellPackages(); }; \ No newline at end of file diff --git a/components/waydroidintegrationplugin/waydroiddbusobject.cpp b/components/waydroidintegrationplugin/waydroiddbusobject.cpp index 17ae31f5..686580ac 100644 --- a/components/waydroidintegrationplugin/waydroiddbusobject.cpp +++ b/components/waydroidintegrationplugin/waydroiddbusobject.cpp @@ -31,6 +31,8 @@ using namespace Qt::StringLiterals; #define MULTI_WINDOWS_PROP_KEY "persist.waydroid.multi_windows" #define SUSPEND_PROP_KEY "persist.waydroid.suspend" #define UEVENT_PROP_KEY "persist.waydroid.uevent" +#define FAKE_TOUCH_PROP_KEY "persist.waydroid.fake_touch" +#define FAKE_WIFI_PROP_KEY "persist.waydroid.fake_wifi" static const QRegularExpression sessionRegExp(u"Session:\\s*(\\w+)"_s); static const QRegularExpression ipAddressRegExp(u"IP address:\\s*(\\d+\\.\\d+\\.\\d+\\.\\d+)"_s); @@ -179,10 +181,12 @@ void WaydroidDBusObject::stopSession() connect(process, &QProcess::finished, this, [this, process](int exitCode, QProcess::ExitStatus exitStatus) { Q_UNUSED(exitStatus); + const QByteArray errorLog = process->readAllStandardError(); process->deleteLater(); - if (exitCode == 0) { - qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Failed to stop the Waydroid session: " << process->readAllStandardError(); + if (exitCode != 0) { + Q_EMIT errorOccurred(i18n("Failed to stop the Waydroid session."), QString::fromUtf8(errorLog)); + qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Failed to stop the Waydroid session: " << errorLog; return; } @@ -250,6 +254,27 @@ void WaydroidDBusObject::installApk(const QString apkFile) process->start(WAYDROID_COMMAND, arguments); } +void WaydroidDBusObject::launchApplication(const QString appId) +{ + const QStringList arguments{u"app"_s, u"launch"_s, appId}; + + QProcess *process = new QProcess(this); + + connect(process, &QProcess::finished, this, [this, appId, process](int exitCode, QProcess::ExitStatus exitStatus) { + const QByteArray errorLog = process->readAllStandardError(); + process->deleteLater(); + + if (exitCode == 0 && exitStatus == QProcess::NormalExit) { + Q_EMIT actionFinished(i18n("Application has been launched")); + } else { + Q_EMIT actionFailed(i18n("Application launch failed")); + qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "Error occurred while launching " << appId << ": " << errorLog; + } + }); + + process->start(WAYDROID_COMMAND, arguments); +} + void WaydroidDBusObject::deleteApplication(const QString appId) { const QStringList arguments{u"app"_s, u"remove"_s, appId}; @@ -375,6 +400,52 @@ void WaydroidDBusObject::setUevent(const bool uevent) coro(this, value, uevent); } +QString WaydroidDBusObject::fakeTouch() const +{ + return m_fakeTouch; +} + +void WaydroidDBusObject::setFakeTouch(const QString &fakeTouch) +{ + if (m_fakeTouch == fakeTouch) { + return; + } + + auto coro = [](WaydroidDBusObject *self, QString fakeTouch) -> QCoro::Task { + QPointer guard(self); + if (co_await self->writePropValue(FAKE_TOUCH_PROP_KEY, fakeTouch)) { + if (guard) { + self->m_fakeTouch = fakeTouch; + Q_EMIT self->fakeTouchChanged(); + } + } + }; + coro(this, fakeTouch); +} + +QString WaydroidDBusObject::fakeWifi() const +{ + return m_fakeWifi; +} + +void WaydroidDBusObject::setFakeWifi(const QString &fakeWifi) +{ + if (m_fakeWifi == fakeWifi) { + return; + } + + auto coro = [](WaydroidDBusObject *self, QString fakeWifi) -> QCoro::Task { + QPointer guard(self); + if (co_await self->writePropValue(FAKE_WIFI_PROP_KEY, fakeWifi)) { + if (guard) { + self->m_fakeWifi = fakeWifi; + Q_EMIT self->fakeWifiChanged(); + } + } + }; + coro(this, fakeWifi); +} + QList WaydroidDBusObject::applications() const { QList paths; @@ -513,6 +584,7 @@ void WaydroidDBusObject::refreshAndroidId() } } else { m_androidId = ""; + Q_EMIT actionFailed(i18n("Failed to fetch Android ID")); qCWarning(WAYDROIDINTEGRATIONPLUGIN) << "KAuth returned an error code:" << executeJob->error(); } @@ -539,6 +611,12 @@ QCoro::Task WaydroidDBusObject::refreshPropsInfo() const QString ueventPropValue = co_await fetchPropValue(UEVENT_PROP_KEY, "false"); m_uevent = ueventPropValue == "true"; Q_EMIT ueventChanged(); + + m_fakeTouch = co_await fetchPropValue(FAKE_TOUCH_PROP_KEY, QString{}); + Q_EMIT fakeTouchChanged(); + + m_fakeWifi = co_await fetchPropValue(FAKE_WIFI_PROP_KEY, QString{}); + Q_EMIT fakeWifiChanged(); } QCoro::Task WaydroidDBusObject::fetchPropValue(const QString key, const QString defaultValue) diff --git a/components/waydroidintegrationplugin/waydroiddbusobject.h b/components/waydroidintegrationplugin/waydroiddbusobject.h index 82e2ba3c..c1c5d1fb 100644 --- a/components/waydroidintegrationplugin/waydroiddbusobject.h +++ b/components/waydroidintegrationplugin/waydroiddbusobject.h @@ -96,6 +96,8 @@ Q_SIGNALS: Q_SCRIPTABLE void multiWindowsChanged(); Q_SCRIPTABLE void suspendChanged(); Q_SCRIPTABLE void ueventChanged(); + Q_SCRIPTABLE void fakeTouchChanged(); + Q_SCRIPTABLE void fakeWifiChanged(); Q_SCRIPTABLE void applicationAdded(QDBusObjectPath path); Q_SCRIPTABLE void applicationRemoved(QDBusObjectPath path); @@ -119,6 +121,10 @@ public Q_SLOTS: Q_SCRIPTABLE void setSuspend(const bool suspend); Q_SCRIPTABLE bool uevent() const; Q_SCRIPTABLE void setUevent(const bool uevent); + Q_SCRIPTABLE QString fakeTouch() const; + Q_SCRIPTABLE void setFakeTouch(const QString &fakeTouch); + Q_SCRIPTABLE QString fakeWifi() const; + Q_SCRIPTABLE void setFakeWifi(const QString &fakeWifi); Q_SCRIPTABLE QList applications() const; Q_SCRIPTABLE void initialize(const int systemType, const int romType, const bool forced = false); @@ -126,7 +132,9 @@ public Q_SLOTS: Q_SCRIPTABLE void stopSession(); Q_SCRIPTABLE void resetWaydroid(); Q_SCRIPTABLE void installApk(const QString apkFile); + Q_SCRIPTABLE void launchApplication(const QString appId); Q_SCRIPTABLE void deleteApplication(const QString appId); + Q_SCRIPTABLE void refreshSupportsInfo(); Q_SCRIPTABLE void refreshSessionInfo(); Q_SCRIPTABLE void refreshAndroidId(); Q_SCRIPTABLE void refreshApplications(); @@ -143,8 +151,9 @@ private: bool m_multiWindows{false}; bool m_suspend{false}; bool m_uevent{false}; + QString m_fakeTouch; + QString m_fakeWifi; - void refreshSupportsInfo(); void refreshInstallationInfo(); QCoro::Task refreshPropsInfo(); diff --git a/kcms/waydroidintegration/ui/WaydroidApplicationsPage.qml b/kcms/waydroidintegration/ui/WaydroidApplicationsPage.qml index 7ee54b11..941df96e 100644 --- a/kcms/waydroidintegration/ui/WaydroidApplicationsPage.qml +++ b/kcms/waydroidintegration/ui/WaydroidApplicationsPage.qml @@ -17,6 +17,45 @@ import org.kde.plasma.private.mobileshell.waydroidintegrationplugin as AIP KCM.SimpleKCM { id: root + function packagePatterns(value: string): var { + if (value === "") { + return [] + } + + return value.split(",") + .map(pattern => pattern.trim()) + .filter(pattern => pattern !== "") + } + + function wildcardRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&") + return new RegExp("^" + escaped.replace(/\*/g, ".*") + "$") + } + + function hasExactPackage(value: string, packageName: string): bool { + return packagePatterns(value).includes(packageName) + } + + function hasWildcardPackage(value: string, packageName: string): bool { + return packagePatterns(value) + .filter(pattern => pattern.includes("*")) + .some(pattern => wildcardRegExp(pattern).test(packageName)) + } + + function hasEffectivePackage(value: string, packageName: string): bool { + return hasExactPackage(value, packageName) || hasWildcardPackage(value, packageName) + } + + function updateExactPackage(value: string, packageName: string, enabled: bool): string { + const updatedPatterns = packagePatterns(value).filter(pattern => pattern !== packageName) + + if (enabled) { + updatedPatterns.push(packageName) + } + + return updatedPatterns.join(",") + } + topPadding: Kirigami.Units.largeSpacing bottomPadding: Kirigami.Units.largeSpacing leftPadding: 0 @@ -74,6 +113,7 @@ KCM.SimpleKCM { } ColumnLayout { + visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized spacing: Kirigami.Units.largeSpacing Kirigami.InlineMessage { @@ -85,6 +125,11 @@ KCM.SimpleKCM { showCloseButton: true } + Kirigami.PlaceholderMessage { + Layout.fillWidth: true + explanation: i18n("This page manages the launchers exported by Waydroid. Enable Show in Game Shell for Android apps you want listed in Game Center's Waydroid tab. Touch and Wi-Fi toggles add or remove exact package names from Waydroid's documented compatibility property lists. Wildcard rules remain in the main Waydroid properties page.") + } + FormCard.FormCard { Repeater { model: AIP.WaydroidDBusClient.applicationListModel @@ -95,23 +140,105 @@ KCM.SimpleKCM { width: ListView.view.width background: null - contentItem: RowLayout { - QQC2.Label { + contentItem: ColumnLayout { + spacing: Kirigami.Units.smallSpacing + + RowLayout { Layout.fillWidth: true - text: model.name - elide: Text.ElideRight + + QQC2.Label { + Layout.fillWidth: true + text: model.name + elide: Text.ElideRight + } + + QQC2.ToolButton { + display: QQC2.AbstractButton.IconOnly + text: i18nc("@action:button", "Launch the application") + icon.name: "media-playback-start" + + onClicked: AIP.WaydroidDBusClient.launchApplication(model.id) + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: text + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + QQC2.ToolButton { + display: QQC2.AbstractButton.IconOnly + text: i18nc("@action:button", "Delete the application") + icon.name: "usermenu-delete" + + onClicked: AIP.WaydroidDBusClient.deleteApplication(model.id) + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: text + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } } - QQC2.ToolButton { - display: QQC2.AbstractButton.IconOnly - text: i18nc("@action:button", "Delete the application") - icon.name: "usermenu-delete" + RowLayout { + Layout.fillWidth: true - onClicked: AIP.WaydroidDBusClient.deleteApplication(model.id) + QQC2.CheckBox { + text: i18n("Show in Game Shell") + checked: AIP.WaydroidDBusClient.gameShellPackages.indexOf(model.id) !== -1 - QQC2.ToolTip.visible: hovered - QQC2.ToolTip.text: text - QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + onClicked: AIP.WaydroidDBusClient.setGameShellEnabledForPackage(model.id, checked) + } + + Item { + Layout.fillWidth: true + } + } + + QQC2.Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + color: Kirigami.Theme.disabledTextColor + text: i18n("Adds this app to the Game Shell allowlist so it appears in Game Center under Waydroid.") + } + + RowLayout { + Layout.fillWidth: true + + QQC2.CheckBox { + id: fakeTouchToggle + text: i18n("Touch") + checked: root.hasEffectivePackage(AIP.WaydroidDBusClient.fakeTouch, model.id) + enabled: !root.hasWildcardPackage(AIP.WaydroidDBusClient.fakeTouch, model.id) + + onClicked: { + AIP.WaydroidDBusClient.fakeTouch = root.updateExactPackage(AIP.WaydroidDBusClient.fakeTouch, model.id, checked) + } + + QQC2.ToolTip.visible: hovered && !enabled + QQC2.ToolTip.text: i18n("Managed by a wildcard pattern in Waydroid properties") + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + QQC2.CheckBox { + id: fakeWifiToggle + text: i18n("Wi-Fi") + checked: root.hasEffectivePackage(AIP.WaydroidDBusClient.fakeWifi, model.id) + enabled: !root.hasWildcardPackage(AIP.WaydroidDBusClient.fakeWifi, model.id) + + onClicked: { + AIP.WaydroidDBusClient.fakeWifi = root.updateExactPackage(AIP.WaydroidDBusClient.fakeWifi, model.id, checked) + } + + QQC2.ToolTip.visible: hovered && !enabled + QQC2.ToolTip.text: i18n("Managed by a wildcard pattern in Waydroid properties") + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + + QQC2.Label { + Layout.fillWidth: true + visible: !fakeTouchToggle.enabled || !fakeWifiToggle.enabled + wrapMode: Text.WordWrap + color: Kirigami.Theme.disabledTextColor + text: i18n("One or more compatibility settings for this app come from a wildcard rule. Edit the global Waydroid property to change that rule.") } } } @@ -121,4 +248,22 @@ KCM.SimpleKCM { } } } + + ColumnLayout { + visible: AIP.WaydroidDBusClient.status !== AIP.WaydroidDBusClient.Initialized + anchors.centerIn: parent + spacing: Kirigami.Units.largeSpacing + + QQC2.Label { + text: i18n("Waydroid is unavailable") + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + } + + PC3.Button { + text: i18n("Check again") + Layout.alignment: Qt.AlignHCenter + onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo() + } + } } diff --git a/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml b/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml index 88b51696..1858084c 100644 --- a/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml +++ b/kcms/waydroidintegration/ui/WaydroidConfigurationForm.qml @@ -15,6 +15,13 @@ import org.kde.plasma.private.mobileshell.waydroidintegrationplugin as AIP ColumnLayout { id: root + visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized + && AIP.WaydroidDBusClient.sessionStatus === AIP.WaydroidDBusClient.SessionRunning + + function packagePatternSummary(value: string): string { + return value === "" ? i18n("Not set") : value + } + FormCard.FormHeader { title: i18n("General information") } @@ -66,6 +73,64 @@ ColumnLayout { onAccepted: AIP.WaydroidDBusClient.resetWaydroid() } + Kirigami.PromptDialog { + id: fakeTouchDialog + title: i18n("Touch input override") + standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel + + onOpened: { + fakeTouchField.text = AIP.WaydroidDBusClient.fakeTouch + fakeTouchField.forceActiveFocus() + } + + onAccepted: AIP.WaydroidDBusClient.fakeTouch = fakeTouchField.text.trim() + + ColumnLayout { + spacing: Kirigami.Units.largeSpacing + + QQC2.Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: i18n("Comma-separated package names for apps where mouse input should be interpreted as touch. Supports * wildcards. Leave empty to clear the override.") + } + + QQC2.TextField { + id: fakeTouchField + Layout.fillWidth: true + placeholderText: "com.rovio.*" + } + } + } + + Kirigami.PromptDialog { + id: fakeWifiDialog + title: i18n("Wi-Fi override") + standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel + + onOpened: { + fakeWifiField.text = AIP.WaydroidDBusClient.fakeWifi + fakeWifiField.forceActiveFocus() + } + + onAccepted: AIP.WaydroidDBusClient.fakeWifi = fakeWifiField.text.trim() + + ColumnLayout { + spacing: Kirigami.Units.largeSpacing + + QQC2.Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: i18n("Comma-separated package names for apps that should always appear to be on Wi-Fi. Supports * wildcards. Leave empty to clear the override.") + } + + QQC2.TextField { + id: fakeWifiField + Layout.fillWidth: true + placeholderText: "com.gameloft.*" + } + } + } + } // Some information such as IP address can take time to be set by Waydroid @@ -140,5 +205,29 @@ ColumnLayout { infoMessage.visible = true } } + + FormCard.FormDelegateSeparator { above: uevent; below: fakeTouch } + + FormCard.FormTextDelegate { + id: fakeTouch + text: i18n("Touch input override") + description: root.packagePatternSummary(AIP.WaydroidDBusClient.fakeTouch) + trailing: PC3.Button { + text: i18n("Edit") + onClicked: fakeTouchDialog.open() + } + } + + FormCard.FormDelegateSeparator { above: fakeTouch; below: fakeWifi } + + FormCard.FormTextDelegate { + id: fakeWifi + text: i18n("Wi-Fi override") + description: root.packagePatternSummary(AIP.WaydroidDBusClient.fakeWifi) + trailing: PC3.Button { + text: i18n("Edit") + onClicked: fakeWifiDialog.open() + } + } } } diff --git a/kcms/waydroidintegration/ui/WaydroidGooglePlayProtectConfigurationPage.qml b/kcms/waydroidintegration/ui/WaydroidGooglePlayProtectConfigurationPage.qml index 1087bcb4..89aa2472 100644 --- a/kcms/waydroidintegration/ui/WaydroidGooglePlayProtectConfigurationPage.qml +++ b/kcms/waydroidintegration/ui/WaydroidGooglePlayProtectConfigurationPage.qml @@ -29,7 +29,9 @@ KCM.SimpleKCM { } WaydroidLoader { - visible: AIP.WaydroidDBusClient.androidId === "" + visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized + && AIP.WaydroidDBusClient.androidId === "" + && !inlineMessage.visible text: i18n("Fetching your Android ID.\nIt can take a few seconds.") } @@ -53,7 +55,8 @@ KCM.SimpleKCM { } ColumnLayout { - visible: AIP.WaydroidDBusClient.androidId !== "" + visible: AIP.WaydroidDBusClient.status === AIP.WaydroidDBusClient.Initialized + && AIP.WaydroidDBusClient.androidId !== "" anchors.verticalCenter: parent.verticalCenter anchors.left: parent anchors.leftMargin: Kirigami.Units.largeSpacing @@ -76,4 +79,22 @@ KCM.SimpleKCM { } } } + + ColumnLayout { + visible: AIP.WaydroidDBusClient.status !== AIP.WaydroidDBusClient.Initialized + anchors.centerIn: parent + spacing: Kirigami.Units.largeSpacing + + QQC2.Label { + text: i18n("Waydroid is unavailable") + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + } + + QQC2.Button { + text: i18n("Check again") + Layout.alignment: Qt.AlignHCenter + onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo() + } + } } diff --git a/kcms/waydroidintegration/ui/main.qml b/kcms/waydroidintegration/ui/main.qml index 6e5b618a..432206c7 100644 --- a/kcms/waydroidintegration/ui/main.qml +++ b/kcms/waydroidintegration/ui/main.qml @@ -29,14 +29,14 @@ KCM.SimpleKCM { spacing: Kirigami.Units.largeSpacing QQC2.Label { - text: i18n("Waydroid is not installed") + text: i18n("Waydroid is unavailable") Layout.alignment: Qt.AlignHCenter horizontalAlignment: Text.AlignHCenter } PC3.Button { - text: i18n("Check installation") + text: i18n("Check again") Layout.alignment: Qt.AlignHCenter onClicked: AIP.WaydroidDBusClient.refreshSupportsInfo() } From 26945e83fa33f106de01bad836b36245406a82ea Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Fri, 24 Apr 2026 09:40:53 +0200 Subject: [PATCH 21/26] Show Waydroid apps in Game Center Read the Waydroid allowlist from plasmamobilerc and treat matching Waydroid launchers as their own source in the gaming shell. Surface that source in Game Center so Android titles get their own tab, source chip, and empty-state guidance. --- .../gamelauncherprovider.cpp | 51 ++++++++++- .../gamingshellplugin/gamelauncherprovider.h | 6 +- .../folio/qml/gaming/GameCenterOverlay.qml | 88 ++++++++++++++++--- 3 files changed, 127 insertions(+), 18 deletions(-) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index 3a5dbe99..d37e66cf 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -28,6 +28,8 @@ #include static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed"); +static const QString s_waydroidGamingGroup = QStringLiteral("WaydroidGaming"); +static const QString s_gameShellPackagesKey = QStringLiteral("gameShellPackages"); namespace { @@ -224,6 +226,29 @@ bool parseVdf(const QString &input, VdfNode &root, QString *error) return false; } } + +QString waydroidPackageFromService(const KService::Ptr &service) +{ + static const QRegularExpression execPattern(QStringLiteral("^waydroid\\s+app\\s+launch\\s+([^\\s%]+)")); + const QRegularExpressionMatch execMatch = execPattern.match(service->exec()); + if (execMatch.hasMatch()) { + return execMatch.captured(1); + } + + static const QRegularExpression storageIdPattern(QStringLiteral("^waydroid\\.(.+)\\.desktop$")); + const QRegularExpressionMatch storageIdMatch = storageIdPattern.match(service->storageId()); + if (!storageIdMatch.hasMatch()) { + return {}; + } + + return storageIdMatch.captured(1); +} + +QStringList waydroidGameShellPackages(const KSharedConfigPtr &config) +{ + const KConfigGroup group(config, s_waydroidGamingGroup); + return group.readEntry(s_gameShellPackagesKey, QStringList{}); +} } // namespace GameLauncherProvider::GameLauncherProvider(QObject *parent) @@ -231,6 +256,13 @@ GameLauncherProvider::GameLauncherProvider(QObject *parent) , m_config(KSharedConfig::openConfig(QStringLiteral("plasmamobilerc"))) { connect(KSycoca::self(), &KSycoca::databaseChanged, this, &GameLauncherProvider::refresh); + m_configWatcher = KConfigWatcher::create(m_config); + connect(m_configWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group) { + if (group.name() == s_waydroidGamingGroup) { + m_config->reparseConfiguration(); + refresh(); + } + }); m_pendingLaunchTimer.setInterval(15000); m_pendingLaunchTimer.setSingleShot(true); connect(&m_pendingLaunchTimer, &QTimer::timeout, this, &GameLauncherProvider::clearPendingLaunch); @@ -363,7 +395,7 @@ void GameLauncherProvider::launchEntry(GameEntry &entry) { clearLastLaunchError(); - if (entry.source == QLatin1String("desktop")) { + if (entry.source == QLatin1String("desktop") || entry.source == QLatin1String("waydroid")) { auto service = KService::serviceByStorageId(entry.storageId); if (!service) { markLaunchFailed(entry.name, QStringLiteral("Desktop entry is no longer available")); @@ -426,6 +458,8 @@ void GameLauncherProvider::deduplicateGames() void GameLauncherProvider::loadDesktopGames() { + const QStringList allowedWaydroidPackages = waydroidGameShellPackages(m_config); + const QSet enabledWaydroidPackages(allowedWaydroidPackages.cbegin(), allowedWaydroidPackages.cend()); const auto services = KService::allServices(); for (const auto &service : services) { if (service->noDisplay() || service->exec().isEmpty()) { @@ -433,20 +467,29 @@ void GameLauncherProvider::loadDesktopGames() } const QStringList cats = service->categories(); bool isGame = false; + bool isWaydroidApp = false; for (const auto &cat : cats) { if (cat.compare(QLatin1String("Game"), Qt::CaseInsensitive) == 0) { isGame = true; - break; + } else if (cat.compare(QLatin1String("X-WayDroid-App"), Qt::CaseInsensitive) == 0) { + isWaydroidApp = true; } } if (!isGame) { - continue; + if (!isWaydroidApp) { + continue; + } + + const QString packageName = waydroidPackageFromService(service); + if (packageName.isEmpty() || !enabledWaydroidPackages.contains(packageName)) { + continue; + } } GameEntry entry; entry.name = service->name(); entry.icon = service->icon(); - entry.source = QStringLiteral("desktop"); + entry.source = isWaydroidApp ? QStringLiteral("waydroid") : QStringLiteral("desktop"); entry.storageId = service->storageId(); entry.launchCommand = service->exec(); entry.installed = true; diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index c9d3dd09..675e00f2 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -10,6 +10,7 @@ #include #include +#include #include class GameLauncherProvider : public QAbstractListModel @@ -33,7 +34,7 @@ public: enum Roles { NameRole = Qt::UserRole + 1, IconRole, - SourceRole, // "desktop", "steam", "flatpak" + SourceRole, // "desktop", "waydroid", "steam", "flatpak" StorageIdRole, // .desktop file name or launch URI LaunchCommandRole, ArtworkRole, // path to banner/grid image (empty if none) @@ -107,7 +108,7 @@ private: QList m_allGames; QList m_games; // filtered view QString m_filterString; - QString m_sourceFilter; // empty = all, or "desktop"/"steam"/"flatpak" + QString m_sourceFilter; // empty = all, or a specific source such as "desktop" or "waydroid" KSharedConfigPtr m_config; bool m_loading = false; bool m_overlayEnabled = false; @@ -115,4 +116,5 @@ private: QString m_pendingLaunchName; QString m_lastLaunchError; QTimer m_pendingLaunchTimer; + KConfigWatcher::Ptr m_configWatcher; }; diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index 8af494af..16fb0e40 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -288,8 +288,8 @@ Window { } } - // Cycle through source filter tabs (All → Steam → Desktop → All …) - readonly property var _sourceFilters: ["", "steam", "desktop", "lutris", "heroic"] + // Cycle through source filter tabs. + readonly property var _sourceFilters: ["", "steam", "desktop", "waydroid", "lutris", "heroic"] function cycleSourceFilter(direction) { var current = _sourceFilters.indexOf( GamingShell.GameLauncherProvider.sourceFilter) @@ -300,6 +300,40 @@ Window { sourceFilterBar.currentIndex = next } + function sourceLabel(source) { + switch (source) { + case "steam": + return i18n("Steam") + case "waydroid": + return i18n("Waydroid") + case "lutris": + return i18n("Lutris") + case "heroic": + return i18n("Heroic") + case "flatpak": + return i18n("Flatpak") + default: + return "" + } + } + + function sourceChipColor(source) { + switch (source) { + case "steam": + return Qt.rgba(0.12, 0.23, 0.38, 0.9) + case "waydroid": + return Qt.rgba(0.13, 0.42, 0.36, 0.92) + case "lutris": + return Qt.rgba(0.42, 0.25, 0.11, 0.9) + case "heroic": + return Qt.rgba(0.37, 0.19, 0.16, 0.9) + case "flatpak": + return Qt.rgba(0.16, 0.26, 0.46, 0.9) + default: + return Qt.rgba(0.2, 0.2, 0.2, 0.72) + } + } + Rectangle { anchors.fill: parent Kirigami.Theme.inherit: false @@ -543,6 +577,11 @@ Window { width: implicitWidth onClicked: GamingShell.GameLauncherProvider.sourceFilter = "desktop" } + QQC2.TabButton { + text: i18n("Waydroid") + width: implicitWidth + onClicked: GamingShell.GameLauncherProvider.sourceFilter = "waydroid" + } QQC2.TabButton { text: "Lutris" width: implicitWidth @@ -586,7 +625,7 @@ Window { : i18n("No games found") explanation: searchField.text.length > 0 ? "" - : i18n("Install games or check that they have the Game category in their .desktop file") + : i18n("Install games, or enable supported Waydroid apps from the Waydroid applications page") } onActiveFocusChanged: { @@ -657,6 +696,26 @@ Window { smooth: true asynchronous: true } + + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: Kirigami.Units.smallSpacing + visible: source !== "desktop" + radius: height / 2 + color: root.sourceChipColor(source) + implicitHeight: chipLabel.implicitHeight + Kirigami.Units.smallSpacing + implicitWidth: chipLabel.implicitWidth + Kirigami.Units.largeSpacing + + PC3.Label { + id: chipLabel + anchors.centerIn: parent + text: root.sourceLabel(source) + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.72 + font.weight: Font.DemiBold + color: "white" + } + } } // Title beneath artwork @@ -710,17 +769,22 @@ Window { : Kirigami.Theme.textColor } - // Source badge - PC3.Label { + Rectangle { Layout.alignment: Qt.AlignHCenter - text: source === "steam" ? "Steam" - : source === "flatpak" ? "Flatpak" - : source === "lutris" ? "Lutris" - : source === "heroic" ? "Heroic" - : "" visible: source !== "desktop" - font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 - opacity: 0.6 + radius: height / 2 + color: root.sourceChipColor(source) + implicitHeight: sourceChipLabel.implicitHeight + Kirigami.Units.smallSpacing + implicitWidth: sourceChipLabel.implicitWidth + Kirigami.Units.largeSpacing + + PC3.Label { + id: sourceChipLabel + anchors.centerIn: parent + text: root.sourceLabel(source) + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.72 + font.weight: Font.DemiBold + color: "white" + } } Item { Layout.fillHeight: true } From b42ef44e23b574a517e906335894643a9d061744 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sat, 25 Apr 2026 09:48:25 +0200 Subject: [PATCH 22/26] Add MangoHud overlay, FPS cap, and game pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inject MangoHud when launching desktop and command-line games. MANGOHUD_CONFIG is built per-launch via a QProcess instance so env vars are isolated to each child process — qputenv is not used. Global overlay toggle and FPS cap (Off/30/40/60) are stored as properties on GameLauncherProvider and reflected in the quick settings panel. Per-game overrides stored in plasmamobilerc under [GamingPerGame/] take precedence over the globals at launch time. Games can be pinned to the top of the grid. The pinned set is persisted in plasmamobilerc [GamingPinned] and restored on start. applyFilter() uses stable_sort so pinned games float to the top while alphabetical order is preserved within each group. --- .../gamelauncherprovider.cpp | 280 +++++++++++++++++- .../gamingshellplugin/gamelauncherprovider.h | 29 ++ .../folio/qml/gaming/GamingQuickSettings.qml | 38 +++ 3 files changed, 337 insertions(+), 10 deletions(-) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index d37e66cf..5e343249 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -18,7 +18,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -28,6 +30,8 @@ #include static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed"); +static const QString s_pinnedGroup = QStringLiteral("GamingPinned"); +static const QString s_perGameGroup = QStringLiteral("GamingPerGame"); static const QString s_waydroidGamingGroup = QStringLiteral("WaydroidGaming"); static const QString s_gameShellPackagesKey = QStringLiteral("gameShellPackages"); @@ -266,6 +270,9 @@ GameLauncherProvider::GameLauncherProvider(QObject *parent) m_pendingLaunchTimer.setInterval(15000); m_pendingLaunchTimer.setSingleShot(true); connect(&m_pendingLaunchTimer, &QTimer::timeout, this, &GameLauncherProvider::clearPendingLaunch); + m_mangohudPath = QStandardPaths::findExecutable(QStringLiteral("mangohud")); + m_mangohudAvailable = !m_mangohudPath.isEmpty(); + loadPinnedGames(); refresh(); } @@ -291,10 +298,16 @@ QVariant GameLauncherProvider::data(const QModelIndex &index, int role) const return g.storageId; case LaunchCommandRole: return g.launchCommand; + case LaunchMethodRole: + return launchMethodForEntry(g); case ArtworkRole: return g.artwork; + case LastPlayedTextRole: + return formatLastPlayed(g.lastPlayed); case InstalledRole: return g.installed; + case PinnedRole: + return m_pinnedGames.contains(g.storageId); } return {}; } @@ -307,8 +320,11 @@ QHash GameLauncherProvider::roleNames() const {SourceRole, "source"}, {StorageIdRole, "storageId"}, {LaunchCommandRole, "launchCommand"}, + {LaunchMethodRole, "launchMethod"}, {ArtworkRole, "artwork"}, + {LastPlayedTextRole, "lastPlayedText"}, {InstalledRole, "installed"}, + {PinnedRole, "pinned"}, }; } @@ -391,10 +407,68 @@ void GameLauncherProvider::launchByStorageId(const QString &storageId) } } +QVariantMap GameLauncherProvider::gameDetails(const QString &storageId) const +{ + const int entryIndex = findEntryIndexByStorageId(storageId); + if (entryIndex < 0) { + return {}; + } + + const auto &entry = m_allGames.at(entryIndex); + return { + {QStringLiteral("name"), entry.name}, + {QStringLiteral("icon"), entry.icon}, + {QStringLiteral("source"), entry.source}, + {QStringLiteral("storageId"), entry.storageId}, + {QStringLiteral("launchMethod"), launchMethodForEntry(entry)}, + {QStringLiteral("artwork"), entry.artwork}, + {QStringLiteral("lastPlayedText"), formatLastPlayed(entry.lastPlayed)}, + {QStringLiteral("installed"), entry.installed}, + {QStringLiteral("pinned"), m_pinnedGames.contains(entry.storageId)}, + {QStringLiteral("perGameFpsLimit"), perGameFpsLimit(entry.storageId)}, + {QStringLiteral("perGameOverlayState"), perGameOverlayState(entry.storageId)}, + }; +} + +bool GameLauncherProvider::openSourceApp(const QString &source) +{ + QString program; + QString displayName; + + if (source == QLatin1String("steam")) { + program = QStringLiteral("steam"); + displayName = QStringLiteral("Steam"); + } else if (source == QLatin1String("lutris")) { + program = QStringLiteral("lutris"); + displayName = QStringLiteral("Lutris"); + } else if (source == QLatin1String("heroic")) { + program = QStringLiteral("heroic"); + displayName = QStringLiteral("Heroic"); + } else { + return false; + } + + clearLastLaunchError(); + + if (!QProcess::startDetached(program, {})) { + markLaunchFailed(displayName, QStringLiteral("Unable to start %1").arg(program)); + return false; + } + + return true; +} + void GameLauncherProvider::launchEntry(GameEntry &entry) { clearLastLaunchError(); + const KConfigGroup pgParent(m_config.data(), s_perGameGroup); + const KConfigGroup pgGroup(&pgParent, entry.storageId); + const int pgFpsLimit = pgGroup.readEntry("fpsLimit", -1); + const int pgOverlayState = pgGroup.readEntry("overlayState", -1); + const int effectiveFps = (pgFpsLimit >= 0) ? pgFpsLimit : m_fpsLimit; + const bool effectiveOverlay = (pgOverlayState >= 0) ? (pgOverlayState == 1) : m_overlayEnabled; + if (entry.source == QLatin1String("desktop") || entry.source == QLatin1String("waydroid")) { auto service = KService::serviceByStorageId(entry.storageId); if (!service) { @@ -402,6 +476,27 @@ void GameLauncherProvider::launchEntry(GameEntry &entry) return; } + // Wrap native desktop entries with mangohud when overlay or FPS cap is active. + // Waydroid (Android) games don't benefit from it, so skip them. + if (entry.source == QLatin1String("desktop") && m_mangohudAvailable && (effectiveOverlay || effectiveFps > 0)) { + QString exec = service->exec(); + // Strip KDE .desktop field-code placeholders (%u, %U, %f, %F, %i, %c, %k …) + exec.remove(QRegularExpression(QStringLiteral("%[a-zA-Z]"))); + KShell::Errors parseError = KShell::NoError; + QStringList parts = KShell::splitArgs(exec.simplified(), KShell::NoOptions, &parseError); + if (parseError == KShell::NoError && !parts.isEmpty()) { + const QString program = parts.takeFirst(); + qint64 pid = 0; + if (launchWithMangohud(program, parts, effectiveOverlay, effectiveFps, &pid)) { + markLaunchSucceeded(entry.storageId, entry.name); + } else { + markLaunchFailed(entry.name, QStringLiteral("Failed to launch %1 with mangohud").arg(program)); + } + return; + } + // Exec parsing failed — fall through to the standard KIO job without overlay. + } + auto *job = new KIO::ApplicationLauncherJob(service); connect(job, &KJob::result, this, [this, job, storageId = entry.storageId, name = entry.name]() { if (job->error() != 0) { @@ -426,7 +521,13 @@ void GameLauncherProvider::launchEntry(GameEntry &entry) } QString program = parts.takeFirst(); - if (!QProcess::startDetached(program, parts)) { + if (m_mangohudAvailable && (effectiveOverlay || effectiveFps > 0)) { + qint64 pid = 0; + if (!launchWithMangohud(program, parts, effectiveOverlay, effectiveFps, &pid)) { + markLaunchFailed(entry.name, QStringLiteral("Unable to start %1 with mangohud").arg(program)); + return; + } + } else if (!QProcess::startDetached(program, parts)) { markLaunchFailed(entry.name, QStringLiteral("Unable to start %1").arg(program)); return; } @@ -818,6 +919,46 @@ bool GameLauncherProvider::overlayEnabled() const return m_overlayEnabled; } +bool GameLauncherProvider::mangohudAvailable() const +{ + return m_mangohudAvailable; +} + +int GameLauncherProvider::fpsLimit() const +{ + return m_fpsLimit; +} + +void GameLauncherProvider::setFpsLimit(int limit) +{ + if (m_fpsLimit == limit) { + return; + } + m_fpsLimit = limit; + Q_EMIT fpsLimitChanged(); +} + +bool GameLauncherProvider::launchWithMangohud(const QString &program, const QStringList &args, bool overlayEnabled, int fpsLimit, qint64 *pid) +{ + QStringList config; + if (!overlayEnabled) { + config << QStringLiteral("no_display"); + } + if (fpsLimit > 0) { + config << QStringLiteral("fps_limit=%1").arg(fpsLimit); + } + + QProcess proc; + if (!config.isEmpty()) { + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("MANGOHUD_CONFIG"), config.join(QLatin1Char(','))); + proc.setProcessEnvironment(env); + } + proc.setProgram(m_mangohudPath); + proc.setArguments(QStringList{program} + args); + return proc.startDetached(pid); +} + void GameLauncherProvider::setOverlayEnabled(bool enabled) { if (m_overlayEnabled == enabled) { @@ -825,15 +966,6 @@ void GameLauncherProvider::setOverlayEnabled(bool enabled) } m_overlayEnabled = enabled; Q_EMIT overlayEnabledChanged(); - - // Set/unset MangoHud environment variables for child processes - if (enabled) { - qputenv("MANGOHUD", "1"); - qputenv("MANGOHUD_DLSYM", "1"); - } else { - qunsetenv("MANGOHUD"); - qunsetenv("MANGOHUD_DLSYM"); - } } void GameLauncherProvider::applyFilter() @@ -851,6 +983,10 @@ void GameLauncherProvider::applyFilter() m_games.append(g); } + std::stable_sort(m_games.begin(), m_games.end(), [this](const GameEntry &a, const GameEntry &b) { + return m_pinnedGames.contains(a.storageId) > m_pinnedGames.contains(b.storageId); + }); + endResetModel(); Q_EMIT countChanged(); } @@ -873,6 +1009,129 @@ void GameLauncherProvider::saveRecentTimestamp(const QString &storageId, const Q group.sync(); } +void GameLauncherProvider::loadPinnedGames() +{ + const KConfigGroup group(m_config, s_pinnedGroup); + const QStringList list = group.readEntry(QStringLiteral("pinned"), QStringList{}); + m_pinnedGames = QSet(list.begin(), list.end()); +} + +void GameLauncherProvider::togglePin(const QString &storageId) +{ + if (storageId.isEmpty()) { + return; + } + if (m_pinnedGames.contains(storageId)) { + m_pinnedGames.remove(storageId); + } else { + m_pinnedGames.insert(storageId); + } + KConfigGroup group(m_config, s_pinnedGroup); + group.writeEntry(QStringLiteral("pinned"), QStringList(m_pinnedGames.begin(), m_pinnedGames.end())); + group.sync(); + applyFilter(); +} + +int GameLauncherProvider::perGameFpsLimit(const QString &storageId) const +{ + const KConfigGroup parent(m_config.data(), s_perGameGroup); + const KConfigGroup group(&parent, storageId); + return group.readEntry("fpsLimit", -1); +} + +void GameLauncherProvider::setPerGameFpsLimit(const QString &storageId, int limit) +{ + if (storageId.isEmpty()) { + return; + } + KConfigGroup parent(m_config.data(), s_perGameGroup); + KConfigGroup group(&parent, storageId); + if (limit < 0) { + group.deleteEntry("fpsLimit"); + } else { + group.writeEntry("fpsLimit", limit); + } + group.sync(); +} + +int GameLauncherProvider::perGameOverlayState(const QString &storageId) const +{ + const KConfigGroup parent(m_config.data(), s_perGameGroup); + const KConfigGroup group(&parent, storageId); + return group.readEntry("overlayState", -1); +} + +void GameLauncherProvider::setPerGameOverlayState(const QString &storageId, int state) +{ + if (storageId.isEmpty()) { + return; + } + KConfigGroup parent(m_config.data(), s_perGameGroup); + KConfigGroup group(&parent, storageId); + if (state < 0) { + group.deleteEntry("overlayState"); + } else { + group.writeEntry("overlayState", state); + } + group.sync(); +} + +void GameLauncherProvider::clearLastPlayed(const QString &storageId) +{ + if (storageId.isEmpty()) { + return; + } + + KConfigGroup group(m_config, s_recentGroup); + if (!group.hasKey(storageId)) { + return; + } + group.deleteEntry(storageId); + group.sync(); + + const int entryIndex = findEntryIndexByStorageId(storageId); + if (entryIndex >= 0) { + m_allGames[entryIndex].lastPlayed = QDateTime(); + const int filteredIndex = [&] { + for (int i = 0; i < m_games.size(); ++i) { + if (m_games.at(i).storageId == storageId) { + return i; + } + } + return -1; + }(); + if (filteredIndex >= 0) { + m_games[filteredIndex].lastPlayed = QDateTime(); + const QModelIndex idx = index(filteredIndex); + Q_EMIT dataChanged(idx, idx, {LastPlayedTextRole}); + } + } + + Q_EMIT recentGamesChanged(); +} + +QString GameLauncherProvider::launchMethodForEntry(const GameEntry &entry) const +{ + if (entry.source == QLatin1String("desktop") || entry.source == QLatin1String("waydroid")) { + return QStringLiteral("desktop-entry"); + } + + if (entry.launchCommand.contains(QStringLiteral("://"))) { + return QStringLiteral("protocol"); + } + + return QStringLiteral("command"); +} + +QString GameLauncherProvider::formatLastPlayed(const QDateTime &when) const +{ + if (!when.isValid()) { + return {}; + } + + return QLocale().toString(when, QLocale::ShortFormat); +} + void GameLauncherProvider::clearPendingLaunch() { if (!m_launchPending && m_pendingLaunchName.isEmpty()) { @@ -917,6 +1176,7 @@ void GameLauncherProvider::markLaunchSucceeded(const QString &storageId, const Q setPendingLaunch(name); Q_EMIT gameLaunched(name); + Q_EMIT recentGamesChanged(); } void GameLauncherProvider::markLaunchFailed(const QString &name, const QString &error) diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index 675e00f2..2a6b0550 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -6,8 +6,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -24,6 +26,8 @@ class GameLauncherProvider : public QAbstractListModel Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged) Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged) Q_PROPERTY(bool overlayEnabled READ overlayEnabled WRITE setOverlayEnabled NOTIFY overlayEnabledChanged) + Q_PROPERTY(bool mangohudAvailable READ mangohudAvailable NOTIFY mangohudAvailableChanged) + Q_PROPERTY(int fpsLimit READ fpsLimit WRITE setFpsLimit NOTIFY fpsLimitChanged) Q_PROPERTY(bool launchPending READ launchPending NOTIFY launchPendingChanged) Q_PROPERTY(QString pendingLaunchName READ pendingLaunchName NOTIFY launchPendingChanged) Q_PROPERTY(QString lastLaunchError READ lastLaunchError NOTIFY lastLaunchErrorChanged) @@ -37,8 +41,11 @@ public: SourceRole, // "desktop", "waydroid", "steam", "flatpak" StorageIdRole, // .desktop file name or launch URI LaunchCommandRole, + LaunchMethodRole, ArtworkRole, // path to banner/grid image (empty if none) + LastPlayedTextRole, InstalledRole, + PinnedRole, }; Q_ENUM(Roles) @@ -54,6 +61,9 @@ public: void setSourceFilter(const QString &source); bool overlayEnabled() const; void setOverlayEnabled(bool enabled); + bool mangohudAvailable() const; + int fpsLimit() const; + void setFpsLimit(int limit); bool launchPending() const; QString pendingLaunchName() const; QString lastLaunchError() const; @@ -61,9 +71,17 @@ public: Q_INVOKABLE void refresh(); Q_INVOKABLE void launch(int index); Q_INVOKABLE void launchByStorageId(const QString &storageId); + Q_INVOKABLE QVariantMap gameDetails(const QString &storageId) const; + Q_INVOKABLE bool openSourceApp(const QString &source); + Q_INVOKABLE void clearLastPlayed(const QString &storageId); Q_INVOKABLE QVariantList recentGames(int limit = 5) const; Q_INVOKABLE void clearPendingLaunch(); Q_INVOKABLE void clearLastLaunchError(); + Q_INVOKABLE void togglePin(const QString &storageId); + Q_INVOKABLE int perGameFpsLimit(const QString &storageId) const; + Q_INVOKABLE void setPerGameFpsLimit(const QString &storageId, int limit); + Q_INVOKABLE int perGameOverlayState(const QString &storageId) const; + Q_INVOKABLE void setPerGameOverlayState(const QString &storageId, int state); Q_SIGNALS: void countChanged(); @@ -71,10 +89,13 @@ Q_SIGNALS: void filterStringChanged(); void sourceFilterChanged(); void overlayEnabledChanged(); + void mangohudAvailableChanged(); + void fpsLimitChanged(); void launchPendingChanged(); void lastLaunchErrorChanged(); void gameLaunched(const QString &name); void gameLaunchFailed(const QString &name, const QString &error); + void recentGamesChanged(); private: struct GameEntry { @@ -98,12 +119,16 @@ private: void saveRecentTimestamp(const QString &storageId, const QDateTime &when); void applyFilter(); void launchEntry(GameEntry &entry); + bool launchWithMangohud(const QString &program, const QStringList &args, bool overlayEnabled, int fpsLimit, qint64 *pid = nullptr); + QString launchMethodForEntry(const GameEntry &entry) const; + QString formatLastPlayed(const QDateTime &when) const; // Returns the current m_allGames index for the storage id. // Callers must re-lookup after any mutation that can rebuild or reorder the list. int findEntryIndexByStorageId(const QString &storageId) const; void markLaunchSucceeded(const QString &storageId, const QString &name); void markLaunchFailed(const QString &name, const QString &error); void setPendingLaunch(const QString &name); + void loadPinnedGames(); QList m_allGames; QList m_games; // filtered view @@ -112,6 +137,10 @@ private: KSharedConfigPtr m_config; bool m_loading = false; bool m_overlayEnabled = false; + int m_fpsLimit = 0; + bool m_mangohudAvailable = false; + QString m_mangohudPath; + QSet m_pinnedGames; bool m_launchPending = false; QString m_pendingLaunchName; QString m_lastLaunchError; diff --git a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml index ea38664e..a623c310 100644 --- a/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml +++ b/containments/homescreens/folio/qml/gaming/GamingQuickSettings.qml @@ -505,7 +505,45 @@ Item { id: overlaySwitch text: i18n("Perf Overlay") checked: GamingShell.GameLauncherProvider.overlayEnabled + enabled: GamingShell.GameLauncherProvider.mangohudAvailable + opacity: enabled ? 1.0 : 0.5 onToggled: GamingShell.GameLauncherProvider.overlayEnabled = checked + + QQC2.ToolTip.visible: !GamingShell.GameLauncherProvider.mangohudAvailable && hovered + QQC2.ToolTip.text: i18n("MangoHud is not installed") + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + // FPS cap — spans both columns, driven by MangoHud fps_limit + QQC2.ButtonGroup { id: fpsCap; exclusive: true } + + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + enabled: GamingShell.GameLauncherProvider.mangohudAvailable + opacity: enabled ? 1.0 : 0.5 + + PC3.Label { text: i18n("FPS Cap") } + Item { Layout.fillWidth: true } + + Repeater { + model: [ + { label: i18nc("@action:button FPS cap off", "Off"), fps: 0 }, + { label: "30", fps: 30 }, + { label: "40", fps: 40 }, + { label: "60", fps: 60 } + ] + delegate: QQC2.Button { + required property var modelData + text: modelData.label + flat: true + checkable: true + checked: GamingShell.GameLauncherProvider.fpsLimit === modelData.fps + QQC2.ButtonGroup.group: fpsCap + onClicked: GamingShell.GameLauncherProvider.fpsLimit = modelData.fps + } + } } // GameMode status (auto-managed, read-only indicator) From 739cc33049176a531e149c158ce6194fd4240e39 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sat, 25 Apr 2026 09:48:34 +0200 Subject: [PATCH 23/26] Update Game Center with pinning and per-game settings Star badge on pinned tiles (passive Kirigami.Icon, no event handlers to avoid pointer-feedback loops). Pin/Unpin action and per-game FPS cap + overlay rows added to the details dialog. Per-game FPS and overlay rows use ButtonGroup with a Global option that deletes the per-game key and falls back to the global quick-settings value. Remove isCurrent from info button visibility; add Menu and I key handlers on the grid for keyboard/gamepad access to details. --- .../folio/qml/gaming/GameCenterOverlay.qml | 488 +++++++++++++++++- 1 file changed, 477 insertions(+), 11 deletions(-) diff --git a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml index 16fb0e40..5df0a6b8 100644 --- a/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml +++ b/containments/homescreens/folio/qml/gaming/GameCenterOverlay.qml @@ -23,6 +23,8 @@ Window { required property var folio property int launchTaskBaseline: 0 + property var selectedGame: ({}) + property int recentRevision: 0 signal gameStarted() signal dismissRequested() @@ -62,6 +64,88 @@ Window { GamingShell.GameLauncherProvider.launchByStorageId(storageId) } + function openGameDetails(storageId) { + selectedGame = GamingShell.GameLauncherProvider.gameDetails(storageId) + if (!selectedGame.storageId || selectedGame.storageId.length === 0) { + selectedGame = ({}) + return + } + gameDetailsDialog.active = true + gameDetailsDialog.item.open() + } + + function focusRecentGames() { + if (recentList.count <= 0) { + return + } + if (recentList.currentIndex < 0) { + recentList.currentIndex = 0 + } + recentList.forceActiveFocus() + } + + function sourceDescription(source) { + switch (source) { + case "steam": + return i18n("Launches through the Steam protocol handler.") + case "lutris": + return i18n("Launches through the Lutris launcher.") + case "heroic": + return i18n("Launches through Heroic's protocol handler.") + case "waydroid": + return i18n("Launches through the exported Waydroid desktop entry.") + case "flatpak": + return i18n("Launches through its exported desktop entry.") + default: + return i18n("Launches through its desktop entry.") + } + } + + function sourceHint(source) { + switch (source) { + case "waydroid": + return i18n("Manage which Android titles appear here from the Waydroid applications page.") + case "steam": + return i18n("Steam entries come from your local Steam library manifests.") + case "lutris": + return i18n("Lutris entries come from the local Lutris library database.") + case "heroic": + return i18n("Heroic entries come from Heroic's local library cache.") + default: + return i18n("Desktop entries come from the application menu database.") + } + } + + function launchMethodDescription(method) { + switch (method) { + case "desktop-entry": + return i18n("Desktop entry") + case "protocol": + return i18n("Protocol handler") + case "command": + return i18n("Command line") + default: + return i18n("Unknown") + } + } + + function canOpenSourceApp(source) { + return source === "steam" || source === "lutris" || source === "heroic" + } + + function sourceAppActionLabel(source) { + switch (source) { + case "steam": + return i18n("Open Steam") + case "lutris": + return i18n("Open Lutris") + case "heroic": + return i18n("Open Heroic") + default: + return i18n("Open Source App") + } + } + width: Screen.width height: Screen.height color: "transparent" @@ -90,6 +174,10 @@ Window { function onGameLaunchFailed(name, error) { launchErrorTimer.restart() } + + function onRecentGamesChanged() { + root.recentRevision++ + } } Timer { @@ -148,28 +236,52 @@ Window { case GamingShell.GamepadManager.ButtonDPadUp: if (grid.activeFocus) { if (grid.currentIndex < grid.columns && runningGames.hasTasks) { - runningGames.focusFirstTask() + if (recentList.count > 0) { + root.focusRecentGames() + } else { + runningGames.focusFirstTask() + } + } else if (grid.currentIndex < grid.columns && recentList.count > 0) { + root.focusRecentGames() } else { grid.moveCurrentIndexUp() } + } else if (recentList.activeFocus && runningGames.hasTasks) { + runningGames.focusFirstTask() } break case GamingShell.GamepadManager.ButtonDPadDown: if (runningGames.activeFocus) { + if (recentList.count > 0) { + root.focusRecentGames() + } else { + grid.forceActiveFocus() + } + } else if (recentList.activeFocus) { grid.forceActiveFocus() } else if (grid.activeFocus) { grid.moveCurrentIndexDown() } break case GamingShell.GamepadManager.ButtonDPadLeft: - if (grid.activeFocus) grid.moveCurrentIndexLeft() + if (recentList.activeFocus) { + recentList.decrementCurrentIndex() + } else if (grid.activeFocus) { + grid.moveCurrentIndexLeft() + } break case GamingShell.GamepadManager.ButtonDPadRight: - if (grid.activeFocus) grid.moveCurrentIndexRight() + if (recentList.activeFocus) { + recentList.incrementCurrentIndex() + } else if (grid.activeFocus) { + grid.moveCurrentIndexRight() + } break case GamingShell.GamepadManager.ButtonA: if (runningGames.activeFocus) { runningGames.activateCurrent() + } else if (recentList.activeFocus && recentList.currentItem) { + root.launchGameByStorageId(recentList.currentItem.storageId) } else if (grid.activeFocus && grid.currentItem) { root.launchGame(grid.currentIndex) } @@ -177,6 +289,10 @@ Window { case GamingShell.GamepadManager.ButtonX: if (runningGames.activeFocus) { runningGames.closeCurrent() + } else if (recentList.activeFocus && recentList.currentItem) { + root.openGameDetails(recentList.currentItem.storageId) + } else if (grid.activeFocus && grid.currentItem) { + grid.currentItem.showDetails() } break case GamingShell.GamepadManager.ButtonB: @@ -244,20 +360,38 @@ Window { if (stickState.leftY < -stickState.deadzone) { if (grid.activeFocus) { if (grid.currentIndex < grid.columns && runningGames.hasTasks) { - runningGames.focusFirstTask() + if (recentList.count > 0) { + root.focusRecentGames() + } else { + runningGames.focusFirstTask() + } + } else if (grid.currentIndex < grid.columns && recentList.count > 0) { + root.focusRecentGames() } else { grid.moveCurrentIndexUp() } + } else if (recentList.activeFocus && runningGames.hasTasks) { + runningGames.focusFirstTask() } } else if (stickState.leftY > stickState.deadzone) { if (runningGames.activeFocus) { + if (recentList.count > 0) { + root.focusRecentGames() + } else { + grid.forceActiveFocus() + } + } else if (recentList.activeFocus) { grid.forceActiveFocus() } else if (grid.activeFocus) { grid.moveCurrentIndexDown() } } - if (stickState.leftX < -stickState.deadzone && grid.activeFocus) { + if (stickState.leftX < -stickState.deadzone && recentList.activeFocus) { + recentList.decrementCurrentIndex() + } else if (stickState.leftX < -stickState.deadzone && grid.activeFocus) { grid.moveCurrentIndexLeft() + } else if (stickState.leftX > stickState.deadzone && recentList.activeFocus) { + recentList.incrementCurrentIndex() } else if (stickState.leftX > stickState.deadzone && grid.activeFocus) { grid.moveCurrentIndexRight() } @@ -486,20 +620,56 @@ Window { orientation: ListView.Horizontal spacing: Kirigami.Units.largeSpacing clip: true + keyNavigationEnabled: true - model: root.visible ? GamingShell.GameLauncherProvider.recentGames(5) : [] + model: root.visible ? (root.recentRevision, GamingShell.GameLauncherProvider.recentGames(5)) : [] + + function activateCurrentRecent() { + if (currentItem) { + root.launchGameByStorageId(currentItem.storageId) + } + } + + function showCurrentRecentDetails() { + if (currentItem) { + root.openGameDetails(currentItem.storageId) + } + } + + onActiveFocusChanged: { + if (activeFocus && count > 0 && currentIndex < 0) { + currentIndex = 0 + } + } + + Keys.onLeftPressed: decrementCurrentIndex() + Keys.onRightPressed: incrementCurrentIndex() + Keys.onReturnPressed: activateCurrentRecent() + Keys.onEnterPressed: activateCurrentRecent() + Keys.onUpPressed: { + if (runningGames.hasTasks) { + runningGames.focusFirstTask() + } + } + Keys.onDownPressed: grid.forceActiveFocus() delegate: QQC2.ItemDelegate { width: Kirigami.Units.gridUnit * 7 height: recentList.height required property var modelData + readonly property string storageId: modelData.storageId || "" + readonly property bool isCurrent: ListView.isCurrentItem && recentList.activeFocus readonly property bool hasArt: modelData.artwork && modelData.artwork.length > 0 + HoverHandler { id: tileHover } + background: Rectangle { radius: Kirigami.Units.cornerRadius - color: parent.hovered ? Kirigami.Theme.hoverColor : "transparent" + color: parent.isCurrent + ? Kirigami.Theme.highlightColor + : (parent.hovered ? Kirigami.Theme.hoverColor : "transparent") } contentItem: ColumnLayout { @@ -529,10 +699,27 @@ Window { elide: Text.ElideRight horizontalAlignment: Text.AlignHCenter font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + color: parent.parent.isCurrent + ? Kirigami.Theme.highlightedTextColor + : Kirigami.Theme.textColor } } onClicked: root.launchGameByStorageId(modelData.storageId) + + QQC2.ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Kirigami.Units.smallSpacing + visible: tileHover.hovered || parent.isCurrent + icon.name: "documentinfo" + display: QQC2.AbstractButton.IconOnly + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: i18n("Details") + + onClicked: root.openGameDetails(parent.storageId) + } } } } @@ -647,6 +834,17 @@ Window { } Keys.onEnterPressed: Keys.onReturnPressed(event) Keys.onEscapePressed: root.dismissRequested() + Keys.onMenuPressed: { + if (currentIndex >= 0) { + root.openGameDetails(currentItem.storageId) + } + } + Keys.onPressed: (event) => { + if ((event.key === Qt.Key_I) && currentIndex >= 0) { + root.openGameDetails(currentItem.storageId) + event.accepted = true + } + } delegate: Item { width: grid.cellWidth @@ -657,8 +855,19 @@ Window { required property string icon required property string source required property string artwork + required property string storageId + required property string launchMethod + required property string lastPlayedText + required property bool pinned readonly property bool hasArt: artwork.length > 0 + readonly property bool isCurrent: GridView.isCurrentItem && grid.activeFocus + + HoverHandler { id: gridTileHover } + + function showDetails() { + root.openGameDetails(storageId) + } QQC2.ItemDelegate { anchors.fill: parent @@ -793,6 +1002,30 @@ Window { onClicked: root.launchGame(index) } + + QQC2.ToolButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Kirigami.Units.smallSpacing * 1.5 + visible: gridTileHover.hovered + icon.name: "documentinfo" + display: QQC2.AbstractButton.IconOnly + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: i18n("Details") + + onClicked: parent.showDetails() + } + + Kirigami.Icon { + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: Kirigami.Units.smallSpacing * 1.5 + visible: pinned + source: "starred" + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + } } } @@ -847,13 +1080,17 @@ Window { // Gamepad legend PC3.Label { - text: runningGames.hasTasks + text: runningGames.activeFocus ? 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) + : recentList.activeFocus + ? i18n("%1: Play %2: Details %3: Back %4: Exit %5: Settings %6: Search", + actionButtonLabel, closeButtonLabel, backButtonLabel, exitButtonLabel, + quickSettingsButtonLabel, searchButtonLabel) + : i18n("%1: Play %2: Details %3: Back %4: Exit %5/%6: Filter %7: Settings %8: Search", + actionButtonLabel, closeButtonLabel, backButtonLabel, exitButtonLabel, + leftShoulderLabel, rightShoulderLabel, quickSettingsButtonLabel, searchButtonLabel) font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 opacity: 0.5 } @@ -894,6 +1131,235 @@ Window { } } + Loader { + id: gameDetailsDialog + active: false + anchors.fill: parent + + sourceComponent: Kirigami.PromptDialog { + id: theGameDetailsDialog + title: root.selectedGame.name || "" + subtitle: root.sourceLabel(root.selectedGame.source || "") + standardButtons: Kirigami.Dialog.NoButton + + property int pgFpsLimit: root.selectedGame.perGameFpsLimit ?? -1 + property int pgOverlayState: root.selectedGame.perGameOverlayState ?? -1 + customFooterActions: [ + Kirigami.Action { + text: i18n("Close") + onTriggered: theGameDetailsDialog.close() + }, + Kirigami.Action { + visible: root.canOpenSourceApp(root.selectedGame.source || "") + text: root.sourceAppActionLabel(root.selectedGame.source || "") + onTriggered: { + if (GamingShell.GameLauncherProvider.openSourceApp(root.selectedGame.source || "")) { + theGameDetailsDialog.close() + root.gameStarted() + } + } + }, + Kirigami.Action { + text: (root.selectedGame.pinned || false) ? i18n("Unpin") : i18n("Pin to top") + onTriggered: { + GamingShell.GameLauncherProvider.togglePin(root.selectedGame.storageId || "") + theGameDetailsDialog.close() + } + }, + Kirigami.Action { + visible: (root.selectedGame.lastPlayedText || "").length > 0 + text: i18n("Remove from Continue Playing") + onTriggered: { + GamingShell.GameLauncherProvider.clearLastPlayed(root.selectedGame.storageId || "") + theGameDetailsDialog.close() + } + }, + Kirigami.Action { + text: i18n("Play") + enabled: (root.selectedGame.storageId || "").length > 0 + onTriggered: { + root.launchGameByStorageId(root.selectedGame.storageId) + theGameDetailsDialog.close() + } + } + ] + + ColumnLayout { + spacing: Kirigami.Units.largeSpacing + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.largeSpacing + + Rectangle { + Layout.preferredWidth: Kirigami.Units.gridUnit * 5 + Layout.preferredHeight: Kirigami.Units.gridUnit * 7 + radius: Kirigami.Units.cornerRadius + clip: true + color: Kirigami.Theme.alternateBackgroundColor + + Image { + anchors.fill: parent + source: root.selectedGame.artwork && root.selectedGame.artwork.length > 0 + ? "file://" + root.selectedGame.artwork : "" + fillMode: Image.PreserveAspectCrop + visible: source.length > 0 + asynchronous: true + } + + Kirigami.Icon { + anchors.centerIn: parent + visible: !parent.children[0].visible + source: root.selectedGame.icon || "games-config-options" + implicitWidth: Kirigami.Units.iconSizes.huge + implicitHeight: Kirigami.Units.iconSizes.huge + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Rectangle { + visible: (root.selectedGame.source || "") !== "desktop" + radius: height / 2 + color: root.sourceChipColor(root.selectedGame.source || "") + implicitHeight: sourceBadgeLabel.implicitHeight + Kirigami.Units.smallSpacing + implicitWidth: sourceBadgeLabel.implicitWidth + Kirigami.Units.largeSpacing + + PC3.Label { + id: sourceBadgeLabel + anchors.centerIn: parent + text: root.sourceLabel(root.selectedGame.source || "") + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8 + font.weight: Font.DemiBold + color: "white" + } + } + + PC3.Label { + Layout.fillWidth: true + text: root.sourceDescription(root.selectedGame.source || "") + wrapMode: Text.WordWrap + } + + PC3.Label { + Layout.fillWidth: true + text: root.sourceHint(root.selectedGame.source || "") + wrapMode: Text.WordWrap + opacity: 0.75 + } + + PC3.Label { + Layout.fillWidth: true + text: i18n("Launch method: %1", root.launchMethodDescription(root.selectedGame.launchMethod || "")) + wrapMode: Text.WordWrap + opacity: 0.75 + } + + PC3.Label { + Layout.fillWidth: true + visible: (root.selectedGame.lastPlayedText || "").length > 0 + text: i18n("Last played: %1", root.selectedGame.lastPlayedText || "") + wrapMode: Text.WordWrap + opacity: 0.75 + } + + PC3.Label { + Layout.fillWidth: true + text: i18n("Identifier: %1", root.selectedGame.storageId || "") + wrapMode: Text.WrapAnywhere + opacity: 0.6 + } + } + } + + Kirigami.Separator { Layout.fillWidth: true } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + PC3.Label { + text: i18n("FPS Cap") + opacity: 0.75 + Layout.alignment: Qt.AlignVCenter + } + + Item { Layout.fillWidth: true } + + QQC2.ButtonGroup { id: pgFpsCap; exclusive: true } + + Repeater { + model: [ + { label: i18n("Global"), fps: -1 }, + { label: i18nc("@action:button FPS cap off", "Off"), fps: 0 }, + { label: "30", fps: 30 }, + { label: "40", fps: 40 }, + { label: "60", fps: 60 } + ] + delegate: QQC2.Button { + required property var modelData + text: modelData.label + flat: true + checkable: true + checked: theGameDetailsDialog.pgFpsLimit === modelData.fps + QQC2.ButtonGroup.group: pgFpsCap + onClicked: { + theGameDetailsDialog.pgFpsLimit = modelData.fps + GamingShell.GameLauncherProvider.setPerGameFpsLimit( + root.selectedGame.storageId || "", modelData.fps) + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + PC3.Label { + text: i18n("Overlay") + opacity: 0.75 + Layout.alignment: Qt.AlignVCenter + } + + Item { Layout.fillWidth: true } + + QQC2.ButtonGroup { id: pgOverlayGroup; exclusive: true } + + Repeater { + model: [ + { label: i18n("Global"), state: -1 }, + { label: i18n("Off"), state: 0 }, + { label: i18n("On"), state: 1 } + ] + delegate: QQC2.Button { + required property var modelData + text: modelData.label + flat: true + checkable: true + checked: theGameDetailsDialog.pgOverlayState === modelData.state + enabled: modelData.state !== 1 || GamingShell.GameLauncherProvider.mangohudAvailable + opacity: enabled ? 1.0 : 0.5 + QQC2.ButtonGroup.group: pgOverlayGroup + onClicked: { + theGameDetailsDialog.pgOverlayState = modelData.state + GamingShell.GameLauncherProvider.setPerGameOverlayState( + root.selectedGame.storageId || "", modelData.state) + } + } + } + } + } + + onClosed: { + gameDetailsDialog.active = false + root.selectedGame = ({}) + } + } + } + Loader { id: exitGamingDialog active: false From 0ce8d6eb4640a1ba5590dc53e7e33eae35909c15 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sat, 25 Apr 2026 09:48:42 +0200 Subject: [PATCH 24/26] Add HUD toasts and quick-resume shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect to gameLaunched and gameLaunchFailed signals. Launch toasts dismiss after 3 s; error toasts after 5 s with a red background. The window grows downward from the HUD pill to accommodate the toast pill. When recentGamesChanged fires, the most recently played game is stored in quickResumeGame. While set, the HUD pill widens leftward to show the game name and a play button that calls launchByStorageId directly — no need to open Game Center. --- .../folio/qml/gaming/GamingHUD.qml | 158 +++++++++++++++++- 1 file changed, 150 insertions(+), 8 deletions(-) diff --git a/containments/homescreens/folio/qml/gaming/GamingHUD.qml b/containments/homescreens/folio/qml/gaming/GamingHUD.qml index b39fae4a..e67b0342 100644 --- a/containments/homescreens/folio/qml/gaming/GamingHUD.qml +++ b/containments/homescreens/folio/qml/gaming/GamingHUD.qml @@ -3,6 +3,7 @@ import QtQuick import QtQuick.Controls as QQC2 +import QtQuick.Layouts import QtQuick.Window import org.kde.kirigami as Kirigami @@ -18,11 +19,32 @@ Window { // Guard against startup timing where Kirigami units may briefly be 0/NaN. // LayerShell surfaces must never be committed with zero size. readonly property real safeGridUnit: ((Kirigami.Units.gridUnit || 0) > 0) ? Kirigami.Units.gridUnit : 16 - width: safeGridUnit * 4 - height: safeGridUnit * 2 + + property string toastMessage: "" + property bool toastError: false + readonly property bool toastActive: toastMessage.length > 0 + + // Most-recently-played game for quick resume. Populated from recentGames(1) + // and refreshed whenever the recent list changes. + property var quickResumeGame: null + readonly property bool hasQuickResume: quickResumeGame !== null + + // Window grows leftward from top-right anchor: + // toast active → widest (needs room for message text) + // quick resume → medium (game name + controls) + // idle → compact (controls only) + width: toastActive ? safeGridUnit * 16 : (hasQuickResume ? safeGridUnit * 14 : safeGridUnit * 4) + height: toastActive ? safeGridUnit * 4 : safeGridUnit * 2 color: "transparent" flags: Qt.FramelessWindowHint + Behavior on width { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } + } + Behavior on height { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic } + } + LayerShell.Window.scope: "gaming-hud" LayerShell.Window.layer: LayerShell.Window.LayerOverlay LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorRight @@ -38,16 +60,93 @@ Window { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad } } + Timer { + id: toastTimer + onTriggered: root.toastMessage = "" + } + + Connections { + target: GamingShell.GameLauncherProvider + function onGameLaunched(name) { + root.toastMessage = i18n("Launching %1", name) + root.toastError = false + toastTimer.interval = 3000 + toastTimer.restart() + } + function onGameLaunchFailed(name, error) { + root.toastMessage = error.length > 0 ? error : i18n("Failed to launch %1", name) + root.toastError = true + toastTimer.interval = 5000 + toastTimer.restart() + } + function onRecentGamesChanged() { + const recent = GamingShell.GameLauncherProvider.recentGames(1) + root.quickResumeGame = recent.length > 0 ? recent[0] : null + } + } + + Component.onCompleted: { + const recent = GamingShell.GameLauncherProvider.recentGames(1) + root.quickResumeGame = recent.length > 0 ? recent[0] : null + } + + // ---- HUD pill (always visible, fills window width, grows leftward) ---- Rectangle { - anchors.fill: parent + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right anchors.margins: Kirigami.Units.smallSpacing + height: safeGridUnit * 2 - Kirigami.Units.smallSpacing * 2 radius: height / 2 color: Qt.rgba(0, 0, 0, 0.55) - Row { - anchors.centerIn: parent - spacing: Kirigami.Units.smallSpacing + RowLayout { + anchors { + fill: parent + leftMargin: Kirigami.Units.smallSpacing + rightMargin: Kirigami.Units.smallSpacing + } + spacing: 0 + // Quick-resume section — only visible when a recent game exists + QQC2.ToolButton { + visible: root.hasQuickResume + icon.name: "media-playback-start" + icon.color: "white" + display: QQC2.AbstractButton.IconOnly + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: root.quickResumeGame ? i18n("Resume %1", root.quickResumeGame.name) : "" + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + onClicked: { + if (root.quickResumeGame) { + GamingShell.GameLauncherProvider.launchByStorageId(root.quickResumeGame.storageId) + } + } + } + + QQC2.Label { + visible: root.hasQuickResume + Layout.fillWidth: true + text: root.quickResumeGame ? root.quickResumeGame.name : "" + color: "white" + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + elide: Text.ElideRight + maximumLineCount: 1 + leftPadding: Kirigami.Units.smallSpacing + } + + // Separator between quick-resume and controls + Rectangle { + visible: root.hasQuickResume + width: 1 + implicitHeight: Kirigami.Units.gridUnit + color: Qt.rgba(1, 1, 1, 0.25) + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: Kirigami.Units.smallSpacing + Layout.rightMargin: Kirigami.Units.smallSpacing + } + + // Game Center button QQC2.ToolButton { icon.name: "input-gaming" icon.color: "white" @@ -57,7 +156,7 @@ Window { onClicked: root.openRequested() } - // Show primary gamepad battery when connected + // Primary gamepad battery QQC2.Label { visible: GamingShell.GamepadManager.hasGamepad && GamingShell.GamepadManager.primaryGamepad @@ -67,7 +166,50 @@ Window { : "" color: "white" font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.8 - anchors.verticalCenter: parent.verticalCenter + rightPadding: Kirigami.Units.smallSpacing + } + } + } + + // ---- toast pill (slides in below HUD pill when active) ---- + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Kirigami.Units.smallSpacing + height: safeGridUnit * 2 - Kirigami.Units.smallSpacing * 2 + radius: height / 2 + color: root.toastError ? Qt.rgba(0.75, 0.1, 0.05, 0.9) : Qt.rgba(0, 0, 0, 0.55) + + opacity: root.toastActive ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.InOutQuad } + } + + RowLayout { + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + leftMargin: Kirigami.Units.largeSpacing + rightMargin: Kirigami.Units.largeSpacing + } + spacing: Kirigami.Units.smallSpacing + + Kirigami.Icon { + source: root.toastError ? "dialog-warning" : "media-playback-start" + implicitWidth: Kirigami.Units.iconSizes.small + implicitHeight: Kirigami.Units.iconSizes.small + Layout.alignment: Qt.AlignVCenter + } + + QQC2.Label { + Layout.fillWidth: true + text: root.toastMessage + color: "white" + font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.85 + elide: Text.ElideRight + maximumLineCount: 1 } } } From 69b8484025af5c43dc081802be36c55a93c4470c Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sat, 25 Apr 2026 09:48:50 +0200 Subject: [PATCH 25/26] Hide HUD while a game window is fullscreen A mapped LayerShell surface prevents KWin from using DRM direct scanout for fullscreen windows. Setting showing=false fades out the HUD then sets visible=false, unmapping the Wayland surface and allowing KWin to bypass the compositor render loop entirely for the game frame. The HUD reappears when the game exits or is minimised. Error toasts still work because a failed launch never produces a fullscreen window, so showingWindow remains false. --- containments/homescreens/folio/qml/main.qml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index cfa4b45f..1c8de4f0 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -758,11 +758,17 @@ ContainmentItem { // the user return to the Game Center after launching a game. // Keep the Loader active for the full duration of gaming mode so the // opacity Behavior in GamingHUD can animate both fade-in and fade-out. + // + // Hide the HUD while a game window covers the screen. A mapped LayerShell + // surface prevents KWin from using DRM direct scanout for the fullscreen + // game window. Setting showing=false triggers the opacity fade-out and then + // sets visible=false, which unmaps the Wayland surface and lets KWin bypass + // the compositor render loop entirely for the game frame. Loader { active: ShellSettings.Settings.gamingModeEnabled sourceComponent: GamingHUD { visible: showing - showing: !root.gameCenterOpen + showing: !root.gameCenterOpen && !windowMaximizedTracker.showingWindow onOpenRequested: root.gameCenterOpen = true } } From bbb22b9289721c8987f8bea7e3dcce67ef2c9844 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sat, 25 Apr 2026 10:19:04 +0200 Subject: [PATCH 26/26] Remove dead code before merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the empty loadFlatpakGames() stub — desktop .desktop files with the Game category are already covered by loadDesktopGames(). Remove InstalledRole: the field was always true and nothing in QML consumed it. gamepadAt() is an internal helper; drop Q_INVOKABLE so it isn't part of the public QML surface. --- .../gamelauncherprovider.cpp | 20 ------------------- .../gamingshellplugin/gamelauncherprovider.h | 3 --- components/gamingshellplugin/gamepadmanager.h | 2 +- 3 files changed, 1 insertion(+), 24 deletions(-) diff --git a/components/gamingshellplugin/gamelauncherprovider.cpp b/components/gamingshellplugin/gamelauncherprovider.cpp index 5e343249..99bfd3ae 100644 --- a/components/gamingshellplugin/gamelauncherprovider.cpp +++ b/components/gamingshellplugin/gamelauncherprovider.cpp @@ -304,8 +304,6 @@ QVariant GameLauncherProvider::data(const QModelIndex &index, int role) const return g.artwork; case LastPlayedTextRole: return formatLastPlayed(g.lastPlayed); - case InstalledRole: - return g.installed; case PinnedRole: return m_pinnedGames.contains(g.storageId); } @@ -323,7 +321,6 @@ QHash GameLauncherProvider::roleNames() const {LaunchMethodRole, "launchMethod"}, {ArtworkRole, "artwork"}, {LastPlayedTextRole, "lastPlayedText"}, - {InstalledRole, "installed"}, {PinnedRole, "pinned"}, }; } @@ -362,7 +359,6 @@ void GameLauncherProvider::refresh() loadDesktopGames(); loadSteamGames(); - loadFlatpakGames(); loadLutrisGames(); loadHeroicGames(); loadRecentTimestamps(); @@ -423,7 +419,6 @@ QVariantMap GameLauncherProvider::gameDetails(const QString &storageId) const {QStringLiteral("launchMethod"), launchMethodForEntry(entry)}, {QStringLiteral("artwork"), entry.artwork}, {QStringLiteral("lastPlayedText"), formatLastPlayed(entry.lastPlayed)}, - {QStringLiteral("installed"), entry.installed}, {QStringLiteral("pinned"), m_pinnedGames.contains(entry.storageId)}, {QStringLiteral("perGameFpsLimit"), perGameFpsLimit(entry.storageId)}, {QStringLiteral("perGameOverlayState"), perGameOverlayState(entry.storageId)}, @@ -593,7 +588,6 @@ void GameLauncherProvider::loadDesktopGames() entry.source = isWaydroidApp ? QStringLiteral("waydroid") : QStringLiteral("desktop"); entry.storageId = service->storageId(); entry.launchCommand = service->exec(); - entry.installed = true; m_allGames.append(entry); } } @@ -682,7 +676,6 @@ void GameLauncherProvider::loadSteamGames() entry.source = QStringLiteral("steam"); entry.storageId = QStringLiteral("steam://rungameid/") + appId; entry.launchCommand = QStringLiteral("steam steam://rungameid/") + appId; - entry.installed = true; // Check for grid artwork for (const auto &root : steamRoots) { @@ -708,17 +701,6 @@ void GameLauncherProvider::loadSteamGames() } } -// --- Flatpak games (non-Steam) --- - -void GameLauncherProvider::loadFlatpakGames() -{ - // Flatpak games that export .desktop files with Game category - // are already picked up by loadDesktopGames() via KService. - // This method is a hook for future Flatpak-specific enrichment - // (e.g. querying flatpak metadata for games that don't set - // the Game category properly). -} - // --- Lutris library (SQLite) --- void GameLauncherProvider::loadLutrisGames() @@ -767,7 +749,6 @@ void GameLauncherProvider::loadLutrisGames() entry.storageId = QStringLiteral("lutris:%1").arg(slug); entry.icon = QStringLiteral("lutris"); entry.launchCommand = QStringLiteral("lutris lutris:rungameid/%1").arg(gameId); - entry.installed = true; // Cover art: Lutris stores covers in ~/.local/share/lutris/coverart/ if (!coverart.isEmpty()) { @@ -852,7 +833,6 @@ void GameLauncherProvider::loadHeroicGames() entry.source = QStringLiteral("heroic"); entry.storageId = QStringLiteral("heroic:%1").arg(appName); entry.icon = QStringLiteral("heroic"); - entry.installed = true; // Launch via Heroic protocol handler if (isGog) { diff --git a/components/gamingshellplugin/gamelauncherprovider.h b/components/gamingshellplugin/gamelauncherprovider.h index 2a6b0550..32bf6fcd 100644 --- a/components/gamingshellplugin/gamelauncherprovider.h +++ b/components/gamingshellplugin/gamelauncherprovider.h @@ -44,7 +44,6 @@ public: LaunchMethodRole, ArtworkRole, // path to banner/grid image (empty if none) LastPlayedTextRole, - InstalledRole, PinnedRole, }; Q_ENUM(Roles) @@ -106,12 +105,10 @@ private: QString launchCommand; QString artwork; QDateTime lastPlayed; - bool installed = true; }; void loadDesktopGames(); void loadSteamGames(); - void loadFlatpakGames(); void loadLutrisGames(); void loadHeroicGames(); void deduplicateGames(); diff --git a/components/gamingshellplugin/gamepadmanager.h b/components/gamingshellplugin/gamepadmanager.h index 620fc64a..a66ca045 100644 --- a/components/gamingshellplugin/gamepadmanager.h +++ b/components/gamingshellplugin/gamepadmanager.h @@ -78,7 +78,7 @@ public: bool hasGamepad() const; GamepadDevice *primaryGamepad() const; - Q_INVOKABLE GamepadDevice *gamepadAt(int index) const; + GamepadDevice *gamepadAt(int index) const; Q_INVOKABLE QString buttonLabel(int button, int gamepadIndex = -1) const; Q_SIGNALS: