From 6ac9c58d9394abcf32fc2b06439a968428bfd643 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sun, 19 Apr 2026 13:51:20 +0200 Subject: [PATCH] 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() + } + } + } + } + } +}