Compare commits

..

23 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
ed9367fd71 Core: harden library.json loading for migration 2026-01-25 14:32:42 +01:00
3ec2badcd7 Media: add per-game screenshots manager and gallery 2026-01-25 14:30:02 +01:00
27f5a31561 Cleanup: stop shipping unused FocusableCard.qml 2026-01-25 14:26:11 +01:00
99406a1989 Core: per-game compatibility and env overrides 2026-01-25 14:19:57 +01:00
49997ed0cb Cleanup: remove redundant QML registrations 2026-01-25 14:08:33 +01:00
a0b95fbf9a Build: ignore local third_party and kde-builder metadata 2026-01-25 14:05:52 +01:00
71c1b047b8 Docs: prepare 0.1.1rc2 2026-01-25 14:04:38 +01:00
e754d88eb0 qml: confirm destructive actions 2026-01-25 10:03:46 +01:00
b961a8cc8f qml: move Settings to pageStack layer
Replace the settings OverlaySheet with a pageStack layer page to avoid header/sidebar overlap. Keep keyboard/gamepad focus navigation and scrolling. Tweak SettingsPage spacing and make grid selection start unselected for better navigation.
2026-01-24 20:50:20 +01:00
36 changed files with 4515 additions and 311 deletions

3
.gitignore vendored
View file

@ -8,3 +8,6 @@
/.idea/ /.idea/
/FEATURE_GAP_ANALYSIS.md /FEATURE_GAP_ANALYSIS.md
/third_party/SDL/
/kde-builder-persistent-data.json-kde-builder.yaml

58
CLEANUP_NOTES.md Normal file
View file

@ -0,0 +1,58 @@
# Cleanup Notes
## 2026-01-25
This file tracks cleanup/refactor steps taken during a “deep cleanup” pass, with the goal of reducing dead/duplicate code **without changing application functionality**.
### Applied changes
#### 1) Remove duplicate QML type registration in `src/main.cpp`
- **What changed**
- Removed manual `qmlRegisterSingletonType` / `qmlRegisterType` / `qmlRegisterUncreatableType` calls for types already marked with `QML_ELEMENT` / `QML_SINGLETON`.
- **Why**
- The project uses `ecm_add_qml_module(...)` and the generated QML type registration (based on `QML_ELEMENT` / `QML_SINGLETON`) already registers these types.
- Keeping both mechanisms is redundant and risks divergence.
- **Files**
- `src/main.cpp`
#### 2) Remove dead QML state in `src/qml/SettingsPage.qml`
- **What changed**
- Removed unused `pendingDisableImportDelegate` state.
- Simplified `requestDisableImport(...)` to no longer accept an unused `delegate` parameter.
- **Why**
- The variable was assigned/cleared but never read.
- **Files**
- `src/qml/SettingsPage.qml`
#### 3) Stop shipping `FocusableCard.qml` in the QML module (but keep the file)
- **What changed**
- Removed `qml/components/FocusableCard.qml` from the `QML_FILES` list in `ecm_add_qml_module(...)`.
- **Why**
- The component is not referenced anywhere in QML currently.
- We want to keep the implementation around because it may be useful later (e.g. Desktop/Couch mode), but avoid shipping unused module contents.
- **Files**
- `src/CMakeLists.txt`
- Note: `src/qml/components/FocusableCard.qml` still exists in the repository.
### Verification performed
- Built successfully:
- `cmake --build build-debug`
- Quick run smoke test:
- Launches without QML type registration errors.
- Only the existing Kirigami `StackView has detected conflicting anchors` warning was observed.
### Intentional non-changes (paused items)
- Do **not** delete `src/qml/components/FocusableCard.qml` yet.
- Do **not** remove or migrate any persisted `Config` settings or public QML API fields yet.
### How to revert / resume later
- To re-ship `FocusableCard.qml`:
- Add it back to `ecm_add_qml_module(... QML_FILES ...)` in `src/CMakeLists.txt`.
- To fully remove it:
- `git rm src/qml/components/FocusableCard.qml` (only after confirming it is not needed for future UI modes).

View file

@ -6,10 +6,25 @@
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
- Manual game entries with editable metadata (name, developer/publisher, executable, working directory) - Manual game entries with editable metadata (name, developer/publisher, executable, working directory)
- Per-game launch overrides (environment variables, compatibility runner selection, Wine/Proton prefix path)
- Per-game screenshots gallery with import and centralized storage
- Grid and list views - Grid and list views
- Search by name/developer/publisher and filter by source - Search by name/developer/publisher and filter by source
- Favorites and hidden games views - Favorites and hidden games views
@ -194,6 +209,8 @@ Settings are stored in `~/.config/alakarterc` and include:
Game library is stored in `~/.local/share/alakarte/library.json`. Game library is stored in `~/.local/share/alakarte/library.json`.
Imported screenshots are stored under `~/.local/share/alakarte/media/screenshots/<gameId>/`.
## Code Overview ## Code Overview
- **Application entry point**: `src/main.cpp` - **Application entry point**: `src/main.cpp`
@ -201,6 +218,7 @@ Game library is stored in `~/.local/share/alakarte/library.json`.
- **Data model**: - **Data model**:
- `src/game.*` stores per-game metadata - `src/game.*` stores per-game metadata
- `src/gamemodel.*` and `src/gamesortfiltermodel.*` provide sorting/filtering for the UI - `src/gamemodel.*` and `src/gamesortfiltermodel.*` provide sorting/filtering for the UI
- **Media**: `src/mediamanager.*` + `src/screenshotmodel.*` provide centralized per-game screenshot management
- **Importers**: `src/*importer.*` (Steam/Lutris/Heroic/etc.) - **Importers**: `src/*importer.*` (Steam/Lutris/Heroic/etc.)
- **Launcher**: `src/gamelauncher.*` launches processes/URLs and records last-played on launch - **Launcher**: `src/gamelauncher.*` launches processes/URLs and records last-played on launch
- **UI**: `src/qml/` (Kirigami + Qt Quick) - **UI**: `src/qml/` (Kirigami + Qt Quick)

View file

@ -1,5 +1,23 @@
# Changelog # Changelog
## [0.1.1rc2] - 2026-01-25
### Added
- Per-game compatibility settings: Native/Wine/Proton/custom runner selection
- Proton discovery (Steam/Flatpak) and per-game prefix path support
- Per-game launch environment variables
- Centralized screenshot management with per-game gallery and import action
### Changed
- Game JSON now supports a nested `launch` object for launch overrides (backwards compatible)
### Fixed
- More robust `library.json` parsing with better logging and legacy entry handling
- Runner-aware launching no longer bypasses overrides for Steam/Lutris URL entries
## [0.1.1rc1] - 2026-01-24 ## [0.1.1rc1] - 2026-01-24
### Added ### Added

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

@ -75,6 +75,16 @@
</keywords> </keywords>
<releases> <releases>
<release version="0.1.1rc2" date="2026-01-25">
<description>
<p>Preview release with per-game compatibility and screenshots</p>
<ul>
<li>Per-game compatibility settings (Native/Wine/Proton/custom runner)</li>
<li>Per-game launch environment variables</li>
<li>Centralized per-game screenshots gallery with import action</li>
</ul>
</description>
</release>
<release version="0.1.1rc1" date="2026-01-24"> <release version="0.1.1rc1" date="2026-01-24">
<description> <description>
<p>Preview release with improved navigation and importing</p> <p>Preview release with improved navigation and importing</p>

View file

@ -8,6 +8,9 @@ add_executable(alakarte
gamesortfiltermodel.cpp gamesortfiltermodel.cpp
game.cpp game.cpp
gamepadmanager.cpp gamepadmanager.cpp
inputmanager.cpp
mediamanager.cpp
screenshotmodel.cpp
platformimporter.cpp platformimporter.cpp
steamimporter.cpp steamimporter.cpp
lutrisimporter.cpp lutrisimporter.cpp
@ -61,10 +64,10 @@ 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
qml/components/FocusableCard.qml
qml/components/GameGridView.qml qml/components/GameGridView.qml
qml/components/SearchHeader.qml qml/components/SearchHeader.qml
qml/components/IconWithResourceFallback.qml qml/components/IconWithResourceFallback.qml
@ -117,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

@ -17,7 +17,10 @@
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError>
#include <QSet>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTimer>
#include <QtConcurrent> #include <QtConcurrent>
App *App::s_instance = nullptr; App *App::s_instance = nullptr;
@ -27,6 +30,7 @@ App::App(QObject *parent)
, m_gameModel(new GameModel(this)) , m_gameModel(new GameModel(this))
, m_launcher(new GameLauncher(this)) , m_launcher(new GameLauncher(this))
, m_steamGridDB(new SteamGridDB(this)) , m_steamGridDB(new SteamGridDB(this))
, m_mediaManager(new MediaManager(this))
, m_config(new Config(this)) , m_config(new Config(this))
{ {
loadLibrary(); loadLibrary();
@ -140,6 +144,12 @@ App::App(QObject *parent)
} }
} }
}); });
if (m_config->autoImportOnStartup()) {
QTimer::singleShot(0, this, [this]() {
importAllGames();
});
}
} }
App *App::instance() App *App::instance()
@ -172,6 +182,11 @@ SteamGridDB *App::steamGridDB() const
return m_steamGridDB; return m_steamGridDB;
} }
MediaManager *App::mediaManager() const
{
return m_mediaManager;
}
Config *App::config() const Config *App::config() const
{ {
return m_config; return m_config;
@ -208,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"));
@ -227,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) {
@ -249,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);
@ -284,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);
@ -319,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);
@ -354,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);
@ -389,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);
@ -424,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);
@ -459,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);
@ -494,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);
@ -529,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);
@ -558,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);
@ -572,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);
}); });
@ -604,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);
}); });
@ -636,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);
}); });
@ -668,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);
}); });
@ -700,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);
}); });
@ -732,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);
}); });
@ -764,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);
}); });
@ -796,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);
}); });
@ -828,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);
}); });
@ -952,20 +1265,48 @@ void App::loadLibrary()
return; return;
} }
QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); const QByteArray data = file.readAll();
if (!doc.isArray()) { QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(data, &parseError);
if (parseError.error != QJsonParseError::NoError) {
qWarning() << "Failed to parse library.json:" << parseError.errorString() << "at offset" << parseError.offset;
return; return;
} }
QJsonArray gamesArray = doc.array(); QJsonArray gamesArray;
for (const QJsonValue &value : gamesArray) { if (doc.isArray()) {
if (value.isObject()) { gamesArray = doc.array();
} else if (doc.isObject()) {
const QJsonObject rootObj = doc.object();
const QJsonValue gamesValue = rootObj.value(QStringLiteral("games"));
if (gamesValue.isArray()) {
gamesArray = gamesValue.toArray();
} else {
qWarning() << "Invalid library.json format: expected a JSON array (or an object with a 'games' array)";
return;
}
} else {
qWarning() << "Invalid library.json format: expected a JSON array (or an object with a 'games' array)";
return;
}
m_gameModel->clear();
for (int i = 0; i < gamesArray.size(); ++i) {
const QJsonValue value = gamesArray.at(i);
if (!value.isObject()) {
qWarning() << "Skipping invalid library entry (not an object) at index" << i;
continue;
}
Game *game = Game::fromJson(value.toObject(), this); Game *game = Game::fromJson(value.toObject(), this);
if (game) { if (!game) {
qWarning() << "Skipping invalid game entry at index" << i;
continue;
}
m_gameModel->addGame(game); m_gameModel->addGame(game);
} }
}
}
} }
Game *App::createGame(const QString &name, const QString &launchCommand) Game *App::createGame(const QString &name, const QString &launchCommand)

View file

@ -11,6 +11,7 @@
#include "config.h" #include "config.h"
#include "gamelauncher.h" #include "gamelauncher.h"
#include "gamemodel.h" #include "gamemodel.h"
#include "mediamanager.h"
#include "steamgriddb.h" #include "steamgriddb.h"
class App : public QObject class App : public QObject
@ -22,6 +23,7 @@ class App : public QObject
Q_PROPERTY(GameModel *gameModel READ gameModel CONSTANT) Q_PROPERTY(GameModel *gameModel READ gameModel CONSTANT)
Q_PROPERTY(GameLauncher *launcher READ launcher CONSTANT) Q_PROPERTY(GameLauncher *launcher READ launcher CONSTANT)
Q_PROPERTY(SteamGridDB *steamGridDB READ steamGridDB CONSTANT) Q_PROPERTY(SteamGridDB *steamGridDB READ steamGridDB CONSTANT)
Q_PROPERTY(MediaManager *mediaManager READ mediaManager CONSTANT)
Q_PROPERTY(Config *config READ config CONSTANT) Q_PROPERTY(Config *config READ config CONSTANT)
Q_PROPERTY(bool importing READ importing NOTIFY importingChanged) Q_PROPERTY(bool importing READ importing NOTIFY importingChanged)
Q_PROPERTY(QString importStatus READ importStatus NOTIFY importStatusChanged) Q_PROPERTY(QString importStatus READ importStatus NOTIFY importStatusChanged)
@ -33,6 +35,7 @@ public:
GameModel *gameModel() const; GameModel *gameModel() const;
GameLauncher *launcher() const; GameLauncher *launcher() const;
SteamGridDB *steamGridDB() const; SteamGridDB *steamGridDB() const;
MediaManager *mediaManager() const;
Config *config() const; Config *config() const;
bool importing() const; bool importing() const;
@ -72,6 +75,7 @@ private:
GameModel *m_gameModel; GameModel *m_gameModel;
GameLauncher *m_launcher; GameLauncher *m_launcher;
SteamGridDB *m_steamGridDB; SteamGridDB *m_steamGridDB;
MediaManager *m_mediaManager;
Config *m_config; Config *m_config;
bool m_importing = false; bool m_importing = false;

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");
const QString xdgDataHome = qEnvironmentVariable("XDG_DATA_HOME");
if (!xdgDataHome.isEmpty()) {
appDirs << QDir(xdgDataHome).absoluteFilePath(QStringLiteral("applications"));
} else {
appDirs << expandPath(QStringLiteral("~/.local/share/applications")); 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

@ -4,6 +4,8 @@
#include "game.h" #include "game.h"
#include <KLocalizedString> #include <KLocalizedString>
#include <QCryptographicHash>
#include <QJsonValue>
#include <QLocale> #include <QLocale>
#include <QUuid> #include <QUuid>
@ -131,6 +133,186 @@ void Game::setWorkingDirectory(const QString &dir)
} }
} }
QVariantMap Game::launchEnv() const
{
return m_launchEnv;
}
void Game::setLaunchEnv(const QVariantMap &env)
{
if (m_launchEnv != env) {
m_launchEnv = env;
Q_EMIT launchEnvChanged();
}
}
QString Game::launchRunner() const
{
return m_launchRunner;
}
void Game::setLaunchRunner(const QString &runner)
{
if (m_launchRunner != runner) {
m_launchRunner = runner;
Q_EMIT launchRunnerChanged();
}
}
QString Game::launchRunnerPath() const
{
return m_launchRunnerPath;
}
void Game::setLaunchRunnerPath(const QString &path)
{
if (m_launchRunnerPath != path) {
m_launchRunnerPath = path;
Q_EMIT launchRunnerPathChanged();
}
}
QString Game::launchPrefixPath() const
{
return m_launchPrefixPath;
}
void Game::setLaunchPrefixPath(const QString &path)
{
if (m_launchPrefixPath != path) {
m_launchPrefixPath = path;
Q_EMIT launchPrefixPathChanged();
}
}
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;
@ -268,6 +450,84 @@ QJsonObject Game::toJson() const
obj[QStringLiteral("favorite")] = m_favorite; obj[QStringLiteral("favorite")] = m_favorite;
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()
|| !m_launchProfiles.isEmpty() || (!m_activeLaunchProfile.isEmpty() && m_activeLaunchProfile != QLatin1String("default"));
if (hasLaunchConfig) {
QJsonObject launchObj;
if (!m_launchEnv.isEmpty()) {
QJsonObject envObj;
for (auto it = m_launchEnv.constBegin(); it != m_launchEnv.constEnd(); ++it) {
envObj.insert(it.key(), QJsonValue::fromVariant(it.value()));
}
launchObj.insert(QStringLiteral("env"), envObj);
}
if (!m_launchRunner.isEmpty()) {
launchObj.insert(QStringLiteral("runner"), m_launchRunner);
}
if (!m_launchRunnerPath.isEmpty()) {
launchObj.insert(QStringLiteral("runnerPath"), m_launchRunnerPath);
}
if (!m_launchPrefixPath.isEmpty()) {
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);
}
return obj; return obj;
} }
@ -276,26 +536,135 @@ Game *Game::fromJson(const QJsonObject &json, QObject *parent)
QString id = json[QStringLiteral("id")].toString(); QString id = json[QStringLiteral("id")].toString();
QString name = json[QStringLiteral("name")].toString(); QString name = json[QStringLiteral("name")].toString();
if (id.isEmpty() || name.isEmpty()) { if (name.isEmpty()) {
return nullptr; return nullptr;
} }
const QString platformId = json[QStringLiteral("platformId")].toString();
const QString platform = json[QStringLiteral("platform")].toString();
const QString launchCommand = json[QStringLiteral("launchCommand")].toString();
const QString workingDirectory = json[QStringLiteral("workingDirectory")].toString();
if (id.isEmpty()) {
const QByteArray fingerprint =
(platformId + QLatin1Char('|') + platform + QLatin1Char('|') + name + QLatin1Char('|') + launchCommand + QLatin1Char('|') + workingDirectory)
.toUtf8();
const QByteArray hash = QCryptographicHash::hash(fingerprint, QCryptographicHash::Sha1).toHex();
id = QStringLiteral("legacy-") + QString::fromLatin1(hash);
}
Game *game = new Game(id, name, parent); Game *game = new Game(id, name, parent);
game->setDescription(json[QStringLiteral("description")].toString()); game->setDescription(json[QStringLiteral("description")].toString());
game->setDeveloper(json[QStringLiteral("developer")].toString()); game->setDeveloper(json[QStringLiteral("developer")].toString());
game->setPublisher(json[QStringLiteral("publisher")].toString()); game->setPublisher(json[QStringLiteral("publisher")].toString());
game->setCoverUrl(QUrl(json[QStringLiteral("coverUrl")].toString())); game->setCoverUrl(QUrl(json[QStringLiteral("coverUrl")].toString()));
game->setIconUrl(QUrl(json[QStringLiteral("iconUrl")].toString())); game->setIconUrl(QUrl(json[QStringLiteral("iconUrl")].toString()));
game->setLaunchCommand(json[QStringLiteral("launchCommand")].toString()); game->setLaunchCommand(launchCommand);
game->setWorkingDirectory(json[QStringLiteral("workingDirectory")].toString()); game->setWorkingDirectory(workingDirectory);
game->setPlatform(json[QStringLiteral("platform")].toString()); game->setPlatform(platform);
game->setPlatformId(json[QStringLiteral("platformId")].toString()); game->setPlatformId(platformId);
game->setDateAdded(QDateTime::fromString(json[QStringLiteral("dateAdded")].toString(), Qt::ISODate)); game->setDateAdded(QDateTime::fromString(json[QStringLiteral("dateAdded")].toString(), Qt::ISODate));
game->setLastPlayed(QDateTime::fromString(json[QStringLiteral("lastPlayed")].toString(), Qt::ISODate)); game->setLastPlayed(QDateTime::fromString(json[QStringLiteral("lastPlayed")].toString(), Qt::ISODate));
game->setPlayTime(json[QStringLiteral("playTime")].toInteger()); game->setPlayTime(json[QStringLiteral("playTime")].toInteger());
game->setFavorite(json[QStringLiteral("favorite")].toBool()); game->setFavorite(json[QStringLiteral("favorite")].toBool());
game->setHidden(json[QStringLiteral("hidden")].toBool()); game->setHidden(json[QStringLiteral("hidden")].toBool());
game->setInstalled(json[QStringLiteral("installed")].toBool(true)); game->setInstalled(json[QStringLiteral("installed")].toBool(true));
QVariantMap env;
QString runner;
QString runnerPath;
QString prefixPath;
QString activeProfile;
const QJsonValue launchValue = json.value(QStringLiteral("launch"));
if (launchValue.isObject()) {
const QJsonObject launchObj = launchValue.toObject();
const QJsonValue envValue = launchObj.value(QStringLiteral("env"));
if (envValue.isObject()) {
env = envValue.toObject().toVariantMap();
}
runner = launchObj.value(QStringLiteral("runner")).toString();
runnerPath = launchObj.value(QStringLiteral("runnerPath")).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"));
if (env.isEmpty() && legacyEnvValue.isObject()) {
env = legacyEnvValue.toObject().toVariantMap();
}
if (runner.isEmpty()) {
runner = json.value(QStringLiteral("launchRunner")).toString();
}
if (runnerPath.isEmpty()) {
runnerPath = json.value(QStringLiteral("launchRunnerPath")).toString();
}
if (prefixPath.isEmpty()) {
prefixPath = json.value(QStringLiteral("launchPrefixPath")).toString();
}
if (!env.isEmpty()) {
game->setLaunchEnv(env);
}
if (!runner.isEmpty()) {
game->setLaunchRunner(runner);
}
if (!runnerPath.isEmpty()) {
game->setLaunchRunnerPath(runnerPath);
}
if (!prefixPath.isEmpty()) {
game->setLaunchPrefixPath(prefixPath);
}
if (!activeProfile.isEmpty()) {
game->setActiveLaunchProfile(activeProfile);
}
return game; return game;
} }

View file

@ -9,6 +9,7 @@
#include <QQmlEngine> #include <QQmlEngine>
#include <QString> #include <QString>
#include <QUrl> #include <QUrl>
#include <QVariantMap>
class Game : public QObject class Game : public QObject
{ {
@ -25,6 +26,11 @@ class Game : public QObject
Q_PROPERTY(QUrl iconUrl READ iconUrl WRITE setIconUrl NOTIFY iconUrlChanged) Q_PROPERTY(QUrl iconUrl READ iconUrl WRITE setIconUrl NOTIFY iconUrlChanged)
Q_PROPERTY(QString launchCommand READ launchCommand WRITE setLaunchCommand NOTIFY launchCommandChanged) Q_PROPERTY(QString launchCommand READ launchCommand WRITE setLaunchCommand NOTIFY launchCommandChanged)
Q_PROPERTY(QString workingDirectory READ workingDirectory WRITE setWorkingDirectory NOTIFY workingDirectoryChanged) Q_PROPERTY(QString workingDirectory READ workingDirectory WRITE setWorkingDirectory NOTIFY workingDirectoryChanged)
Q_PROPERTY(QVariantMap launchEnv READ launchEnv WRITE setLaunchEnv NOTIFY launchEnvChanged)
Q_PROPERTY(QString launchRunner READ launchRunner WRITE setLaunchRunner NOTIFY launchRunnerChanged)
Q_PROPERTY(QString launchRunnerPath READ launchRunnerPath WRITE setLaunchRunnerPath NOTIFY launchRunnerPathChanged)
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)
@ -65,6 +71,26 @@ public:
QString workingDirectory() const; QString workingDirectory() const;
void setWorkingDirectory(const QString &dir); void setWorkingDirectory(const QString &dir);
QVariantMap launchEnv() const;
void setLaunchEnv(const QVariantMap &env);
QString launchRunner() const;
void setLaunchRunner(const QString &runner);
QString launchRunnerPath() const;
void setLaunchRunnerPath(const QString &path);
QString launchPrefixPath() const;
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);
@ -108,6 +134,11 @@ Q_SIGNALS:
void iconUrlChanged(); void iconUrlChanged();
void launchCommandChanged(); void launchCommandChanged();
void workingDirectoryChanged(); void workingDirectoryChanged();
void launchEnvChanged();
void launchRunnerChanged();
void launchRunnerPathChanged();
void launchPrefixPathChanged();
void activeLaunchProfileChanged();
void platformChanged(); void platformChanged();
void platformIdChanged(); void platformIdChanged();
void dateAddedChanged(); void dateAddedChanged();
@ -128,6 +159,12 @@ private:
QUrl m_iconUrl; QUrl m_iconUrl;
QString m_launchCommand; QString m_launchCommand;
QString m_workingDirectory; QString m_workingDirectory;
QVariantMap m_launchEnv;
QString m_launchRunner;
QString m_launchRunnerPath;
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,13 +3,140 @@
#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>
#include <QDesktopServices> #include <QDesktopServices>
#include <QDir>
#include <QFileInfo>
#include <QProcessEnvironment>
#include <QStandardPaths>
#include <QTimer> #include <QTimer>
#include <QUrl> #include <QUrl>
static QStringList steamCandidateRoots()
{
const QString home = QDir::homePath();
return {
home + QStringLiteral("/.steam/root"),
home + QStringLiteral("/.steam/steam"),
home + QStringLiteral("/.local/share/Steam"),
home + QStringLiteral("/.var/app/com.valvesoftware.Steam/data/Steam"),
home + QStringLiteral("/.var/app/com.valvesoftware.Steam/.local/share/Steam"),
};
}
static QString findSteamClientInstallPathFromProton(const QString &protonExe)
{
if (protonExe.isEmpty()) {
return {};
}
QDir dir(QFileInfo(protonExe).absoluteDir());
for (int i = 0; i < 10; ++i) {
if (dir.exists(QStringLiteral("steamapps"))) {
return dir.absolutePath();
}
if (!dir.cdUp()) {
break;
}
}
for (const QString &root : steamCandidateRoots()) {
if (QDir(root).exists(QStringLiteral("steamapps"))) {
return root;
}
}
return {};
}
static QString discoverDefaultProtonExecutable()
{
static bool done = false;
static QString cached;
if (done) {
return cached;
}
done = true;
QStringList candidates;
for (const QString &root : steamCandidateRoots()) {
const QString compatTools = root + QStringLiteral("/compatibilitytools.d");
QDir compatDir(compatTools);
if (compatDir.exists()) {
const QStringList toolDirs = compatDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
for (const QString &tool : toolDirs) {
const QString protonPath = compatTools + QLatin1Char('/') + tool + QStringLiteral("/proton");
if (QFileInfo::exists(protonPath)) {
candidates.append(protonPath);
}
}
}
const QString common = root + QStringLiteral("/steamapps/common");
QDir commonDir(common);
if (commonDir.exists()) {
const QStringList dirs = commonDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
for (const QString &d : dirs) {
if (!d.contains(QStringLiteral("Proton"), Qt::CaseInsensitive)) {
continue;
}
const QString protonPath = common + QLatin1Char('/') + d + QStringLiteral("/proton");
if (QFileInfo::exists(protonPath)) {
candidates.append(protonPath);
}
}
}
}
candidates.removeDuplicates();
candidates.sort();
for (const QString &c : candidates) {
if (c.contains(QStringLiteral("Proton - Experimental"))) {
cached = c;
return cached;
}
}
if (!candidates.isEmpty()) {
cached = candidates.constLast();
}
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)
{ {
@ -34,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) {
@ -46,6 +328,14 @@ void GameLauncher::launchGame(Game *game)
return; return;
} }
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();
// Check if already running // Check if already running
if (m_runningGames.contains(game->id())) { if (m_runningGames.contains(game->id())) {
Q_EMIT gameError(game, tr("Game is already running")); Q_EMIT gameError(game, tr("Game is already running"));
@ -53,7 +343,7 @@ void GameLauncher::launchGame(Game *game)
} }
// Handle Steam URLs // Handle Steam URLs
if (command.startsWith(QLatin1String("steam://"))) { if (!hasLaunchOverrides && command.startsWith(QLatin1String("steam://"))) {
QDesktopServices::openUrl(QUrl(command)); QDesktopServices::openUrl(QUrl(command));
game->setLastPlayed(QDateTime::currentDateTime()); game->setLastPlayed(QDateTime::currentDateTime());
Q_EMIT gameStarted(game); Q_EMIT gameStarted(game);
@ -61,7 +351,7 @@ void GameLauncher::launchGame(Game *game)
} }
// Handle Lutris URLs // Handle Lutris URLs
if (command.startsWith(QLatin1String("lutris "))) { if (!hasLaunchOverrides && command.startsWith(QLatin1String("lutris "))) {
QString lutrisCommand = command.mid(7); // Remove "lutris " QString lutrisCommand = command.mid(7); // Remove "lutris "
QDesktopServices::openUrl(QUrl(lutrisCommand)); QDesktopServices::openUrl(QUrl(lutrisCommand));
game->setLastPlayed(QDateTime::currentDateTime()); game->setLastPlayed(QDateTime::currentDateTime());
@ -72,6 +362,21 @@ void GameLauncher::launchGame(Game *game)
// Start process for other commands // Start process for other commands
QProcess *process = new QProcess(this); QProcess *process = new QProcess(this);
// Apply per-game environment variables
QProcessEnvironment env;
bool envChanged = false;
if (!launchEnv.isEmpty()) {
env = QProcessEnvironment::systemEnvironment();
envChanged = true;
for (auto it = launchEnv.constBegin(); it != launchEnv.constEnd(); ++it) {
const QString key = it.key();
if (key.isEmpty() || key.contains(QLatin1Char('='))) {
continue;
}
env.insert(key, it.value().toString());
}
}
// Set working directory if available // Set working directory if available
if (!game->workingDirectory().isEmpty()) { if (!game->workingDirectory().isEmpty()) {
process->setWorkingDirectory(game->workingDirectory()); process->setWorkingDirectory(game->workingDirectory());
@ -84,7 +389,14 @@ void GameLauncher::launchGame(Game *game)
m_processToGame.insert(process, game); m_processToGame.insert(process, game);
// Parse command and arguments // Parse command and arguments
QStringList parts = QProcess::splitCommand(command); 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()) { if (parts.isEmpty()) {
cleanupProcess(process); cleanupProcess(process);
Q_EMIT gameError(game, tr("Invalid launch command")); Q_EMIT gameError(game, tr("Invalid launch command"));
@ -92,7 +404,89 @@ void GameLauncher::launchGame(Game *game)
} }
QString program = parts.takeFirst(); QString program = parts.takeFirst();
process->start(program, parts);
QString finalProgram = program;
QStringList finalArgs = parts;
if ((runner == QLatin1String("wine") || runner == QLatin1String("proton")) && (program == QLatin1String("xdg-open"))) {
cleanupProcess(process);
Q_EMIT gameError(game, tr("This runner cannot be used with URL-based launch commands"));
return;
}
if (runner == QLatin1String("custom")) {
if (runnerPath.isEmpty()) {
cleanupProcess(process);
Q_EMIT gameError(game, tr("Custom runner is enabled but no runner path is configured"));
return;
}
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()) {
cleanupProcess(process);
Q_EMIT gameError(game, tr("Wine runner is enabled but Wine was not found"));
return;
}
QString winePrefix = prefixPath;
if (winePrefix.isEmpty()) {
winePrefix = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id();
}
QDir().mkpath(winePrefix);
if (!envChanged) {
env = QProcessEnvironment::systemEnvironment();
envChanged = true;
}
env.insert(QStringLiteral("WINEPREFIX"), winePrefix);
finalProgram = wineExe;
finalArgs = {program};
finalArgs.append(parts);
} else if (runner == QLatin1String("proton")) {
QString protonExe = runnerPath;
if (protonExe.isEmpty()) {
protonExe = discoverDefaultProtonExecutable();
}
if (protonExe.isEmpty()) {
cleanupProcess(process);
Q_EMIT gameError(game, tr("Proton runner is enabled but no Proton installation was found"));
return;
}
QString steamInstallPath = findSteamClientInstallPathFromProton(protonExe);
QString compatDataPath = prefixPath;
if (compatDataPath.isEmpty()) {
compatDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/prefixes/") + game->id();
}
QDir().mkpath(compatDataPath);
if (!envChanged) {
env = QProcessEnvironment::systemEnvironment();
envChanged = true;
}
env.insert(QStringLiteral("STEAM_COMPAT_DATA_PATH"), compatDataPath);
if (!steamInstallPath.isEmpty()) {
env.insert(QStringLiteral("STEAM_COMPAT_CLIENT_INSTALL_PATH"), steamInstallPath);
}
finalProgram = protonExe;
finalArgs = {QStringLiteral("run"), program};
finalArgs.append(parts);
}
if (envChanged) {
process->setProcessEnvironment(env);
}
process->start(finalProgram, finalArgs);
if (!process->waitForStarted(5000)) { if (!process->waitForStarted(5000)) {
QString error = process->errorString(); QString error = process->errorString();

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>
@ -16,15 +19,10 @@
#include "alakarte-version.h" #include "alakarte-version.h"
#include "app.h" #include "app.h"
#include "config.h"
#include "game.h"
#include "gamepadmanager.h"
#include "gamelauncher.h"
#include "gamemodel.h"
#include "gamesortfiltermodel.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"));
@ -52,29 +50,55 @@ 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"));
} }
QQmlApplicationEngine engine; if (parser.isSet(importAllAndExitOption) || parser.isSet(importDesktopAndExitOption) || parser.isSet(startupAndExitOption)) {
App *alakarteApp = App::instance();
qmlRegisterSingletonType<App>("org.kde.alakarte", 1, 0, "App", [](QQmlEngine *engine, QJSEngine *) -> QObject * { QObject::connect(alakarteApp, &App::importCompleted, &app, [&app](int count) {
Q_UNUSED(engine) QTextStream(stdout) << count << Qt::endl;
return App::instance(); app.exit(0);
});
QObject::connect(alakarteApp, &App::importError, &app, [&app](const QString &error) {
QTextStream(stderr) << error << Qt::endl;
app.exit(1);
}); });
qmlRegisterSingletonType<GamepadManager>("org.kde.alakarte", 1, 0, "GamepadManager", &GamepadManager::create); 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;
}
qmlRegisterType<GameModel>("org.kde.alakarte", 1, 0, "GameModel"); QTimer::singleShot(60000, &app, [&app]() {
qmlRegisterType<GameSortFilterModel>("org.kde.alakarte", 1, 0, "GameSortFilterModel"); QTextStream(stderr) << QStringLiteral("Timed out waiting for auto-import") << Qt::endl;
qmlRegisterUncreatableType<Game>("org.kde.alakarte", 1, 0, "Game", QStringLiteral("Game objects are created by GameModel")); app.exit(2);
qmlRegisterType<GameLauncher>("org.kde.alakarte", 1, 0, "GameLauncher"); });
qmlRegisterType<Config>("org.kde.alakarte", 1, 0, "Config"); }
return app.exec();
}
KDBusService service(KDBusService::Unique);
QQmlApplicationEngine engine;
engine.rootContext()->setContextObject(new KLocalizedContext(&engine)); engine.rootContext()->setContextObject(new KLocalizedContext(&engine));

145
src/mediamanager.cpp Normal file
View file

@ -0,0 +1,145 @@
#include "mediamanager.h"
#include "game.h"
#include "screenshotmodel.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
MediaManager::MediaManager(QObject *parent)
: QObject(parent)
{
}
QString MediaManager::screenshotsBasePath() const
{
return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + QStringLiteral("/media/screenshots");
}
QString MediaManager::gameId(Game *game) const
{
return game ? game->id() : QString();
}
QString MediaManager::ensureDirectory(const QString &path) const
{
if (path.isEmpty()) {
return {};
}
QDir dir(path);
if (!dir.exists()) {
QDir parent;
parent.mkpath(path);
}
return QDir(path).absolutePath();
}
QString MediaManager::gameScreenshotsFolderPath(Game *game) const
{
const QString id = gameId(game);
if (id.isEmpty()) {
return {};
}
return screenshotsBasePath() + QLatin1Char('/') + id;
}
QString MediaManager::screenshotsFolderPath(Game *game) const
{
const QString path = gameScreenshotsFolderPath(game);
return ensureDirectory(path);
}
QUrl MediaManager::screenshotsFolderUrl(Game *game) const
{
const QString path = screenshotsFolderPath(game);
if (path.isEmpty()) {
return {};
}
return QUrl::fromLocalFile(path);
}
QObject *MediaManager::screenshotsModel(Game *game)
{
const QString id = gameId(game);
if (id.isEmpty()) {
return nullptr;
}
if (m_models.contains(id) && m_models.value(id)) {
return m_models.value(id);
}
auto *model = new ScreenshotModel(this);
model->setDirectoryPath(screenshotsFolderPath(game));
m_models.insert(id, model);
return model;
}
QString MediaManager::uniqueDestinationPath(const QString &folderPath, const QString &fileName) const
{
const QString baseName = QFileInfo(fileName).completeBaseName();
const QString suffix = QFileInfo(fileName).suffix();
const QString cleanBase = baseName.isEmpty() ? QStringLiteral("screenshot") : baseName;
const QString cleanSuffix = suffix.isEmpty() ? QStringLiteral("png") : suffix;
QString candidate = folderPath + QLatin1Char('/') + cleanBase + QLatin1Char('.') + cleanSuffix;
if (!QFileInfo::exists(candidate)) {
return candidate;
}
for (int i = 1; i < 10000; ++i) {
candidate = folderPath + QLatin1Char('/') + cleanBase + QLatin1Char('_') + QString::number(i) + QLatin1Char('.') + cleanSuffix;
if (!QFileInfo::exists(candidate)) {
return candidate;
}
}
return folderPath + QLatin1Char('/') + cleanBase + QLatin1Char('_') + QString::number(QDateTime::currentMSecsSinceEpoch()) + QLatin1Char('.') + cleanSuffix;
}
bool MediaManager::importScreenshot(Game *game, const QUrl &sourceUrl)
{
if (!game) {
return false;
}
if (!sourceUrl.isLocalFile()) {
Q_EMIT importError(game, tr("Only local files are supported"));
return false;
}
const QString srcPath = sourceUrl.toLocalFile();
QFileInfo srcInfo(srcPath);
if (!srcInfo.exists() || !srcInfo.isFile()) {
Q_EMIT importError(game, tr("Source file does not exist"));
return false;
}
const QString folder = screenshotsFolderPath(game);
if (folder.isEmpty()) {
Q_EMIT importError(game, tr("Failed to create screenshots folder"));
return false;
}
const QString destPath = uniqueDestinationPath(folder, srcInfo.fileName());
if (!QFile::copy(srcPath, destPath)) {
Q_EMIT importError(game, tr("Failed to copy file"));
return false;
}
const QString id = gameId(game);
if (m_models.contains(id) && m_models.value(id)) {
m_models.value(id)->refresh();
}
Q_EMIT screenshotsImported(game, 1);
return true;
}

41
src/mediamanager.h Normal file
View file

@ -0,0 +1,41 @@
#pragma once
#include <QHash>
#include <QObject>
#include <QPointer>
#include <QQmlEngine>
#include <QUrl>
class Game;
class ScreenshotModel;
class MediaManager : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString screenshotsBasePath READ screenshotsBasePath CONSTANT)
public:
explicit MediaManager(QObject *parent = nullptr);
QString screenshotsBasePath() const;
Q_INVOKABLE QString screenshotsFolderPath(Game *game) const;
Q_INVOKABLE QUrl screenshotsFolderUrl(Game *game) const;
Q_INVOKABLE QObject *screenshotsModel(Game *game);
Q_INVOKABLE bool importScreenshot(Game *game, const QUrl &sourceUrl);
Q_SIGNALS:
void screenshotsImported(Game *game, int count);
void importError(Game *game, const QString &error);
private:
QString gameId(Game *game) const;
QString gameScreenshotsFolderPath(Game *game) const;
QString ensureDirectory(const QString &path) const;
QString uniqueDestinationPath(const QString &folderPath, const QString &fileName) const;
QHash<QString, QPointer<ScreenshotModel>> m_models;
};

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

@ -3,6 +3,7 @@
import QtQuick import QtQuick
import QtQuick.Controls as QQC2 import QtQuick.Controls as QQC2
import QtQuick.Dialogs
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Effects import QtQuick.Effects
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
@ -11,7 +12,20 @@ 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 bool isMobile: { readonly property bool isMobile: {
let w = applicationWindow() let w = applicationWindow()
@ -45,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
@ -78,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
@ -85,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
@ -92,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
@ -99,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
@ -106,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
@ -377,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")
@ -422,6 +500,94 @@ Kirigami.OverlaySheet {
Kirigami.FormData.label: i18n("Install path:") Kirigami.FormData.label: i18n("Install path:")
} }
} }
Kirigami.Heading {
Layout.fillWidth: true
level: 2
text: i18n("Screenshots")
visible: !!game
}
RowLayout {
Layout.fillWidth: true
visible: !!game
QQC2.Button {
text: i18n("Open Folder")
icon.name: "folder-open"
enabled: !!game
onClicked: if (game) {
Qt.openUrlExternally(App.mediaManager.screenshotsFolderUrl(game))
}
}
QQC2.Button {
text: i18n("Import Screenshot")
icon.name: "document-open"
enabled: !!game
onClicked: screenshotImportDialog.open()
}
Item { Layout.fillWidth: true }
}
QQC2.Label {
Layout.fillWidth: true
visible: !!game && (!detailsSheet.screenshotsModel || detailsSheet.screenshotsModel.count === 0)
text: i18n("No screenshots yet")
color: Kirigami.Theme.disabledTextColor
}
GridView {
id: screenshotsGrid
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 12
visible: detailsSheet.screenshotsModel && detailsSheet.screenshotsModel.count > 0
clip: true
cellWidth: Kirigami.Units.gridUnit * 6
cellHeight: Kirigami.Units.gridUnit * 4
model: detailsSheet.screenshotsModel
delegate: Item {
width: screenshotsGrid.cellWidth
height: screenshotsGrid.cellHeight
Kirigami.ShadowedRectangle {
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
radius: Kirigami.Units.smallSpacing
color: Kirigami.Theme.alternateBackgroundColor
clip: true
Image {
anchors.fill: parent
source: url
asynchronous: true
fillMode: Image.PreserveAspectCrop
smooth: true
mipmap: App.config.highQualityImages
}
MouseArea {
anchors.fill: parent
onClicked: Qt.openUrlExternally(url)
}
}
}
}
}
FileDialog {
id: screenshotImportDialog
title: i18n("Import Screenshot")
fileMode: FileDialog.OpenFile
nameFilters: [i18n("Image files (*.png *.jpg *.jpeg *.webp)"), i18n("All files (*)")]
onAccepted: {
if (detailsSheet.game) {
App.mediaManager.importScreenshot(detailsSheet.game, selectedFile)
}
}
} }
function getPlatformColor(platform) { function getPlatformColor(platform) {

View file

@ -14,6 +14,11 @@ 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 {
id: envModel
}
signal gameCreated(var game) signal gameCreated(var game)
signal gameUpdated(var game) signal gameUpdated(var game)
@ -31,13 +36,33 @@ Kirigami.Dialog {
Kirigami.Action { Kirigami.Action {
text: isEditing ? i18n("Apply") : i18n("Add") text: isEditing ? i18n("Apply") : i18n("Add")
icon.name: isEditing ? "dialog-ok-apply" : "list-add" icon.name: isEditing ? "dialog-ok-apply" : "list-add"
enabled: nameField.text.trim() !== "" && executableField.text.trim() !== "" enabled: nameField.text.trim() !== "" && executableField.text.trim() !== "" && (runnerCombo.currentIndex !== 3 || runnerPathField.text.trim() !== "")
onTriggered: { onTriggered: {
let runnerValue = dialog.runnerFromIndex(runnerCombo.currentIndex)
let runnerPathValue = runnerPathField.text.trim()
let prefixPathValue = prefixPathField.text.trim()
if (runnerValue === "") {
runnerPathValue = ""
prefixPathValue = ""
}
if (isEditing) { if (isEditing) {
game.name = nameField.text.trim() game.name = nameField.text.trim()
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()
if (dialog.editProfileId === "couch") {
game.setLaunchProfileConfig("couch", {
"runner": runnerValue,
"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)
} }
@ -48,6 +73,10 @@ Kirigami.Dialog {
if (newGame) { if (newGame) {
newGame.developer = developerField.text.trim() newGame.developer = developerField.text.trim()
newGame.workingDirectory = workingDirField.text.trim() newGame.workingDirectory = workingDirField.text.trim()
newGame.launchEnv = dialog.envModelToMap()
newGame.launchRunner = runnerValue
newGame.launchRunnerPath = runnerPathValue
newGame.launchPrefixPath = prefixPathValue
if (selectedCoverPath !== "") { if (selectedCoverPath !== "") {
App.setCoverFromFile(newGame, selectedCoverPath) App.setCoverFromFile(newGame, selectedCoverPath)
} }
@ -67,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
@ -99,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
} }
@ -109,6 +154,92 @@ Kirigami.Dialog {
} }
} }
function envModelToMap() {
let result = ({})
for (let i = 0; i < envModel.count; i++) {
let row = envModel.get(i)
let k = (row.key || "").trim()
if (k === "") continue
if (k.indexOf("=") !== -1) continue
result[k] = String(row.value || "")
}
return result
}
function setEnvVar(key, value) {
let k = (key || "").trim()
if (k === "") return
for (let i = 0; i < envModel.count; i++) {
let row = envModel.get(i)
if ((row.key || "").trim() === k) {
envModel.setProperty(i, "value", String(value || ""))
return
}
}
envModel.append({ key: k, value: String(value || "") })
}
function runnerFromIndex(idx) {
if (idx === 1) return "wine"
if (idx === 2) return "proton"
if (idx === 3) return "custom"
return ""
}
function runnerToIndex(runner) {
let r = (runner || "").trim().toLowerCase()
if (r === "wine") return 1
if (r === "proton") return 2
if (r === "custom") return 3
return 0
}
function profileIdFromIndex(idx) {
if (idx === 1) return "couch"
return "default"
}
function profileIndexFromId(profileId) {
if ((profileId || "").trim() === "couch") return 1
return 0
}
function loadProfileFields() {
envModel.clear()
if (!isEditing || !game) {
runnerCombo.currentIndex = 0
runnerPathField.text = ""
prefixPathField.text = ""
return
}
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)
keys.sort()
for (let i = 0; i < keys.length; i++) {
let k = keys[i]
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() { function loadFields() {
selectedCoverPath = "" selectedCoverPath = ""
if (isEditing && game) { if (isEditing && game) {
@ -116,17 +247,32 @@ Kirigami.Dialog {
developerField.text = game.developer || "" developerField.text = game.developer || ""
executableField.text = game.launchCommand || "" executableField.text = game.launchCommand || ""
workingDirField.text = game.workingDirectory || "" 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 = ""
dialog.editProfileId = "default"
profileCombo.currentIndex = 0
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
@ -134,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
@ -141,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
@ -148,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
@ -155,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
@ -215,6 +369,197 @@ Kirigami.Dialog {
text: "" text: ""
placeholderText: i18n("Optional") placeholderText: i18n("Optional")
} }
FormCard.FormDelegateSeparator {}
FormCard.FormCard {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.largeSpacing
FormCard.FormHeader {
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 {
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
text: i18n("Runner")
Layout.alignment: Qt.AlignVCenter
}
QQC2.ComboBox {
id: runnerCombo
Layout.fillWidth: true
model: [i18n("Native"), i18n("Wine"), i18n("Proton"), i18n("Custom")]
}
}
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: runnerPathField
label: i18n("Runner Path")
text: ""
placeholderText: runnerCombo.currentIndex === 2 ? i18n("Auto-detect Proton") : (runnerCombo.currentIndex === 1 ? i18n("Use system Wine") : i18n("Required for Custom"))
enabled: runnerCombo.currentIndex !== 0
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Browse Runner...")
icon.name: "document-open"
enabled: runnerCombo.currentIndex !== 0
onClicked: runnerFileDialog.open()
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: prefixPathField
label: i18n("Prefix Path")
text: ""
placeholderText: i18n("Default (AppDataLocation/prefixes/<gameId>)")
enabled: runnerCombo.currentIndex === 1 || runnerCombo.currentIndex === 2
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Browse Prefix...")
icon.name: "document-open-folder"
enabled: runnerCombo.currentIndex === 1 || runnerCombo.currentIndex === 2
onClicked: prefixFolderDialog.open()
}
}
FormCard.FormCard {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.largeSpacing
FormCard.FormHeader {
title: i18n("Environment Variables")
}
FormCard.FormTextDelegate {
text: i18n("These variables are applied only when launching this game from A-La-Karte.")
textItem.wrapMode: Text.WordWrap
textItem.font: Kirigami.Theme.smallFont
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Add Variable")
icon.name: "list-add"
onClicked: envModel.append({ key: "", value: "" })
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Preset: MangoHud")
description: i18n("Sets MANGOHUD=1")
icon.name: "games-hint"
onClicked: dialog.setEnvVar("MANGOHUD", "1")
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Preset: GameMode")
description: i18n("Sets GAMEMODERUN=1")
icon.name: "speedometer"
onClicked: dialog.setEnvVar("GAMEMODERUN", "1")
}
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
text: i18n("Clear Variables")
icon.name: "edit-clear"
enabled: envModel.count > 0
onClicked: envModel.clear()
}
Item {
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
spacing: Kirigami.Units.smallSpacing
Repeater {
model: envModel
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
Layout.fillWidth: true
Layout.preferredWidth: 1
placeholderText: i18n("KEY")
text: key
onTextChanged: envModel.setProperty(index, "key", text)
}
QQC2.TextField {
Layout.fillWidth: true
Layout.preferredWidth: 2
placeholderText: i18n("VALUE")
text: value
onTextChanged: envModel.setProperty(index, "value", text)
}
QQC2.ToolButton {
icon.name: "list-remove"
onClicked: envModel.remove(index)
}
}
}
}
}
}
} }
FormCard.FormCard { FormCard.FormCard {
@ -339,6 +684,23 @@ Kirigami.Dialog {
} }
} }
FileDialog {
id: runnerFileDialog
title: i18n("Select Runner Executable")
fileMode: FileDialog.OpenFile
onAccepted: {
runnerPathField.text = selectedFile.toString().replace("file://", "")
}
}
FolderDialog {
id: prefixFolderDialog
title: i18n("Select Prefix Folder")
onAccepted: {
prefixPathField.text = selectedFolder.toString().replace("file://", "")
}
}
function saveCoverImage(game) { function saveCoverImage(game) {
if (selectedCoverPath === "") return if (selectedCoverPath === "") return

View file

@ -14,7 +14,7 @@ FocusScope {
property string filterSource: "all" property string filterSource: "all"
property bool searchActive: false property bool searchActive: false
property int focusedIndex: 0 property int focusedIndex: -1
property int adaptiveCardSize: App.config.gridSize property int adaptiveCardSize: App.config.gridSize
property bool isTouchDevice: false property bool isTouchDevice: false
@ -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 {
@ -47,6 +61,10 @@ FocusScope {
function launchFocusedGame() { function launchFocusedGame() {
if (!gameGrid || !proxyModel) return if (!gameGrid || !proxyModel) return
if (gameGrid.currentIndex < 0 && proxyModel.count > 0) {
gameGrid.currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(gameGrid.currentIndex) let game = proxyModel.get(gameGrid.currentIndex)
if (game) { if (game) {
libraryRoot.gameLaunched(game) libraryRoot.gameLaunched(game)
@ -55,6 +73,10 @@ FocusScope {
function openDetailsForFocusedGame() { function openDetailsForFocusedGame() {
if (!gameGrid || !proxyModel) return if (!gameGrid || !proxyModel) return
if (gameGrid.currentIndex < 0 && proxyModel.count > 0) {
gameGrid.currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(gameGrid.currentIndex) let game = proxyModel.get(gameGrid.currentIndex)
if (game) { if (game) {
libraryRoot.gameSelected(game) libraryRoot.gameSelected(game)
@ -168,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()
}
}
} }
} }
@ -249,12 +281,20 @@ FocusScope {
Keys.onPressed: function(event) { Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (currentIndex < 0 && proxyModel.count > 0) {
currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(currentIndex) let game = proxyModel.get(currentIndex)
if (game) { if (game) {
libraryRoot.gameSelected(game) libraryRoot.gameSelected(game)
} }
event.accepted = true event.accepted = true
} else if (event.key === Qt.Key_Space) { } else if (event.key === Qt.Key_Space) {
if (currentIndex < 0 && proxyModel.count > 0) {
currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(currentIndex) let game = proxyModel.get(currentIndex)
if (game) { if (game) {
libraryRoot.gameLaunched(game) libraryRoot.gameLaunched(game)

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,105 @@ ColumnLayout {
showPlatformBadgesDelegate.forceActiveFocus() showPlatformBadgesDelegate.forceActiveFocus()
} }
property var pendingDisableImportApply: null
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) {
pendingDisableImportName = sourceName
pendingDisableImportApply = applyFn
disableImportConfirmDialog.open()
}
FormCard.FormHeader { FormCard.FormHeader {
Layout.fillWidth: true Layout.fillWidth: true
title: i18n("Appearance") title: i18n("Appearance")
@ -39,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"
@ -47,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
@ -56,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
@ -63,19 +167,60 @@ 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
} }
} }
} }
} }
FormCard.FormHeader { 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 {
Layout.topMargin: Kirigami.Units.mediumSpacing
Layout.fillWidth: true Layout.fillWidth: true
title: i18n("Import Sources") title: i18n("Import Sources")
} }
FormCard.FormCard { FormCard.FormCard {
Layout.fillWidth: true Layout.fillWidth: true
@ -87,8 +232,26 @@ ColumnLayout {
secondary: "steam" secondary: "steam"
resourceFallback: Qt.resolvedUrl("icons/brand/steam-symbolic.svg") resourceFallback: Qt.resolvedUrl("icons/brand/steam-symbolic.svg")
} }
property bool restoring: false
checked: App.config.importSteam checked: App.config.importSteam
onToggled: App.config.importSteam = checked onToggled: {
if (restoring) return
if (checked === App.config.importSteam) return
if (!checked && App.gameModel.hasPlatformPrefix("Steam")) {
restoring = true
checked = Qt.binding(function() { return App.config.importSteam })
restoring = false
settingsPage.requestDisableImport(i18n("Steam"), function() { App.config.importSteam = false })
return
}
App.config.importSteam = checked
restoring = true
checked = Qt.binding(function() { return App.config.importSteam })
restoring = false
}
} }
FormCard.FormDelegateSeparator {} FormCard.FormDelegateSeparator {}
@ -100,8 +263,26 @@ ColumnLayout {
primary: "lutris" primary: "lutris"
secondary: "applications-games" secondary: "applications-games"
} }
property bool restoring: false
checked: App.config.importLutris checked: App.config.importLutris
onToggled: App.config.importLutris = checked onToggled: {
if (restoring) return
if (checked === App.config.importLutris) return
if (!checked && App.gameModel.hasPlatformPrefix("Lutris")) {
restoring = true
checked = Qt.binding(function() { return App.config.importLutris })
restoring = false
settingsPage.requestDisableImport(i18n("Lutris"), function() { App.config.importLutris = false })
return
}
App.config.importLutris = checked
restoring = true
checked = Qt.binding(function() { return App.config.importLutris })
restoring = false
}
} }
FormCard.FormDelegateSeparator {} FormCard.FormDelegateSeparator {}
@ -114,8 +295,26 @@ ColumnLayout {
primary: "com.heroicgameslauncher.hgl" primary: "com.heroicgameslauncher.hgl"
secondary: "applications-games" secondary: "applications-games"
} }
property bool restoring: false
checked: App.config.importHeroic checked: App.config.importHeroic
onToggled: App.config.importHeroic = checked onToggled: {
if (restoring) return
if (checked === App.config.importHeroic) return
if (!checked && App.gameModel.hasPlatformPrefix("Heroic")) {
restoring = true
checked = Qt.binding(function() { return App.config.importHeroic })
restoring = false
settingsPage.requestDisableImport(i18n("Heroic Games Launcher"), function() { App.config.importHeroic = false })
return
}
App.config.importHeroic = checked
restoring = true
checked = Qt.binding(function() { return App.config.importHeroic })
restoring = false
}
} }
FormCard.FormDelegateSeparator {} FormCard.FormDelegateSeparator {}
@ -128,8 +327,26 @@ ColumnLayout {
primary: "user-desktop" primary: "user-desktop"
secondary: "computer" secondary: "computer"
} }
property bool restoring: false
checked: App.config.importDesktop checked: App.config.importDesktop
onToggled: App.config.importDesktop = checked onToggled: {
if (restoring) return
if (checked === App.config.importDesktop) return
if (!checked && App.gameModel.hasPlatformPrefix("Desktop")) {
restoring = true
checked = Qt.binding(function() { return App.config.importDesktop })
restoring = false
settingsPage.requestDisableImport(i18n("Desktop Entries"), function() { App.config.importDesktop = false })
return
}
App.config.importDesktop = checked
restoring = true
checked = Qt.binding(function() { return App.config.importDesktop })
restoring = false
}
} }
FormCard.FormDelegateSeparator {} FormCard.FormDelegateSeparator {}
@ -142,8 +359,26 @@ ColumnLayout {
primary: "com.usebottles.bottles" primary: "com.usebottles.bottles"
secondary: "application-x-executable" secondary: "application-x-executable"
} }
property bool restoring: false
checked: App.config.importBottles checked: App.config.importBottles
onToggled: App.config.importBottles = checked onToggled: {
if (restoring) return
if (checked === App.config.importBottles) return
if (!checked && App.gameModel.hasPlatformPrefix("Bottles")) {
restoring = true
checked = Qt.binding(function() { return App.config.importBottles })
restoring = false
settingsPage.requestDisableImport(i18n("Bottles"), function() { App.config.importBottles = false })
return
}
App.config.importBottles = checked
restoring = true
checked = Qt.binding(function() { return App.config.importBottles })
restoring = false
}
} }
FormCard.FormDelegateSeparator {} FormCard.FormDelegateSeparator {}
@ -156,8 +391,26 @@ ColumnLayout {
primary: "flatpak-discover" primary: "flatpak-discover"
secondary: "applications-games" secondary: "applications-games"
} }
property bool restoring: false
checked: App.config.importFlatpak checked: App.config.importFlatpak
onToggled: App.config.importFlatpak = checked onToggled: {
if (restoring) return
if (checked === App.config.importFlatpak) return
if (!checked && App.gameModel.hasPlatformPrefix("Flatpak")) {
restoring = true
checked = Qt.binding(function() { return App.config.importFlatpak })
restoring = false
settingsPage.requestDisableImport(i18n("Flatpak"), function() { App.config.importFlatpak = false })
return
}
App.config.importFlatpak = checked
restoring = true
checked = Qt.binding(function() { return App.config.importFlatpak })
restoring = false
}
} }
FormCard.FormDelegateSeparator {} FormCard.FormDelegateSeparator {}
@ -171,8 +424,26 @@ ColumnLayout {
secondary: "itch" secondary: "itch"
resourceFallback: Qt.resolvedUrl("icons/brand/itchdotio-symbolic.svg") resourceFallback: Qt.resolvedUrl("icons/brand/itchdotio-symbolic.svg")
} }
property bool restoring: false
checked: App.config.importItch checked: App.config.importItch
onToggled: App.config.importItch = checked onToggled: {
if (restoring) return
if (checked === App.config.importItch) return
if (!checked && App.gameModel.hasPlatformPrefix("itch.io")) {
restoring = true
checked = Qt.binding(function() { return App.config.importItch })
restoring = false
settingsPage.requestDisableImport(i18n("itch.io"), function() { App.config.importItch = false })
return
}
App.config.importItch = checked
restoring = true
checked = Qt.binding(function() { return App.config.importItch })
restoring = false
}
} }
FormCard.FormDelegateSeparator {} FormCard.FormDelegateSeparator {}
@ -185,8 +456,26 @@ ColumnLayout {
primary: "legendary" primary: "legendary"
secondary: "applications-games" secondary: "applications-games"
} }
property bool restoring: false
checked: App.config.importLegendary checked: App.config.importLegendary
onToggled: App.config.importLegendary = checked onToggled: {
if (restoring) return
if (checked === App.config.importLegendary) return
if (!checked && App.gameModel.hasPlatformPrefix("Legendary")) {
restoring = true
checked = Qt.binding(function() { return App.config.importLegendary })
restoring = false
settingsPage.requestDisableImport(i18n("Legendary"), function() { App.config.importLegendary = false })
return
}
App.config.importLegendary = checked
restoring = true
checked = Qt.binding(function() { return App.config.importLegendary })
restoring = false
}
} }
FormCard.FormDelegateSeparator {} FormCard.FormDelegateSeparator {}
@ -200,8 +489,26 @@ ColumnLayout {
secondary: "retroarch" secondary: "retroarch"
resourceFallback: Qt.resolvedUrl("icons/brand/retroarch-symbolic.svg") resourceFallback: Qt.resolvedUrl("icons/brand/retroarch-symbolic.svg")
} }
property bool restoring: false
checked: App.config.importRetroArch checked: App.config.importRetroArch
onToggled: App.config.importRetroArch = checked onToggled: {
if (restoring) return
if (checked === App.config.importRetroArch) return
if (!checked && App.gameModel.hasPlatformPrefix("RetroArch")) {
restoring = true
checked = Qt.binding(function() { return App.config.importRetroArch })
restoring = false
settingsPage.requestDisableImport(i18n("RetroArch"), function() { App.config.importRetroArch = false })
return
}
App.config.importRetroArch = checked
restoring = true
checked = Qt.binding(function() { return App.config.importRetroArch })
restoring = false
}
} }
FormCard.FormDelegateSeparator {} FormCard.FormDelegateSeparator {}
@ -215,6 +522,7 @@ ColumnLayout {
} }
FormCard.FormHeader { FormCard.FormHeader {
Layout.topMargin: Kirigami.Units.mediumSpacing
Layout.fillWidth: true Layout.fillWidth: true
title: i18n("Behavior") title: i18n("Behavior")
} }
@ -239,6 +547,7 @@ ColumnLayout {
} }
FormCard.FormHeader { FormCard.FormHeader {
Layout.topMargin: Kirigami.Units.mediumSpacing
Layout.fillWidth: true Layout.fillWidth: true
title: i18n("Cover Art") title: i18n("Cover Art")
} }
@ -263,6 +572,7 @@ ColumnLayout {
} }
FormCard.FormHeader { FormCard.FormHeader {
Layout.topMargin: Kirigami.Units.mediumSpacing
Layout.fillWidth: true Layout.fillWidth: true
title: i18n("SteamGridDB") title: i18n("SteamGridDB")
} }
@ -305,7 +615,7 @@ ColumnLayout {
description: App.steamGridDB.busy ? i18n("Fetching...") : i18n("Download covers for all games") description: App.steamGridDB.busy ? i18n("Fetching...") : i18n("Download covers for all games")
icon.name: "download" icon.name: "download"
enabled: App.steamGridDB.enabled && App.steamGridDB.apiKey.length > 0 && !App.steamGridDB.busy enabled: App.steamGridDB.enabled && App.steamGridDB.apiKey.length > 0 && !App.steamGridDB.busy
onClicked: App.steamGridDB.fetchAllCovers() onClicked: fetchAllCoversConfirmDialog.open()
} }
FormCard.FormDelegateSeparator {} FormCard.FormDelegateSeparator {}
@ -322,6 +632,7 @@ ColumnLayout {
} }
FormCard.FormHeader { FormCard.FormHeader {
Layout.topMargin: Kirigami.Units.mediumSpacing
Layout.fillWidth: true Layout.fillWidth: true
title: i18n("Library") title: i18n("Library")
} }
@ -332,15 +643,15 @@ ColumnLayout {
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()
} }
@ -350,7 +661,7 @@ ColumnLayout {
text: i18n("Remove Missing Games") text: i18n("Remove Missing Games")
description: i18n("Remove games whose executables no longer exist") description: i18n("Remove games whose executables no longer exist")
icon.name: "edit-delete" icon.name: "edit-delete"
onClicked: App.removeMissingGames() onClicked: removeMissingConfirmDialog.open()
} }
FormCard.FormDelegateSeparator {} FormCard.FormDelegateSeparator {}
@ -372,11 +683,51 @@ ColumnLayout {
} }
} }
Kirigami.PromptDialog {
id: disableImportConfirmDialog
title: i18n("Disable Import Source")
subtitle: i18n("Disabling %1 will remove all games imported from that source. Are you sure?", settingsPage.pendingDisableImportName)
standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No
onOpened: settingsPage.focusNoButton(disableImportConfirmDialog)
onAccepted: {
if (settingsPage.pendingDisableImportApply) {
settingsPage.pendingDisableImportApply()
}
settingsPage.pendingDisableImportApply = null
settingsPage.pendingDisableImportName = ""
}
onRejected: {
settingsPage.pendingDisableImportApply = null
settingsPage.pendingDisableImportName = ""
}
}
Kirigami.PromptDialog {
id: fetchAllCoversConfirmDialog
title: i18n("Fetch All Covers")
subtitle: App.steamGridDB.preferSteamGridDB
? 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?")
standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No
onOpened: settingsPage.focusNoButton(fetchAllCoversConfirmDialog)
onAccepted: App.steamGridDB.fetchAllCovers()
}
Kirigami.PromptDialog {
id: removeMissingConfirmDialog
title: i18n("Remove Missing Games")
subtitle: i18n("This will remove games whose executables cannot be found. This cannot be undone. Continue?")
standardButtons: Kirigami.Dialog.Yes | Kirigami.Dialog.No
onOpened: settingsPage.focusNoButton(removeMissingConfirmDialog)
onAccepted: App.removeMissingGames()
}
Kirigami.PromptDialog { Kirigami.PromptDialog {
id: clearConfirmDialog id: clearConfirmDialog
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()
} }
@ -385,6 +736,7 @@ ColumnLayout {
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

@ -22,6 +22,8 @@ GridView {
keyNavigationWraps: false keyNavigationWraps: false
activeFocusOnTab: true activeFocusOnTab: true
currentIndex: -1
highlightMoveDuration: Kirigami.Units.shortDuration highlightMoveDuration: Kirigami.Units.shortDuration
highlightFollowsCurrentItem: true highlightFollowsCurrentItem: true
@ -31,24 +33,40 @@ GridView {
Keys.onRightPressed: navigateRight() Keys.onRightPressed: navigateRight()
function navigateUp() { function navigateUp() {
if (currentIndex < 0 && count > 0) {
currentIndex = 0
return
}
if (currentIndex >= columns) { if (currentIndex >= columns) {
currentIndex -= columns currentIndex -= columns
} }
} }
function navigateDown() { function navigateDown() {
if (currentIndex < 0 && count > 0) {
currentIndex = 0
return
}
if (currentIndex + columns < count) { if (currentIndex + columns < count) {
currentIndex += columns currentIndex += columns
} }
} }
function navigateLeft() { function navigateLeft() {
if (currentIndex < 0 && count > 0) {
currentIndex = 0
return
}
if (currentIndex > 0) { if (currentIndex > 0) {
currentIndex-- currentIndex--
} }
} }
function navigateRight() { function navigateRight() {
if (currentIndex < 0 && count > 0) {
currentIndex = 0
return
}
if (currentIndex < count - 1) { if (currentIndex < count - 1) {
currentIndex++ currentIndex++
} }
@ -56,12 +74,36 @@ 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() {
if (gridView.activeFocus && gridView.currentItem) { let w = applicationWindow()
if (w && w.currentConfirmDialog && w.currentConfirmDialog()) return
if (!gridView.activeFocus) {
return
}
if (gridView.currentIndex < 0 && gridView.count > 0) {
gridView.currentIndex = 0
}
if (gridView.currentItem) {
if (gridView.currentItem.play) { if (gridView.currentItem.play) {
gridView.currentItem.play() gridView.currentItem.play()
} else { } else {

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

135
src/screenshotmodel.cpp Normal file
View file

@ -0,0 +1,135 @@
#include "screenshotmodel.h"
#include <QDir>
#include <QFileInfo>
#include <QtConcurrent>
ScreenshotModel::ScreenshotModel(QObject *parent)
: QAbstractListModel(parent)
{
connect(&m_watcher, &QFutureWatcher<QVector<ScreenshotEntry>>::finished, this, [this]() {
const int token = m_watcher.property("refreshToken").toInt();
if (token != m_refreshToken) {
return;
}
setEntries(m_watcher.result());
});
}
ScreenshotModel::~ScreenshotModel()
{
m_refreshToken++;
m_watcher.cancel();
m_watcher.waitForFinished();
}
QString ScreenshotModel::directoryPath() const
{
return m_directoryPath;
}
void ScreenshotModel::setDirectoryPath(const QString &path)
{
if (m_directoryPath == path) {
return;
}
m_directoryPath = path;
Q_EMIT directoryPathChanged();
refresh();
}
int ScreenshotModel::count() const
{
return rowCount();
}
int ScreenshotModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_entries.size();
}
QVariant ScreenshotModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) {
return {};
}
const ScreenshotEntry &e = m_entries.at(index.row());
switch (role) {
case UrlRole:
return QUrl::fromLocalFile(e.filePath);
case FileNameRole:
return e.fileName;
case CreatedRole:
return e.created;
case SizeRole:
return e.size;
default:
return {};
}
}
QHash<int, QByteArray> ScreenshotModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[UrlRole] = "url";
roles[FileNameRole] = "fileName";
roles[CreatedRole] = "created";
roles[SizeRole] = "size";
return roles;
}
void ScreenshotModel::refresh()
{
const QString path = m_directoryPath;
const int token = ++m_refreshToken;
m_watcher.setProperty("refreshToken", token);
auto future = QtConcurrent::run([path]() -> QVector<ScreenshotEntry> {
QVector<ScreenshotEntry> result;
if (path.isEmpty()) {
return result;
}
QDir dir(path);
if (!dir.exists()) {
return result;
}
const QStringList nameFilters = {
QStringLiteral("*.png"),
QStringLiteral("*.jpg"),
QStringLiteral("*.jpeg"),
QStringLiteral("*.webp"),
};
const QFileInfoList files = dir.entryInfoList(nameFilters, QDir::Files | QDir::Readable, QDir::Time | QDir::Reversed);
result.reserve(files.size());
for (const QFileInfo &fi : files) {
ScreenshotEntry e;
e.filePath = fi.absoluteFilePath();
e.fileName = fi.fileName();
e.created = fi.lastModified();
e.size = fi.size();
result.push_back(std::move(e));
}
return result;
});
m_watcher.setFuture(future);
}
void ScreenshotModel::setEntries(QVector<ScreenshotEntry> entries)
{
beginResetModel();
m_entries = std::move(entries);
endResetModel();
Q_EMIT countChanged();
}

59
src/screenshotmodel.h Normal file
View file

@ -0,0 +1,59 @@
#pragma once
#include <QAbstractListModel>
#include <QDateTime>
#include <QFutureWatcher>
#include <QQmlEngine>
#include <QUrl>
#include <QVector>
class ScreenshotModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString directoryPath READ directoryPath WRITE setDirectoryPath NOTIFY directoryPathChanged)
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
enum Roles {
UrlRole = Qt::UserRole + 1,
FileNameRole,
CreatedRole,
SizeRole,
};
explicit ScreenshotModel(QObject *parent = nullptr);
~ScreenshotModel() override;
QString directoryPath() const;
void setDirectoryPath(const QString &path);
int count() const;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE void refresh();
Q_SIGNALS:
void directoryPathChanged();
void countChanged();
private:
struct ScreenshotEntry {
QString filePath;
QString fileName;
QDateTime created;
qint64 size = 0;
};
void setEntries(QVector<ScreenshotEntry> entries);
QString m_directoryPath;
QVector<ScreenshotEntry> m_entries;
QFutureWatcher<QVector<ScreenshotEntry>> m_watcher;
int m_refreshToken = 0;
};