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.
This commit is contained in:
Marco Allegretti 2026-04-20 14:09:33 +02:00
parent 4a9a5b2a97
commit bcb985c652
11 changed files with 767 additions and 31 deletions

View file

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

View file

@ -4,6 +4,7 @@
#include "gamelauncherprovider.h"
#include <KConfigGroup>
#include <KIO/ApplicationLauncherJob>
#include <KService>
#include <KSharedConfig>
#include <KShell>
@ -12,8 +13,13 @@
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QRegularExpression>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QStandardPaths>
#include <QTextStream>
@ -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<QString> 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<QString> 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<quintptr>(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;

View file

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

View file

@ -7,6 +7,8 @@
#include <SDL3/SDL_power.h>
#include <SDL3/SDL_properties.h>
#include <algorithm>
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<uint16_t>(lowFreqMs), static_cast<uint16_t>(highFreqMs), static_cast<uint32_t>(durationMs));
auto lo = static_cast<uint16_t>(std::clamp(lowIntensity, 0, 65535));
auto hi = static_cast<uint16_t>(std::clamp(highIntensity, 0, 65535));
auto dur = static_cast<uint32_t>(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<uint8_t>(r), static_cast<uint8_t>(g), static_cast<uint8_t>(b));
auto cr = static_cast<uint8_t>(std::clamp(r, 0, 255));
auto cg = static_cast<uint8_t>(std::clamp(g, 0, 255));
auto cb = static_cast<uint8_t>(std::clamp(b, 0, 255));
return SDL_SetGamepadLED(m_pad, cr, cg, cb);
}
SDL_Gamepad *GamepadDevice::sdlGamepad() const

View file

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

View file

@ -8,6 +8,9 @@
#include <SDL3/SDL_gamepad.h>
#include <QDebug>
#include <QQmlEngine>
#include <algorithm>
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<float>(event.gaxis.value) / 32767.0f;
float normalized = std::clamp(static_cast<float>(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();
}

View file

@ -7,6 +7,8 @@
#include <QTimer>
#include <qqmlregistration.h>
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<GamepadDevice *> m_gamepads;
};

View file

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

View file

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

View file

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

View file

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