// 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.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 Window { id: root required property var folio property int launchTaskBaseline: 0 property var selectedGame: ({}) property int recentRevision: 0 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) readonly property int shortAnimationDuration: MobileShell.Motion.duration(MobileShell.Motion.EffectsFast) readonly property int longAnimationDuration: MobileShell.Motion.duration(MobileShell.Motion.EffectsDefault) readonly property int launchFadeDuration: MobileShell.Motion.duration(MobileShell.Motion.StandardAccel) function controlLegendText() { if (GamingShell.GamepadManager.hasGamepad) { if (runningGames.activeFocus) { return i18n("%1: Select %2: Close %3: Back %4: Exit %5: Settings %6: Search", actionButtonLabel, closeButtonLabel, backButtonLabel, exitButtonLabel, quickSettingsButtonLabel, searchButtonLabel) } if (recentList.activeFocus) { return i18n("%1: Play %2: Details %3: Back %4: Exit %5: Settings %6: Search", actionButtonLabel, closeButtonLabel, backButtonLabel, exitButtonLabel, quickSettingsButtonLabel, searchButtonLabel) } return i18n("%1: Play %2: Details %3: Back %4: Exit %5/%6: Filter %7: Settings %8: Search", actionButtonLabel, closeButtonLabel, backButtonLabel, exitButtonLabel, leftShoulderLabel, rightShoulderLabel, quickSettingsButtonLabel, searchButtonLabel) } if (runningGames.activeFocus) { return i18n("Mouse: click a running game to focus it. Keyboard: arrows move between cards, Enter selects, Esc closes.") } if (recentList.activeFocus) { return i18n("Mouse: click a recent game to play it. Keyboard: arrows move between cards, Enter plays, Esc closes.") } return i18n("Mouse: click a game or details button. Keyboard: arrows navigate, Enter plays, I shows details, Esc closes.") } 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) launchTaskBaseline = runningGames.taskCount GamingShell.GameLauncherProvider.launch(index) } function launchGameByStorageId(storageId) { pulsePrimaryGamepad(14000, 22000, 80) launchTaskBaseline = runningGames.taskCount 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" 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 { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.EffectsDefault; duration: root.longAnimationDuration } } Connections { target: GamingShell.GameLauncherProvider function onGameLaunched(name) { launchFade.restart() } function onGameLaunchFailed(name, error) { launchErrorTimer.restart() } function onRecentGamesChanged() { root.recentRevision++ } } Timer { id: launchErrorTimer interval: 6000 repeat: false onTriggered: GamingShell.GameLauncherProvider.clearLastLaunchError() } onVisibleChanged: { if (visible) { GamingShell.GameLauncherProvider.filterString = "" GamingShell.GameLauncherProvider.sourceFilter = "" GamingShell.GameLauncherProvider.refresh() if (runningGames.hasTasks) { runningGames.focusFirstTask() } else { grid.forceActiveFocus() } } } // Translate gamepad buttons to focus navigation Connections { target: GamingShell.GamepadManager 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) { if (grid.currentIndex < grid.columns && runningGames.hasTasks) { 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 (recentList.activeFocus) { recentList.decrementCurrentIndex() } else if (grid.activeFocus) { grid.moveCurrentIndexLeft() } break case GamingShell.GamepadManager.ButtonDPadRight: 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) } break 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: root.dismissRequested() break 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 case GamingShell.GamepadManager.ButtonBack: quickSettings.toggle() pulsePrimaryGamepad(7000, 11000, 40) break } } function onAxisChanged(axis, value, gamepadIndex) { if (axis === GamingShell.GamepadManager.AxisLeftX) { stickState.leftX = value } else if (axis === GamingShell.GamepadManager.AxisLeftY) { stickState.leftY = value } else if (axis === GamingShell.GamepadManager.AxisRightY) { stickState.rightY = value } } } // Left-stick navigation state + repeat timer QtObject { id: stickState property real leftX: 0 property real leftY: 0 property real rightY: 0 readonly property real deadzone: 0.4 } 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) { 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 && 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() } } 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() } // 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 (only includes installed launcher sources). readonly property var _sourceFilters: { var filters = ["", "desktop", "waydroid"] if (GamingShell.GameLauncherProvider.steamAvailable) filters.splice(1, 0, "steam") if (GamingShell.GameLauncherProvider.lutrisAvailable) filters.push("lutris") if (GamingShell.GameLauncherProvider.heroicAvailable) filters.push("heroic") return filters } 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 } 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 Kirigami.Theme.colorSet: Kirigami.Theme.Window color: MobileShell.SurfaceColors.withAlpha(MobileShell.SurfaceColors.accentSurface(Kirigami.Theme.backgroundColor, 0.24, 0.12), 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 spacing: Kirigami.Units.largeSpacing Kirigami.Heading { 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") display: QQC2.AbstractButton.TextBesideIcon Keys.onReturnPressed: clicked() Keys.onEnterPressed: clicked() onClicked: root.requestExitGamingMode() } } // ---- running games ---- RunningGamesView { id: runningGames Layout.fillWidth: true 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" isMask: true color: Kirigami.Theme.textColor } 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 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 keyNavigationEnabled: true 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.isCurrent ? Kirigami.Theme.highlightColor : (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 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) } } } } // ---- 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 Repeater { model: { var tabs = [ {label: i18n("All"), filter: ""}, {label: i18n("Desktop"), filter: "desktop"}, {label: i18n("Waydroid"),filter: "waydroid"} ] if (GamingShell.GameLauncherProvider.steamAvailable) tabs.splice(1, 0, {label: "Steam", filter: "steam"}) if (GamingShell.GameLauncherProvider.lutrisAvailable) tabs.push({label: "Lutris", filter: "lutris"}) if (GamingShell.GameLauncherProvider.heroicAvailable) tabs.push({label: "Heroic", filter: "heroic"}) return tabs } QQC2.TabButton { text: modelData.label width: implicitWidth onClicked: GamingShell.GameLauncherProvider.sourceFilter = modelData.filter } } } } // ---- game grid ---- GridView { id: grid Layout.fillWidth: true Layout.fillHeight: true model: GamingShell.GameLauncherProvider 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: Math.floor(cellWidth * 1.5) + Kirigami.Units.gridUnit * 2 keyNavigationEnabled: true 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 enable supported Waydroid apps from the Waydroid applications page") } onActiveFocusChanged: { if (activeFocus && count > 0 && currentIndex < 0) { currentIndex = 0 } } Keys.onUpPressed: { if (runningGames.hasTasks) { runningGames.focusFirstTask() } } Keys.onReturnPressed: { if (currentIndex >= 0) { root.launchGame(currentIndex) } } 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 height: grid.cellHeight required property int index required property string name 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 anchors.margins: Kirigami.Units.smallSpacing padding: 0 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 } contentItem: Item { // ---- cover art tile ---- ColumnLayout { anchors.fill: parent spacing: 0 visible: hasArt Rectangle { Layout.fillWidth: true Layout.fillHeight: true radius: Kirigami.Units.cornerRadius clip: true color: "transparent" Image { anchors.fill: parent source: hasArt ? "file://" + artwork : "" fillMode: Image.PreserveAspectCrop 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 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 } } // ---- fallback icon tile ---- ColumnLayout { anchors.fill: parent anchors.margins: Kirigami.Units.smallSpacing visible: !hasArt spacing: Kirigami.Units.smallSpacing 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 { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.EffectsFast; duration: root.shortAnimationDuration } } } 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 } Rectangle { Layout.alignment: Qt.AlignHCenter visible: source !== "desktop" 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 } } } 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 isMask: true color: Kirigami.Theme.textColor } } } // ---- 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" isMask: true color: Kirigami.Theme.textColor } Repeater { model: GamingShell.GamepadManager RowLayout { spacing: Kirigami.Units.smallSpacing required property string name required property int battery required property string type required property var device 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 } 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 } } } Item { Layout.fillWidth: true } // Gamepad legend PC3.Label { Layout.fillWidth: true text: root.controlLegendText() font.pointSize: Kirigami.Theme.defaultFont.pointSize * 0.75 opacity: 0.5 wrapMode: Text.WordWrap horizontalAlignment: Text.AlignRight } } } } // Quick settings slide-out panel GamingQuickSettings { id: quickSettings z: 50 } // Launch transition: brief fade to black, then dismiss Rectangle { id: launchCurtain anchors.fill: parent color: "black" opacity: 0 z: 100 Behavior on opacity { MobileShell.MotionNumberAnimation { type: MobileShell.Motion.StandardAccel; duration: root.launchFadeDuration } } } Timer { id: launchFade interval: 300 onTriggered: { launchCurtain.opacity = 0 root.gameStarted() } onRunningChanged: { if (running) { launchCurtain.opacity = 1 } } } 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 anchors.fill: parent sourceComponent: Kirigami.PromptDialog { 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 theExitDialog.close() } } ] onClosed: exitGamingDialog.active = false } } }