Compare commits

...

38 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
76f2678bf6 Docs: Add SDL3 build and release notes
Document SDL3 discovery and packaging guidance for local builds.

Add a changelog and record the 0.1.1rc1 release notes.
2026-01-24 14:14:59 +01:00
7858f76c7a Docs: Update build instructions
Document SDL3 as a required dependency and describe how to build the
project with kde-builder.

Also consolidate the repo-local config as kde-builder.yaml (without the
leading dot) so it is recognized consistently.
2026-01-24 13:56:51 +01:00
3357e48cc7 Import: Avoid re-importing sources
Skip sources that already have imported games and avoid importing
platforms that are disabled in settings.

Remove imported games when a source is disabled so the library stays in
sync with user preferences.
2026-01-24 13:53:12 +01:00
9c3c0e1dfd Import: Improve Heroic cover handling
Prefer cached cover images when available and import sideload apps.

Also accept Legendary-only setups and normalize cover metadata from the
Heroic configuration.
2026-01-24 13:51:53 +01:00
0897aa8769 Core: Fix Game object lifetime
Use deleteLater() for Game and QProcess objects to avoid deleting
QObject instances at unsafe times.

Add helpers to detect/remove games by platform prefix, used by the
import workflow to avoid re-importing content.
2026-01-24 13:50:54 +01:00
05abbf329b Import: Filter non-game Steam entries
Ignore Steam runtime/redistributable entries and other non-game content.

Also skip manifests that are not fully installed and ensure the install
folder exists before importing.
2026-01-24 13:49:59 +01:00
24b9ee6491 UI: Add hint bar and UI mode override
Add a bottom hint bar with keyboard/gamepad hints and controller-specific icons.

Also add a UI mode setting (auto/desktop/handheld) to improve the adaptive layout on different form factors.
2026-01-24 13:27:30 +01:00
24c0137fff Input: Use SDL3 for gamepad navigation
Implement SDL3-based gamepad handling and expose controller-specific button labels to QML.
2026-01-24 13:22:57 +01:00
40f1c5ff38 Build: Require SDL3
Link against SDL3 and support building against a locally provided SDL3 source tree via ALAKARTE_SDL3_SOURCE_DIR.
2026-01-24 13:20:46 +01:00
95a3c20436 Improve game action discoverability
Show Favorite/Hide/Edit/Remove as labeled buttons in the game details sheet so actions remain visible and accessible even on narrow layouts.
2026-01-20 11:00:09 +01:00
ccffb1e49c Fix editing update and dialog state
Ensure editing a game updates the library view and the edit dialog reliably.

- Refresh the model when a Game emits change signals so filtering/sorting stays in sync.
- Reload edit fields on open/game change to avoid stale values after typing.
- Center the edit dialog with an explicit size.
2026-01-20 10:59:29 +01:00
394227f9a5 Refine Settings and About sheets
Make Settings/About sheets easier to dismiss via close affordances and
press-outside behavior.
Lazy-load the AboutPage to avoid KirigamiAddons null window warnings.
Simplify About metadata and provide non-empty license text.
2026-01-20 00:15:28 +01:00
946baee9cf Fix app icon resource aliasing
Set the Qt resource alias before qt_add_resources.
This ensures the embedded app icon is resolvable at runtime and avoids
fallback icons rendering incorrectly.
2026-01-20 00:14:45 +01:00
58f69e6717 Fix library persistence and duplicate handling
Ensure persistence operates on the full game list, not filtered rows.
Deduplicate imported entries by stable game ID to prevent missing games
and regressions when importing from multiple sources.
2026-01-20 00:13:11 +01:00
e468f53c91 Fix desktop and Flatpak game detection
Use KDesktopFile/KConfigGroup to parse .desktop files reliably.
This avoids Categories parsing pitfalls and improves detection of KDE
stock games. Centralize game-category matching and use stable IDs
based on the full desktop-file basename.
2026-01-20 00:12:12 +01:00
87 changed files with 7416 additions and 762 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

@ -3,7 +3,7 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(alakarte VERSION 0.1.0) project(alakarte VERSION 0.1.1)
set(PROJECT_DEP_VERSION "6.0.0") set(PROJECT_DEP_VERSION "6.0.0")
set(QT_MIN_VERSION "6.6.0") set(QT_MIN_VERSION "6.6.0")
@ -50,7 +50,49 @@ find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS
Sql Sql
) )
find_package(Qt6 ${QT_MIN_VERSION} QUIET COMPONENTS Gamepad) set(ALAKARTE_SDL3_SOURCE_DIR "" CACHE PATH "Path to an SDL3 source tree to build instead of using the system SDL3 package")
if (ALAKARTE_SDL3_SOURCE_DIR)
if (EXISTS "${ALAKARTE_SDL3_SOURCE_DIR}/CMakeLists.txt")
set(SDL_TESTS OFF CACHE BOOL "" FORCE)
set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE)
set(SDL_DISABLE_INSTALL ON CACHE BOOL "" FORCE)
set(SDL_SHARED OFF CACHE BOOL "" FORCE)
set(SDL_STATIC ON CACHE BOOL "" FORCE)
set(SDL_X11_XSCRNSAVER OFF CACHE BOOL "" FORCE)
add_subdirectory("${ALAKARTE_SDL3_SOURCE_DIR}" third_party/SDL-build EXCLUDE_FROM_ALL)
# SDL built from source is treated as a third-party dependency. Silence its
# warnings so they don't drown out warnings from A-La-Karte itself.
foreach(_alakarte_sdl_target IN ITEMS SDL3-static SDL3-static_a SDL3-shared SDL_uclibc)
if (TARGET ${_alakarte_sdl_target})
target_compile_options(${_alakarte_sdl_target} PRIVATE
$<$<COMPILE_LANG_AND_ID:C,GNU,Clang,AppleClang>:-w>
$<$<COMPILE_LANG_AND_ID:CXX,GNU,Clang,AppleClang>:-w>
$<$<COMPILE_LANG_AND_ID:C,MSVC>:/w>
$<$<COMPILE_LANG_AND_ID:CXX,MSVC>:/w>
)
endif()
endforeach()
else()
message(FATAL_ERROR "ALAKARTE_SDL3_SOURCE_DIR does not contain a CMakeLists.txt: ${ALAKARTE_SDL3_SOURCE_DIR}")
endif()
else()
find_package(SDL3 REQUIRED)
endif()
if (NOT TARGET SDL3::SDL3)
if (TARGET SDL3::SDL3-static)
add_library(SDL3::SDL3 ALIAS SDL3::SDL3-static)
elseif (TARGET SDL3::SDL3-shared)
add_library(SDL3::SDL3 ALIAS SDL3::SDL3-shared)
elseif (TARGET SDL3::SDL3::SDL3)
add_library(SDL3::SDL3 ALIAS SDL3::SDL3::SDL3)
else()
message(FATAL_ERROR "SDL3 was found/built, but no usable CMake target was provided (expected SDL3::SDL3 or a compatible variant)")
endif()
endif()
find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS
Kirigami Kirigami

View file

@ -10,6 +10,66 @@ This document is intended for contributors who want to build, run, and debug A-L
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
``` ```
### SDL3
A-La-Karte uses SDL3 for gamepad navigation.
There are two supported ways to satisfy the SDL3 dependency:
#### Option 1: Use a system SDL3 package
If your distribution provides SDL3 development packages, install them and configure normally.
Common package names include `libsdl3-dev` (Debian/Ubuntu), `SDL3-devel` (Fedora/openSUSE), or similarly named SDL3 "-devel" packages.
At CMake configure time, A-La-Karte uses `find_package(SDL3 REQUIRED)`. This requires that SDL3 provides a CMake package (e.g. `SDL3Config.cmake`) and that CMake can find it.
If CMake cannot find SDL3, you can point it at the right prefix:
```bash
cmake -B build -S . \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_PREFIX_PATH=/path/to/sdl3/prefix
```
Alternatively you can point directly at SDL3's CMake package directory:
```bash
cmake -B build -S . \
-DCMAKE_BUILD_TYPE=Debug \
-DSDL3_DIR=/path/to/SDL3Config.cmake/parent
```
#### Option 2: Build SDL3 from a source tree
If your distro does not ship SDL3 yet (or ships a version without a usable CMake package), you can build SDL3 as part of the A-La-Karte build by pointing CMake at an SDL3 source checkout:
You can obtain a source tree by cloning upstream SDL:
```bash
git clone https://github.com/libsdl-org/SDL.git
```
```bash
cmake -B build -S . \
-DCMAKE_BUILD_TYPE=Debug \
-DALAKARTE_SDL3_SOURCE_DIR=/path/to/SDL
```
`ALAKARTE_SDL3_SOURCE_DIR` must point at a directory that contains `CMakeLists.txt` from SDL3.
This is intended for local development and CI setups. Distributions should prefer packaging SDL3 as a system dependency.
#### Packaging guidance
Packagers should depend on the system SDL3 development package and ensure CMake can discover it.
In particular:
- The build dependency must include SDL3 headers and the CMake package files.
- A-La-Karte links against `SDL3::SDL3`.
- Avoid using `ALAKARTE_SDL3_SOURCE_DIR` in distribution packaging.
### Build ### Build
```bash ```bash
@ -82,4 +142,4 @@ Then:
## Notes ## Notes
- SteamGridDB cover fetching requires an API key. - SteamGridDB cover fetching requires an API key.
- KRunner integration and gamepad navigation are optional at build time (depending on available dependencies). - KRunner integration is optional at build time (depending on available dependencies).

View file

@ -6,17 +6,32 @@
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
- Last-played timestamp - Last-played timestamp
- Optional SteamGridDB cover fetching (requires API key) - Optional SteamGridDB cover fetching (requires API key)
- Optional KRunner integration (if KF6Runner is available at build time) - Optional KRunner integration (if KF6Runner is available at build time)
- Optional gamepad navigation (if Qt Gamepad is available at build time) - Gamepad navigation (via SDL3)
## Requirements ## Requirements
@ -30,6 +45,7 @@ A unified game launcher for KDE Plasma 6, inspired by [KDE Apps Initiative issue
- KCoreAddons - KCoreAddons
- KConfig - KConfig
- Extra CMake Modules (ECM) - Extra CMake Modules (ECM)
- SDL3
### Runtime Dependencies ### Runtime Dependencies
@ -51,10 +67,36 @@ A unified game launcher for KDE Plasma 6, inspired by [KDE Apps Initiative issue
### Using kde-builder (Recommended) ### Using kde-builder (Recommended)
This project is not currently part of KDE's repo-metadata, so you need to add it as a custom project in your `~/.config/kde-builder.yaml`.
1. Set up kde-builder:
- https://develop.kde.org/docs/getting-started/building/kde-builder-setup/
2. Install distro build dependencies:
```bash
kde-builder --install-distro-packages
```
3. Add a project entry to `~/.config/kde-builder.yaml`:
```yaml
project alakarte:
repository: https://invent.kde.org/marcoa/a-la-karte.git
cmake-options: -DALAKARTE_SDL3_SOURCE_DIR=/path/to/SDL
```
4. Build:
```bash ```bash
kde-builder alakarte kde-builder alakarte
``` ```
To run the built app:
```bash
kde-builder --run alakarte
```
### Manual Build ### Manual Build
```bash ```bash
@ -167,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`
@ -174,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)

41
changelog.md Normal file
View file

@ -0,0 +1,41 @@
# 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
### Added
- SDL3-based gamepad navigation
- Bottom hint bar with keyboard/gamepad hints and controller icons
- UI mode setting (auto/desktop/handheld)
### Changed
- Import workflow skips sources that were already imported
- Steam importing filters out non-game entries (runtimes/redistributables)
### Fixed
- Heroic importing prefers cached cover images and imports sideload apps
- Safer QObject lifetime management for games and launcher processes
## [0.1.0] - 2026-01-17
- First public preview release

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

@ -1,8 +1,10 @@
# kde-builder configuration for A-La-Karte # kde-builder configuration for A-La-Karte
# See https://kde-builder.kde.org/en/configuration/config-file-overview.html # See https://kde-builder.kde.org/en/configuration/config-file-overview.html
config-version: 2
global: global:
branch-group: kf6-qt6 branch-group: latest-kf6
install-dir: ~/kde/usr install-dir: ~/kde/usr
source-dir: ~/kde/src source-dir: ~/kde/src
build-dir: ~/kde/build build-dir: ~/kde/build
@ -15,9 +17,8 @@ global:
num-cores: auto num-cores: auto
num-cores-low-mem: 2 num-cores-low-mem: 2
install-after-build: true
project alakarte: project alakarte:
repository: . repository: .
no-src: true
cmake-options: cmake-options:
- -DCMAKE_INSTALL_PREFIX=${install-dir} - -DCMAKE_INSTALL_PREFIX=${install-dir}

View file

@ -75,6 +75,29 @@
</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">
<description>
<p>Preview release with improved navigation and importing</p>
<ul>
<li>SDL3-based gamepad navigation</li>
<li>Bottom hint bar with keyboard/gamepad hints</li>
<li>UI mode setting (auto/desktop/handheld)</li>
<li>Steam importing filters non-game entries</li>
<li>Import workflow avoids re-importing already imported sources</li>
<li>Heroic importing prefers cached covers and imports sideload apps</li>
</ul>
</description>
</release>
<release version="0.1.0" date="2026-01-17"> <release version="0.1.0" date="2026-01-17">
<description> <description>
<p>First public preview release of A-La-Karte</p> <p>First public preview release of A-La-Karte</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
@ -23,16 +26,16 @@ add_executable(alakarte
config.cpp config.cpp
) )
set_source_files_properties(qml/icons/app/org.kde.alakarte.svg PROPERTIES
QT_RESOURCE_ALIAS "sc-apps-org.kde.alakarte.svg"
)
qt_add_resources(alakarte alakarte_app_icon qt_add_resources(alakarte alakarte_app_icon
PREFIX "/icons" PREFIX "/icons"
FILES FILES
qml/icons/app/org.kde.alakarte.svg qml/icons/app/org.kde.alakarte.svg
) )
set_source_files_properties(qml/icons/app/org.kde.alakarte.svg PROPERTIES
QT_RESOURCE_ALIAS "sc-apps-org.kde.alakarte.svg"
)
target_link_libraries(alakarte PRIVATE target_link_libraries(alakarte PRIVATE
Qt6::Core Qt6::Core
Qt6::Quick Qt6::Quick
@ -43,6 +46,7 @@ target_link_libraries(alakarte PRIVATE
Qt6::Concurrent Qt6::Concurrent
Qt6::DBus Qt6::DBus
Qt6::Svg Qt6::Svg
SDL3::SDL3
KF6::Kirigami KF6::Kirigami
KF6::I18n KF6::I18n
KF6::CoreAddons KF6::CoreAddons
@ -53,11 +57,6 @@ target_link_libraries(alakarte PRIVATE
KF6::WindowSystem KF6::WindowSystem
) )
if (TARGET Qt6::Gamepad)
target_link_libraries(alakarte PRIVATE Qt6::Gamepad)
target_compile_definitions(alakarte PRIVATE ALAKARTE_HAVE_QT_GAMEPAD=1)
endif()
ecm_add_qml_module(alakarte URI org.kde.alakarte ecm_add_qml_module(alakarte URI org.kde.alakarte
VERSION 1.0 VERSION 1.0
QML_FILES QML_FILES
@ -65,25 +64,63 @@ 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
qml/components/EmptyState.qml qml/components/EmptyState.qml
qml/components/BottomHintBar.qml
RESOURCES RESOURCES
qml/icons/brand/steam-symbolic.svg qml/icons/brand/steam-symbolic.svg
qml/icons/brand/itchdotio-symbolic.svg qml/icons/brand/itchdotio-symbolic.svg
qml/icons/brand/retroarch-symbolic.svg qml/icons/brand/retroarch-symbolic.svg
qml/icons/app/org.kde.alakarte.svg qml/icons/app/org.kde.alakarte.svg
qml/icons/gamepad/playstation/cross.svg
qml/icons/gamepad/playstation/circle.svg
qml/icons/gamepad/playstation/square.svg
qml/icons/gamepad/playstation/triangle.svg
qml/icons/gamepad/playstation/options.svg
qml/icons/gamepad/playstation/share.svg
qml/icons/gamepad/xbox/a.svg
qml/icons/gamepad/xbox/b.svg
qml/icons/gamepad/xbox/x.svg
qml/icons/gamepad/xbox/y.svg
qml/icons/gamepad/xbox/menu.svg
qml/icons/gamepad/xbox/view.svg
qml/icons/gamepad/nintendo/a.svg
qml/icons/gamepad/nintendo/b.svg
qml/icons/gamepad/nintendo/x.svg
qml/icons/gamepad/nintendo/y.svg
qml/icons/gamepad/nintendo/plus.svg
qml/icons/gamepad/nintendo/minus.svg
qml/icons/gamepad/nintendo/home.svg
qml/icons/gamepad/generic/south.svg
qml/icons/gamepad/generic/east.svg
qml/icons/gamepad/generic/west.svg
qml/icons/gamepad/generic/north.svg
qml/icons/gamepad/generic/lb.svg
qml/icons/gamepad/generic/rb.svg
qml/icons/gamepad/generic/lt.svg
qml/icons/gamepad/generic/rt.svg
qml/icons/gamepad/generic/dpad.svg
qml/icons/gamepad/generic/dpad-up.svg
qml/icons/gamepad/generic/dpad-down.svg
qml/icons/gamepad/generic/dpad-left.svg
qml/icons/gamepad/generic/dpad-right.svg
qml/icons/gamepad/generic/lstick.svg
qml/icons/gamepad/generic/rstick.svg
qml/icons/gamepad/generic/menu.svg
qml/icons/gamepad/generic/back.svg
SOURCES SOURCES
app.h app.h
gamemodel.h gamemodel.h
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

File diff suppressed because it is too large Load diff

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

@ -26,6 +26,20 @@ void Config::setViewMode(ViewMode mode)
} }
} }
Config::UiMode Config::uiMode() const
{
return m_uiMode;
}
void Config::setUiMode(UiMode mode)
{
if (m_uiMode != mode) {
m_uiMode = mode;
save();
Q_EMIT uiModeChanged();
}
}
int Config::gridSize() const int Config::gridSize() const
{ {
return m_gridSize; return m_gridSize;
@ -265,12 +279,81 @@ 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"));
KConfigGroup generalGroup(config, QStringLiteral("General")); KConfigGroup generalGroup(config, QStringLiteral("General"));
generalGroup.writeEntry(QStringLiteral("viewMode"), static_cast<int>(m_viewMode)); generalGroup.writeEntry(QStringLiteral("viewMode"), static_cast<int>(m_viewMode));
generalGroup.writeEntry(QStringLiteral("uiMode"), static_cast<int>(m_uiMode));
generalGroup.writeEntry(QStringLiteral("gridSize"), m_gridSize); generalGroup.writeEntry(QStringLiteral("gridSize"), m_gridSize);
generalGroup.writeEntry(QStringLiteral("showPlatformBadges"), m_showPlatformBadges); generalGroup.writeEntry(QStringLiteral("showPlatformBadges"), m_showPlatformBadges);
generalGroup.writeEntry(QStringLiteral("theme"), m_theme); generalGroup.writeEntry(QStringLiteral("theme"), m_theme);
@ -293,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();
} }
@ -302,6 +390,7 @@ void Config::load()
const KConfigGroup generalGroup(config, QStringLiteral("General")); const KConfigGroup generalGroup(config, QStringLiteral("General"));
m_viewMode = static_cast<ViewMode>(generalGroup.readEntry(QStringLiteral("viewMode"), static_cast<int>(GridView))); m_viewMode = static_cast<ViewMode>(generalGroup.readEntry(QStringLiteral("viewMode"), static_cast<int>(GridView)));
m_uiMode = static_cast<UiMode>(generalGroup.readEntry(QStringLiteral("uiMode"), static_cast<int>(Auto)));
m_gridSize = generalGroup.readEntry(QStringLiteral("gridSize"), 180); m_gridSize = generalGroup.readEntry(QStringLiteral("gridSize"), 180);
m_showPlatformBadges = generalGroup.readEntry(QStringLiteral("showPlatformBadges"), true); m_showPlatformBadges = generalGroup.readEntry(QStringLiteral("showPlatformBadges"), true);
m_theme = generalGroup.readEntry(QStringLiteral("theme"), QString()); m_theme = generalGroup.readEntry(QStringLiteral("theme"), QString());
@ -323,11 +412,17 @@ 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()
{ {
m_viewMode = GridView; m_viewMode = GridView;
m_uiMode = Auto;
m_gridSize = 180; m_gridSize = 180;
m_showPlatformBadges = true; m_showPlatformBadges = true;
m_autoImportOnStartup = false; m_autoImportOnStartup = false;
@ -346,9 +441,14 @@ 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();
Q_EMIT uiModeChanged();
Q_EMIT gridSizeChanged(); Q_EMIT gridSizeChanged();
Q_EMIT showPlatformBadgesChanged(); Q_EMIT showPlatformBadgesChanged();
Q_EMIT autoImportOnStartupChanged(); Q_EMIT autoImportOnStartupChanged();
@ -366,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

@ -12,6 +12,7 @@ class Config : public QObject
QML_ELEMENT QML_ELEMENT
Q_PROPERTY(ViewMode viewMode READ viewMode WRITE setViewMode NOTIFY viewModeChanged) Q_PROPERTY(ViewMode viewMode READ viewMode WRITE setViewMode NOTIFY viewModeChanged)
Q_PROPERTY(UiMode uiMode READ uiMode WRITE setUiMode NOTIFY uiModeChanged)
Q_PROPERTY(int gridSize READ gridSize WRITE setGridSize NOTIFY gridSizeChanged) Q_PROPERTY(int gridSize READ gridSize WRITE setGridSize NOTIFY gridSizeChanged)
Q_PROPERTY(bool showPlatformBadges READ showPlatformBadges WRITE setShowPlatformBadges NOTIFY showPlatformBadgesChanged) Q_PROPERTY(bool showPlatformBadges READ showPlatformBadges WRITE setShowPlatformBadges NOTIFY showPlatformBadgesChanged)
Q_PROPERTY(bool autoImportOnStartup READ autoImportOnStartup WRITE setAutoImportOnStartup NOTIFY autoImportOnStartupChanged) Q_PROPERTY(bool autoImportOnStartup READ autoImportOnStartup WRITE setAutoImportOnStartup NOTIFY autoImportOnStartupChanged)
@ -29,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 {
@ -38,11 +42,21 @@ public:
}; };
Q_ENUM(ViewMode) Q_ENUM(ViewMode)
enum UiMode {
Auto,
Desktop,
Couch
};
Q_ENUM(UiMode)
explicit Config(QObject *parent = nullptr); explicit Config(QObject *parent = nullptr);
ViewMode viewMode() const; ViewMode viewMode() const;
void setViewMode(ViewMode mode); void setViewMode(ViewMode mode);
UiMode uiMode() const;
void setUiMode(UiMode mode);
int gridSize() const; int gridSize() const;
void setGridSize(int size); void setGridSize(int size);
@ -94,12 +108,24 @@ 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 gridSizeChanged(); void gridSizeChanged();
void showPlatformBadgesChanged(); void showPlatformBadgesChanged();
void autoImportOnStartupChanged(); void autoImportOnStartupChanged();
@ -117,9 +143,13 @@ 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;
UiMode m_uiMode = Auto;
int m_gridSize = 180; int m_gridSize = 180;
bool m_showPlatformBadges = true; bool m_showPlatformBadges = true;
bool m_autoImportOnStartup = false; bool m_autoImportOnStartup = false;
@ -137,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

@ -3,10 +3,11 @@
#include "desktopimporter.h" #include "desktopimporter.h"
#include <KConfigGroup>
#include <KDesktopFile>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSettings>
#include <QStandardPaths> #include <QStandardPaths>
DesktopImporter::DesktopImporter(QObject *parent) DesktopImporter::DesktopImporter(QObject *parent)
@ -39,7 +40,13 @@ QStringList DesktopImporter::getDesktopFilePaths() const
// Add common system locations // Add common system locations
appDirs << QStringLiteral("/usr/share/applications"); appDirs << QStringLiteral("/usr/share/applications");
appDirs << QStringLiteral("/usr/local/share/applications"); appDirs << QStringLiteral("/usr/local/share/applications");
appDirs << expandPath(QStringLiteral("~/.local/share/applications"));
const QString xdgDataHome = qEnvironmentVariable("XDG_DATA_HOME");
if (!xdgDataHome.isEmpty()) {
appDirs << QDir(xdgDataHome).absoluteFilePath(QStringLiteral("applications"));
} else {
appDirs << expandPath(QStringLiteral("~/.local/share/applications"));
}
// Flatpak export directories // Flatpak export directories
appDirs << expandPath(QStringLiteral("~/.local/share/flatpak/exports/share/applications")); appDirs << expandPath(QStringLiteral("~/.local/share/flatpak/exports/share/applications"));
@ -63,58 +70,38 @@ QStringList DesktopImporter::getDesktopFilePaths() const
bool DesktopImporter::isGameDesktopFile(const QString &filePath) const bool DesktopImporter::isGameDesktopFile(const QString &filePath) const
{ {
QSettings desktop(filePath, QSettings::IniFormat); KDesktopFile desktopFile(filePath);
desktop.beginGroup(QStringLiteral("Desktop Entry")); KConfigGroup desktop = desktopFile.desktopGroup();
QString type = desktop.value(QStringLiteral("Type")).toString(); const QString type = desktop.readEntry(QStringLiteral("Type"));
if (type != QStringLiteral("Application")) { if (type != QStringLiteral("Application")) {
return false; return false;
} }
// Check if hidden or not shown // Check if hidden or not shown
if (desktop.value(QStringLiteral("Hidden"), false).toBool()) { if (desktop.readEntry(QStringLiteral("Hidden"), false)) {
return false; return false;
} }
if (desktop.value(QStringLiteral("NoDisplay"), false).toBool()) { if (desktop.readEntry(QStringLiteral("NoDisplay"), false)) {
return false; return false;
} }
// Check categories for game-related entries // Check categories for game-related entries
QString categories = desktop.value(QStringLiteral("Categories")).toString(); const QStringList categories = desktop.readEntry(QStringLiteral("Categories")).split(QLatin1Char(';'), Qt::SkipEmptyParts);
QStringList gameCategories = {QStringLiteral("Game"),
QStringLiteral("ArcadeGame"),
QStringLiteral("ActionGame"),
QStringLiteral("AdventureGame"),
QStringLiteral("BlocksGame"),
QStringLiteral("BoardGame"),
QStringLiteral("CardGame"),
QStringLiteral("KidsGame"),
QStringLiteral("LogicGame"),
QStringLiteral("RolePlaying"),
QStringLiteral("Shooter"),
QStringLiteral("Simulation"),
QStringLiteral("SportsGame"),
QStringLiteral("StrategyGame")};
for (const QString &cat : gameCategories) { return hasGameCategory(categories);
if (categories.contains(cat, Qt::CaseInsensitive)) {
return true;
}
}
return false;
} }
Game *DesktopImporter::parseDesktopFile(const QString &filePath) const Game *DesktopImporter::parseDesktopFile(const QString &filePath) const
{ {
QSettings desktop(filePath, QSettings::IniFormat); KDesktopFile desktopFile(filePath);
desktop.beginGroup(QStringLiteral("Desktop Entry")); KConfigGroup desktop = desktopFile.desktopGroup();
QString name = desktop.value(QStringLiteral("Name")).toString(); const QString name = desktop.readEntry(QStringLiteral("Name"));
QString exec = desktop.value(QStringLiteral("Exec")).toString(); QString exec = desktop.readEntry(QStringLiteral("Exec"));
QString icon = desktop.value(QStringLiteral("Icon")).toString(); const QString icon = desktop.readEntry(QStringLiteral("Icon"));
QString comment = desktop.value(QStringLiteral("Comment")).toString(); const QString comment = desktop.readEntry(QStringLiteral("Comment"));
QString genericName = desktop.value(QStringLiteral("GenericName")).toString(); const QString genericName = desktop.readEntry(QStringLiteral("GenericName"));
if (name.isEmpty() || exec.isEmpty()) { if (name.isEmpty() || exec.isEmpty()) {
return nullptr; return nullptr;
@ -126,12 +113,13 @@ Game *DesktopImporter::parseDesktopFile(const QString &filePath) const
// Create unique ID from the desktop file name // Create unique ID from the desktop file name
QFileInfo fileInfo(filePath); QFileInfo fileInfo(filePath);
QString gameId = QStringLiteral("desktop_") + fileInfo.baseName(); const QString desktopId = fileInfo.completeBaseName();
QString gameId = QStringLiteral("desktop_") + desktopId;
Game *game = new Game(gameId, name); Game *game = new Game(gameId, name);
game->setLaunchCommand(exec); game->setLaunchCommand(exec);
game->setPlatform(platformName()); game->setPlatform(platformName());
game->setPlatformId(platformId()); game->setPlatformId(desktopId);
if (!comment.isEmpty()) { if (!comment.isEmpty()) {
game->setDescription(comment); game->setDescription(comment);

View file

@ -3,11 +3,12 @@
#include "flatpakimporter.h" #include "flatpakimporter.h"
#include <KConfigGroup>
#include <KDesktopFile>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QProcess> #include <QProcess>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSettings>
FlatpakImporter::FlatpakImporter(QObject *parent) FlatpakImporter::FlatpakImporter(QObject *parent)
: PlatformImporter(parent) : PlatformImporter(parent)
@ -43,54 +44,34 @@ bool FlatpakImporter::isAvailable() const
bool FlatpakImporter::isGameApp(const QString &desktopFilePath) const bool FlatpakImporter::isGameApp(const QString &desktopFilePath) const
{ {
QSettings desktop(desktopFilePath, QSettings::IniFormat); KDesktopFile desktopFile(desktopFilePath);
desktop.beginGroup(QStringLiteral("Desktop Entry")); KConfigGroup desktop = desktopFile.desktopGroup();
QString type = desktop.value(QStringLiteral("Type")).toString(); const QString type = desktop.readEntry(QStringLiteral("Type"));
if (type != QStringLiteral("Application")) { if (type != QStringLiteral("Application")) {
return false; return false;
} }
if (desktop.value(QStringLiteral("Hidden"), false).toBool()) { if (desktop.readEntry(QStringLiteral("Hidden"), false)) {
return false; return false;
} }
if (desktop.value(QStringLiteral("NoDisplay"), false).toBool()) { if (desktop.readEntry(QStringLiteral("NoDisplay"), false)) {
return false; return false;
} }
QString categories = desktop.value(QStringLiteral("Categories")).toString(); const QStringList categories = desktop.readEntry(QStringLiteral("Categories")).split(QLatin1Char(';'), Qt::SkipEmptyParts);
QStringList gameCategories = {QStringLiteral("Game"),
QStringLiteral("ArcadeGame"),
QStringLiteral("ActionGame"),
QStringLiteral("AdventureGame"),
QStringLiteral("BlocksGame"),
QStringLiteral("BoardGame"),
QStringLiteral("CardGame"),
QStringLiteral("KidsGame"),
QStringLiteral("LogicGame"),
QStringLiteral("RolePlaying"),
QStringLiteral("Shooter"),
QStringLiteral("Simulation"),
QStringLiteral("SportsGame"),
QStringLiteral("StrategyGame")};
for (const QString &cat : gameCategories) { return hasGameCategory(categories);
if (categories.contains(cat, Qt::CaseInsensitive)) {
return true;
}
}
return false;
} }
Game *FlatpakImporter::parseDesktopFile(const QString &filePath, const QString &appId) const Game *FlatpakImporter::parseDesktopFile(const QString &filePath, const QString &appId) const
{ {
QSettings desktop(filePath, QSettings::IniFormat); KDesktopFile desktopFile(filePath);
desktop.beginGroup(QStringLiteral("Desktop Entry")); KConfigGroup desktop = desktopFile.desktopGroup();
QString name = desktop.value(QStringLiteral("Name")).toString(); const QString name = desktop.readEntry(QStringLiteral("Name"));
QString icon = desktop.value(QStringLiteral("Icon")).toString(); const QString icon = desktop.readEntry(QStringLiteral("Icon"));
QString comment = desktop.value(QStringLiteral("Comment")).toString(); const QString comment = desktop.readEntry(QStringLiteral("Comment"));
if (name.isEmpty()) { if (name.isEmpty()) {
return nullptr; return nullptr;
@ -100,7 +81,7 @@ Game *FlatpakImporter::parseDesktopFile(const QString &filePath, const QString &
Game *game = new Game(gameId, name); Game *game = new Game(gameId, name);
game->setPlatform(platformName()); game->setPlatform(platformName());
game->setPlatformId(platformId()); game->setPlatformId(appId);
game->setLaunchCommand(QStringLiteral("flatpak run %1").arg(appId)); game->setLaunchCommand(QStringLiteral("flatpak run %1").arg(appId));
if (!comment.isEmpty()) { if (!comment.isEmpty()) {

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)
{ {
@ -22,8 +149,11 @@ GameLauncher::~GameLauncher()
process->disconnect(); process->disconnect();
process->terminate(); process->terminate();
process->waitForFinished(3000); process->waitForFinished(3000);
delete process; process->deleteLater();
} }
m_runningGames.clear();
m_processToGame.clear();
} }
bool GameLauncher::hasRunningGames() const bool GameLauncher::hasRunningGames() const
@ -31,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) {
@ -43,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"));
@ -50,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);
@ -58,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());
@ -69,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());
@ -81,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"));
@ -89,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;

View file

@ -201,20 +201,84 @@ void GameModel::addGame(Game *game)
// Check for duplicates // Check for duplicates
for (Game *existing : m_games) { for (Game *existing : m_games) {
if (existing->platform() == game->platform() && existing->platformId() == game->platformId()) { if (existing->id() == game->id()) {
delete game; if (!game->parent()) {
game->setParent(this);
}
game->deleteLater();
return; return;
} }
} }
game->setParent(this); game->setParent(this);
beginInsertRows(QModelIndex(), m_games.count(), m_games.count()); connect(
m_games.append(game); game,
endInsertRows(); &Game::nameChanged,
this,
[this]() {
applyFilter();
},
Qt::QueuedConnection);
connect(
game,
&Game::developerChanged,
this,
[this]() {
applyFilter();
},
Qt::QueuedConnection);
connect(
game,
&Game::publisherChanged,
this,
[this]() {
applyFilter();
},
Qt::QueuedConnection);
connect(
game,
&Game::favoriteChanged,
this,
[this]() {
applyFilter();
},
Qt::QueuedConnection);
connect(
game,
&Game::hiddenChanged,
this,
[this]() {
applyFilter();
},
Qt::QueuedConnection);
connect(
game,
&Game::platformChanged,
this,
[this]() {
applyFilter();
},
Qt::QueuedConnection);
connect(
game,
&Game::lastPlayedChanged,
this,
[this]() {
applyFilter();
},
Qt::QueuedConnection);
connect(
game,
&Game::playTimeChanged,
this,
[this]() {
applyFilter();
},
Qt::QueuedConnection);
m_games.append(game);
applyFilter(); applyFilter();
Q_EMIT countChanged();
} }
void GameModel::removeGame(const QString &id) void GameModel::removeGame(const QString &id)
@ -228,7 +292,10 @@ void GameModel::removeGame(const QString &id)
endRemoveRows(); endRemoveRows();
} }
delete m_games.takeAt(i); Game *game = m_games.takeAt(i);
if (game) {
game->deleteLater();
}
Q_EMIT countChanged(); Q_EMIT countChanged();
return; return;
} }
@ -256,7 +323,11 @@ Game *GameModel::gameById(const QString &id) const
void GameModel::clear() void GameModel::clear()
{ {
beginResetModel(); beginResetModel();
qDeleteAll(m_games); for (Game *game : m_games) {
if (game) {
game->deleteLater();
}
}
m_games.clear(); m_games.clear();
m_filteredGames.clear(); m_filteredGames.clear();
endResetModel(); endResetModel();
@ -275,6 +346,81 @@ QStringList GameModel::platforms() const
return result; return result;
} }
QList<Game *> GameModel::allGames() const
{
return m_games;
}
bool GameModel::hasPlatformPrefix(const QString &platformPrefix) const
{
if (platformPrefix.isEmpty()) {
return false;
}
for (Game *game : m_games) {
if (!game) {
continue;
}
if (game->platform().startsWith(platformPrefix)) {
return true;
}
}
return false;
}
int GameModel::removeByPlatformPrefix(const QString &platformPrefix)
{
if (platformPrefix.isEmpty()) {
return 0;
}
bool hasMatch = false;
for (Game *game : m_games) {
if (!game) {
continue;
}
if (game->platform().startsWith(platformPrefix)) {
hasMatch = true;
break;
}
}
if (!hasMatch) {
return 0;
}
beginResetModel();
int removed = 0;
for (int i = m_games.count() - 1; i >= 0; --i) {
Game *game = m_games.at(i);
if (!game) {
continue;
}
if (game->platform().startsWith(platformPrefix)) {
m_games.takeAt(i)->deleteLater();
removed++;
}
}
m_filteredGames.clear();
for (Game *game : m_games) {
if (matchesFilter(game)) {
m_filteredGames.append(game);
}
}
applySort();
endResetModel();
if (removed > 0) {
Q_EMIT countChanged();
}
return removed;
}
bool GameModel::matchesFilter(Game *game) const bool GameModel::matchesFilter(Game *game) const
{ {
if (!m_showHidden && game->hidden()) { if (!m_showHidden && game->hidden()) {

View file

@ -83,6 +83,10 @@ public:
Q_INVOKABLE Game *gameById(const QString &id) const; Q_INVOKABLE Game *gameById(const QString &id) const;
Q_INVOKABLE void clear(); Q_INVOKABLE void clear();
Q_INVOKABLE QStringList platforms() const; Q_INVOKABLE QStringList platforms() const;
Q_INVOKABLE bool hasPlatformPrefix(const QString &platformPrefix) const;
Q_INVOKABLE int removeByPlatformPrefix(const QString &platformPrefix);
QList<Game *> allGames() const;
Q_SIGNALS: Q_SIGNALS:
void countChanged(); void countChanged();

View file

@ -3,27 +3,111 @@
#include "gamepadmanager.h" #include "gamepadmanager.h"
#if defined(ALAKARTE_HAVE_QT_GAMEPAD) #include <QCoreApplication>
#include <QString>
#include <QGamepad> #include <memory>
#include <QGamepadManager>
#define SDL_MAIN_HANDLED 1
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
namespace
{
constexpr int PollIntervalMs = 16;
constexpr int RepeatIntervalMs = 150;
constexpr qint16 AxisThreshold = 16000;
QString buttonLabelToString(SDL_GamepadButtonLabel label)
{
switch (label) {
case SDL_GAMEPAD_BUTTON_LABEL_A:
return QStringLiteral("A");
case SDL_GAMEPAD_BUTTON_LABEL_B:
return QStringLiteral("B");
case SDL_GAMEPAD_BUTTON_LABEL_X:
return QStringLiteral("X");
case SDL_GAMEPAD_BUTTON_LABEL_Y:
return QStringLiteral("Y");
case SDL_GAMEPAD_BUTTON_LABEL_CROSS:
return QStringLiteral("Cross");
case SDL_GAMEPAD_BUTTON_LABEL_CIRCLE:
return QStringLiteral("Circle");
case SDL_GAMEPAD_BUTTON_LABEL_SQUARE:
return QStringLiteral("Square");
case SDL_GAMEPAD_BUTTON_LABEL_TRIANGLE:
return QStringLiteral("Triangle");
default:
return QString();
}
}
GamepadManager::ControllerStyle controllerStyleFromTypeVendor(SDL_GamepadType type, Uint16 vendor)
{
switch (type) {
case SDL_GAMEPAD_TYPE_XBOX360:
case SDL_GAMEPAD_TYPE_XBOXONE:
return GamepadManager::XboxController;
case SDL_GAMEPAD_TYPE_PS3:
case SDL_GAMEPAD_TYPE_PS4:
case SDL_GAMEPAD_TYPE_PS5:
return GamepadManager::PlayStationController;
case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO:
case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT:
case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT:
case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR:
return GamepadManager::NintendoController;
default:
break;
}
if (vendor == 0x045E) {
return GamepadManager::XboxController;
}
if (vendor == 0x054C) {
return GamepadManager::PlayStationController;
}
if (vendor == 0x057E) {
return GamepadManager::NintendoController;
}
if (type == SDL_GAMEPAD_TYPE_STANDARD) {
return GamepadManager::GenericController;
}
return GamepadManager::UnknownController;
}
}
GamepadManager *GamepadManager::s_instance = nullptr; GamepadManager *GamepadManager::s_instance = nullptr;
GamepadManager::GamepadManager(QObject *parent) GamepadManager::GamepadManager(QObject *parent)
: QObject(parent) : QObject(parent)
{ {
auto *manager = QGamepadManager::instance(); SDL_SetMainReady();
connect(manager, &QGamepadManager::gamepadConnected, this, &GamepadManager::onGamepadConnected); m_sdlInitialized = SDL_Init(SDL_INIT_GAMEPAD);
connect(manager, &QGamepadManager::gamepadDisconnected, this, &GamepadManager::onGamepadDisconnected);
m_repeatTimer.setInterval(150); connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]() {
m_pollTimer.stop();
m_repeatTimer.stop();
closeGamepad();
if (m_sdlInitialized) {
SDL_Quit();
m_sdlInitialized = false;
}
});
m_pollTimer.setInterval(PollIntervalMs);
connect(&m_pollTimer, &QTimer::timeout, this, &GamepadManager::pollEvents);
m_repeatTimer.setInterval(RepeatIntervalMs);
connect(&m_repeatTimer, &QTimer::timeout, this, &GamepadManager::processInput); connect(&m_repeatTimer, &QTimer::timeout, this, &GamepadManager::processInput);
const auto gamepads = manager->connectedGamepads(); if (m_sdlInitialized) {
if (!gamepads.isEmpty()) { SDL_SetGamepadEventsEnabled(true);
setupGamepad(gamepads.first()); openFirstAvailableGamepad();
m_pollTimer.start();
} }
} }
@ -52,6 +136,31 @@ bool GamepadManager::active() const
return m_active; return m_active;
} }
GamepadManager::ControllerStyle GamepadManager::controllerStyle() const
{
return m_controllerStyle;
}
QString GamepadManager::confirmButtonLabel() const
{
return m_confirmButtonLabel;
}
QString GamepadManager::backButtonLabel() const
{
return m_backButtonLabel;
}
QString GamepadManager::detailsButtonLabel() const
{
return m_detailsButtonLabel;
}
QString GamepadManager::searchButtonLabel() const
{
return m_searchButtonLabel;
}
void GamepadManager::setActive(bool active) void GamepadManager::setActive(bool active)
{ {
if (m_active != active) { if (m_active != active) {
@ -60,173 +169,279 @@ void GamepadManager::setActive(bool active)
} }
} }
void GamepadManager::onGamepadConnected(int deviceId) void GamepadManager::openFirstAvailableGamepad()
{ {
if (!m_gamepad) { if (!m_sdlInitialized || m_gamepad) {
setupGamepad(deviceId); return;
}
int count = 0;
std::unique_ptr<SDL_JoystickID[], decltype(&SDL_free)> ids(SDL_GetGamepads(&count), SDL_free);
if (!ids || count <= 0) {
return;
}
const SDL_JoystickID instanceId = ids[0];
if (SDL_IsGamepad(instanceId)) {
openGamepad(static_cast<int>(instanceId));
} }
} }
void GamepadManager::onGamepadDisconnected(int deviceId) void GamepadManager::openGamepad(int deviceId)
{ {
if (m_gamepad && m_gamepad->deviceId() == deviceId) { if (!m_sdlInitialized || m_gamepad) {
m_gamepad->deleteLater(); return;
m_gamepad = nullptr;
m_connected = false;
m_repeatTimer.stop();
Q_EMIT connectedChanged();
const auto gamepads = QGamepadManager::instance()->connectedGamepads();
if (!gamepads.isEmpty()) {
setupGamepad(gamepads.first());
}
} }
}
void GamepadManager::setupGamepad(int deviceId) SDL_Gamepad *pad = SDL_OpenGamepad(static_cast<SDL_JoystickID>(deviceId));
{ if (!pad) {
m_gamepad = new QGamepad(deviceId, this); return;
}
m_gamepad = pad;
m_gamepadId = deviceId;
m_connected = true; m_connected = true;
Q_EMIT connectedChanged(); Q_EMIT connectedChanged();
updateControllerInfo();
}
void GamepadManager::closeGamepad()
{
if (!m_gamepad) {
return;
}
SDL_CloseGamepad(m_gamepad);
m_gamepad = nullptr;
m_gamepadId = 0;
m_connected = false;
setActive(false);
m_leftX = 0;
m_leftY = 0;
m_leftDirX = 0;
m_leftDirY = 0;
m_repeatTimer.stop();
Q_EMIT connectedChanged();
updateControllerInfo();
}
void GamepadManager::updateControllerInfo()
{
ControllerStyle style = UnknownController;
QString confirmLabel;
QString backLabel;
QString detailsLabel;
QString searchLabel;
if (m_gamepad) {
const SDL_GamepadType type = SDL_GetGamepadType(m_gamepad);
const Uint16 vendor = SDL_GetGamepadVendor(m_gamepad);
style = controllerStyleFromTypeVendor(type, vendor);
#if SDL_VERSION_ATLEAST(3, 2, 0)
confirmLabel = buttonLabelToString(SDL_GetGamepadButtonLabel(m_gamepad, SDL_GAMEPAD_BUTTON_SOUTH));
backLabel = buttonLabelToString(SDL_GetGamepadButtonLabel(m_gamepad, SDL_GAMEPAD_BUTTON_EAST));
detailsLabel = buttonLabelToString(SDL_GetGamepadButtonLabel(m_gamepad, SDL_GAMEPAD_BUTTON_WEST));
searchLabel = buttonLabelToString(SDL_GetGamepadButtonLabel(m_gamepad, SDL_GAMEPAD_BUTTON_NORTH));
#endif
if (confirmLabel.isEmpty() || backLabel.isEmpty() || detailsLabel.isEmpty() || searchLabel.isEmpty()) {
if (style == PlayStationController) {
if (confirmLabel.isEmpty()) {
confirmLabel = QStringLiteral("Cross");
}
if (backLabel.isEmpty()) {
backLabel = QStringLiteral("Circle");
}
if (detailsLabel.isEmpty()) {
detailsLabel = QStringLiteral("Square");
}
if (searchLabel.isEmpty()) {
searchLabel = QStringLiteral("Triangle");
}
} else if (style == NintendoController) {
if (confirmLabel.isEmpty()) {
confirmLabel = QStringLiteral("B");
}
if (backLabel.isEmpty()) {
backLabel = QStringLiteral("A");
}
if (detailsLabel.isEmpty()) {
detailsLabel = QStringLiteral("Y");
}
if (searchLabel.isEmpty()) {
searchLabel = QStringLiteral("X");
}
} else {
if (confirmLabel.isEmpty()) {
confirmLabel = QStringLiteral("A");
}
if (backLabel.isEmpty()) {
backLabel = QStringLiteral("B");
}
if (detailsLabel.isEmpty()) {
detailsLabel = QStringLiteral("X");
}
if (searchLabel.isEmpty()) {
searchLabel = QStringLiteral("Y");
}
}
}
}
const bool changed = (style != m_controllerStyle) || (confirmLabel != m_confirmButtonLabel) || (backLabel != m_backButtonLabel)
|| (detailsLabel != m_detailsButtonLabel) || (searchLabel != m_searchButtonLabel);
m_controllerStyle = style;
m_confirmButtonLabel = confirmLabel;
m_backButtonLabel = backLabel;
m_detailsButtonLabel = detailsLabel;
m_searchButtonLabel = searchLabel;
if (changed) {
Q_EMIT controllerInfoChanged();
}
}
void GamepadManager::pollEvents()
{
if (!m_sdlInitialized) {
return;
}
SDL_Event e;
while (SDL_PollEvent(&e)) {
switch (e.type) {
case SDL_EVENT_GAMEPAD_ADDED: {
if (!m_gamepad) {
openGamepad(static_cast<int>(e.gdevice.which));
}
break;
}
case SDL_EVENT_GAMEPAD_REMOVED: {
if (m_gamepad && static_cast<int>(e.gdevice.which) == m_gamepadId) {
closeGamepad();
openFirstAvailableGamepad();
}
break;
}
case SDL_EVENT_GAMEPAD_AXIS_MOTION: {
if (!m_gamepad || static_cast<int>(e.gaxis.which) != m_gamepadId) {
break;
}
if (e.gaxis.axis == SDL_GAMEPAD_AXIS_LEFTX) {
m_leftX = e.gaxis.value;
} else if (e.gaxis.axis == SDL_GAMEPAD_AXIS_LEFTY) {
m_leftY = e.gaxis.value;
} else {
break;
}
const qint8 dirX = (m_leftX < -AxisThreshold) ? -1 : (m_leftX > AxisThreshold ? 1 : 0);
const qint8 dirY = (m_leftY < -AxisThreshold) ? -1 : (m_leftY > AxisThreshold ? 1 : 0);
if (dirX != 0 || dirY != 0) {
setActive(true);
}
if (dirY != m_leftDirY) {
m_leftDirY = dirY;
if (dirY < 0) {
Q_EMIT navigateUp();
} else if (dirY > 0) {
Q_EMIT navigateDown();
}
}
if (dirX != m_leftDirX) {
m_leftDirX = dirX;
if (dirX < 0) {
Q_EMIT navigateLeft();
} else if (dirX > 0) {
Q_EMIT navigateRight();
}
}
if (m_leftDirX != 0 || m_leftDirY != 0) {
if (!m_repeatTimer.isActive()) {
m_repeatTimer.start();
}
} else {
m_repeatTimer.stop();
}
break;
}
case SDL_EVENT_GAMEPAD_BUTTON_DOWN: {
if (!m_gamepad || static_cast<int>(e.gbutton.which) != m_gamepadId) {
break;
}
connect(m_gamepad, &QGamepad::axisLeftYChanged, this, [this](double value) {
if (qAbs(value) > 0.5) {
setActive(true); setActive(true);
if (value < -0.5) {
switch (static_cast<SDL_GamepadButton>(e.gbutton.button)) {
case SDL_GAMEPAD_BUTTON_SOUTH:
Q_EMIT selectPressed();
break;
case SDL_GAMEPAD_BUTTON_EAST:
case SDL_GAMEPAD_BUTTON_BACK:
Q_EMIT backPressed();
break;
case SDL_GAMEPAD_BUTTON_WEST:
Q_EMIT detailsPressed();
break;
case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER:
Q_EMIT leftBumperPressed();
break;
case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER:
Q_EMIT rightBumperPressed();
break;
case SDL_GAMEPAD_BUTTON_START:
Q_EMIT menuPressed();
break;
case SDL_GAMEPAD_BUTTON_NORTH:
Q_EMIT searchPressed();
break;
case SDL_GAMEPAD_BUTTON_DPAD_UP:
Q_EMIT navigateUp(); Q_EMIT navigateUp();
} else { break;
case SDL_GAMEPAD_BUTTON_DPAD_DOWN:
Q_EMIT navigateDown(); Q_EMIT navigateDown();
} break;
m_repeatTimer.start(); case SDL_GAMEPAD_BUTTON_DPAD_LEFT:
} else {
m_repeatTimer.stop();
}
});
connect(m_gamepad, &QGamepad::axisLeftXChanged, this, [this](double value) {
if (qAbs(value) > 0.5) {
setActive(true);
if (value < -0.5) {
Q_EMIT navigateLeft(); Q_EMIT navigateLeft();
} else { break;
case SDL_GAMEPAD_BUTTON_DPAD_RIGHT:
Q_EMIT navigateRight(); Q_EMIT navigateRight();
break;
default:
break;
} }
m_repeatTimer.start(); break;
} else {
m_repeatTimer.stop();
} }
}); default:
break;
connect(m_gamepad, &QGamepad::buttonAChanged, this, [this](bool pressed) {
if (pressed) {
setActive(true);
Q_EMIT selectPressed();
} }
}); }
connect(m_gamepad, &QGamepad::buttonBChanged, this, [this](bool pressed) {
if (pressed) {
setActive(true);
Q_EMIT backPressed();
}
});
connect(m_gamepad, &QGamepad::buttonStartChanged, this, [this](bool pressed) {
if (pressed) {
setActive(true);
Q_EMIT menuPressed();
}
});
connect(m_gamepad, &QGamepad::buttonYChanged, this, [this](bool pressed) {
if (pressed) {
setActive(true);
Q_EMIT searchPressed();
}
});
connect(m_gamepad, &QGamepad::buttonUpChanged, this, [this](bool pressed) {
if (pressed) {
setActive(true);
Q_EMIT navigateUp();
}
});
connect(m_gamepad, &QGamepad::buttonDownChanged, this, [this](bool pressed) {
if (pressed) {
setActive(true);
Q_EMIT navigateDown();
}
});
connect(m_gamepad, &QGamepad::buttonLeftChanged, this, [this](bool pressed) {
if (pressed) {
setActive(true);
Q_EMIT navigateLeft();
}
});
connect(m_gamepad, &QGamepad::buttonRightChanged, this, [this](bool pressed) {
if (pressed) {
setActive(true);
Q_EMIT navigateRight();
}
});
} }
void GamepadManager::processInput() void GamepadManager::processInput()
{ {
if (!m_gamepad) if (!m_gamepad) {
return; return;
}
double y = m_gamepad->axisLeftY(); if (m_leftY < -AxisThreshold) {
double x = m_gamepad->axisLeftX();
if (y < -0.5) {
Q_EMIT navigateUp(); Q_EMIT navigateUp();
} else if (y > 0.5) { } else if (m_leftY > AxisThreshold) {
Q_EMIT navigateDown(); Q_EMIT navigateDown();
} }
if (x < -0.5) { if (m_leftX < -AxisThreshold) {
Q_EMIT navigateLeft(); Q_EMIT navigateLeft();
} else if (x > 0.5) { } else if (m_leftX > AxisThreshold) {
Q_EMIT navigateRight(); Q_EMIT navigateRight();
} }
} }
#else
GamepadManager *GamepadManager::s_instance = nullptr;
GamepadManager::GamepadManager(QObject *parent)
: QObject(parent)
{
}
GamepadManager *GamepadManager::instance()
{
if (!s_instance) {
s_instance = new GamepadManager();
}
return s_instance;
}
GamepadManager *GamepadManager::create(QQmlEngine *engine, QJSEngine *scriptEngine)
{
Q_UNUSED(engine)
Q_UNUSED(scriptEngine)
return instance();
}
bool GamepadManager::connected() const
{
return false;
}
bool GamepadManager::active() const
{
return false;
}
#endif

View file

@ -5,10 +5,10 @@
#include <QObject> #include <QObject>
#include <QQmlEngine> #include <QQmlEngine>
#include <QString>
#include <QTimer> #include <QTimer>
class QGamepad; struct SDL_Gamepad;
class QGamepadManager;
class GamepadManager : public QObject class GamepadManager : public QObject
{ {
@ -18,17 +18,38 @@ class GamepadManager : public QObject
Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged)
Q_PROPERTY(bool active READ active NOTIFY activeChanged) Q_PROPERTY(bool active READ active NOTIFY activeChanged)
Q_PROPERTY(ControllerStyle controllerStyle READ controllerStyle NOTIFY controllerInfoChanged)
Q_PROPERTY(QString confirmButtonLabel READ confirmButtonLabel NOTIFY controllerInfoChanged)
Q_PROPERTY(QString backButtonLabel READ backButtonLabel NOTIFY controllerInfoChanged)
Q_PROPERTY(QString detailsButtonLabel READ detailsButtonLabel NOTIFY controllerInfoChanged)
Q_PROPERTY(QString searchButtonLabel READ searchButtonLabel NOTIFY controllerInfoChanged)
public: public:
enum ControllerStyle {
UnknownController = 0,
XboxController,
PlayStationController,
NintendoController,
GenericController,
};
Q_ENUM(ControllerStyle)
static GamepadManager *instance(); static GamepadManager *instance();
static GamepadManager *create(QQmlEngine *engine, QJSEngine *scriptEngine); static GamepadManager *create(QQmlEngine *engine, QJSEngine *scriptEngine);
bool connected() const; bool connected() const;
bool active() const; bool active() const;
ControllerStyle controllerStyle() const;
QString confirmButtonLabel() const;
QString backButtonLabel() const;
QString detailsButtonLabel() const;
QString searchButtonLabel() const;
Q_SIGNALS: Q_SIGNALS:
void connectedChanged(); void connectedChanged();
void activeChanged(); void activeChanged();
void controllerInfoChanged();
void navigateUp(); void navigateUp();
void navigateDown(); void navigateDown();
@ -36,22 +57,40 @@ Q_SIGNALS:
void navigateRight(); void navigateRight();
void selectPressed(); void selectPressed();
void backPressed(); void backPressed();
void detailsPressed();
void menuPressed(); void menuPressed();
void searchPressed(); void searchPressed();
void leftBumperPressed();
void rightBumperPressed();
private: private:
explicit GamepadManager(QObject *parent = nullptr); explicit GamepadManager(QObject *parent = nullptr);
static GamepadManager *s_instance; static GamepadManager *s_instance;
QGamepad *m_gamepad = nullptr; SDL_Gamepad *m_gamepad = nullptr;
int m_gamepadId = 0;
bool m_connected = false; bool m_connected = false;
bool m_active = false; bool m_active = false;
bool m_sdlInitialized = false;
ControllerStyle m_controllerStyle = UnknownController;
QString m_confirmButtonLabel;
QString m_backButtonLabel;
QString m_detailsButtonLabel;
QString m_searchButtonLabel;
QTimer m_pollTimer;
QTimer m_repeatTimer; QTimer m_repeatTimer;
qint16 m_leftX = 0;
qint16 m_leftY = 0;
qint8 m_leftDirX = 0;
qint8 m_leftDirY = 0;
void onGamepadConnected(int deviceId); void openFirstAvailableGamepad();
void onGamepadDisconnected(int deviceId); void openGamepad(int deviceId);
void setupGamepad(int deviceId); void closeGamepad();
void updateControllerInfo();
void pollEvents();
void processInput(); void processInput();
void setActive(bool active); void setActive(bool active);
}; };

View file

@ -10,6 +10,22 @@ GameSortFilterModel::GameSortFilterModel(QObject *parent)
{ {
setDynamicSortFilter(true); setDynamicSortFilter(true);
sort(0); sort(0);
connect(this, &QAbstractItemModel::rowsInserted, this, [this](const QModelIndex &, int, int) {
Q_EMIT countChanged();
});
connect(this, &QAbstractItemModel::rowsRemoved, this, [this](const QModelIndex &, int, int) {
Q_EMIT countChanged();
});
connect(this, &QAbstractItemModel::rowsMoved, this, [this](const QModelIndex &, int, int, const QModelIndex &, int) {
Q_EMIT countChanged();
});
connect(this, &QAbstractItemModel::modelReset, this, [this] {
Q_EMIT countChanged();
});
connect(this, &QAbstractItemModel::layoutChanged, this, [this](const QList<QPersistentModelIndex> &, QAbstractItemModel::LayoutChangeHint) {
Q_EMIT countChanged();
});
} }
QString GameSortFilterModel::filterText() const QString GameSortFilterModel::filterText() const
@ -169,6 +185,7 @@ bool GameSortFilterModel::lessThan(const QModelIndex &left, const QModelIndex &r
void GameSortFilterModel::invalidateAndEmit() void GameSortFilterModel::invalidateAndEmit()
{ {
invalidateFilter(); beginFilterChange();
endFilterChange();
Q_EMIT countChanged(); Q_EMIT countChanged();
} }

View file

@ -3,6 +3,7 @@
#include "heroicimporter.h" #include "heroicimporter.h"
#include <QCryptographicHash>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QJsonArray> #include <QJsonArray>
@ -27,7 +28,7 @@ QString HeroicImporter::platformId() const
bool HeroicImporter::isAvailable() const bool HeroicImporter::isAvailable() const
{ {
return directoryExists(findHeroicConfigPath()); return directoryExists(findHeroicConfigPath()) || directoryExists(findLegendaryConfigPath());
} }
QString HeroicImporter::findHeroicConfigPath() const QString HeroicImporter::findHeroicConfigPath() const
@ -44,17 +45,158 @@ QString HeroicImporter::findHeroicConfigPath() const
return {}; return {};
} }
QString HeroicImporter::findLegendaryConfigPath() const
{
QStringList possiblePaths = {expandPath(QStringLiteral("~/.config/legendary")),
expandPath(QStringLiteral("~/.var/app/com.heroicgameslauncher.hgl/config/legendary"))};
for (const QString &path : possiblePaths) {
if (directoryExists(path)) {
return path;
}
}
return {};
}
QString HeroicImporter::launchCommandForApp(const QString &appName) const
{
if (!findExecutable(QStringLiteral("heroic")).isEmpty()) {
return QStringLiteral("heroic --launch %1").arg(appName);
}
if (!findExecutable(QStringLiteral("flatpak")).isEmpty()) {
return QStringLiteral("flatpak run com.heroicgameslauncher.hgl --launch %1").arg(appName);
}
return QStringLiteral("heroic --launch %1").arg(appName);
}
QUrl HeroicImporter::extractCoverUrl(const QJsonObject &gameObj) const
{
static const QStringList stringKeys = {
QStringLiteral("coverUrl"),
QStringLiteral("cover_url"),
QStringLiteral("cover"),
QStringLiteral("image"),
QStringLiteral("imageUrl"),
QStringLiteral("image_url"),
QStringLiteral("art_cover"),
QStringLiteral("artCover"),
QStringLiteral("art_square"),
QStringLiteral("artSquare"),
QStringLiteral("thumbnail"),
};
for (const QString &key : stringKeys) {
const QString urlString = gameObj.value(key).toString();
if (!urlString.isEmpty()) {
const QUrl url(urlString);
if (url.isValid()) {
return url;
}
}
}
const QJsonValue keyImagesValue = gameObj.value(QStringLiteral("keyImages"));
if (keyImagesValue.isArray()) {
const QJsonArray keyImages = keyImagesValue.toArray();
for (const QJsonValue &imgValue : keyImages) {
const QJsonObject imgObj = imgValue.toObject();
const QString type = imgObj.value(QStringLiteral("type")).toString();
if (type == QStringLiteral("DieselGameBoxTall") || type == QStringLiteral("Thumbnail") || type == QStringLiteral("Poster")) {
const QString urlString = imgObj.value(QStringLiteral("url")).toString();
if (!urlString.isEmpty()) {
const QUrl url(urlString);
if (url.isValid()) {
return url;
}
}
}
}
}
const QJsonValue imagesValue = gameObj.value(QStringLiteral("images"));
if (imagesValue.isArray()) {
const QJsonArray images = imagesValue.toArray();
for (const QJsonValue &imgValue : images) {
const QJsonObject imgObj = imgValue.toObject();
const QString urlString = imgObj.value(QStringLiteral("url")).toString();
if (!urlString.isEmpty()) {
const QUrl url(urlString);
if (url.isValid()) {
return url;
}
}
}
}
return {};
}
QUrl HeroicImporter::preferCachedCoverUrl(const QUrl &coverUrl) const
{
if (!coverUrl.isValid()) {
return {};
}
if (coverUrl.isLocalFile() || coverUrl.scheme() == QLatin1String("file")) {
return coverUrl;
}
QString urlString = coverUrl.toString();
// Heroic uses an imagecache:// scheme which percent-encodes the real image URL.
if (coverUrl.scheme() == QLatin1String("imagecache")) {
const QString prefix = QStringLiteral("imagecache://");
if (urlString.startsWith(prefix)) {
urlString = QUrl::fromPercentEncoding(urlString.mid(prefix.size()).toUtf8());
}
}
const QUrl realUrl(urlString);
if (!realUrl.isValid()) {
return coverUrl;
}
if (realUrl.scheme() != QLatin1String("http") && realUrl.scheme() != QLatin1String("https")) {
return realUrl;
}
const QString configPath = findHeroicConfigPath();
if (configPath.isEmpty()) {
return realUrl;
}
const QString cacheDir = configPath + QStringLiteral("/images-cache");
if (!QDir(cacheDir).exists()) {
return realUrl;
}
// Upstream Heroic stores cached images as sha256(url) (no extension) inside images-cache.
const QByteArray digest = QCryptographicHash::hash(realUrl.toString().toUtf8(), QCryptographicHash::Sha256).toHex();
const QString cachedPath = cacheDir + QStringLiteral("/") + QString::fromLatin1(digest);
if (QFile::exists(cachedPath)) {
return QUrl::fromLocalFile(cachedPath);
}
return realUrl;
}
QUrl HeroicImporter::findCoverImage(const QString &appName, const QString &store) const QUrl HeroicImporter::findCoverImage(const QString &appName, const QString &store) const
{ {
QString configPath = findHeroicConfigPath(); QString configPath = findHeroicConfigPath();
if (configPath.isEmpty()) { if (configPath.isEmpty()) {
return {}; configPath = findLegendaryConfigPath();
} }
// Check images cache // Check images cache
QStringList imageDirs = {configPath + QStringLiteral("/images-cache"), configPath + QStringLiteral("/images")}; QStringList imageDirs = {configPath + QStringLiteral("/images-cache"), configPath + QStringLiteral("/images")};
if (!store.isEmpty()) {
const QString storeLower = store.toLower();
imageDirs.append(configPath + QStringLiteral("/images-cache/") + storeLower);
imageDirs.append(configPath + QStringLiteral("/images/") + storeLower);
}
QStringList extensions = {QStringLiteral(".jpg"), QStringLiteral(".png"), QStringLiteral(".webp")}; QStringList extensions = {QStringLiteral(".jpg"), QStringLiteral(".jpeg"), QStringLiteral(".png"), QStringLiteral(".webp")};
QStringList prefixes = {appName, appName + QStringLiteral("_cover"), appName + QStringLiteral("_library")}; QStringList prefixes = {appName, appName + QStringLiteral("_cover"), appName + QStringLiteral("_library")};
for (const QString &imageDir : imageDirs) { for (const QString &imageDir : imageDirs) {
@ -71,6 +213,16 @@ QUrl HeroicImporter::findCoverImage(const QString &appName, const QString &store
} }
} }
QStringList wildcardFilters;
wildcardFilters.reserve(extensions.count());
for (const QString &ext : extensions) {
wildcardFilters.append(QStringLiteral("*") + appName + QStringLiteral("*") + ext);
}
const QStringList wildcardMatches = dir.entryList(wildcardFilters, QDir::Files);
if (!wildcardMatches.isEmpty()) {
return QUrl::fromLocalFile(imageDir + QStringLiteral("/") + wildcardMatches.first());
}
// Also try looking in subdirectories // Also try looking in subdirectories
QStringList subDirs = dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); QStringList subDirs = dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
for (const QString &subDir : subDirs) { for (const QString &subDir : subDirs) {
@ -82,6 +234,14 @@ QUrl HeroicImporter::findCoverImage(const QString &appName, const QString &store
} }
} }
} }
QDir sub(imageDir + QStringLiteral("/") + subDir);
if (sub.exists()) {
const QStringList subMatches = sub.entryList(wildcardFilters, QDir::Files);
if (!subMatches.isEmpty()) {
return QUrl::fromLocalFile(sub.absoluteFilePath(subMatches.first()));
}
}
} }
} }
@ -95,6 +255,179 @@ QList<Game *> HeroicImporter::importGames()
games.append(importEpicGames()); games.append(importEpicGames());
games.append(importGOGGames()); games.append(importGOGGames());
games.append(importAmazonGames()); games.append(importAmazonGames());
games.append(importSideloadApps());
return games;
}
QList<Game *> HeroicImporter::importSideloadApps()
{
QList<Game *> games;
const QString configPath = findHeroicConfigPath();
if (configPath.isEmpty()) {
return games;
}
const QString libraryPath = configPath + QStringLiteral("/sideload_apps/library.json");
QFile libraryFile(libraryPath);
if (!libraryFile.open(QIODevice::ReadOnly)) {
return games;
}
const QJsonDocument doc = QJsonDocument::fromJson(libraryFile.readAll());
QList<QJsonObject> entries;
if (doc.isArray()) {
const QJsonArray arr = doc.array();
entries.reserve(arr.size());
for (const QJsonValue &value : arr) {
if (value.isObject()) {
entries.append(value.toObject());
}
}
} else if (doc.isObject()) {
const QJsonObject root = doc.object();
const QStringList arrayKeys = {
QStringLiteral("games"),
QStringLiteral("apps"),
QStringLiteral("library"),
QStringLiteral("items"),
};
bool foundArray = false;
for (const QString &key : arrayKeys) {
const QJsonValue v = root.value(key);
if (v.isArray()) {
const QJsonArray arr = v.toArray();
entries.reserve(arr.size());
for (const QJsonValue &item : arr) {
if (item.isObject()) {
entries.append(item.toObject());
}
}
foundArray = true;
break;
}
}
if (!foundArray) {
// Some formats may store entries keyed by appName.
for (auto it = root.constBegin(); it != root.constEnd(); ++it) {
if (it.value().isObject()) {
QJsonObject obj = it.value().toObject();
if (!obj.contains(QStringLiteral("app_name")) && !obj.contains(QStringLiteral("appName"))) {
obj.insert(QStringLiteral("app_name"), it.key());
}
entries.append(obj);
}
}
}
} else {
return games;
}
for (const QJsonObject &obj : entries) {
QString appName = obj.value(QStringLiteral("app_name")).toString();
if (appName.isEmpty()) {
appName = obj.value(QStringLiteral("appName")).toString();
}
if (appName.isEmpty()) {
appName = obj.value(QStringLiteral("id")).toString();
}
QString title = obj.value(QStringLiteral("title")).toString();
if (title.isEmpty()) {
title = obj.value(QStringLiteral("name")).toString();
}
if (appName.isEmpty() || title.isEmpty()) {
continue;
}
QString launchCommand = obj.value(QStringLiteral("launchCommand")).toString();
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("launch_command")).toString();
}
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("command")).toString();
}
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("exec")).toString();
}
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("exe")).toString();
}
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("executable")).toString();
}
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("selectedExe")).toString();
}
QString workingDirectory = obj.value(QStringLiteral("workingDirectory")).toString();
if (workingDirectory.isEmpty()) {
workingDirectory = obj.value(QStringLiteral("install_path")).toString();
}
if (workingDirectory.isEmpty()) {
workingDirectory = obj.value(QStringLiteral("installPath")).toString();
}
bool isInstalled = obj.value(QStringLiteral("installed")).toBool(true);
if (obj.contains(QStringLiteral("is_installed"))) {
isInstalled = obj.value(QStringLiteral("is_installed")).toBool();
}
if (obj.contains(QStringLiteral("isInstalled"))) {
isInstalled = obj.value(QStringLiteral("isInstalled")).toBool();
}
Game *game = new Game(QStringLiteral("heroic-sideload-%1").arg(appName), title);
game->setPlatform(QStringLiteral("Heroic (Sideload)"));
game->setPlatformId(appName);
if (!workingDirectory.isEmpty()) {
game->setWorkingDirectory(workingDirectory);
}
game->setInstalled(isInstalled);
// We don't know if Heroic's CLI can always launch sideload apps consistently.
// Prefer an explicit command from the library file; fallback to Heroic's CLI if absent.
if (!launchCommand.isEmpty()) {
game->setLaunchCommand(launchCommand);
} else {
game->setLaunchCommand(launchCommandForApp(appName));
}
QString cover = obj.value(QStringLiteral("art_square")).toString();
if (cover.isEmpty()) {
cover = obj.value(QStringLiteral("artSquare")).toString();
}
if (cover.isEmpty()) {
cover = obj.value(QStringLiteral("art_cover")).toString();
}
if (cover.isEmpty()) {
cover = obj.value(QStringLiteral("artCover")).toString();
}
QUrl coverUrl;
if (!cover.isEmpty()) {
if (cover.startsWith(QLatin1String("/"))) {
coverUrl = QUrl::fromLocalFile(cover);
} else {
coverUrl = QUrl(cover);
}
} else {
coverUrl = extractCoverUrl(obj);
}
coverUrl = preferCachedCoverUrl(coverUrl);
if (!coverUrl.isValid()) {
coverUrl = findCoverImage(appName, QStringLiteral("sideload"));
}
if (coverUrl.isValid()) {
game->setCoverUrl(coverUrl);
}
games.append(game);
}
return games; return games;
} }
@ -103,24 +436,37 @@ QList<Game *> HeroicImporter::importEpicGames()
{ {
QList<Game *> games; QList<Game *> games;
QString configPath = findHeroicConfigPath(); QString configPath = findHeroicConfigPath();
QString legendaryConfigPath = findLegendaryConfigPath();
if (configPath.isEmpty()) { if (configPath.isEmpty() && legendaryConfigPath.isEmpty()) {
return games; return games;
} }
// Check for installed.json // Check for installed.json
QString installedPath = configPath + QStringLiteral("/legendaryConfig/legendary/installed.json"); QStringList installedPaths;
QFile installedFile(installedPath); if (!legendaryConfigPath.isEmpty()) {
installedPaths.append(legendaryConfigPath + QStringLiteral("/installed.json"));
}
if (!configPath.isEmpty()) {
installedPaths.append(configPath + QStringLiteral("/legendaryConfig/legendary/installed.json"));
installedPaths.append(configPath + QStringLiteral("/store_cache/legendary_library"));
installedPaths.append(configPath + QStringLiteral("/store_cache/legendary_library.json"));
installedPaths.append(configPath + QStringLiteral("/store/legendary_library"));
installedPaths.append(configPath + QStringLiteral("/store/legendary_library.json"));
}
if (!installedFile.open(QIODevice::ReadOnly)) { QFile installedFile;
// Try alternative path for (const QString &path : installedPaths) {
installedPath = configPath + QStringLiteral("/store_cache/legendary_library.json"); installedFile.setFileName(path);
installedFile.setFileName(installedPath); if (installedFile.open(QIODevice::ReadOnly)) {
if (!installedFile.open(QIODevice::ReadOnly)) { break;
return games;
} }
} }
if (!installedFile.isOpen()) {
return games;
}
QJsonDocument doc = QJsonDocument::fromJson(installedFile.readAll()); QJsonDocument doc = QJsonDocument::fromJson(installedFile.readAll());
if (doc.isObject()) { if (doc.isObject()) {
@ -142,7 +488,7 @@ QList<Game *> HeroicImporter::importEpicGames()
Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title); Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title);
game->setPlatform(QStringLiteral("Heroic (Epic)")); game->setPlatform(QStringLiteral("Heroic (Epic)"));
game->setPlatformId(appName); game->setPlatformId(appName);
game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName)); game->setLaunchCommand(launchCommandForApp(appName));
game->setWorkingDirectory(installPath); game->setWorkingDirectory(installPath);
game->setInstalled(isInstalled); game->setInstalled(isInstalled);
@ -152,7 +498,10 @@ QList<Game *> HeroicImporter::importEpicGames()
game->setDeveloper(developer); game->setDeveloper(developer);
game->setPublisher(publisher); game->setPublisher(publisher);
QUrl coverUrl = findCoverImage(appName, QStringLiteral("epic")); QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj));
if (!coverUrl.isValid()) {
coverUrl = findCoverImage(appName, QStringLiteral("epic"));
}
if (coverUrl.isValid()) { if (coverUrl.isValid()) {
game->setCoverUrl(coverUrl); game->setCoverUrl(coverUrl);
} }
@ -180,10 +529,13 @@ QList<Game *> HeroicImporter::importEpicGames()
Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title); Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title);
game->setPlatform(QStringLiteral("Heroic (Epic)")); game->setPlatform(QStringLiteral("Heroic (Epic)"));
game->setPlatformId(appName); game->setPlatformId(appName);
game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName)); game->setLaunchCommand(launchCommandForApp(appName));
game->setInstalled(isInstalled); game->setInstalled(isInstalled);
QUrl coverUrl = findCoverImage(appName, QStringLiteral("epic")); QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj));
if (!coverUrl.isValid()) {
coverUrl = findCoverImage(appName, QStringLiteral("epic"));
}
if (coverUrl.isValid()) { if (coverUrl.isValid()) {
game->setCoverUrl(coverUrl); game->setCoverUrl(coverUrl);
} }
@ -206,19 +558,27 @@ QList<Game *> HeroicImporter::importGOGGames()
return games; return games;
} }
// Check for GOG installed games // Check for GOG installed games / library cache
QString installedPath = configPath + QStringLiteral("/gog_store/installed.json"); QStringList installedPaths = {
QFile installedFile(installedPath); configPath + QStringLiteral("/gog_store/installed.json"),
configPath + QStringLiteral("/store_cache/gog_library"),
configPath + QStringLiteral("/store_cache/gog_library.json"),
configPath + QStringLiteral("/store/gog_library"),
configPath + QStringLiteral("/store/gog_library.json"),
};
if (!installedFile.open(QIODevice::ReadOnly)) { QFile installedFile;
// Try library file for (const QString &path : installedPaths) {
installedPath = configPath + QStringLiteral("/store_cache/gog_library.json"); installedFile.setFileName(path);
installedFile.setFileName(installedPath); if (installedFile.open(QIODevice::ReadOnly)) {
if (!installedFile.open(QIODevice::ReadOnly)) { break;
return games;
} }
} }
if (!installedFile.isOpen()) {
return games;
}
QJsonDocument doc = QJsonDocument::fromJson(installedFile.readAll()); QJsonDocument doc = QJsonDocument::fromJson(installedFile.readAll());
auto parseGames = [this, &games](const QJsonArray &arr) { auto parseGames = [this, &games](const QJsonArray &arr) {
@ -243,10 +603,13 @@ QList<Game *> HeroicImporter::importGOGGames()
Game *game = new Game(QStringLiteral("heroic-gog-%1").arg(appName), title); Game *game = new Game(QStringLiteral("heroic-gog-%1").arg(appName), title);
game->setPlatform(QStringLiteral("Heroic (GOG)")); game->setPlatform(QStringLiteral("Heroic (GOG)"));
game->setPlatformId(appName); game->setPlatformId(appName);
game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName)); game->setLaunchCommand(launchCommandForApp(appName));
game->setInstalled(isInstalled); game->setInstalled(isInstalled);
QUrl coverUrl = findCoverImage(appName, QStringLiteral("gog")); QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj));
if (!coverUrl.isValid()) {
coverUrl = findCoverImage(appName, QStringLiteral("gog"));
}
if (coverUrl.isValid()) { if (coverUrl.isValid()) {
game->setCoverUrl(coverUrl); game->setCoverUrl(coverUrl);
} }
@ -276,10 +639,22 @@ QList<Game *> HeroicImporter::importAmazonGames()
return games; return games;
} }
QString libraryPath = configPath + QStringLiteral("/store_cache/nile_library.json"); QStringList libraryPaths = {
QFile libraryFile(libraryPath); configPath + QStringLiteral("/store_cache/nile_library"),
configPath + QStringLiteral("/store_cache/nile_library.json"),
configPath + QStringLiteral("/store/nile_library"),
configPath + QStringLiteral("/store/nile_library.json"),
};
if (!libraryFile.open(QIODevice::ReadOnly)) { QFile libraryFile;
for (const QString &path : libraryPaths) {
libraryFile.setFileName(path);
if (libraryFile.open(QIODevice::ReadOnly)) {
break;
}
}
if (!libraryFile.isOpen()) {
return games; return games;
} }
@ -304,10 +679,13 @@ QList<Game *> HeroicImporter::importAmazonGames()
Game *game = new Game(QStringLiteral("heroic-amazon-%1").arg(appName), title); Game *game = new Game(QStringLiteral("heroic-amazon-%1").arg(appName), title);
game->setPlatform(QStringLiteral("Heroic (Amazon)")); game->setPlatform(QStringLiteral("Heroic (Amazon)"));
game->setPlatformId(appName); game->setPlatformId(appName);
game->setLaunchCommand(QStringLiteral("heroic --launch %1").arg(appName)); game->setLaunchCommand(launchCommandForApp(appName));
game->setInstalled(isInstalled); game->setInstalled(isInstalled);
QUrl coverUrl = findCoverImage(appName, QStringLiteral("amazon")); QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj));
if (!coverUrl.isValid()) {
coverUrl = findCoverImage(appName, QStringLiteral("amazon"));
}
if (coverUrl.isValid()) { if (coverUrl.isValid()) {
game->setCoverUrl(coverUrl); game->setCoverUrl(coverUrl);
} }

View file

@ -19,8 +19,13 @@ public:
private: private:
QString findHeroicConfigPath() const; QString findHeroicConfigPath() const;
QString findLegendaryConfigPath() const;
QList<Game *> importEpicGames(); QList<Game *> importEpicGames();
QList<Game *> importGOGGames(); QList<Game *> importGOGGames();
QList<Game *> importAmazonGames(); QList<Game *> importAmazonGames();
QList<Game *> importSideloadApps();
QUrl extractCoverUrl(const QJsonObject &gameObj) const;
QUrl preferCachedCoverUrl(const QUrl &coverUrl) const;
QString launchCommandForApp(const QString &appName) const;
QUrl findCoverImage(const QString &appName, const QString &store) const; QUrl findCoverImage(const QString &appName, const QString &store) 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,30 +50,56 @@ int main(int argc, char *argv[])
QCommandLineParser parser; QCommandLineParser parser;
aboutData.setupCommandLine(&parser); aboutData.setupCommandLine(&parser);
QCommandLineOption importAllAndExitOption(QStringLiteral("import-all-and-exit"), i18n("Import all enabled sources and exit"));
QCommandLineOption importDesktopAndExitOption(QStringLiteral("import-desktop-and-exit"), i18n("Import desktop entries and exit"));
QCommandLineOption startupAndExitOption(QStringLiteral("startup-and-exit"), i18n("Start without UI and exit after startup auto-import (if enabled)"));
parser.addOption(importAllAndExitOption);
parser.addOption(importDesktopAndExitOption);
parser.addOption(startupAndExitOption);
parser.process(app); parser.process(app);
aboutData.processCommandLine(&parser); aboutData.processCommandLine(&parser);
KDBusService service(KDBusService::Unique);
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) { if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) {
QQuickStyle::setStyle(QStringLiteral("org.kde.desktop")); QQuickStyle::setStyle(QStringLiteral("org.kde.desktop"));
} }
if (parser.isSet(importAllAndExitOption) || parser.isSet(importDesktopAndExitOption) || parser.isSet(startupAndExitOption)) {
App *alakarteApp = App::instance();
QObject::connect(alakarteApp, &App::importCompleted, &app, [&app](int count) {
QTextStream(stdout) << count << Qt::endl;
app.exit(0);
});
QObject::connect(alakarteApp, &App::importError, &app, [&app](const QString &error) {
QTextStream(stderr) << error << Qt::endl;
app.exit(1);
});
if (parser.isSet(importAllAndExitOption)) {
alakarteApp->importAllGames();
} else if (parser.isSet(importDesktopAndExitOption)) {
alakarteApp->importFromDesktop();
} else {
if (!alakarteApp->config() || !alakarteApp->config()->autoImportOnStartup()) {
QTextStream(stdout) << 0 << Qt::endl;
return 0;
}
QTimer::singleShot(60000, &app, [&app]() {
QTextStream(stderr) << QStringLiteral("Timed out waiting for auto-import") << Qt::endl;
app.exit(2);
});
}
return app.exec();
}
KDBusService service(KDBusService::Unique);
QQmlApplicationEngine engine; QQmlApplicationEngine engine;
qmlRegisterSingletonType<App>("org.kde.alakarte", 1, 0, "App", [](QQmlEngine *engine, QJSEngine *) -> QObject * {
Q_UNUSED(engine)
return App::instance();
});
qmlRegisterSingletonType<GamepadManager>("org.kde.alakarte", 1, 0, "GamepadManager", &GamepadManager::create);
qmlRegisterType<GameModel>("org.kde.alakarte", 1, 0, "GameModel");
qmlRegisterType<GameSortFilterModel>("org.kde.alakarte", 1, 0, "GameSortFilterModel");
qmlRegisterUncreatableType<Game>("org.kde.alakarte", 1, 0, "Game", QStringLiteral("Game objects are created by GameModel"));
qmlRegisterType<GameLauncher>("org.kde.alakarte", 1, 0, "GameLauncher");
qmlRegisterType<Config>("org.kde.alakarte", 1, 0, "Config");
engine.rootContext()->setContextObject(new KLocalizedContext(&engine)); engine.rootContext()->setContextObject(new KLocalizedContext(&engine));
engine.loadFromModule("org.kde.alakarte", "Main"); engine.loadFromModule("org.kde.alakarte", "Main");

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

@ -44,3 +44,31 @@ QString PlatformImporter::expandPath(const QString &path) const
return result; return result;
} }
bool PlatformImporter::hasGameCategory(const QStringList &categories)
{
static const QStringList gameCategories = {QStringLiteral("Game"),
QStringLiteral("ArcadeGame"),
QStringLiteral("ActionGame"),
QStringLiteral("AdventureGame"),
QStringLiteral("BlocksGame"),
QStringLiteral("BoardGame"),
QStringLiteral("CardGame"),
QStringLiteral("KidsGame"),
QStringLiteral("LogicGame"),
QStringLiteral("RolePlaying"),
QStringLiteral("Shooter"),
QStringLiteral("Simulation"),
QStringLiteral("SportsGame"),
QStringLiteral("StrategyGame")};
for (const QString &category : categories) {
for (const QString &cat : gameCategories) {
if (category.compare(cat, Qt::CaseInsensitive) == 0) {
return true;
}
}
}
return false;
}

View file

@ -5,6 +5,7 @@
#include <QList> #include <QList>
#include <QObject> #include <QObject>
#include <QStringList>
#include "game.h" #include "game.h"
@ -29,4 +30,5 @@ protected:
QString findExecutable(const QString &name) const; QString findExecutable(const QString &name) const;
bool directoryExists(const QString &path) const; bool directoryExists(const QString &path) const;
QString expandPath(const QString &path) const; QString expandPath(const QString &path) const;
static bool hasGameCategory(const QStringList &categories);
}; };

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

@ -15,9 +15,14 @@ FocusScope {
property bool showPlayButton: true property bool showPlayButton: true
property bool focused: activeFocus property bool focused: activeFocus
readonly property bool isTouchDevice: Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile readonly property bool isTouchDevice: {
let w = applicationWindow()
if (w && w.isTouchDevice !== undefined) return w.isTouchDevice
return Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile
}
readonly property int adaptiveFocusRingWidth: 1 readonly property int adaptiveFocusRingWidth: 1
readonly property real adaptiveHoverScale: 1.015 readonly property real adaptiveHoverScale: 1.015
readonly property real adaptiveFocusScale: 1.03
readonly property bool useAnimatedCover: App.config.animatedCovers readonly property bool useAnimatedCover: App.config.animatedCovers
&& game && game
@ -38,24 +43,50 @@ FocusScope {
color: Kirigami.Theme.backgroundColor color: Kirigami.Theme.backgroundColor
shadow { shadow {
size: (hoverHandler.hovered || gameCard.focused) ? Kirigami.Units.mediumSpacing : Kirigami.Units.smallSpacing size: gameCard.focused ? Kirigami.Units.mediumSpacing : (hoverHandler.hovered ? Kirigami.Units.smallSpacing * 1.5 : Kirigami.Units.smallSpacing)
color: (hoverHandler.hovered || gameCard.focused) ? Qt.rgba(0, 0, 0, 0.30) : Qt.rgba(0, 0, 0, 0.16) color: gameCard.focused ? Qt.rgba(0, 0, 0, 0.34) : (hoverHandler.hovered ? Qt.rgba(0, 0, 0, 0.24) : Qt.rgba(0, 0, 0, 0.16))
}
Behavior on shadow.color {
ColorAnimation { duration: Kirigami.Units.shortDuration }
} }
border.width: gameCard.focused ? gameCard.adaptiveFocusRingWidth : 0 border.width: gameCard.focused ? gameCard.adaptiveFocusRingWidth : 0
border.color: Kirigami.Theme.highlightColor border.color: Kirigami.Theme.highlightColor
Behavior on border.width { Behavior on border.width {
NumberAnimation { duration: Kirigami.Units.shortDuration } NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
} }
Behavior on shadow.size { Behavior on shadow.size {
NumberAnimation { duration: Kirigami.Units.shortDuration } NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
} }
scale: (hoverHandler.hovered || gameCard.focused) ? gameCard.adaptiveHoverScale : 1.0 y: gameCard.focused ? -Kirigami.Units.smallSpacing : (hoverHandler.hovered ? -Kirigami.Units.smallSpacing * 0.5 : 0)
Behavior on y {
NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
scale: gameCard.focused ? gameCard.adaptiveFocusScale : (hoverHandler.hovered ? gameCard.adaptiveHoverScale : 1.0)
Behavior on scale { Behavior on scale {
NumberAnimation { duration: Kirigami.Units.shortDuration } NumberAnimation { duration: Kirigami.Units.shortDuration; easing.type: Easing.OutCubic }
}
Rectangle {
anchors.fill: parent
radius: Kirigami.Units.mediumSpacing
color: "transparent"
border.width: 2
border.color: Kirigami.Theme.highlightColor
opacity: 0.0
visible: gameCard.focused
SequentialAnimation on opacity {
running: gameCard.focused
loops: Animation.Infinite
NumberAnimation { from: 0.10; to: 0.28; duration: 900; easing.type: Easing.InOutQuad }
NumberAnimation { from: 0.28; to: 0.12; duration: 900; easing.type: Easing.InOutQuad }
}
} }
// Cover image // Cover image

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,12 +12,33 @@ 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 isMobile: Kirigami.Settings.isMobile 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: {
let w = applicationWindow()
if (w && w.isMobile !== undefined) return w.isMobile
return Kirigami.Settings.isMobile
}
readonly property bool isNarrowScreen: applicationWindow() && applicationWindow().width < Kirigami.Units.gridUnit * 30 readonly property bool isNarrowScreen: applicationWindow() && applicationWindow().width < Kirigami.Units.gridUnit * 30
readonly property bool useCompactLayout: isMobile || isNarrowScreen readonly property bool useCompactLayout: isMobile || isNarrowScreen
readonly property bool isTouchDevice: Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile readonly property bool isTouchDevice: {
let w = applicationWindow()
if (w && w.isTouchDevice !== undefined) return w.isTouchDevice
return Kirigami.Settings.tabletMode || Kirigami.Settings.isMobile
}
readonly property int actionIconButtonSize: isTouchDevice ? Math.round(Kirigami.Units.gridUnit * 2.5) : Math.round(Kirigami.Units.gridUnit * 2) readonly property int actionIconButtonSize: isTouchDevice ? Math.round(Kirigami.Units.gridUnit * 2.5) : Math.round(Kirigami.Units.gridUnit * 2)
@ -37,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
@ -70,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
@ -77,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
@ -84,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
@ -91,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
@ -98,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
@ -313,95 +373,48 @@ Kirigami.OverlaySheet {
Item { Layout.fillHeight: true } Item { Layout.fillHeight: true }
RowLayout { ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
QQC2.Button { RowLayout {
id: playButton Layout.fillWidth: true
text: game && game.running ? i18n("Running...") : i18n("Play") spacing: Kirigami.Units.smallSpacing
icon.name: "media-playback-start"
enabled: game && !game.running
highlighted: true
focus: true
onClicked: detailsSheet.launchRequested() QQC2.Button {
id: playButton
text: game && game.running ? i18n("Running...") : i18n("Play")
icon.name: "media-playback-start"
enabled: game && !game.running
highlighted: true
focus: true
Keys.onReturnPressed: clicked() onClicked: detailsSheet.launchRequested()
Keys.onEnterPressed: clicked()
Keys.onReturnPressed: clicked()
Keys.onEnterPressed: clicked()
}
Item { Layout.fillWidth: true }
} }
Item { Layout.fillWidth: true } Flow {
Layout.fillWidth: true
RowLayout {
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
QQC2.Button { QQC2.Button {
icon.name: game && game.favorite ? "bookmark-remove" : "bookmark-new" icon.name: game && game.favorite ? "bookmark-remove" : "bookmark-new"
text: game && game.favorite ? i18n("Unfavorite") : i18n("Favorite") text: game && game.favorite ? i18n("Unfavorite") : i18n("Favorite")
display: QQC2.AbstractButton.IconOnly display: detailsSheet.useCompactLayout ? QQC2.AbstractButton.TextUnderIcon : QQC2.AbstractButton.TextBesideIcon
implicitWidth: detailsSheet.actionIconButtonSize enabled: !!game
implicitHeight: detailsSheet.actionIconButtonSize
icon.width: Kirigami.Units.iconSizes.smallMedium
icon.height: Kirigami.Units.iconSizes.smallMedium
hoverEnabled: true
flat: true
padding: 0
background: Rectangle {
radius: Kirigami.Units.smallSpacing
color: parent.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.08 : 0.0)
border.width: parent.activeFocus ? 1 : 0
border.color: Kirigami.Theme.highlightColor
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration }
}
Behavior on border.width {
NumberAnimation { duration: Kirigami.Units.shortDuration }
}
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: if (game) game.favorite = !game.favorite onClicked: if (game) game.favorite = !game.favorite
} }
QQC2.Button { QQC2.Button {
icon.name: game && game.hidden ? "view-visible" : "view-hidden" icon.name: game && game.hidden ? "view-visible" : "view-hidden"
text: game && game.hidden ? i18n("Unhide") : i18n("Hide") text: game && game.hidden ? i18n("Unhide") : i18n("Hide")
display: QQC2.AbstractButton.IconOnly display: detailsSheet.useCompactLayout ? QQC2.AbstractButton.TextUnderIcon : QQC2.AbstractButton.TextBesideIcon
implicitWidth: detailsSheet.actionIconButtonSize enabled: !!game
implicitHeight: detailsSheet.actionIconButtonSize
icon.width: Kirigami.Units.iconSizes.smallMedium
icon.height: Kirigami.Units.iconSizes.smallMedium
hoverEnabled: true
flat: true
padding: 0
background: Rectangle {
radius: Kirigami.Units.smallSpacing
color: parent.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.08 : 0.0)
border.width: parent.activeFocus ? 1 : 0
border.color: Kirigami.Theme.highlightColor
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration }
}
Behavior on border.width {
NumberAnimation { duration: Kirigami.Units.shortDuration }
}
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: if (game) { onClicked: if (game) {
game.hidden = !game.hidden game.hidden = !game.hidden
App.saveLibrary() App.saveLibrary()
@ -411,70 +424,42 @@ Kirigami.OverlaySheet {
QQC2.Button { QQC2.Button {
icon.name: "document-edit" icon.name: "document-edit"
text: i18n("Edit") text: i18n("Edit")
display: QQC2.AbstractButton.IconOnly display: detailsSheet.useCompactLayout ? QQC2.AbstractButton.TextUnderIcon : QQC2.AbstractButton.TextBesideIcon
implicitWidth: detailsSheet.actionIconButtonSize enabled: !!game
implicitHeight: detailsSheet.actionIconButtonSize onClicked: detailsSheet.editRequested()
icon.width: Kirigami.Units.iconSizes.smallMedium }
icon.height: Kirigami.Units.iconSizes.smallMedium
hoverEnabled: true
flat: true
padding: 0
background: Rectangle { QQC2.Button {
radius: Kirigami.Units.smallSpacing icon.name: "view-fullscreen"
color: parent.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor text: detailsSheet.effectiveCouchMode ? i18n("Couch mode: On") : i18n("Couch mode: Off")
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.08 : 0.0) display: detailsSheet.useCompactLayout ? QQC2.AbstractButton.TextUnderIcon : QQC2.AbstractButton.TextBesideIcon
border.width: parent.activeFocus ? 1 : 0 onClicked: {
border.color: Kirigami.Theme.highlightColor if (detailsSheet.effectiveCouchMode) {
if (detailsSheet.lastNonCouchUiMode === Config.Auto) {
Behavior on opacity { App.config.uiMode = Config.Desktop
NumberAnimation { duration: Kirigami.Units.shortDuration } } else {
} App.config.uiMode = detailsSheet.lastNonCouchUiMode
}
Behavior on border.width { } else {
NumberAnimation { duration: Kirigami.Units.shortDuration } detailsSheet.lastNonCouchUiMode = App.config.uiMode
App.config.uiMode = Config.Couch
} }
} }
}
QQC2.ToolTip.text: text QQC2.Button {
QQC2.ToolTip.visible: hovered icon.name: "dialog-information"
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay text: i18n("Diagnostics")
display: detailsSheet.useCompactLayout ? QQC2.AbstractButton.TextUnderIcon : QQC2.AbstractButton.TextBesideIcon
onClicked: detailsSheet.editRequested() enabled: !!game
onClicked: detailsSheet.diagnosticsRequested()
} }
QQC2.Button { QQC2.Button {
icon.name: "edit-delete" icon.name: "edit-delete"
text: i18n("Remove") text: i18n("Remove")
display: QQC2.AbstractButton.IconOnly display: detailsSheet.useCompactLayout ? QQC2.AbstractButton.TextUnderIcon : QQC2.AbstractButton.TextBesideIcon
implicitWidth: detailsSheet.actionIconButtonSize enabled: !!game
implicitHeight: detailsSheet.actionIconButtonSize
icon.width: Kirigami.Units.iconSizes.smallMedium
icon.height: Kirigami.Units.iconSizes.smallMedium
hoverEnabled: true
flat: true
padding: 0
background: Rectangle {
radius: Kirigami.Units.smallSpacing
color: parent.activeFocus ? Kirigami.Theme.highlightColor : Kirigami.Theme.alternateBackgroundColor
opacity: parent.activeFocus ? 0.18 : (parent.hovered ? 0.08 : 0.0)
border.width: parent.activeFocus ? 1 : 0
border.color: Kirigami.Theme.highlightColor
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration }
}
Behavior on border.width {
NumberAnimation { duration: Kirigami.Units.shortDuration }
}
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: detailsSheet.removeRequested() onClicked: detailsSheet.removeRequested()
} }
} }
@ -515,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)
@ -22,18 +27,42 @@ Kirigami.Dialog {
standardButtons: Kirigami.Dialog.NoButton standardButtons: Kirigami.Dialog.NoButton
width: Math.min(parent.width - Kirigami.Units.gridUnit * 4, Kirigami.Units.gridUnit * 30) width: Math.min(parent.width - Kirigami.Units.gridUnit * 4, Kirigami.Units.gridUnit * 30)
height: Math.min(parent.height - Kirigami.Units.gridUnit * 4, implicitHeight)
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
customFooterActions: [ customFooterActions: [
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)
} }
@ -44,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)
} }
@ -63,6 +96,227 @@ 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) {
let p = item
while (p) {
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
}
return false
}
function focusNextInChain(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 (dialog.isDescendant(next, dialog)) {
next.forceActiveFocus()
return
}
}
}
function activateFocused() {
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
let item = w.activeFocusItem
if (typeof item.toggle === "function") {
item.toggle()
return
}
if (item.checkable !== undefined && item.checkable && item.checked !== undefined) {
item.checked = !item.checked
return
}
if (typeof item.clicked === "function") {
item.clicked()
return
}
}
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() {
selectedCoverPath = ""
if (isEditing && game) {
nameField.text = game.name || ""
developerField.text = game.developer || ""
executableField.text = game.launchCommand || ""
workingDirField.text = game.workingDirectory || ""
dialog.editProfileId = (App.config.uiMode === Config.Couch
|| (App.config.uiMode === Config.Auto
&& GamepadManager.connected
&& (InputManager.activeInput === InputManager.Gamepad || !InputManager.hasSeenKeyboardMouse)))
? "couch"
: "default"
profileCombo.currentIndex = dialog.profileIndexFromId(dialog.editProfileId)
dialog.loadProfileFields()
} else {
nameField.text = ""
developerField.text = ""
executableField.text = ""
workingDirField.text = ""
dialog.editProfileId = "default"
profileCombo.currentIndex = 0
dialog.loadProfileFields()
}
}
Connections {
target: GamepadManager
function onNavigateUp() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!dialog.opened) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!dialog.isDescendant(w.activeFocusItem, dialog)) return
dialog.focusNextInChain(false)
}
function onNavigateDown() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!dialog.opened) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!dialog.isDescendant(w.activeFocusItem, dialog)) return
dialog.focusNextInChain(true)
}
function onNavigateLeft() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!dialog.opened) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!dialog.isDescendant(w.activeFocusItem, dialog)) return
dialog.focusNextInChain(false)
}
function onNavigateRight() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!dialog.opened) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!dialog.isDescendant(w.activeFocusItem, dialog)) return
dialog.focusNextInChain(true)
}
function onSelectPressed() {
let app = applicationWindow()
if (app && app.currentConfirmDialog && app.currentConfirmDialog()) return
if (!dialog.opened) return
let w = applicationWindow()
if (!w || !w.activeFocusItem) return
if (!dialog.isDescendant(w.activeFocusItem, dialog)) return
dialog.activateFocused()
}
}
ColumnLayout { ColumnLayout {
spacing: 0 spacing: 0
@ -72,7 +326,7 @@ Kirigami.Dialog {
FormCard.FormTextFieldDelegate { FormCard.FormTextFieldDelegate {
id: nameField id: nameField
label: i18n("Name") label: i18n("Name")
text: isEditing && game ? game.name : "" text: ""
placeholderText: i18n("Game title") placeholderText: i18n("Game title")
onAccepted: developerField.forceActiveFocus() onAccepted: developerField.forceActiveFocus()
} }
@ -82,7 +336,7 @@ Kirigami.Dialog {
FormCard.FormTextFieldDelegate { FormCard.FormTextFieldDelegate {
id: developerField id: developerField
label: i18n("Developer") label: i18n("Developer")
text: isEditing && game ? (game.developer || "") : "" text: ""
placeholderText: i18n("Optional") placeholderText: i18n("Optional")
onAccepted: executableField.forceActiveFocus() onAccepted: executableField.forceActiveFocus()
} }
@ -92,7 +346,7 @@ Kirigami.Dialog {
FormCard.FormTextFieldDelegate { FormCard.FormTextFieldDelegate {
id: executableField id: executableField
label: i18n("Executable") label: i18n("Executable")
text: isEditing && game ? game.launchCommand : "" text: ""
placeholderText: i18n("/path/to/game or command") placeholderText: i18n("/path/to/game or command")
onAccepted: if (nameField.text.trim() !== "" && text.trim() !== "") { onAccepted: if (nameField.text.trim() !== "" && text.trim() !== "") {
dialog.customFooterActions[0].trigger() dialog.customFooterActions[0].trigger()
@ -112,9 +366,200 @@ Kirigami.Dialog {
FormCard.FormTextFieldDelegate { FormCard.FormTextFieldDelegate {
id: workingDirField id: workingDirField
label: i18n("Working Directory") label: i18n("Working Directory")
text: isEditing && game ? (game.workingDirectory || "") : "" 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 {
@ -239,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
@ -252,7 +714,9 @@ Kirigami.Dialog {
} }
onOpened: { onOpened: {
loadFields()
nameField.forceActiveFocus() nameField.forceActiveFocus()
selectedCoverPath = ""
} }
onGameChanged: loadFields()
} }

View file

@ -4,6 +4,7 @@
import QtQuick import QtQuick
import QtQuick.Controls as QQC2 import QtQuick.Controls as QQC2
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Effects
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import org.kde.alakarte import org.kde.alakarte
import "components" import "components"
@ -13,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
@ -21,20 +22,70 @@ FocusScope {
signal gameSelected(var game) signal gameSelected(var game)
signal gameLaunched(var game) signal gameLaunched(var game)
readonly property int gameCount: proxyModel.count
property url focusedCoverUrl: ""
readonly property bool anyMenuOpen: searchHeader.anyMenuOpen
function closeCurrentMenu() {
searchHeader.closeCurrentMenu()
}
function focusSearch() { function focusSearch() {
searchField.forceActiveFocus() searchField.forceActiveFocus()
} }
function clearSearch() {
searchField.text = ""
proxyModel.filterText = ""
}
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 {
if (libraryRoot.focusedIndex >= 0 && libraryRoot.focusedIndex < proxyModel.count) {
gameGrid.currentIndex = libraryRoot.focusedIndex
}
gameGrid.forceActiveFocus() gameGrid.forceActiveFocus()
} }
} }
function launchFocusedGame() {
if (!gameGrid || !proxyModel) return
if (gameGrid.currentIndex < 0 && proxyModel.count > 0) {
gameGrid.currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(gameGrid.currentIndex)
if (game) {
libraryRoot.gameLaunched(game)
}
}
function openDetailsForFocusedGame() {
if (!gameGrid || !proxyModel) return
if (gameGrid.currentIndex < 0 && proxyModel.count > 0) {
gameGrid.currentIndex = 0
libraryRoot.focusedIndex = 0
}
let game = proxyModel.get(gameGrid.currentIndex)
if (game) {
libraryRoot.gameSelected(game)
}
}
onSearchActiveChanged: { onSearchActiveChanged: {
if (!libraryRoot.searchActive) { if (!libraryRoot.searchActive) {
libraryRoot.clearSearch()
Qt.callLater(function() { Qt.callLater(function() {
gameGrid.forceActiveFocus() gameGrid.forceActiveFocus()
}) })
@ -45,6 +96,62 @@ FocusScope {
anchors.fill: parent anchors.fill: parent
anchors.margins: 0 anchors.margins: 0
Item {
anchors.fill: parent
visible: libraryRoot.gameCount > 0
Image {
id: backgroundCoverA
anchors.fill: parent
source: ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
visible: source.toString().length > 0
smooth: true
mipmap: App.config.highQualityImages
opacity: 0.0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutCubic }
}
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 0.9
blurMax: 64
}
}
Image {
id: backgroundCoverB
anchors.fill: parent
source: ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
visible: source.toString().length > 0
smooth: true
mipmap: App.config.highQualityImages
opacity: 0.0
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutCubic }
}
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 0.9
blurMax: 64
}
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.55)
}
}
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
@ -83,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()
}
}
} }
} }
@ -93,6 +210,27 @@ FocusScope {
cardSize: libraryRoot.adaptiveCardSize cardSize: libraryRoot.adaptiveCardSize
onCurrentIndexChanged: {
if (gameGrid.activeFocus) {
libraryRoot.focusedIndex = currentIndex
}
let game = proxyModel.get(currentIndex)
let url = (game && game.coverUrl) ? game.coverUrl : ""
if (url === libraryRoot.focusedCoverUrl) return
if (backgroundCoverA.opacity > 0.1) {
backgroundCoverB.source = url
backgroundCoverB.opacity = 0.0
backgroundCoverA.opacity = 0.0
Qt.callLater(function() { backgroundCoverB.opacity = 0.22 })
} else {
backgroundCoverA.source = url
backgroundCoverA.opacity = 0.0
backgroundCoverB.opacity = 0.0
Qt.callLater(function() { backgroundCoverA.opacity = 0.22 })
}
libraryRoot.focusedCoverUrl = url
}
model: GameSortFilterModel { model: GameSortFilterModel {
id: proxyModel id: proxyModel
sourceModel: App.gameModel sourceModel: App.gameModel
@ -111,9 +249,17 @@ FocusScope {
height: gameGrid.cellHeight height: gameGrid.cellHeight
function clicked() { function clicked() {
gameGrid.currentIndex = index
libraryRoot.focusedIndex = index
card.clicked() card.clicked()
} }
function play() {
gameGrid.currentIndex = index
libraryRoot.focusedIndex = index
card.playClicked()
}
GameCard { GameCard {
id: card id: card
width: gameGrid.cardSize width: gameGrid.cardSize
@ -135,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)
@ -153,14 +307,13 @@ FocusScope {
anchors.centerIn: parent anchors.centerIn: parent
visible: proxyModel.count === 0 && !App.importing visible: proxyModel.count === 0 && !App.importing
icon: proxyModel.filterText.length > 0 ? "edit-find" : "applications-games" icon: proxyModel.filterText.length > 0 ? "edit-find" : (libraryRoot.filterSource === "favorites" ? "bookmark-new" : (libraryRoot.filterSource === "hidden" ? "view-hidden" : "applications-games"))
title: proxyModel.filterText.length > 0 ? title: proxyModel.filterText.length > 0 ?
i18n("No games found") : i18n("Your library is empty") i18n("No games found") : (libraryRoot.filterSource === "favorites" ? i18n("No favorites yet") : (libraryRoot.filterSource === "hidden" ? i18n("No hidden games") : i18n("Your library is empty")))
description: proxyModel.filterText.length > 0 ? description: proxyModel.filterText.length > 0 ?
i18n("Try adjusting your search") : i18n("Try adjusting your search") : (libraryRoot.filterSource === "favorites" ? i18n("Mark games as favorites to see them here") : (libraryRoot.filterSource === "hidden" ? i18n("Hidden games will appear here") : i18n("Import games to get started")))
i18n("Import games to get started")
actionText: proxyModel.filterText.length > 0 ? "" : i18n("Import Games") actionText: (proxyModel.filterText.length > 0 || libraryRoot.filterSource === "favorites" || libraryRoot.filterSource === "hidden") ? "" : i18n("Import Games")
onActionTriggered: App.importAllGames() onActionTriggered: App.importAllGames()
} }
@ -170,47 +323,6 @@ FocusScope {
visible: App.importing visible: App.importing
} }
} }
QQC2.ToolBar {
Layout.fillWidth: true
visible: proxyModel.count > 0
leftPadding: 0
rightPadding: 0
topPadding: Kirigami.Units.smallSpacing
bottomPadding: Kirigami.Units.smallSpacing
contentItem: RowLayout {
spacing: Kirigami.Units.mediumSpacing
QQC2.Label {
text: i18np("%1 game", "%1 games", proxyModel.count)
color: Kirigami.Theme.disabledTextColor
font.pointSize: Kirigami.Theme.smallFont.pointSize
Layout.alignment: Qt.AlignVCenter
}
Item { Layout.fillWidth: true }
QQC2.Slider {
id: sizeSlider
from: 120
to: 280
stepSize: 20
value: App.config.gridSize
Layout.preferredWidth: Kirigami.Units.gridUnit * 8
Layout.alignment: Qt.AlignVCenter
onMoved: App.config.gridSize = value
QQC2.ToolTip {
parent: sizeSlider.handle
visible: sizeSlider.pressed
text: Math.round(sizeSlider.value) + " px"
}
}
}
}
} }
} }
} }

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")
@ -31,13 +130,98 @@ ColumnLayout {
checked: App.config.showPlatformBadges checked: App.config.showPlatformBadges
onToggled: App.config.showPlatformBadges = checked onToggled: App.config.showPlatformBadges = checked
} }
FormCard.FormDelegateSeparator {}
FormCard.FormButtonDelegate {
id: uiModeDelegate
text: i18n("UI mode")
description: {
if (App.config.uiMode === Config.Desktop) return i18n("Desktop")
if (App.config.uiMode === Config.Couch) return i18n("Couch")
return i18n("Automatic")
}
icon.name: "view-fullscreen"
onClicked: uiModeMenu.open()
QQC2.Menu {
id: uiModeMenu
focus: true
onOpened: Qt.callLater(function() { uiModeAuto.forceActiveFocus() })
QQC2.MenuItem {
id: uiModeAuto
text: i18n("Automatic")
checkable: true
checked: App.config.uiMode === Config.Auto
onTriggered: App.config.uiMode = Config.Auto
}
QQC2.MenuItem {
id: uiModeDesktop
text: i18n("Desktop")
checkable: true
checked: App.config.uiMode === Config.Desktop
onTriggered: App.config.uiMode = Config.Desktop
}
QQC2.MenuItem {
id: uiModeCouch
text: i18n("Couch")
checkable: true
checked: App.config.uiMode === Config.Couch
onTriggered: App.config.uiMode = Config.Couch
}
}
}
} }
FormCard.FormHeader { Connections {
Layout.fillWidth: true target: GamepadManager
title: i18n("Import Sources") 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
title: i18n("Import Sources")
}
FormCard.FormCard { FormCard.FormCard {
Layout.fillWidth: true Layout.fillWidth: true
FormCard.FormCheckDelegate { FormCard.FormCheckDelegate {
@ -48,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 {}
@ -61,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 {}
@ -75,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 {}
@ -89,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 {}
@ -103,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 {}
@ -117,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 {}
@ -132,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 {}
@ -146,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 {}
@ -161,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 {}
@ -176,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")
} }
@ -200,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")
} }
@ -224,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")
} }
@ -266,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 {}
@ -283,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")
} }
@ -293,7 +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 enabled: !App.importing && App.gameModel.count >= 0 && (App.config.importSteam
|| App.config.importLutris
|| App.config.importHeroic
|| App.config.importDesktop
|| App.config.importBottles
|| App.config.importFlatpak
|| App.config.importItch
|| App.config.importLegendary
|| App.config.importRetroArch)
onClicked: App.importAllGames() onClicked: App.importAllGames()
} }
@ -303,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 {}
@ -325,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()
} }
@ -338,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

@ -14,6 +14,9 @@ ColumnLayout {
property string currentSource: "all" property string currentSource: "all"
property string currentSourceName: i18n("All Games") property string currentSourceName: i18n("All Games")
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
@ -33,6 +36,39 @@ ColumnLayout {
} }
} }
function applySourceAtIndex(i) {
if (i < 0 || i >= sourceModel.count) return
let item = sourceModel.get(i)
if (!item) return
sourceList.currentIndex = i
sidebarRoot.currentSource = item.sourceId
sidebarRoot.currentSourceName = item.name
sidebarRoot.sourceSelected(item.sourceId)
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) {
if (sourceModel.count <= 0) return
let i = sourceList.currentIndex
if (i < 0) i = 0
i = (i + delta) % sourceModel.count
if (i < 0) i += sourceModel.count
sidebarRoot.suppressAutoClose = true
applySourceAtIndex(i)
Qt.callLater(function() { sidebarRoot.suppressAutoClose = false })
}
function iconInfoForPlatform(platformName) { function iconInfoForPlatform(platformName) {
let p = (platformName || "").toLowerCase() let p = (platformName || "").toLowerCase()
@ -74,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 {
@ -93,6 +151,21 @@ ColumnLayout {
border.width: (sourceList.activeFocus && ListView.isCurrentItem) ? sidebarRoot.adaptiveFocusRingWidth : 0 border.width: (sourceList.activeFocus && ListView.isCurrentItem) ? sidebarRoot.adaptiveFocusRingWidth : 0
border.color: Kirigami.Theme.highlightColor border.color: Kirigami.Theme.highlightColor
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: Kirigami.Units.smallSpacing
width: Kirigami.Units.smallSpacing
radius: width / 2
color: Kirigami.Theme.highlightColor
opacity: sourceDelegate.highlighted ? 0.9 : (sourceDelegate.hovered ? 0.45 : 0.0)
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration }
}
}
Behavior on opacity { Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.shortDuration } NumberAnimation { duration: Kirigami.Units.shortDuration }
} }
@ -256,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) {
@ -266,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

@ -0,0 +1,380 @@
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.alakarte
RowLayout {
id: root
spacing: uiMode === Config.Couch ? Kirigami.Units.largeSpacing * 1.25 : Kirigami.Units.largeSpacing
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
property string context: "library"
function iconBasePath() {
if (!useGamepadHints) return ""
switch (style) {
case GamepadManager.PlayStationController:
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/playstation/"
case GamepadManager.XboxController:
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/xbox/"
case GamepadManager.NintendoController:
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/nintendo/"
default:
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/generic/"
}
}
function iconForButton(buttonLabel) {
if (!useGamepadHints) return ""
const base = iconBasePath()
if (style === GamepadManager.PlayStationController) {
if (buttonLabel === "Cross") return base + "cross.svg"
if (buttonLabel === "Circle") return base + "circle.svg"
if (buttonLabel === "Square") return base + "square.svg"
if (buttonLabel === "Triangle") return base + "triangle.svg"
}
if (style === GamepadManager.XboxController || style === GamepadManager.NintendoController) {
return base + buttonLabel.toLowerCase() + ".svg"
}
if (buttonLabel === "A") return base + "south.svg"
if (buttonLabel === "B") return base + "east.svg"
if (buttonLabel === "X") return base + "west.svg"
if (buttonLabel === "Y") return base + "north.svg"
return base + "south.svg"
}
function iconForAux(action) {
if (!useGamepadHints) return ""
if (action === "dpad") {
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/generic/dpad.svg"
}
if (action === "lb") {
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/generic/lb.svg"
}
if (action === "rb") {
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/generic/rb.svg"
}
if (action === "menu") {
if (style === GamepadManager.PlayStationController) {
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/playstation/options.svg"
}
if (style === GamepadManager.NintendoController) {
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/nintendo/plus.svg"
}
if (style === GamepadManager.XboxController) {
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/xbox/menu.svg"
}
return "qrc:/qt/qml/org/kde/alakarte/qml/icons/gamepad/generic/menu.svg"
}
return ""
}
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") {
switch (action) {
case "navigate": return i18n("Arrows")
case "confirm": return "Space"
case "back": return ""
case "details": return "Enter"
case "search": return "Ctrl+F"
case "lb": return "Ctrl+PgUp"
case "rb": return "Ctrl+PgDown"
case "menu": return "Ctrl+,"
default: return ""
}
}
if (root.context === "edit") {
switch (action) {
case "navigate": return "Tab"
case "confirm": return "Enter"
case "back": return "Esc"
default: return ""
}
}
if (root.context === "details") {
switch (action) {
case "navigate": return "Tab"
case "confirm": return "Enter"
case "back": return "Esc"
case "details": return "F"
case "search": return "E"
case "menu": return "Ctrl+,"
default: return ""
}
}
if (root.context === "sidebar") {
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 "back": return "Esc"
case "menu": return "Ctrl+,"
default: return ""
}
}
if (root.context === "settings" || root.context === "import" || root.context === "sidebar" || root.context === "about" || root.context === "diagnostics") {
switch (action) {
case "navigate": return "Tab"
case "confirm": return "Enter"
case "back": return "Esc"
default: return ""
}
}
return ""
}
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") {
switch (action) {
case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Play")
case "back": return ""
case "details": return i18n("Details")
case "search": return i18n("Search")
case "lb": return i18n("Prev Source")
case "rb": return i18n("Next Source")
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 ""
}
}
if (root.context === "edit") {
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 ""
}
}
if (root.context === "details") {
switch (action) {
case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Select")
case "back": return i18n("Back")
case "details": return i18n("Favorite")
case "search": return i18n("Edit")
case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Menu") : i18n("Settings")
default: return ""
}
}
if (root.context === "sidebar") {
switch (action) {
case "navigate": return (useGamepadHints || keyboardLabel("navigate") !== "") ? i18n("Navigate") : ""
case "confirm": return i18n("Select")
case "back": return i18n("Back")
case "lb": return i18n("Prev Source")
case "rb": return i18n("Next Source")
case "menu": return (useGamepadHints && uiMode === Config.Couch) ? i18n("Close") : i18n("Settings")
default: return ""
}
}
if (root.context === "settings" || root.context === "import" || root.context === "sidebar") {
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 ""
}
}
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 ""
}
}
return ""
}
component HintItem: RowLayout {
required property string action
property string label: ""
property string iconSource: ""
property string keyLabel: ""
visible: {
if (root.useGamepadHints) return iconSource != "" && label.length > 0
return keyLabel.length > 0 && label.length > 0
}
spacing: Kirigami.Units.smallSpacing
Rectangle {
id: buttonFrame
radius: Kirigami.Units.smallSpacing
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.08)
border.width: 1
border.color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.2)
Layout.preferredHeight: uiMode === Config.Couch ? Kirigami.Units.gridUnit * 1.8 : Kirigami.Units.gridUnit * 1.4
Layout.preferredWidth: useGamepadHints
? Layout.preferredHeight
: Math.max(keyText.implicitWidth + Kirigami.Units.mediumSpacing * 2, Layout.preferredHeight)
Image {
id: buttonIcon
anchors.centerIn: parent
width: parent.height * 0.7
height: width
source: parent.parent.iconSource
visible: useGamepadHints && source != ""
sourceSize: Qt.size(width * 2, height * 2)
}
QQC2.Label {
id: keyText
anchors.centerIn: parent
text: parent.parent.keyLabel
font.bold: true
font.pointSize: uiMode === Config.Couch ? Kirigami.Theme.defaultFont.pointSize : Kirigami.Theme.smallFont.pointSize
color: Kirigami.Theme.textColor
visible: !useGamepadHints
}
}
QQC2.Label {
text: parent.label
color: Kirigami.Theme.textColor
font.pointSize: uiMode === Config.Couch ? Kirigami.Theme.defaultFont.pointSize : Kirigami.Theme.smallFont.pointSize
Layout.alignment: Qt.AlignVCenter
}
}
HintItem {
action: "navigate"
label: actionLabel("navigate")
iconSource: iconForAux("dpad")
keyLabel: keyboardLabel("navigate")
}
HintItem {
action: "confirm"
label: actionLabel("confirm")
iconSource: iconForButton(GamepadManager.confirmButtonLabel)
keyLabel: keyboardLabel("confirm")
}
HintItem {
action: "back"
label: actionLabel("back")
iconSource: iconForButton(GamepadManager.backButtonLabel)
keyLabel: keyboardLabel("back")
}
HintItem {
action: "details"
label: actionLabel("details")
iconSource: iconForButton(GamepadManager.detailsButtonLabel)
keyLabel: keyboardLabel("details")
}
HintItem {
action: "search"
label: actionLabel("search")
iconSource: iconForButton(GamepadManager.searchButtonLabel)
keyLabel: keyboardLabel("search")
}
HintItem {
action: "lb"
label: actionLabel("lb")
iconSource: (root.context === "library" || root.context === "sidebar") ? iconForAux("lb") : ""
keyLabel: keyboardLabel("lb")
}
HintItem {
action: "rb"
label: actionLabel("rb")
iconSource: (root.context === "library" || root.context === "sidebar") ? iconForAux("rb") : ""
keyLabel: keyboardLabel("rb")
}
HintItem {
action: "menu"
label: actionLabel("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")
}
}

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,13 +74,41 @@ 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()
gridView.currentItem.clicked() 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) {
gridView.currentItem.play()
} else {
gridView.currentItem.clicked()
}
} }
} }
} }

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

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="8" width="20" height="10" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="6" y="11" width="5" height="4" fill="currentColor" opacity="0.6"/>
<rect x="13" y="11" width="5" height="4" fill="currentColor" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 2h6v7h7v6h-7v7H9v-7H2V9h7z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<rect x="9" y="15" width="6" height="7" fill="currentColor" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 2h6v7h7v6h-7v7H9v-7H2V9h7z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<rect x="2" y="9" width="7" height="6" fill="currentColor" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 2h6v7h7v6h-7v7H9v-7H2V9h7z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<rect x="15" y="9" width="7" height="6" fill="currentColor" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 2h6v7h7v6h-7v7H9v-7H2V9h7z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<rect x="9" y="2" width="6" height="7" fill="currentColor" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 2h6v7h7v6h-7v7H9v-7H2V9h7z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M7 12h7M11 9l3 3-3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="8" width="20" height="10" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="12" y="16" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="8" fill="currentColor">LB</text>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/>
<circle cx="12" cy="12" r="5" fill="currentColor" opacity="0.3"/>
<circle cx="12" cy="12" r="3" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 298 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 18V8c0-2.2 1.8-4 4-4h8c2.2 0 4 1.8 4 4v10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<text x="12" y="16" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="8" fill="currentColor">LT</text>
</svg>

After

Width:  |  Height:  |  Size: 354 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="8" width="20" height="10" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="7" y1="11" x2="17" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="7" y1="15" x2="17" y2="15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 403 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 17v-7M15 13l-3-3-3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 313 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="8" width="20" height="10" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="12" y="16" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="8" fill="currentColor">RB</text>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/>
<circle cx="12" cy="12" r="5" fill="currentColor" opacity="0.3"/>
<circle cx="12" cy="12" r="3" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 298 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 18V8c0-2.2 1.8-4 4-4h8c2.2 0 4 1.8 4 4v10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<text x="12" y="16" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="8" fill="currentColor">RT</text>
</svg>

After

Width:  |  Height:  |  Size: 354 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 7v7M9 11l3 3 3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M17 12h-7M13 15l-3-3 3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 313 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="12" y="16.5" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="12" fill="currentColor">A</text>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="12" y="16.5" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="12" fill="currentColor">B</text>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 7l5 4v6h-3v-4h-4v4H7v-6z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 296 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="7" y1="12" x2="17" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 279 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="12" y1="7" x2="12" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="7" y1="12" x2="17" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 382 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="12" y="16.5" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="12" fill="currentColor">X</text>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="12" y="16.5" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="12" fill="currentColor">Y</text>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 261 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 8l8 8M16 8l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 271 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="8" width="20" height="10" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="7" y1="11" x2="17" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="7" y1="15" x2="17" y2="15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 403 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="8" width="20" height="10" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 13l4-3 4 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<line x1="12" y1="10" x2="12" y2="16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="7" y="7" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" rx="1"/>
</svg>

After

Width:  |  Height:  |  Size: 279 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 6l6.5 11h-13z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="12" y="16.5" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="12" fill="currentColor">A</text>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="12" y="16.5" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="12" fill="currentColor">B</text>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="8" width="20" height="10" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="7" y1="11" x2="17" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="7" y1="15" x2="17" y2="15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 403 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="8" width="20" height="10" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<rect x="6" y="11" width="5" height="4" fill="currentColor" opacity="0.6"/>
<rect x="13" y="11" width="5" height="4" fill="currentColor" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="12" y="16.5" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="12" fill="currentColor">X</text>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5"/>
<text x="12" y="16.5" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="12" fill="currentColor">Y</text>
</svg>

After

Width:  |  Height:  |  Size: 309 B

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

View file

@ -8,6 +8,7 @@
#include <QFileInfo> #include <QFileInfo>
#include <QRegularExpression> #include <QRegularExpression>
#include <QStandardPaths> #include <QStandardPaths>
#include <QStringList>
#include <QTextStream> #include <QTextStream>
SteamImporter::SteamImporter(QObject *parent) SteamImporter::SteamImporter(QObject *parent)
@ -122,10 +123,54 @@ Game *SteamImporter::parseAppManifest(const QString &path)
return nullptr; return nullptr;
} }
auto isNonGameEntry = [](const QString &title, const QString &dir) {
auto normalize = [](QString s) {
s = s.toLower();
s.remove(QLatin1Char(' '));
s.remove(QLatin1Char('_'));
s.remove(QLatin1Char('-'));
return s;
};
const QString t = normalize(title);
const QString d = normalize(dir);
static const QStringList patterns = {
QStringLiteral("steamlinuxruntime"),
QStringLiteral("steamworkscommonredistributables"),
QStringLiteral("steamworkssdkredist"),
QStringLiteral("shaderprecaching"),
QStringLiteral("proton"),
QStringLiteral("steamvr"),
};
for (const QString &p : patterns) {
if (t.contains(p) || d.contains(p)) {
return true;
}
}
return false;
};
if (isNonGameEntry(name, installDir)) {
return nullptr;
}
// Skip tools and other non-game content // Skip tools and other non-game content
QString stateFlags = getValue(QStringLiteral("StateFlags")); QString stateFlags = getValue(QStringLiteral("StateFlags"));
if (stateFlags == QLatin1String("2")) { if (!stateFlags.isEmpty()) {
// Only partially installed bool ok = false;
const int flags = stateFlags.toInt(&ok);
if (ok) {
constexpr int InstalledFlag = 4;
if ((flags & InstalledFlag) == 0) {
return nullptr;
}
}
}
QFileInfo manifestInfo(path);
const QString gameDir = manifestInfo.absolutePath() + QStringLiteral("/common/") + installDir;
if (!QDir(gameDir).exists()) {
return nullptr;
} }
Game *game = new Game(QStringLiteral("steam-%1").arg(appId), name); Game *game = new Game(QStringLiteral("steam-%1").arg(appId), name);
@ -140,8 +185,6 @@ Game *SteamImporter::parseAppManifest(const QString &path)
} }
// Set installation directory // Set installation directory
QFileInfo manifestInfo(path);
QString gameDir = manifestInfo.absolutePath() + QStringLiteral("/common/") + installDir;
game->setWorkingDirectory(gameDir); game->setWorkingDirectory(gameDir);
game->setInstalled(true); game->setInstalled(true);