Compare commits

...

14 commits

Author SHA1 Message Date
9db6e4cd6a Docs: describe legacy vs gamecenter tracks 2026-02-09 17:04:36 +01:00
0aa7c4727a Merge branch 'fix/incremental-import' into 'master'
Add launch profiles MVP

See merge request marcoa/a-la-karte!4
2026-02-03 11:27:09 +00:00
ecb7eb66d6 Merge branch 'fix/window-geometry' into 'master'
Persist window size across restarts

See merge request marcoa/a-la-karte!3
2026-02-03 11:25:41 +00:00
55d5a70656 Persist window size across restarts 2026-02-03 11:22:11 +01:00
5f0978dd0e Fix desktop import paths and add CLI import mode 2026-02-03 10:42:12 +01:00
245944fca9 Make imports incremental and refreshable 2026-02-03 10:41:29 +01:00
365c3b31cb Use global couch mode for profiles
Select the couch launch profile based on effective UI mode so profile choice is consistent across games.

Update details and edit UI to toggle and reflect couch mode globally, and fix duplicate QML property declarations.
2026-01-30 14:07:58 +01:00
ec7d61e5b9 Add launch profiles MVP
Store per-game launch profile overrides and active profile selection in library.json.

Add a quick profile toggle in the details sheet and minimal editing in the edit dialog. Apply the effective profile config in both launching and diagnostics.
2026-01-30 13:44:12 +01:00
1889f9cf81 Merge branch 'feat/couch-mode-diagnostics' into 'master'
Couch Mode: add diagnostics overlay

See merge request marcoa/a-la-karte!2
2026-01-30 11:52:12 +00:00
aabb8d0f4b Couch Mode: add diagnostics overlay 2026-01-30 11:02:28 +01:00
8ecc1e3a74 Merge branch 'fix/couch-mode-focus' into 'master'
Fix/couch mode focus

See merge request marcoa/a-la-karte!1
2026-01-29 20:06:35 +00:00
f02b26f681 Core: set QT_DESKTOP_FILE_NAME early for portals 2026-01-29 19:57:07 +01:00
5b993cff6b QML: fix couch focus/menu scoping and overlays 2026-01-29 19:49:45 +01:00
444ef65a78 Core: add InputManager and couch UI mode 2026-01-29 19:49:27 +01:00
27 changed files with 2787 additions and 249 deletions

View file

@ -6,6 +6,19 @@
A unified game launcher for KDE Plasma 6, inspired by [KDE Apps Initiative issue#26](https://invent.kde.org/teams/apps-initiative/tasks/-/issues/26). A unified game launcher for KDE Plasma 6, inspired by [KDE Apps Initiative issue#26](https://invent.kde.org/teams/apps-initiative/tasks/-/issues/26).
## Project tracks
This repository currently has two development tracks:
- **Legacy (launcher-first)**
- The current `master`/`legacy` baseline: a traditional game launcher UI.
- See `docs/LEGACY.md`.
- **Gamecenter (launcher + daemons)**
- The `gamecenter` branch: an experimental direction to evolve A-La-Karte into a launcher + game center suitable for a console-like KDE experience.
- See `docs/GAMECENTER.md`.
For documentation index, see `docs/README.md`.
## Features ## Features
- Import games from Steam, Lutris, Heroic, Bottles, itch.io, Legendary, RetroArch playlists, Flatpak applications, and desktop entries - Import games from Steam, Lutris, Heroic, Bottles, itch.io, Legendary, RetroArch playlists, Flatpak applications, and desktop entries

52
docs/GAMECENTER.md Normal file
View file

@ -0,0 +1,52 @@
# Gamecenter track (Launcher + Game Center)
## Goal
The gamecenter track evolves A-La-Karte from “a launcher” into “a launcher + game center” suitable for a **fully integrated, console-like experience on KDE**.
Key ideas:
- The UI becomes a client of background services.
- Launching and monitoring games is handled by a daemon.
- Runner resolution (Wine/Proton) is handled by a runner manager service.
## Architecture (high level)
### UI (A-La-Karte)
- Displays the library and settings.
- Requests launches via the Game Center daemon instead of launching processes directly.
- Surfaces daemon/service errors to the user.
### Game Center daemon
- Manages game sessions (launch/stop, running state, concurrency policy).
- Tracks/monitors sessions so Steam/Lutris URL-based launches can still be managed.
- Intended to be activatable via DBus/systemd.
DBus service (planned/implemented on the gamecenter branch):
- `org.kde.GameCenter1`
### Runner manager daemon
- Manages “runners” (e.g. Proton, Wine, GE builds):
- install/uninstall
- inventory listing
- resolve launch specs deterministically
- prefix lifecycle helpers
DBus service (planned/implemented on the gamecenter branch):
- `org.kde.ALaKarte.Runner1`
## Branches
- `master` / `legacy`
- Launcher-focused baseline.
- `gamecenter`
- Experimental daemon-based direction.
## Status
This document is intentionally a living overview. Detailed API docs / roadmaps should live in separate documents as they solidify.

18
docs/LEGACY.md Normal file
View file

@ -0,0 +1,18 @@
# Legacy track (Launcher)
## What it is
The legacy track is a traditional desktop application:
- The UI directly owns the game library.
- The UI launches games.
- Focus is on being a high-quality KDE Plasma launcher with good keyboard/gamepad navigation.
## What it is not
- A system-level game session manager.
- A daemon that monitors and manages sessions across launchers.
## Why it exists
This track is the stable baseline and the fastest way to iterate on UX, importers, and per-game configuration.

14
docs/README.md Normal file
View file

@ -0,0 +1,14 @@
# Documentation
This folder contains higher-level project documentation.
## Overview
- `LEGACY.md`
- Notes on the current launcher-focused architecture (historically the "legacy" track).
- `GAMECENTER.md`
- Notes on the "gamecenter" track: a launcher + Game Center daemon-based architecture for a more console-like KDE experience.
## Contributing docs
If you add a new document, link it from this index so it stays discoverable.

22
docs/ROADMAP.md Normal file
View file

@ -0,0 +1,22 @@
# Roadmap
This document captures the high-level direction of the project.
## Legacy track (launcher-first)
- Maintain a stable, polished KDE Plasma launcher.
- Keep importers and library UX solid and reliable.
## Gamecenter track (launcher + daemons)
- Evolve into a fully integrated, console-like experience on KDE.
- Move launching + session monitoring into a daemon.
- Add runner management (Wine/Proton) as a service.
## Near-term goals
- (Fill in)
## Longer-term goals
- (Fill in)

View file

@ -8,6 +8,7 @@ add_executable(alakarte
gamesortfiltermodel.cpp gamesortfiltermodel.cpp
game.cpp game.cpp
gamepadmanager.cpp gamepadmanager.cpp
inputmanager.cpp
mediamanager.cpp mediamanager.cpp
screenshotmodel.cpp screenshotmodel.cpp
platformimporter.cpp platformimporter.cpp
@ -63,6 +64,7 @@ ecm_add_qml_module(alakarte URI org.kde.alakarte
qml/LibraryView.qml qml/LibraryView.qml
qml/GameCard.qml qml/GameCard.qml
qml/GameDetailsSheet.qml qml/GameDetailsSheet.qml
qml/DiagnosticsSheet.qml
qml/SettingsPage.qml qml/SettingsPage.qml
qml/SidebarView.qml qml/SidebarView.qml
qml/GameEditDialog.qml qml/GameEditDialog.qml
@ -118,6 +120,7 @@ ecm_add_qml_module(alakarte URI org.kde.alakarte
gamesortfiltermodel.h gamesortfiltermodel.h
game.h game.h
gamepadmanager.h gamepadmanager.h
inputmanager.h
platformimporter.h platformimporter.h
steamimporter.h steamimporter.h
lutrisimporter.h lutrisimporter.h

View file

@ -18,7 +18,9 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError> #include <QJsonParseError>
#include <QSet>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTimer>
#include <QtConcurrent> #include <QtConcurrent>
App *App::s_instance = nullptr; App *App::s_instance = nullptr;
@ -142,6 +144,12 @@ App::App(QObject *parent)
} }
} }
}); });
if (m_config->autoImportOnStartup()) {
QTimer::singleShot(0, this, [this]() {
importAllGames();
});
}
} }
App *App::instance() App *App::instance()
@ -215,18 +223,17 @@ void App::importAllGames()
if (m_importing) if (m_importing)
return; return;
const bool anyEnabled = m_config->importSteam() || m_config->importLutris() || m_config->importHeroic() || m_config->importDesktop() const bool doSteam = m_config->importSteam();
|| m_config->importBottles() || m_config->importFlatpak() || m_config->importItch() || m_config->importLegendary() || m_config->importRetroArch(); const bool doLutris = m_config->importLutris();
const bool doHeroic = m_config->importHeroic();
const bool doDesktop = m_config->importDesktop();
const bool doBottles = m_config->importBottles();
const bool doFlatpak = m_config->importFlatpak();
const bool doItch = m_config->importItch();
const bool doLegendary = m_config->importLegendary();
const bool doRetroArch = m_config->importRetroArch();
const bool doSteam = m_config->importSteam() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Steam")); const bool anyEnabled = doSteam || doLutris || doHeroic || doDesktop || doBottles || doFlatpak || doItch || doLegendary || doRetroArch;
const bool doLutris = m_config->importLutris() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Lutris"));
const bool doHeroic = m_config->importHeroic() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Heroic"));
const bool doDesktop = m_config->importDesktop() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Desktop"));
const bool doBottles = m_config->importBottles() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Bottles"));
const bool doFlatpak = m_config->importFlatpak() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Flatpak"));
const bool doItch = m_config->importItch() && !m_gameModel->hasPlatformPrefix(QStringLiteral("itch.io"));
const bool doLegendary = m_config->importLegendary() && !m_gameModel->hasPlatformPrefix(QStringLiteral("Legendary"));
const bool doRetroArch = m_config->importRetroArch() && !m_gameModel->hasPlatformPrefix(QStringLiteral("RetroArch"));
if (!anyEnabled) { if (!anyEnabled) {
setImportStatus(tr("No import sources enabled")); setImportStatus(tr("No import sources enabled"));
@ -234,17 +241,55 @@ void App::importAllGames()
return; return;
} }
if (!(doSteam || doLutris || doHeroic || doDesktop || doBottles || doFlatpak || doItch || doLegendary || doRetroArch)) { const QSet<QString> existingIds = [this]() {
setImportStatus(tr("All enabled sources already imported")); QSet<QString> ids;
Q_EMIT importCompleted(0); const QList<Game *> games = m_gameModel->allGames();
return; for (Game *game : games) {
} if (game) {
ids.insert(game->id());
}
}
return ids;
}();
setImporting(true); setImporting(true);
setImportStatus(tr("Importing games...")); setImportStatus(tr("Importing games..."));
[[maybe_unused]] auto future = QtConcurrent::run([this, doSteam, doLutris, doHeroic, doDesktop, doBottles, doFlatpak, doItch, doLegendary, doRetroArch]() { [[maybe_unused]] auto future = QtConcurrent::run([this,
doSteam,
doLutris,
doHeroic,
doDesktop,
doBottles,
doFlatpak,
doItch,
doLegendary,
doRetroArch,
existingIds]() {
int totalCount = 0; int totalCount = 0;
QSet<QString> seenIds = existingIds;
const auto keepNewGames = [&seenIds](const QList<Game *> &games) {
QList<Game *> result;
result.reserve(games.size());
for (Game *game : games) {
if (!game) {
continue;
}
const QString id = game->id();
if (id.isEmpty() || seenIds.contains(id)) {
delete game;
continue;
}
seenIds.insert(id);
result.append(game);
}
return result;
};
// Import from Steam // Import from Steam
if (doSteam) { if (doSteam) {
@ -256,7 +301,7 @@ void App::importAllGames()
Qt::QueuedConnection); Qt::QueuedConnection);
SteamImporter steamImporter; SteamImporter steamImporter;
QList<Game *> steamGames = steamImporter.importGames(); QList<Game *> steamGames = keepNewGames(steamImporter.importGames());
for (Game *game : steamGames) { for (Game *game : steamGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
@ -291,7 +336,7 @@ void App::importAllGames()
Qt::QueuedConnection); Qt::QueuedConnection);
LutrisImporter lutrisImporter; LutrisImporter lutrisImporter;
QList<Game *> lutrisGames = lutrisImporter.importGames(); QList<Game *> lutrisGames = keepNewGames(lutrisImporter.importGames());
for (Game *game : lutrisGames) { for (Game *game : lutrisGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
@ -326,7 +371,7 @@ void App::importAllGames()
Qt::QueuedConnection); Qt::QueuedConnection);
HeroicImporter heroicImporter; HeroicImporter heroicImporter;
QList<Game *> heroicGames = heroicImporter.importGames(); QList<Game *> heroicGames = keepNewGames(heroicImporter.importGames());
for (Game *game : heroicGames) { for (Game *game : heroicGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
@ -361,7 +406,7 @@ void App::importAllGames()
Qt::QueuedConnection); Qt::QueuedConnection);
DesktopImporter desktopImporter; DesktopImporter desktopImporter;
QList<Game *> desktopGames = desktopImporter.importGames(); QList<Game *> desktopGames = keepNewGames(desktopImporter.importGames());
for (Game *game : desktopGames) { for (Game *game : desktopGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
@ -396,7 +441,7 @@ void App::importAllGames()
Qt::QueuedConnection); Qt::QueuedConnection);
BottlesImporter bottlesImporter; BottlesImporter bottlesImporter;
QList<Game *> bottlesGames = bottlesImporter.importGames(); QList<Game *> bottlesGames = keepNewGames(bottlesImporter.importGames());
for (Game *game : bottlesGames) { for (Game *game : bottlesGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
@ -431,7 +476,7 @@ void App::importAllGames()
Qt::QueuedConnection); Qt::QueuedConnection);
FlatpakImporter flatpakImporter; FlatpakImporter flatpakImporter;
QList<Game *> flatpakGames = flatpakImporter.importGames(); QList<Game *> flatpakGames = keepNewGames(flatpakImporter.importGames());
for (Game *game : flatpakGames) { for (Game *game : flatpakGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
@ -466,7 +511,7 @@ void App::importAllGames()
Qt::QueuedConnection); Qt::QueuedConnection);
ItchImporter itchImporter; ItchImporter itchImporter;
QList<Game *> itchGames = itchImporter.importGames(); QList<Game *> itchGames = keepNewGames(itchImporter.importGames());
for (Game *game : itchGames) { for (Game *game : itchGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
@ -501,7 +546,7 @@ void App::importAllGames()
Qt::QueuedConnection); Qt::QueuedConnection);
LegendaryImporter legendaryImporter; LegendaryImporter legendaryImporter;
QList<Game *> legendaryGames = legendaryImporter.importGames(); QList<Game *> legendaryGames = keepNewGames(legendaryImporter.importGames());
for (Game *game : legendaryGames) { for (Game *game : legendaryGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
@ -536,7 +581,7 @@ void App::importAllGames()
Qt::QueuedConnection); Qt::QueuedConnection);
RetroArchImporter retroArchImporter; RetroArchImporter retroArchImporter;
QList<Game *> retroArchGames = retroArchImporter.importGames(); QList<Game *> retroArchGames = keepNewGames(retroArchImporter.importGames());
for (Game *game : retroArchGames) { for (Game *game : retroArchGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
@ -565,7 +610,7 @@ void App::importAllGames()
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, totalCount]() { [this, totalCount]() {
setImportStatus(tr("Import complete: %1 games found").arg(totalCount)); setImportStatus(tr("Import complete: %1 new games found").arg(totalCount));
setImporting(false); setImporting(false);
saveLibrary(); saveLibrary();
Q_EMIT importCompleted(totalCount); Q_EMIT importCompleted(totalCount);
@ -579,28 +624,57 @@ void App::importFromSteam()
if (m_importing) if (m_importing)
return; return;
const QSet<QString> existingIds = [this]() {
QSet<QString> ids;
const QList<Game *> games = m_gameModel->allGames();
for (Game *game : games) {
if (game) {
ids.insert(game->id());
}
}
return ids;
}();
setImporting(true); setImporting(true);
setImportStatus(tr("Scanning Steam library...")); setImportStatus(tr("Scanning Steam library..."));
[[maybe_unused]] auto future = QtConcurrent::run([this]() { [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() {
SteamImporter importer; SteamImporter importer;
QList<Game *> games = importer.importGames(); QList<Game *> games = importer.importGames();
QSet<QString> seenIds = existingIds;
QList<Game *> newGames;
newGames.reserve(games.size());
for (Game *game : games) { for (Game *game : games) {
if (!game) {
continue;
}
const QString id = game->id();
if (id.isEmpty() || seenIds.contains(id)) {
delete game;
continue;
}
seenIds.insert(id);
newGames.append(game);
}
for (Game *game : newGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
} }
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, games]() { [this, newGames]() {
for (Game *game : games) { for (Game *game : newGames) {
m_gameModel->addGame(game); m_gameModel->addGame(game);
} }
setImportStatus(tr("Steam import complete: %1 games found").arg(games.count())); setImportStatus(tr("Steam import complete: %1 new games found").arg(newGames.count()));
setImporting(false); setImporting(false);
saveLibrary(); saveLibrary();
Q_EMIT importCompleted(games.count()); Q_EMIT importCompleted(newGames.count());
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
@ -611,28 +685,57 @@ void App::importFromLutris()
if (m_importing) if (m_importing)
return; return;
const QSet<QString> existingIds = [this]() {
QSet<QString> ids;
const QList<Game *> games = m_gameModel->allGames();
for (Game *game : games) {
if (game) {
ids.insert(game->id());
}
}
return ids;
}();
setImporting(true); setImporting(true);
setImportStatus(tr("Scanning Lutris library...")); setImportStatus(tr("Scanning Lutris library..."));
[[maybe_unused]] auto future = QtConcurrent::run([this]() { [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() {
LutrisImporter importer; LutrisImporter importer;
QList<Game *> games = importer.importGames(); QList<Game *> games = importer.importGames();
QSet<QString> seenIds = existingIds;
QList<Game *> newGames;
newGames.reserve(games.size());
for (Game *game : games) { for (Game *game : games) {
if (!game) {
continue;
}
const QString id = game->id();
if (id.isEmpty() || seenIds.contains(id)) {
delete game;
continue;
}
seenIds.insert(id);
newGames.append(game);
}
for (Game *game : newGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
} }
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, games]() { [this, newGames]() {
for (Game *game : games) { for (Game *game : newGames) {
m_gameModel->addGame(game); m_gameModel->addGame(game);
} }
setImportStatus(tr("Lutris import complete: %1 games found").arg(games.count())); setImportStatus(tr("Lutris import complete: %1 new games found").arg(newGames.count()));
setImporting(false); setImporting(false);
saveLibrary(); saveLibrary();
Q_EMIT importCompleted(games.count()); Q_EMIT importCompleted(newGames.count());
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
@ -643,28 +746,57 @@ void App::importFromHeroic()
if (m_importing) if (m_importing)
return; return;
const QSet<QString> existingIds = [this]() {
QSet<QString> ids;
const QList<Game *> games = m_gameModel->allGames();
for (Game *game : games) {
if (game) {
ids.insert(game->id());
}
}
return ids;
}();
setImporting(true); setImporting(true);
setImportStatus(tr("Scanning Heroic library...")); setImportStatus(tr("Scanning Heroic library..."));
[[maybe_unused]] auto future = QtConcurrent::run([this]() { [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() {
HeroicImporter importer; HeroicImporter importer;
QList<Game *> games = importer.importGames(); QList<Game *> games = importer.importGames();
QSet<QString> seenIds = existingIds;
QList<Game *> newGames;
newGames.reserve(games.size());
for (Game *game : games) { for (Game *game : games) {
if (!game) {
continue;
}
const QString id = game->id();
if (id.isEmpty() || seenIds.contains(id)) {
delete game;
continue;
}
seenIds.insert(id);
newGames.append(game);
}
for (Game *game : newGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
} }
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, games]() { [this, newGames]() {
for (Game *game : games) { for (Game *game : newGames) {
m_gameModel->addGame(game); m_gameModel->addGame(game);
} }
setImportStatus(tr("Heroic import complete: %1 games found").arg(games.count())); setImportStatus(tr("Heroic import complete: %1 new games found").arg(newGames.count()));
setImporting(false); setImporting(false);
saveLibrary(); saveLibrary();
Q_EMIT importCompleted(games.count()); Q_EMIT importCompleted(newGames.count());
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
@ -675,28 +807,57 @@ void App::importFromDesktop()
if (m_importing) if (m_importing)
return; return;
const QSet<QString> existingIds = [this]() {
QSet<QString> ids;
const QList<Game *> games = m_gameModel->allGames();
for (Game *game : games) {
if (game) {
ids.insert(game->id());
}
}
return ids;
}();
setImporting(true); setImporting(true);
setImportStatus(tr("Scanning desktop entries...")); setImportStatus(tr("Scanning desktop entries..."));
[[maybe_unused]] auto future = QtConcurrent::run([this]() { [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() {
DesktopImporter importer; DesktopImporter importer;
QList<Game *> games = importer.importGames(); QList<Game *> games = importer.importGames();
QSet<QString> seenIds = existingIds;
QList<Game *> newGames;
newGames.reserve(games.size());
for (Game *game : games) { for (Game *game : games) {
if (!game) {
continue;
}
const QString id = game->id();
if (id.isEmpty() || seenIds.contains(id)) {
delete game;
continue;
}
seenIds.insert(id);
newGames.append(game);
}
for (Game *game : newGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
} }
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, games]() { [this, newGames]() {
for (Game *game : games) { for (Game *game : newGames) {
m_gameModel->addGame(game); m_gameModel->addGame(game);
} }
setImportStatus(tr("Desktop import complete: %1 games found").arg(games.count())); setImportStatus(tr("Desktop import complete: %1 new games found").arg(newGames.count()));
setImporting(false); setImporting(false);
saveLibrary(); saveLibrary();
Q_EMIT importCompleted(games.count()); Q_EMIT importCompleted(newGames.count());
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
@ -707,28 +868,57 @@ void App::importFromBottles()
if (m_importing) if (m_importing)
return; return;
const QSet<QString> existingIds = [this]() {
QSet<QString> ids;
const QList<Game *> games = m_gameModel->allGames();
for (Game *game : games) {
if (game) {
ids.insert(game->id());
}
}
return ids;
}();
setImporting(true); setImporting(true);
setImportStatus(tr("Scanning Bottles...")); setImportStatus(tr("Scanning Bottles..."));
[[maybe_unused]] auto future = QtConcurrent::run([this]() { [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() {
BottlesImporter importer; BottlesImporter importer;
QList<Game *> games = importer.importGames(); QList<Game *> games = importer.importGames();
QSet<QString> seenIds = existingIds;
QList<Game *> newGames;
newGames.reserve(games.size());
for (Game *game : games) { for (Game *game : games) {
if (!game) {
continue;
}
const QString id = game->id();
if (id.isEmpty() || seenIds.contains(id)) {
delete game;
continue;
}
seenIds.insert(id);
newGames.append(game);
}
for (Game *game : newGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
} }
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, games]() { [this, newGames]() {
for (Game *game : games) { for (Game *game : newGames) {
m_gameModel->addGame(game); m_gameModel->addGame(game);
} }
setImportStatus(tr("Bottles import complete: %1 games found").arg(games.count())); setImportStatus(tr("Bottles import complete: %1 new games found").arg(newGames.count()));
setImporting(false); setImporting(false);
saveLibrary(); saveLibrary();
Q_EMIT importCompleted(games.count()); Q_EMIT importCompleted(newGames.count());
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
@ -739,28 +929,57 @@ void App::importFromFlatpak()
if (m_importing) if (m_importing)
return; return;
const QSet<QString> existingIds = [this]() {
QSet<QString> ids;
const QList<Game *> games = m_gameModel->allGames();
for (Game *game : games) {
if (game) {
ids.insert(game->id());
}
}
return ids;
}();
setImporting(true); setImporting(true);
setImportStatus(tr("Scanning Flatpak games...")); setImportStatus(tr("Scanning Flatpak games..."));
[[maybe_unused]] auto future = QtConcurrent::run([this]() { [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() {
FlatpakImporter importer; FlatpakImporter importer;
QList<Game *> games = importer.importGames(); QList<Game *> games = importer.importGames();
QSet<QString> seenIds = existingIds;
QList<Game *> newGames;
newGames.reserve(games.size());
for (Game *game : games) { for (Game *game : games) {
if (!game) {
continue;
}
const QString id = game->id();
if (id.isEmpty() || seenIds.contains(id)) {
delete game;
continue;
}
seenIds.insert(id);
newGames.append(game);
}
for (Game *game : newGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
} }
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, games]() { [this, newGames]() {
for (Game *game : games) { for (Game *game : newGames) {
m_gameModel->addGame(game); m_gameModel->addGame(game);
} }
setImportStatus(tr("Flatpak import complete: %1 games found").arg(games.count())); setImportStatus(tr("Flatpak import complete: %1 new games found").arg(newGames.count()));
setImporting(false); setImporting(false);
saveLibrary(); saveLibrary();
Q_EMIT importCompleted(games.count()); Q_EMIT importCompleted(newGames.count());
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
@ -771,28 +990,57 @@ void App::importFromItch()
if (m_importing) if (m_importing)
return; return;
const QSet<QString> existingIds = [this]() {
QSet<QString> ids;
const QList<Game *> games = m_gameModel->allGames();
for (Game *game : games) {
if (game) {
ids.insert(game->id());
}
}
return ids;
}();
setImporting(true); setImporting(true);
setImportStatus(tr("Scanning itch.io library...")); setImportStatus(tr("Scanning itch.io library..."));
[[maybe_unused]] auto future = QtConcurrent::run([this]() { [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() {
ItchImporter importer; ItchImporter importer;
QList<Game *> games = importer.importGames(); QList<Game *> games = importer.importGames();
QSet<QString> seenIds = existingIds;
QList<Game *> newGames;
newGames.reserve(games.size());
for (Game *game : games) { for (Game *game : games) {
if (!game) {
continue;
}
const QString id = game->id();
if (id.isEmpty() || seenIds.contains(id)) {
delete game;
continue;
}
seenIds.insert(id);
newGames.append(game);
}
for (Game *game : newGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
} }
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, games]() { [this, newGames]() {
for (Game *game : games) { for (Game *game : newGames) {
m_gameModel->addGame(game); m_gameModel->addGame(game);
} }
setImportStatus(tr("itch.io import complete: %1 games found").arg(games.count())); setImportStatus(tr("itch.io import complete: %1 new games found").arg(newGames.count()));
setImporting(false); setImporting(false);
saveLibrary(); saveLibrary();
Q_EMIT importCompleted(games.count()); Q_EMIT importCompleted(newGames.count());
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
@ -803,28 +1051,57 @@ void App::importFromLegendary()
if (m_importing) if (m_importing)
return; return;
const QSet<QString> existingIds = [this]() {
QSet<QString> ids;
const QList<Game *> games = m_gameModel->allGames();
for (Game *game : games) {
if (game) {
ids.insert(game->id());
}
}
return ids;
}();
setImporting(true); setImporting(true);
setImportStatus(tr("Scanning Legendary library...")); setImportStatus(tr("Scanning Legendary library..."));
[[maybe_unused]] auto future = QtConcurrent::run([this]() { [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() {
LegendaryImporter importer; LegendaryImporter importer;
QList<Game *> games = importer.importGames(); QList<Game *> games = importer.importGames();
QSet<QString> seenIds = existingIds;
QList<Game *> newGames;
newGames.reserve(games.size());
for (Game *game : games) { for (Game *game : games) {
if (!game) {
continue;
}
const QString id = game->id();
if (id.isEmpty() || seenIds.contains(id)) {
delete game;
continue;
}
seenIds.insert(id);
newGames.append(game);
}
for (Game *game : newGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
} }
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, games]() { [this, newGames]() {
for (Game *game : games) { for (Game *game : newGames) {
m_gameModel->addGame(game); m_gameModel->addGame(game);
} }
setImportStatus(tr("Legendary import complete: %1 games found").arg(games.count())); setImportStatus(tr("Legendary import complete: %1 new games found").arg(newGames.count()));
setImporting(false); setImporting(false);
saveLibrary(); saveLibrary();
Q_EMIT importCompleted(games.count()); Q_EMIT importCompleted(newGames.count());
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
@ -835,28 +1112,57 @@ void App::importFromRetroArch()
if (m_importing) if (m_importing)
return; return;
const QSet<QString> existingIds = [this]() {
QSet<QString> ids;
const QList<Game *> games = m_gameModel->allGames();
for (Game *game : games) {
if (game) {
ids.insert(game->id());
}
}
return ids;
}();
setImporting(true); setImporting(true);
setImportStatus(tr("Scanning RetroArch playlists...")); setImportStatus(tr("Scanning RetroArch playlists..."));
[[maybe_unused]] auto future = QtConcurrent::run([this]() { [[maybe_unused]] auto future = QtConcurrent::run([this, existingIds]() {
RetroArchImporter importer; RetroArchImporter importer;
QList<Game *> games = importer.importGames(); QList<Game *> games = importer.importGames();
QSet<QString> seenIds = existingIds;
QList<Game *> newGames;
newGames.reserve(games.size());
for (Game *game : games) { for (Game *game : games) {
if (!game) {
continue;
}
const QString id = game->id();
if (id.isEmpty() || seenIds.contains(id)) {
delete game;
continue;
}
seenIds.insert(id);
newGames.append(game);
}
for (Game *game : newGames) {
game->moveToThread(this->thread()); game->moveToThread(this->thread());
game->setParent(nullptr); game->setParent(nullptr);
} }
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, games]() { [this, newGames]() {
for (Game *game : games) { for (Game *game : newGames) {
m_gameModel->addGame(game); m_gameModel->addGame(game);
} }
setImportStatus(tr("RetroArch import complete: %1 games found").arg(games.count())); setImportStatus(tr("RetroArch import complete: %1 new games found").arg(newGames.count()));
setImporting(false); setImporting(false);
saveLibrary(); saveLibrary();
Q_EMIT importCompleted(games.count()); Q_EMIT importCompleted(newGames.count());
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });

View file

@ -279,6 +279,74 @@ void Config::setTheme(const QString &theme)
} }
} }
int Config::windowWidth() const
{
return m_windowWidth;
}
void Config::setWindowWidth(int width)
{
width = qBound(0, width, 10000);
if (m_windowWidth != width) {
m_windowWidth = width;
save();
Q_EMIT windowWidthChanged();
}
}
int Config::windowHeight() const
{
return m_windowHeight;
}
void Config::setWindowHeight(int height)
{
height = qBound(0, height, 10000);
if (m_windowHeight != height) {
m_windowHeight = height;
save();
Q_EMIT windowHeightChanged();
}
}
bool Config::windowMaximized() const
{
return m_windowMaximized;
}
void Config::setWindowMaximized(bool maximized)
{
if (m_windowMaximized != maximized) {
m_windowMaximized = maximized;
save();
Q_EMIT windowMaximizedChanged();
}
}
void Config::setWindowSize(int width, int height)
{
width = qBound(0, width, 10000);
height = qBound(0, height, 10000);
const bool widthChanged = m_windowWidth != width;
const bool heightChanged = m_windowHeight != height;
if (!widthChanged && !heightChanged) {
return;
}
m_windowWidth = width;
m_windowHeight = height;
save();
if (widthChanged) {
Q_EMIT windowWidthChanged();
}
if (heightChanged) {
Q_EMIT windowHeightChanged();
}
}
void Config::save() void Config::save()
{ {
const auto config = KSharedConfig::openConfig(QStringLiteral("alakarterc")); const auto config = KSharedConfig::openConfig(QStringLiteral("alakarterc"));
@ -308,6 +376,11 @@ void Config::save()
behaviorGroup.writeEntry(QStringLiteral("exitAfterLaunch"), m_exitAfterLaunch); behaviorGroup.writeEntry(QStringLiteral("exitAfterLaunch"), m_exitAfterLaunch);
behaviorGroup.writeEntry(QStringLiteral("coverLaunchesGame"), m_coverLaunchesGame); behaviorGroup.writeEntry(QStringLiteral("coverLaunchesGame"), m_coverLaunchesGame);
KConfigGroup windowGroup(config, QStringLiteral("Window"));
windowGroup.writeEntry(QStringLiteral("width"), m_windowWidth);
windowGroup.writeEntry(QStringLiteral("height"), m_windowHeight);
windowGroup.writeEntry(QStringLiteral("maximized"), m_windowMaximized);
config->sync(); config->sync();
} }
@ -339,6 +412,11 @@ void Config::load()
m_animatedCovers = behaviorGroup.readEntry(QStringLiteral("animatedCovers"), false); m_animatedCovers = behaviorGroup.readEntry(QStringLiteral("animatedCovers"), false);
m_exitAfterLaunch = behaviorGroup.readEntry(QStringLiteral("exitAfterLaunch"), false); m_exitAfterLaunch = behaviorGroup.readEntry(QStringLiteral("exitAfterLaunch"), false);
m_coverLaunchesGame = behaviorGroup.readEntry(QStringLiteral("coverLaunchesGame"), false); m_coverLaunchesGame = behaviorGroup.readEntry(QStringLiteral("coverLaunchesGame"), false);
const KConfigGroup windowGroup(config, QStringLiteral("Window"));
m_windowWidth = windowGroup.readEntry(QStringLiteral("width"), 0);
m_windowHeight = windowGroup.readEntry(QStringLiteral("height"), 0);
m_windowMaximized = windowGroup.readEntry(QStringLiteral("maximized"), false);
} }
void Config::resetToDefaults() void Config::resetToDefaults()
@ -363,6 +441,10 @@ void Config::resetToDefaults()
m_animatedCovers = false; m_animatedCovers = false;
m_theme.clear(); m_theme.clear();
m_windowWidth = 0;
m_windowHeight = 0;
m_windowMaximized = false;
save(); save();
Q_EMIT viewModeChanged(); Q_EMIT viewModeChanged();
@ -384,4 +466,7 @@ void Config::resetToDefaults()
Q_EMIT highQualityImagesChanged(); Q_EMIT highQualityImagesChanged();
Q_EMIT animatedCoversChanged(); Q_EMIT animatedCoversChanged();
Q_EMIT themeChanged(); Q_EMIT themeChanged();
Q_EMIT windowWidthChanged();
Q_EMIT windowHeightChanged();
Q_EMIT windowMaximizedChanged();
} }

View file

@ -30,6 +30,9 @@ class Config : public QObject
Q_PROPERTY(bool animatedCovers READ animatedCovers WRITE setAnimatedCovers NOTIFY animatedCoversChanged) Q_PROPERTY(bool animatedCovers READ animatedCovers WRITE setAnimatedCovers NOTIFY animatedCoversChanged)
Q_PROPERTY(bool coverLaunchesGame READ coverLaunchesGame WRITE setCoverLaunchesGame NOTIFY coverLaunchesGameChanged) Q_PROPERTY(bool coverLaunchesGame READ coverLaunchesGame WRITE setCoverLaunchesGame NOTIFY coverLaunchesGameChanged)
Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged) Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged)
Q_PROPERTY(int windowWidth READ windowWidth WRITE setWindowWidth NOTIFY windowWidthChanged)
Q_PROPERTY(int windowHeight READ windowHeight WRITE setWindowHeight NOTIFY windowHeightChanged)
Q_PROPERTY(bool windowMaximized READ windowMaximized WRITE setWindowMaximized NOTIFY windowMaximizedChanged)
public: public:
enum ViewMode { enum ViewMode {
@ -42,7 +45,7 @@ public:
enum UiMode { enum UiMode {
Auto, Auto,
Desktop, Desktop,
Handheld Couch
}; };
Q_ENUM(UiMode) Q_ENUM(UiMode)
@ -105,10 +108,21 @@ public:
QString theme() const; QString theme() const;
void setTheme(const QString &theme); void setTheme(const QString &theme);
int windowWidth() const;
void setWindowWidth(int width);
int windowHeight() const;
void setWindowHeight(int height);
bool windowMaximized() const;
void setWindowMaximized(bool maximized);
Q_INVOKABLE void save(); Q_INVOKABLE void save();
Q_INVOKABLE void load(); Q_INVOKABLE void load();
Q_INVOKABLE void resetToDefaults(); Q_INVOKABLE void resetToDefaults();
Q_INVOKABLE void setWindowSize(int width, int height);
Q_SIGNALS: Q_SIGNALS:
void viewModeChanged(); void viewModeChanged();
void uiModeChanged(); void uiModeChanged();
@ -129,6 +143,9 @@ Q_SIGNALS:
void animatedCoversChanged(); void animatedCoversChanged();
void coverLaunchesGameChanged(); void coverLaunchesGameChanged();
void themeChanged(); void themeChanged();
void windowWidthChanged();
void windowHeightChanged();
void windowMaximizedChanged();
private: private:
ViewMode m_viewMode = GridView; ViewMode m_viewMode = GridView;
@ -150,4 +167,8 @@ private:
bool m_highQualityImages = true; bool m_highQualityImages = true;
bool m_animatedCovers = false; bool m_animatedCovers = false;
QString m_theme; QString m_theme;
int m_windowWidth = 0;
int m_windowHeight = 0;
bool m_windowMaximized = false;
}; };

View file

@ -40,7 +40,13 @@ QStringList DesktopImporter::getDesktopFilePaths() const
// Add common system locations // Add common system locations
appDirs << QStringLiteral("/usr/share/applications"); appDirs << QStringLiteral("/usr/share/applications");
appDirs << QStringLiteral("/usr/local/share/applications"); appDirs << QStringLiteral("/usr/local/share/applications");
appDirs << expandPath(QStringLiteral("~/.local/share/applications"));
const QString xdgDataHome = qEnvironmentVariable("XDG_DATA_HOME");
if (!xdgDataHome.isEmpty()) {
appDirs << QDir(xdgDataHome).absoluteFilePath(QStringLiteral("applications"));
} else {
appDirs << expandPath(QStringLiteral("~/.local/share/applications"));
}
// Flatpak export directories // Flatpak export directories
appDirs << expandPath(QStringLiteral("~/.local/share/flatpak/exports/share/applications")); appDirs << expandPath(QStringLiteral("~/.local/share/flatpak/exports/share/applications"));

View file

@ -185,6 +185,134 @@ void Game::setLaunchPrefixPath(const QString &path)
} }
} }
QString Game::activeLaunchProfile() const
{
return m_activeLaunchProfile;
}
void Game::setActiveLaunchProfile(const QString &profileId)
{
const QString normalized = profileId.trimmed().isEmpty() ? QStringLiteral("default") : profileId.trimmed();
if (m_activeLaunchProfile != normalized) {
m_activeLaunchProfile = normalized;
Q_EMIT activeLaunchProfileChanged();
}
}
QVariantMap Game::launchProfileConfig(const QString &profileId) const
{
const QString id = profileId.trimmed();
if (id.isEmpty() || id == QLatin1String("default")) {
return {};
}
const QVariant value = m_launchProfiles.value(id);
if (!value.isValid()) {
return {};
}
return value.toMap();
}
void Game::setLaunchProfileConfig(const QString &profileId, const QVariantMap &config)
{
const QString id = profileId.trimmed();
if (id.isEmpty() || id == QLatin1String("default")) {
return;
}
QVariantMap cleaned;
const QString runner = config.value(QStringLiteral("runner")).toString().trimmed();
if (!runner.isEmpty()) {
cleaned.insert(QStringLiteral("runner"), runner);
}
const QString runnerPath = config.value(QStringLiteral("runnerPath")).toString().trimmed();
if (!runnerPath.isEmpty()) {
cleaned.insert(QStringLiteral("runnerPath"), runnerPath);
}
const QString prefixPath = config.value(QStringLiteral("prefixPath")).toString().trimmed();
if (!prefixPath.isEmpty()) {
cleaned.insert(QStringLiteral("prefixPath"), prefixPath);
}
const QVariantMap env = config.value(QStringLiteral("env")).toMap();
if (!env.isEmpty()) {
cleaned.insert(QStringLiteral("env"), env);
}
if (cleaned.isEmpty()) {
m_launchProfiles.remove(id);
} else {
m_launchProfiles.insert(id, cleaned);
}
}
QVariantMap Game::effectiveLaunchConfig() const
{
return effectiveLaunchConfigForProfile(m_activeLaunchProfile);
}
QVariantMap Game::effectiveLaunchConfigForProfile(const QString &profileId) const
{
QVariantMap result;
if (!m_launchRunner.isEmpty()) {
result.insert(QStringLiteral("runner"), m_launchRunner);
}
if (!m_launchRunnerPath.isEmpty()) {
result.insert(QStringLiteral("runnerPath"), m_launchRunnerPath);
}
if (!m_launchPrefixPath.isEmpty()) {
result.insert(QStringLiteral("prefixPath"), m_launchPrefixPath);
}
if (!m_launchEnv.isEmpty()) {
result.insert(QStringLiteral("env"), m_launchEnv);
}
const QString id = profileId.trimmed();
if (id.isEmpty() || id == QLatin1String("default")) {
return result;
}
const QVariantMap profileConfig = launchProfileConfig(id);
if (profileConfig.isEmpty()) {
return result;
}
const QString runner = profileConfig.value(QStringLiteral("runner")).toString().trimmed();
if (!runner.isEmpty()) {
result.insert(QStringLiteral("runner"), runner);
}
const QString runnerPath = profileConfig.value(QStringLiteral("runnerPath")).toString().trimmed();
if (!runnerPath.isEmpty()) {
result.insert(QStringLiteral("runnerPath"), runnerPath);
}
const QString prefixPath = profileConfig.value(QStringLiteral("prefixPath")).toString().trimmed();
if (!prefixPath.isEmpty()) {
result.insert(QStringLiteral("prefixPath"), prefixPath);
}
const QVariantMap profileEnv = profileConfig.value(QStringLiteral("env")).toMap();
if (!profileEnv.isEmpty()) {
QVariantMap mergedEnv;
const QVariantMap baseEnv = result.value(QStringLiteral("env")).toMap();
for (auto it = baseEnv.constBegin(); it != baseEnv.constEnd(); ++it) {
mergedEnv.insert(it.key(), it.value());
}
for (auto it = profileEnv.constBegin(); it != profileEnv.constEnd(); ++it) {
mergedEnv.insert(it.key(), it.value());
}
result.insert(QStringLiteral("env"), mergedEnv);
}
return result;
}
QString Game::platform() const QString Game::platform() const
{ {
return m_platform; return m_platform;
@ -323,7 +451,8 @@ QJsonObject Game::toJson() const
obj[QStringLiteral("hidden")] = m_hidden; obj[QStringLiteral("hidden")] = m_hidden;
obj[QStringLiteral("installed")] = m_installed; obj[QStringLiteral("installed")] = m_installed;
const bool hasLaunchConfig = !m_launchEnv.isEmpty() || !m_launchRunner.isEmpty() || !m_launchRunnerPath.isEmpty() || !m_launchPrefixPath.isEmpty(); const bool hasLaunchConfig = !m_launchEnv.isEmpty() || !m_launchRunner.isEmpty() || !m_launchRunnerPath.isEmpty() || !m_launchPrefixPath.isEmpty()
|| !m_launchProfiles.isEmpty() || (!m_activeLaunchProfile.isEmpty() && m_activeLaunchProfile != QLatin1String("default"));
if (hasLaunchConfig) { if (hasLaunchConfig) {
QJsonObject launchObj; QJsonObject launchObj;
@ -345,6 +474,57 @@ QJsonObject Game::toJson() const
launchObj.insert(QStringLiteral("prefixPath"), m_launchPrefixPath); launchObj.insert(QStringLiteral("prefixPath"), m_launchPrefixPath);
} }
if (!m_activeLaunchProfile.isEmpty() && m_activeLaunchProfile != QLatin1String("default")) {
launchObj.insert(QStringLiteral("activeProfile"), m_activeLaunchProfile);
}
if (!m_launchProfiles.isEmpty()) {
QJsonObject profilesObj;
for (auto it = m_launchProfiles.constBegin(); it != m_launchProfiles.constEnd(); ++it) {
const QString profileId = it.key();
if (profileId.isEmpty() || profileId == QLatin1String("default")) {
continue;
}
const QVariantMap profileConfig = it.value().toMap();
if (profileConfig.isEmpty()) {
continue;
}
QJsonObject profileObj;
const QString runner = profileConfig.value(QStringLiteral("runner")).toString();
if (!runner.isEmpty()) {
profileObj.insert(QStringLiteral("runner"), runner);
}
const QString runnerPath = profileConfig.value(QStringLiteral("runnerPath")).toString();
if (!runnerPath.isEmpty()) {
profileObj.insert(QStringLiteral("runnerPath"), runnerPath);
}
const QString prefixPath = profileConfig.value(QStringLiteral("prefixPath")).toString();
if (!prefixPath.isEmpty()) {
profileObj.insert(QStringLiteral("prefixPath"), prefixPath);
}
const QVariantMap env = profileConfig.value(QStringLiteral("env")).toMap();
if (!env.isEmpty()) {
QJsonObject envObj;
for (auto envIt = env.constBegin(); envIt != env.constEnd(); ++envIt) {
envObj.insert(envIt.key(), QJsonValue::fromVariant(envIt.value()));
}
profileObj.insert(QStringLiteral("env"), envObj);
}
if (!profileObj.isEmpty()) {
profilesObj.insert(profileId, profileObj);
}
}
if (!profilesObj.isEmpty()) {
launchObj.insert(QStringLiteral("profiles"), profilesObj);
}
}
obj.insert(QStringLiteral("launch"), launchObj); obj.insert(QStringLiteral("launch"), launchObj);
} }
@ -393,6 +573,7 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent)
QString runner; QString runner;
QString runnerPath; QString runnerPath;
QString prefixPath; QString prefixPath;
QString activeProfile;
const QJsonValue launchValue = json.value(QStringLiteral("launch")); const QJsonValue launchValue = json.value(QStringLiteral("launch"));
if (launchValue.isObject()) { if (launchValue.isObject()) {
const QJsonObject launchObj = launchValue.toObject(); const QJsonObject launchObj = launchValue.toObject();
@ -404,6 +585,51 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent)
runner = launchObj.value(QStringLiteral("runner")).toString(); runner = launchObj.value(QStringLiteral("runner")).toString();
runnerPath = launchObj.value(QStringLiteral("runnerPath")).toString(); runnerPath = launchObj.value(QStringLiteral("runnerPath")).toString();
prefixPath = launchObj.value(QStringLiteral("prefixPath")).toString(); prefixPath = launchObj.value(QStringLiteral("prefixPath")).toString();
activeProfile = launchObj.value(QStringLiteral("activeProfile")).toString();
const QJsonValue profilesValue = launchObj.value(QStringLiteral("profiles"));
if (profilesValue.isObject()) {
const QJsonObject profilesObj = profilesValue.toObject();
for (auto it = profilesObj.constBegin(); it != profilesObj.constEnd(); ++it) {
if (!it.value().isObject()) {
continue;
}
const QString profileId = it.key();
if (profileId.isEmpty() || profileId == QLatin1String("default")) {
continue;
}
const QJsonObject profileObj = it.value().toObject();
QVariantMap profileConfig;
const QString pRunner = profileObj.value(QStringLiteral("runner")).toString();
if (!pRunner.isEmpty()) {
profileConfig.insert(QStringLiteral("runner"), pRunner);
}
const QString pRunnerPath = profileObj.value(QStringLiteral("runnerPath")).toString();
if (!pRunnerPath.isEmpty()) {
profileConfig.insert(QStringLiteral("runnerPath"), pRunnerPath);
}
const QString pPrefixPath = profileObj.value(QStringLiteral("prefixPath")).toString();
if (!pPrefixPath.isEmpty()) {
profileConfig.insert(QStringLiteral("prefixPath"), pPrefixPath);
}
const QJsonValue pEnvValue = profileObj.value(QStringLiteral("env"));
if (pEnvValue.isObject()) {
const QVariantMap pEnv = pEnvValue.toObject().toVariantMap();
if (!pEnv.isEmpty()) {
profileConfig.insert(QStringLiteral("env"), pEnv);
}
}
if (!profileConfig.isEmpty()) {
game->setLaunchProfileConfig(profileId, profileConfig);
}
}
}
} }
const QJsonValue legacyEnvValue = json.value(QStringLiteral("launchEnv")); const QJsonValue legacyEnvValue = json.value(QStringLiteral("launchEnv"));
@ -435,6 +661,10 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent)
game->setLaunchPrefixPath(prefixPath); game->setLaunchPrefixPath(prefixPath);
} }
if (!activeProfile.isEmpty()) {
game->setActiveLaunchProfile(activeProfile);
}
return game; return game;
} }

View file

@ -30,6 +30,7 @@ class Game : public QObject
Q_PROPERTY(QString launchRunner READ launchRunner WRITE setLaunchRunner NOTIFY launchRunnerChanged) Q_PROPERTY(QString launchRunner READ launchRunner WRITE setLaunchRunner NOTIFY launchRunnerChanged)
Q_PROPERTY(QString launchRunnerPath READ launchRunnerPath WRITE setLaunchRunnerPath NOTIFY launchRunnerPathChanged) Q_PROPERTY(QString launchRunnerPath READ launchRunnerPath WRITE setLaunchRunnerPath NOTIFY launchRunnerPathChanged)
Q_PROPERTY(QString launchPrefixPath READ launchPrefixPath WRITE setLaunchPrefixPath NOTIFY launchPrefixPathChanged) Q_PROPERTY(QString launchPrefixPath READ launchPrefixPath WRITE setLaunchPrefixPath NOTIFY launchPrefixPathChanged)
Q_PROPERTY(QString activeLaunchProfile READ activeLaunchProfile WRITE setActiveLaunchProfile NOTIFY activeLaunchProfileChanged)
Q_PROPERTY(QString platform READ platform WRITE setPlatform NOTIFY platformChanged) Q_PROPERTY(QString platform READ platform WRITE setPlatform NOTIFY platformChanged)
Q_PROPERTY(QString platformId READ platformId WRITE setPlatformId NOTIFY platformIdChanged) Q_PROPERTY(QString platformId READ platformId WRITE setPlatformId NOTIFY platformIdChanged)
Q_PROPERTY(QDateTime dateAdded READ dateAdded WRITE setDateAdded NOTIFY dateAddedChanged) Q_PROPERTY(QDateTime dateAdded READ dateAdded WRITE setDateAdded NOTIFY dateAddedChanged)
@ -82,6 +83,14 @@ public:
QString launchPrefixPath() const; QString launchPrefixPath() const;
void setLaunchPrefixPath(const QString &path); void setLaunchPrefixPath(const QString &path);
QString activeLaunchProfile() const;
void setActiveLaunchProfile(const QString &profileId);
Q_INVOKABLE QVariantMap launchProfileConfig(const QString &profileId) const;
Q_INVOKABLE void setLaunchProfileConfig(const QString &profileId, const QVariantMap &config);
QVariantMap effectiveLaunchConfigForProfile(const QString &profileId) const;
QVariantMap effectiveLaunchConfig() const;
QString platform() const; QString platform() const;
void setPlatform(const QString &platform); void setPlatform(const QString &platform);
@ -129,6 +138,7 @@ Q_SIGNALS:
void launchRunnerChanged(); void launchRunnerChanged();
void launchRunnerPathChanged(); void launchRunnerPathChanged();
void launchPrefixPathChanged(); void launchPrefixPathChanged();
void activeLaunchProfileChanged();
void platformChanged(); void platformChanged();
void platformIdChanged(); void platformIdChanged();
void dateAddedChanged(); void dateAddedChanged();
@ -153,6 +163,8 @@ private:
QString m_launchRunner; QString m_launchRunner;
QString m_launchRunnerPath; QString m_launchRunnerPath;
QString m_launchPrefixPath; QString m_launchPrefixPath;
QString m_activeLaunchProfile = QStringLiteral("default");
QVariantMap m_launchProfiles;
QString m_platform; QString m_platform;
QString m_platformId; QString m_platformId;
QDateTime m_dateAdded; QDateTime m_dateAdded;

View file

@ -3,6 +3,8 @@
#include "gamelauncher.h" #include "gamelauncher.h"
#include "app.h" #include "app.h"
#include "gamepadmanager.h"
#include "inputmanager.h"
#include <QCoreApplication> #include <QCoreApplication>
#include <QDateTime> #include <QDateTime>
@ -108,6 +110,33 @@ static QString discoverDefaultProtonExecutable()
return cached; return cached;
} }
static QString profileIdForCurrentUiMode()
{
const Config *config = App::instance() ? App::instance()->config() : nullptr;
if (!config) {
return QStringLiteral("default");
}
if (config->uiMode() == Config::Couch) {
return QStringLiteral("couch");
}
if (config->uiMode() == Config::Auto) {
GamepadManager *pad = GamepadManager::instance();
InputManager *input = InputManager::instance();
if (pad && pad->connected()) {
const bool activeGamepad = input && input->activeInput() == InputManager::Gamepad;
const bool noKeyboardMouse = input && !input->hasSeenKeyboardMouse();
if (activeGamepad || noKeyboardMouse) {
return QStringLiteral("couch");
}
}
}
return QStringLiteral("default");
}
GameLauncher::GameLauncher(QObject *parent) GameLauncher::GameLauncher(QObject *parent)
: QObject(parent) : QObject(parent)
{ {
@ -132,6 +161,161 @@ bool GameLauncher::hasRunningGames() const
return !m_runningGames.isEmpty(); return !m_runningGames.isEmpty();
} }
QVariantMap GameLauncher::resolveLaunchInfo(Game *game) const
{
QVariantMap info;
info.insert(QStringLiteral("ok"), false);
if (!game) {
info.insert(QStringLiteral("error"), tr("No game selected"));
return info;
}
info.insert(QStringLiteral("gameId"), game->id());
info.insert(QStringLiteral("gameName"), game->name());
info.insert(QStringLiteral("isRunning"), m_runningGames.contains(game->id()));
const QString command = game->launchCommand();
info.insert(QStringLiteral("launchCommand"), command);
if (command.isEmpty()) {
info.insert(QStringLiteral("error"), tr("No launch command configured"));
return info;
}
const QString profileId = profileIdForCurrentUiMode();
const QVariantMap effectiveLaunchConfig = game->effectiveLaunchConfigForProfile(profileId);
const QString runner = effectiveLaunchConfig.value(QStringLiteral("runner")).toString().trimmed();
const QString runnerPath = effectiveLaunchConfig.value(QStringLiteral("runnerPath")).toString().trimmed();
const QString prefixPath = effectiveLaunchConfig.value(QStringLiteral("prefixPath")).toString().trimmed();
const QVariantMap launchEnv = effectiveLaunchConfig.value(QStringLiteral("env")).toMap();
const bool hasLaunchOverrides = !runner.isEmpty() || !runnerPath.isEmpty() || !prefixPath.isEmpty() || !launchEnv.isEmpty();
info.insert(QStringLiteral("runner"), runner);
info.insert(QStringLiteral("runnerPath"), runnerPath);
info.insert(QStringLiteral("prefixPath"), prefixPath);
info.insert(QStringLiteral("workingDirectory"), game->workingDirectory());
info.insert(QStringLiteral("hasLaunchOverrides"), hasLaunchOverrides);
QVariantMap envOverrides;
for (auto it = launchEnv.constBegin(); it != launchEnv.constEnd(); ++it) {
const QString key = it.key();
if (key.isEmpty() || key.contains(QLatin1Char('='))) {
continue;
}
envOverrides.insert(key, it.value().toString());
}
info.insert(QStringLiteral("envOverrides"), envOverrides);
if (!hasLaunchOverrides && command.startsWith(QLatin1String("steam://"))) {
info.insert(QStringLiteral("ok"), true);
info.insert(QStringLiteral("launchType"), QStringLiteral("url"));
info.insert(QStringLiteral("url"), command);
info.insert(QStringLiteral("usesDesktopServices"), true);
return info;
}
if (!hasLaunchOverrides && command.startsWith(QLatin1String("lutris "))) {
info.insert(QStringLiteral("ok"), true);
info.insert(QStringLiteral("launchType"), QStringLiteral("url"));
info.insert(QStringLiteral("url"), command.mid(7));
info.insert(QStringLiteral("usesDesktopServices"), true);
return info;
}
QStringList parts;
if (command.startsWith(QLatin1String("steam://"))) {
parts = {QStringLiteral("xdg-open"), command};
} else if (command.startsWith(QLatin1String("lutris "))) {
parts = {QStringLiteral("xdg-open"), command.mid(7)};
} else {
parts = QProcess::splitCommand(command);
}
if (parts.isEmpty()) {
info.insert(QStringLiteral("error"), tr("Invalid launch command"));
return info;
}
const QString program = parts.takeFirst();
info.insert(QStringLiteral("program"), program);
info.insert(QStringLiteral("args"), parts);
QString finalProgram = program;
QStringList finalArgs = parts;
QVariantMap effectiveEnv = envOverrides;
if ((runner == QLatin1String("wine") || runner == QLatin1String("proton")) && (program == QLatin1String("xdg-open"))) {
info.insert(QStringLiteral("error"), tr("This runner cannot be used with URL-based launch commands"));
return info;
}
if (runner == QLatin1String("custom")) {
if (runnerPath.isEmpty()) {
info.insert(QStringLiteral("error"), tr("Custom runner is enabled but no runner path is configured"));
return info;
}
finalProgram = runnerPath;
finalArgs = {program};
finalArgs.append(parts);
} else if (runner == QLatin1String("wine")) {
QString wineExe = runnerPath;
if (wineExe.isEmpty()) {
wineExe = QStandardPaths::findExecutable(QStringLiteral("wine"));
}
if (wineExe.isEmpty()) {
info.insert(QStringLiteral("error"), tr("Wine runner is enabled but Wine was not found"));
return info;
}
QString winePrefix = prefixPath;
if (winePrefix.isEmpty()) {
winePrefix = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id();
}
effectiveEnv.insert(QStringLiteral("WINEPREFIX"), winePrefix);
info.insert(QStringLiteral("resolvedPrefixPath"), winePrefix);
finalProgram = wineExe;
finalArgs = {program};
finalArgs.append(parts);
} else if (runner == QLatin1String("proton")) {
QString protonExe = runnerPath;
if (protonExe.isEmpty()) {
protonExe = discoverDefaultProtonExecutable();
}
if (protonExe.isEmpty()) {
info.insert(QStringLiteral("error"), tr("Proton runner is enabled but no Proton installation was found"));
return info;
}
const QString steamInstallPath = findSteamClientInstallPathFromProton(protonExe);
info.insert(QStringLiteral("resolvedSteamInstallPath"), steamInstallPath);
QString compatDataPath = prefixPath;
if (compatDataPath.isEmpty()) {
compatDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id();
}
effectiveEnv.insert(QStringLiteral("STEAM_COMPAT_DATA_PATH"), compatDataPath);
info.insert(QStringLiteral("resolvedPrefixPath"), compatDataPath);
if (!steamInstallPath.isEmpty()) {
effectiveEnv.insert(QStringLiteral("STEAM_COMPAT_CLIENT_INSTALL_PATH"), steamInstallPath);
}
finalProgram = protonExe;
finalArgs = {QStringLiteral("run"), program};
finalArgs.append(parts);
}
info.insert(QStringLiteral("finalProgram"), finalProgram);
info.insert(QStringLiteral("finalArgs"), finalArgs);
info.insert(QStringLiteral("effectiveEnv"), effectiveEnv);
info.insert(QStringLiteral("ok"), true);
return info;
}
void GameLauncher::launchGame(Game *game) void GameLauncher::launchGame(Game *game)
{ {
if (!game) { if (!game) {
@ -144,10 +328,12 @@ void GameLauncher::launchGame(Game *game)
return; return;
} }
const QString runner = game->launchRunner().trimmed(); const QString profileId = profileIdForCurrentUiMode();
const QString runnerPath = game->launchRunnerPath().trimmed(); const QVariantMap effectiveLaunchConfig = game->effectiveLaunchConfigForProfile(profileId);
const QString prefixPath = game->launchPrefixPath().trimmed(); const QString runner = effectiveLaunchConfig.value(QStringLiteral("runner")).toString().trimmed();
const QVariantMap launchEnv = game->launchEnv(); const QString runnerPath = effectiveLaunchConfig.value(QStringLiteral("runnerPath")).toString().trimmed();
const QString prefixPath = effectiveLaunchConfig.value(QStringLiteral("prefixPath")).toString().trimmed();
const QVariantMap launchEnv = effectiveLaunchConfig.value(QStringLiteral("env")).toMap();
const bool hasLaunchOverrides = !runner.isEmpty() || !runnerPath.isEmpty() || !prefixPath.isEmpty() || !launchEnv.isEmpty(); const bool hasLaunchOverrides = !runner.isEmpty() || !runnerPath.isEmpty() || !prefixPath.isEmpty() || !launchEnv.isEmpty();
// Check if already running // Check if already running

View file

@ -7,6 +7,7 @@
#include <QObject> #include <QObject>
#include <QProcess> #include <QProcess>
#include <QQmlEngine> #include <QQmlEngine>
#include <QVariantMap>
#include "game.h" #include "game.h"
@ -24,6 +25,7 @@ public:
bool hasRunningGames() const; bool hasRunningGames() const;
Q_INVOKABLE void launchGame(Game *game); Q_INVOKABLE void launchGame(Game *game);
Q_INVOKABLE QVariantMap resolveLaunchInfo(Game *game) const;
Q_INVOKABLE void stopGame(Game *game); Q_INVOKABLE void stopGame(Game *game);
Q_INVOKABLE bool isGameRunning(Game *game) const; Q_INVOKABLE bool isGameRunning(Game *game) const;

104
src/inputmanager.cpp Normal file
View file

@ -0,0 +1,104 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
#include "inputmanager.h"
#include "gamepadmanager.h"
#include <QCoreApplication>
#include <QEvent>
#include <QGuiApplication>
InputManager *InputManager::s_instance = nullptr;
InputManager::InputManager(QObject *parent)
: QObject(parent)
{
if (QCoreApplication::instance()) {
QCoreApplication::instance()->installEventFilter(this);
}
auto *pad = GamepadManager::instance();
connect(pad, &GamepadManager::activeChanged, this, [this, pad]() {
if (pad->active()) {
if (!m_hasSeenGamepad) {
m_hasSeenGamepad = true;
Q_EMIT inputHistoryChanged();
}
setActiveInput(Gamepad);
}
});
connect(pad, &GamepadManager::connectedChanged, this, [this, pad]() {
if (!pad->connected() && m_activeInput == Gamepad) {
setActiveInput(KeyboardMouse);
}
});
}
InputManager *InputManager::instance()
{
if (!s_instance) {
s_instance = new InputManager();
}
return s_instance;
}
InputManager *InputManager::create(QQmlEngine *engine, QJSEngine *scriptEngine)
{
Q_UNUSED(engine)
Q_UNUSED(scriptEngine)
return instance();
}
InputManager::ActiveInput InputManager::activeInput() const
{
return m_activeInput;
}
bool InputManager::hasSeenKeyboardMouse() const
{
return m_hasSeenKeyboardMouse;
}
bool InputManager::hasSeenGamepad() const
{
return m_hasSeenGamepad;
}
void InputManager::setActiveInput(ActiveInput input)
{
if (m_activeInput == input) {
return;
}
m_activeInput = input;
Q_EMIT activeInputChanged();
}
bool InputManager::eventFilter(QObject *watched, QEvent *event)
{
Q_UNUSED(watched)
if (!event) {
return false;
}
switch (event->type()) {
case QEvent::KeyPress:
case QEvent::KeyRelease:
case QEvent::MouseButtonPress:
case QEvent::MouseButtonRelease:
case QEvent::Wheel:
if (!m_hasSeenKeyboardMouse) {
m_hasSeenKeyboardMouse = true;
Q_EMIT inputHistoryChanged();
}
setActiveInput(KeyboardMouse);
break;
default:
break;
}
return false;
}

54
src/inputmanager.h Normal file
View file

@ -0,0 +1,54 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
#pragma once
#include <QObject>
#include <QQmlEngine>
class QEvent;
class QJSEngine;
class InputManager : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(ActiveInput activeInput READ activeInput NOTIFY activeInputChanged)
Q_PROPERTY(bool hasSeenKeyboardMouse READ hasSeenKeyboardMouse NOTIFY inputHistoryChanged)
Q_PROPERTY(bool hasSeenGamepad READ hasSeenGamepad NOTIFY inputHistoryChanged)
public:
enum ActiveInput {
KeyboardMouse = 0,
Gamepad = 1,
};
Q_ENUM(ActiveInput)
static InputManager *instance();
static InputManager *create(QQmlEngine *engine, QJSEngine *scriptEngine);
ActiveInput activeInput() const;
bool hasSeenKeyboardMouse() const;
bool hasSeenGamepad() const;
Q_SIGNALS:
void activeInputChanged();
void inputHistoryChanged();
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
explicit InputManager(QObject *parent = nullptr);
static InputManager *s_instance;
ActiveInput m_activeInput = KeyboardMouse;
bool m_hasSeenKeyboardMouse = false;
bool m_hasSeenGamepad = false;
void setActiveInput(ActiveInput input);
};

View file

@ -1,12 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors // SPDX-FileCopyrightText: 2026 A-La-Karte Contributors
#include <QByteArray>
#include <QCommandLineParser> #include <QCommandLineParser>
#include <QGuiApplication> #include <QGuiApplication>
#include <QIcon> #include <QIcon>
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
#include <QQmlContext> #include <QQmlContext>
#include <QQuickStyle> #include <QQuickStyle>
#include <QTextStream>
#include <QTimer>
#include <KAboutData> #include <KAboutData>
#include <KCrash> #include <KCrash>
@ -15,9 +18,11 @@
#include <KLocalizedString> #include <KLocalizedString>
#include "alakarte-version.h" #include "alakarte-version.h"
#include "app.h"
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
qputenv("QT_DESKTOP_FILE_NAME", QByteArray("org.kde.alakarte"));
QGuiApplication app(argc, argv); QGuiApplication app(argc, argv);
QGuiApplication::setDesktopFileName(QStringLiteral("org.kde.alakarte")); QGuiApplication::setDesktopFileName(QStringLiteral("org.kde.alakarte"));
@ -45,15 +50,54 @@ int main(int argc, char *argv[])
QCommandLineParser parser; QCommandLineParser parser;
aboutData.setupCommandLine(&parser); aboutData.setupCommandLine(&parser);
QCommandLineOption importAllAndExitOption(QStringLiteral("import-all-and-exit"), i18n("Import all enabled sources and exit"));
QCommandLineOption importDesktopAndExitOption(QStringLiteral("import-desktop-and-exit"), i18n("Import desktop entries and exit"));
QCommandLineOption startupAndExitOption(QStringLiteral("startup-and-exit"), i18n("Start without UI and exit after startup auto-import (if enabled)"));
parser.addOption(importAllAndExitOption);
parser.addOption(importDesktopAndExitOption);
parser.addOption(startupAndExitOption);
parser.process(app); parser.process(app);
aboutData.processCommandLine(&parser); aboutData.processCommandLine(&parser);
KDBusService service(KDBusService::Unique);
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) { if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) {
QQuickStyle::setStyle(QStringLiteral("org.kde.desktop")); QQuickStyle::setStyle(QStringLiteral("org.kde.desktop"));
} }
if (parser.isSet(importAllAndExitOption) || parser.isSet(importDesktopAndExitOption) || parser.isSet(startupAndExitOption)) {
App *alakarteApp = App::instance();
QObject::connect(alakarteApp, &App::importCompleted, &app, [&app](int count) {
QTextStream(stdout) << count << Qt::endl;
app.exit(0);
});
QObject::connect(alakarteApp, &App::importError, &app, [&app](const QString &error) {
QTextStream(stderr) << error << Qt::endl;
app.exit(1);
});
if (parser.isSet(importAllAndExitOption)) {
alakarteApp->importAllGames();
} else if (parser.isSet(importDesktopAndExitOption)) {
alakarteApp->importFromDesktop();
} else {
if (!alakarteApp->config() || !alakarteApp->config()->autoImportOnStartup()) {
QTextStream(stdout) << 0 << Qt::endl;
return 0;
}
QTimer::singleShot(60000, &app, [&app]() {
QTextStream(stderr) << QStringLiteral("Timed out waiting for auto-import") << Qt::endl;
app.exit(2);
});
}
return app.exec();
}
KDBusService service(KDBusService::Unique);
QQmlApplicationEngine engine; QQmlApplicationEngine engine;
engine.rootContext()->setContextObject(new KLocalizedContext(&engine)); engine.rootContext()->setContextObject(new KLocalizedContext(&engine));

View file

@ -0,0 +1,126 @@
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.alakarte
Kirigami.OverlaySheet {
id: diagnosticsSheet
property var game
property var launchInfo: ({})
title: i18n("Diagnostics")
parent: applicationWindow().overlay
closePolicy: QQC2.Popup.CloseOnEscape | QQC2.Popup.CloseOnPressOutside
implicitWidth: {
let w = applicationWindow()
if (!w) return Kirigami.Units.gridUnit * 30
if (w.isMobile) return w.width
if (w.isNarrowScreen) return w.width - Kirigami.Units.largeSpacing * 2
return Math.min(w.width - Kirigami.Units.gridUnit * 2, Kirigami.Units.gridUnit * 34)
}
implicitHeight: {
let w = applicationWindow()
if (!w) return Kirigami.Units.gridUnit * 30
if (w.isMobile) return w.height
return Math.min(w.height - Kirigami.Units.gridUnit * 2, Kirigami.Units.gridUnit * 42)
}
onOpened: {
if (game) {
launchInfo = App.launcher.resolveLaunchInfo(game)
} else {
launchInfo = ({ "ok": false, "error": i18n("No game selected") })
}
closeButton.forceActiveFocus()
}
header: Kirigami.ShadowedRectangle {
implicitHeight: headerRow.implicitHeight + Kirigami.Units.largeSpacing * 2
radius: Kirigami.Units.mediumSpacing
color: Kirigami.Theme.backgroundColor
RowLayout {
id: headerRow
anchors.fill: parent
anchors.margins: Kirigami.Units.largeSpacing
spacing: Kirigami.Units.mediumSpacing
Kirigami.Heading {
text: diagnosticsSheet.title
level: 2
Layout.fillWidth: true
elide: Text.ElideRight
}
QQC2.ToolButton {
id: closeButton
text: i18n("Close")
icon.name: "dialog-close"
display: QQC2.AbstractButton.IconOnly
onClicked: diagnosticsSheet.close()
}
}
}
contentItem: QQC2.ScrollView {
id: scroll
clip: true
ColumnLayout {
width: scroll.availableWidth
spacing: Kirigami.Units.largeSpacing
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: launchInfo && launchInfo.ok === false
type: Kirigami.MessageType.Error
text: launchInfo && launchInfo.error ? launchInfo.error : i18n("Unknown error")
}
Kirigami.FormLayout {
Layout.fillWidth: true
QQC2.Label {
Kirigami.FormData.label: i18n("Launch command:")
text: launchInfo && launchInfo.launchCommand ? launchInfo.launchCommand : ""
wrapMode: Text.WrapAnywhere
}
QQC2.Label {
Kirigami.FormData.label: i18n("Runner:")
text: launchInfo && launchInfo.runner ? launchInfo.runner : i18n("None")
wrapMode: Text.WrapAnywhere
}
QQC2.Label {
Kirigami.FormData.label: i18n("Program:")
text: launchInfo && launchInfo.finalProgram ? launchInfo.finalProgram : ""
wrapMode: Text.WrapAnywhere
}
QQC2.Label {
Kirigami.FormData.label: i18n("Args:")
text: launchInfo && launchInfo.finalArgs ? launchInfo.finalArgs.join(" ") : ""
wrapMode: Text.WrapAnywhere
}
QQC2.Label {
Kirigami.FormData.label: i18n("Working directory:")
text: launchInfo && launchInfo.workingDirectory ? launchInfo.workingDirectory : i18n("Not specified")
wrapMode: Text.WrapAnywhere
}
QQC2.Label {
Kirigami.FormData.label: i18n("Prefix:")
text: launchInfo && launchInfo.resolvedPrefixPath ? launchInfo.resolvedPrefixPath : (launchInfo && launchInfo.prefixPath ? launchInfo.prefixPath : "")
wrapMode: Text.WrapAnywhere
}
}
}
}
}

View file

@ -12,7 +12,18 @@ import org.kde.alakarte
Kirigami.OverlaySheet { Kirigami.OverlaySheet {
id: detailsSheet id: detailsSheet
property var game property var game: null
property int lastNonCouchUiMode: Config.Auto
readonly property bool effectiveCouchMode: {
if (App.config.uiMode === Config.Couch) return true
if (App.config.uiMode !== Config.Auto) return false
if (GamepadManager.connected) {
if (InputManager.activeInput === InputManager.Gamepad) return true
if (!InputManager.hasSeenKeyboardMouse) return true
}
return false
}
readonly property var screenshotsModel: game ? App.mediaManager.screenshotsModel(game) : null readonly property var screenshotsModel: game ? App.mediaManager.screenshotsModel(game) : null
@ -48,16 +59,44 @@ Kirigami.OverlaySheet {
signal launchRequested() signal launchRequested()
signal editRequested() signal editRequested()
signal diagnosticsRequested()
signal removeRequested() signal removeRequested()
title: game ? game.name : "" title: game ? game.name : ""
onOpened: playButton.forceActiveFocus() onOpened: playButton.forceActiveFocus()
Shortcut {
enabled: detailsSheet.opened
sequence: "E"
onActivated: {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
detailsSheet.editRequested()
}
}
Shortcut {
enabled: detailsSheet.opened
sequence: "F"
onActivated: {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (game) game.favorite = !game.favorite
}
}
function isDescendant(item, ancestor) { function isDescendant(item, ancestor) {
let p = item let p = item
while (p) { while (p) {
if (p === ancestor) return true if (p === ancestor) return true
if (ancestor.contentItem && p === ancestor.contentItem) return true
if (ancestor.header && p === ancestor.header) return true
if (p.visualParent !== undefined && p.visualParent !== null) {
if (detailsSheet.isDescendant(p.visualParent, ancestor)) return true
} else if (p.popup !== undefined && p.popup !== null && p.popup.visualParent !== undefined && p.popup.visualParent !== null) {
if (detailsSheet.isDescendant(p.popup.visualParent, ancestor)) return true
}
p = p.parent p = p.parent
} }
return false return false
@ -81,6 +120,8 @@ Kirigami.OverlaySheet {
Connections { Connections {
target: GamepadManager target: GamepadManager
function onNavigateLeft() { function onNavigateLeft() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!detailsSheet.opened) return if (!detailsSheet.opened) return
let w = applicationWindow() let w = applicationWindow()
if (!w || !w.activeFocusItem) return if (!w || !w.activeFocusItem) return
@ -88,6 +129,8 @@ Kirigami.OverlaySheet {
detailsSheet.focusNextInChain(false) detailsSheet.focusNextInChain(false)
} }
function onNavigateRight() { function onNavigateRight() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!detailsSheet.opened) return if (!detailsSheet.opened) return
let w = applicationWindow() let w = applicationWindow()
if (!w || !w.activeFocusItem) return if (!w || !w.activeFocusItem) return
@ -95,6 +138,8 @@ Kirigami.OverlaySheet {
detailsSheet.focusNextInChain(true) detailsSheet.focusNextInChain(true)
} }
function onNavigateUp() { function onNavigateUp() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!detailsSheet.opened) return if (!detailsSheet.opened) return
let w = applicationWindow() let w = applicationWindow()
if (!w || !w.activeFocusItem) return if (!w || !w.activeFocusItem) return
@ -102,6 +147,8 @@ Kirigami.OverlaySheet {
detailsSheet.focusNextInChain(false) detailsSheet.focusNextInChain(false)
} }
function onNavigateDown() { function onNavigateDown() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!detailsSheet.opened) return if (!detailsSheet.opened) return
let w = applicationWindow() let w = applicationWindow()
if (!w || !w.activeFocusItem) return if (!w || !w.activeFocusItem) return
@ -109,6 +156,8 @@ Kirigami.OverlaySheet {
detailsSheet.focusNextInChain(true) detailsSheet.focusNextInChain(true)
} }
function onSelectPressed() { function onSelectPressed() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!detailsSheet.opened) return if (!detailsSheet.opened) return
let w = applicationWindow() let w = applicationWindow()
if (!w || !w.activeFocusItem) return if (!w || !w.activeFocusItem) return
@ -380,6 +429,32 @@ Kirigami.OverlaySheet {
onClicked: detailsSheet.editRequested() onClicked: detailsSheet.editRequested()
} }
QQC2.Button {
icon.name: "view-fullscreen"
text: detailsSheet.effectiveCouchMode ? i18n("Couch mode: On") : i18n("Couch mode: Off")
display: detailsSheet.useCompactLayout ? QQC2.AbstractButton.TextUnderIcon : QQC2.AbstractButton.TextBesideIcon
onClicked: {
if (detailsSheet.effectiveCouchMode) {
if (detailsSheet.lastNonCouchUiMode === Config.Auto) {
App.config.uiMode = Config.Desktop
} else {
App.config.uiMode = detailsSheet.lastNonCouchUiMode
}
} else {
detailsSheet.lastNonCouchUiMode = App.config.uiMode
App.config.uiMode = Config.Couch
}
}
}
QQC2.Button {
icon.name: "dialog-information"
text: i18n("Diagnostics")
display: detailsSheet.useCompactLayout ? QQC2.AbstractButton.TextUnderIcon : QQC2.AbstractButton.TextBesideIcon
enabled: !!game
onClicked: detailsSheet.diagnosticsRequested()
}
QQC2.Button { QQC2.Button {
icon.name: "edit-delete" icon.name: "edit-delete"
text: i18n("Remove") text: i18n("Remove")

View file

@ -14,6 +14,7 @@ Kirigami.Dialog {
property var game: null property var game: null
property bool isEditing: game !== null property bool isEditing: game !== null
property string editProfileId: "default"
ListModel { ListModel {
id: envModel id: envModel
@ -49,10 +50,19 @@ Kirigami.Dialog {
game.developer = developerField.text.trim() game.developer = developerField.text.trim()
game.launchCommand = executableField.text.trim() game.launchCommand = executableField.text.trim()
game.workingDirectory = workingDirField.text.trim() game.workingDirectory = workingDirField.text.trim()
game.launchEnv = dialog.envModelToMap() if (dialog.editProfileId === "couch") {
game.launchRunner = runnerValue game.setLaunchProfileConfig("couch", {
game.launchRunnerPath = runnerPathValue "runner": runnerValue,
game.launchPrefixPath = prefixPathValue "runnerPath": runnerPathValue,
"prefixPath": prefixPathValue,
"env": dialog.envModelToMap()
})
} else {
game.launchEnv = dialog.envModelToMap()
game.launchRunner = runnerValue
game.launchRunnerPath = runnerPathValue
game.launchPrefixPath = prefixPathValue
}
if (selectedCoverPath !== "") { if (selectedCoverPath !== "") {
App.setCoverFromFile(game, selectedCoverPath) App.setCoverFromFile(game, selectedCoverPath)
} }
@ -86,10 +96,26 @@ Kirigami.Dialog {
property string selectedCoverPath: "" property string selectedCoverPath: ""
readonly property bool anyMenuOpen: (runnerCombo && runnerCombo.popup && runnerCombo.popup.visible) || (profileCombo && profileCombo.popup && profileCombo.popup.visible)
function closeCurrentMenu() {
if (runnerCombo && runnerCombo.popup && runnerCombo.popup.visible) {
runnerCombo.popup.close()
}
if (profileCombo && profileCombo.popup && profileCombo.popup.visible) {
profileCombo.popup.close()
}
}
function isDescendant(item, ancestor) { function isDescendant(item, ancestor) {
let p = item let p = item
while (p) { while (p) {
if (p === ancestor || (ancestor.contentItem && p === ancestor.contentItem)) return true if (p === ancestor || (ancestor.contentItem && p === ancestor.contentItem)) return true
if (p.visualParent !== undefined && p.visualParent !== null) {
if (dialog.isDescendant(p.visualParent, ancestor)) return true
} else if (p.popup !== undefined && p.popup !== null && p.popup.visualParent !== undefined && p.popup.visualParent !== null) {
if (dialog.isDescendant(p.popup.visualParent, ancestor)) return true
}
p = p.parent p = p.parent
} }
return false return false
@ -118,7 +144,7 @@ Kirigami.Dialog {
item.toggle() item.toggle()
return return
} }
if (item.hasOwnProperty("checked")) { if (item.checkable !== undefined && item.checkable && item.checked !== undefined) {
item.checked = !item.checked item.checked = !item.checked
return return
} }
@ -168,41 +194,85 @@ Kirigami.Dialog {
return 0 return 0
} }
function loadFields() { function profileIdFromIndex(idx) {
selectedCoverPath = "" if (idx === 1) return "couch"
return "default"
}
function profileIndexFromId(profileId) {
if ((profileId || "").trim() === "couch") return 1
return 0
}
function loadProfileFields() {
envModel.clear() envModel.clear()
if (isEditing && game) {
nameField.text = game.name || ""
developerField.text = game.developer || ""
executableField.text = game.launchCommand || ""
workingDirField.text = game.workingDirectory || ""
runnerCombo.currentIndex = dialog.runnerToIndex(game.launchRunner) if (!isEditing || !game) {
runnerPathField.text = game.launchRunnerPath || "" runnerCombo.currentIndex = 0
prefixPathField.text = game.launchPrefixPath || "" runnerPathField.text = ""
prefixPathField.text = ""
return
}
let env = game.launchEnv || ({}) if (dialog.editProfileId === "couch") {
let cfg = game.launchProfileConfig("couch") || ({})
runnerCombo.currentIndex = dialog.runnerToIndex(cfg.runner)
runnerPathField.text = cfg.runnerPath || ""
prefixPathField.text = cfg.prefixPath || ""
let env = cfg.env || ({})
let keys = Object.keys(env) let keys = Object.keys(env)
keys.sort() keys.sort()
for (let i = 0; i < keys.length; i++) { for (let i = 0; i < keys.length; i++) {
let k = keys[i] let k = keys[i]
envModel.append({ key: k, value: String(env[k]) }) envModel.append({ key: k, value: String(env[k]) })
} }
} else {
runnerCombo.currentIndex = dialog.runnerToIndex(game.launchRunner)
runnerPathField.text = game.launchRunnerPath || ""
prefixPathField.text = game.launchPrefixPath || ""
let env = game.launchEnv || ({})
let keys = Object.keys(env)
keys.sort()
for (let i = 0; i < keys.length; i++) {
let k = keys[i]
envModel.append({ key: k, value: String(env[k]) })
}
}
}
function loadFields() {
selectedCoverPath = ""
if (isEditing && game) {
nameField.text = game.name || ""
developerField.text = game.developer || ""
executableField.text = game.launchCommand || ""
workingDirField.text = game.workingDirectory || ""
dialog.editProfileId = (App.config.uiMode === Config.Couch
|| (App.config.uiMode === Config.Auto
&& GamepadManager.connected
&& (InputManager.activeInput === InputManager.Gamepad || !InputManager.hasSeenKeyboardMouse)))
? "couch"
: "default"
profileCombo.currentIndex = dialog.profileIndexFromId(dialog.editProfileId)
dialog.loadProfileFields()
} else { } else {
nameField.text = "" nameField.text = ""
developerField.text = "" developerField.text = ""
executableField.text = "" executableField.text = ""
workingDirField.text = "" workingDirField.text = ""
runnerCombo.currentIndex = 0 dialog.editProfileId = "default"
runnerPathField.text = "" profileCombo.currentIndex = 0
prefixPathField.text = "" dialog.loadProfileFields()
} }
} }
Connections { Connections {
target: GamepadManager target: GamepadManager
function onNavigateUp() { function onNavigateUp() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!dialog.opened) return if (!dialog.opened) return
let w = applicationWindow() let w = applicationWindow()
if (!w || !w.activeFocusItem) return if (!w || !w.activeFocusItem) return
@ -210,6 +280,8 @@ Kirigami.Dialog {
dialog.focusNextInChain(false) dialog.focusNextInChain(false)
} }
function onNavigateDown() { function onNavigateDown() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!dialog.opened) return if (!dialog.opened) return
let w = applicationWindow() let w = applicationWindow()
if (!w || !w.activeFocusItem) return if (!w || !w.activeFocusItem) return
@ -217,6 +289,8 @@ Kirigami.Dialog {
dialog.focusNextInChain(true) dialog.focusNextInChain(true)
} }
function onNavigateLeft() { function onNavigateLeft() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!dialog.opened) return if (!dialog.opened) return
let w = applicationWindow() let w = applicationWindow()
if (!w || !w.activeFocusItem) return if (!w || !w.activeFocusItem) return
@ -224,6 +298,8 @@ Kirigami.Dialog {
dialog.focusNextInChain(false) dialog.focusNextInChain(false)
} }
function onNavigateRight() { function onNavigateRight() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!dialog.opened) return if (!dialog.opened) return
let w = applicationWindow() let w = applicationWindow()
if (!w || !w.activeFocusItem) return if (!w || !w.activeFocusItem) return
@ -231,6 +307,8 @@ Kirigami.Dialog {
dialog.focusNextInChain(true) dialog.focusNextInChain(true)
} }
function onSelectPressed() { function onSelectPressed() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!dialog.opened) return if (!dialog.opened) return
let w = applicationWindow() let w = applicationWindow()
if (!w || !w.activeFocusItem) return if (!w || !w.activeFocusItem) return
@ -302,6 +380,35 @@ Kirigami.Dialog {
title: i18n("Compatibility") title: i18n("Compatibility")
} }
Item {
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
visible: isEditing
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
text: i18n("Profile")
Layout.alignment: Qt.AlignVCenter
}
QQC2.ComboBox {
id: profileCombo
Layout.fillWidth: true
model: [i18n("Default"), i18n("Couch")]
onCurrentIndexChanged: if (dialog.isEditing) {
dialog.editProfileId = dialog.profileIdFromIndex(currentIndex)
dialog.loadProfileFields()
}
}
}
}
FormCard.FormDelegateSeparator { visible: isEditing }
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing Layout.margins: Kirigami.Units.largeSpacing

View file

@ -25,6 +25,12 @@ FocusScope {
readonly property int gameCount: proxyModel.count readonly property int gameCount: proxyModel.count
property url focusedCoverUrl: "" property url focusedCoverUrl: ""
readonly property bool anyMenuOpen: searchHeader.anyMenuOpen
function closeCurrentMenu() {
searchHeader.closeCurrentMenu()
}
function focusSearch() { function focusSearch() {
searchField.forceActiveFocus() searchField.forceActiveFocus()
} }
@ -35,6 +41,14 @@ FocusScope {
} }
function restoreFocus() { function restoreFocus() {
let w = applicationWindow()
if (w && w.hasOwnProperty("pendingSidebarOpen") && w.pendingSidebarOpen) {
w.pendingSidebarOpen = false
if (w.globalDrawer && typeof w.globalDrawer.open === "function") {
w.globalDrawer.open()
return
}
}
if (libraryRoot.searchActive) { if (libraryRoot.searchActive) {
libraryRoot.focusSearch() libraryRoot.focusSearch()
} else { } else {
@ -176,6 +190,16 @@ FocusScope {
} }
Keys.onDownPressed: gameGrid.forceActiveFocus() Keys.onDownPressed: gameGrid.forceActiveFocus()
Connections {
target: GamepadManager
function onNavigateDown() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (!searchField.activeFocus) return
gameGrid.forceActiveFocus()
}
}
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,96 @@ ColumnLayout {
property var pendingDisableImportApply: null property var pendingDisableImportApply: null
property string pendingDisableImportName: "" property string pendingDisableImportName: ""
readonly property bool anyConfirmOpen: disableImportConfirmDialog.opened
|| fetchAllCoversConfirmDialog.opened
|| removeMissingConfirmDialog.opened
|| clearConfirmDialog.opened
|| resetConfirmDialog.opened
readonly property bool anyMenuOpen: uiModeMenu.visible
function currentConfirmDialog() {
if (disableImportConfirmDialog.opened) return disableImportConfirmDialog
if (fetchAllCoversConfirmDialog.opened) return fetchAllCoversConfirmDialog
if (removeMissingConfirmDialog.opened) return removeMissingConfirmDialog
if (clearConfirmDialog.opened) return clearConfirmDialog
if (resetConfirmDialog.opened) return resetConfirmDialog
return null
}
function closeCurrentConfirmDialog() {
let d = currentConfirmDialog()
if (!d) return
if (typeof d.reject === "function") {
d.reject()
} else {
d.close()
}
}
function closeCurrentMenu() {
if (uiModeMenu.visible) {
uiModeMenu.close()
}
}
function isDescendant(item, ancestor) {
let p = item
while (p) {
if (p === ancestor) return true
if (ancestor.contentItem && p === ancestor.contentItem) return true
if (p.visualParent !== undefined && p.visualParent !== null) {
if (settingsPage.isDescendant(p.visualParent, ancestor)) return true
} else if (p.popup !== undefined && p.popup !== null && p.popup.visualParent !== undefined && p.popup.visualParent !== null) {
if (settingsPage.isDescendant(p.popup.visualParent, ancestor)) return true
}
p = p.parent
}
return false
}
function focusNextInMenu(forward) {
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
let next = w.activeFocusItem
for (let i = 0; i < 50; i++) {
next = next.nextItemInFocusChain(forward)
if (!next) return
if (settingsPage.isDescendant(next, uiModeMenu)) {
next.forceActiveFocus()
return
}
}
}
function activateFocusedInMenu() {
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
let item = w.activeFocusItem
if (typeof item.triggered === "function") {
item.triggered()
return
}
if (typeof item.clicked === "function") {
item.clicked()
return
}
}
function focusNoButton(dialog) {
Qt.callLater(function() {
if (!dialog) return
if (typeof dialog.standardButton === "function") {
let noButton = dialog.standardButton(Kirigami.Dialog.No)
if (noButton) {
noButton.forceActiveFocus()
return
}
}
dialog.forceActiveFocus()
})
}
function requestDisableImport(sourceName, applyFn) { function requestDisableImport(sourceName, applyFn) {
pendingDisableImportName = sourceName pendingDisableImportName = sourceName
pendingDisableImportApply = applyFn pendingDisableImportApply = applyFn
@ -48,7 +138,7 @@ ColumnLayout {
text: i18n("UI mode") text: i18n("UI mode")
description: { description: {
if (App.config.uiMode === Config.Desktop) return i18n("Desktop") if (App.config.uiMode === Config.Desktop) return i18n("Desktop")
if (App.config.uiMode === Config.Handheld) return i18n("Handheld") if (App.config.uiMode === Config.Couch) return i18n("Couch")
return i18n("Automatic") return i18n("Automatic")
} }
icon.name: "view-fullscreen" icon.name: "view-fullscreen"
@ -56,8 +146,12 @@ ColumnLayout {
QQC2.Menu { QQC2.Menu {
id: uiModeMenu id: uiModeMenu
focus: true
onOpened: Qt.callLater(function() { uiModeAuto.forceActiveFocus() })
QQC2.MenuItem { QQC2.MenuItem {
id: uiModeAuto
text: i18n("Automatic") text: i18n("Automatic")
checkable: true checkable: true
checked: App.config.uiMode === Config.Auto checked: App.config.uiMode === Config.Auto
@ -65,6 +159,7 @@ ColumnLayout {
} }
QQC2.MenuItem { QQC2.MenuItem {
id: uiModeDesktop
text: i18n("Desktop") text: i18n("Desktop")
checkable: true checkable: true
checked: App.config.uiMode === Config.Desktop checked: App.config.uiMode === Config.Desktop
@ -72,15 +167,55 @@ ColumnLayout {
} }
QQC2.MenuItem { QQC2.MenuItem {
text: i18n("Handheld") id: uiModeCouch
text: i18n("Couch")
checkable: true checkable: true
checked: App.config.uiMode === Config.Handheld checked: App.config.uiMode === Config.Couch
onTriggered: App.config.uiMode = Config.Handheld onTriggered: App.config.uiMode = Config.Couch
} }
} }
} }
} }
Connections {
target: GamepadManager
function onNavigateUp() {
if (!uiModeMenu.visible) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!settingsPage.isDescendant(w.activeFocusItem, uiModeMenu)) return
settingsPage.focusNextInMenu(false)
}
function onNavigateDown() {
if (!uiModeMenu.visible) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!settingsPage.isDescendant(w.activeFocusItem, uiModeMenu)) return
settingsPage.focusNextInMenu(true)
}
function onNavigateLeft() {
if (!uiModeMenu.visible) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!settingsPage.isDescendant(w.activeFocusItem, uiModeMenu)) return
settingsPage.focusNextInMenu(false)
}
function onNavigateRight() {
if (!uiModeMenu.visible) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!settingsPage.isDescendant(w.activeFocusItem, uiModeMenu)) return
settingsPage.focusNextInMenu(true)
}
function onSelectPressed() {
if (!uiModeMenu.visible) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!settingsPage.isDescendant(w.activeFocusItem, uiModeMenu)) return
settingsPage.activateFocusedInMenu()
}
}
FormCard.FormHeader { FormCard.FormHeader {
Layout.topMargin: Kirigami.Units.mediumSpacing Layout.topMargin: Kirigami.Units.mediumSpacing
Layout.fillWidth: true Layout.fillWidth: true
@ -508,15 +643,15 @@ FormCard.FormHeader {
text: i18n("Import All Games") text: i18n("Import All Games")
description: i18n("Scan all enabled sources") description: i18n("Scan all enabled sources")
icon.name: "document-import" icon.name: "document-import"
enabled: !App.importing && App.gameModel.count >= 0 && ((App.config.importSteam && !App.gameModel.hasPlatformPrefix("Steam")) enabled: !App.importing && App.gameModel.count >= 0 && (App.config.importSteam
|| (App.config.importLutris && !App.gameModel.hasPlatformPrefix("Lutris")) || App.config.importLutris
|| (App.config.importHeroic && !App.gameModel.hasPlatformPrefix("Heroic")) || App.config.importHeroic
|| (App.config.importDesktop && !App.gameModel.hasPlatformPrefix("Desktop")) || App.config.importDesktop
|| (App.config.importBottles && !App.gameModel.hasPlatformPrefix("Bottles")) || App.config.importBottles
|| (App.config.importFlatpak && !App.gameModel.hasPlatformPrefix("Flatpak")) || App.config.importFlatpak
|| (App.config.importItch && !App.gameModel.hasPlatformPrefix("itch.io")) || App.config.importItch
|| (App.config.importLegendary && !App.gameModel.hasPlatformPrefix("Legendary")) || App.config.importLegendary
|| (App.config.importRetroArch && !App.gameModel.hasPlatformPrefix("RetroArch"))) || App.config.importRetroArch)
onClicked: App.importAllGames() onClicked: App.importAllGames()
} }
@ -553,6 +688,7 @@ FormCard.FormHeader {
title: i18n("Disable Import Source") title: i18n("Disable Import Source")
subtitle: i18n("Disabling %1 will remove all games imported from that source. Are you sure?", settingsPage.pendingDisableImportName) subtitle: i18n("Disabling %1 will remove all games imported from that source. Are you sure?", settingsPage.pendingDisableImportName)
standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No
onOpened: settingsPage.focusNoButton(disableImportConfirmDialog)
onAccepted: { onAccepted: {
if (settingsPage.pendingDisableImportApply) { if (settingsPage.pendingDisableImportApply) {
settingsPage.pendingDisableImportApply() settingsPage.pendingDisableImportApply()
@ -573,6 +709,7 @@ FormCard.FormHeader {
? i18n("This will download cover art for all games and may replace existing covers. Continue?") ? i18n("This will download cover art for all games and may replace existing covers. Continue?")
: i18n("This will download cover art for games that are missing covers. Continue?") : i18n("This will download cover art for games that are missing covers. Continue?")
standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No
onOpened: settingsPage.focusNoButton(fetchAllCoversConfirmDialog)
onAccepted: App.steamGridDB.fetchAllCovers() onAccepted: App.steamGridDB.fetchAllCovers()
} }
@ -581,6 +718,7 @@ FormCard.FormHeader {
title: i18n("Remove Missing Games") title: i18n("Remove Missing Games")
subtitle: i18n("This will remove games whose executables cannot be found. This cannot be undone. Continue?") subtitle: i18n("This will remove games whose executables cannot be found. This cannot be undone. Continue?")
standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No
onOpened: settingsPage.focusNoButton(removeMissingConfirmDialog)
onAccepted: App.removeMissingGames() onAccepted: App.removeMissingGames()
} }
@ -589,6 +727,7 @@ FormCard.FormHeader {
title: i18n("Clear Library") title: i18n("Clear Library")
subtitle: i18n("Are you sure you want to remove all games?") subtitle: i18n("Are you sure you want to remove all games?")
standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No
onOpened: settingsPage.focusNoButton(clearConfirmDialog)
onAccepted: App.clearLibrary() onAccepted: App.clearLibrary()
} }
@ -597,6 +736,7 @@ FormCard.FormHeader {
title: i18n("Reset Application") title: i18n("Reset Application")
subtitle: i18n("This will remove all games and reset all settings to defaults. Are you sure?") subtitle: i18n("This will remove all games and reset all settings to defaults. Are you sure?")
standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No
onOpened: settingsPage.focusNoButton(resetConfirmDialog)
onAccepted: { onAccepted: {
App.clearLibrary() App.clearLibrary()
App.config.resetToDefaults() App.config.resetToDefaults()

View file

@ -16,6 +16,8 @@ ColumnLayout {
property string currentSourceName: i18n("All Games") property string currentSourceName: i18n("All Games")
property bool suppressAutoClose: false property bool suppressAutoClose: false
readonly property bool hasSidebarFocus: sourceList.activeFocus || importAction.activeFocus || settingsAction.activeFocus || aboutAction.activeFocus
readonly property int adaptiveFocusRingWidth: 1 readonly property int adaptiveFocusRingWidth: 1
signal sourceSelected(string source) signal sourceSelected(string source)
@ -45,6 +47,17 @@ ColumnLayout {
sourceList.positionViewAtIndex(i, ListView.Contain) sourceList.positionViewAtIndex(i, ListView.Contain)
} }
function applySourceById(sourceId) {
for (let i = 0; i < sourceModel.count; i++) {
let item = sourceModel.get(i)
if (item && item.sourceId === sourceId) {
applySourceAtIndex(i)
return
}
}
applySourceAtIndex(0)
}
function cycleSource(delta) { function cycleSource(delta) {
if (sourceModel.count <= 0) return if (sourceModel.count <= 0) return
let i = sourceList.currentIndex let i = sourceList.currentIndex
@ -97,9 +110,31 @@ ColumnLayout {
Connections { Connections {
target: GamepadManager target: GamepadManager
function onNavigateUp() { if (sourceList.activeFocus) sourceList.decrementCurrentIndex() } function onNavigateUp() {
function onNavigateDown() { if (sourceList.activeFocus) sourceList.incrementCurrentIndex() } let w = applicationWindow()
function onSelectPressed() { if (sourceList.activeFocus) sidebarRoot.activateCurrentItem() } if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (!sourceList.activeFocus) return
if (sourceList.count > 0 && sourceList.currentIndex <= 0) {
aboutAction.forceActiveFocus()
} else {
sourceList.decrementCurrentIndex()
}
}
function onNavigateDown() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (!sourceList.activeFocus) return
if (sourceList.count > 0 && sourceList.currentIndex >= sourceList.count - 1) {
importAction.forceActiveFocus()
} else {
sourceList.incrementCurrentIndex()
}
}
function onSelectPressed() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (sourceList.activeFocus) sidebarRoot.activateCurrentItem()
}
} }
delegate: QQC2.ItemDelegate { delegate: QQC2.ItemDelegate {
@ -294,6 +329,8 @@ ColumnLayout {
Connections { Connections {
target: GamepadManager target: GamepadManager
function onNavigateUp() { function onNavigateUp() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (aboutAction.activeFocus) { if (aboutAction.activeFocus) {
settingsAction.forceActiveFocus() settingsAction.forceActiveFocus()
} else if (settingsAction.activeFocus) { } else if (settingsAction.activeFocus) {
@ -304,13 +341,20 @@ ColumnLayout {
} }
} }
function onNavigateDown() { function onNavigateDown() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (importAction.activeFocus) { if (importAction.activeFocus) {
settingsAction.forceActiveFocus() settingsAction.forceActiveFocus()
} else if (settingsAction.activeFocus) { } else if (settingsAction.activeFocus) {
aboutAction.forceActiveFocus() aboutAction.forceActiveFocus()
} else if (aboutAction.activeFocus) {
sourceList.forceActiveFocus()
sourceList.currentIndex = 0
} }
} }
function onSelectPressed() { function onSelectPressed() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (importAction.activeFocus) { if (importAction.activeFocus) {
importAction.clicked() importAction.clicked()
} else if (settingsAction.activeFocus) { } else if (settingsAction.activeFocus) {

View file

@ -7,9 +7,19 @@ import org.kde.alakarte
RowLayout { RowLayout {
id: root id: root
spacing: Kirigami.Units.largeSpacing spacing: uiMode === Config.Couch ? Kirigami.Units.largeSpacing * 1.25 : Kirigami.Units.largeSpacing
readonly property bool useGamepadHints: GamepadManager.connected property int uiMode: Config.Auto
property int activeInput: InputManager.KeyboardMouse
readonly property bool useGamepadHints: {
if (!GamepadManager.connected) return false
if (uiMode === Config.Couch) {
if (activeInput === InputManager.KeyboardMouse && InputManager.hasSeenKeyboardMouse) return false
return true
}
return activeInput === InputManager.Gamepad
}
readonly property int style: GamepadManager.controllerStyle readonly property int style: GamepadManager.controllerStyle
property string context: "library" property string context: "library"
@ -49,6 +59,9 @@ RowLayout {
function iconForAux(action) { function iconForAux(action) {
if (!useGamepadHints) return "" if (!useGamepadHints) return ""
if (action === "dpad") {
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/generic/dpad.svg"
}
if (action === "lb") { if (action === "lb") {
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/generic/lb.svg" return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/generic/lb.svg"
} }
@ -72,12 +85,25 @@ RowLayout {
} }
function keyboardLabel(action) { function keyboardLabel(action) {
if (root.context === "confirm" || root.context === "confirm_remove") {
switch (action) {
case "navigate": return "Tab"
case "confirm": return "Enter"
case "back": return "Esc"
case "menu": return ""
default: return ""
}
}
if (root.context === "library") { if (root.context === "library") {
switch (action) { switch (action) {
case "navigate": return i18n("Arrows")
case "confirm": return "Space" case "confirm": return "Space"
case "back": return "Esc" case "back": return ""
case "details": return "Enter" case "details": return "Enter"
case "search": return "Ctrl+F" case "search": return "Ctrl+F"
case "lb": return "Ctrl+PgUp"
case "rb": return "Ctrl+PgDown"
case "menu": return "Ctrl+," case "menu": return "Ctrl+,"
default: return "" default: return ""
} }
@ -85,6 +111,7 @@ RowLayout {
if (root.context === "edit") { if (root.context === "edit") {
switch (action) { switch (action) {
case "navigate": return "Tab"
case "confirm": return "Enter" case "confirm": return "Enter"
case "back": return "Esc" case "back": return "Esc"
default: return "" default: return ""
@ -93,8 +120,11 @@ RowLayout {
if (root.context === "details") { if (root.context === "details") {
switch (action) { switch (action) {
case "navigate": return "Tab"
case "confirm": return "Enter" case "confirm": return "Enter"
case "back": return "Esc" case "back": return "Esc"
case "details": return "F"
case "search": return "E"
case "menu": return "Ctrl+," case "menu": return "Ctrl+,"
default: return "" default: return ""
} }
@ -102,6 +132,19 @@ RowLayout {
if (root.context === "sidebar") { if (root.context === "sidebar") {
switch (action) { switch (action) {
case "navigate": return i18n("Arrows/Tab")
case "confirm": return "Enter"
case "back": return "Esc"
case "lb": return "Ctrl+PgUp"
case "rb": return "Ctrl+PgDown"
case "menu": return "Ctrl+,"
default: return ""
}
}
if (root.context === "settings") {
switch (action) {
case "navigate": return "Tab"
case "confirm": return "Enter" case "confirm": return "Enter"
case "back": return "Esc" case "back": return "Esc"
case "menu": return "Ctrl+," case "menu": return "Ctrl+,"
@ -109,8 +152,9 @@ RowLayout {
} }
} }
if (root.context === "settings" || root.context === "import" || root.context === "sidebar") { if (root.context === "settings" || root.context === "import" || root.context === "sidebar" || root.context === "about" || root.context === "diagnostics") {
switch (action) { switch (action) {
case "navigate": return "Tab"
case "confirm": return "Enter" case "confirm": return "Enter"
case "back": return "Esc" case "back": return "Esc"
default: return "" default: return ""
@ -121,53 +165,100 @@ RowLayout {
} }
function actionLabel(action) { function actionLabel(action) {
if (root.context === "confirm" || root.context === "confirm_remove") {
switch (action) {
case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Select")
case "back": return i18n("Cancel")
case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Menu") : ""
default: return ""
}
}
if (root.context === "library") { if (root.context === "library") {
switch (action) { switch (action) {
case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Play") case "confirm": return i18n("Play")
case "back": return i18n("Back") case "back": return ""
case "details": return i18n("Details") case "details": return i18n("Details")
case "search": return i18n("Search") case "search": return i18n("Search")
case "lb": return i18n("Prev Source") case "lb": return i18n("Prev Source")
case "rb": return i18n("Next Source") case "rb": return i18n("Next Source")
case "menu": return i18n("Settings") case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Menu") : i18n("Settings")
default: return ""
}
}
if (root.context === "settings") {
switch (action) {
case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Select")
case "back": return i18n("Back")
case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Menu") : i18n("Close")
default: return ""
}
}
if (root.context === "diagnostics") {
switch (action) {
case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Select")
case "back": return i18n("Back")
case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Menu") : i18n("Close")
default: return "" default: return ""
} }
} }
if (root.context === "edit") { if (root.context === "edit") {
switch (action) { switch (action) {
case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Select") case "confirm": return i18n("Select")
case "back": return i18n("Back") case "back": return i18n("Back")
case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Menu") : ""
default: return "" default: return ""
} }
} }
if (root.context === "details") { if (root.context === "details") {
switch (action) { switch (action) {
case "confirm": return i18n("Play") case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Select")
case "back": return i18n("Back") case "back": return i18n("Back")
case "details": return i18n("Favorite") case "details": return i18n("Favorite")
case "search": return i18n("Edit") case "search": return i18n("Edit")
case "menu": return i18n("Settings") case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Menu") : i18n("Settings")
default: return "" default: return ""
} }
} }
if (root.context === "sidebar") { if (root.context === "sidebar") {
switch (action) { switch (action) {
case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Select") case "confirm": return i18n("Select")
case "back": return i18n("Back") case "back": return i18n("Back")
case "lb": return i18n("Prev Source") case "lb": return i18n("Prev Source")
case "rb": return i18n("Next Source") case "rb": return i18n("Next Source")
case "menu": return i18n("Settings") case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Close") : i18n("Settings")
default: return "" default: return ""
} }
} }
if (root.context === "settings" || root.context === "import" || root.context === "sidebar") { if (root.context === "settings" || root.context === "import" || root.context === "sidebar") {
switch (action) { switch (action) {
case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Select") case "confirm": return i18n("Select")
case "back": return i18n("Back") case "back": return i18n("Back")
case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Menu") : ""
default: return ""
}
}
if (root.context === "about") {
switch (action) {
case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Select")
case "back": return i18n("Back")
case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Menu") : ""
default: return "" default: return ""
} }
} }
@ -195,7 +286,7 @@ RowLayout {
border.width: 1 border.width: 1
border.color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.2) border.color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.2)
Layout.preferredHeight: Kirigami.Units.gridUnit * 1.4 Layout.preferredHeight: uiMode === Config.Couch ? Kirigami.Units.gridUnit * 1.8 : Kirigami.Units.gridUnit * 1.4
Layout.preferredWidth: useGamepadHints Layout.preferredWidth: useGamepadHints
? Layout.preferredHeight ? Layout.preferredHeight
: Math.max(keyText.implicitWidth + Kirigami.Units.mediumSpacing * 2, Layout.preferredHeight) : Math.max(keyText.implicitWidth + Kirigami.Units.mediumSpacing * 2, Layout.preferredHeight)
@ -215,7 +306,7 @@ RowLayout {
anchors.centerIn: parent anchors.centerIn: parent
text: parent.parent.keyLabel text: parent.parent.keyLabel
font.bold: true font.bold: true
font.pointSize: Kirigami.Theme.smallFont.pointSize font.pointSize: uiMode === Config.Couch ? Kirigami.Theme.defaultFont.pointSize : Kirigami.Theme.smallFont.pointSize
color: Kirigami.Theme.textColor color: Kirigami.Theme.textColor
visible: !useGamepadHints visible: !useGamepadHints
} }
@ -224,11 +315,18 @@ RowLayout {
QQC2.Label { QQC2.Label {
text: parent.label text: parent.label
color: Kirigami.Theme.textColor color: Kirigami.Theme.textColor
font.pointSize: Kirigami.Theme.smallFont.pointSize font.pointSize: uiMode === Config.Couch ? Kirigami.Theme.defaultFont.pointSize : Kirigami.Theme.smallFont.pointSize
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
} }
} }
HintItem {
action: "navigate"
label: actionLabel("navigate")
iconSource: iconForAux("dpad")
keyLabel: keyboardLabel("navigate")
}
HintItem { HintItem {
action: "confirm" action: "confirm"
label: actionLabel("confirm") label: actionLabel("confirm")
@ -261,20 +359,22 @@ RowLayout {
action: "lb" action: "lb"
label: actionLabel("lb") label: actionLabel("lb")
iconSource: (root.context === "library" || root.context === "sidebar") ? iconForAux("lb") : "" iconSource: (root.context === "library" || root.context === "sidebar") ? iconForAux("lb") : ""
keyLabel: "" keyLabel: keyboardLabel("lb")
} }
HintItem { HintItem {
action: "rb" action: "rb"
label: actionLabel("rb") label: actionLabel("rb")
iconSource: (root.context === "library" || root.context === "sidebar") ? iconForAux("rb") : "" iconSource: (root.context === "library" || root.context === "sidebar") ? iconForAux("rb") : ""
keyLabel: "" keyLabel: keyboardLabel("rb")
} }
HintItem { HintItem {
action: "menu" action: "menu"
label: actionLabel("menu") label: actionLabel("menu")
iconSource: (root.context === "library" || root.context === "details" || root.context === "sidebar") ? iconForAux("menu") : "" iconSource: (useGamepadHints && uiMode === Config.Couch)
? iconForAux("menu")
: ((root.context === "library" || root.context === "details" || root.context === "sidebar" || root.context === "settings" || root.context === "diagnostics") ? iconForAux("menu") : "")
keyLabel: keyboardLabel("menu") keyLabel: keyboardLabel("menu")
} }
} }

View file

@ -74,11 +74,29 @@ GridView {
Connections { Connections {
target: GamepadManager target: GamepadManager
function onNavigateUp() { if (gridView.activeFocus) gridView.navigateUp() } function onNavigateUp() {
function onNavigateDown() { if (gridView.activeFocus) gridView.navigateDown() } let w = applicationWindow()
function onNavigateLeft() { if (gridView.activeFocus) gridView.navigateLeft() } if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
function onNavigateRight() { if (gridView.activeFocus) gridView.navigateRight() } if (gridView.activeFocus) gridView.navigateUp()
}
function onNavigateDown() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (gridView.activeFocus) gridView.navigateDown()
}
function onNavigateLeft() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (gridView.activeFocus) gridView.navigateLeft()
}
function onNavigateRight() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (gridView.activeFocus) gridView.navigateRight()
}
function onSelectPressed() { function onSelectPressed() {
let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (!gridView.activeFocus) { if (!gridView.activeFocus) {
return return
} }

View file

@ -13,6 +13,57 @@ QQC2.ToolBar {
property alias searchField: searchFieldContainer.data property alias searchField: searchFieldContainer.data
property int currentSortMode: 0 property int currentSortMode: 0
readonly property bool anyMenuOpen: sortMenu.visible
function closeCurrentMenu() {
if (sortMenu.visible) {
sortMenu.close()
}
}
function isDescendant(item, ancestor) {
let p = item
while (p) {
if (p === ancestor) return true
if (ancestor.contentItem && p === ancestor.contentItem) return true
if (p.visualParent !== undefined && p.visualParent !== null) {
if (searchHeader.isDescendant(p.visualParent, ancestor)) return true
} else if (p.popup !== undefined && p.popup !== null && p.popup.visualParent !== undefined && p.popup.visualParent !== null) {
if (searchHeader.isDescendant(p.popup.visualParent, ancestor)) return true
}
p = p.parent
}
return false
}
function focusNextInMenu(forward) {
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
let next = w.activeFocusItem
for (let i = 0; i < 50; i++) {
next = next.nextItemInFocusChain(forward)
if (!next) return
if (searchHeader.isDescendant(next, sortMenu)) {
next.forceActiveFocus()
return
}
}
}
function activateFocusedInMenu() {
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
let item = w.activeFocusItem
if (typeof item.triggered === "function") {
item.triggered()
return
}
if (typeof item.clicked === "function") {
item.clicked()
return
}
}
signal searchChanged(string text) signal searchChanged(string text)
signal sortChanged(int mode) signal sortChanged(int mode)
@ -47,8 +98,12 @@ QQC2.ToolBar {
QQC2.Menu { QQC2.Menu {
id: sortMenu id: sortMenu
focus: true
onOpened: Qt.callLater(function() { sortModeLastPlayed.forceActiveFocus() })
QQC2.MenuItem { QQC2.MenuItem {
id: sortModeLastPlayed
text: i18n("Last Played") text: i18n("Last Played")
checkable: true checkable: true
checked: searchHeader.currentSortMode === 0 checked: searchHeader.currentSortMode === 0
@ -59,6 +114,7 @@ QQC2.ToolBar {
} }
QQC2.MenuItem { QQC2.MenuItem {
id: sortModeName
text: i18n("Name") text: i18n("Name")
checkable: true checkable: true
checked: searchHeader.currentSortMode === 1 checked: searchHeader.currentSortMode === 1
@ -69,6 +125,7 @@ QQC2.ToolBar {
} }
QQC2.MenuItem { QQC2.MenuItem {
id: sortModePlayTime
text: i18n("Play Time") text: i18n("Play Time")
checkable: true checkable: true
checked: searchHeader.currentSortMode === 2 checked: searchHeader.currentSortMode === 2
@ -80,4 +137,43 @@ QQC2.ToolBar {
} }
} }
} }
Connections {
target: GamepadManager
function onNavigateUp() {
if (!sortMenu.visible) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!searchHeader.isDescendant(w.activeFocusItem, sortMenu)) return
searchHeader.focusNextInMenu(false)
}
function onNavigateDown() {
if (!sortMenu.visible) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!searchHeader.isDescendant(w.activeFocusItem, sortMenu)) return
searchHeader.focusNextInMenu(true)
}
function onNavigateLeft() {
if (!sortMenu.visible) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!searchHeader.isDescendant(w.activeFocusItem, sortMenu)) return
searchHeader.focusNextInMenu(false)
}
function onNavigateRight() {
if (!sortMenu.visible) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!searchHeader.isDescendant(w.activeFocusItem, sortMenu)) return
searchHeader.focusNextInMenu(true)
}
function onSelectPressed() {
if (!sortMenu.visible) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!searchHeader.isDescendant(w.activeFocusItem, sortMenu)) return
searchHeader.activateFocusedInMenu()
}
}
} }