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.
This commit is contained in:
Marco Allegretti 2026-04-19 13:51:20 +02:00
parent b0739dd9a7
commit 6ac9c58d93
5 changed files with 525 additions and 0 deletions

View file

@ -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

View file

@ -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
}
}
}

View file

@ -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 }
}
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}
}
}
}