Add MangoHud overlay, FPS cap, and game pinning

Inject MangoHud when launching desktop and command-line games.
MANGOHUD_CONFIG is built per-launch via a QProcess instance so
env vars are isolated to each child process — qputenv is not used.

Global overlay toggle and FPS cap (Off/30/40/60) are stored as
properties on GameLauncherProvider and reflected in the quick
settings panel. Per-game overrides stored in plasmamobilerc under
[GamingPerGame/<storageId>] take precedence over the globals at
launch time.

Games can be pinned to the top of the grid. The pinned set is
persisted in plasmamobilerc [GamingPinned] and restored on start.
applyFilter() uses stable_sort so pinned games float to the top
while alphabetical order is preserved within each group.
This commit is contained in:
Marco Allegretti 2026-04-25 09:48:25 +02:00
parent 26945e83fa
commit b42ef44e23
3 changed files with 337 additions and 10 deletions

View file

@ -18,7 +18,9 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLocale>
#include <QProcess>
#include <QProcessEnvironment>
#include <QRegularExpression>
#include <QSqlDatabase>
#include <QSqlQuery>
@ -28,6 +30,8 @@
#include <memory>
static const QString s_recentGroup = QStringLiteral("GamingRecentlyPlayed");
static const QString s_pinnedGroup = QStringLiteral("GamingPinned");
static const QString s_perGameGroup = QStringLiteral("GamingPerGame");
static const QString s_waydroidGamingGroup = QStringLiteral("WaydroidGaming");
static const QString s_gameShellPackagesKey = QStringLiteral("gameShellPackages");
@ -266,6 +270,9 @@ GameLauncherProvider::GameLauncherProvider(QObject *parent)
m_pendingLaunchTimer.setInterval(15000);
m_pendingLaunchTimer.setSingleShot(true);
connect(&m_pendingLaunchTimer, &QTimer::timeout, this, &GameLauncherProvider::clearPendingLaunch);
m_mangohudPath = QStandardPaths::findExecutable(QStringLiteral("mangohud"));
m_mangohudAvailable = !m_mangohudPath.isEmpty();
loadPinnedGames();
refresh();
}
@ -291,10 +298,16 @@ QVariant GameLauncherProvider::data(const QModelIndex &index, int role) const
return g.storageId;
case LaunchCommandRole:
return g.launchCommand;
case LaunchMethodRole:
return launchMethodForEntry(g);
case ArtworkRole:
return g.artwork;
case LastPlayedTextRole:
return formatLastPlayed(g.lastPlayed);
case InstalledRole:
return g.installed;
case PinnedRole:
return m_pinnedGames.contains(g.storageId);
}
return {};
}
@ -307,8 +320,11 @@ QHash<int, QByteArray> GameLauncherProvider::roleNames() const
{SourceRole, "source"},
{StorageIdRole, "storageId"},
{LaunchCommandRole, "launchCommand"},
{LaunchMethodRole, "launchMethod"},
{ArtworkRole, "artwork"},
{LastPlayedTextRole, "lastPlayedText"},
{InstalledRole, "installed"},
{PinnedRole, "pinned"},
};
}
@ -391,10 +407,68 @@ void GameLauncherProvider::launchByStorageId(const QString &storageId)
}
}
QVariantMap GameLauncherProvider::gameDetails(const QString &storageId) const
{
const int entryIndex = findEntryIndexByStorageId(storageId);
if (entryIndex < 0) {
return {};
}
const auto &entry = m_allGames.at(entryIndex);
return {
{QStringLiteral("name"), entry.name},
{QStringLiteral("icon"), entry.icon},
{QStringLiteral("source"), entry.source},
{QStringLiteral("storageId"), entry.storageId},
{QStringLiteral("launchMethod"), launchMethodForEntry(entry)},
{QStringLiteral("artwork"), entry.artwork},
{QStringLiteral("lastPlayedText"), formatLastPlayed(entry.lastPlayed)},
{QStringLiteral("installed"), entry.installed},
{QStringLiteral("pinned"), m_pinnedGames.contains(entry.storageId)},
{QStringLiteral("perGameFpsLimit"), perGameFpsLimit(entry.storageId)},
{QStringLiteral("perGameOverlayState"), perGameOverlayState(entry.storageId)},
};
}
bool GameLauncherProvider::openSourceApp(const QString &source)
{
QString program;
QString displayName;
if (source == QLatin1String("steam")) {
program = QStringLiteral("steam");
displayName = QStringLiteral("Steam");
} else if (source == QLatin1String("lutris")) {
program = QStringLiteral("lutris");
displayName = QStringLiteral("Lutris");
} else if (source == QLatin1String("heroic")) {
program = QStringLiteral("heroic");
displayName = QStringLiteral("Heroic");
} else {
return false;
}
clearLastLaunchError();
if (!QProcess::startDetached(program, {})) {
markLaunchFailed(displayName, QStringLiteral("Unable to start %1").arg(program));
return false;
}
return true;
}
void GameLauncherProvider::launchEntry(GameEntry &entry)
{
clearLastLaunchError();
const KConfigGroup pgParent(m_config.data(), s_perGameGroup);
const KConfigGroup pgGroup(&pgParent, entry.storageId);
const int pgFpsLimit = pgGroup.readEntry("fpsLimit", -1);
const int pgOverlayState = pgGroup.readEntry("overlayState", -1);
const int effectiveFps = (pgFpsLimit >= 0) ? pgFpsLimit : m_fpsLimit;
const bool effectiveOverlay = (pgOverlayState >= 0) ? (pgOverlayState == 1) : m_overlayEnabled;
if (entry.source == QLatin1String("desktop") || entry.source == QLatin1String("waydroid")) {
auto service = KService::serviceByStorageId(entry.storageId);
if (!service) {
@ -402,6 +476,27 @@ void GameLauncherProvider::launchEntry(GameEntry &entry)
return;
}
// Wrap native desktop entries with mangohud when overlay or FPS cap is active.
// Waydroid (Android) games don't benefit from it, so skip them.
if (entry.source == QLatin1String("desktop") && m_mangohudAvailable && (effectiveOverlay || effectiveFps > 0)) {
QString exec = service->exec();
// Strip KDE .desktop field-code placeholders (%u, %U, %f, %F, %i, %c, %k …)
exec.remove(QRegularExpression(QStringLiteral("%[a-zA-Z]")));
KShell::Errors parseError = KShell::NoError;
QStringList parts = KShell::splitArgs(exec.simplified(), KShell::NoOptions, &parseError);
if (parseError == KShell::NoError && !parts.isEmpty()) {
const QString program = parts.takeFirst();
qint64 pid = 0;
if (launchWithMangohud(program, parts, effectiveOverlay, effectiveFps, &pid)) {
markLaunchSucceeded(entry.storageId, entry.name);
} else {
markLaunchFailed(entry.name, QStringLiteral("Failed to launch %1 with mangohud").arg(program));
}
return;
}
// Exec parsing failed — fall through to the standard KIO job without overlay.
}
auto *job = new KIO::ApplicationLauncherJob(service);
connect(job, &KJob::result, this, [this, job, storageId = entry.storageId, name = entry.name]() {
if (job->error() != 0) {
@ -426,7 +521,13 @@ void GameLauncherProvider::launchEntry(GameEntry &entry)
}
QString program = parts.takeFirst();
if (!QProcess::startDetached(program, parts)) {
if (m_mangohudAvailable && (effectiveOverlay || effectiveFps > 0)) {
qint64 pid = 0;
if (!launchWithMangohud(program, parts, effectiveOverlay, effectiveFps, &pid)) {
markLaunchFailed(entry.name, QStringLiteral("Unable to start %1 with mangohud").arg(program));
return;
}
} else if (!QProcess::startDetached(program, parts)) {
markLaunchFailed(entry.name, QStringLiteral("Unable to start %1").arg(program));
return;
}
@ -818,6 +919,46 @@ bool GameLauncherProvider::overlayEnabled() const
return m_overlayEnabled;
}
bool GameLauncherProvider::mangohudAvailable() const
{
return m_mangohudAvailable;
}
int GameLauncherProvider::fpsLimit() const
{
return m_fpsLimit;
}
void GameLauncherProvider::setFpsLimit(int limit)
{
if (m_fpsLimit == limit) {
return;
}
m_fpsLimit = limit;
Q_EMIT fpsLimitChanged();
}
bool GameLauncherProvider::launchWithMangohud(const QString &program, const QStringList &args, bool overlayEnabled, int fpsLimit, qint64 *pid)
{
QStringList config;
if (!overlayEnabled) {
config << QStringLiteral("no_display");
}
if (fpsLimit > 0) {
config << QStringLiteral("fps_limit=%1").arg(fpsLimit);
}
QProcess proc;
if (!config.isEmpty()) {
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("MANGOHUD_CONFIG"), config.join(QLatin1Char(',')));
proc.setProcessEnvironment(env);
}
proc.setProgram(m_mangohudPath);
proc.setArguments(QStringList{program} + args);
return proc.startDetached(pid);
}
void GameLauncherProvider::setOverlayEnabled(bool enabled)
{
if (m_overlayEnabled == enabled) {
@ -825,15 +966,6 @@ void GameLauncherProvider::setOverlayEnabled(bool enabled)
}
m_overlayEnabled = enabled;
Q_EMIT overlayEnabledChanged();
// Set/unset MangoHud environment variables for child processes
if (enabled) {
qputenv("MANGOHUD", "1");
qputenv("MANGOHUD_DLSYM", "1");
} else {
qunsetenv("MANGOHUD");
qunsetenv("MANGOHUD_DLSYM");
}
}
void GameLauncherProvider::applyFilter()
@ -851,6 +983,10 @@ void GameLauncherProvider::applyFilter()
m_games.append(g);
}
std::stable_sort(m_games.begin(), m_games.end(), [this](const GameEntry &a, const GameEntry &b) {
return m_pinnedGames.contains(a.storageId) > m_pinnedGames.contains(b.storageId);
});
endResetModel();
Q_EMIT countChanged();
}
@ -873,6 +1009,129 @@ void GameLauncherProvider::saveRecentTimestamp(const QString &storageId, const Q
group.sync();
}
void GameLauncherProvider::loadPinnedGames()
{
const KConfigGroup group(m_config, s_pinnedGroup);
const QStringList list = group.readEntry(QStringLiteral("pinned"), QStringList{});
m_pinnedGames = QSet<QString>(list.begin(), list.end());
}
void GameLauncherProvider::togglePin(const QString &storageId)
{
if (storageId.isEmpty()) {
return;
}
if (m_pinnedGames.contains(storageId)) {
m_pinnedGames.remove(storageId);
} else {
m_pinnedGames.insert(storageId);
}
KConfigGroup group(m_config, s_pinnedGroup);
group.writeEntry(QStringLiteral("pinned"), QStringList(m_pinnedGames.begin(), m_pinnedGames.end()));
group.sync();
applyFilter();
}
int GameLauncherProvider::perGameFpsLimit(const QString &storageId) const
{
const KConfigGroup parent(m_config.data(), s_perGameGroup);
const KConfigGroup group(&parent, storageId);
return group.readEntry("fpsLimit", -1);
}
void GameLauncherProvider::setPerGameFpsLimit(const QString &storageId, int limit)
{
if (storageId.isEmpty()) {
return;
}
KConfigGroup parent(m_config.data(), s_perGameGroup);
KConfigGroup group(&parent, storageId);
if (limit < 0) {
group.deleteEntry("fpsLimit");
} else {
group.writeEntry("fpsLimit", limit);
}
group.sync();
}
int GameLauncherProvider::perGameOverlayState(const QString &storageId) const
{
const KConfigGroup parent(m_config.data(), s_perGameGroup);
const KConfigGroup group(&parent, storageId);
return group.readEntry("overlayState", -1);
}
void GameLauncherProvider::setPerGameOverlayState(const QString &storageId, int state)
{
if (storageId.isEmpty()) {
return;
}
KConfigGroup parent(m_config.data(), s_perGameGroup);
KConfigGroup group(&parent, storageId);
if (state < 0) {
group.deleteEntry("overlayState");
} else {
group.writeEntry("overlayState", state);
}
group.sync();
}
void GameLauncherProvider::clearLastPlayed(const QString &storageId)
{
if (storageId.isEmpty()) {
return;
}
KConfigGroup group(m_config, s_recentGroup);
if (!group.hasKey(storageId)) {
return;
}
group.deleteEntry(storageId);
group.sync();
const int entryIndex = findEntryIndexByStorageId(storageId);
if (entryIndex >= 0) {
m_allGames[entryIndex].lastPlayed = QDateTime();
const int filteredIndex = [&] {
for (int i = 0; i < m_games.size(); ++i) {
if (m_games.at(i).storageId == storageId) {
return i;
}
}
return -1;
}();
if (filteredIndex >= 0) {
m_games[filteredIndex].lastPlayed = QDateTime();
const QModelIndex idx = index(filteredIndex);
Q_EMIT dataChanged(idx, idx, {LastPlayedTextRole});
}
}
Q_EMIT recentGamesChanged();
}
QString GameLauncherProvider::launchMethodForEntry(const GameEntry &entry) const
{
if (entry.source == QLatin1String("desktop") || entry.source == QLatin1String("waydroid")) {
return QStringLiteral("desktop-entry");
}
if (entry.launchCommand.contains(QStringLiteral("://"))) {
return QStringLiteral("protocol");
}
return QStringLiteral("command");
}
QString GameLauncherProvider::formatLastPlayed(const QDateTime &when) const
{
if (!when.isValid()) {
return {};
}
return QLocale().toString(when, QLocale::ShortFormat);
}
void GameLauncherProvider::clearPendingLaunch()
{
if (!m_launchPending && m_pendingLaunchName.isEmpty()) {
@ -917,6 +1176,7 @@ void GameLauncherProvider::markLaunchSucceeded(const QString &storageId, const Q
setPendingLaunch(name);
Q_EMIT gameLaunched(name);
Q_EMIT recentGamesChanged();
}
void GameLauncherProvider::markLaunchFailed(const QString &name, const QString &error)

View file

@ -6,8 +6,10 @@
#include <QAbstractListModel>
#include <QDateTime>
#include <QList>
#include <QSet>
#include <QString>
#include <QTimer>
#include <QVariantMap>
#include <qqmlregistration.h>
#include <KConfigWatcher>
@ -24,6 +26,8 @@ class GameLauncherProvider : public QAbstractListModel
Q_PROPERTY(QString filterString READ filterString WRITE setFilterString NOTIFY filterStringChanged)
Q_PROPERTY(QString sourceFilter READ sourceFilter WRITE setSourceFilter NOTIFY sourceFilterChanged)
Q_PROPERTY(bool overlayEnabled READ overlayEnabled WRITE setOverlayEnabled NOTIFY overlayEnabledChanged)
Q_PROPERTY(bool mangohudAvailable READ mangohudAvailable NOTIFY mangohudAvailableChanged)
Q_PROPERTY(int fpsLimit READ fpsLimit WRITE setFpsLimit NOTIFY fpsLimitChanged)
Q_PROPERTY(bool launchPending READ launchPending NOTIFY launchPendingChanged)
Q_PROPERTY(QString pendingLaunchName READ pendingLaunchName NOTIFY launchPendingChanged)
Q_PROPERTY(QString lastLaunchError READ lastLaunchError NOTIFY lastLaunchErrorChanged)
@ -37,8 +41,11 @@ public:
SourceRole, // "desktop", "waydroid", "steam", "flatpak"
StorageIdRole, // .desktop file name or launch URI
LaunchCommandRole,
LaunchMethodRole,
ArtworkRole, // path to banner/grid image (empty if none)
LastPlayedTextRole,
InstalledRole,
PinnedRole,
};
Q_ENUM(Roles)
@ -54,6 +61,9 @@ public:
void setSourceFilter(const QString &source);
bool overlayEnabled() const;
void setOverlayEnabled(bool enabled);
bool mangohudAvailable() const;
int fpsLimit() const;
void setFpsLimit(int limit);
bool launchPending() const;
QString pendingLaunchName() const;
QString lastLaunchError() const;
@ -61,9 +71,17 @@ public:
Q_INVOKABLE void refresh();
Q_INVOKABLE void launch(int index);
Q_INVOKABLE void launchByStorageId(const QString &storageId);
Q_INVOKABLE QVariantMap gameDetails(const QString &storageId) const;
Q_INVOKABLE bool openSourceApp(const QString &source);
Q_INVOKABLE void clearLastPlayed(const QString &storageId);
Q_INVOKABLE QVariantList recentGames(int limit = 5) const;
Q_INVOKABLE void clearPendingLaunch();
Q_INVOKABLE void clearLastLaunchError();
Q_INVOKABLE void togglePin(const QString &storageId);
Q_INVOKABLE int perGameFpsLimit(const QString &storageId) const;
Q_INVOKABLE void setPerGameFpsLimit(const QString &storageId, int limit);
Q_INVOKABLE int perGameOverlayState(const QString &storageId) const;
Q_INVOKABLE void setPerGameOverlayState(const QString &storageId, int state);
Q_SIGNALS:
void countChanged();
@ -71,10 +89,13 @@ Q_SIGNALS:
void filterStringChanged();
void sourceFilterChanged();
void overlayEnabledChanged();
void mangohudAvailableChanged();
void fpsLimitChanged();
void launchPendingChanged();
void lastLaunchErrorChanged();
void gameLaunched(const QString &name);
void gameLaunchFailed(const QString &name, const QString &error);
void recentGamesChanged();
private:
struct GameEntry {
@ -98,12 +119,16 @@ private:
void saveRecentTimestamp(const QString &storageId, const QDateTime &when);
void applyFilter();
void launchEntry(GameEntry &entry);
bool launchWithMangohud(const QString &program, const QStringList &args, bool overlayEnabled, int fpsLimit, qint64 *pid = nullptr);
QString launchMethodForEntry(const GameEntry &entry) const;
QString formatLastPlayed(const QDateTime &when) const;
// Returns the current m_allGames index for the storage id.
// Callers must re-lookup after any mutation that can rebuild or reorder the list.
int findEntryIndexByStorageId(const QString &storageId) const;
void markLaunchSucceeded(const QString &storageId, const QString &name);
void markLaunchFailed(const QString &name, const QString &error);
void setPendingLaunch(const QString &name);
void loadPinnedGames();
QList<GameEntry> m_allGames;
QList<GameEntry> m_games; // filtered view
@ -112,6 +137,10 @@ private:
KSharedConfigPtr m_config;
bool m_loading = false;
bool m_overlayEnabled = false;
int m_fpsLimit = 0;
bool m_mangohudAvailable = false;
QString m_mangohudPath;
QSet<QString> m_pinnedGames;
bool m_launchPending = false;
QString m_pendingLaunchName;
QString m_lastLaunchError;

View file

@ -505,7 +505,45 @@ Item {
id: overlaySwitch
text: i18n("Perf Overlay")
checked: GamingShell.GameLauncherProvider.overlayEnabled
enabled: GamingShell.GameLauncherProvider.mangohudAvailable
opacity: enabled ? 1.0 : 0.5
onToggled: GamingShell.GameLauncherProvider.overlayEnabled = checked
QQC2.ToolTip.visible: !GamingShell.GameLauncherProvider.mangohudAvailable && hovered
QQC2.ToolTip.text: i18n("MangoHud is not installed")
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
// FPS cap spans both columns, driven by MangoHud fps_limit
QQC2.ButtonGroup { id: fpsCap; exclusive: true }
RowLayout {
Layout.columnSpan: 2
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
enabled: GamingShell.GameLauncherProvider.mangohudAvailable
opacity: enabled ? 1.0 : 0.5
PC3.Label { text: i18n("FPS Cap") }
Item { Layout.fillWidth: true }
Repeater {
model: [
{ label: i18nc("@action:button FPS cap off", "Off"), fps: 0 },
{ label: "30", fps: 30 },
{ label: "40", fps: 40 },
{ label: "60", fps: 60 }
]
delegate: QQC2.Button {
required property var modelData
text: modelData.label
flat: true
checkable: true
checked: GamingShell.GameLauncherProvider.fpsLimit === modelData.fps
QQC2.ButtonGroup.group: fpsCap
onClicked: GamingShell.GameLauncherProvider.fpsLimit = modelData.fps
}
}
}
// GameMode status (auto-managed, read-only indicator)